diff --git a/.gitignore b/.gitignore index ad243a8..f3111c9 100644 --- a/.gitignore +++ b/.gitignore @@ -28,12 +28,10 @@ nulla-*.tar # Ignore assets that are produced by build tools. /priv/static/assets/ -# Ignore upload dir -/priv/static/system/ - # Ignore digested assets cache. /priv/static/cache_manifest.json # In case you use Node.js/npm, you want to ignore these. npm-debug.log /assets/node_modules/ + diff --git a/LICENSE b/LICENSE deleted file mode 100644 index 42fa4fb..0000000 --- a/LICENSE +++ /dev/null @@ -1,7 +0,0 @@ -Copyright (c) 2025 Nulla - -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/README.md b/README.md index 8d4ba93..b9d465c 100644 --- a/README.md +++ b/README.md @@ -1,315 +1,18 @@ # Nulla -An agnostic social network following the KISS and UNIX philosophy, the main principles of which are minimalism and rationalism. It implements the ActivityPub protocol and promises maximum compatibility with all existing implementations. +To start your Phoenix server: -## TODO + * Run `mix setup` to install and setup dependencies + * Start Phoenix endpoint with `mix phx.server` or inside IEx with `iex -S mix phx.server` -### API +Now you can visit [`localhost:4000`](http://localhost:4000) from your browser. -#### accounts +Ready to run in production? Please [check our deployment guides](https://hexdocs.pm/phoenix/deployment.html). -- [ ] POST [/api/v1/accounts](https://docs.joinmastodon.org/methods/accounts/#create) -- [ ] GET [/api/v1/accounts/verify_credentials](https://docs.joinmastodon.org/methods/accounts/#verify_credentials) -- [ ] PATCH [/api/v1/accounts/update_credentials](https://docs.joinmastodon.org/methods/accounts/#update_credentials) -- [ ] GET [/api/v1/accounts/:id](https://docs.joinmastodon.org/methods/accounts/#get) -- [ ] GET [/api/v1/accounts](https://docs.joinmastodon.org/methods/accounts/#index) -- [ ] GET [/api/v1/accounts/:id/notes](https://docs.joinmastodon.org/methods/accounts/#statuses) -- [ ] GET [/api/v1/accounts/:id/followers](https://docs.joinmastodon.org/methods/accounts/#followers) -- [ ] GET [/api/v1/accounts/:id/following](https://docs.joinmastodon.org/methods/accounts/#following) -- [ ] GET [/api/v1/accounts/:id/featured_tags](https://docs.joinmastodon.org/methods/accounts/#featured_tags) -- [ ] GET [/api/v1/accounts/:id/lists](https://docs.joinmastodon.org/methods/accounts/#lists) -- [ ] POST [/api/v1/accounts/:id/follow](https://docs.joinmastodon.org/methods/accounts/#follow) -- [ ] POST [/api/v1/accounts/:id/unfollow](https://docs.joinmastodon.org/methods/accounts/#unfollow) -- [ ] POST [/api/v1/accounts/:id/remove_from_followers](https://docs.joinmastodon.org/methods/accounts/#remove_from_followers) -- [ ] POST [/api/v1/accounts/:id/block](https://docs.joinmastodon.org/methods/accounts/#block) -- [ ] POST [/api/v1/accounts/:id/unblock](https://docs.joinmastodon.org/methods/accounts/#unblock) -- [ ] POST [/api/v1/accounts/:id/mute](https://docs.joinmastodon.org/methods/accounts/#mute) -- [ ] POST [/api/v1/accounts/:id/unmute](https://docs.joinmastodon.org/methods/accounts/#unmute) -- [ ] GET [/api/v1/accounts/:id/endorsements](https://docs.joinmastodon.org/methods/accounts/#endorsements) -- [ ] POST [/api/v1/accounts/:id/endorse](https://docs.joinmastodon.org/methods/accounts/#endorsements) -- [ ] POST [/api/v1/accounts/:id/unendorse](https://docs.joinmastodon.org/methods/accounts/#unendorse) -- [ ] POST [/api/v1/accounts/:id/note](https://docs.joinmastodon.org/methods/accounts/#note) -- [ ] GET [/api/v1/accounts/relationships](https://docs.joinmastodon.org/methods/accounts/#relationships) -- [ ] GET [/api/v1/accounts/familiar_followers](https://docs.joinmastodon.org/methods/accounts/#familiar_followers) -- [ ] GET [/api/v1/accounts/search](https://docs.joinmastodon.org/methods/accounts/#search) -- [ ] GET [/api/v1/accounts/lookup](https://docs.joinmastodon.org/methods/accounts/#lookup) +## Learn more -#### announcements - -- [ ] GET [/api/v1/announcements](https://docs.joinmastodon.org/methods/announcements/#get) -- [ ] POST [/api/v1/announcements/:id/dismiss](https://docs.joinmastodon.org/methods/announcements/#dismiss) -- [ ] PUT [/api/v1/announcements/:id/reactions/:name](https://docs.joinmastodon.org/methods/announcements/#put-reactions) -- [ ] DELETE [/api/v1/announcements/:id/reactions/:name](https://docs.joinmastodon.org/methods/announcements/#delete-reactions) - -#### blocks - -- [ ] GET [/api/v1/blocks](https://docs.joinmastodon.org/methods/blocks/#get) - -#### bookmarks - -- [ ] GET [/api/v1/bookmarks](https://docs.joinmastodon.org/methods/bookmarks/#get) - -#### conversations - -- [ ] GET [/api/v1/conversations](https://docs.joinmastodon.org/methods/converstions/#get) -- [ ] DELETE [/api/v1/conversations/:id](https://docs.joinmastodon.org/methods/converstions/#delete) -- [ ] POST [/api/v1/conversations/:id/read](https://docs.joinmastodon.org/methods/converstions/#read) - -#### custom_emojis - -- [ ] GET [/api/v1/custom_emojis](https://docs.joinmastodon.org/methods/custom_emojis/#get) - -#### directory - -- [ ] GET [/api/v1/directory](https://docs.joinmastodon.org/methods/directory/#get) - -#### domain_blocks - -- [ ] GET [/api/v1/domain_blocks](https://docs.joinmastodon.org/methods/domain_blocks/#get) -- [ ] POST [/api/v1/domain_blocks](https://docs.joinmastodon.org/methods/domain_blocks/#block) -- [ ] POST [/api/v1/domain_blocks](https://docs.joinmastodon.org/methods/domain_blocks/#unblock) - -#### emails - -- [ ] POST [/api/v1/emails/confirmations](https://docs.joinmastodon.org/methods/emails/#confirmation) - -#### endorsements - -- [ ] GET [/api/v1/endorsements](https://docs.joinmastodon.org/methods/endorsements/#get) - -#### favourites - -- [ ] GET [/api/v1/favourites](https://docs.joinmastodon.org/methods/favourites/#get) - -#### featured_tags - -- [ ] GET [/api/v1/featured_tags](https://docs.joinmastodon.org/methods/featured_tags/#get) -- [ ] POST [/api/v1/featured_tags](https://docs.joinmastodon.org/methods/featured_tags/#feature) -- [ ] GET [/api/v1/featured_tags/:id](https://docs.joinmastodon.org/methods/featured_tags/#unfeature) -- [ ] GET [/api/v1/featured_tags/suggestions](https://docs.joinmastodon.org/methods/featured_tags/#suggestions) - -#### filters - -- [ ] GET [/api/v2/filters](https://docs.joinmastodon.org/methods/filters/#get) -- [ ] GET [/api/v2/filters/:id](https://docs.joinmastodon.org/methods/filters/#get-one) -- [ ] POST [/api/v2/filters](https://docs.joinmastodon.org/methods/filters/#create) -- [ ] PUT [/api/v2/filters/:id](https://docs.joinmastodon.org/methods/filters/#update) -- [ ] DELETE [/api/v2/filters/:id](https://docs.joinmastodon.org/methods/filters/#delete) -- [ ] GET [/api/v2/filters/:filter_id/keywords](https://docs.joinmastodon.org/methods/filters/#keywords-get) -- [ ] POST [/api/v2/filters/:filter_id/keywords](https://docs.joinmastodon.org/methods/filters/#keywords-create) -- [ ] GET [/api/v2/filters/keywords/:id](https://docs.joinmastodon.org/methods/filters/#keywords-get-one) -- [ ] PUT [/api/v2/filters/keywords/:id](https://docs.joinmastodon.org/methods/filters/#keywords-update) -- [ ] DELETE [/api/v2/filters/keywords/:id](https://docs.joinmastodon.org/methods/filters/#keywords-delete) -- [ ] GET [/api/v2/filters/:filter_id/notes](https://docs.joinmastodon.org/methods/filters/#statuses-get) -- [ ] POST [/api/v2/filters/:filter_id/notes](https://docs.joinmastodon.org/methods/filters/#statuses-add) -- [ ] GET [/api/v2/filters/notes/:id](https://docs.joinmastodon.org/methods/filters/#statuses-get-one) -- [ ] DELETE [/api/v2/filters/notes/:id](https://docs.joinmastodon.org/methods/filters/#statuses-remove) - -#### follow_requests - -- [ ] GET [/api/v1/follow_requests](https://docs.joinmastodon.org/methods/follow_requests/#get) -- [ ] POST [/api/v1/follow_requests/:account_id/authorize](https://docs.joinmastodon.org/methods/follow_requests/#accept) -- [ ] POST [/api/v1/follow_requests/:account_id/reject](https://docs.joinmastodon.org/methods/follow_requests/#reject) - -#### followed_tags - -- [ ] GET [/api/v1/followed_tags](https://docs.joinmastodon.org/methods/followed_tags/#get) - -#### grouped_notifications - -- [ ] GET [/api/v2/notifications](https://docs.joinmastodon.org/methods/grouped_notifications/#get-grouped) -- [ ] GET [/api/v2/notifications/:group_key](https://docs.joinmastodon.org/methods/grouped_notifications/#get-notification-group) -- [ ] POST [/api/v2/notifications/:group_key/dismiss](https://docs.joinmastodon.org/methods/grouped_notifications/#dismiss-group) -- [ ] GET [/api/v2/notifications/:group_key/accounts](https://docs.joinmastodon.org/methods/grouped_notifications/#get-group-accounts) -- [ ] GET [/api/v2/notifications/unread_count](https://docs.joinmastodon.org/methods/grouped_notifications/#unread-group-count) - -#### instance - -- [ ] GET [/api/v2/instance](https://docs.joinmastodon.org/methods/instance/#v2) -- [ ] GET [/api/v2/instance/peers](https://docs.joinmastodon.org/methods/instance/#peers) -- [ ] GET [/api/v2/instance/activity](https://docs.joinmastodon.org/methods/instance/#activity) -- [ ] GET [/api/v2/instance/rules](https://docs.joinmastodon.org/methods/instance/#rules) -- [ ] GET [/api/v2/instance/domain_blocks](https://docs.joinmastodon.org/methods/instance/#domain_blocks) -- [ ] GET [/api/v2/instance/extended_description](https://docs.joinmastodon.org/methods/instance/#extended_description) -- [ ] GET [/api/v2/instance/privacy_policy](https://docs.joinmastodon.org/methods/instance/#privacy_policy) -- [ ] GET [/api/v2/instance/terms_of_service](https://docs.joinmastodon.org/methods/instance/#terms_of_service) -- [ ] GET [/api/v2/instance/terms_of_service/:date](https://docs.joinmastodon.org/methods/instance/#terms_of_service_date) -- [ ] GET [/api/v2/instance/translation_languages](https://docs.joinmastodon.org/methods/instance/#translation_languages) - -#### lists - -- [ ] GET [/api/v1/lists](https://docs.joinmastodon.org/methods/lists/#get) -- [ ] GET [/api/v1/lists/:id](https://docs.joinmastodon.org/methods/lists/#get-one) -- [ ] POST [/api/v1/lists](https://docs.joinmastodon.org/methods/lists/#create) -- [ ] PUT [/api/v1/lists/:id](https://docs.joinmastodon.org/methods/lists/#update) -- [ ] DELETE [/api/v1/lists/:id](https://docs.joinmastodon.org/methods/lists/#delete) -- [ ] GET [/api/v1/lists/:id/accounts](https://docs.joinmastodon.org/methods/lists/#accounts) -- [ ] POST [/api/v1/lists/:id/accounts](https://docs.joinmastodon.org/methods/lists/#accounts-add) -- [ ] DELETE [/api/v1/lists/:id/accounts](https://docs.joinmastodon.org/methods/lists/#accounts-remove) - -#### markers - -- [ ] GET [/api/v1/markers](https://docs.joinmastodon.org/methods/markers/#get) -- [ ] POST [/api/v1/markers](https://docs.joinmastodon.org/methods/lists/#create) - -#### media - -- [ ] POST [/api/v2/media](https://docs.joinmastodon.org/methods/media/#v2) -- [ ] GET [/api/v1/media/:id](https://docs.joinmastodon.org/methods/media/#get) -- [ ] DELETE [/api/v1/media/:id](https://docs.joinmastodon.org/methods/media/#delete) -- [ ] PUT [/api/v1/media/:id](https://docs.joinmastodon.org/methods/media/#update) - -#### mutes - -- [ ] GET [/api/v1/mutes](https://docs.joinmastodon.org/methods/mutes/#get) - -#### notifications - -- [ ] GET [/api/v1/notifications](https://docs.joinmastodon.org/methods/notifications/#get) -- [ ] GET [/api/v1/notifications/:id](https://docs.joinmastodon.org/methods/notifications/#get-one) -- [ ] POST [/api/v1/notifications/clear](https://docs.joinmastodon.org/methods/notifications/#clear) -- [ ] POST [/api/v1/notifications/:id/dismiss](https://docs.joinmastodon.org/methods/notifications/#dismiss) -- [ ] GET [/api/v1/notifications/unread_count](https://docs.joinmastodon.org/methods/notifications/#unread-count) -- [ ] GET [/api/v2/notifications/policy](https://docs.joinmastodon.org/methods/notifications/#policy) -- [ ] PATCH [/api/v2/notifications/policy](https://docs.joinmastodon.org/methods/notifications/#get-policy) -- [ ] GET [/api/v1/notifications/requests](https://docs.joinmastodon.org/methods/notifications/#get-requests) -- [ ] GET [/api/v1/notifications/requests/:id](https://docs.joinmastodon.org/methods/notifications/#get-one-request) -- [ ] POST [/api/v1/notifications/requests/:id/accept](https://docs.joinmastodon.org/methods/notifications/#accept-request) -- [ ] POST [/api/v1/notifications/requests/:id/dismiss](https://docs.joinmastodon.org/methods/notifications/#dismiss-request) -- [ ] POST [/api/v1/notifications/requests/accept](https://docs.joinmastodon.org/methods/notifications/#accept-multiple-requests) -- [ ] POST [/api/v1/notifications/requests/dismiss](https://docs.joinmastodon.org/methods/notifications/#dismiss-multiple-requests) -- [ ] GET [/api/v1/notifications/requests/merged](https://docs.joinmastodon.org/methods/notifications/#requests-merged) - -#### oauth - -- [ ] GET [/oauth/authorize](https://docs.joinmastodon.org/methods/oauth/#authorize) -- [ ] POST [/oauth/token](https://docs.joinmastodon.org/methods/oauth/#token) -- [ ] POST [/oauth/revoke](https://docs.joinmastodon.org/methods/oauth/#revoke) -- [ ] GET [/oauth/userinfo](https://docs.joinmastodon.org/methods/oauth/#userinfo) -- [ ] GET [/.well-known/oauth-authorization-server](https://docs.joinmastodon.org/methods/oauth/#authorization-server-metadata) - -#### oembed - -- [ ] GET [/api/oembed](https://docs.joinmastodon.org/methods/oembed/#get) - -#### polls - -- [ ] GET [/api/v1/polls/:id](https://docs.joinmastodon.org/methods/polls/#get) -- [ ] POST [/api/v1/polls/:id/votes](https://docs.joinmastodon.org/methods/polls/#vote) - -#### preferences - -- [ ] GET [/api/v1/preferences](https://docs.joinmastodon.org/methods/preferences/#get) - -#### profile - -- [ ] DELETE [/api/v1/profile/avatar](https://docs.joinmastodon.org/methods/profile/#delete-profile-avatar) -- [ ] DELETE [/api/v1/profile/header](https://docs.joinmastodon.org/methods/profile/#delete-profile-header) - -#### push - -- [ ] POST [/api/v1/push/subscription](https://docs.joinmastodon.org/methods/push/#create) -- [ ] GET [/api/v1/push/subscription](https://docs.joinmastodon.org/methods/push/#get) -- [ ] PUT [/api/v1/push/subscription](https://docs.joinmastodon.org/methods/push/#update) -- [ ] DELETE [/api/v1/push/subscription](https://docs.joinmastodon.org/methods/push/#delete) - -#### reports - -- [ ] POST [/api/v1/reports](https://docs.joinmastodon.org/methods/reports/#post) - -#### search - -- [ ] GET [/api/v2/search](https://docs.joinmastodon.org/methods/search/#v2) - -#### notes - -- [ ] POST [/api/v1/notes](https://docs.joinmastodon.org/methods/statuses/#create) -- [ ] GET [/api/v1/notes/:id](https://docs.joinmastodon.org/methods/statuses/#get) -- [ ] GET [/api/v1/notes](https://docs.joinmastodon.org/methods/statuses/#index) -- [ ] DELETE [/api/v1/notes/:id](https://docs.joinmastodon.org/methods/statuses/#delete) -- [ ] GET [/api/v1/notes/:id/context](https://docs.joinmastodon.org/methods/statuses/#context) -- [ ] POST [/api/v1/notes/:id/translate](https://docs.joinmastodon.org/methods/statuses/#translate) -- [ ] GET [/api/v1/notes/:id/reblogged_by](https://docs.joinmastodon.org/methods/statuses/#reblogged_by) -- [ ] GET [/api/v1/notes/:id/favourited_by](https://docs.joinmastodon.org/methods/statuses/#favourited_by) -- [ ] POST [/api/v1/notes/:id/favourite](https://docs.joinmastodon.org/methods/statuses/#favourite) -- [ ] POST [/api/v1/notes/:id/unfavourite](https://docs.joinmastodon.org/methods/statuses/#unfavourite) -- [ ] POST [/api/v1/notes/:id/reblog](https://docs.joinmastodon.org/methods/statuses/#boost) -- [ ] POST [/api/v1/notes/:id/unreblog](https://docs.joinmastodon.org/methods/statuses/#unreblog) -- [ ] POST [/api/v1/notes/:id/bookmark](https://docs.joinmastodon.org/methods/statuses/#bookmark) -- [ ] POST [/api/v1/notes/:id/unbookmark](https://docs.joinmastodon.org/methods/statuses/#unbookmark) -- [ ] POST [/api/v1/notes/:id/mute](https://docs.joinmastodon.org/methods/statuses/#mute) -- [ ] POST [/api/v1/notes/:id/unmute](https://docs.joinmastodon.org/methods/statuses/#unmute) -- [ ] POST [/api/v1/notes/:id/pin](https://docs.joinmastodon.org/methods/statuses/#pin) -- [ ] POST [/api/v1/notes/:id/unpin](https://docs.joinmastodon.org/methods/statuses/#unpin) -- [ ] PUT [/api/v1/notes/:id](https://docs.joinmastodon.org/methods/statuses/#edit) -- [ ] GET [/api/v1/notes/:id/history](https://docs.joinmastodon.org/methods/statuses/#history) -- [ ] GET [/api/v1/notes/:id/source](https://docs.joinmastodon.org/methods/statuses/#source) - -#### streaming - -- [ ] GET [/api/v1/streaming/health](https://docs.joinmastodon.org/methods/streaming/#health) -- [ ] GET [/api/v1/streaming/user](https://docs.joinmastodon.org/methods/streaming/#user) -- [ ] GET [/api/v1/streaming/user/notification](https://docs.joinmastodon.org/methods/streaming/#notification) -- [ ] GET [/api/v1/streaming/public](https://docs.joinmastodon.org/methods/streaming/#public) -- [ ] GET [/api/v1/streaming/public/local](https://docs.joinmastodon.org/methods/streaming/#public-local) -- [ ] GET [/api/v1/streaming/public/remote](https://docs.joinmastodon.org/methods/streaming/#public-remote) -- [ ] GET [/api/v1/streaming/hashtag](https://docs.joinmastodon.org/methods/streaming/#hashtag) -- [ ] GET [/api/v1/streaming/hashtag/local](https://docs.joinmastodon.org/methods/streaming/#hashtag-local) -- [ ] GET [/api/v1/streaming/list](https://docs.joinmastodon.org/methods/streaming/#list) -- [ ] GET [/api/v1/streaming/direct](https://docs.joinmastodon.org/methods/streaming/#direct) -- [ ] WSS [/api/v1/streaming](https://docs.joinmastodon.org/methods/streaming/#websocket) - -#### suggestions - -- [ ] GET [/api/v2/suggestions](https://docs.joinmastodon.org/methods/suggestions/#v2) -- [ ] DELETE [/api/v1/suggestions/:account_id](https://docs.joinmastodon.org/methods/suggestions/#remove) - -#### tags - -- [ ] GET [/api/v1/tags/:name](https://docs.joinmastodon.org/methods/tags/#get) -- [ ] POST [/api/v1/tags/:name/follow](https://docs.joinmastodon.org/methods/tags/#follow) -- [ ] POST [/api/v1/tags/:name/unfollow](https://docs.joinmastodon.org/methods/tags/#unfollow) -- [ ] POST [/api/v1/tags/:id/feature](https://docs.joinmastodon.org/methods/tags/#feature) -- [ ] POST [/api/v1/tags/:id/unfeature](https://docs.joinmastodon.org/methods/tags/#unfeature) - -#### timelines - -- [ ] GET [/api/v1/timelines/public](https://docs.joinmastodon.org/methods/timelines/#public) -- [ ] GET [/api/v1/timelines/tag/:hashtag](https://docs.joinmastodon.org/methods/timelines/#tag) -- [ ] GET [/api/v1/timelines/home](https://docs.joinmastodon.org/methods/timelines/#home) -- [ ] GET [/api/v1/timelines/link?url=:url](https://docs.joinmastodon.org/methods/timelines/#link) -- [ ] GET [/api/v1/timelines/list/:list_id](https://docs.joinmastodon.org/methods/timelines/#list) - -#### trends - -- [ ] GET [/api/v1/trends/tags](https://docs.joinmastodon.org/methods/trends/#tags) -- [ ] GET [/api/v1/trends/notes](https://docs.joinmastodon.org/methods/trends/#statuses) -- [ ] GET [/api/v1/trends/links](https://docs.joinmastodon.org/methods/trends/#links) - -### Features - -- [ ] Lightweight web interface -- [ ] API compatible with other ActivityPub instances -- [ ] JWT -- [ ] Groups -- [ ] Formatting: big/medium/small title, bold, italic, strikethrough, link, code, code block -- [ ] Links preview -- [ ] Timelines: Home / Local / Global / Custom -- [ ] Global search -- [ ] Bookmarks -- [ ] Profile links verification -- [ ] Links preview -- [ ] Import/Export posts -- [x] Sync user settings on the server -- [ ] Restricted direct messages -- [ ] Direct messages tab - -## Contributing - -### Patches via Email - -You can create a patch with this command `git format-patch -M origin/main` and send it to this [email address](mailto:miraikumiko@disroot.org). Check out this [guide](https://git-send-email.io). - -### Your repository - -You fork this repository and make your changes in the feature branch, then I pull it. + * Official website: https://www.phoenixframework.org/ + * Guides: https://hexdocs.pm/phoenix/overview.html + * Docs: https://hexdocs.pm/phoenix + * Forum: https://elixirforum.com/c/phoenix-forum + * Source: https://github.com/phoenixframework/phoenix diff --git a/config/config.exs b/config/config.exs index c3983d9..8ab193c 100644 --- a/config/config.exs +++ b/config/config.exs @@ -20,11 +20,20 @@ config :nulla, NullaWeb.Endpoint, layout: false ], pubsub_server: Nulla.PubSub, - live_view: [signing_salt: "jcAt5/U+"] + live_view: [signing_salt: "rmaJ4fGm"] # Snowflake configuration config :nulla, :snowflake, worker_id: 1 +# Instance configuration +config :nulla, :instance, + name: "Nulla", + description: "Freedom Social Network", + registration: false, + max_characters: 5000, + max_upload_size: 50, + api_limit: 100 + # Configures the mailer # # By default it uses the "Local" adapter which stores the emails @@ -64,11 +73,6 @@ config :logger, :console, # Use Jason for JSON parsing in Phoenix config :phoenix, :json_library, Jason -config :mime, :types, %{ - "application/activity+json" => ["activity+json"], - "application/ld+json" => ["ld+json"] -} - # Import environment specific config. This must remain at the bottom # of this file so it overrides the configuration defined above. import_config "#{config_env()}.exs" diff --git a/config/dev.exs b/config/dev.exs index d62d0ae..200ebfd 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -16,18 +16,14 @@ config :nulla, Nulla.Repo, # The watchers configuration can be used to run external # watchers to your application. For example, we can use it # to bundle .js and .css sources. -host = System.get_env("PHX_HOST") || "localhost" -port = String.to_integer(System.get_env("PORT") || "4000") - config :nulla, NullaWeb.Endpoint, # Binding to loopback ipv4 address prevents access from other machines. # Change to `ip: {0, 0, 0, 0}` to allow access from other machines. - url: [host: host, port: port], - http: [ip: {127, 0, 0, 1}, port: port], + http: [ip: {127, 0, 0, 1}, port: 4000], check_origin: false, code_reloader: true, debug_errors: true, - secret_key_base: "VfFSj33PMul7V6oKoeanGdabenUTRUabPkosFKft2PqKMlMKPW5s7Ls0OtFcgSSO", + secret_key_base: "lqclOxhFj2lh7RLyFeE47uMJTjyt298gBeH659w0B3/RgGH+bWB9GmYGl4ILMkqK", watchers: [ esbuild: {Esbuild, :install_and_run, [:nulla, ~w(--sourcemap=inline --watch)]}, tailwind: {Tailwind, :install_and_run, [:nulla, ~w(--watch)]} diff --git a/config/test.exs b/config/test.exs index ece2ea6..8c70f7f 100644 --- a/config/test.exs +++ b/config/test.exs @@ -1,5 +1,8 @@ import Config +# Only in tests, remove the complexity from the password hashing algorithm +config :bcrypt_elixir, :log_rounds, 1 + # Configure your database # # The MIX_TEST_PARTITION environment variable can be used @@ -17,7 +20,7 @@ config :nulla, Nulla.Repo, # you can enable the server option below. config :nulla, NullaWeb.Endpoint, http: [ip: {127, 0, 0, 1}, port: 4002], - secret_key_base: "En2KMJ4iAUgGiOa8ADt/0nmQ6yC63U2COOGVSXR+A9nENgS1+6O2HjzwAseCSBCW", + secret_key_base: "DshBXGErBJik2O+qnSRsffwx9/TZDy8anaxLwB0uQSEXJsYmCtZFwR89fL5LtoSA", server: false # In test we don't send emails diff --git a/lib/nulla/accounts.ex b/lib/nulla/accounts.ex new file mode 100644 index 0000000..b6e5d8b --- /dev/null +++ b/lib/nulla/accounts.ex @@ -0,0 +1,377 @@ +defmodule Nulla.Accounts do + @moduledoc """ + The Accounts context. + """ + + import Ecto.Query, warn: false + alias Nulla.Repo + + alias Nulla.Accounts.{User, UserToken, UserNotifier} + + ## Database getters + + @doc """ + Gets a user by email. + + ## Examples + + iex> get_user_by_email("foo@example.com") + %User{} + + iex> get_user_by_email("unknown@example.com") + nil + + """ + def get_user_by_email(email) when is_binary(email) do + Repo.get_by(User, email: email) + end + + @doc """ + Gets a user by email and password. + + ## Examples + + iex> get_user_by_email_and_password("foo@example.com", "correct_password") + %User{} + + iex> get_user_by_email_and_password("foo@example.com", "invalid_password") + nil + + """ + def get_user_by_email_and_password(email, password) + when is_binary(email) and is_binary(password) do + user = Repo.get_by(User, email: email) + if User.valid_password?(user, password), do: user + end + + @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) + + ## User registration + + @doc """ + Registers a user. + + ## Examples + + iex> register_user(%{field: value}) + {:ok, %User{}} + + iex> register_user(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def register_user(attrs) do + %User{} + |> User.registration_changeset(attrs) + |> Repo.insert() + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking user changes. + + ## Examples + + iex> change_user_registration(user) + %Ecto.Changeset{data: %User{}} + + """ + def change_user_registration(%User{} = user, attrs \\ %{}) do + User.registration_changeset(user, attrs, hash_password: false, validate_email: false) + end + + ## Settings + + @doc """ + Returns an `%Ecto.Changeset{}` for changing the user email. + + ## Examples + + iex> change_user_email(user) + %Ecto.Changeset{data: %User{}} + + """ + def change_user_email(user, attrs \\ %{}) do + User.email_changeset(user, attrs, validate_email: false) + end + + @doc """ + Emulates that the email will change without actually changing + it in the database. + + ## Examples + + iex> apply_user_email(user, "valid password", %{email: ...}) + {:ok, %User{}} + + iex> apply_user_email(user, "invalid password", %{email: ...}) + {:error, %Ecto.Changeset{}} + + """ + def apply_user_email(user, password, attrs) do + user + |> User.email_changeset(attrs) + |> User.validate_current_password(password) + |> Ecto.Changeset.apply_action(:update) + end + + @doc """ + Updates the user email using the given token. + + If the token matches, the user email is updated and the token is deleted. + The confirmed_at date is also updated to the current time. + """ + def update_user_email(user, token) do + context = "change:#{user.email}" + + with {:ok, query} <- UserToken.verify_change_email_token_query(token, context), + %UserToken{sent_to: email} <- Repo.one(query), + {:ok, _} <- Repo.transaction(user_email_multi(user, email, context)) do + :ok + else + _ -> :error + end + end + + defp user_email_multi(user, email, context) do + changeset = + user + |> User.email_changeset(%{email: email}) + |> User.confirm_changeset() + + Ecto.Multi.new() + |> Ecto.Multi.update(:user, changeset) + |> Ecto.Multi.delete_all(:tokens, UserToken.by_user_and_contexts_query(user, [context])) + end + + @doc ~S""" + Delivers the update email instructions to the given user. + + ## Examples + + iex> deliver_user_update_email_instructions(user, current_email, &url(~p"/users/settings/confirm_email/#{&1}")) + {:ok, %{to: ..., body: ...}} + + """ + def deliver_user_update_email_instructions(%User{} = user, current_email, update_email_url_fun) + when is_function(update_email_url_fun, 1) do + {encoded_token, user_token} = UserToken.build_email_token(user, "change:#{current_email}") + + Repo.insert!(user_token) + UserNotifier.deliver_update_email_instructions(user, update_email_url_fun.(encoded_token)) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for changing the user password. + + ## Examples + + iex> change_user_password(user) + %Ecto.Changeset{data: %User{}} + + """ + def change_user_password(user, attrs \\ %{}) do + User.password_changeset(user, attrs, hash_password: false) + end + + @doc """ + Updates the user password. + + ## Examples + + iex> update_user_password(user, "valid password", %{password: ...}) + {:ok, %User{}} + + iex> update_user_password(user, "invalid password", %{password: ...}) + {:error, %Ecto.Changeset{}} + + """ + def update_user_password(user, password, attrs) do + changeset = + user + |> User.password_changeset(attrs) + |> User.validate_current_password(password) + + Ecto.Multi.new() + |> Ecto.Multi.update(:user, changeset) + |> Ecto.Multi.delete_all(:tokens, UserToken.by_user_and_contexts_query(user, :all)) + |> Repo.transaction() + |> case do + {:ok, %{user: user}} -> {:ok, user} + {:error, :user, changeset, _} -> {:error, changeset} + end + end + + ## Session + + @doc """ + Generates a session token. + """ + def generate_user_session_token(user) do + {token, user_token} = UserToken.build_session_token(user) + Repo.insert!(user_token) + token + end + + @doc """ + Gets the user with the given signed token. + """ + def get_user_by_session_token(token) do + {:ok, query} = UserToken.verify_session_token_query(token) + Repo.one(query) + end + + @doc """ + Deletes the signed token with the given context. + """ + def delete_user_session_token(token) do + Repo.delete_all(UserToken.by_token_and_context_query(token, "session")) + :ok + end + + ## Confirmation + + @doc ~S""" + Delivers the confirmation email instructions to the given user. + + ## Examples + + iex> deliver_user_confirmation_instructions(user, &url(~p"/users/confirm/#{&1}")) + {:ok, %{to: ..., body: ...}} + + iex> deliver_user_confirmation_instructions(confirmed_user, &url(~p"/users/confirm/#{&1}")) + {:error, :already_confirmed} + + """ + def deliver_user_confirmation_instructions(%User{} = user, confirmation_url_fun) + when is_function(confirmation_url_fun, 1) do + if user.confirmed_at do + {:error, :already_confirmed} + else + {encoded_token, user_token} = UserToken.build_email_token(user, "confirm") + Repo.insert!(user_token) + UserNotifier.deliver_confirmation_instructions(user, confirmation_url_fun.(encoded_token)) + end + end + + @doc """ + Confirms a user by the given token. + + If the token matches, the user account is marked as confirmed + and the token is deleted. + """ + def confirm_user(token) do + with {:ok, query} <- UserToken.verify_email_token_query(token, "confirm"), + %User{} = user <- Repo.one(query), + {:ok, %{user: user}} <- Repo.transaction(confirm_user_multi(user)) do + {:ok, user} + else + _ -> :error + end + end + + defp confirm_user_multi(user) do + Ecto.Multi.new() + |> Ecto.Multi.update(:user, User.confirm_changeset(user)) + |> Ecto.Multi.delete_all(:tokens, UserToken.by_user_and_contexts_query(user, ["confirm"])) + end + + ## Reset password + + @doc ~S""" + Delivers the reset password email to the given user. + + ## Examples + + iex> deliver_user_reset_password_instructions(user, &url(~p"/users/reset_password/#{&1}")) + {:ok, %{to: ..., body: ...}} + + """ + def deliver_user_reset_password_instructions(%User{} = user, reset_password_url_fun) + when is_function(reset_password_url_fun, 1) do + {encoded_token, user_token} = UserToken.build_email_token(user, "reset_password") + Repo.insert!(user_token) + UserNotifier.deliver_reset_password_instructions(user, reset_password_url_fun.(encoded_token)) + end + + @doc """ + Gets the user by reset password token. + + ## Examples + + iex> get_user_by_reset_password_token("validtoken") + %User{} + + iex> get_user_by_reset_password_token("invalidtoken") + nil + + """ + def get_user_by_reset_password_token(token) do + with {:ok, query} <- UserToken.verify_email_token_query(token, "reset_password"), + %User{} = user <- Repo.one(query) do + user + else + _ -> nil + end + end + + @doc """ + Resets the user password. + + ## Examples + + iex> reset_user_password(user, %{password: "new long password", password_confirmation: "new long password"}) + {:ok, %User{}} + + iex> reset_user_password(user, %{password: "valid", password_confirmation: "not the same"}) + {:error, %Ecto.Changeset{}} + + """ + def reset_user_password(user, attrs) do + Ecto.Multi.new() + |> Ecto.Multi.update(:user, User.password_changeset(user, attrs)) + |> Ecto.Multi.delete_all(:tokens, UserToken.by_user_and_contexts_query(user, :all)) + |> Repo.transaction() + |> case do + {:ok, %{user: user}} -> {:ok, user} + {:error, :user, changeset, _} -> {:error, changeset} + end + end + + @doc """ + Creates a new api token for a user. + + The token returned must be saved somewhere safe. + This token cannot be recovered from the database. + """ + def create_user_api_token(user) do + {encoded_token, user_token} = UserToken.build_email_token(user, "api-token") + Repo.insert!(user_token) + encoded_token + end + + @doc """ + Fetches the user by API token. + """ + def fetch_user_by_api_token(token) do + with {:ok, query} <- UserToken.verify_email_token_query(token, "api-token"), + %User{} = user <- Repo.one(query) do + {:ok, user} + else + _ -> :error + end + end +end diff --git a/lib/nulla/accounts/user.ex b/lib/nulla/accounts/user.ex new file mode 100644 index 0000000..cace737 --- /dev/null +++ b/lib/nulla/accounts/user.ex @@ -0,0 +1,171 @@ +defmodule Nulla.Accounts.User do + use Ecto.Schema + import Ecto.Changeset + alias Ecto.Changeset + alias Nulla.Snowflake + + @primary_key {:id, :integer, autogenerate: false} + schema "users" do + field :email, :string + field :password, :string, virtual: true, redact: true + field :hashed_password, :string, redact: true + field :current_password, :string, virtual: true, redact: true + field :confirmed_at, :utc_datetime + + timestamps(type: :utc_datetime) + end + + @doc """ + A user changeset for registration. + + It is important to validate the length of both email and password. + Otherwise databases may truncate the email without warnings, which + could lead to unpredictable or insecure behaviour. Long passwords may + also be very expensive to hash for certain algorithms. + + ## Options + + * `:hash_password` - Hashes the password so it can be stored securely + in the database and ensures the password field is cleared to prevent + leaks in the logs. If password hashing is not needed and clearing the + password field is not desired (like when using this changeset for + validations on a LiveView form), this option can be set to `false`. + Defaults to `true`. + + * `:validate_email` - Validates the uniqueness of the email, in case + you don't want to validate the uniqueness of the email (like when + using this changeset for validations on a LiveView form before + submitting the form), this option can be set to `false`. + Defaults to `true`. + """ + def registration_changeset(user, attrs, opts \\ []) do + user + |> cast(attrs, [:email, :password]) + |> maybe_put_id() + |> validate_email(opts) + |> validate_password(opts) + end + + defp maybe_put_id(%Changeset{data: %{id: nil}} = changeset) do + change(changeset, id: Snowflake.next_id()) + end + + defp maybe_put_id(changeset), do: changeset + + defp validate_email(changeset, opts) do + changeset + |> validate_required([:email]) + |> validate_format(:email, ~r/^[^\s]+@[^\s]+$/, message: "must have the @ sign and no spaces") + |> validate_length(:email, max: 160) + |> maybe_validate_unique_email(opts) + end + + defp validate_password(changeset, opts) do + changeset + |> validate_required([:password]) + |> validate_length(:password, min: 12, max: 72) + # Examples of additional password validation: + # |> validate_format(:password, ~r/[a-z]/, message: "at least one lower case character") + # |> validate_format(:password, ~r/[A-Z]/, message: "at least one upper case character") + # |> validate_format(:password, ~r/[!?@#$%^&*_0-9]/, message: "at least one digit or punctuation character") + |> maybe_hash_password(opts) + end + + defp maybe_hash_password(changeset, opts) do + hash_password? = Keyword.get(opts, :hash_password, true) + password = get_change(changeset, :password) + + if hash_password? && password && changeset.valid? do + changeset + # If using Bcrypt, then further validate it is at most 72 bytes long + |> validate_length(:password, max: 72, count: :bytes) + # Hashing could be done with `Ecto.Changeset.prepare_changes/2`, but that + # would keep the database transaction open longer and hurt performance. + |> put_change(:hashed_password, Bcrypt.hash_pwd_salt(password)) + |> delete_change(:password) + else + changeset + end + end + + defp maybe_validate_unique_email(changeset, opts) do + if Keyword.get(opts, :validate_email, true) do + changeset + |> unsafe_validate_unique(:email, Nulla.Repo) + |> unique_constraint(:email) + else + changeset + end + end + + @doc """ + A user changeset for changing the email. + + It requires the email to change otherwise an error is added. + """ + def email_changeset(user, attrs, opts \\ []) do + user + |> cast(attrs, [:email]) + |> validate_email(opts) + |> case do + %{changes: %{email: _}} = changeset -> changeset + %{} = changeset -> add_error(changeset, :email, "did not change") + end + end + + @doc """ + A user changeset for changing the password. + + ## Options + + * `:hash_password` - Hashes the password so it can be stored securely + in the database and ensures the password field is cleared to prevent + leaks in the logs. If password hashing is not needed and clearing the + password field is not desired (like when using this changeset for + validations on a LiveView form), this option can be set to `false`. + Defaults to `true`. + """ + def password_changeset(user, attrs, opts \\ []) do + user + |> cast(attrs, [:password]) + |> validate_confirmation(:password, message: "does not match password") + |> validate_password(opts) + end + + @doc """ + Confirms the account by setting `confirmed_at`. + """ + def confirm_changeset(user) do + now = DateTime.utc_now() |> DateTime.truncate(:second) + change(user, confirmed_at: now) + end + + @doc """ + Verifies the password. + + If there is no user or the user doesn't have a password, we call + `Bcrypt.no_user_verify/0` to avoid timing attacks. + """ + def valid_password?(%Nulla.Accounts.User{hashed_password: hashed_password}, password) + when is_binary(hashed_password) and byte_size(password) > 0 do + Bcrypt.verify_pass(password, hashed_password) + end + + def valid_password?(_, _) do + Bcrypt.no_user_verify() + false + end + + @doc """ + Validates the current password otherwise adds an error to the changeset. + """ + def validate_current_password(changeset, password) do + changeset = cast(changeset, %{current_password: password}, [:current_password]) + + if valid_password?(changeset.data, password) do + changeset + else + add_error(changeset, :current_password, "is not valid") + end + end +end diff --git a/lib/nulla/accounts/user_notifier.ex b/lib/nulla/accounts/user_notifier.ex new file mode 100644 index 0000000..682f899 --- /dev/null +++ b/lib/nulla/accounts/user_notifier.ex @@ -0,0 +1,79 @@ +defmodule Nulla.Accounts.UserNotifier do + import Swoosh.Email + + alias Nulla.Mailer + + # Delivers the email using the application mailer. + defp deliver(recipient, subject, body) do + email = + new() + |> to(recipient) + |> from({"Nulla", "contact@example.com"}) + |> subject(subject) + |> text_body(body) + + with {:ok, _metadata} <- Mailer.deliver(email) do + {:ok, email} + end + end + + @doc """ + Deliver instructions to confirm account. + """ + def deliver_confirmation_instructions(user, url) do + deliver(user.email, "Confirmation instructions", """ + + ============================== + + Hi #{user.email}, + + You can confirm your account by visiting the URL below: + + #{url} + + If you didn't create an account with us, please ignore this. + + ============================== + """) + end + + @doc """ + Deliver instructions to reset a user password. + """ + def deliver_reset_password_instructions(user, url) do + deliver(user.email, "Reset password instructions", """ + + ============================== + + Hi #{user.email}, + + You can reset your password by visiting the URL below: + + #{url} + + If you didn't request this change, please ignore this. + + ============================== + """) + end + + @doc """ + Deliver instructions to update a user email. + """ + def deliver_update_email_instructions(user, url) do + deliver(user.email, "Update email instructions", """ + + ============================== + + Hi #{user.email}, + + You can change your email by visiting the URL below: + + #{url} + + If you didn't request this change, please ignore this. + + ============================== + """) + end +end diff --git a/lib/nulla/accounts/user_token.ex b/lib/nulla/accounts/user_token.ex new file mode 100644 index 0000000..d351435 --- /dev/null +++ b/lib/nulla/accounts/user_token.ex @@ -0,0 +1,179 @@ +defmodule Nulla.Accounts.UserToken do + use Ecto.Schema + import Ecto.Query + alias Nulla.Accounts.UserToken + + @hash_algorithm :sha256 + @rand_size 32 + + # It is very important to keep the reset password token expiry short, + # since someone with access to the email may take over the account. + @reset_password_validity_in_days 1 + @confirm_validity_in_days 7 + @change_email_validity_in_days 7 + @session_validity_in_days 60 + + schema "users_tokens" do + field :token, :binary + field :context, :string + field :sent_to, :string + belongs_to :user, Nulla.Accounts.User + + timestamps(type: :utc_datetime, updated_at: false) + end + + @doc """ + Generates a token that will be stored in a signed place, + such as session or cookie. As they are signed, those + tokens do not need to be hashed. + + The reason why we store session tokens in the database, even + though Phoenix already provides a session cookie, is because + Phoenix' default session cookies are not persisted, they are + simply signed and potentially encrypted. This means they are + valid indefinitely, unless you change the signing/encryption + salt. + + Therefore, storing them allows individual user + sessions to be expired. The token system can also be extended + to store additional data, such as the device used for logging in. + You could then use this information to display all valid sessions + and devices in the UI and allow users to explicitly expire any + session they deem invalid. + """ + def build_session_token(user) do + token = :crypto.strong_rand_bytes(@rand_size) + {token, %UserToken{token: token, context: "session", user_id: user.id}} + end + + @doc """ + Checks if the token is valid and returns its underlying lookup query. + + The query returns the user found by the token, if any. + + The token is valid if it matches the value in the database and it has + not expired (after @session_validity_in_days). + """ + def verify_session_token_query(token) do + query = + from token in by_token_and_context_query(token, "session"), + join: user in assoc(token, :user), + where: token.inserted_at > ago(@session_validity_in_days, "day"), + select: user + + {:ok, query} + end + + @doc """ + Builds a token and its hash to be delivered to the user's email. + + The non-hashed token is sent to the user email while the + hashed part is stored in the database. The original token cannot be reconstructed, + which means anyone with read-only access to the database cannot directly use + the token in the application to gain access. Furthermore, if the user changes + their email in the system, the tokens sent to the previous email are no longer + valid. + + Users can easily adapt the existing code to provide other types of delivery methods, + for example, by phone numbers. + """ + def build_email_token(user, context) do + build_hashed_token(user, context, user.email) + end + + defp build_hashed_token(user, context, sent_to) do + token = :crypto.strong_rand_bytes(@rand_size) + hashed_token = :crypto.hash(@hash_algorithm, token) + + {Base.url_encode64(token, padding: false), + %UserToken{ + token: hashed_token, + context: context, + sent_to: sent_to, + user_id: user.id + }} + end + + @doc """ + Checks if the token is valid and returns its underlying lookup query. + + The query returns the user found by the token, if any. + + The given token is valid if it matches its hashed counterpart in the + database and the user email has not changed. This function also checks + if the token is being used within a certain period, depending on the + context. The default contexts supported by this function are either + "confirm", for account confirmation emails, and "reset_password", + for resetting the password. For verifying requests to change the email, + see `verify_change_email_token_query/2`. + """ + def verify_email_token_query(token, context) do + case Base.url_decode64(token, padding: false) do + {:ok, decoded_token} -> + hashed_token = :crypto.hash(@hash_algorithm, decoded_token) + days = days_for_context(context) + + query = + from token in by_token_and_context_query(hashed_token, context), + join: user in assoc(token, :user), + where: token.inserted_at > ago(^days, "day") and token.sent_to == user.email, + select: user + + {:ok, query} + + :error -> + :error + end + end + + defp days_for_context("confirm"), do: @confirm_validity_in_days + defp days_for_context("reset_password"), do: @reset_password_validity_in_days + + @doc """ + Checks if the token is valid and returns its underlying lookup query. + + The query returns the user found by the token, if any. + + This is used to validate requests to change the user + email. It is different from `verify_email_token_query/2` precisely because + `verify_email_token_query/2` validates the email has not changed, which is + the starting point by this function. + + The given token is valid if it matches its hashed counterpart in the + database and if it has not expired (after @change_email_validity_in_days). + The context must always start with "change:". + """ + def verify_change_email_token_query(token, "change:" <> _ = context) do + case Base.url_decode64(token, padding: false) do + {:ok, decoded_token} -> + hashed_token = :crypto.hash(@hash_algorithm, decoded_token) + + query = + from token in by_token_and_context_query(hashed_token, context), + where: token.inserted_at > ago(@change_email_validity_in_days, "day") + + {:ok, query} + + :error -> + :error + end + end + + @doc """ + Returns the token struct for the given token value and context. + """ + def by_token_and_context_query(token, context) do + from UserToken, where: [token: ^token, context: ^context] + end + + @doc """ + Gets all tokens for the given user for the given contexts. + """ + def by_user_and_contexts_query(user, :all) do + from t in UserToken, where: t.user_id == ^user.id + end + + def by_user_and_contexts_query(user, [_ | _] = contexts) do + from t in UserToken, where: t.user_id == ^user.id and t.context in ^contexts + end +end diff --git a/lib/nulla/activities.ex b/lib/nulla/activities.ex new file mode 100644 index 0000000..c6119fd --- /dev/null +++ b/lib/nulla/activities.ex @@ -0,0 +1,104 @@ +defmodule Nulla.Activities do + @moduledoc """ + The Activities context. + """ + + import Ecto.Query, warn: false + alias Nulla.Repo + + alias Nulla.Activities.Activity + + @doc """ + Returns the list of activities. + + ## Examples + + iex> list_activities() + [%Activity{}, ...] + + """ + def list_activities do + Repo.all(Activity) + end + + @doc """ + Gets a single activity. + + Raises `Ecto.NoResultsError` if the Activity does not exist. + + ## Examples + + iex> get_activity!(123) + %Activity{} + + iex> get_activity!(456) + ** (Ecto.NoResultsError) + + """ + def get_activity!(id), do: Repo.get!(Activity, id) + + @doc """ + Creates a activity. + + ## Examples + + iex> create_activity(%{field: value}) + {:ok, %Activity{}} + + iex> create_activity(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def create_activity(attrs \\ %{}) do + %Activity{} + |> Activity.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Updates a activity. + + ## Examples + + iex> update_activity(activity, %{field: new_value}) + {:ok, %Activity{}} + + iex> update_activity(activity, %{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def update_activity(%Activity{} = activity, attrs) do + activity + |> Activity.changeset(attrs) + |> Repo.update() + end + + @doc """ + Deletes a activity. + + ## Examples + + iex> delete_activity(activity) + {:ok, %Activity{}} + + iex> delete_activity(activity) + {:error, %Ecto.Changeset{}} + + """ + def delete_activity(%Activity{} = activity) do + Repo.delete(activity) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking activity changes. + + ## Examples + + iex> change_activity(activity) + %Ecto.Changeset{data: %Activity{}} + + """ + def change_activity(%Activity{} = activity, attrs \\ %{}) do + Activity.changeset(activity, attrs) + end +end diff --git a/lib/nulla/models/activity.ex b/lib/nulla/activities/activity.ex similarity index 64% rename from lib/nulla/models/activity.ex rename to lib/nulla/activities/activity.ex index 00add89..23a35b7 100644 --- a/lib/nulla/models/activity.ex +++ b/lib/nulla/activities/activity.ex @@ -1,7 +1,7 @@ -defmodule Nulla.Models.Activity do +defmodule Nulla.Activities.Activity do use Ecto.Schema import Ecto.Changeset - alias Nulla.Repo + alias Ecto.Changeset alias Nulla.Snowflake alias Nulla.Types.StringOrJson @@ -15,23 +15,20 @@ defmodule Nulla.Models.Activity do field :to, {:array, :string} field :cc, {:array, :string} - timestamps() + timestamps(type: :utc_datetime) end @doc false def changeset(activity, attrs) do activity |> cast(attrs, [:ap_id, :type, :actor, :object, :to, :cc]) + |> maybe_put_id() |> validate_required([:ap_id, :type, :actor, :object]) - |> validate_inclusion(:type, ~w(Create Update Delete Undo Like Announce Follow Accept Reject)) end - def create_activity(attrs) do - id = Map.get(attrs, :id, Snowflake.next_id()) - - %__MODULE__{} - |> __MODULE__.changeset(attrs) - |> put_change(:id, id) - |> Repo.insert() + defp maybe_put_id(%Changeset{data: %{id: nil}} = changeset) do + change(changeset, id: Snowflake.next_id()) end + + defp maybe_put_id(changeset), do: changeset end diff --git a/lib/nulla/activitypub.ex b/lib/nulla/activitypub.ex deleted file mode 100644 index 12ad121..0000000 --- a/lib/nulla/activitypub.ex +++ /dev/null @@ -1,354 +0,0 @@ -defmodule Nulla.ActivityPub do - alias Nulla.Models.Actor - alias Nulla.Models.Activity - alias Nulla.Models.Note - alias Nulla.Models.InstanceSettings - - @spec context() :: list() - defp context do - [ - "https://www.w3.org/ns/activitystreams", - "https://w3id.org/security/v1", - Jason.OrderedObject.new( - manuallyApprovesFollowers: "as:manuallyApprovesFollowers", - toot: "http://joinmastodon.org/ns#", - featured: %{"@id" => "toot:featured", "@type" => "@id"}, - featuredTags: %{"@id" => "toot:featuredTags", "@type" => "@id"}, - alsoKnownAs: %{"@id" => "as:alsoKnownAs", "@type" => "@id"}, - movedTo: %{"@id" => "as:movedTo", "@type" => "@id"}, - schema: "http://schema.org#", - PropertyValue: "schema:PropertyValue", - value: "schema:value", - discoverable: "toot:discoverable", - suspended: "toot:suspended", - memorial: "toot:memorial", - indexable: "toot:indexable", - attributionDomains: %{"@id" => "toot:attributionDomains", "@type" => "@id"}, - Hashtag: "as:Hashtag", - vcard: "http://www.w3.org/2006/vcard/ns#" - ) - ] - end - - @spec actor(Actor.t()) :: Jason.OrderedObject.t() - def actor(actor) do - Jason.OrderedObject.new( - "@context": context(), - id: actor.ap_id, - type: actor.type, - following: actor.following, - followers: actor.followers, - inbox: actor.inbox, - outbox: actor.outbox, - featured: actor.featured, - featuredTags: actor.featuredTags, - preferredUsername: actor.preferredUsername, - name: actor.name, - summary: actor.summary, - url: actor.url, - manuallyApprovesFollowers: actor.manuallyApprovesFollowers, - discoverable: actor.discoverable, - indexable: actor.indexable, - published: DateTime.to_iso8601(actor.published), - memorial: actor.memorial, - publicKey: - Jason.OrderedObject.new( - id: actor.publicKey["id"], - owner: actor.publicKey["owner"], - publicKeyPem: actor.publicKey["publicKeyPem"] - ), - tag: actor.tag, - attachment: actor.attachment, - endpoints: actor.endpoints, - icon: actor.icon, - image: actor.image, - "vcard:bday": actor.vcard_bday, - "vcard:Address": actor.vcard_Address - ) - end - - @spec note(Note.t()) :: Jason.OrderedObject.t() - def note(note) do - attachment = - case note.media_attachments do - [] -> - [] - - attachments -> - [ - attachment: - Enum.map(attachments, fn att -> - Jason.OrderedObject.new( - type: "Document", - mediaType: att.mime_type, - url: "https://#{note.actor.domain}/files/#{att.file}" - ) - end) - ] - end - - Jason.OrderedObject.new( - "@context": [ - "https://www.w3.org/ns/activitystreams", - Jason.OrderedObject.new(sensitive: "as:sensitive") - ], - id: "#{note.actor.ap_id}/notes/#{note.id}", - type: "Note", - summary: nil, - inReplyTo: note.inReplyTo, - published: note.published, - url: note.url, - attributedTo: note.actor.ap_id, - to: note.to, - cc: note.cc, - sensitive: note.sensitive, - content: note.content, - contentMap: Jason.OrderedObject.new("#{note.language}": note.content), - attachment: attachment - ) - end - - @spec activity(Activity.t()) :: Jason.OrderedObject.t() - def activity(activity) do - Jason.OrderedObject.new( - "@context": "https://www.w3.org/ns/activitystreams", - id: activity.ap_id, - type: activity.type, - actor: activity.actor, - object: activity.object - ) - end - - @spec following(Actor.t(), Integer.t()) :: Jason.OrderedObject.t() - def following(actor, total) do - Jason.OrderedObject.new( - "@context": "https://www.w3.org/ns/activitystreams", - id: "#{actor.ap_id}/following", - type: "OrderedCollection", - totalItems: total, - first: "#{actor.ap_id}/following?page=1" - ) - end - - @spec following(Actor.t(), Integer.t(), List.t(), Integer.t(), Integer.t()) :: - Jason.OrderedObject.t() - def following(actor, total, following_list, page, limit) when is_integer(page) and page > 0 do - data = [ - "@context": "https://www.w3.org/ns/activitystreams", - id: "#{actor.ap_id}/following?page=#{page}", - type: "OrderedCollectionPage", - totalItems: total, - next: "#{actor.ap_id}/following?page=#{page + 1}", - prev: "#{actor.ap_id}/following?page=#{page - 1}", - partOf: "#{actor.ap_id}/following", - orderedItems: following_list - ] - - data = - if page <= 1 do - Keyword.delete(data, :prev) - else - data - end - - data = - if page * limit > total do - data - |> Keyword.delete(:next) - |> Keyword.delete(:prev) - else - data - end - - Jason.OrderedObject.new(data) - end - - @spec followers(Actor.t(), Integer.t()) :: Jason.OrderedObject.t() - def followers(actor, total) do - Jason.OrderedObject.new( - "@context": "https://www.w3.org/ns/activitystreams", - id: "#{actor.ap_id}/followers", - type: "OrderedCollection", - totalItems: total, - first: "#{actor.ap_id}/followers?page=1" - ) - end - - @spec followers(Actor.t(), Integer.t(), List.t(), Integer.t(), Integer.t()) :: - Jason.OrderedObject.t() - def followers(actor, total, followers_list, page, limit) - when is_integer(page) and page > 0 do - data = [ - "@context": "https://www.w3.org/ns/activitystreams", - id: "#{actor.ap_id}/followers?page=#{page}", - type: "OrderedCollectionPage", - totalItems: total, - next: "#{actor.ap_id}/followers?page=#{page + 1}", - prev: "#{actor.ap_id}/followers?page=#{page - 1}", - partOf: "#{actor.ap_id}/followers", - orderedItems: followers_list - ] - - data = - if page <= 1 do - Keyword.delete(data, :prev) - else - data - end - - data = - if page * limit > total do - data - |> Keyword.delete(:next) - |> Keyword.delete(:prev) - else - data - end - - Jason.OrderedObject.new(data) - end - - @spec webfinger(Actor.t()) :: Jason.OrderedObject.t() - def webfinger(actor) do - data = [ - subject: "#{actor.preferredUsername}@#{actor.domain}", - aliases: [ - actor.url, - actor.ap_id - ], - links: [ - Jason.OrderedObject.new( - rel: "http://webfinger.net/rel/profile-page", - type: "text/html", - href: actor.url - ), - Jason.OrderedObject.new( - rel: "self", - type: "application/activity+json", - href: actor.ap_id - ) - ] - ] - - data = - if actor.icon do - Keyword.update!(data, :links, fn links -> - links ++ - [ - Jason.OrderedObject.new( - rel: "http://webfinger.net/rel/avatar", - type: Map.get(actor.icon, :mediaType), - href: Map.get(actor.icon, :url) - ) - ] - end) - else - data - end - - Jason.OrderedObject.new(data) - end - - @spec nodeinfo(String.t()) :: Jason.OrderedObject.t() - def nodeinfo(domain) do - Jason.OrderedObject.new( - links: [ - Jason.OrderedObject.new( - rel: "http://nodeinfo.diaspora.software/ns/schema/2.0", - href: "https://#{domain}/nodeinfo/2.0" - ) - ] - ) - end - - @spec nodeinfo(String.t(), Map.t(), InstanceSettings.t()) :: Jason.OrderedObject.t() - def nodeinfo(version, users, instance) do - Jason.OrderedObject.new( - version: "2.0", - software: - Jason.OrderedObject.new( - name: "nulla", - version: version - ), - protocols: [ - "activitypub" - ], - services: - Jason.OrderedObject.new( - outbound: [], - inbound: [] - ), - usage: - Jason.OrderedObject.new( - users: - Jason.OrderedObject.new( - total: users.total, - activeMonth: users.month, - activeHalfyear: users.halfyear - ) - ), - openRegistrations: instance.registration, - metadata: - Jason.OrderedObject.new( - nodeName: instance.name, - nodeDescription: instance.description - ) - ) - end - - @spec outbox(Actor.t(), Integer.t()) :: Jason.OrderedObject.t() - def outbox(actor, total) do - Jason.OrderedObject.new( - "@context": "https://www.w3.org/ns/activitystreams", - id: "#{actor.ap_id}/outbox", - type: "OrderedCollection", - totalItems: total, - first: "#{actor.ap_id}/outbox?page=true", - last: "#{actor.ap_id}/outbox?min_id=0&page=true" - ) - end - - @spec outbox(Actor.t(), Integer.t(), Integer.t(), List.t()) :: Jason.OrderedObject.t() - def outbox(actor, max_id, min_id, items) do - Jason.OrderedObject.new( - "@context": [ - "https://www.w3.org/ns/activitystreams", - Jason.OrderedObject.new( - sensitive: "as:sensitive", - Hashtag: "as:Hashtag" - ) - ], - id: "#{actor.ap_id}/outbox?page=true", - type: "OrderedCollectionPage", - next: "#{actor.ap_id}/outbox?max_id=#{max_id}&page=true", - prev: "#{actor.ap_id}/outbox?min_id=#{min_id}&page=true", - partOf: "#{actor.ap_id}/outbox", - orderedItems: items - ) - end - - @spec activity_note(Note.t()) :: Jason.OrderedObject.t() - def activity_note(note) do - Jason.OrderedObject.new( - id: "#{note.actor.ap_id}/notes/#{note.id}/activity", - type: "Create", - actor: note.actor.ap_id, - published: note.inserted_at |> DateTime.to_iso8601(), - to: [ - "https://www.w3.org/ns/activitystreams#Public" - ], - cc: [], - object: - Jason.OrderedObject.new( - id: "#{note.actor.ap_id}/notes/#{note.id}", - type: "Note", - content: note.content, - published: note.inserted_at |> DateTime.to_iso8601(), - attributedTo: note.actor.ap_id, - to: [ - "https://www.w3.org/ns/activitystreams#Public" - ] - ) - ) - end -end diff --git a/lib/nulla/actors.ex b/lib/nulla/actors.ex new file mode 100644 index 0000000..9064c46 --- /dev/null +++ b/lib/nulla/actors.ex @@ -0,0 +1,104 @@ +defmodule Nulla.Actors do + @moduledoc """ + The Actors context. + """ + + import Ecto.Query, warn: false + alias Nulla.Repo + + alias Nulla.Actors.Actor + + @doc """ + Returns the list of actors. + + ## Examples + + iex> list_actors() + [%Actor{}, ...] + + """ + def list_actors do + Repo.all(Actor) + end + + @doc """ + Gets a single actor. + + Raises `Ecto.NoResultsError` if the Actor does not exist. + + ## Examples + + iex> get_actor!(123) + %Actor{} + + iex> get_actor!(456) + ** (Ecto.NoResultsError) + + """ + def get_actor!(id), do: Repo.get!(Actor, id) + + @doc """ + Creates a actor. + + ## Examples + + iex> create_actor(%{field: value}) + {:ok, %Actor{}} + + iex> create_actor(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def create_actor(attrs \\ %{}) do + %Actor{} + |> Actor.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Updates a actor. + + ## Examples + + iex> update_actor(actor, %{field: new_value}) + {:ok, %Actor{}} + + iex> update_actor(actor, %{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def update_actor(%Actor{} = actor, attrs) do + actor + |> Actor.changeset(attrs) + |> Repo.update() + end + + @doc """ + Deletes a actor. + + ## Examples + + iex> delete_actor(actor) + {:ok, %Actor{}} + + iex> delete_actor(actor) + {:error, %Ecto.Changeset{}} + + """ + def delete_actor(%Actor{} = actor) do + Repo.delete(actor) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking actor changes. + + ## Examples + + iex> change_actor(actor) + %Ecto.Changeset{data: %Actor{}} + + """ + def change_actor(%Actor{} = actor, attrs \\ %{}) do + Actor.changeset(actor, attrs) + end +end diff --git a/lib/nulla/actors/actor.ex b/lib/nulla/actors/actor.ex new file mode 100644 index 0000000..67a71d1 --- /dev/null +++ b/lib/nulla/actors/actor.ex @@ -0,0 +1,95 @@ +defmodule Nulla.Actors.Actor do + use Ecto.Schema + import Ecto.Changeset + alias Ecto.Changeset + alias Nulla.Snowflake + + @primary_key {:id, :integer, autogenerate: false} + schema "actors" do + field :acct, :string + field :ap_id, :string + field :type, :string + field :following, :string + field :followers, :string + field :inbox, :string + field :outbox, :string + field :featured, :string + field :featuredTags, :string + field :preferredUsername, :string + field :name, :string + field :summary, :string + field :url, :string + field :manuallyApprovesFollowers, :boolean, default: false + field :discoverable, :boolean, default: true + field :indexable, :boolean, default: true + field :published, :utc_datetime + field :memorial, :boolean, default: false + field :publicKey, :map + field :privateKeyPem, :string + field :tag, {:array, :map}, default: [] + field :attachment, {:array, :map}, default: [] + field :endpoints, :map + field :icon, :map + field :image, :map + field :vcard_bday, :date + field :vcard_Address, :string + + timestamps(type: :utc_datetime) + end + + @doc false + def changeset(actor, attrs) do + actor + |> cast(attrs, [ + :acct, + :ap_id, + :type, + :following, + :followers, + :inbox, + :outbox, + :featured, + :featuredTags, + :preferredUsername, + :name, + :summary, + :url, + :manuallyApprovesFollowers, + :discoverable, + :indexable, + :published, + :memorial, + :publicKey, + :privateKeyPem, + :tag, + :attachment, + :endpoints, + :icon, + :image, + :vcard_bday, + :vcard_Address + ]) + |> maybe_put_id() + |> validate_required([ + :acct, + :ap_id, + :type, + :following, + :followers, + :inbox, + :outbox, + :featured, + :featuredTags, + :preferredUsername, + :url, + :publicKey, + :endpoints + ]) + end + + defp maybe_put_id(%Changeset{data: %{id: nil}} = changeset) do + change(changeset, id: Snowflake.next_id()) + end + + defp maybe_put_id(changeset), do: changeset +end diff --git a/lib/nulla/media_attachments.ex b/lib/nulla/media_attachments.ex new file mode 100644 index 0000000..334319b --- /dev/null +++ b/lib/nulla/media_attachments.ex @@ -0,0 +1,104 @@ +defmodule Nulla.MediaAttachments do + @moduledoc """ + The MediaAttachments context. + """ + + import Ecto.Query, warn: false + alias Nulla.Repo + + alias Nulla.MediaAttachments.MediaAttachment + + @doc """ + Returns the list of media_attachments. + + ## Examples + + iex> list_media_attachments() + [%MediaAttachment{}, ...] + + """ + def list_media_attachments do + Repo.all(MediaAttachment) + end + + @doc """ + Gets a single media_attachment. + + Raises `Ecto.NoResultsError` if the Media attachment does not exist. + + ## Examples + + iex> get_media_attachment!(123) + %MediaAttachment{} + + iex> get_media_attachment!(456) + ** (Ecto.NoResultsError) + + """ + def get_media_attachment!(id), do: Repo.get!(MediaAttachment, id) + + @doc """ + Creates a media_attachment. + + ## Examples + + iex> create_media_attachment(%{field: value}) + {:ok, %MediaAttachment{}} + + iex> create_media_attachment(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def create_media_attachment(attrs \\ %{}) do + %MediaAttachment{} + |> MediaAttachment.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Updates a media_attachment. + + ## Examples + + iex> update_media_attachment(media_attachment, %{field: new_value}) + {:ok, %MediaAttachment{}} + + iex> update_media_attachment(media_attachment, %{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def update_media_attachment(%MediaAttachment{} = media_attachment, attrs) do + media_attachment + |> MediaAttachment.changeset(attrs) + |> Repo.update() + end + + @doc """ + Deletes a media_attachment. + + ## Examples + + iex> delete_media_attachment(media_attachment) + {:ok, %MediaAttachment{}} + + iex> delete_media_attachment(media_attachment) + {:error, %Ecto.Changeset{}} + + """ + def delete_media_attachment(%MediaAttachment{} = media_attachment) do + Repo.delete(media_attachment) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking media_attachment changes. + + ## Examples + + iex> change_media_attachment(media_attachment) + %Ecto.Changeset{data: %MediaAttachment{}} + + """ + def change_media_attachment(%MediaAttachment{} = media_attachment, attrs \\ %{}) do + MediaAttachment.changeset(media_attachment, attrs) + end +end diff --git a/lib/nulla/models/media_attachment.ex b/lib/nulla/media_attachments/media_attachment.ex similarity index 67% rename from lib/nulla/models/media_attachment.ex rename to lib/nulla/media_attachments/media_attachment.ex index 61dbb1f..6efd9e1 100644 --- a/lib/nulla/models/media_attachment.ex +++ b/lib/nulla/media_attachments/media_attachment.ex @@ -1,9 +1,9 @@ -defmodule Nulla.Models.MediaAttachment do +defmodule Nulla.MediaAttachments.MediaAttachment do use Ecto.Schema import Ecto.Changeset - alias Nulla.Repo + alias Ecto.Changeset alias Nulla.Snowflake - alias Nulla.Models.Note + alias Nulla.Notes.Note @primary_key {:id, :integer, autogenerate: false} schema "media_attachments" do @@ -19,18 +19,17 @@ defmodule Nulla.Models.MediaAttachment do timestamps(type: :utc_datetime) end + @doc false def changeset(media_attachment, attrs) do media_attachment |> cast(attrs, [:type, :mediaType, :url, :name, :width, :height, :note_id]) + |> maybe_put_id() |> validate_required([:type, :mediaType, :url, :note_id]) end - def create_media_attachment(attrs) when is_map(attrs) do - id = Map.get(attrs, :id, Snowflake.next_id()) - - %__MODULE__{} - |> changeset(attrs) - |> put_change(:id, id) - |> Repo.insert() + defp maybe_put_id(%Changeset{data: %{id: nil}} = changeset) do + change(changeset, id: Snowflake.next_id()) end + + defp maybe_put_id(changeset), do: changeset end diff --git a/lib/nulla/models/actor.ex b/lib/nulla/models/actor.ex deleted file mode 100644 index 5ffc15f..0000000 --- a/lib/nulla/models/actor.ex +++ /dev/null @@ -1,164 +0,0 @@ -defmodule Nulla.Models.Actor do - use Ecto.Schema - import Ecto.Changeset - alias Nulla.Repo - alias Nulla.Snowflake - alias Nulla.Models.Note - - @primary_key {:id, :integer, autogenerate: false} - schema "actors" do - field :domain, :string - field :ap_id, :string - field :type, :string - field :following, :string - field :followers, :string - field :inbox, :string - field :outbox, :string - field :featured, :string - field :featuredTags, :string - field :preferredUsername, :string - field :name, :string - field :summary, :string - field :url, :string - field :manuallyApprovesFollowers, :boolean - field :discoverable, :boolean, default: true - field :indexable, :boolean, default: true - field :published, :utc_datetime - field :memorial, :boolean, default: false - field :publicKey, :map - field :tag, {:array, :map}, default: [] - field :attachment, {:array, :map}, default: [] - field :endpoints, :map - field :icon, :map - field :image, :map - field :vcard_bday, :date - field :vcard_Address, :string - - has_many :notes, Note - has_many :media_attachments, through: [:notes, :media_attachments] - end - - @doc false - def changeset(actor, attrs) do - actor - |> cast(attrs, [ - :id, - :domain, - :ap_id, - :type, - :following, - :followers, - :inbox, - :outbox, - :featured, - :featuredTags, - :preferredUsername, - :name, - :summary, - :url, - :manuallyApprovesFollowers, - :discoverable, - :indexable, - :published, - :memorial, - :publicKey, - :tag, - :attachment, - :endpoints, - :icon, - :image, - :vcard_bday, - :vcard_Address - ]) - |> validate_required([ - :domain, - :ap_id, - :type, - :following, - :followers, - :inbox, - :outbox, - :preferredUsername, - :url, - :publicKey, - :endpoints - ]) - |> unique_constraint([:preferredUsername, :domain]) - |> unique_constraint(:ap_id) - end - - def create_actor(attrs) when is_map(attrs) do - id = Map.get(attrs, :id, Snowflake.next_id()) - - %__MODULE__{} - |> changeset(attrs) - |> put_change(:id, id) - |> Repo.insert() - end - - def create_actor_minimal(username, domain, publicKeyPem) do - id = Snowflake.next_id() - - attrs = %{ - id: id, - domain: domain, - ap_id: "https://#{domain}/users/#{username}", - type: "Person", - following: "https://#{domain}/users/#{username}/following", - followers: "https://#{domain}/users/#{username}/followers", - inbox: "https://#{domain}/users/#{username}/inbox", - outbox: "https://#{domain}/users/#{username}/outbox", - featured: "https://#{domain}/users/#{username}/collections/featured", - featuredTags: "https://#{domain}/users/#{username}/collections/tags", - preferredUsername: username, - url: "https://#{domain}/@#{username}", - manuallyApprovesFollowers: false, - discoverable: true, - indexable: true, - published: DateTime.utc_now(), - memorial: false, - publicKey: - Jason.OrderedObject.new( - id: "https://#{domain}/users/#{username}#main-key", - owner: "https://#{domain}/users/#{username}", - publicKeyPem: publicKeyPem - ), - endpoints: Jason.OrderedObject.new(sharedInbox: "https://#{domain}/inbox") - } - - %__MODULE__{} - |> changeset(attrs) - |> Repo.insert() - end - - def get_actor(by) when is_map(by) or is_list(by) do - Repo.get_by(__MODULE__, by) - end - - def get_or_create_actor(%{"id" => ap_id} = actor_json) when is_binary(ap_id) do - case get_actor(ap_id: ap_id) do - nil -> - params = - actor_json - |> Map.put("ap_id", ap_id) - |> Map.delete("id") - |> Map.put("domain", URI.parse(ap_id).host) - - case create_actor(params) do - {:ok, actor} -> {:ok, actor} - {:error, changeset} -> {:error, {:actor_creation_failed, changeset}} - end - - actor -> - updates = - actor_json - |> Map.delete("id") - |> Map.put("domain", URI.parse(ap_id).host) - - case changeset(actor, updates) |> Repo.update() do - {:ok, updated_actor} -> {:ok, updated_actor} - {:error, changeset} -> {:error, {:actor_update_failed, changeset}} - end - end - end -end diff --git a/lib/nulla/models/bookmarks.ex b/lib/nulla/models/bookmarks.ex deleted file mode 100644 index 1688167..0000000 --- a/lib/nulla/models/bookmarks.ex +++ /dev/null @@ -1,24 +0,0 @@ -defmodule Nulla.Models.Bookmark do - use Ecto.Schema - import Ecto.Changeset - import Ecto.Query - alias Nulla.Repo - alias Nulla.Models.Bookmark - - @primary_key {:id, :integer, autogenerate: false} - schema "bookmarks" do - field :url, :string - field :user_id, :id - - timestamps(type: :utc_datetime) - end - - @doc false - def changeset(bookmark, attrs) do - bookmark - |> cast(attrs, [:url, :user_id]) - |> validate_required([:url, :user_id]) - end - - def get_all_bookmarks!(user_id), do: Repo.all(from n in Bookmark, where: n.user_id == ^user_id) -end diff --git a/lib/nulla/models/hashtag.ex b/lib/nulla/models/hashtag.ex deleted file mode 100644 index 5e6d71e..0000000 --- a/lib/nulla/models/hashtag.ex +++ /dev/null @@ -1,20 +0,0 @@ -defmodule Nulla.Models.Hashtag do - use Ecto.Schema - import Ecto.Changeset - - @primary_key {:id, :integer, autogenerate: false} - schema "hashtags" do - field :tag, :string - field :usage_count, :integer, default: 0 - - timestamps() - end - - @doc false - def changeset(hashtag, attrs) do - hashtag - |> cast(attrs, [:tag, :usage_count]) - |> validate_required([:tag]) - |> unique_constraint(:tag) - end -end diff --git a/lib/nulla/models/instance_settings.ex b/lib/nulla/models/instance_settings.ex deleted file mode 100644 index b46d3be..0000000 --- a/lib/nulla/models/instance_settings.ex +++ /dev/null @@ -1,44 +0,0 @@ -defmodule Nulla.Models.InstanceSettings do - use Ecto.Schema - import Ecto.Changeset - alias Nulla.Repo - alias Nulla.Models.InstanceSettings - - schema "instance_settings" do - field :name, :string, default: "Nulla" - field :description, :string, default: "Freedom Social Network" - field :registration, :boolean, default: false - field :max_characters, :integer, default: 5000 - field :max_upload_size, :integer, default: 50 - field :api_limit, :integer, default: 100 - field :public_key, :string - field :private_key, :string - end - - @doc false - def changeset(instance_settings, attrs) do - instance_settings - |> cast(attrs, [ - :name, - :description, - :registration, - :max_characters, - :max_upload_size, - :api_limit, - :public_key, - :private_key - ]) - |> validate_required([ - :name, - :description, - :registration, - :max_characters, - :max_upload_size, - :api_limit, - :public_key, - :private_key - ]) - end - - def get_instance_settings!, do: Repo.one!(InstanceSettings) -end diff --git a/lib/nulla/models/moderation_log.ex b/lib/nulla/models/moderation_log.ex deleted file mode 100644 index 1a62b71..0000000 --- a/lib/nulla/models/moderation_log.ex +++ /dev/null @@ -1,25 +0,0 @@ -defmodule Nulla.Models.ModerationLog do - use Ecto.Schema - import Ecto.Changeset - alias Nulla.Models.User - - @primary_key {:id, :integer, autogenerate: false} - schema "moderation_logs" do - field :target_type, :string - field :target_id, :string - field :action, :string - field :reason, :string - field :metadata, :map - - belongs_to :moderator, User - - timestamps() - end - - @doc false - def changeset(moderation_log, attrs) do - moderation_log - |> cast(attrs, [:moderator_id, :target_type, :target_id, :action, :reason, :metadata]) - |> validate_required([:moderator_id, :target_type, :target_id, :action]) - end -end diff --git a/lib/nulla/models/note.ex b/lib/nulla/models/note.ex deleted file mode 100644 index 9eb9468..0000000 --- a/lib/nulla/models/note.ex +++ /dev/null @@ -1,134 +0,0 @@ -defmodule Nulla.Models.Note do - use Ecto.Schema - import Ecto.Changeset - import Ecto.Query - alias Nulla.Repo - alias Nulla.Snowflake - alias Nulla.Models.Actor - alias Nulla.Models.MediaAttachment - - @primary_key {:id, :integer, autogenerate: false} - schema "notes" do - field :inReplyTo, :string - field :published, :utc_datetime - field :url, :string - field :visibility, :string - field :to, {:array, :string} - field :cc, {:array, :string} - field :sensitive, :boolean, default: false - field :content, :string - field :language, :string - - belongs_to :actor, Actor - has_many :media_attachments, MediaAttachment - - timestamps(type: :utc_datetime) - end - - @doc false - def changeset(note, attrs) do - note - |> cast(attrs, [ - :id, - :inReplyTo, - :published, - :url, - :visibility, - :to, - :cc, - :sensitive, - :content, - :language, - :actor_id - ]) - |> validate_required([:published, :url, :to, :cc, :content, :language, :actor_id]) - end - - def create_note(attrs) when is_map(attrs) do - id = Map.get(attrs, :id, Snowflake.next_id()) - actor_id = Map.get(attrs, :actor_id) - actor = Actor.get_actor(id: actor_id) - published = Map.get(attrs, :published, DateTime.utc_now()) - visibility = Map.get(attrs, :visibility, "public") - - url = - case Map.get(attrs, :url) do - nil -> - "#{actor.url}/#{id}" - - _ -> - Map.get(attrs, :url) - end - - {to, cc} = - case visibility do - "public" -> - to = ["https://www.w3.org/ns/activitystreams#Public"] - cc = [actor.followers | Map.get(attrs, :cc, [])] - - {to, cc} - - "unlisted" -> - to = [actor.followers] - cc = ["https://www.w3.org/ns/activitystreams#Public" | Map.get(attrs, :cc, [])] - - {to, cc} - - "followers" -> - to = [actor.followers] - cc = Map.get(attrs, :cc, []) - - {to, cc} - - "private" -> - to = Map.get(attrs, :to, []) - cc = [] - - {to, cc} - - _ -> - raise ArgumentError, "Invalid visibility: #{visibility}" - end - - attrs = - attrs - |> Map.put(:id, id) - |> Map.put(:published, published) - |> Map.put(:url, url) - |> Map.put(:to, to) - |> Map.put(:cc, cc) - - %__MODULE__{} - |> changeset(attrs) - |> Repo.insert() - end - - def get_note(by) when is_map(by) or is_list(by) do - Repo.get_by(__MODULE__, by) - end - - def get_latest_notes(actor_id, limit \\ 20) do - from(n in __MODULE__, - where: n.actor_id == ^actor_id, - order_by: [desc: n.inserted_at], - limit: ^limit, - preload: [:actor, :media_attachments] - ) - |> Repo.all() - end - - def get_before_notes(actor_id, max_id, limit \\ 20) do - from(n in __MODULE__, - where: n.actor_id == ^actor_id and n.id < ^max_id, - order_by: [desc: n.inserted_at], - limit: ^limit, - preload: [:actor, :media_attachments] - ) - |> Repo.all() - end - - def get_total_notes_count(actor_id) do - from(n in __MODULE__, where: n.actor_id == ^actor_id) - |> Repo.aggregate(:count, :id) - end -end diff --git a/lib/nulla/models/notification.ex b/lib/nulla/models/notification.ex deleted file mode 100644 index 70e1dcc..0000000 --- a/lib/nulla/models/notification.ex +++ /dev/null @@ -1,25 +0,0 @@ -defmodule Nulla.Models.Notification do - use Ecto.Schema - import Ecto.Changeset - alias Nulla.Models.User - alias Nulla.Models.Actor - - @primary_key {:id, :integer, autogenerate: false} - schema "notifications" do - field :type, :string - field :data, :map - field :read, :boolean, default: false - - belongs_to :user, User - belongs_to :actor, Actor - - timestamps() - end - - @doc false - def changeset(notification, attrs) do - notification - |> cast(attrs, [:user_id, :actor_id, :type, :data, :read]) - |> validate_required([:user_id, :type]) - end -end diff --git a/lib/nulla/models/relation.ex b/lib/nulla/models/relation.ex deleted file mode 100644 index 7d50f93..0000000 --- a/lib/nulla/models/relation.ex +++ /dev/null @@ -1,136 +0,0 @@ -defmodule Nulla.Models.Relation do - use Ecto.Schema - import Ecto.Changeset - import Ecto.Query - alias Nulla.Repo - alias Nulla.Snowflake - alias Nulla.Models.Actor - - @primary_key {:id, :integer, autogenerate: false} - @foreign_key_type :integer - schema "relations" do - field :following, :boolean, default: false - field :followed_by, :boolean, default: false - field :showing_replies, :boolean, default: true - field :showing_reblogs, :boolean, default: true - field :notifying, :boolean, default: false - field :muting, :boolean, default: false - field :muting_notifications, :boolean, default: false - field :blocking, :boolean, default: false - field :blocked_by, :boolean, default: false - field :domain_blocking, :boolean, default: false - field :requested, :boolean, default: false - field :note, :string - - belongs_to :local_actor, Actor - belongs_to :remote_actor, Actor - - timestamps() - end - - @doc false - def changeset(relation, attrs) do - relation - |> cast(attrs, [ - :id, - :following, - :followed_by, - :showing_replies, - :showing_reblogs, - :notifying, - :muting, - :muting_notifications, - :blocking, - :blocked_by, - :domain_blocking, - :requested, - :note, - :local_actor_id, - :remote_actor_id - ]) - |> validate_required([:local_actor_id, :remote_actor_id]) - |> unique_constraint([:local_actor_id, :remote_actor_id]) - end - - def create_relation(attrs) do - id = Map.get(attrs, :id, Snowflake.next_id()) - - %__MODULE__{} - |> __MODULE__.changeset(attrs) - |> put_change(:id, id) - |> Repo.insert() - end - - def get_relation(by) when is_map(by) or is_list(by) do - Repo.get_by(__MODULE__, by) - end - - def get_or_create_relation(local_actor_id, remote_actor_id, opts \\ []) do - case get_relation(local_actor_id: local_actor_id, remote_actor_id: remote_actor_id) do - nil -> - attrs = - [local_actor_id: local_actor_id, remote_actor_id: remote_actor_id] - |> Keyword.merge(opts) - |> Enum.into(%{}) - - case create_relation(attrs) do - {:ok, relation} -> {:ok, relation} - {:error, changeset} -> {:error, {:relation_creation_failed, changeset}} - end - - relation -> - updates = Enum.into(opts, %{}) - - case changeset(relation, updates) |> Repo.update() do - {:ok, updated_relation} -> {:ok, updated_relation} - {:error, changeset} -> {:error, {:relation_update_failed, changeset}} - end - end - end - - def count_following(local_actor_id) do - __MODULE__ - |> where([r], r.local_actor_id == ^local_actor_id and r.following == true) - |> select([r], count(r.id)) - |> Repo.one() - end - - def get_following(local_actor_id, page, limit) when is_integer(page) and page > 0 do - offset = (page - 1) * limit - - query = - from r in __MODULE__, - join: a in Actor, - on: a.id == r.remote_actor_id, - where: r.local_actor_id == ^local_actor_id and r.following == true, - order_by: [asc: a.published], - offset: ^offset, - limit: ^limit, - select: a - - Repo.all(query) - end - - def count_followers(local_actor_id) do - __MODULE__ - |> where([r], r.local_actor_id == ^local_actor_id and r.followed_by == true) - |> select([r], count(r.id)) - |> Repo.one() - end - - def get_followers(local_actor_id, page, limit) when is_integer(page) and page > 0 do - offset = (page - 1) * limit - - query = - from r in __MODULE__, - join: a in Actor, - on: a.id == r.remote_actor_id, - where: r.local_actor_id == ^local_actor_id and r.followed_by == true, - order_by: [asc: a.published], - offset: ^offset, - limit: ^limit, - select: a - - Repo.all(query) - end -end diff --git a/lib/nulla/models/session.ex b/lib/nulla/models/session.ex deleted file mode 100644 index 9058f6a..0000000 --- a/lib/nulla/models/session.ex +++ /dev/null @@ -1,23 +0,0 @@ -defmodule Nulla.Models.Session do - use Ecto.Schema - import Ecto.Changeset - alias Nulla.Models.User - - @primary_key {:id, :integer, autogenerate: false} - schema "sessions" do - field :token, :string - field :user_agent, :string - field :ip, :string - - belongs_to :user, User - - timestamps(type: :utc_datetime) - end - - def changeset(session, attrs) do - session - |> cast(attrs, [:user_id, :token, :user_agent, :ip]) - |> validate_required([:user_id, :token]) - |> unique_constraint(:token) - end -end diff --git a/lib/nulla/models/user.ex b/lib/nulla/models/user.ex deleted file mode 100644 index 9c5adeb..0000000 --- a/lib/nulla/models/user.ex +++ /dev/null @@ -1,65 +0,0 @@ -defmodule Nulla.Models.User do - use Ecto.Schema - import Ecto.Changeset - import Ecto.Query - alias Nulla.Repo - alias Nulla.Models.Session - - @primary_key {:id, :integer, autogenerate: false} - schema "users" do - field :email, :string - field :password, :string - field :privateKeyPem, :string - field :last_active_at, :utc_datetime - - has_many :user_sessions, Session - - timestamps(type: :utc_datetime) - end - - @doc false - def changeset(user, attrs) do - user - |> cast(attrs, [ - :id, - :email, - :password, - :privateKeyPem, - :last_active_at - ]) - |> validate_required([ - :id, - :email, - :password, - :privateKeyPem, - :last_active_at - ]) - end - - def create_user(attrs) when is_map(attrs) do - %__MODULE__{} - |> changeset(attrs) - |> Repo.insert() - end - - def get_user(by) when is_map(by) or is_list(by) do - Repo.get_by(__MODULE__, by) - end - - def get_total_users_count() do - Repo.aggregate(from(u in __MODULE__), :count, :id) - end - - def get_active_users_count(days) do - cutoff = DateTime.add(DateTime.utc_now(), -days * 86400, :second) - - from(u in __MODULE__, where: u.last_active_at > ^cutoff) - |> Repo.aggregate(:count, :id) - end - - def update_last_active(user) do - user - |> change(last_active_at: DateTime.utc_now()) - |> Repo.update() - end -end diff --git a/lib/nulla/notes.ex b/lib/nulla/notes.ex new file mode 100644 index 0000000..99c4999 --- /dev/null +++ b/lib/nulla/notes.ex @@ -0,0 +1,104 @@ +defmodule Nulla.Notes do + @moduledoc """ + The Notes context. + """ + + import Ecto.Query, warn: false + alias Nulla.Repo + + alias Nulla.Notes.Note + + @doc """ + Returns the list of notes. + + ## Examples + + iex> list_notes() + [%Note{}, ...] + + """ + def list_notes do + Repo.all(Note) + end + + @doc """ + Gets a single note. + + Raises `Ecto.NoResultsError` if the Note does not exist. + + ## Examples + + iex> get_note!(123) + %Note{} + + iex> get_note!(456) + ** (Ecto.NoResultsError) + + """ + def get_note!(id), do: Repo.get!(Note, id) + + @doc """ + Creates a note. + + ## Examples + + iex> create_note(%{field: value}) + {:ok, %Note{}} + + iex> create_note(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def create_note(attrs \\ %{}) do + %Note{} + |> Note.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Updates a note. + + ## Examples + + iex> update_note(note, %{field: new_value}) + {:ok, %Note{}} + + iex> update_note(note, %{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def update_note(%Note{} = note, attrs) do + note + |> Note.changeset(attrs) + |> Repo.update() + end + + @doc """ + Deletes a note. + + ## Examples + + iex> delete_note(note) + {:ok, %Note{}} + + iex> delete_note(note) + {:error, %Ecto.Changeset{}} + + """ + def delete_note(%Note{} = note) do + Repo.delete(note) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking note changes. + + ## Examples + + iex> change_note(note) + %Ecto.Changeset{data: %Note{}} + + """ + def change_note(%Note{} = note, attrs \\ %{}) do + Note.changeset(note, attrs) + end +end diff --git a/lib/nulla/notes/note.ex b/lib/nulla/notes/note.ex new file mode 100644 index 0000000..d4ba8b1 --- /dev/null +++ b/lib/nulla/notes/note.ex @@ -0,0 +1,60 @@ +defmodule Nulla.Notes.Note do + use Ecto.Schema + import Ecto.Changeset + alias Ecto.Changeset + alias Nulla.Snowflake + alias Nulla.Actors.Actor + alias Nulla.MediaAttachments.MediaAttachment + + @primary_key {:id, :integer, autogenerate: false} + schema "notes" do + field :inReplyTo, :string + field :published, :utc_datetime + field :url, :string + field :visibility, :string + field :to, {:array, :string} + field :cc, {:array, :string} + field :sensitive, :boolean, default: false + field :content, :string + field :language, :string + + belongs_to :actor, Actor + has_many :media_attachments, MediaAttachment + + timestamps(type: :utc_datetime) + end + + @doc false + def changeset(note, attrs) do + note + |> cast(attrs, [ + :inReplyTo, + :published, + :url, + :visibility, + :to, + :cc, + :sensitive, + :content, + :language, + :actor_id + ]) + |> maybe_put_id() + |> validate_required([ + :published, + :url, + :visibility, + :to, + :cc, + :content, + :language, + :actor_id + ]) + end + + defp maybe_put_id(%Changeset{data: %{id: nil}} = changeset) do + change(changeset, id: Snowflake.next_id()) + end + + defp maybe_put_id(changeset), do: changeset +end diff --git a/lib/nulla/relations.ex b/lib/nulla/relations.ex new file mode 100644 index 0000000..088d01a --- /dev/null +++ b/lib/nulla/relations.ex @@ -0,0 +1,104 @@ +defmodule Nulla.Relations do + @moduledoc """ + The Relations context. + """ + + import Ecto.Query, warn: false + alias Nulla.Repo + + alias Nulla.Relations.Relation + + @doc """ + Returns the list of relations. + + ## Examples + + iex> list_relations() + [%Relation{}, ...] + + """ + def list_relations do + Repo.all(Relation) + end + + @doc """ + Gets a single relation. + + Raises `Ecto.NoResultsError` if the Relation does not exist. + + ## Examples + + iex> get_relation!(123) + %Relation{} + + iex> get_relation!(456) + ** (Ecto.NoResultsError) + + """ + def get_relation!(id), do: Repo.get!(Relation, id) + + @doc """ + Creates a relation. + + ## Examples + + iex> create_relation(%{field: value}) + {:ok, %Relation{}} + + iex> create_relation(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def create_relation(attrs \\ %{}) do + %Relation{} + |> Relation.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Updates a relation. + + ## Examples + + iex> update_relation(relation, %{field: new_value}) + {:ok, %Relation{}} + + iex> update_relation(relation, %{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def update_relation(%Relation{} = relation, attrs) do + relation + |> Relation.changeset(attrs) + |> Repo.update() + end + + @doc """ + Deletes a relation. + + ## Examples + + iex> delete_relation(relation) + {:ok, %Relation{}} + + iex> delete_relation(relation) + {:error, %Ecto.Changeset{}} + + """ + def delete_relation(%Relation{} = relation) do + Repo.delete(relation) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking relation changes. + + ## Examples + + iex> change_relation(relation) + %Ecto.Changeset{data: %Relation{}} + + """ + def change_relation(%Relation{} = relation, attrs \\ %{}) do + Relation.changeset(relation, attrs) + end +end diff --git a/lib/nulla/relations/relation.ex b/lib/nulla/relations/relation.ex new file mode 100644 index 0000000..8ac7e38 --- /dev/null +++ b/lib/nulla/relations/relation.ex @@ -0,0 +1,71 @@ +defmodule Nulla.Relations.Relation do + use Ecto.Schema + import Ecto.Changeset + alias Ecto.Changeset + alias Nulla.Snowflake + alias Nulla.Actors.Actor + + schema "relations" do + field :following, :boolean, default: false + field :followed_by, :boolean, default: false + field :showing_replies, :boolean, default: true + field :showings_reblogs, :boolean, default: true + field :notifying, :boolean, default: false + field :muting, :boolean, default: false + field :muting_notifications, :boolean, default: false + field :blocking, :boolean, default: false + field :blocked_by, :boolean, default: false + field :domain_blocking, :boolean, default: false + field :requested, :boolean, default: false + field :note, :string + + belongs_to :local_actor, Actor + belongs_to :remote_actor, Actor + + timestamps(type: :utc_datetime) + end + + @doc false + def changeset(relation, attrs) do + relation + |> cast(attrs, [ + :following, + :followed_by, + :showing_replies, + :showings_reblogs, + :notifying, + :muting, + :muting_notifications, + :blocking, + :blocked_by, + :domain_blocking, + :requested, + :note, + :local_actor_id, + :remote_actor_id + ]) + |> maybe_put_id() + |> validate_required([ + :following, + :followed_by, + :showing_replies, + :showings_reblogs, + :notifying, + :muting, + :muting_notifications, + :blocking, + :blocked_by, + :domain_blocking, + :requested, + :note, + :local_actor_id, + :remote_actor_id + ]) + end + + defp maybe_put_id(%Changeset{data: %{id: nil}} = changeset) do + change(changeset, id: Snowflake.next_id()) + end + + defp maybe_put_id(changeset), do: changeset +end diff --git a/lib/nulla/sender.ex b/lib/nulla/sender.ex index 8e3a516..110fa2c 100644 --- a/lib/nulla/sender.ex +++ b/lib/nulla/sender.ex @@ -1,9 +1,9 @@ defmodule Nulla.Sender do - alias Nulla.ActivityPub alias Nulla.HTTPSignature + alias NullaWeb.ActivityJSON def send_activity(method, inbox, activity, publicKeyId, privateKeyPem) do - body = Jason.encode!(ActivityPub.activity(activity)) + body = Jason.encode!(ActivityJSON.activitypub(activity)) headers = HTTPSignature.make_headers(body, inbox, publicKeyId, privateKeyPem) request = Finch.build(method, inbox, headers, body) diff --git a/lib/nulla/uploader.ex b/lib/nulla/uploader.ex deleted file mode 100644 index 7aada0f..0000000 --- a/lib/nulla/uploader.ex +++ /dev/null @@ -1,92 +0,0 @@ -defmodule Nulla.Uploader do - alias Nulla.Snowflake - alias Nulla.Models.MediaAttachment - - @upload_base "priv/static" - @upload_prefix "system" - - def upload(%Plug.Upload{path: temp_path, filename: original_name}, domain, name) do - {:ok, binary} = File.read(temp_path) - ext = Path.extname(original_name) - mimetype = MIME.type(ext) - filename = Base.encode16(:crypto.strong_rand_bytes(8), case: :lower) <> ext - media_attachment_id = Snowflake.next_id() - - relative_path = - Path.join([ - @upload_prefix, - "media_attachments/files", - partition_id(media_attachment_id), - "original", - filename - ]) - - full_path = Path.join(@upload_base, relative_path) - - full_path |> Path.dirname() |> File.mkdir_p!() - - File.write!(full_path, binary) - - type = - cond do - mimetype =~ "image" -> "Image" - mimetype =~ "video" -> "Video" - mimetype =~ "audio" -> "Audio" - true -> "Document" - end - - {width, height} = get_width_and_height(temp_path, mimetype) - - MediaAttachment.create_media_attachment(%{ - id: media_attachment_id, - type: type, - mediaType: mimetype, - url: "https://#{domain}/#{relative_path}", - name: name, - width: width, - height: height - }) - end - - defp get_width_and_height(path, mimetype) do - cond do - mimetype =~ "image" or "video" -> - {output, 0} = - System.cmd("ffprobe", [ - "-v", - "error", - "-select_streams", - "v:0", - "-show_entries", - "stream=width,height", - "-of", - "default=noprint_wrappers=1", - path - ]) - - width = - Regex.run(~r/width=(\d+)/, output) - |> List.last() - |> String.to_integer() - - height = - Regex.run(~r/height=(\d+)/, output) - |> List.last() - |> String.to_integer() - - {width, height} - - true -> - {nil, nil} - end - end - - defp partition_id(id) do - id - |> Integer.to_string() - |> String.pad_leading(18, "0") - |> String.graphemes() - |> Enum.chunk_every(3) - |> Enum.map_join("/", &Enum.join/1) - end -end diff --git a/lib/nulla/utils.ex b/lib/nulla/utils.ex deleted file mode 100644 index 4dd8d22..0000000 --- a/lib/nulla/utils.ex +++ /dev/null @@ -1,25 +0,0 @@ -defmodule Nulla.Utils do - def fetch_remote_actor(uri) do - headers = [ - {"Accept", "application/activity+json"}, - {"User-Agent", "Nulla/1.0"}, - {"Host", URI.parse(uri).host} - ] - - request = Finch.build(:get, uri, headers) - - case Finch.request(request, Nulla.Finch) do - {:ok, %Finch.Response{status: 200, body: body}} -> - case Jason.decode(body) do - {:ok, data} -> {:ok, data} - _ -> {:error, :invalid_json} - end - - {:ok, %Finch.Response{status: code}} when code in 300..399 -> - {:error, :redirect_not_followed} - - _ -> - {:error, :actor_fetch_failed} - end - end -end diff --git a/lib/nulla_web.ex b/lib/nulla_web.ex index 0c0b0cb..862aea6 100644 --- a/lib/nulla_web.ex +++ b/lib/nulla_web.ex @@ -17,7 +17,7 @@ defmodule NullaWeb do those modules here. """ - def static_paths, do: ~w(assets system fonts images favicon.ico robots.txt) + def static_paths, do: ~w(assets fonts images favicon.ico robots.txt) def router do quote do diff --git a/lib/nulla_web/components/layouts/root.html.heex b/lib/nulla_web/components/layouts/root.html.heex index ea6a36d..8ca2517 100644 --- a/lib/nulla_web/components/layouts/root.html.heex +++ b/lib/nulla_web/components/layouts/root.html.heex @@ -12,6 +12,47 @@ + {@inner_content} diff --git a/lib/nulla_web/components/templates.ex b/lib/nulla_web/components/templates.ex deleted file mode 100644 index 65365a4..0000000 --- a/lib/nulla_web/components/templates.ex +++ /dev/null @@ -1,125 +0,0 @@ -defmodule NullaWeb.PageHTML do - use NullaWeb, :html - - embed_templates "templates/page/*" -end - -defmodule NullaWeb.ActorHTML do - use NullaWeb, :html - - embed_templates "templates/actor/*" - - def avatar(url) do - if url do - url - else - "/images/default-avatar.jpg" - end - end - - def profile_header(url) do - if url do - url - else - "/images/default-header.jpg" - end - end - - def format_birthdate(date) do - formatted = Date.to_string(date) |> String.replace("-", "/") - - today = Date.utc_today() - - age = - today.year - date.year - - if {today.month, today.day} < {date.month, date.day}, do: 1, else: 0 - - "#{formatted} (#{age} years old)" - end - - def format_registration_date(date) do - now = Date.utc_today() - formatted = Date.to_string(date) |> String.replace("-", "/") - - diff_days = Date.diff(now, date) - - cond do - diff_days == 0 -> - "#{formatted} (today)" - - diff_days == 1 -> - "#{formatted} (1 day ago)" - - diff_days < 30 -> - "#{formatted} (#{diff_days} days ago)" - - diff_days < 365 -> - year_diff = now.year - date.year - month_diff = now.month - date.month - day_correction = if now.day < date.day, do: -1, else: 0 - months = year_diff * 12 + month_diff + day_correction - - if months == 1 do - "#{formatted} (1 month ago)" - else - "#{formatted} (#{months} months ago)" - end - - true -> - year_diff = now.year - date.year - - years = - if {now.month, now.day} < {date.month, date.day}, do: year_diff - 1, else: year_diff - - if years == 1 do - "#{formatted} (1 year ago)" - else - "#{formatted} (#{years} years ago)" - end - end - end - - def format_note_datetime(datetime) do - Calendar.strftime(datetime, "%d %B %Y, %H:%M") - end - - def format_note_datetime_diff(datetime) do - now = DateTime.utc_now() - diff_seconds = DateTime.diff(now, datetime) - - cond do - diff_seconds < 60 -> - "now" - - diff_seconds < 3600 -> - minutes = div(diff_seconds, 60) - "#{minutes}m ago" - - diff_seconds < 86400 -> - hours = div(diff_seconds, 3600) - "#{hours}h ago" - - diff_seconds < 604_800 -> - days = div(diff_seconds, 86400) - "#{days}d ago" - - diff_seconds < 2_592_000 -> - weeks = div(diff_seconds, 604_800) - "#{weeks}w ago" - - diff_seconds < 31_536_000 -> - months = div(diff_seconds, 2_592_000) - "#{months}mo ago" - - true -> - years = div(diff_seconds, 31_536_000) - "#{years}y ago" - end - end -end - -defmodule NullaWeb.NoteHTML do - use NullaWeb, :html - - embed_templates "templates/note/*" -end diff --git a/lib/nulla_web/components/templates/actor/show.html.heex b/lib/nulla_web/components/templates/actor/show.html.heex deleted file mode 100644 index 6f667cd..0000000 --- a/lib/nulla_web/components/templates/actor/show.html.heex +++ /dev/null @@ -1,127 +0,0 @@ -
-
- -
- -
- -
-
-
-
-
- -
- - -
-
-
- {@actor.name} - @{@actor.preferredUsername}@{@actor.domain} -
-

{@actor.summary}

-
-
- <%= if @actor.vcard_Address do %> -
- <.icon name="hero-map-pin" class="mt-0.5 h-5 w-5 flex-none" /> -
-
{@actor.vcard_Address}
- <% end %> - <%= if @actor.vcard_bday do %> -
- <.icon name="hero-cake" class="mt-0.5 h-5 w-5 flex-none" /> -
-
{format_birthdate(@actor.vcard_bday)}
- <% end %> -
- <.icon name="hero-calendar" class="mt-0.5 h-5 w-5 flex-none" /> -
-
{format_registration_date(@actor.published)}
-
- <%= if @actor.attachment do %> -
- <%= for %{"type" => "PropertyValue", "name" => name, "value" => value} <- @actor.attachment do %> -
{name}
-
- <%= if Regex.match?(~r{://}, value) do %> - {Regex.replace(~r{^\w+://}, value, "")} - <% else %> - {value} - <% end %> -
- <% end %> -
- <% end %> - -
- -
- <%= for note <- @notes do %> -
-
- -
-
-
- - {@actor.name} - - - @{@actor.preferredUsername}@{@actor.domain} - -
-
- <%= case note.visibility do %> - <% "public" -> %> - <.icon name="hero-globe-americas" class="h-5 w-5" /> - <% "unlisted" -> %> - <.icon name="hero-moon" class="h-5 w-5" /> - <% "followers" -> %> - <.icon name="hero-lock-closed" class="h-5 w-5" /> - <% "private" -> %> - <.icon name="hero-at-symbol" class="h-5 w-5" /> - <% end %> - {format_note_datetime_diff(note.inserted_at)} -
-
-
-

{note.content}

-
-
- - - -
-
-
-
- <% end %> -
-
-
-
-
-
diff --git a/lib/nulla_web/components/templates/note/show.html.heex b/lib/nulla_web/components/templates/note/show.html.heex deleted file mode 100644 index 453f308..0000000 --- a/lib/nulla_web/components/templates/note/show.html.heex +++ /dev/null @@ -1,23 +0,0 @@ -
-
- -
- -
- -
-
-
-
- {@note.content} -
-
-
-
-
diff --git a/lib/nulla_web/components/templates/page/home.html.heex b/lib/nulla_web/components/templates/page/home.html.heex deleted file mode 100644 index 2d928c6..0000000 --- a/lib/nulla_web/components/templates/page/home.html.heex +++ /dev/null @@ -1,3 +0,0 @@ -
- <.flash_group flash={@flash} /> -
diff --git a/lib/nulla_web/components/templates/page/sign_in.html.heex b/lib/nulla_web/components/templates/page/sign_in.html.heex deleted file mode 100644 index 33ee7da..0000000 --- a/lib/nulla_web/components/templates/page/sign_in.html.heex +++ /dev/null @@ -1,12 +0,0 @@ -
- <.flash_group flash={@flash} /> - -
- - - - - - -
-
diff --git a/lib/nulla_web/components/templates/page/sign_up.html.heex b/lib/nulla_web/components/templates/page/sign_up.html.heex deleted file mode 100644 index 58b7e26..0000000 --- a/lib/nulla_web/components/templates/page/sign_up.html.heex +++ /dev/null @@ -1,22 +0,0 @@ -
- <.flash_group flash={@flash} /> - -
- - - - - - - - - -
-
diff --git a/lib/nulla_web/controllers/activity_controller.ex b/lib/nulla_web/controllers/activity_controller.ex new file mode 100644 index 0000000..9f25573 --- /dev/null +++ b/lib/nulla_web/controllers/activity_controller.ex @@ -0,0 +1,43 @@ +defmodule NullaWeb.ActivityController do + use NullaWeb, :controller + + alias Nulla.Activities + alias Nulla.Activities.Activity + + action_fallback NullaWeb.FallbackController + + def index(conn, _params) do + activities = Activities.list_activities() + render(conn, :index, activities: activities) + end + + def create(conn, %{"activity" => activity_params}) do + with {:ok, %Activity{} = activity} <- Activities.create_activity(activity_params) do + conn + |> put_status(:created) + |> put_resp_header("location", ~p"/api/activities/#{activity}") + |> render(:show, activity: activity) + end + end + + def show(conn, %{"id" => id}) do + activity = Activities.get_activity!(id) + render(conn, :show, activity: activity) + end + + def update(conn, %{"id" => id, "activity" => activity_params}) do + activity = Activities.get_activity!(id) + + with {:ok, %Activity{} = activity} <- Activities.update_activity(activity, activity_params) do + render(conn, :show, activity: activity) + end + end + + def delete(conn, %{"id" => id}) do + activity = Activities.get_activity!(id) + + with {:ok, %Activity{}} <- Activities.delete_activity(activity) do + send_resp(conn, :no_content, "") + end + end +end diff --git a/lib/nulla_web/controllers/activity_json.ex b/lib/nulla_web/controllers/activity_json.ex new file mode 100644 index 0000000..4713bed --- /dev/null +++ b/lib/nulla_web/controllers/activity_json.ex @@ -0,0 +1,41 @@ +defmodule NullaWeb.ActivityJSON do + alias Nulla.Activities.Activity + + @doc """ + Renders a list of activities. + """ + def index(%{activities: activities}) do + %{data: for(activity <- activities, do: data(activity))} + end + + @doc """ + Renders a single activity. + """ + def show(%{activity: activity}) do + %{data: data(activity)} + end + + defp data(%Activity{} = activity) do + %{ + id: activity.id, + ap_id: activity.ap_id, + type: activity.type, + actor: activity.actor, + object: activity.object, + to: activity.to, + cc: activity.cc + } + end + + def activitypub(%Activity{} = activity) do + Jason.OrderedObject.new( + "@context": "https://www.w3.org/ns/activitystreams", + id: activity.ap_id, + type: activity.type, + actor: activity.actor, + object: activity.object, + to: activity.to, + cc: activity.cc + ) + end +end diff --git a/lib/nulla_web/controllers/activitypub/actor_controller.ex b/lib/nulla_web/controllers/activitypub/actor_controller.ex deleted file mode 100644 index 6012e78..0000000 --- a/lib/nulla_web/controllers/activitypub/actor_controller.ex +++ /dev/null @@ -1,21 +0,0 @@ -defmodule NullaWeb.ActivityPub.ActorController do - use NullaWeb, :controller - alias Nulla.ActivityPub - alias Nulla.Models.Actor - - def show(conn, %{"username" => username}) do - domain = NullaWeb.Endpoint.host() - - case Actor.get_actor(preferredUsername: username, domain: domain) do - nil -> - conn - |> put_status(:not_found) - |> json(%{error: "Not Found"}) - - %Actor{} = actor -> - conn - |> put_resp_content_type("application/activity+json") - |> send_resp(200, Jason.encode!(ActivityPub.actor(actor))) - end - end -end diff --git a/lib/nulla_web/controllers/activitypub/follow_controller.ex b/lib/nulla_web/controllers/activitypub/follow_controller.ex deleted file mode 100644 index e12535d..0000000 --- a/lib/nulla_web/controllers/activitypub/follow_controller.ex +++ /dev/null @@ -1,67 +0,0 @@ -defmodule NullaWeb.ActivityPub.FollowController do - use NullaWeb, :controller - alias Nulla.ActivityPub - alias Nulla.Models.Actor - alias Nulla.Models.Relation - alias Nulla.Models.InstanceSettings - - def following(conn, %{"username" => username, "page" => page_param}) do - instance_settings = InstanceSettings.get_instance_settings!() - domain = NullaWeb.Endpoint.host() - limit = instance_settings.api_limit - actor = Actor.get_actor(preferredUsername: username, domain: domain) - total = Relation.count_following(actor.id) - - page = - case Integer.parse(page_param) do - {int, _} when int > 0 -> int - _ -> 1 - end - - following_list = Enum.map(Relation.get_following(actor.id, page, limit), & &1.ap_id) - - conn - |> put_resp_content_type("application/activity+json") - |> json(ActivityPub.following(actor, total, following_list, page, limit)) - end - - def following(conn, %{"username" => username}) do - domain = NullaWeb.Endpoint.host() - actor = Actor.get_actor(preferredUsername: username, domain: domain) - total = Relation.count_following(actor.id) - - conn - |> put_resp_content_type("application/activity+json") - |> json(ActivityPub.following(actor, total)) - end - - def followers(conn, %{"username" => username, "page" => page_param}) do - instance_settings = InstanceSettings.get_instance_settings!() - domain = NullaWeb.Endpoint.host() - limit = instance_settings.api_limit - actor = Actor.get_actor(preferredUsername: username, domain: domain) - total = Relation.count_followers(actor.id) - - page = - case Integer.parse(page_param) do - {int, _} when int > 0 -> int - _ -> 1 - end - - followers_list = Enum.map(Relation.get_followers(actor.id, page, limit), & &1.ap_id) - - conn - |> put_resp_content_type("application/activity+json") - |> json(ActivityPub.followers(actor, total, followers_list, page, limit)) - end - - def followers(conn, %{"username" => username}) do - domain = NullaWeb.Endpoint.host() - actor = Actor.get_actor(preferredUsername: username, domain: domain) - total = Relation.count_followers(actor.id) - - conn - |> put_resp_content_type("application/activity+json") - |> json(ActivityPub.followers(actor, total)) - end -end diff --git a/lib/nulla_web/controllers/activitypub/hostmeta_controller.ex b/lib/nulla_web/controllers/activitypub/hostmeta_controller.ex deleted file mode 100644 index fb573e8..0000000 --- a/lib/nulla_web/controllers/activitypub/hostmeta_controller.ex +++ /dev/null @@ -1,18 +0,0 @@ -defmodule NullaWeb.ActivityPub.HostmetaController do - use NullaWeb, :controller - - def index(conn, _params) do - domain = NullaWeb.Endpoint.host() - - xml = """ - - - - - """ - - conn - |> put_resp_content_type("application/xrd+xml") - |> send_resp(200, xml) - end -end diff --git a/lib/nulla_web/controllers/activitypub/inbox_controller.ex b/lib/nulla_web/controllers/activitypub/inbox_controller.ex deleted file mode 100644 index d40170c..0000000 --- a/lib/nulla_web/controllers/activitypub/inbox_controller.ex +++ /dev/null @@ -1,215 +0,0 @@ -defmodule NullaWeb.ActivityPub.InboxController do - use NullaWeb, :controller - alias Nulla.Snowflake - alias Nulla.HTTPSignature - alias Nulla.Sender - alias Nulla.Utils - alias Nulla.Models.User - alias Nulla.Models.Actor - alias Nulla.Models.Relation - alias Nulla.Models.Activity - - def inbox(conn, %{ - "id" => _create_id, - "type" => "Create", - "actor" => _actor_uri, - "object" => _target_uri - }) do - send_resp(conn, 200, "") - end - - def inbox(conn, %{ - "id" => _read_id, - "type" => "Read", - "actor" => _actor_uri, - "object" => _target_uri - }) do - send_resp(conn, 200, "") - end - - def inbox(conn, %{ - "id" => _update_id, - "type" => "Update", - "actor" => _actor_uri, - "object" => _target_uri - }) do - send_resp(conn, 200, "") - end - - def inbox(conn, %{ - "id" => _delete_id, - "type" => "Delete", - "actor" => _actor_uri, - "object" => _target_uri - }) do - send_resp(conn, 200, "") - end - - def inbox(conn, %{ - "id" => _add_id, - "type" => "Add", - "actor" => _actor_uri, - "object" => _target_uri - }) do - send_resp(conn, 200, "") - end - - def inbox(conn, %{ - "id" => _view_id, - "type" => "View", - "actor" => _actor_uri, - "object" => _target_uri - }) do - send_resp(conn, 200, "") - end - - def inbox(conn, %{ - "id" => _move_id, - "type" => "Move", - "actor" => _actor_uri, - "object" => _target_uri - }) do - send_resp(conn, 200, "") - end - - def inbox(conn, %{ - "id" => _undo_id, - "type" => "Undo", - "actor" => _actor_uri, - "object" => _target_uri - }) do - send_resp(conn, 200, "") - end - - def inbox(conn, %{ - "id" => follow_id, - "type" => "Follow", - "actor" => actor_uri, - "object" => target_uri - }) do - accept_id = Snowflake.next_id() - - with local_actor <- Actor.get_actor(ap_id: target_uri), - {:ok, remote_actor_json} <- Utils.fetch_remote_actor(actor_uri), - :ok <- HTTPSignature.verify(conn, remote_actor_json["publicKey"]["publicKeyPem"]), - {:ok, remote_actor} <- Actor.get_or_create_actor(remote_actor_json), - {:ok, follow_activity} <- - Activity.create_activity(%{ - ap_id: follow_id, - type: "Follow", - actor: remote_actor.ap_id, - object: target_uri - }), - {:ok, accept_activity} <- - Activity.create_activity(%{ - id: accept_id, - ap_id: "https://#{local_actor.domain}/activities/accept/#{accept_id}", - type: "Accept", - actor: local_actor.ap_id, - object: Jason.encode!(follow_activity) - }), - {:ok, _relation} <- - Relation.get_or_create_relation(local_actor.id, remote_actor.id, followed_by: true) do - user = User.get_user(id: local_actor.id) - - Sender.send_activity( - :post, - remote_actor.inbox, - accept_activity, - local_actor.publicKey["id"], - user.privateKeyPem - ) - - send_resp(conn, 200, "") - else - error -> - IO.inspect(error, label: "Follow error") - json(conn, %{"error" => "Failed to process Follow"}) - end - end - - def inbox(conn, %{ - "id" => _accept_id, - "type" => "Accept", - "actor" => _actor_uri, - "object" => _target_uri - }) do - send_resp(conn, 200, "") - end - - def inbox(conn, %{ - "id" => _reject_id, - "type" => "Reject", - "actor" => _actor_uri, - "object" => _target_uri - }) do - send_resp(conn, 200, "") - end - - def inbox(conn, %{ - "id" => _block_id, - "type" => "Block", - "actor" => _actor_uri, - "object" => _target_uri - }) do - send_resp(conn, 200, "") - end - - def inbox(conn, %{ - "id" => _join_id, - "type" => "Join", - "actor" => _actor_uri, - "object" => _target_uri - }) do - send_resp(conn, 200, "") - end - - def inbox(conn, %{ - "id" => _leave_id, - "type" => "Leave", - "actor" => _actor_uri, - "object" => _target_uri - }) do - send_resp(conn, 200, "") - end - - def inbox(conn, %{ - "id" => _like_id, - "type" => "Like", - "actor" => _actor_uri, - "object" => _target_uri - }) do - send_resp(conn, 200, "") - end - - def inbox(conn, %{ - "id" => _dislike_id, - "type" => "Dislike", - "actor" => _actor_uri, - "object" => _target_uri - }) do - send_resp(conn, 200, "") - end - - def inbox(conn, %{ - "id" => _announce_id, - "type" => "Announce", - "actor" => _actor_uri, - "object" => _target_uri - }) do - send_resp(conn, 200, "") - end - - def inbox(conn, %{ - "id" => _question_id, - "type" => "Question", - "actor" => _actor_uri, - "object" => _target_uri - }) do - send_resp(conn, 200, "") - end - - def inbox(conn, _params) do - send_resp(conn, 400, "") - end -end diff --git a/lib/nulla_web/controllers/activitypub/nodeinfo_controller.ex b/lib/nulla_web/controllers/activitypub/nodeinfo_controller.ex deleted file mode 100644 index aafc7f8..0000000 --- a/lib/nulla_web/controllers/activitypub/nodeinfo_controller.ex +++ /dev/null @@ -1,29 +0,0 @@ -defmodule NullaWeb.ActivityPub.NodeinfoController do - use NullaWeb, :controller - alias Nulla.ActivityPub - alias Nulla.Models.User - alias Nulla.Models.InstanceSettings - - def index(conn, _params) do - domain = NullaWeb.Endpoint.host() - - json(conn, ActivityPub.nodeinfo(domain)) - end - - def show(conn, _params) do - version = Application.spec(:nulla, :vsn) |> to_string() - total = User.get_total_users_count() - month = User.get_active_users_count(30) - halfyear = User.get_active_users_count(180) - - users = %{ - total: total, - month: month, - halfyear: halfyear - } - - instance_settings = InstanceSettings.get_instance_settings!() - - json(conn, ActivityPub.nodeinfo(version, users, instance_settings)) - end -end diff --git a/lib/nulla_web/controllers/activitypub/note_controller.ex b/lib/nulla_web/controllers/activitypub/note_controller.ex deleted file mode 100644 index 48185b0..0000000 --- a/lib/nulla_web/controllers/activitypub/note_controller.ex +++ /dev/null @@ -1,44 +0,0 @@ -defmodule NullaWeb.ActivityPub.NoteController do - use NullaWeb, :controller - alias Nulla.Repo - alias Nulla.ActivityPub - alias Nulla.Models.Note - - def show(conn, %{"username" => username, "id" => id}) do - case Integer.parse(id) do - {int_id, ""} -> - note = Note.get_note(id: int_id) |> Repo.preload([:actor, :media_attachments]) - - cond do - is_nil(note) -> - conn - |> put_status(:not_found) - |> json(%{error: "Not Found"}) - |> halt() - - username != note.actor.preferredUsername -> - conn - |> put_status(:not_found) - |> json(%{error: "Not Found"}) - |> halt() - - true -> - format = Phoenix.Controller.get_format(conn) - - if format == "activity+json" do - conn - |> put_resp_content_type("application/activity+json") - |> json(ActivityPub.note(note)) - else - render(conn, :show, note: note, layout: false) - end - end - - _ -> - conn - |> put_status(:not_found) - |> json(%{error: "Not Found"}) - |> halt() - end - end -end diff --git a/lib/nulla_web/controllers/activitypub/outbox_controller.ex b/lib/nulla_web/controllers/activitypub/outbox_controller.ex deleted file mode 100644 index 7243ca2..0000000 --- a/lib/nulla_web/controllers/activitypub/outbox_controller.ex +++ /dev/null @@ -1,51 +0,0 @@ -defmodule NullaWeb.ActivityPub.OutboxController do - use NullaWeb, :controller - alias Nulla.ActivityPub - alias Nulla.Models.Actor - alias Nulla.Models.Note - - def outbox(conn, %{"username" => username} = params) do - domain = NullaWeb.Endpoint.host() - actor = Actor.get_actor(preferredUsername: username, domain: domain) - - case Map.get(params, "page") do - "true" -> - max_id = params["max_id"] && String.to_integer(params["max_id"]) - - notes = - if max_id do - Note.get_before_notes(actor.id, max_id) - else - Note.get_latest_notes(actor.id) - end - - items = Enum.map(notes, &ActivityPub.activity_note(&1)) - - next_max_id = - case List.last(notes) do - nil -> 0 - last -> last.id - end - - min_id = - case List.first(notes) do - nil -> 0 - first -> first.id - end - - conn - |> put_resp_content_type("application/activity+json") - |> send_resp( - 200, - Jason.encode!(ActivityPub.outbox(actor, next_max_id, min_id || 0, items)) - ) - - _ -> - total = Note.get_total_notes_count(actor.id) - - conn - |> put_resp_content_type("application/activity+json") - |> send_resp(200, Jason.encode!(ActivityPub.outbox(actor, total))) - end - end -end diff --git a/lib/nulla_web/controllers/activitypub/webfinger_controller.ex b/lib/nulla_web/controllers/activitypub/webfinger_controller.ex deleted file mode 100644 index fd4459d..0000000 --- a/lib/nulla_web/controllers/activitypub/webfinger_controller.ex +++ /dev/null @@ -1,39 +0,0 @@ -defmodule NullaWeb.ActivityPub.WebfingerController do - use NullaWeb, :controller - alias Nulla.ActivityPub - alias Nulla.Models.Actor - - def index(conn, %{"resource" => resource}) do - case Regex.run(~r/^acct:(.+)@(.+)$/, resource) do - [_, preferredUsername, actor_domain] -> - case Actor.get_actor(preferredUsername: preferredUsername, domain: actor_domain) do - nil -> - conn - |> put_resp_content_type("text/plain") - |> send_resp(404, "") - - %Actor{} = actor -> - domain = NullaWeb.Endpoint.host() - - if actor_domain == domain do - json(conn, ActivityPub.webfinger(actor)) - else - conn - |> put_resp_content_type("text/plain") - |> send_resp(404, "") - end - end - - _ -> - conn - |> put_resp_content_type("text/plain") - |> send_resp(400, "") - end - end - - def index(conn, _params) do - conn - |> put_resp_content_type("text/plain") - |> send_resp(400, "") - end -end diff --git a/lib/nulla_web/controllers/actor_controller.ex b/lib/nulla_web/controllers/actor_controller.ex new file mode 100644 index 0000000..cf9028e --- /dev/null +++ b/lib/nulla_web/controllers/actor_controller.ex @@ -0,0 +1,43 @@ +defmodule NullaWeb.ActorController do + use NullaWeb, :controller + + alias Nulla.Actors + alias Nulla.Actors.Actor + + action_fallback NullaWeb.FallbackController + + def index(conn, _params) do + actors = Actors.list_actors() + render(conn, :index, actors: actors) + end + + def create(conn, %{"actor" => actor_params}) do + with {:ok, %Actor{} = actor} <- Actors.create_actor(actor_params) do + conn + |> put_status(:created) + |> put_resp_header("location", ~p"/api/actors/#{actor}") + |> render(:show, actor: actor) + end + end + + def show(conn, %{"id" => id}) do + actor = Actors.get_actor!(id) + render(conn, :show, actor: actor) + end + + def update(conn, %{"id" => id, "actor" => actor_params}) do + actor = Actors.get_actor!(id) + + with {:ok, %Actor{} = actor} <- Actors.update_actor(actor, actor_params) do + render(conn, :show, actor: actor) + end + end + + def delete(conn, %{"id" => id}) do + actor = Actors.get_actor!(id) + + with {:ok, %Actor{}} <- Actors.delete_actor(actor) do + send_resp(conn, :no_content, "") + end + end +end diff --git a/lib/nulla_web/controllers/actor_json.ex b/lib/nulla_web/controllers/actor_json.ex new file mode 100644 index 0000000..ca59e62 --- /dev/null +++ b/lib/nulla_web/controllers/actor_json.ex @@ -0,0 +1,49 @@ +defmodule NullaWeb.ActorJSON do + alias Nulla.Actors.Actor + + @doc """ + Renders a list of actors. + """ + def index(%{actors: actors}) do + %{data: for(actor <- actors, do: data(actor))} + end + + @doc """ + Renders a single actor. + """ + def show(%{actor: actor}) do + %{data: data(actor)} + end + + defp data(%Actor{} = actor) do + %{ + id: actor.id, + acct: actor.acct, + ap_id: actor.ap_id, + type: actor.type, + following: actor.following, + followers: actor.followers, + inbox: actor.inbox, + outbox: actor.outbox, + featured: actor.featured, + featuredTags: actor.featuredTags, + preferredUsername: actor.preferredUsername, + name: actor.name, + summary: actor.summary, + url: actor.url, + manuallyApprovesFollowers: actor.manuallyApprovesFollowers, + discoverable: actor.discoverable, + indexable: actor.indexable, + published: actor.published, + memorial: actor.memorial, + publicKey: actor.publicKey, + tag: actor.tag, + attachment: actor.attachment, + endpoints: actor.endpoints, + icon: actor.icon, + image: actor.image, + vcard_bday: actor.vcard_bday, + vcard_Address: actor.vcard_Address + } + end +end diff --git a/lib/nulla_web/controllers/api/note_controller.ex b/lib/nulla_web/controllers/api/note_controller.ex deleted file mode 100644 index 6a5b6f5..0000000 --- a/lib/nulla_web/controllers/api/note_controller.ex +++ /dev/null @@ -1,33 +0,0 @@ -defmodule NullaWeb.Api.NoteController do - use NullaWeb, :controller - alias Nulla.Models.Note - - def index(conn, _params) do - json(conn, []) - end - - def show(conn, %{"id" => id}) do - note = Note.get_note(id: id) - - case note do - nil -> - conn - |> put_status(:not_found) - |> json(%{error: "Not Found"}) - - %Note{} = note -> - IO.inspect note - - json(conn, %{}) - end - end - - def create(_conn, _params) do - end - - def update(_conn, _params) do - end - - def delete(_conn, _params) do - end -end diff --git a/lib/nulla_web/controllers/changeset_json.ex b/lib/nulla_web/controllers/changeset_json.ex new file mode 100644 index 0000000..9eca0fd --- /dev/null +++ b/lib/nulla_web/controllers/changeset_json.ex @@ -0,0 +1,25 @@ +defmodule NullaWeb.ChangesetJSON do + @doc """ + Renders changeset errors. + """ + def error(%{changeset: changeset}) do + # When encoded, the changeset returns its errors + # as a JSON object. So we just pass it forward. + %{errors: Ecto.Changeset.traverse_errors(changeset, &translate_error/1)} + end + + defp translate_error({msg, opts}) do + # You can make use of gettext to translate error messages by + # uncommenting and adjusting the following code: + + # if count = opts[:count] do + # Gettext.dngettext(NullaWeb.Gettext, "errors", msg, msg, count, opts) + # else + # Gettext.dgettext(NullaWeb.Gettext, "errors", msg, opts) + # end + + Enum.reduce(opts, msg, fn {key, value}, acc -> + String.replace(acc, "%{#{key}}", fn _ -> to_string(value) end) + end) + end +end diff --git a/lib/nulla_web/controllers/fallback_controller.ex b/lib/nulla_web/controllers/fallback_controller.ex new file mode 100644 index 0000000..9d731e1 --- /dev/null +++ b/lib/nulla_web/controllers/fallback_controller.ex @@ -0,0 +1,24 @@ +defmodule NullaWeb.FallbackController do + @moduledoc """ + Translates controller action results into valid `Plug.Conn` responses. + + See `Phoenix.Controller.action_fallback/1` for more details. + """ + use NullaWeb, :controller + + # This clause handles errors returned by Ecto's insert/update/delete. + def call(conn, {:error, %Ecto.Changeset{} = changeset}) do + conn + |> put_status(:unprocessable_entity) + |> put_view(json: NullaWeb.ChangesetJSON) + |> render(:error, changeset: changeset) + end + + # This clause is an example of how to handle resources that cannot be found. + def call(conn, {:error, :not_found}) do + conn + |> put_status(:not_found) + |> put_view(html: NullaWeb.ErrorHTML, json: NullaWeb.ErrorJSON) + |> render(:"404") + end +end diff --git a/lib/nulla_web/controllers/media_attachment_controller.ex b/lib/nulla_web/controllers/media_attachment_controller.ex new file mode 100644 index 0000000..840f503 --- /dev/null +++ b/lib/nulla_web/controllers/media_attachment_controller.ex @@ -0,0 +1,45 @@ +defmodule NullaWeb.MediaAttachmentController do + use NullaWeb, :controller + + alias Nulla.MediaAttachments + alias Nulla.MediaAttachments.MediaAttachment + + action_fallback NullaWeb.FallbackController + + def index(conn, _params) do + media_attachments = MediaAttachments.list_media_attachments() + render(conn, :index, media_attachments: media_attachments) + end + + def create(conn, %{"media_attachment" => media_attachment_params}) do + with {:ok, %MediaAttachment{} = media_attachment} <- + MediaAttachments.create_media_attachment(media_attachment_params) do + conn + |> put_status(:created) + |> put_resp_header("location", ~p"/api/media_attachments/#{media_attachment}") + |> render(:show, media_attachment: media_attachment) + end + end + + def show(conn, %{"id" => id}) do + media_attachment = MediaAttachments.get_media_attachment!(id) + render(conn, :show, media_attachment: media_attachment) + end + + def update(conn, %{"id" => id, "media_attachment" => media_attachment_params}) do + media_attachment = MediaAttachments.get_media_attachment!(id) + + with {:ok, %MediaAttachment{} = media_attachment} <- + MediaAttachments.update_media_attachment(media_attachment, media_attachment_params) do + render(conn, :show, media_attachment: media_attachment) + end + end + + def delete(conn, %{"id" => id}) do + media_attachment = MediaAttachments.get_media_attachment!(id) + + with {:ok, %MediaAttachment{}} <- MediaAttachments.delete_media_attachment(media_attachment) do + send_resp(conn, :no_content, "") + end + end +end diff --git a/lib/nulla_web/controllers/media_attachment_json.ex b/lib/nulla_web/controllers/media_attachment_json.ex new file mode 100644 index 0000000..8c24f1f --- /dev/null +++ b/lib/nulla_web/controllers/media_attachment_json.ex @@ -0,0 +1,29 @@ +defmodule NullaWeb.MediaAttachmentJSON do + alias Nulla.MediaAttachments.MediaAttachment + + @doc """ + Renders a list of media_attachments. + """ + def index(%{media_attachments: media_attachments}) do + %{data: for(media_attachment <- media_attachments, do: data(media_attachment))} + end + + @doc """ + Renders a single media_attachment. + """ + def show(%{media_attachment: media_attachment}) do + %{data: data(media_attachment)} + end + + defp data(%MediaAttachment{} = media_attachment) do + %{ + id: media_attachment.id, + type: media_attachment.type, + mediaType: media_attachment.mediaType, + url: media_attachment.url, + name: media_attachment.name, + width: media_attachment.width, + height: media_attachment.height + } + end +end diff --git a/lib/nulla_web/controllers/note_controller.ex b/lib/nulla_web/controllers/note_controller.ex new file mode 100644 index 0000000..925ebe3 --- /dev/null +++ b/lib/nulla_web/controllers/note_controller.ex @@ -0,0 +1,43 @@ +defmodule NullaWeb.NoteController do + use NullaWeb, :controller + + alias Nulla.Notes + alias Nulla.Notes.Note + + action_fallback NullaWeb.FallbackController + + def index(conn, _params) do + notes = Notes.list_notes() + render(conn, :index, notes: notes) + end + + def create(conn, %{"note" => note_params}) do + with {:ok, %Note{} = note} <- Notes.create_note(note_params) do + conn + |> put_status(:created) + |> put_resp_header("location", ~p"/api/notes/#{note}") + |> render(:show, note: note) + end + end + + def show(conn, %{"id" => id}) do + note = Notes.get_note!(id) + render(conn, :show, note: note) + end + + def update(conn, %{"id" => id, "note" => note_params}) do + note = Notes.get_note!(id) + + with {:ok, %Note{} = note} <- Notes.update_note(note, note_params) do + render(conn, :show, note: note) + end + end + + def delete(conn, %{"id" => id}) do + note = Notes.get_note!(id) + + with {:ok, %Note{}} <- Notes.delete_note(note) do + send_resp(conn, :no_content, "") + end + end +end diff --git a/lib/nulla_web/controllers/note_json.ex b/lib/nulla_web/controllers/note_json.ex new file mode 100644 index 0000000..a7773d9 --- /dev/null +++ b/lib/nulla_web/controllers/note_json.ex @@ -0,0 +1,32 @@ +defmodule NullaWeb.NoteJSON do + alias Nulla.Notes.Note + + @doc """ + Renders a list of notes. + """ + def index(%{notes: notes}) do + %{data: for(note <- notes, do: data(note))} + end + + @doc """ + Renders a single note. + """ + def show(%{note: note}) do + %{data: data(note)} + end + + defp data(%Note{} = note) do + %{ + id: note.id, + inReplyTo: note.inReplyTo, + published: note.published, + url: note.url, + visibility: note.visibility, + to: note.to, + cc: note.cc, + sensitive: note.sensitive, + content: note.content, + language: note.language + } + end +end diff --git a/lib/nulla_web/controllers/page_controller.ex b/lib/nulla_web/controllers/page_controller.ex new file mode 100644 index 0000000..3daa035 --- /dev/null +++ b/lib/nulla_web/controllers/page_controller.ex @@ -0,0 +1,9 @@ +defmodule NullaWeb.PageController do + use NullaWeb, :controller + + def home(conn, _params) do + # The home page is often custom made, + # so skip the default app layout. + render(conn, :home, layout: false) + end +end diff --git a/lib/nulla_web/controllers/page_html.ex b/lib/nulla_web/controllers/page_html.ex new file mode 100644 index 0000000..fbb6c6e --- /dev/null +++ b/lib/nulla_web/controllers/page_html.ex @@ -0,0 +1,10 @@ +defmodule NullaWeb.PageHTML do + @moduledoc """ + This module contains pages rendered by PageController. + + See the `page_html` directory for all templates available. + """ + use NullaWeb, :html + + embed_templates "page_html/*" +end diff --git a/lib/nulla_web/controllers/page_html/home.html.heex b/lib/nulla_web/controllers/page_html/home.html.heex new file mode 100644 index 0000000..d72b03c --- /dev/null +++ b/lib/nulla_web/controllers/page_html/home.html.heex @@ -0,0 +1,222 @@ +<.flash_group flash={@flash} /> + +
+
+ +

+ Phoenix Framework + + v{Application.spec(:phoenix, :vsn)} + +

+

+ Peace of mind from prototype to production. +

+

+ Build rich, interactive web applications quickly, with less code and fewer moving parts. Join our growing community of developers using Phoenix to craft APIs, HTML5 apps and more, for fun or at scale. +

+ +
+
diff --git a/lib/nulla_web/controllers/relation_controller.ex b/lib/nulla_web/controllers/relation_controller.ex new file mode 100644 index 0000000..cc5f968 --- /dev/null +++ b/lib/nulla_web/controllers/relation_controller.ex @@ -0,0 +1,43 @@ +defmodule NullaWeb.RelationController do + use NullaWeb, :controller + + alias Nulla.Relations + alias Nulla.Relations.Relation + + action_fallback NullaWeb.FallbackController + + def index(conn, _params) do + relations = Relations.list_relations() + render(conn, :index, relations: relations) + end + + def create(conn, %{"relation" => relation_params}) do + with {:ok, %Relation{} = relation} <- Relations.create_relation(relation_params) do + conn + |> put_status(:created) + |> put_resp_header("location", ~p"/api/relations/#{relation}") + |> render(:show, relation: relation) + end + end + + def show(conn, %{"id" => id}) do + relation = Relations.get_relation!(id) + render(conn, :show, relation: relation) + end + + def update(conn, %{"id" => id, "relation" => relation_params}) do + relation = Relations.get_relation!(id) + + with {:ok, %Relation{} = relation} <- Relations.update_relation(relation, relation_params) do + render(conn, :show, relation: relation) + end + end + + def delete(conn, %{"id" => id}) do + relation = Relations.get_relation!(id) + + with {:ok, %Relation{}} <- Relations.delete_relation(relation) do + send_resp(conn, :no_content, "") + end + end +end diff --git a/lib/nulla_web/controllers/relation_json.ex b/lib/nulla_web/controllers/relation_json.ex new file mode 100644 index 0000000..42ab693 --- /dev/null +++ b/lib/nulla_web/controllers/relation_json.ex @@ -0,0 +1,35 @@ +defmodule NullaWeb.RelationJSON do + alias Nulla.Relations.Relation + + @doc """ + Renders a list of relations. + """ + def index(%{relations: relations}) do + %{data: for(relation <- relations, do: data(relation))} + end + + @doc """ + Renders a single relation. + """ + def show(%{relation: relation}) do + %{data: data(relation)} + end + + defp data(%Relation{} = relation) do + %{ + id: relation.id, + following: relation.following, + followed_by: relation.followed_by, + showing_replies: relation.showing_replies, + showings_reblogs: relation.showings_reblogs, + notifying: relation.notifying, + muting: relation.muting, + muting_notifications: relation.muting_notifications, + blocking: relation.blocking, + blocked_by: relation.blocked_by, + domain_blocking: relation.domain_blocking, + requested: relation.requested, + note: relation.note + } + end +end diff --git a/lib/nulla_web/controllers/user_session_controller.ex b/lib/nulla_web/controllers/user_session_controller.ex new file mode 100644 index 0000000..ba818f6 --- /dev/null +++ b/lib/nulla_web/controllers/user_session_controller.ex @@ -0,0 +1,42 @@ +defmodule NullaWeb.UserSessionController do + use NullaWeb, :controller + + alias Nulla.Accounts + alias NullaWeb.UserAuth + + def create(conn, %{"_action" => "registered"} = params) do + create(conn, params, "Account created successfully!") + end + + def create(conn, %{"_action" => "password_updated"} = params) do + conn + |> put_session(:user_return_to, ~p"/users/settings") + |> create(params, "Password updated successfully!") + end + + def create(conn, params) do + create(conn, params, "Welcome back!") + end + + defp create(conn, %{"user" => user_params}, info) do + %{"email" => email, "password" => password} = user_params + + if user = Accounts.get_user_by_email_and_password(email, password) do + conn + |> put_flash(:info, info) + |> UserAuth.log_in_user(user, user_params) + else + # In order to prevent user enumeration attacks, don't disclose whether the email is registered. + conn + |> put_flash(:error, "Invalid email or password") + |> put_flash(:email, String.slice(email, 0, 160)) + |> redirect(to: ~p"/users/log_in") + end + end + + def delete(conn, _params) do + conn + |> put_flash(:info, "Logged out successfully.") + |> UserAuth.log_out_user() + end +end diff --git a/lib/nulla_web/controllers/web/actor_controller.ex b/lib/nulla_web/controllers/web/actor_controller.ex deleted file mode 100644 index e42a775..0000000 --- a/lib/nulla_web/controllers/web/actor_controller.ex +++ /dev/null @@ -1,32 +0,0 @@ -defmodule NullaWeb.Web.ActorController do - use NullaWeb, :controller - alias Nulla.Models.Actor - alias Nulla.Models.Relation - alias Nulla.Models.Note - - def show(conn, %{"username" => username}) do - domain = NullaWeb.Endpoint.host() - - case Actor.get_actor(preferredUsername: username, domain: domain) do - nil -> - conn - |> put_status(:not_found) - |> json(%{error: "Not Found"}) - - %Actor{} = actor -> - following = Relation.count_following(actor.id) - followers = Relation.count_followers(actor.id) - notes = Note.get_latest_notes(actor.id) - - render( - conn, - :show, - actor: actor, - following: following, - followers: followers, - notes: notes, - layout: false - ) - end - end -end diff --git a/lib/nulla_web/controllers/web/auth_controller.ex b/lib/nulla_web/controllers/web/auth_controller.ex deleted file mode 100644 index 99e055c..0000000 --- a/lib/nulla_web/controllers/web/auth_controller.ex +++ /dev/null @@ -1,66 +0,0 @@ -defmodule NullaWeb.Web.AuthController do - use NullaWeb, :controller - alias Nulla.Models.User - alias Nulla.Models.Actor - alias Nulla.Models.InstanceSettings - - def sign_in(conn, %{"email" => email, "password" => password}) do - user = User.get_user(email: email) - - if user do - if Argon2.verify_pass(password, user.password) do - conn - |> put_session(:user_id, user.id) - |> redirect(to: ~p"/") - else - conn - |> put_flash(:error, "Invalid login or password.") - |> redirect(to: ~p"/auth/sign_in") - end - else - conn - |> put_flash(:error, "User not exist.") - |> redirect(to: ~p"/auth/sign_in") - end - end - - def sign_out(conn, _params) do - conn - |> configure_session(drop: true) - |> put_flash(:info, "You have been logged out.") - |> redirect(to: ~p"/") - end - - def sign_up(conn, %{"username" => username, "email" => email, "password" => password}) do - instance_settings = InstanceSettings.get_instance_settings!() - - if not instance_settings.registration do - conn - |> put_flash(:error, "Registration is disabled.") - |> redirect(to: ~p"/") - else - domain = NullaWeb.Endpoint.host() - hashed_password = Argon2.hash_pwd_salt(password) - - {publicKeyPem, privateKeyPem} = Nulla.KeyGen.gen() - - with {:ok, actor} <- Actor.create_actor_minimal(username, domain, publicKeyPem), - {:ok, user} <- - User.create_user(%{ - id: actor.id, - email: email, - password: hashed_password, - privateKeyPem: privateKeyPem, - last_active_at: DateTime.utc_now() - }) do - conn - |> put_session(:user_id, user.id) - |> put_flash(:info, "You're registred!") - |> redirect(to: ~p"/") - else - {:error, %Ecto.Changeset{} = changeset} -> - render(conn, "sign_up.html", changeset: changeset) - end - end - end -end diff --git a/lib/nulla_web/controllers/web/note_controller.ex b/lib/nulla_web/controllers/web/note_controller.ex deleted file mode 100644 index ceb13e5..0000000 --- a/lib/nulla_web/controllers/web/note_controller.ex +++ /dev/null @@ -1,44 +0,0 @@ -defmodule NullaWeb.Web.NoteController do - use NullaWeb, :controller - alias Nulla.Repo - alias Nulla.ActivityPub - alias Nulla.Models.Note - - def show(conn, %{"username" => username, "id" => id}) do - case Integer.parse(id) do - {int_id, ""} -> - note = Note.get_note(id: int_id) |> Repo.preload([:actor, :media_attachments]) - - cond do - is_nil(note) -> - conn - |> put_status(:not_found) - |> json(%{error: "Not Found"}) - |> halt() - - username != note.actor.preferredUsername -> - conn - |> put_status(:not_found) - |> json(%{error: "Not Found"}) - |> halt() - - true -> - format = Phoenix.Controller.get_format(conn) - - if format == "activity+json" do - conn - |> put_resp_content_type("application/activity+json") - |> json(ActivityPub.note(note)) - else - render(conn, :show, note: note, layout: false) - end - end - - _ -> - conn - |> put_status(:not_found) - |> json(%{error: "Not Found"}) - |> halt() - end - end -end diff --git a/lib/nulla_web/controllers/web/page_controller.ex b/lib/nulla_web/controllers/web/page_controller.ex deleted file mode 100644 index 3649c08..0000000 --- a/lib/nulla_web/controllers/web/page_controller.ex +++ /dev/null @@ -1,15 +0,0 @@ -defmodule NullaWeb.Web.PageController do - use NullaWeb, :controller - - def home(conn, _params) do - render(conn, :home, layout: false) - end - - def sign_in(conn, _params) do - render(conn, :sign_in, layout: false) - end - - def sign_up(conn, _params) do - render(conn, :sign_up, layout: false) - end -end diff --git a/lib/nulla_web/endpoint.ex b/lib/nulla_web/endpoint.ex index ca59adb..361acc5 100644 --- a/lib/nulla_web/endpoint.ex +++ b/lib/nulla_web/endpoint.ex @@ -7,7 +7,7 @@ defmodule NullaWeb.Endpoint do @session_options [ store: :cookie, key: "_nulla_key", - signing_salt: "/+24FZnt", + signing_salt: "FuDu07Pq", same_site: "Lax" ] diff --git a/lib/nulla_web/live/user_confirmation_instructions_live.ex b/lib/nulla_web/live/user_confirmation_instructions_live.ex new file mode 100644 index 0000000..8de0f24 --- /dev/null +++ b/lib/nulla_web/live/user_confirmation_instructions_live.ex @@ -0,0 +1,51 @@ +defmodule NullaWeb.UserConfirmationInstructionsLive do + use NullaWeb, :live_view + + alias Nulla.Accounts + + def render(assigns) do + ~H""" +
+ <.header class="text-center"> + No confirmation instructions received? + <:subtitle>We'll send a new confirmation link to your inbox + + + <.simple_form for={@form} id="resend_confirmation_form" phx-submit="send_instructions"> + <.input field={@form[:email]} type="email" placeholder="Email" required /> + <:actions> + <.button phx-disable-with="Sending..." class="w-full"> + Resend confirmation instructions + + + + +

+ <.link href={~p"/users/register"}>Register + | <.link href={~p"/users/log_in"}>Log in +

+
+ """ + end + + def mount(_params, _session, socket) do + {:ok, assign(socket, form: to_form(%{}, as: "user"))} + end + + def handle_event("send_instructions", %{"user" => %{"email" => email}}, socket) do + if user = Accounts.get_user_by_email(email) do + Accounts.deliver_user_confirmation_instructions( + user, + &url(~p"/users/confirm/#{&1}") + ) + end + + info = + "If your email is in our system and it has not been confirmed yet, you will receive an email with instructions shortly." + + {:noreply, + socket + |> put_flash(:info, info) + |> redirect(to: ~p"/")} + end +end diff --git a/lib/nulla_web/live/user_confirmation_live.ex b/lib/nulla_web/live/user_confirmation_live.ex new file mode 100644 index 0000000..0aabe24 --- /dev/null +++ b/lib/nulla_web/live/user_confirmation_live.ex @@ -0,0 +1,58 @@ +defmodule NullaWeb.UserConfirmationLive do + use NullaWeb, :live_view + + alias Nulla.Accounts + + def render(%{live_action: :edit} = assigns) do + ~H""" +
+ <.header class="text-center">Confirm Account + + <.simple_form for={@form} id="confirmation_form" phx-submit="confirm_account"> + + <:actions> + <.button phx-disable-with="Confirming..." class="w-full">Confirm my account + + + +

+ <.link href={~p"/users/register"}>Register + | <.link href={~p"/users/log_in"}>Log in +

+
+ """ + end + + def mount(%{"token" => token}, _session, socket) do + form = to_form(%{"token" => token}, as: "user") + {:ok, assign(socket, form: form), temporary_assigns: [form: nil]} + end + + # Do not log in the user after confirmation to avoid a + # leaked token giving the user access to the account. + def handle_event("confirm_account", %{"user" => %{"token" => token}}, socket) do + case Accounts.confirm_user(token) do + {:ok, _} -> + {:noreply, + socket + |> put_flash(:info, "User confirmed successfully.") + |> redirect(to: ~p"/")} + + :error -> + # If there is a current user and the account was already confirmed, + # then odds are that the confirmation link was already visited, either + # by some automation or by the user themselves, so we redirect without + # a warning message. + case socket.assigns do + %{current_user: %{confirmed_at: confirmed_at}} when not is_nil(confirmed_at) -> + {:noreply, redirect(socket, to: ~p"/")} + + %{} -> + {:noreply, + socket + |> put_flash(:error, "User confirmation link is invalid or it has expired.") + |> redirect(to: ~p"/")} + end + end + end +end diff --git a/lib/nulla_web/live/user_forgot_password_live.ex b/lib/nulla_web/live/user_forgot_password_live.ex new file mode 100644 index 0000000..21e3cfc --- /dev/null +++ b/lib/nulla_web/live/user_forgot_password_live.ex @@ -0,0 +1,50 @@ +defmodule NullaWeb.UserForgotPasswordLive do + use NullaWeb, :live_view + + alias Nulla.Accounts + + def render(assigns) do + ~H""" +
+ <.header class="text-center"> + Forgot your password? + <:subtitle>We'll send a password reset link to your inbox + + + <.simple_form for={@form} id="reset_password_form" phx-submit="send_email"> + <.input field={@form[:email]} type="email" placeholder="Email" required /> + <:actions> + <.button phx-disable-with="Sending..." class="w-full"> + Send password reset instructions + + + +

+ <.link href={~p"/users/register"}>Register + | <.link href={~p"/users/log_in"}>Log in +

+
+ """ + end + + def mount(_params, _session, socket) do + {:ok, assign(socket, form: to_form(%{}, as: "user"))} + end + + def handle_event("send_email", %{"user" => %{"email" => email}}, socket) do + if user = Accounts.get_user_by_email(email) do + Accounts.deliver_user_reset_password_instructions( + user, + &url(~p"/users/reset_password/#{&1}") + ) + end + + info = + "If your email is in our system, you will receive instructions to reset your password shortly." + + {:noreply, + socket + |> put_flash(:info, info) + |> redirect(to: ~p"/")} + end +end diff --git a/lib/nulla_web/live/user_login_live.ex b/lib/nulla_web/live/user_login_live.ex new file mode 100644 index 0000000..a005b1d --- /dev/null +++ b/lib/nulla_web/live/user_login_live.ex @@ -0,0 +1,43 @@ +defmodule NullaWeb.UserLoginLive do + use NullaWeb, :live_view + + def render(assigns) do + ~H""" +
+ <.header class="text-center"> + Log in to account + <:subtitle> + Don't have an account? + <.link navigate={~p"/users/register"} class="font-semibold text-brand hover:underline"> + Sign up + + for an account now. + + + + <.simple_form for={@form} id="login_form" action={~p"/users/log_in"} phx-update="ignore"> + <.input field={@form[:email]} type="email" label="Email" required /> + <.input field={@form[:password]} type="password" label="Password" required /> + + <:actions> + <.input field={@form[:remember_me]} type="checkbox" label="Keep me logged in" /> + <.link href={~p"/users/reset_password"} class="text-sm font-semibold"> + Forgot your password? + + + <:actions> + <.button phx-disable-with="Logging in..." class="w-full"> + Log in + + + +
+ """ + end + + def mount(_params, _session, socket) do + email = Phoenix.Flash.get(socket.assigns.flash, :email) + form = to_form(%{"email" => email}, as: "user") + {:ok, assign(socket, form: form), temporary_assigns: [form: form]} + end +end diff --git a/lib/nulla_web/live/user_registration_live.ex b/lib/nulla_web/live/user_registration_live.ex new file mode 100644 index 0000000..e35ad1f --- /dev/null +++ b/lib/nulla_web/live/user_registration_live.ex @@ -0,0 +1,87 @@ +defmodule NullaWeb.UserRegistrationLive do + use NullaWeb, :live_view + + alias Nulla.Accounts + alias Nulla.Accounts.User + + def render(assigns) do + ~H""" +
+ <.header class="text-center"> + Register for an account + <:subtitle> + Already registered? + <.link navigate={~p"/users/log_in"} class="font-semibold text-brand hover:underline"> + Log in + + to your account now. + + + + <.simple_form + for={@form} + id="registration_form" + phx-submit="save" + phx-change="validate" + phx-trigger-action={@trigger_submit} + action={~p"/users/log_in?_action=registered"} + method="post" + > + <.error :if={@check_errors}> + Oops, something went wrong! Please check the errors below. + + + <.input field={@form[:email]} type="email" label="Email" required /> + <.input field={@form[:password]} type="password" label="Password" required /> + + <:actions> + <.button phx-disable-with="Creating account..." class="w-full">Create an account + + +
+ """ + end + + def mount(_params, _session, socket) do + changeset = Accounts.change_user_registration(%User{}) + + socket = + socket + |> assign(trigger_submit: false, check_errors: false) + |> assign_form(changeset) + + {:ok, socket, temporary_assigns: [form: nil]} + end + + def handle_event("save", %{"user" => user_params}, socket) do + case Accounts.register_user(user_params) do + {:ok, user} -> + {:ok, _} = + Accounts.deliver_user_confirmation_instructions( + user, + &url(~p"/users/confirm/#{&1}") + ) + + changeset = Accounts.change_user_registration(user) + {:noreply, socket |> assign(trigger_submit: true) |> assign_form(changeset)} + + {:error, %Ecto.Changeset{} = changeset} -> + {:noreply, socket |> assign(check_errors: true) |> assign_form(changeset)} + end + end + + def handle_event("validate", %{"user" => user_params}, socket) do + changeset = Accounts.change_user_registration(%User{}, user_params) + {:noreply, assign_form(socket, Map.put(changeset, :action, :validate))} + end + + defp assign_form(socket, %Ecto.Changeset{} = changeset) do + form = to_form(changeset, as: "user") + + if changeset.valid? do + assign(socket, form: form, check_errors: false) + else + assign(socket, form: form) + end + end +end diff --git a/lib/nulla_web/live/user_reset_password_live.ex b/lib/nulla_web/live/user_reset_password_live.ex new file mode 100644 index 0000000..04ddbc9 --- /dev/null +++ b/lib/nulla_web/live/user_reset_password_live.ex @@ -0,0 +1,89 @@ +defmodule NullaWeb.UserResetPasswordLive do + use NullaWeb, :live_view + + alias Nulla.Accounts + + def render(assigns) do + ~H""" +
+ <.header class="text-center">Reset Password + + <.simple_form + for={@form} + id="reset_password_form" + phx-submit="reset_password" + phx-change="validate" + > + <.error :if={@form.errors != []}> + Oops, something went wrong! Please check the errors below. + + + <.input field={@form[:password]} type="password" label="New password" required /> + <.input + field={@form[:password_confirmation]} + type="password" + label="Confirm new password" + required + /> + <:actions> + <.button phx-disable-with="Resetting..." class="w-full">Reset Password + + + +

+ <.link href={~p"/users/register"}>Register + | <.link href={~p"/users/log_in"}>Log in +

+
+ """ + end + + def mount(params, _session, socket) do + socket = assign_user_and_token(socket, params) + + form_source = + case socket.assigns do + %{user: user} -> + Accounts.change_user_password(user) + + _ -> + %{} + end + + {:ok, assign_form(socket, form_source), temporary_assigns: [form: nil]} + end + + # Do not log in the user after reset password to avoid a + # leaked token giving the user access to the account. + def handle_event("reset_password", %{"user" => user_params}, socket) do + case Accounts.reset_user_password(socket.assigns.user, user_params) do + {:ok, _} -> + {:noreply, + socket + |> put_flash(:info, "Password reset successfully.") + |> redirect(to: ~p"/users/log_in")} + + {:error, changeset} -> + {:noreply, assign_form(socket, Map.put(changeset, :action, :insert))} + end + end + + def handle_event("validate", %{"user" => user_params}, socket) do + changeset = Accounts.change_user_password(socket.assigns.user, user_params) + {:noreply, assign_form(socket, Map.put(changeset, :action, :validate))} + end + + defp assign_user_and_token(socket, %{"token" => token}) do + if user = Accounts.get_user_by_reset_password_token(token) do + assign(socket, user: user, token: token) + else + socket + |> put_flash(:error, "Reset password link is invalid or it has expired.") + |> redirect(to: ~p"/") + end + end + + defp assign_form(socket, %{} = source) do + assign(socket, :form, to_form(source, as: "user")) + end +end diff --git a/lib/nulla_web/live/user_settings_live.ex b/lib/nulla_web/live/user_settings_live.ex new file mode 100644 index 0000000..8932554 --- /dev/null +++ b/lib/nulla_web/live/user_settings_live.ex @@ -0,0 +1,167 @@ +defmodule NullaWeb.UserSettingsLive do + use NullaWeb, :live_view + + alias Nulla.Accounts + + def render(assigns) do + ~H""" + <.header class="text-center"> + Account Settings + <:subtitle>Manage your account email address and password settings + + +
+
+ <.simple_form + for={@email_form} + id="email_form" + phx-submit="update_email" + phx-change="validate_email" + > + <.input field={@email_form[:email]} type="email" label="Email" required /> + <.input + field={@email_form[:current_password]} + name="current_password" + id="current_password_for_email" + type="password" + label="Current password" + value={@email_form_current_password} + required + /> + <:actions> + <.button phx-disable-with="Changing...">Change Email + + +
+
+ <.simple_form + for={@password_form} + id="password_form" + action={~p"/users/log_in?_action=password_updated"} + method="post" + phx-change="validate_password" + phx-submit="update_password" + phx-trigger-action={@trigger_submit} + > + + <.input field={@password_form[:password]} type="password" label="New password" required /> + <.input + field={@password_form[:password_confirmation]} + type="password" + label="Confirm new password" + /> + <.input + field={@password_form[:current_password]} + name="current_password" + type="password" + label="Current password" + id="current_password_for_password" + value={@current_password} + required + /> + <:actions> + <.button phx-disable-with="Changing...">Change Password + + +
+
+ """ + end + + def mount(%{"token" => token}, _session, socket) do + socket = + case Accounts.update_user_email(socket.assigns.current_user, token) do + :ok -> + put_flash(socket, :info, "Email changed successfully.") + + :error -> + put_flash(socket, :error, "Email change link is invalid or it has expired.") + end + + {:ok, push_navigate(socket, to: ~p"/users/settings")} + end + + def mount(_params, _session, socket) do + user = socket.assigns.current_user + email_changeset = Accounts.change_user_email(user) + password_changeset = Accounts.change_user_password(user) + + socket = + socket + |> assign(:current_password, nil) + |> assign(:email_form_current_password, nil) + |> assign(:current_email, user.email) + |> assign(:email_form, to_form(email_changeset)) + |> assign(:password_form, to_form(password_changeset)) + |> assign(:trigger_submit, false) + + {:ok, socket} + end + + def handle_event("validate_email", params, socket) do + %{"current_password" => password, "user" => user_params} = params + + email_form = + socket.assigns.current_user + |> Accounts.change_user_email(user_params) + |> Map.put(:action, :validate) + |> to_form() + + {:noreply, assign(socket, email_form: email_form, email_form_current_password: password)} + end + + def handle_event("update_email", params, socket) do + %{"current_password" => password, "user" => user_params} = params + user = socket.assigns.current_user + + case Accounts.apply_user_email(user, password, user_params) do + {:ok, applied_user} -> + Accounts.deliver_user_update_email_instructions( + applied_user, + user.email, + &url(~p"/users/settings/confirm_email/#{&1}") + ) + + info = "A link to confirm your email change has been sent to the new address." + {:noreply, socket |> put_flash(:info, info) |> assign(email_form_current_password: nil)} + + {:error, changeset} -> + {:noreply, assign(socket, :email_form, to_form(Map.put(changeset, :action, :insert)))} + end + end + + def handle_event("validate_password", params, socket) do + %{"current_password" => password, "user" => user_params} = params + + password_form = + socket.assigns.current_user + |> Accounts.change_user_password(user_params) + |> Map.put(:action, :validate) + |> to_form() + + {:noreply, assign(socket, password_form: password_form, current_password: password)} + end + + def handle_event("update_password", params, socket) do + %{"current_password" => password, "user" => user_params} = params + user = socket.assigns.current_user + + case Accounts.update_user_password(user, password, user_params) do + {:ok, user} -> + password_form = + user + |> Accounts.change_user_password(user_params) + |> to_form() + + {:noreply, assign(socket, trigger_submit: true, password_form: password_form)} + + {:error, changeset} -> + {:noreply, assign(socket, password_form: to_form(changeset))} + end + end +end diff --git a/lib/nulla_web/router.ex b/lib/nulla_web/router.ex index 150e99b..0693036 100644 --- a/lib/nulla_web/router.ex +++ b/lib/nulla_web/router.ex @@ -1,66 +1,37 @@ defmodule NullaWeb.Router do use NullaWeb, :router + import NullaWeb.UserAuth + pipeline :browser do - plug :accepts, ["html", "json", "activity+json"] + plug :accepts, ["html"] plug :fetch_session plug :fetch_live_flash plug :put_root_layout, html: {NullaWeb.Layouts, :root} plug :protect_from_forgery plug :put_secure_browser_headers + plug :fetch_current_user end pipeline :api do plug :accepts, ["json"] + plug :fetch_api_user end - pipeline :activitypub do - plug :accepts, ["activity+json"] - end - - scope "/", NullaWeb.Web, as: :web do + scope "/", NullaWeb do pipe_through :browser get "/", PageController, :home - - scope "/auth" do - post "/", AuthController, :sign_up - post "/sign_in", AuthController, :sign_in - delete "/sign_out", AuthController, :sign_out - get "/sign_up", PageController, :sign_up - get "/sign_in", PageController, :sign_in - end - - scope "/@:username" do - get "/", ActorController, :show - get "/:id", NoteController, :show - end end - scope "/api", NullaWeb.Api, as: :api do + scope "/api", NullaWeb do pipe_through :api - resources "/notes", NoteController - end - - scope "/", NullaWeb.ActivityPub, as: :activitypub do - pipe_through :activitypub - - get "/.well-known/host-meta", HostmetaController, :index - get "/.well-known/webfinger", WebfingerController, :index - get "/.well-known/nodeinfo", NodeinfoController, :index - get "/nodeinfo/2.0", NodeinfoController, :show - - post "/inbox", InboxController, :inbox - - scope "/users/:username" do - get "/", ActorController, :show - get "/following", FollowController, :following - get "/followers", FollowController, :followers - post "/inbox", InboxController, :inbox - get "/outbox", OutboxController, :outbox - get "/notes/:id", NoteController, :show - end + resources "/actors", ActorController, except: [:new, :edit] + resources "/notes", NoteController, except: [:new, :edit] + resources "/media_attachments", MediaAttachmentController, except: [:new, :edit] + resources "/relations", RelationController, except: [:new, :edit] + resources "/activities", ActivityController, except: [:new, :edit] end # Enable LiveDashboard and Swoosh mailbox preview in development @@ -79,4 +50,42 @@ defmodule NullaWeb.Router do forward "/mailbox", Plug.Swoosh.MailboxPreview end end + + ## Authentication routes + + scope "/", NullaWeb do + pipe_through [:browser, :redirect_if_user_is_authenticated] + + live_session :redirect_if_user_is_authenticated, + on_mount: [{NullaWeb.UserAuth, :redirect_if_user_is_authenticated}] do + live "/users/register", UserRegistrationLive, :new + live "/users/log_in", UserLoginLive, :new + live "/users/reset_password", UserForgotPasswordLive, :new + live "/users/reset_password/:token", UserResetPasswordLive, :edit + end + + post "/users/log_in", UserSessionController, :create + end + + scope "/", NullaWeb do + pipe_through [:browser, :require_authenticated_user] + + live_session :require_authenticated_user, + on_mount: [{NullaWeb.UserAuth, :ensure_authenticated}] do + live "/users/settings", UserSettingsLive, :edit + live "/users/settings/confirm_email/:token", UserSettingsLive, :confirm_email + end + end + + scope "/", NullaWeb do + pipe_through [:browser] + + delete "/users/log_out", UserSessionController, :delete + + live_session :current_user, + on_mount: [{NullaWeb.UserAuth, :mount_current_user}] do + live "/users/confirm/:token", UserConfirmationLive, :edit + live "/users/confirm", UserConfirmationInstructionsLive, :new + end + end end diff --git a/lib/nulla_web/user_auth.ex b/lib/nulla_web/user_auth.ex new file mode 100644 index 0000000..49913f7 --- /dev/null +++ b/lib/nulla_web/user_auth.ex @@ -0,0 +1,245 @@ +defmodule NullaWeb.UserAuth do + use NullaWeb, :verified_routes + + import Plug.Conn + import Phoenix.Controller + + alias Nulla.Accounts + + # Make the remember me cookie valid for 60 days. + # If you want bump or reduce this value, also change + # the token expiry itself in UserToken. + @max_age 60 * 60 * 24 * 60 + @remember_me_cookie "_nulla_web_user_remember_me" + @remember_me_options [sign: true, max_age: @max_age, same_site: "Lax"] + + @doc """ + Logs the user in. + + It renews the session ID and clears the whole session + to avoid fixation attacks. See the renew_session + function to customize this behaviour. + + It also sets a `:live_socket_id` key in the session, + so LiveView sessions are identified and automatically + disconnected on log out. The line can be safely removed + if you are not using LiveView. + """ + def log_in_user(conn, user, params \\ %{}) do + token = Accounts.generate_user_session_token(user) + user_return_to = get_session(conn, :user_return_to) + + conn + |> renew_session() + |> put_token_in_session(token) + |> maybe_write_remember_me_cookie(token, params) + |> redirect(to: user_return_to || signed_in_path(conn)) + end + + defp maybe_write_remember_me_cookie(conn, token, %{"remember_me" => "true"}) do + put_resp_cookie(conn, @remember_me_cookie, token, @remember_me_options) + end + + defp maybe_write_remember_me_cookie(conn, _token, _params) do + conn + end + + # This function renews the session ID and erases the whole + # session to avoid fixation attacks. If there is any data + # in the session you may want to preserve after log in/log out, + # you must explicitly fetch the session data before clearing + # and then immediately set it after clearing, for example: + # + # defp renew_session(conn) do + # preferred_locale = get_session(conn, :preferred_locale) + # + # conn + # |> configure_session(renew: true) + # |> clear_session() + # |> put_session(:preferred_locale, preferred_locale) + # end + # + defp renew_session(conn) do + delete_csrf_token() + + conn + |> configure_session(renew: true) + |> clear_session() + end + + @doc """ + Logs the user out. + + It clears all session data for safety. See renew_session. + """ + def log_out_user(conn) do + user_token = get_session(conn, :user_token) + user_token && Accounts.delete_user_session_token(user_token) + + if live_socket_id = get_session(conn, :live_socket_id) do + NullaWeb.Endpoint.broadcast(live_socket_id, "disconnect", %{}) + end + + conn + |> renew_session() + |> delete_resp_cookie(@remember_me_cookie) + |> redirect(to: ~p"/") + end + + @doc """ + Authenticates the user by looking into the session + and remember me token. + """ + def fetch_current_user(conn, _opts) do + {user_token, conn} = ensure_user_token(conn) + user = user_token && Accounts.get_user_by_session_token(user_token) + assign(conn, :current_user, user) + end + + defp ensure_user_token(conn) do + if token = get_session(conn, :user_token) do + {token, conn} + else + conn = fetch_cookies(conn, signed: [@remember_me_cookie]) + + if token = conn.cookies[@remember_me_cookie] do + {token, put_token_in_session(conn, token)} + else + {nil, conn} + end + end + end + + @doc """ + Handles mounting and authenticating the current_user in LiveViews. + + ## `on_mount` arguments + + * `:mount_current_user` - Assigns current_user + to socket assigns based on user_token, or nil if + there's no user_token or no matching user. + + * `:ensure_authenticated` - Authenticates the user from the session, + and assigns the current_user to socket assigns based + on user_token. + Redirects to login page if there's no logged user. + + * `:redirect_if_user_is_authenticated` - Authenticates the user from the session. + Redirects to signed_in_path if there's a logged user. + + ## Examples + + Use the `on_mount` lifecycle macro in LiveViews to mount or authenticate + the current_user: + + defmodule NullaWeb.PageLive do + use NullaWeb, :live_view + + on_mount {NullaWeb.UserAuth, :mount_current_user} + ... + end + + Or use the `live_session` of your router to invoke the on_mount callback: + + live_session :authenticated, on_mount: [{NullaWeb.UserAuth, :ensure_authenticated}] do + live "/profile", ProfileLive, :index + end + """ + def on_mount(:mount_current_user, _params, session, socket) do + {:cont, mount_current_user(socket, session)} + end + + def on_mount(:ensure_authenticated, _params, session, socket) do + socket = mount_current_user(socket, session) + + if socket.assigns.current_user do + {:cont, socket} + else + socket = + socket + |> Phoenix.LiveView.put_flash(:error, "You must log in to access this page.") + |> Phoenix.LiveView.redirect(to: ~p"/users/log_in") + + {:halt, socket} + end + end + + def on_mount(:redirect_if_user_is_authenticated, _params, session, socket) do + socket = mount_current_user(socket, session) + + if socket.assigns.current_user do + {:halt, Phoenix.LiveView.redirect(socket, to: signed_in_path(socket))} + else + {:cont, socket} + end + end + + defp mount_current_user(socket, session) do + Phoenix.Component.assign_new(socket, :current_user, fn -> + if user_token = session["user_token"] do + Accounts.get_user_by_session_token(user_token) + end + end) + end + + @doc """ + Used for routes that require the user to not be authenticated. + """ + def redirect_if_user_is_authenticated(conn, _opts) do + if conn.assigns[:current_user] do + conn + |> redirect(to: signed_in_path(conn)) + |> halt() + else + conn + end + end + + @doc """ + Used for routes that require the user to be authenticated. + + If you want to enforce the user email is confirmed before + they use the application at all, here would be a good place. + """ + def require_authenticated_user(conn, _opts) do + if conn.assigns[:current_user] do + conn + else + conn + |> put_flash(:error, "You must log in to access this page.") + |> maybe_store_return_to() + |> redirect(to: ~p"/users/log_in") + |> halt() + end + end + + def fetch_api_user(conn, _opts) do + if Mix.env() != :test do + with ["Bearer " <> token] <- get_req_header(conn, "authorization"), + {:ok, user} <- Accounts.fetch_user_by_api_token(token) do + assign(conn, :current_user, user) + else + _ -> + conn + |> send_resp(:unauthorized, "No access for you") + |> halt() + end + else + conn + end + end + + defp put_token_in_session(conn, token) do + conn + |> put_session(:user_token, token) + |> put_session(:live_socket_id, "users_sessions:#{Base.url_encode64(token)}") + end + + defp maybe_store_return_to(%{method: "GET"} = conn) do + put_session(conn, :user_return_to, current_path(conn)) + end + + defp maybe_store_return_to(conn), do: conn + + defp signed_in_path(_conn), do: ~p"/" +end diff --git a/mix.exs b/mix.exs index c51a18a..5343832 100644 --- a/mix.exs +++ b/mix.exs @@ -32,6 +32,7 @@ defmodule Nulla.MixProject do # Type `mix help deps` for examples and options. defp deps do [ + {:bcrypt_elixir, "~> 3.0"}, {:phoenix, "~> 1.7.21"}, {:phoenix_ecto, "~> 4.5"}, {:ecto_sql, "~> 3.10"}, @@ -57,8 +58,7 @@ defmodule Nulla.MixProject do {:gettext, "~> 0.26"}, {:jason, "~> 1.2"}, {:dns_cluster, "~> 0.1.1"}, - {:bandit, "~> 1.5"}, - {:argon2_elixir, "~> 4.1"} + {:bandit, "~> 1.5"} ] end diff --git a/mix.lock b/mix.lock index 92840a8..a852d05 100644 --- a/mix.lock +++ b/mix.lock @@ -1,13 +1,13 @@ %{ - "argon2_elixir": {:hex, :argon2_elixir, "4.1.3", "4f28318286f89453364d7fbb53e03d4563fd7ed2438a60237eba5e426e97785f", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "7c295b8d8e0eaf6f43641698f962526cdf87c6feb7d14bd21e599271b510608c"}, "bandit": {:hex, :bandit, "1.7.0", "d1564f30553c97d3e25f9623144bb8df11f3787a26733f00b21699a128105c0c", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "3e2f7a98c7a11f48d9d8c037f7177cd39778e74d55c7af06fe6227c742a8168a"}, + "bcrypt_elixir": {:hex, :bcrypt_elixir, "3.3.2", "d50091e3c9492d73e17fc1e1619a9b09d6a5ef99160eb4d736926fd475a16ca3", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "471be5151874ae7931911057d1467d908955f93554f7a6cd1b7d804cac8cef53"}, "castore": {:hex, :castore, "1.0.14", "4582dd7d630b48cf5e1ca8d3d42494db51e406b7ba704e81fbd401866366896a", [:mix], [], "hexpm", "7bc1b65249d31701393edaaac18ec8398d8974d52c647b7904d01b964137b9f4"}, "comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"}, - "db_connection": {:hex, :db_connection, "2.7.0", "b99faa9291bb09892c7da373bb82cba59aefa9b36300f6145c5f201c7adf48ec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "dcf08f31b2701f857dfc787fbad78223d61a32204f217f15e881dd93e4bdd3ff"}, + "db_connection": {:hex, :db_connection, "2.8.0", "64fd82cfa6d8e25ec6660cea73e92a4cbc6a18b31343910427b702838c4b33b2", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "008399dae5eee1bf5caa6e86d204dcb44242c82b1ed5e22c881f2c34da201b15"}, "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, "dns_cluster": {:hex, :dns_cluster, "0.1.3", "0bc20a2c88ed6cc494f2964075c359f8c2d00e1bf25518a6a6c7fd277c9b0c66", [:mix], [], "hexpm", "46cb7c4a1b3e52c7ad4cbe33ca5079fbde4840dedeafca2baf77996c2da1bc33"}, - "ecto": {:hex, :ecto, "3.13.1", "ebb11c2f0307ff62e8aaba57def59ad920a3cbd89d002b1118944cbf598c13c7", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d9ea5075a6f3af9cd2cdbabe8a0759eb73b485e981fd7c03014f79479ac85340"}, - "ecto_sql": {:hex, :ecto_sql, "3.13.1", "d3d76d78afd2757644b5c4f7ca37f90bcf1e05d05a06cca8526e30cefb0034a1", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "5fba0174284fd339f69376b0405942036ce5f0ff7d59402a6ccf3b7ce2903198"}, + "ecto": {:hex, :ecto, "3.13.2", "7d0c0863f3fc8d71d17fc3ad3b9424beae13f02712ad84191a826c7169484f01", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "669d9291370513ff56e7b7e7081b7af3283d02e046cf3d403053c557894a0b3e"}, + "ecto_sql": {:hex, :ecto_sql, "3.13.2", "a07d2461d84107b3d037097c822ffdd36ed69d1cf7c0f70e12a3d1decf04e2e1", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "539274ab0ecf1a0078a6a72ef3465629e4d6018a3028095dc90f60a19c371717"}, "elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"}, "esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"}, "expo": {:hex, :expo, "1.1.0", "f7b9ed7fb5745ebe1eeedf3d6f29226c5dd52897ac67c0f8af62a07e661e5c75", [:mix], [], "hexpm", "fbadf93f4700fb44c331362177bdca9eeb8097e8b0ef525c9cc501cb9917c960"}, @@ -23,7 +23,7 @@ "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, "phoenix": {:hex, :phoenix, "1.7.21", "14ca4f1071a5f65121217d6b57ac5712d1857e40a0833aff7a691b7870fc9a3b", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "336dce4f86cba56fed312a7d280bf2282c720abb6074bdb1b61ec8095bdd0bc9"}, - "phoenix_ecto": {:hex, :phoenix_ecto, "4.6.4", "dcf3483ab45bab4c15e3a47c34451392f64e433846b08469f5d16c2a4cd70052", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "f5b8584c36ccc9b903948a696fc9b8b81102c79c7c0c751a9f00cdec55d5f2d7"}, + "phoenix_ecto": {:hex, :phoenix_ecto, "4.6.5", "c4ef322acd15a574a8b1a08eff0ee0a85e73096b53ce1403b6563709f15e1cea", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "26ec3208eef407f31b748cadd044045c6fd485fbff168e35963d2f9dfff28d4b"}, "phoenix_html": {:hex, :phoenix_html, "4.2.1", "35279e2a39140068fc03f8874408d58eef734e488fc142153f055c5454fd1c08", [:mix], [], "hexpm", "cff108100ae2715dd959ae8f2a8cef8e20b593f8dfd031c9cba92702cf23e053"}, "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.7", "405880012cb4b706f26dd1c6349125bfc903fb9e44d1ea668adaf4e04d4884b7", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "3a8625cab39ec261d48a13b7468dc619c0ede099601b084e343968309bd4d7d7"}, "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.6.0", "2791fac0e2776b640192308cc90c0dbcf67843ad51387ed4ecae2038263d708d", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "b3a1fa036d7eb2f956774eda7a7638cf5123f8f2175aca6d6420a7f95e598e1c"}, diff --git a/priv/repo/migrations/20250527054942_create_instance_settings.exs b/priv/repo/migrations/20250527054942_create_instance_settings.exs deleted file mode 100644 index 4a132be..0000000 --- a/priv/repo/migrations/20250527054942_create_instance_settings.exs +++ /dev/null @@ -1,45 +0,0 @@ -defmodule Nulla.Repo.Migrations.CreateInstanceSettings do - use Ecto.Migration - - def change do - create table(:instance_settings, primary_key: false) do - add :id, :integer, primary_key: true - add :name, :string, default: "Nulla", null: false - add :description, :text, default: "Freedom Social Network", null: false - add :registration, :boolean, default: false, null: false - add :max_characters, :integer, default: 5000, null: false - add :max_upload_size, :integer, default: 50, null: false - add :api_limit, :integer, default: 100, null: false - add :public_key, :text - add :private_key, :text - - timestamps() - end - - execute "ALTER TABLE instance_settings ADD CONSTRAINT single_row CHECK (id = 1);" - - flush() - - execute(fn -> - {public_key, private_key} = Nulla.KeyGen.gen() - now = DateTime.utc_now() - - esc = fn str -> "'#{String.replace(str, "'", "''")}'" end - - sql = """ - INSERT INTO instance_settings ( - id, name, description, registration, - max_characters, max_upload_size, api_limit, - public_key, private_key, inserted_at, updated_at - ) VALUES ( - 1, 'Nulla', 'Freedom Social Network', false, - 5000, 50, 100, - #{esc.(public_key)}, #{esc.(private_key)}, - '#{now}', '#{now}' - ) - """ - - Ecto.Adapters.SQL.query!(Nulla.Repo, sql, []) - end) - end -end diff --git a/priv/repo/migrations/20250606103649_create_hashtags.exs b/priv/repo/migrations/20250606103649_create_hashtags.exs deleted file mode 100644 index ac94cb2..0000000 --- a/priv/repo/migrations/20250606103649_create_hashtags.exs +++ /dev/null @@ -1,15 +0,0 @@ -defmodule Nulla.Repo.Migrations.CreateHashtags do - use Ecto.Migration - - def change do - create table(:hashtags, primary_key: false) do - add :id, :bigint, primary_key: true - add :tag, :string, null: false - add :usage_count, :integer, default: 0, null: false - - timestamps() - end - - create unique_index(:hashtags, [:tag]) - end -end diff --git a/priv/repo/migrations/20250615131158_create_users.exs b/priv/repo/migrations/20250615131158_create_users.exs deleted file mode 100644 index 9ee5345..0000000 --- a/priv/repo/migrations/20250615131158_create_users.exs +++ /dev/null @@ -1,15 +0,0 @@ -defmodule Nulla.Repo.Migrations.CreateUsers do - use Ecto.Migration - - def change do - create table(:users, primary_key: false) do - add :id, :bigint, primary_key: true - add :email, :string - add :password, :string - add :privateKeyPem, :text - add :last_active_at, :utc_datetime - - timestamps(type: :utc_datetime) - end - end -end diff --git a/priv/repo/migrations/20250615131534_create_moderations_logs.exs b/priv/repo/migrations/20250615131534_create_moderations_logs.exs deleted file mode 100644 index 546f88e..0000000 --- a/priv/repo/migrations/20250615131534_create_moderations_logs.exs +++ /dev/null @@ -1,20 +0,0 @@ -defmodule Nulla.Repo.Migrations.CreateModerationLogs do - use Ecto.Migration - - def change do - create table(:moderation_logs, primary_key: false) do - add :id, :bigint, primary_key: true - add :moderator_id, references(:users, on_delete: :nilify_all), null: false - add :target_type, :string, null: false - add :target_id, :string, null: false - add :action, :string, null: false - add :reason, :text - add :metadata, :map - - timestamps() - end - - create index(:moderation_logs, [:moderator_id]) - create index(:moderation_logs, [:target_type, :target_id]) - end -end diff --git a/priv/repo/migrations/20250615131610_create_sessions.exs b/priv/repo/migrations/20250615131610_create_sessions.exs deleted file mode 100644 index 3e0e9e2..0000000 --- a/priv/repo/migrations/20250615131610_create_sessions.exs +++ /dev/null @@ -1,18 +0,0 @@ -defmodule Nulla.Repo.Migrations.CreateSessions do - use Ecto.Migration - - def change do - create table(:sessions, primary_key: false) do - add :id, :bigint, primary_key: true - add :user_id, references(:users, on_delete: :delete_all), null: false - add :token, :string, null: false - add :user_agent, :string - add :ip, :string - - timestamps(type: :utc_datetime) - end - - create index(:sessions, [:user_id]) - create unique_index(:sessions, [:token]) - end -end diff --git a/priv/repo/migrations/20250615131800_create_bookmarks.exs b/priv/repo/migrations/20250615131800_create_bookmarks.exs deleted file mode 100644 index 5ddb447..0000000 --- a/priv/repo/migrations/20250615131800_create_bookmarks.exs +++ /dev/null @@ -1,15 +0,0 @@ -defmodule Nulla.Repo.Migrations.CreateBookmarks do - use Ecto.Migration - - def change do - create table(:bookmarks, primary_key: false) do - add :id, :bigint, primary_key: true - add :url, :string - add :user_id, references(:users, on_delete: :delete_all) - - timestamps(type: :utc_datetime) - end - - create index(:bookmarks, [:user_id]) - end -end diff --git a/priv/repo/migrations/20250615132202_create_notifications.exs b/priv/repo/migrations/20250615132202_create_notifications.exs deleted file mode 100644 index 34e3204..0000000 --- a/priv/repo/migrations/20250615132202_create_notifications.exs +++ /dev/null @@ -1,19 +0,0 @@ -defmodule Nulla.Repo.Migrations.CreateNotifications do - use Ecto.Migration - - def change do - create table(:notifications, primary_key: false) do - add :id, :bigint, primary_key: true - add :user_id, references(:users, on_delete: :delete_all), null: false - add :actor_id, references(:actors, on_delete: :nilify_all) - add :type, :string, null: false - add :data, :map - add :read, :boolean, default: false, null: false - - timestamps() - end - - create index(:notifications, [:user_id]) - create index(:notifications, [:actor_id]) - end -end diff --git a/priv/repo/migrations/20250617091354_create_relations.exs b/priv/repo/migrations/20250617091354_create_relations.exs deleted file mode 100644 index 55b7012..0000000 --- a/priv/repo/migrations/20250617091354_create_relations.exs +++ /dev/null @@ -1,31 +0,0 @@ -defmodule Nulla.Repo.Migrations.CreateActorRelations do - use Ecto.Migration - - def change do - create table(:relations, primary_key: false) do - add :id, :bigint, primary_key: true - add :following, :boolean, null: false, default: false - add :followed_by, :boolean, null: false, default: false - add :showing_replies, :boolean, null: false, default: true - add :showing_reblogs, :boolean, null: false, default: true - add :notifying, :boolean, null: false, default: false - add :muting, :boolean, null: false, default: false - add :muting_notifications, :boolean, null: false, default: false - add :blocking, :boolean, null: false, default: false - add :blocked_by, :boolean, null: false, default: false - add :domain_blocking, :boolean, null: false, default: false - add :requested, :boolean, null: false, default: false - add :note, :string - - add :local_actor_id, references(:actors, type: :bigint), null: false - add :remote_actor_id, references(:actors, type: :bigint), null: false - - timestamps() - end - - create index(:relations, [:local_actor_id]) - create index(:relations, [:remote_actor_id]) - - create unique_index(:relations, [:local_actor_id, :remote_actor_id]) - end -end diff --git a/priv/repo/migrations/20250701093122_create_users_auth_tables.exs b/priv/repo/migrations/20250701093122_create_users_auth_tables.exs new file mode 100644 index 0000000..7068f76 --- /dev/null +++ b/priv/repo/migrations/20250701093122_create_users_auth_tables.exs @@ -0,0 +1,30 @@ +defmodule Nulla.Repo.Migrations.CreateUsersAuthTables do + use Ecto.Migration + + def change do + execute "CREATE EXTENSION IF NOT EXISTS citext", "" + + create table(:users, primary_key: false) do + add :id, :bigint, primary_key: true + add :email, :citext, null: false + add :hashed_password, :string, null: false + add :confirmed_at, :utc_datetime + + timestamps(type: :utc_datetime) + end + + create unique_index(:users, [:email]) + + create table(:users_tokens) do + add :user_id, references(:users, on_delete: :delete_all), null: false + add :token, :binary, null: false + add :context, :string, null: false + add :sent_to, :string + + timestamps(type: :utc_datetime, updated_at: false) + end + + create index(:users_tokens, [:user_id]) + create unique_index(:users_tokens, [:context, :token]) + end +end diff --git a/priv/repo/migrations/20250615130714_create_actors.exs b/priv/repo/migrations/20250701133126_create_actors.exs similarity index 50% rename from priv/repo/migrations/20250615130714_create_actors.exs rename to priv/repo/migrations/20250701133126_create_actors.exs index bb403fd..4dfe0ea 100644 --- a/priv/repo/migrations/20250615130714_create_actors.exs +++ b/priv/repo/migrations/20250701133126_create_actors.exs @@ -4,25 +4,26 @@ defmodule Nulla.Repo.Migrations.CreateActors do def change do create table(:actors, primary_key: false) do add :id, :bigint, primary_key: true - add :domain, :string - add :ap_id, :string, null: false - add :type, :string, null: false - add :following, :string, null: false - add :followers, :string, null: false - add :inbox, :string, null: false - add :outbox, :string, null: false + add :acct, :string + add :ap_id, :string + add :type, :string + add :following, :string + add :followers, :string + add :inbox, :string + add :outbox, :string add :featured, :string add :featuredTags, :string - add :preferredUsername, :string, null: false + add :preferredUsername, :string add :name, :string - add :summary, :text + add :summary, :string add :url, :string - add :manuallyApprovesFollowers, :boolean - add :discoverable, :boolean, default: true - add :indexable, :boolean, default: true + add :manuallyApprovesFollowers, :boolean, default: false, null: false + add :discoverable, :boolean, default: true, null: false + add :indexable, :boolean, default: true, null: false add :published, :utc_datetime - add :memorial, :boolean, default: false + add :memorial, :boolean, default: false, null: false add :publicKey, :map + add :privateKeyPem, :text add :tag, {:array, :map}, default: [] add :attachment, {:array, :map}, default: [] add :endpoints, :map @@ -30,9 +31,11 @@ defmodule Nulla.Repo.Migrations.CreateActors do add :image, :map add :vcard_bday, :date add :vcard_Address, :string + + timestamps(type: :utc_datetime) end - create unique_index(:actors, [:preferredUsername, :domain]) + create unique_index(:actors, [:acct]) create unique_index(:actors, [:ap_id]) end end diff --git a/priv/repo/migrations/20250615131431_create_notes.exs b/priv/repo/migrations/20250702091405_create_notes.exs similarity index 86% rename from priv/repo/migrations/20250615131431_create_notes.exs rename to priv/repo/migrations/20250702091405_create_notes.exs index 2513289..6f65471 100644 --- a/priv/repo/migrations/20250615131431_create_notes.exs +++ b/priv/repo/migrations/20250702091405_create_notes.exs @@ -10,8 +10,8 @@ defmodule Nulla.Repo.Migrations.CreateNotes do add :visibility, :string add :to, {:array, :string} add :cc, {:array, :string} - add :sensitive, :boolean, default: false - add :content, :text + add :sensitive, :boolean, default: false, null: false + add :content, :string add :language, :string add :actor_id, references(:actors, on_delete: :delete_all) diff --git a/priv/repo/migrations/20250615131644_create_media_attachments.exs b/priv/repo/migrations/20250702091750_create_media_attachments.exs similarity index 70% rename from priv/repo/migrations/20250615131644_create_media_attachments.exs rename to priv/repo/migrations/20250702091750_create_media_attachments.exs index efacd89..44e69c6 100644 --- a/priv/repo/migrations/20250615131644_create_media_attachments.exs +++ b/priv/repo/migrations/20250702091750_create_media_attachments.exs @@ -4,9 +4,9 @@ defmodule Nulla.Repo.Migrations.CreateMediaAttachments do def change do create table(:media_attachments, primary_key: false) do add :id, :bigint, primary_key: true - add :type, :string, null: false - add :mediaType, :string, null: false - add :url, :string, null: false + add :type, :string + add :mediaType, :string + add :url, :string add :name, :string add :width, :integer add :height, :integer @@ -14,7 +14,5 @@ defmodule Nulla.Repo.Migrations.CreateMediaAttachments do timestamps(type: :utc_datetime) end - - create index(:media_attachments, [:note_id]) end end diff --git a/priv/repo/migrations/20250615131856_create_activities.exs b/priv/repo/migrations/20250702151805_create_activities.exs similarity index 69% rename from priv/repo/migrations/20250615131856_create_activities.exs rename to priv/repo/migrations/20250702151805_create_activities.exs index b21c662..67c7253 100644 --- a/priv/repo/migrations/20250615131856_create_activities.exs +++ b/priv/repo/migrations/20250702151805_create_activities.exs @@ -4,14 +4,14 @@ defmodule Nulla.Repo.Migrations.CreateActivities do def change do create table(:activities, primary_key: false) do add :id, :bigint, primary_key: true - add :ap_id, :string, null: false - add :type, :string, null: false - add :actor, :string, null: false - add :object, :text, null: false + add :ap_id, :string + add :type, :string + add :actor, :string + add :object, :string add :to, {:array, :string} add :cc, {:array, :string} - timestamps() + timestamps(type: :utc_datetime) end create index(:activities, [:ap_id]) diff --git a/priv/repo/migrations/20250702152953_create_relations.exs b/priv/repo/migrations/20250702152953_create_relations.exs new file mode 100644 index 0000000..a8d7caf --- /dev/null +++ b/priv/repo/migrations/20250702152953_create_relations.exs @@ -0,0 +1,30 @@ +defmodule Nulla.Repo.Migrations.CreateRelations do + use Ecto.Migration + + def change do + create table(:relations) do + add :following, :boolean, default: false, null: false + add :followed_by, :boolean, default: false, null: false + add :showing_replies, :boolean, default: false, null: false + add :showings_reblogs, :boolean, default: false, null: false + add :notifying, :boolean, default: false, null: false + add :muting, :boolean, default: false, null: false + add :muting_notifications, :boolean, default: false, null: false + add :blocking, :boolean, default: false, null: false + add :blocked_by, :boolean, default: false, null: false + add :domain_blocking, :boolean, default: false, null: false + add :requested, :boolean, default: false, null: false + add :note, :string + + add :local_actor_id, references(:actors, type: :bigint), null: false + add :remote_actor_id, references(:actors, type: :bigint), null: false + + timestamps(type: :utc_datetime) + end + + create index(:relations, [:local_actor_id]) + create index(:relations, [:remote_actor_id]) + + create unique_index(:relations, [:local_actor_id, :remote_actor_id]) + end +end diff --git a/priv/static/favicon.ico b/priv/static/favicon.ico index 145cc29..7f372bf 100644 Binary files a/priv/static/favicon.ico and b/priv/static/favicon.ico differ diff --git a/priv/static/images/default-avatar.jpg b/priv/static/images/default-avatar.jpg deleted file mode 100644 index 3113c7f..0000000 Binary files a/priv/static/images/default-avatar.jpg and /dev/null differ diff --git a/priv/static/images/default-header.jpg b/priv/static/images/default-header.jpg deleted file mode 100644 index 4176386..0000000 Binary files a/priv/static/images/default-header.jpg and /dev/null differ diff --git a/test/nulla/accounts_test.exs b/test/nulla/accounts_test.exs new file mode 100644 index 0000000..a5c5724 --- /dev/null +++ b/test/nulla/accounts_test.exs @@ -0,0 +1,508 @@ +defmodule Nulla.AccountsTest do + use Nulla.DataCase + + alias Nulla.Accounts + + import Nulla.AccountsFixtures + alias Nulla.Accounts.{User, UserToken} + + describe "get_user_by_email/1" do + test "does not return the user if the email does not exist" do + refute Accounts.get_user_by_email("unknown@example.com") + end + + test "returns the user if the email exists" do + %{id: id} = user = user_fixture() + assert %User{id: ^id} = Accounts.get_user_by_email(user.email) + end + end + + describe "get_user_by_email_and_password/2" do + test "does not return the user if the email does not exist" do + refute Accounts.get_user_by_email_and_password("unknown@example.com", "hello world!") + end + + test "does not return the user if the password is not valid" do + user = user_fixture() + refute Accounts.get_user_by_email_and_password(user.email, "invalid") + end + + test "returns the user if the email and password are valid" do + %{id: id} = user = user_fixture() + + assert %User{id: ^id} = + Accounts.get_user_by_email_and_password(user.email, valid_user_password()) + end + end + + describe "get_user!/1" do + test "raises if id is invalid" do + assert_raise Ecto.NoResultsError, fn -> + Accounts.get_user!(-1) + end + end + + test "returns the user with the given id" do + %{id: id} = user = user_fixture() + assert %User{id: ^id} = Accounts.get_user!(user.id) + end + end + + describe "register_user/1" do + test "requires email and password to be set" do + {:error, changeset} = Accounts.register_user(%{}) + + assert %{ + password: ["can't be blank"], + email: ["can't be blank"] + } = errors_on(changeset) + end + + test "validates email and password when given" do + {:error, changeset} = Accounts.register_user(%{email: "not valid", password: "not valid"}) + + assert %{ + email: ["must have the @ sign and no spaces"], + password: ["should be at least 12 character(s)"] + } = errors_on(changeset) + end + + test "validates maximum values for email and password for security" do + too_long = String.duplicate("db", 100) + {:error, changeset} = Accounts.register_user(%{email: too_long, password: too_long}) + assert "should be at most 160 character(s)" in errors_on(changeset).email + assert "should be at most 72 character(s)" in errors_on(changeset).password + end + + test "validates email uniqueness" do + %{email: email} = user_fixture() + {:error, changeset} = Accounts.register_user(%{email: email}) + assert "has already been taken" in errors_on(changeset).email + + # Now try with the upper cased email too, to check that email case is ignored. + {:error, changeset} = Accounts.register_user(%{email: String.upcase(email)}) + assert "has already been taken" in errors_on(changeset).email + end + + test "registers users with a hashed password" do + email = unique_user_email() + {:ok, user} = Accounts.register_user(valid_user_attributes(email: email)) + assert user.email == email + assert is_binary(user.hashed_password) + assert is_nil(user.confirmed_at) + assert is_nil(user.password) + end + end + + describe "change_user_registration/2" do + test "returns a changeset" do + assert %Ecto.Changeset{} = changeset = Accounts.change_user_registration(%User{}) + assert changeset.required == [:password, :email] + end + + test "allows fields to be set" do + email = unique_user_email() + password = valid_user_password() + + changeset = + Accounts.change_user_registration( + %User{}, + valid_user_attributes(email: email, password: password) + ) + + assert changeset.valid? + assert get_change(changeset, :email) == email + assert get_change(changeset, :password) == password + assert is_nil(get_change(changeset, :hashed_password)) + end + end + + describe "change_user_email/2" do + test "returns a user changeset" do + assert %Ecto.Changeset{} = changeset = Accounts.change_user_email(%User{}) + assert changeset.required == [:email] + end + end + + describe "apply_user_email/3" do + setup do + %{user: user_fixture()} + end + + test "requires email to change", %{user: user} do + {:error, changeset} = Accounts.apply_user_email(user, valid_user_password(), %{}) + assert %{email: ["did not change"]} = errors_on(changeset) + end + + test "validates email", %{user: user} do + {:error, changeset} = + Accounts.apply_user_email(user, valid_user_password(), %{email: "not valid"}) + + assert %{email: ["must have the @ sign and no spaces"]} = errors_on(changeset) + end + + test "validates maximum value for email for security", %{user: user} do + too_long = String.duplicate("db", 100) + + {:error, changeset} = + Accounts.apply_user_email(user, valid_user_password(), %{email: too_long}) + + assert "should be at most 160 character(s)" in errors_on(changeset).email + end + + test "validates email uniqueness", %{user: user} do + %{email: email} = user_fixture() + password = valid_user_password() + + {:error, changeset} = Accounts.apply_user_email(user, password, %{email: email}) + + assert "has already been taken" in errors_on(changeset).email + end + + test "validates current password", %{user: user} do + {:error, changeset} = + Accounts.apply_user_email(user, "invalid", %{email: unique_user_email()}) + + assert %{current_password: ["is not valid"]} = errors_on(changeset) + end + + test "applies the email without persisting it", %{user: user} do + email = unique_user_email() + {:ok, user} = Accounts.apply_user_email(user, valid_user_password(), %{email: email}) + assert user.email == email + assert Accounts.get_user!(user.id).email != email + end + end + + describe "deliver_user_update_email_instructions/3" do + setup do + %{user: user_fixture()} + end + + test "sends token through notification", %{user: user} do + token = + extract_user_token(fn url -> + Accounts.deliver_user_update_email_instructions(user, "current@example.com", url) + end) + + {:ok, token} = Base.url_decode64(token, padding: false) + assert user_token = Repo.get_by(UserToken, token: :crypto.hash(:sha256, token)) + assert user_token.user_id == user.id + assert user_token.sent_to == user.email + assert user_token.context == "change:current@example.com" + end + end + + describe "update_user_email/2" do + setup do + user = user_fixture() + email = unique_user_email() + + token = + extract_user_token(fn url -> + Accounts.deliver_user_update_email_instructions(%{user | email: email}, user.email, url) + end) + + %{user: user, token: token, email: email} + end + + test "updates the email with a valid token", %{user: user, token: token, email: email} do + assert Accounts.update_user_email(user, token) == :ok + changed_user = Repo.get!(User, user.id) + assert changed_user.email != user.email + assert changed_user.email == email + assert changed_user.confirmed_at + assert changed_user.confirmed_at != user.confirmed_at + refute Repo.get_by(UserToken, user_id: user.id) + end + + test "does not update email with invalid token", %{user: user} do + assert Accounts.update_user_email(user, "oops") == :error + assert Repo.get!(User, user.id).email == user.email + assert Repo.get_by(UserToken, user_id: user.id) + end + + test "does not update email if user email changed", %{user: user, token: token} do + assert Accounts.update_user_email(%{user | email: "current@example.com"}, token) == :error + assert Repo.get!(User, user.id).email == user.email + assert Repo.get_by(UserToken, user_id: user.id) + end + + test "does not update email if token expired", %{user: user, token: token} do + {1, nil} = Repo.update_all(UserToken, set: [inserted_at: ~N[2020-01-01 00:00:00]]) + assert Accounts.update_user_email(user, token) == :error + assert Repo.get!(User, user.id).email == user.email + assert Repo.get_by(UserToken, user_id: user.id) + end + end + + describe "change_user_password/2" do + test "returns a user changeset" do + assert %Ecto.Changeset{} = changeset = Accounts.change_user_password(%User{}) + assert changeset.required == [:password] + end + + test "allows fields to be set" do + changeset = + Accounts.change_user_password(%User{}, %{ + "password" => "new valid password" + }) + + assert changeset.valid? + assert get_change(changeset, :password) == "new valid password" + assert is_nil(get_change(changeset, :hashed_password)) + end + end + + describe "update_user_password/3" do + setup do + %{user: user_fixture()} + end + + test "validates password", %{user: user} do + {:error, changeset} = + Accounts.update_user_password(user, valid_user_password(), %{ + password: "not valid", + password_confirmation: "another" + }) + + assert %{ + password: ["should be at least 12 character(s)"], + password_confirmation: ["does not match password"] + } = errors_on(changeset) + end + + test "validates maximum values for password for security", %{user: user} do + too_long = String.duplicate("db", 100) + + {:error, changeset} = + Accounts.update_user_password(user, valid_user_password(), %{password: too_long}) + + assert "should be at most 72 character(s)" in errors_on(changeset).password + end + + test "validates current password", %{user: user} do + {:error, changeset} = + Accounts.update_user_password(user, "invalid", %{password: valid_user_password()}) + + assert %{current_password: ["is not valid"]} = errors_on(changeset) + end + + test "updates the password", %{user: user} do + {:ok, user} = + Accounts.update_user_password(user, valid_user_password(), %{ + password: "new valid password" + }) + + assert is_nil(user.password) + assert Accounts.get_user_by_email_and_password(user.email, "new valid password") + end + + test "deletes all tokens for the given user", %{user: user} do + _ = Accounts.generate_user_session_token(user) + + {:ok, _} = + Accounts.update_user_password(user, valid_user_password(), %{ + password: "new valid password" + }) + + refute Repo.get_by(UserToken, user_id: user.id) + end + end + + describe "generate_user_session_token/1" do + setup do + %{user: user_fixture()} + end + + test "generates a token", %{user: user} do + token = Accounts.generate_user_session_token(user) + assert user_token = Repo.get_by(UserToken, token: token) + assert user_token.context == "session" + + # Creating the same token for another user should fail + assert_raise Ecto.ConstraintError, fn -> + Repo.insert!(%UserToken{ + token: user_token.token, + user_id: user_fixture().id, + context: "session" + }) + end + end + end + + describe "get_user_by_session_token/1" do + setup do + user = user_fixture() + token = Accounts.generate_user_session_token(user) + %{user: user, token: token} + end + + test "returns user by token", %{user: user, token: token} do + assert session_user = Accounts.get_user_by_session_token(token) + assert session_user.id == user.id + end + + test "does not return user for invalid token" do + refute Accounts.get_user_by_session_token("oops") + end + + test "does not return user for expired token", %{token: token} do + {1, nil} = Repo.update_all(UserToken, set: [inserted_at: ~N[2020-01-01 00:00:00]]) + refute Accounts.get_user_by_session_token(token) + end + end + + describe "delete_user_session_token/1" do + test "deletes the token" do + user = user_fixture() + token = Accounts.generate_user_session_token(user) + assert Accounts.delete_user_session_token(token) == :ok + refute Accounts.get_user_by_session_token(token) + end + end + + describe "deliver_user_confirmation_instructions/2" do + setup do + %{user: user_fixture()} + end + + test "sends token through notification", %{user: user} do + token = + extract_user_token(fn url -> + Accounts.deliver_user_confirmation_instructions(user, url) + end) + + {:ok, token} = Base.url_decode64(token, padding: false) + assert user_token = Repo.get_by(UserToken, token: :crypto.hash(:sha256, token)) + assert user_token.user_id == user.id + assert user_token.sent_to == user.email + assert user_token.context == "confirm" + end + end + + describe "confirm_user/1" do + setup do + user = user_fixture() + + token = + extract_user_token(fn url -> + Accounts.deliver_user_confirmation_instructions(user, url) + end) + + %{user: user, token: token} + end + + test "confirms the email with a valid token", %{user: user, token: token} do + assert {:ok, confirmed_user} = Accounts.confirm_user(token) + assert confirmed_user.confirmed_at + assert confirmed_user.confirmed_at != user.confirmed_at + assert Repo.get!(User, user.id).confirmed_at + refute Repo.get_by(UserToken, user_id: user.id) + end + + test "does not confirm with invalid token", %{user: user} do + assert Accounts.confirm_user("oops") == :error + refute Repo.get!(User, user.id).confirmed_at + assert Repo.get_by(UserToken, user_id: user.id) + end + + test "does not confirm email if token expired", %{user: user, token: token} do + {1, nil} = Repo.update_all(UserToken, set: [inserted_at: ~N[2020-01-01 00:00:00]]) + assert Accounts.confirm_user(token) == :error + refute Repo.get!(User, user.id).confirmed_at + assert Repo.get_by(UserToken, user_id: user.id) + end + end + + describe "deliver_user_reset_password_instructions/2" do + setup do + %{user: user_fixture()} + end + + test "sends token through notification", %{user: user} do + token = + extract_user_token(fn url -> + Accounts.deliver_user_reset_password_instructions(user, url) + end) + + {:ok, token} = Base.url_decode64(token, padding: false) + assert user_token = Repo.get_by(UserToken, token: :crypto.hash(:sha256, token)) + assert user_token.user_id == user.id + assert user_token.sent_to == user.email + assert user_token.context == "reset_password" + end + end + + describe "get_user_by_reset_password_token/1" do + setup do + user = user_fixture() + + token = + extract_user_token(fn url -> + Accounts.deliver_user_reset_password_instructions(user, url) + end) + + %{user: user, token: token} + end + + test "returns the user with valid token", %{user: %{id: id}, token: token} do + assert %User{id: ^id} = Accounts.get_user_by_reset_password_token(token) + assert Repo.get_by(UserToken, user_id: id) + end + + test "does not return the user with invalid token", %{user: user} do + refute Accounts.get_user_by_reset_password_token("oops") + assert Repo.get_by(UserToken, user_id: user.id) + end + + test "does not return the user if token expired", %{user: user, token: token} do + {1, nil} = Repo.update_all(UserToken, set: [inserted_at: ~N[2020-01-01 00:00:00]]) + refute Accounts.get_user_by_reset_password_token(token) + assert Repo.get_by(UserToken, user_id: user.id) + end + end + + describe "reset_user_password/2" do + setup do + %{user: user_fixture()} + end + + test "validates password", %{user: user} do + {:error, changeset} = + Accounts.reset_user_password(user, %{ + password: "not valid", + password_confirmation: "another" + }) + + assert %{ + password: ["should be at least 12 character(s)"], + password_confirmation: ["does not match password"] + } = errors_on(changeset) + end + + test "validates maximum values for password for security", %{user: user} do + too_long = String.duplicate("db", 100) + {:error, changeset} = Accounts.reset_user_password(user, %{password: too_long}) + assert "should be at most 72 character(s)" in errors_on(changeset).password + end + + test "updates the password", %{user: user} do + {:ok, updated_user} = Accounts.reset_user_password(user, %{password: "new valid password"}) + assert is_nil(updated_user.password) + assert Accounts.get_user_by_email_and_password(user.email, "new valid password") + end + + test "deletes all tokens for the given user", %{user: user} do + _ = Accounts.generate_user_session_token(user) + {:ok, _} = Accounts.reset_user_password(user, %{password: "new valid password"}) + refute Repo.get_by(UserToken, user_id: user.id) + end + end + + describe "inspect/2 for the User module" do + test "does not include password" do + refute inspect(%User{password: "123456"}) =~ "password: \"123456\"" + end + end +end diff --git a/test/nulla/activities_test.exs b/test/nulla/activities_test.exs new file mode 100644 index 0000000..ffd8f08 --- /dev/null +++ b/test/nulla/activities_test.exs @@ -0,0 +1,84 @@ +defmodule Nulla.ActivitiesTest do + use Nulla.DataCase + + alias Nulla.Activities + + describe "activities" do + alias Nulla.Activities.Activity + + import Nulla.ActivitiesFixtures + + @invalid_attrs %{type: nil, cc: nil, to: nil, ap_id: nil, actor: nil, object: nil} + + test "list_activities/0 returns all activities" do + activity = activity_fixture() + assert Activities.list_activities() == [activity] + end + + test "get_activity!/1 returns the activity with given id" do + activity = activity_fixture() + assert Activities.get_activity!(activity.id) == activity + end + + test "create_activity/1 with valid data creates a activity" do + valid_attrs = %{ + type: "some type", + cc: ["option1", "option2"], + to: ["option1", "option2"], + ap_id: "some ap_id", + actor: "some actor", + object: "some object" + } + + assert {:ok, %Activity{} = activity} = Activities.create_activity(valid_attrs) + assert activity.type == "some type" + assert activity.cc == ["option1", "option2"] + assert activity.to == ["option1", "option2"] + assert activity.ap_id == "some ap_id" + assert activity.actor == "some actor" + assert activity.object == "some object" + end + + test "create_activity/1 with invalid data returns error changeset" do + assert {:error, %Ecto.Changeset{}} = Activities.create_activity(@invalid_attrs) + end + + test "update_activity/2 with valid data updates the activity" do + activity = activity_fixture() + + update_attrs = %{ + type: "some updated type", + cc: ["option1"], + to: ["option1"], + ap_id: "some updated ap_id", + actor: "some updated actor", + object: "some updated object" + } + + assert {:ok, %Activity{} = activity} = Activities.update_activity(activity, update_attrs) + assert activity.type == "some updated type" + assert activity.cc == ["option1"] + assert activity.to == ["option1"] + assert activity.ap_id == "some updated ap_id" + assert activity.actor == "some updated actor" + assert activity.object == "some updated object" + end + + test "update_activity/2 with invalid data returns error changeset" do + activity = activity_fixture() + assert {:error, %Ecto.Changeset{}} = Activities.update_activity(activity, @invalid_attrs) + assert activity == Activities.get_activity!(activity.id) + end + + test "delete_activity/1 deletes the activity" do + activity = activity_fixture() + assert {:ok, %Activity{}} = Activities.delete_activity(activity) + assert_raise Ecto.NoResultsError, fn -> Activities.get_activity!(activity.id) end + end + + test "change_activity/1 returns a activity changeset" do + activity = activity_fixture() + assert %Ecto.Changeset{} = Activities.change_activity(activity) + end + end +end diff --git a/test/nulla/actors_test.exs b/test/nulla/actors_test.exs new file mode 100644 index 0000000..bc2a88d --- /dev/null +++ b/test/nulla/actors_test.exs @@ -0,0 +1,210 @@ +defmodule Nulla.ActorsTest do + use Nulla.DataCase + + alias Nulla.KeyGen + alias Nulla.Actors + + describe "actors" do + alias Nulla.Actors.Actor + + import Nulla.ActorsFixtures + + @invalid_attrs %{ + name: nil, + tag: nil, + type: nil, + image: nil, + url: nil, + acct: nil, + ap_id: nil, + following: nil, + followers: nil, + inbox: nil, + outbox: nil, + featured: nil, + featuredTags: nil, + preferredUsername: nil, + summary: nil, + manuallyApprovesFollowers: nil, + discoverable: nil, + indexable: nil, + published: nil, + memorial: nil, + publicKey: nil, + privateKeyPem: nil, + attachment: nil, + endpoints: nil, + icon: nil, + vcard_bday: nil, + vcard_Address: nil + } + + test "list_actors/0 returns all actors" do + actor = actor_fixture() + assert Actors.list_actors() == [actor] + end + + test "get_actor!/1 returns the actor with given id" do + actor = actor_fixture() + assert Actors.get_actor!(actor.id) == actor + end + + test "create_actor/1 with valid data creates a actor" do + username = "test#{System.unique_integer()}" + {publicKeyPem, privateKeyPem} = KeyGen.gen() + + valid_attrs = %{ + name: "some name", + tag: [], + type: "some type", + image: %{}, + url: "some url", + acct: "#{username}@localhost", + ap_id: "http://localhost/users/#{username}", + following: "some following", + followers: "some followers", + inbox: "some inbox", + outbox: "some outbox", + featured: "some featured", + featuredTags: "some featuredTags", + preferredUsername: username, + summary: "some summary", + manuallyApprovesFollowers: true, + discoverable: true, + indexable: true, + published: ~U[2025-06-30 13:31:00Z], + memorial: true, + publicKey: %{ + "id" => "http://localhost/users/#{username}#main-key", + "owner" => "http://localhost/users/#{username}", + "publicKeyPem" => publicKeyPem + }, + privateKeyPem: privateKeyPem, + attachment: [], + endpoints: %{}, + icon: %{}, + vcard_bday: ~D[2025-06-30], + vcard_Address: "some vcard_Address" + } + + assert {:ok, %Actor{} = actor} = Actors.create_actor(valid_attrs) + assert actor.name == "some name" + assert actor.tag == [] + assert actor.type == "some type" + assert actor.image == %{} + assert actor.url == "some url" + assert actor.acct == "#{username}@localhost" + assert actor.ap_id == "http://localhost/users/#{username}" + assert actor.following == "some following" + assert actor.followers == "some followers" + assert actor.inbox == "some inbox" + assert actor.outbox == "some outbox" + assert actor.featured == "some featured" + assert actor.featuredTags == "some featuredTags" + assert actor.preferredUsername == username + assert actor.summary == "some summary" + assert actor.manuallyApprovesFollowers == true + assert actor.discoverable == true + assert actor.indexable == true + assert actor.published == ~U[2025-06-30 13:31:00Z] + assert actor.memorial == true + assert actor.publicKey["publicKeyPem"] == publicKeyPem + assert actor.privateKeyPem == privateKeyPem + assert actor.attachment == [] + assert actor.endpoints == %{} + assert actor.icon == %{} + assert actor.vcard_bday == ~D[2025-06-30] + assert actor.vcard_Address == "some vcard_Address" + end + + test "create_actor/1 with invalid data returns error changeset" do + assert {:error, %Ecto.Changeset{}} = Actors.create_actor(@invalid_attrs) + end + + test "update_actor/2 with valid data updates the actor" do + actor = actor_fixture() + username = "test#{System.unique_integer()}" + {publicKeyPem, privateKeyPem} = KeyGen.gen() + + update_attrs = %{ + name: "some updated name", + tag: [], + type: "some updated type", + image: %{}, + url: "some updated url", + acct: "#{username}@localhost", + ap_id: "http://localhost/users/#{username}", + following: "some updated following", + followers: "some updated followers", + inbox: "some updated inbox", + outbox: "some updated outbox", + featured: "some updated featured", + featuredTags: "some updated featuredTags", + preferredUsername: username, + summary: "some updated summary", + manuallyApprovesFollowers: false, + discoverable: false, + indexable: false, + published: ~U[2025-07-01 13:31:00Z], + memorial: false, + publicKey: %{ + "id" => "http://localhost/users/#{username}#main-key", + "owner" => "http://localhost/users/#{username}", + "publicKeyPem" => publicKeyPem + }, + privateKeyPem: privateKeyPem, + attachment: [], + endpoints: %{}, + icon: %{}, + vcard_bday: ~D[2025-07-01], + vcard_Address: "some updated vcard_Address" + } + + assert {:ok, %Actor{} = actor} = Actors.update_actor(actor, update_attrs) + assert actor.name == "some updated name" + assert actor.tag == [] + assert actor.type == "some updated type" + assert actor.image == %{} + assert actor.url == "some updated url" + assert actor.acct == "#{username}@localhost" + assert actor.ap_id == "http://localhost/users/#{username}" + assert actor.following == "some updated following" + assert actor.followers == "some updated followers" + assert actor.inbox == "some updated inbox" + assert actor.outbox == "some updated outbox" + assert actor.featured == "some updated featured" + assert actor.featuredTags == "some updated featuredTags" + assert actor.preferredUsername == username + assert actor.summary == "some updated summary" + assert actor.manuallyApprovesFollowers == false + assert actor.discoverable == false + assert actor.indexable == false + assert actor.published == ~U[2025-07-01 13:31:00Z] + assert actor.memorial == false + assert actor.publicKey["publicKeyPem"] == publicKeyPem + assert actor.privateKeyPem == privateKeyPem + assert actor.attachment == [] + assert actor.endpoints == %{} + assert actor.icon == %{} + assert actor.vcard_bday == ~D[2025-07-01] + assert actor.vcard_Address == "some updated vcard_Address" + end + + test "update_actor/2 with invalid data returns error changeset" do + actor = actor_fixture() + assert {:error, %Ecto.Changeset{}} = Actors.update_actor(actor, @invalid_attrs) + assert actor == Actors.get_actor!(actor.id) + end + + test "delete_actor/1 deletes the actor" do + actor = actor_fixture() + assert {:ok, %Actor{}} = Actors.delete_actor(actor) + assert_raise Ecto.NoResultsError, fn -> Actors.get_actor!(actor.id) end + end + + test "change_actor/1 returns a actor changeset" do + actor = actor_fixture() + assert %Ecto.Changeset{} = Actors.change_actor(actor) + end + end +end diff --git a/test/nulla/http_signature_test.exs b/test/nulla/http_signature_test.exs deleted file mode 100644 index afab9df..0000000 --- a/test/nulla/http_signature_test.exs +++ /dev/null @@ -1,49 +0,0 @@ -defmodule Nulla.HTTPSignatureTest do - use NullaWeb.ConnCase, async: false - import Plug.Conn - import Plug.Test - import Nulla.Fixtures.Data - alias Nulla.HTTPSignature - alias Nulla.Snowflake - alias Nulla.Models.User - alias Nulla.Models.Actor - - setup do - create_data() - :ok - end - - test "make_headers/3 creates valid signature headers and verify/2 validates them" do - actor = Actor.get_actor(preferredUsername: "test") - target_actor = Actor.get_actor(preferredUsername: "test2") - user = User.get_user(id: actor.id) - - follow_activity = %{ - "@context" => "https://www.w3.org/ns/activitystreams", - "id" => Snowflake.next_id(), - "type" => "Follow", - "actor" => actor.ap_id, - "object" => target_actor.ap_id - } - - body = Jason.encode!(follow_activity) - - headers = - HTTPSignature.make_headers( - body, - target_actor.inbox, - actor.publicKey["id"], - user.privateKeyPem - ) - - conn = - conn(:post, "/users/test2/inbox", body) - |> put_req_header("content-type", Map.new(headers) |> Map.get("Content-Type")) - |> put_req_header("date", Map.new(headers) |> Map.get("Date")) - |> put_req_header("digest", Map.new(headers) |> Map.get("Digest")) - |> put_req_header("signature", Map.new(headers) |> Map.get("Signature")) - |> Map.put(:host, URI.parse(target_actor.inbox).host) - - assert :ok = HTTPSignature.verify(conn, actor.publicKey["publicKeyPem"]) - end -end diff --git a/test/nulla/keys.exs b/test/nulla/keys.exs deleted file mode 100644 index 10f8083..0000000 --- a/test/nulla/keys.exs +++ /dev/null @@ -1,32 +0,0 @@ -defmodule Nulla.KeysTest do - use NullaWeb.ConnCase, async: false - import Nulla.Fixtures.Data - alias Nulla.Models.User - alias Nulla.Models.Actor - - setup do - create() - :ok - end - - test "verify user's keys" do - actor = Actor.get_actor(preferredUsername: "test") - user = User.get_user(id: actor.id) - - message = "test message" - - private_key = - :public_key.pem_decode(user.privateKeyPem) - |> hd() - |> :public_key.pem_entry_decode() - - public_key = - :public_key.pem_decode(actor.publicKey["publicKeyPem"]) - |> hd() - |> :public_key.pem_entry_decode() - - signature = :public_key.sign(message, :sha256, private_key) - - assert :public_key.verify(message, :sha256, signature, public_key) - end -end diff --git a/test/nulla/media_attachments_test.exs b/test/nulla/media_attachments_test.exs new file mode 100644 index 0000000..9b509eb --- /dev/null +++ b/test/nulla/media_attachments_test.exs @@ -0,0 +1,113 @@ +defmodule Nulla.MediaAttachmentsTest do + use Nulla.DataCase + + alias Nulla.MediaAttachments + + describe "media_attachments" do + alias Nulla.MediaAttachments.MediaAttachment + + import Nulla.NotesFixtures + import Nulla.MediaAttachmentsFixtures + + @invalid_attrs %{ + name: nil, + type: nil, + width: nil, + url: nil, + mediaType: nil, + height: nil, + note_id: nil + } + + test "list_media_attachments/0 returns all media_attachments" do + media_attachment = media_attachment_fixture() + assert MediaAttachments.list_media_attachments() == [media_attachment] + end + + test "get_media_attachment!/1 returns the media_attachment with given id" do + media_attachment = media_attachment_fixture() + assert MediaAttachments.get_media_attachment!(media_attachment.id) == media_attachment + end + + test "create_media_attachment/1 with valid data creates a media_attachment" do + note = note_fixture() + + valid_attrs = %{ + name: "some name", + type: "some type", + width: 42, + url: "some url", + mediaType: "some mediaType", + height: 42, + note_id: note.id + } + + assert {:ok, %MediaAttachment{} = media_attachment} = + MediaAttachments.create_media_attachment(valid_attrs) + + assert media_attachment.name == "some name" + assert media_attachment.type == "some type" + assert media_attachment.width == 42 + assert media_attachment.url == "some url" + assert media_attachment.mediaType == "some mediaType" + assert media_attachment.height == 42 + assert media_attachment.note_id == note.id + end + + test "create_media_attachment/1 with invalid data returns error changeset" do + assert {:error, %Ecto.Changeset{}} = + MediaAttachments.create_media_attachment(@invalid_attrs) + end + + test "update_media_attachment/2 with valid data updates the media_attachment" do + note = note_fixture() + media_attachment = media_attachment_fixture() + + update_attrs = %{ + name: "some updated name", + type: "some updated type", + width: 43, + url: "some updated url", + mediaType: "some updated mediaType", + height: 43, + note_id: note.id + } + + assert {:ok, %MediaAttachment{} = media_attachment} = + MediaAttachments.update_media_attachment(media_attachment, update_attrs) + + assert media_attachment.name == "some updated name" + assert media_attachment.type == "some updated type" + assert media_attachment.width == 43 + assert media_attachment.url == "some updated url" + assert media_attachment.mediaType == "some updated mediaType" + assert media_attachment.height == 43 + assert media_attachment.note_id == note.id + end + + test "update_media_attachment/2 with invalid data returns error changeset" do + media_attachment = media_attachment_fixture() + + assert {:error, %Ecto.Changeset{}} = + MediaAttachments.update_media_attachment(media_attachment, @invalid_attrs) + + assert media_attachment == MediaAttachments.get_media_attachment!(media_attachment.id) + end + + test "delete_media_attachment/1 deletes the media_attachment" do + media_attachment = media_attachment_fixture() + + assert {:ok, %MediaAttachment{}} = + MediaAttachments.delete_media_attachment(media_attachment) + + assert_raise Ecto.NoResultsError, fn -> + MediaAttachments.get_media_attachment!(media_attachment.id) + end + end + + test "change_media_attachment/1 returns a media_attachment changeset" do + media_attachment = media_attachment_fixture() + assert %Ecto.Changeset{} = MediaAttachments.change_media_attachment(media_attachment) + end + end +end diff --git a/test/nulla/notes_test.exs b/test/nulla/notes_test.exs new file mode 100644 index 0000000..d200212 --- /dev/null +++ b/test/nulla/notes_test.exs @@ -0,0 +1,115 @@ +defmodule Nulla.NotesTest do + use Nulla.DataCase + + alias Nulla.Notes + + describe "notes" do + alias Nulla.Notes.Note + + import Nulla.ActorsFixtures + import Nulla.NotesFixtures + + @invalid_attrs %{ + sensitive: nil, + cc: nil, + to: nil, + url: nil, + language: nil, + inReplyTo: nil, + published: nil, + visibility: nil, + content: nil, + actor_id: nil + } + + test "list_notes/0 returns all notes" do + note = note_fixture() + assert Notes.list_notes() == [note] + end + + test "get_note!/1 returns the note with given id" do + note = note_fixture() + assert Notes.get_note!(note.id) == note + end + + test "create_note/1 with valid data creates a note" do + actor = actor_fixture() + + valid_attrs = %{ + sensitive: true, + cc: ["option1", "option2"], + to: ["option1", "option2"], + url: "some url", + language: "some language", + inReplyTo: "some inReplyTo", + published: ~U[2025-07-01 09:17:00Z], + visibility: "some visibility", + content: "some content", + actor_id: actor.id + } + + assert {:ok, %Note{} = note} = Notes.create_note(valid_attrs) + assert note.sensitive == true + assert note.cc == ["option1", "option2"] + assert note.to == ["option1", "option2"] + assert note.url == "some url" + assert note.language == "some language" + assert note.inReplyTo == "some inReplyTo" + assert note.published == ~U[2025-07-01 09:17:00Z] + assert note.visibility == "some visibility" + assert note.content == "some content" + assert note.actor_id == actor.id + end + + test "create_note/1 with invalid data returns error changeset" do + assert {:error, %Ecto.Changeset{}} = Notes.create_note(@invalid_attrs) + end + + test "update_note/2 with valid data updates the note" do + actor = actor_fixture() + note = note_fixture() + + update_attrs = %{ + sensitive: false, + cc: ["option1"], + to: ["option1"], + url: "some updated url", + language: "some updated language", + inReplyTo: "some updated inReplyTo", + published: ~U[2025-07-02 09:17:00Z], + visibility: "some updated visibility", + content: "some updated content", + actor_id: actor.id + } + + assert {:ok, %Note{} = note} = Notes.update_note(note, update_attrs) + assert note.sensitive == false + assert note.cc == ["option1"] + assert note.to == ["option1"] + assert note.url == "some updated url" + assert note.language == "some updated language" + assert note.inReplyTo == "some updated inReplyTo" + assert note.published == ~U[2025-07-02 09:17:00Z] + assert note.visibility == "some updated visibility" + assert note.content == "some updated content" + assert note.actor_id == actor.id + end + + test "update_note/2 with invalid data returns error changeset" do + note = note_fixture() + assert {:error, %Ecto.Changeset{}} = Notes.update_note(note, @invalid_attrs) + assert note == Notes.get_note!(note.id) + end + + test "delete_note/1 deletes the note" do + note = note_fixture() + assert {:ok, %Note{}} = Notes.delete_note(note) + assert_raise Ecto.NoResultsError, fn -> Notes.get_note!(note.id) end + end + + test "change_note/1 returns a note changeset" do + note = note_fixture() + assert %Ecto.Changeset{} = Notes.change_note(note) + end + end +end diff --git a/test/nulla/relations_test.exs b/test/nulla/relations_test.exs new file mode 100644 index 0000000..a4e45c5 --- /dev/null +++ b/test/nulla/relations_test.exs @@ -0,0 +1,137 @@ +defmodule Nulla.RelationsTest do + use Nulla.DataCase + + alias Nulla.Relations + + describe "relations" do + alias Nulla.Relations.Relation + + import Nulla.ActorsFixtures + import Nulla.RelationsFixtures + + @invalid_attrs %{ + following: nil, + followed_by: nil, + showing_replies: nil, + showings_reblogs: nil, + notifying: nil, + muting: nil, + muting_notifications: nil, + blocking: nil, + blocked_by: nil, + domain_blocking: nil, + requested: nil, + note: nil, + local_actor_id: nil, + remote_actor_id: nil + } + + test "list_relations/0 returns all relations" do + relation = relation_fixture() + assert Relations.list_relations() == [relation] + end + + test "get_relation!/1 returns the relation with given id" do + relation = relation_fixture() + assert Relations.get_relation!(relation.id) == relation + end + + test "create_relation/1 with valid data creates a relation" do + local_actor = actor_fixture() + remote_actor = actor_fixture() + + valid_attrs = %{ + following: true, + followed_by: true, + showing_replies: true, + showings_reblogs: true, + notifying: true, + muting: true, + muting_notifications: true, + blocking: true, + blocked_by: true, + domain_blocking: true, + requested: true, + note: "some note", + local_actor_id: local_actor.id, + remote_actor_id: remote_actor.id + } + + assert {:ok, %Relation{} = relation} = Relations.create_relation(valid_attrs) + assert relation.following == true + assert relation.followed_by == true + assert relation.showing_replies == true + assert relation.showings_reblogs == true + assert relation.notifying == true + assert relation.muting == true + assert relation.muting_notifications == true + assert relation.blocking == true + assert relation.blocked_by == true + assert relation.domain_blocking == true + assert relation.requested == true + assert relation.note == "some note" + assert relation.local_actor_id == local_actor.id + assert relation.remote_actor_id == remote_actor.id + end + + test "create_relation/1 with invalid data returns error changeset" do + assert {:error, %Ecto.Changeset{}} = Relations.create_relation(@invalid_attrs) + end + + test "update_relation/2 with valid data updates the relation" do + local_actor = actor_fixture() + remote_actor = actor_fixture() + relation = relation_fixture() + + update_attrs = %{ + following: false, + followed_by: false, + showing_replies: false, + showings_reblogs: false, + notifying: false, + muting: false, + muting_notifications: false, + blocking: false, + blocked_by: false, + domain_blocking: false, + requested: false, + note: "some updated note", + local_actor_id: local_actor.id, + remote_actor_id: remote_actor.id + } + + assert {:ok, %Relation{} = relation} = Relations.update_relation(relation, update_attrs) + assert relation.following == false + assert relation.followed_by == false + assert relation.showing_replies == false + assert relation.showings_reblogs == false + assert relation.notifying == false + assert relation.muting == false + assert relation.muting_notifications == false + assert relation.blocking == false + assert relation.blocked_by == false + assert relation.domain_blocking == false + assert relation.requested == false + assert relation.note == "some updated note" + assert relation.local_actor_id == local_actor.id + assert relation.remote_actor_id == remote_actor.id + end + + test "update_relation/2 with invalid data returns error changeset" do + relation = relation_fixture() + assert {:error, %Ecto.Changeset{}} = Relations.update_relation(relation, @invalid_attrs) + assert relation == Relations.get_relation!(relation.id) + end + + test "delete_relation/1 deletes the relation" do + relation = relation_fixture() + assert {:ok, %Relation{}} = Relations.delete_relation(relation) + assert_raise Ecto.NoResultsError, fn -> Relations.get_relation!(relation.id) end + end + + test "change_relation/1 returns a relation changeset" do + relation = relation_fixture() + assert %Ecto.Changeset{} = Relations.change_relation(relation) + end + end +end diff --git a/test/nulla_web/controllers/activity_controller_test.exs b/test/nulla_web/controllers/activity_controller_test.exs new file mode 100644 index 0000000..1ae210c --- /dev/null +++ b/test/nulla_web/controllers/activity_controller_test.exs @@ -0,0 +1,107 @@ +defmodule NullaWeb.ActivityControllerTest do + use NullaWeb.ConnCase + + import Nulla.ActivitiesFixtures + + alias Nulla.Activities.Activity + + @create_attrs %{ + type: "some type", + cc: ["option1", "option2"], + to: ["option1", "option2"], + ap_id: "some ap_id", + actor: "some actor", + object: "some object" + } + @update_attrs %{ + type: "some updated type", + cc: ["option1"], + to: ["option1"], + ap_id: "some updated ap_id", + actor: "some updated actor", + object: "some updated object" + } + @invalid_attrs %{type: nil, cc: nil, to: nil, ap_id: nil, actor: nil, object: nil} + + setup %{conn: conn} do + {:ok, conn: put_req_header(conn, "accept", "application/json")} + end + + describe "index" do + test "lists all activities", %{conn: conn} do + conn = get(conn, ~p"/api/activities") + assert json_response(conn, 200)["data"] == [] + end + end + + describe "create activity" do + test "renders activity when data is valid", %{conn: conn} do + conn = post(conn, ~p"/api/activities", activity: @create_attrs) + assert %{"id" => id} = json_response(conn, 201)["data"] + + conn = get(conn, ~p"/api/activities/#{id}") + + assert %{ + "id" => ^id, + "actor" => "some actor", + "ap_id" => "some ap_id", + "cc" => ["option1", "option2"], + "object" => "some object", + "to" => ["option1", "option2"], + "type" => "some type" + } = json_response(conn, 200)["data"] + end + + test "renders errors when data is invalid", %{conn: conn} do + conn = post(conn, ~p"/api/activities", activity: @invalid_attrs) + assert json_response(conn, 422)["errors"] != %{} + end + end + + describe "update activity" do + setup [:create_activity] + + test "renders activity when data is valid", %{ + conn: conn, + activity: %Activity{id: id} = activity + } do + conn = put(conn, ~p"/api/activities/#{activity}", activity: @update_attrs) + assert %{"id" => ^id} = json_response(conn, 200)["data"] + + conn = get(conn, ~p"/api/activities/#{id}") + + assert %{ + "id" => ^id, + "actor" => "some updated actor", + "ap_id" => "some updated ap_id", + "cc" => ["option1"], + "object" => "some updated object", + "to" => ["option1"], + "type" => "some updated type" + } = json_response(conn, 200)["data"] + end + + test "renders errors when data is invalid", %{conn: conn, activity: activity} do + conn = put(conn, ~p"/api/activities/#{activity}", activity: @invalid_attrs) + assert json_response(conn, 422)["errors"] != %{} + end + end + + describe "delete activity" do + setup [:create_activity] + + test "deletes chosen activity", %{conn: conn, activity: activity} do + conn = delete(conn, ~p"/api/activities/#{activity}") + assert response(conn, 204) + + assert_error_sent 404, fn -> + get(conn, ~p"/api/activities/#{activity}") + end + end + end + + defp create_activity(_) do + activity = activity_fixture() + %{activity: activity} + end +end diff --git a/test/nulla_web/controllers/actor_controller_test.exs b/test/nulla_web/controllers/actor_controller_test.exs index 4204cb1..39f5224 100644 --- a/test/nulla_web/controllers/actor_controller_test.exs +++ b/test/nulla_web/controllers/actor_controller_test.exs @@ -1,61 +1,211 @@ defmodule NullaWeb.ActorControllerTest do use NullaWeb.ConnCase - import Nulla.Fixtures.Data - setup do - create_data() - :ok + import Nulla.ActorsFixtures + + alias Nulla.Actors.Actor + + @create_attrs %{ + name: "some name", + tag: [], + type: "some type", + image: %{}, + url: "some url", + acct: "some acct", + ap_id: "some ap_id", + following: "some following", + followers: "some followers", + inbox: "some inbox", + outbox: "some outbox", + featured: "some featured", + featuredTags: "some featuredTags", + preferredUsername: "some preferredUsername", + summary: "some summary", + manuallyApprovesFollowers: true, + discoverable: true, + indexable: true, + published: ~U[2025-06-30 13:31:00Z], + memorial: true, + publicKey: %{}, + attachment: [], + endpoints: %{}, + icon: %{}, + vcard_bday: ~D[2025-06-30], + vcard_Address: "some vcard_Address" + } + @update_attrs %{ + name: "some updated name", + tag: [], + type: "some updated type", + image: %{}, + url: "some updated url", + acct: "some updated acct", + ap_id: "some updated ap_id", + following: "some updated following", + followers: "some updated followers", + inbox: "some updated inbox", + outbox: "some updated outbox", + featured: "some updated featured", + featuredTags: "some updated featuredTags", + preferredUsername: "some updated preferredUsername", + summary: "some updated summary", + manuallyApprovesFollowers: false, + discoverable: false, + indexable: false, + published: ~U[2025-07-01 13:31:00Z], + memorial: false, + publicKey: %{}, + attachment: [], + endpoints: %{}, + icon: %{}, + vcard_bday: ~D[2025-07-01], + vcard_Address: "some updated vcard_Address" + } + @invalid_attrs %{ + name: nil, + tag: nil, + type: nil, + image: nil, + url: nil, + acct: nil, + ap_id: nil, + following: nil, + followers: nil, + inbox: nil, + outbox: nil, + featured: nil, + featuredTags: nil, + preferredUsername: nil, + summary: nil, + manuallyApprovesFollowers: nil, + discoverable: nil, + indexable: nil, + published: nil, + memorial: nil, + publicKey: nil, + attachment: nil, + endpoints: nil, + icon: nil, + vcard_bday: nil, + vcard_Address: nil + } + + setup %{conn: conn} do + {:ok, conn: put_req_header(conn, "accept", "application/json")} end - describe "GET /users/username" do - test "renders ActivityPub JSON if Accept header is activity+json", %{conn: conn} do - conn = - conn - |> put_req_header("accept", "application/activity+json") - |> get(~p"/users/test") + describe "index" do + test "lists all actors", %{conn: conn} do + conn = get(conn, ~p"/api/actors") + assert json_response(conn, 200)["data"] == [] + end + end - assert response = json_response(conn, 200) + describe "create actor" do + test "renders actor when data is valid", %{conn: conn} do + conn = post(conn, ~p"/api/actors", actor: @create_attrs) + assert %{"id" => id} = json_response(conn, 201)["data"] - assert is_list(response["@context"]) - assert is_binary(response["id"]) - assert is_binary(response["type"]) - assert is_binary(response["following"]) - assert is_binary(response["followers"]) - assert is_binary(response["inbox"]) - assert is_binary(response["outbox"]) - assert is_binary(response["featured"]) - assert is_binary(response["featuredTags"]) - assert is_binary(response["preferredUsername"]) - assert is_binary(response["name"]) or is_nil(response["name"]) - assert is_binary(response["summary"]) or is_nil(response["summary"]) - assert is_binary(response["url"]) - assert is_boolean(response["manuallyApprovesFollowers"]) - assert is_boolean(response["discoverable"]) - assert is_boolean(response["indexable"]) - assert {:ok, _dt, _offset} = DateTime.from_iso8601(response["published"]) - assert is_boolean(response["memorial"]) - assert is_map(response["publicKey"]) - assert is_list(response["tag"]) - assert is_list(response["attachment"]) - assert is_map(response["endpoints"]) - assert is_map(response["icon"]) or is_nil(response["icon"]) - assert is_map(response["image"]) or is_nil(response["image"]) - assert is_binary(response["vcard:bday"]) or is_nil(response["vcard:bday"]) - assert is_binary(response["vcard:Address"]) or is_nil(response["vcard:Address"]) + conn = get(conn, ~p"/api/actors/#{id}") + + assert %{ + "id" => ^id, + "acct" => "some acct", + "ap_id" => "some ap_id", + "attachment" => [], + "discoverable" => true, + "endpoints" => %{}, + "featured" => "some featured", + "featuredTags" => "some featuredTags", + "followers" => "some followers", + "following" => "some following", + "icon" => %{}, + "image" => %{}, + "inbox" => "some inbox", + "indexable" => true, + "manuallyApprovesFollowers" => true, + "memorial" => true, + "name" => "some name", + "outbox" => "some outbox", + "preferredUsername" => "some preferredUsername", + "publicKey" => %{}, + "published" => "2025-06-30T13:31:00Z", + "summary" => "some summary", + "tag" => [], + "type" => "some type", + "url" => "some url", + "vcard_Address" => "some vcard_Address", + "vcard_bday" => "2025-06-30" + } = json_response(conn, 200)["data"] end - test "renders HTML if Accept header is regular", %{conn: conn} do - conn = get(conn, ~p"/@test") + test "renders errors when data is invalid", %{conn: conn} do + conn = post(conn, ~p"/api/actors", actor: @invalid_attrs) + assert json_response(conn, 422)["errors"] != %{} + end + end - assert html_response(conn, 200) =~ "test" - assert html_response(conn, 200) =~ "Test" - assert html_response(conn, 200) =~ "test@localhost" + describe "update actor" do + setup [:create_actor] + + test "renders actor when data is valid", %{conn: conn, actor: %Actor{id: id} = actor} do + conn = put(conn, ~p"/api/actors/#{actor}", actor: @update_attrs) + assert %{"id" => ^id} = json_response(conn, 200)["data"] + + conn = get(conn, ~p"/api/actors/#{id}") + + assert %{ + "id" => ^id, + "acct" => "some updated acct", + "ap_id" => "some updated ap_id", + "attachment" => [], + "discoverable" => false, + "endpoints" => %{}, + "featured" => "some updated featured", + "featuredTags" => "some updated featuredTags", + "followers" => "some updated followers", + "following" => "some updated following", + "icon" => %{}, + "image" => %{}, + "inbox" => "some updated inbox", + "indexable" => false, + "manuallyApprovesFollowers" => false, + "memorial" => false, + "name" => "some updated name", + "outbox" => "some updated outbox", + "preferredUsername" => "some updated preferredUsername", + "publicKey" => %{}, + "published" => "2025-07-01T13:31:00Z", + "summary" => "some updated summary", + "tag" => [], + "type" => "some updated type", + "url" => "some updated url", + "vcard_Address" => "some updated vcard_Address", + "vcard_bday" => "2025-07-01" + } = json_response(conn, 200)["data"] end - test "returns 404 if actor not found", %{conn: conn} do - conn = get(conn, ~p"/@nonexistent") - - assert json_response(conn, 404)["error"] == "Not Found" + test "renders errors when data is invalid", %{conn: conn, actor: actor} do + conn = put(conn, ~p"/api/actors/#{actor}", actor: @invalid_attrs) + assert json_response(conn, 422)["errors"] != %{} end end + + describe "delete actor" do + setup [:create_actor] + + test "deletes chosen actor", %{conn: conn, actor: actor} do + conn = delete(conn, ~p"/api/actors/#{actor}") + assert response(conn, 204) + + assert_error_sent 404, fn -> + get(conn, ~p"/api/actors/#{actor}") + end + end + end + + defp create_actor(_) do + actor = actor_fixture() + %{actor: actor} + end end diff --git a/test/nulla_web/controllers/follow_controller_test.exs b/test/nulla_web/controllers/follow_controller_test.exs deleted file mode 100644 index 711148d..0000000 --- a/test/nulla_web/controllers/follow_controller_test.exs +++ /dev/null @@ -1,83 +0,0 @@ -defmodule NullaWeb.FollowControllerTest do - use NullaWeb.ConnCase - import Nulla.Fixtures.Data - - setup do - create_data() - :ok - end - - describe "GET /users/username/following" do - test "returns ActivityPub JSON of following", %{conn: conn} do - conn = - conn - |> get(~p"/users/test/following") - - assert response = json_response(conn, 200) - - assert response["id"] == "http://localhost/users/test/following" - assert response["type"] == "OrderedCollection" - assert response["totalItems"] == 0 - assert response["first"] == "http://localhost/users/test/following?page=1" - end - - test "returns ActivityPub JSON of following with page", %{conn: conn} do - conn = - conn - |> get(~p"/users/test/following?page=1") - - assert response = json_response(conn, 200) - - assert response["id"] == "http://localhost/users/test/following?page=1" - assert response["type"] == "OrderedCollectionPage" - assert response["totalItems"] == 0 - assert response["partOf"] == "http://localhost/users/test/following" - assert is_list(response["orderedItems"]) - end - - test "returns first page with invalid value", %{conn: conn} do - conn = - conn - |> get(~p"/users/test/following?page=abc") - - assert conn.status == 200 - end - end - - describe "GET /users/username/followers" do - test "returns ActivityPub JSON of followers", %{conn: conn} do - conn = - conn - |> get(~p"/users/test/followers") - - assert response = json_response(conn, 200) - - assert response["id"] == "http://localhost/users/test/followers" - assert response["type"] == "OrderedCollection" - assert response["totalItems"] == 0 - assert response["first"] == "http://localhost/users/test/followers?page=1" - end - - test "returns ActivityPub JSON of followers with page", %{conn: conn} do - conn = - conn - |> get(~p"/users/test/followers?page=1") - - assert response = json_response(conn, 200) - - assert response["id"] == "http://localhost/users/test/followers?page=1" - assert response["type"] == "OrderedCollectionPage" - assert response["totalItems"] == 0 - assert response["partOf"] == "http://localhost/users/test/followers" - assert is_list(response["orderedItems"]) - end - - test "returns first page with invalid value", %{conn: conn} do - conn = - conn - |> get(~p"/users/test/followers?page=abc") - - assert conn.status == 200 - end - end -end diff --git a/test/nulla_web/controllers/hostmeta_controller_test.exs b/test/nulla_web/controllers/hostmeta_controller_test.exs deleted file mode 100644 index 99d5baa..0000000 --- a/test/nulla_web/controllers/hostmeta_controller_test.exs +++ /dev/null @@ -1,13 +0,0 @@ -defmodule NullaWeb.HostmetaControllerTest do - use NullaWeb.ConnCase - - describe "GET /.well-known/host-meta" do - test "returns Nodeinfo JSON index", %{conn: conn} do - conn = - conn - |> get(~p"/.well-known/host-meta") - - assert response(conn, 200) =~ "localhost" - end - end -end diff --git a/test/nulla_web/controllers/inbox_controller_test.exs b/test/nulla_web/controllers/inbox_controller_test.exs deleted file mode 100644 index 14e2559..0000000 --- a/test/nulla_web/controllers/inbox_controller_test.exs +++ /dev/null @@ -1,72 +0,0 @@ -defmodule NullaWeb.InboxControllerTest do - use NullaWeb.ConnCase - import Nulla.Fixtures.Data - alias Nulla.Snowflake - alias Nulla.Models.User - alias Nulla.Models.Actor - - setup do - create_data() - :ok - end - - describe "POST /users/username/inbox" do - test "Validate follow request", %{conn: conn} do - actor = Actor.get_actor(preferredUsername: "test") - target_actor = Actor.get_actor(preferredUsername: "test2") - user = User.get_user(id: actor.id) - - follow_activity = %{ - "@context" => "https://www.w3.org/ns/activitystreams", - "id" => Snowflake.next_id(), - "type" => "Follow", - "actor" => actor.ap_id, - "object" => target_actor.ap_id - } - - body = Jason.encode!(follow_activity) - - digest = "SHA-256=" <> (:crypto.hash(:sha256, body) |> Base.encode64()) - date = Calendar.strftime(DateTime.utc_now(), "%a, %d %b %Y %H:%M:%S GMT") - uri = URI.parse("/users/test2/inbox") - - signature_string = """ - (request-target): post #{uri.path} - host: localhost - date: #{date} - digest: #{digest} - """ - - private_key = - case :public_key.pem_decode(user.privateKeyPem) do - [entry] -> :public_key.pem_entry_decode(entry) - _ -> raise "Invalid PEM format" - end - - signature = - :public_key.sign(signature_string, :sha256, private_key) - |> Base.encode64() - - signature_header = - """ - keyId="#{actor.publicKey["id"]}", - algorithm="rsa-sha256", - headers="(request-target) host date digest", - signature="#{signature}" - """ - |> String.replace("\n", "") - |> String.trim() - - conn = - conn - |> put_req_header("content-type", "application/activity+json") - |> put_req_header("accept", "application/activity+json") - |> put_req_header("date", date) - |> put_req_header("digest", digest) - |> put_req_header("signature", signature_header) - |> post("/users/test2/inbox", body) - - assert conn.status == 200 - end - end -end diff --git a/test/nulla_web/controllers/media_attachment_controller_test.exs b/test/nulla_web/controllers/media_attachment_controller_test.exs new file mode 100644 index 0000000..b443cc7 --- /dev/null +++ b/test/nulla_web/controllers/media_attachment_controller_test.exs @@ -0,0 +1,118 @@ +defmodule NullaWeb.MediaAttachmentControllerTest do + use NullaWeb.ConnCase + + import Nulla.NotesFixtures + import Nulla.MediaAttachmentsFixtures + + alias Nulla.MediaAttachments.MediaAttachment + + @create_attrs %{ + name: "some name", + type: "some type", + width: 42, + url: "some url", + mediaType: "some mediaType", + height: 42 + } + @update_attrs %{ + name: "some updated name", + type: "some updated type", + width: 43, + url: "some updated url", + mediaType: "some updated mediaType", + height: 43 + } + @invalid_attrs %{name: nil, type: nil, width: nil, url: nil, mediaType: nil, height: nil} + + setup %{conn: conn} do + {:ok, conn: put_req_header(conn, "accept", "application/json")} + end + + describe "index" do + test "lists all media_attachments", %{conn: conn} do + conn = get(conn, ~p"/api/media_attachments") + assert json_response(conn, 200)["data"] == [] + end + end + + describe "create media_attachment" do + test "renders media_attachment when data is valid", %{conn: conn} do + note = note_fixture() + + create_attrs = Map.merge(@create_attrs, %{note_id: note.id}) + + conn = post(conn, ~p"/api/media_attachments", media_attachment: create_attrs) + assert %{"id" => id} = json_response(conn, 201)["data"] + + conn = get(conn, ~p"/api/media_attachments/#{id}") + + assert %{ + "id" => ^id, + "height" => 42, + "mediaType" => "some mediaType", + "name" => "some name", + "type" => "some type", + "url" => "some url", + "width" => 42 + } = json_response(conn, 200)["data"] + end + + test "renders errors when data is invalid", %{conn: conn} do + conn = post(conn, ~p"/api/media_attachments", media_attachment: @invalid_attrs) + assert json_response(conn, 422)["errors"] != %{} + end + end + + describe "update media_attachment" do + setup [:create_media_attachment] + + test "renders media_attachment when data is valid", %{ + conn: conn, + media_attachment: %MediaAttachment{id: id} = media_attachment + } do + conn = + put(conn, ~p"/api/media_attachments/#{media_attachment}", media_attachment: @update_attrs) + + assert %{"id" => ^id} = json_response(conn, 200)["data"] + + conn = get(conn, ~p"/api/media_attachments/#{id}") + + assert %{ + "id" => ^id, + "height" => 43, + "mediaType" => "some updated mediaType", + "name" => "some updated name", + "type" => "some updated type", + "url" => "some updated url", + "width" => 43 + } = json_response(conn, 200)["data"] + end + + test "renders errors when data is invalid", %{conn: conn, media_attachment: media_attachment} do + conn = + put(conn, ~p"/api/media_attachments/#{media_attachment}", + media_attachment: @invalid_attrs + ) + + assert json_response(conn, 422)["errors"] != %{} + end + end + + describe "delete media_attachment" do + setup [:create_media_attachment] + + test "deletes chosen media_attachment", %{conn: conn, media_attachment: media_attachment} do + conn = delete(conn, ~p"/api/media_attachments/#{media_attachment}") + assert response(conn, 204) + + assert_error_sent 404, fn -> + get(conn, ~p"/api/media_attachments/#{media_attachment}") + end + end + end + + defp create_media_attachment(_) do + media_attachment = media_attachment_fixture() + %{media_attachment: media_attachment} + end +end diff --git a/test/nulla_web/controllers/nodeinfo_controller_test.exs b/test/nulla_web/controllers/nodeinfo_controller_test.exs deleted file mode 100644 index ab1d5d2..0000000 --- a/test/nulla_web/controllers/nodeinfo_controller_test.exs +++ /dev/null @@ -1,55 +0,0 @@ -defmodule NullaWeb.NodeinfoControllerTest do - use NullaWeb.ConnCase - import Nulla.Fixtures.Data - - setup do - create_data() - :ok - end - - describe "GET /.well-known/nodeinfo" do - test "returns Nodeinfo JSON index", %{conn: conn} do - conn = - conn - |> get(~p"/.well-known/nodeinfo") - - assert response = json_response(conn, 200) - - assert is_list(response["links"]) - - Enum.each(response["links"], fn link -> - assert is_map(link) - assert is_binary(link["rel"]) - assert is_binary(link["href"]) - end) - end - end - - describe "GET /nodeinfo/2.0" do - test "returns Nodeinfo JSON show", %{conn: conn} do - conn = - conn - |> get(~p"/nodeinfo/2.0") - - assert response = json_response(conn, 200) - - assert response["version"] == "2.0" - assert is_map(response["software"]) - assert response["software"]["name"] == "nulla" - assert is_binary(response["software"]["version"]) - assert "activitypub" in response["protocols"] - assert is_map(response["services"]) - assert is_list(response["services"]["outbound"]) - assert is_list(response["services"]["inbound"]) - assert is_map(response["usage"]) - assert is_map(response["usage"]["users"]) - assert response["usage"]["users"]["total"] > 0 - assert response["usage"]["users"]["activeMonth"] > 0 - assert response["usage"]["users"]["activeHalfyear"] > 0 - assert is_boolean(response["openRegistrations"]) - assert is_map(response["metadata"]) - assert is_binary(response["metadata"]["nodeName"]) - assert is_binary(response["metadata"]["nodeDescription"]) - end - end -end diff --git a/test/nulla_web/controllers/note_controller_test.exs b/test/nulla_web/controllers/note_controller_test.exs index 16c328b..b5855fa 100644 --- a/test/nulla_web/controllers/note_controller_test.exs +++ b/test/nulla_web/controllers/note_controller_test.exs @@ -1,55 +1,131 @@ defmodule NullaWeb.NoteControllerTest do use NullaWeb.ConnCase - import Nulla.Fixtures.Data - alias Nulla.Models.Actor - alias Nulla.Models.Note - setup do - create_data() - :ok + import Nulla.ActorsFixtures + import Nulla.NotesFixtures + + alias Nulla.Notes.Note + + @create_attrs %{ + sensitive: true, + cc: ["option1", "option2"], + to: ["option1", "option2"], + url: "some url", + language: "some language", + inReplyTo: "some inReplyTo", + published: ~U[2025-07-01 09:17:00Z], + visibility: "some visibility", + content: "some content" + } + @update_attrs %{ + sensitive: false, + cc: ["option1"], + to: ["option1"], + url: "some updated url", + language: "some updated language", + inReplyTo: "some updated inReplyTo", + published: ~U[2025-07-02 09:17:00Z], + visibility: "some updated visibility", + content: "some updated content" + } + @invalid_attrs %{ + sensitive: nil, + cc: nil, + to: nil, + url: nil, + language: nil, + inReplyTo: nil, + published: nil, + visibility: nil, + content: nil + } + + setup %{conn: conn} do + {:ok, conn: put_req_header(conn, "accept", "application/json")} end - describe "GET /notes/id" do - test "returns ActivityPub JSON with note", %{conn: conn} do - actor = Actor.get_actor(preferredUsername: "test") - note = Note.get_note(actor_id: actor.id) + describe "index" do + test "lists all notes", %{conn: conn} do + conn = get(conn, ~p"/api/notes") + assert json_response(conn, 200)["data"] == [] + end + end - conn = - conn - |> put_req_header("accept", "application/activity+json") - |> get(~p"/users/#{actor.preferredUsername}/notes/#{note.id}") + describe "create note" do + test "renders note when data is valid", %{conn: conn} do + actor = actor_fixture() - assert response = json_response(conn, 200) + create_attrs = Map.merge(@create_attrs, %{actor_id: actor.id}) - assert is_list(response["@context"]) - assert response["id"] == "http://localhost/users/test/notes/#{note.id}" - assert response["type"] == "Note" - assert response["summary"] == nil - assert response["inReplyTo"] == nil - assert {:ok, _dt, _offset} = DateTime.from_iso8601(response["published"]) - assert response["url"] == note.url - assert response["attributedTo"] == "http://localhost/users/test" - assert is_list(response["to"]) - assert is_list(response["cc"]) - assert response["sensitive"] == false - assert is_binary(response["content"]) - assert is_map(response["contentMap"]) - assert is_list(response["attachment"]) + conn = post(conn, ~p"/api/notes", note: create_attrs) + assert %{"id" => id} = json_response(conn, 201)["data"] + + conn = get(conn, ~p"/api/notes/#{id}") + + assert %{ + "id" => ^id, + "cc" => ["option1", "option2"], + "content" => "some content", + "inReplyTo" => "some inReplyTo", + "language" => "some language", + "published" => "2025-07-01T09:17:00Z", + "sensitive" => true, + "to" => ["option1", "option2"], + "url" => "some url", + "visibility" => "some visibility" + } = json_response(conn, 200)["data"] end - test "renders HTML if Accept header is regular", %{conn: conn} do - actor = Actor.get_actor(preferredUsername: "test") - note = Note.get_note(actor_id: actor.id) + test "renders errors when data is invalid", %{conn: conn} do + conn = post(conn, ~p"/api/notes", note: @invalid_attrs) + assert json_response(conn, 422)["errors"] != %{} + end + end - conn = get(conn, ~p"/@test/#{note.id}") + describe "update note" do + setup [:create_note] - assert html_response(conn, 200) =~ note.content + test "renders note when data is valid", %{conn: conn, note: %Note{id: id} = note} do + conn = put(conn, ~p"/api/notes/#{note}", note: @update_attrs) + assert %{"id" => ^id} = json_response(conn, 200)["data"] + + conn = get(conn, ~p"/api/notes/#{id}") + + assert %{ + "id" => ^id, + "cc" => ["option1"], + "content" => "some updated content", + "inReplyTo" => "some updated inReplyTo", + "language" => "some updated language", + "published" => "2025-07-02T09:17:00Z", + "sensitive" => false, + "to" => ["option1"], + "url" => "some updated url", + "visibility" => "some updated visibility" + } = json_response(conn, 200)["data"] end - test "returns 404 if note not found", %{conn: conn} do - conn = get(conn, ~p"/@test/nonexistent") - - assert json_response(conn, 404)["error"] == "Not Found" + test "renders errors when data is invalid", %{conn: conn, note: note} do + conn = put(conn, ~p"/api/notes/#{note}", note: @invalid_attrs) + assert json_response(conn, 422)["errors"] != %{} end end + + describe "delete note" do + setup [:create_note] + + test "deletes chosen note", %{conn: conn, note: note} do + conn = delete(conn, ~p"/api/notes/#{note}") + assert response(conn, 204) + + assert_error_sent 404, fn -> + get(conn, ~p"/api/notes/#{note}") + end + end + end + + defp create_note(_) do + note = note_fixture() + %{note: note} + end end diff --git a/test/nulla_web/controllers/outbox_controller_test.exs b/test/nulla_web/controllers/outbox_controller_test.exs deleted file mode 100644 index 0318e96..0000000 --- a/test/nulla_web/controllers/outbox_controller_test.exs +++ /dev/null @@ -1,42 +0,0 @@ -defmodule NullaWeb.OutboxControllerTest do - use NullaWeb.ConnCase - import Nulla.Fixtures.Data - - setup do - create_data() - :ok - end - - describe "GET /users/username/outbox" do - test "returns ActivityPub JSON of outbox", %{conn: conn} do - conn = - conn - |> get(~p"/users/test/outbox") - - assert response = json_response(conn, 200) - - assert is_binary(response["@context"]) - assert response["id"] == "http://localhost/users/test/outbox" - assert response["type"] == "OrderedCollection" - assert response["totalItems"] == 1 - assert response["first"] == "http://localhost/users/test/outbox?page=true" - assert response["last"] == "http://localhost/users/test/outbox?min_id=0&page=true" - end - - test "returns ActivityPub JSON of outbox with params", %{conn: conn} do - conn = - conn - |> get(~p"/users/test/outbox?page=true") - - assert response = json_response(conn, 200) - - assert is_list(response["@context"]) - assert response["id"] == "http://localhost/users/test/outbox?page=true" - assert response["type"] == "OrderedCollectionPage" - assert is_binary(response["next"]) - assert is_binary(response["prev"]) - assert response["partOf"] == "http://localhost/users/test/outbox" - assert Enum.any?(response["orderedItems"]) - end - end -end diff --git a/test/nulla_web/controllers/page_controller_test.exs b/test/nulla_web/controllers/page_controller_test.exs index 48fa534..5e8da73 100644 --- a/test/nulla_web/controllers/page_controller_test.exs +++ b/test/nulla_web/controllers/page_controller_test.exs @@ -3,7 +3,6 @@ defmodule NullaWeb.PageControllerTest do test "GET /", %{conn: conn} do conn = get(conn, ~p"/") - - assert String.length(html_response(conn, 200)) > 0 + assert html_response(conn, 200) =~ "Peace of mind from prototype to production" end end diff --git a/test/nulla_web/controllers/relation_controller_test.exs b/test/nulla_web/controllers/relation_controller_test.exs new file mode 100644 index 0000000..a221d33 --- /dev/null +++ b/test/nulla_web/controllers/relation_controller_test.exs @@ -0,0 +1,154 @@ +defmodule NullaWeb.RelationControllerTest do + use NullaWeb.ConnCase + + import Nulla.ActorsFixtures + import Nulla.RelationsFixtures + + alias Nulla.Relations.Relation + + @create_attrs %{ + following: true, + followed_by: true, + showing_replies: true, + showings_reblogs: true, + notifying: true, + muting: true, + muting_notifications: true, + blocking: true, + blocked_by: true, + domain_blocking: true, + requested: true, + note: "some note" + } + @update_attrs %{ + following: false, + followed_by: false, + showing_replies: false, + showings_reblogs: false, + notifying: false, + muting: false, + muting_notifications: false, + blocking: false, + blocked_by: false, + domain_blocking: false, + requested: false, + note: "some updated note" + } + @invalid_attrs %{ + following: nil, + followed_by: nil, + showing_replies: nil, + showings_reblogs: nil, + notifying: nil, + muting: nil, + muting_notifications: nil, + blocking: nil, + blocked_by: nil, + domain_blocking: nil, + requested: nil, + note: nil + } + + setup %{conn: conn} do + {:ok, conn: put_req_header(conn, "accept", "application/json")} + end + + describe "index" do + test "lists all relations", %{conn: conn} do + conn = get(conn, ~p"/api/relations") + assert json_response(conn, 200)["data"] == [] + end + end + + describe "create relation" do + test "renders relation when data is valid", %{conn: conn} do + local_actor = actor_fixture() + remote_actor = actor_fixture() + + create_attrs = + Map.merge(@create_attrs, %{ + local_actor_id: local_actor.id, + remote_actor_id: remote_actor.id + }) + + conn = post(conn, ~p"/api/relations", relation: create_attrs) + assert %{"id" => id} = json_response(conn, 201)["data"] + + conn = get(conn, ~p"/api/relations/#{id}") + + assert %{ + "id" => ^id, + "blocked_by" => true, + "blocking" => true, + "domain_blocking" => true, + "followed_by" => true, + "following" => true, + "muting" => true, + "muting_notifications" => true, + "note" => "some note", + "notifying" => true, + "requested" => true, + "showing_replies" => true, + "showings_reblogs" => true + } = json_response(conn, 200)["data"] + end + + test "renders errors when data is invalid", %{conn: conn} do + conn = post(conn, ~p"/api/relations", relation: @invalid_attrs) + assert json_response(conn, 422)["errors"] != %{} + end + end + + describe "update relation" do + setup [:create_relation] + + test "renders relation when data is valid", %{ + conn: conn, + relation: %Relation{id: id} = relation + } do + conn = put(conn, ~p"/api/relations/#{relation}", relation: @update_attrs) + assert %{"id" => ^id} = json_response(conn, 200)["data"] + + conn = get(conn, ~p"/api/relations/#{id}") + + assert %{ + "id" => ^id, + "blocked_by" => false, + "blocking" => false, + "domain_blocking" => false, + "followed_by" => false, + "following" => false, + "muting" => false, + "muting_notifications" => false, + "note" => "some updated note", + "notifying" => false, + "requested" => false, + "showing_replies" => false, + "showings_reblogs" => false + } = json_response(conn, 200)["data"] + end + + test "renders errors when data is invalid", %{conn: conn, relation: relation} do + conn = put(conn, ~p"/api/relations/#{relation}", relation: @invalid_attrs) + assert json_response(conn, 422)["errors"] != %{} + end + end + + describe "delete relation" do + setup [:create_relation] + + test "deletes chosen relation", %{conn: conn, relation: relation} do + conn = delete(conn, ~p"/api/relations/#{relation}") + assert response(conn, 204) + + assert_error_sent 404, fn -> + get(conn, ~p"/api/relations/#{relation}") + end + end + end + + defp create_relation(_) do + relation = relation_fixture() + %{relation: relation} + end +end diff --git a/test/nulla_web/controllers/user_session_controller_test.exs b/test/nulla_web/controllers/user_session_controller_test.exs new file mode 100644 index 0000000..b4d82f0 --- /dev/null +++ b/test/nulla_web/controllers/user_session_controller_test.exs @@ -0,0 +1,113 @@ +defmodule NullaWeb.UserSessionControllerTest do + use NullaWeb.ConnCase, async: true + + import Nulla.AccountsFixtures + + setup do + %{user: user_fixture()} + end + + describe "POST /users/log_in" do + test "logs the user in", %{conn: conn, user: user} do + conn = + post(conn, ~p"/users/log_in", %{ + "user" => %{"email" => user.email, "password" => valid_user_password()} + }) + + assert get_session(conn, :user_token) + assert redirected_to(conn) == ~p"/" + + # Now do a logged in request and assert on the menu + conn = get(conn, ~p"/") + response = html_response(conn, 200) + assert response =~ user.email + assert response =~ ~p"/users/settings" + assert response =~ ~p"/users/log_out" + end + + test "logs the user in with remember me", %{conn: conn, user: user} do + conn = + post(conn, ~p"/users/log_in", %{ + "user" => %{ + "email" => user.email, + "password" => valid_user_password(), + "remember_me" => "true" + } + }) + + assert conn.resp_cookies["_nulla_web_user_remember_me"] + assert redirected_to(conn) == ~p"/" + end + + test "logs the user in with return to", %{conn: conn, user: user} do + conn = + conn + |> init_test_session(user_return_to: "/foo/bar") + |> post(~p"/users/log_in", %{ + "user" => %{ + "email" => user.email, + "password" => valid_user_password() + } + }) + + assert redirected_to(conn) == "/foo/bar" + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Welcome back!" + end + + test "login following registration", %{conn: conn, user: user} do + conn = + conn + |> post(~p"/users/log_in", %{ + "_action" => "registered", + "user" => %{ + "email" => user.email, + "password" => valid_user_password() + } + }) + + assert redirected_to(conn) == ~p"/" + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Account created successfully" + end + + test "login following password update", %{conn: conn, user: user} do + conn = + conn + |> post(~p"/users/log_in", %{ + "_action" => "password_updated", + "user" => %{ + "email" => user.email, + "password" => valid_user_password() + } + }) + + assert redirected_to(conn) == ~p"/users/settings" + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Password updated successfully" + end + + test "redirects to login page with invalid credentials", %{conn: conn} do + conn = + post(conn, ~p"/users/log_in", %{ + "user" => %{"email" => "invalid@email.com", "password" => "invalid_password"} + }) + + assert Phoenix.Flash.get(conn.assigns.flash, :error) == "Invalid email or password" + assert redirected_to(conn) == ~p"/users/log_in" + end + end + + describe "DELETE /users/log_out" do + test "logs the user out", %{conn: conn, user: user} do + conn = conn |> log_in_user(user) |> delete(~p"/users/log_out") + assert redirected_to(conn) == ~p"/" + refute get_session(conn, :user_token) + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Logged out successfully" + end + + test "succeeds even if the user is not logged in", %{conn: conn} do + conn = delete(conn, ~p"/users/log_out") + assert redirected_to(conn) == ~p"/" + refute get_session(conn, :user_token) + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Logged out successfully" + end + end +end diff --git a/test/nulla_web/controllers/webfinger_controller_test.exs b/test/nulla_web/controllers/webfinger_controller_test.exs deleted file mode 100644 index 7c7cc18..0000000 --- a/test/nulla_web/controllers/webfinger_controller_test.exs +++ /dev/null @@ -1,55 +0,0 @@ -defmodule NullaWeb.WebfingerControllerTest do - use NullaWeb.ConnCase - import Nulla.Fixtures.Data - - setup do - create_data() - :ok - end - - describe "GET /.well-known/webfinger" do - test "returns 400 without parameters", %{conn: conn} do - conn = - conn - |> get(~p"/.well-known/webfinger") - - assert conn.status == 400 - end - - test "returns 400 without :acct", %{conn: conn} do - conn = - conn - |> get(~p"/.well-known/webfinger?resource=test@localhost") - - assert conn.status == 400 - end - - test "returns 400 without domain", %{conn: conn} do - conn = - conn - |> get(~p"/.well-known/webfinger?resource=acct:test") - - assert conn.status == 400 - end - - test "returns 404 with invalid domain", %{conn: conn} do - conn = - conn - |> get(~p"/.well-known/webfinger?resource=acct:test@example") - - assert conn.status == 404 - end - - test "returns Webfinger", %{conn: conn} do - conn = - conn - |> get(~p"/.well-known/webfinger?resource=acct:test@localhost") - - assert response = json_response(conn, 200) - - assert is_binary(response["subject"]) - assert is_list(response["aliases"]) - assert is_list(response["links"]) - end - end -end diff --git a/test/nulla_web/live/user_confirmation_instructions_live_test.exs b/test/nulla_web/live/user_confirmation_instructions_live_test.exs new file mode 100644 index 0000000..0b13cdd --- /dev/null +++ b/test/nulla_web/live/user_confirmation_instructions_live_test.exs @@ -0,0 +1,67 @@ +defmodule NullaWeb.UserConfirmationInstructionsLiveTest do + use NullaWeb.ConnCase, async: true + + import Phoenix.LiveViewTest + import Nulla.AccountsFixtures + + alias Nulla.Accounts + alias Nulla.Repo + + setup do + %{user: user_fixture()} + end + + describe "Resend confirmation" do + test "renders the resend confirmation page", %{conn: conn} do + {:ok, _lv, html} = live(conn, ~p"/users/confirm") + assert html =~ "Resend confirmation instructions" + end + + test "sends a new confirmation token", %{conn: conn, user: user} do + {:ok, lv, _html} = live(conn, ~p"/users/confirm") + + {:ok, conn} = + lv + |> form("#resend_confirmation_form", user: %{email: user.email}) + |> render_submit() + |> follow_redirect(conn, ~p"/") + + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ + "If your email is in our system" + + assert Repo.get_by!(Accounts.UserToken, user_id: user.id).context == "confirm" + end + + test "does not send confirmation token if user is confirmed", %{conn: conn, user: user} do + Repo.update!(Accounts.User.confirm_changeset(user)) + + {:ok, lv, _html} = live(conn, ~p"/users/confirm") + + {:ok, conn} = + lv + |> form("#resend_confirmation_form", user: %{email: user.email}) + |> render_submit() + |> follow_redirect(conn, ~p"/") + + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ + "If your email is in our system" + + refute Repo.get_by(Accounts.UserToken, user_id: user.id) + end + + test "does not send confirmation token if email is invalid", %{conn: conn} do + {:ok, lv, _html} = live(conn, ~p"/users/confirm") + + {:ok, conn} = + lv + |> form("#resend_confirmation_form", user: %{email: "unknown@example.com"}) + |> render_submit() + |> follow_redirect(conn, ~p"/") + + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ + "If your email is in our system" + + assert Repo.all(Accounts.UserToken) == [] + end + end +end diff --git a/test/nulla_web/live/user_confirmation_live_test.exs b/test/nulla_web/live/user_confirmation_live_test.exs new file mode 100644 index 0000000..561e757 --- /dev/null +++ b/test/nulla_web/live/user_confirmation_live_test.exs @@ -0,0 +1,89 @@ +defmodule NullaWeb.UserConfirmationLiveTest do + use NullaWeb.ConnCase, async: true + + import Phoenix.LiveViewTest + import Nulla.AccountsFixtures + + alias Nulla.Accounts + alias Nulla.Repo + + setup do + %{user: user_fixture()} + end + + describe "Confirm user" do + test "renders confirmation page", %{conn: conn} do + {:ok, _lv, html} = live(conn, ~p"/users/confirm/some-token") + assert html =~ "Confirm Account" + end + + test "confirms the given token once", %{conn: conn, user: user} do + token = + extract_user_token(fn url -> + Accounts.deliver_user_confirmation_instructions(user, url) + end) + + {:ok, lv, _html} = live(conn, ~p"/users/confirm/#{token}") + + result = + lv + |> form("#confirmation_form") + |> render_submit() + |> follow_redirect(conn, "/") + + assert {:ok, conn} = result + + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ + "User confirmed successfully" + + assert Accounts.get_user!(user.id).confirmed_at + refute get_session(conn, :user_token) + assert Repo.all(Accounts.UserToken) == [] + + # when not logged in + {:ok, lv, _html} = live(conn, ~p"/users/confirm/#{token}") + + result = + lv + |> form("#confirmation_form") + |> render_submit() + |> follow_redirect(conn, "/") + + assert {:ok, conn} = result + + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ + "User confirmation link is invalid or it has expired" + + # when logged in + conn = + build_conn() + |> log_in_user(user) + + {:ok, lv, _html} = live(conn, ~p"/users/confirm/#{token}") + + result = + lv + |> form("#confirmation_form") + |> render_submit() + |> follow_redirect(conn, "/") + + assert {:ok, conn} = result + refute Phoenix.Flash.get(conn.assigns.flash, :error) + end + + test "does not confirm email with invalid token", %{conn: conn, user: user} do + {:ok, lv, _html} = live(conn, ~p"/users/confirm/invalid-token") + + {:ok, conn} = + lv + |> form("#confirmation_form") + |> render_submit() + |> follow_redirect(conn, ~p"/") + + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ + "User confirmation link is invalid or it has expired" + + refute Accounts.get_user!(user.id).confirmed_at + end + end +end diff --git a/test/nulla_web/live/user_forgot_password_live_test.exs b/test/nulla_web/live/user_forgot_password_live_test.exs new file mode 100644 index 0000000..848b93f --- /dev/null +++ b/test/nulla_web/live/user_forgot_password_live_test.exs @@ -0,0 +1,63 @@ +defmodule NullaWeb.UserForgotPasswordLiveTest do + use NullaWeb.ConnCase, async: true + + import Phoenix.LiveViewTest + import Nulla.AccountsFixtures + + alias Nulla.Accounts + alias Nulla.Repo + + describe "Forgot password page" do + test "renders email page", %{conn: conn} do + {:ok, lv, html} = live(conn, ~p"/users/reset_password") + + assert html =~ "Forgot your password?" + assert has_element?(lv, ~s|a[href="#{~p"/users/register"}"]|, "Register") + assert has_element?(lv, ~s|a[href="#{~p"/users/log_in"}"]|, "Log in") + end + + test "redirects if already logged in", %{conn: conn} do + result = + conn + |> log_in_user(user_fixture()) + |> live(~p"/users/reset_password") + |> follow_redirect(conn, ~p"/") + + assert {:ok, _conn} = result + end + end + + describe "Reset link" do + setup do + %{user: user_fixture()} + end + + test "sends a new reset password token", %{conn: conn, user: user} do + {:ok, lv, _html} = live(conn, ~p"/users/reset_password") + + {:ok, conn} = + lv + |> form("#reset_password_form", user: %{"email" => user.email}) + |> render_submit() + |> follow_redirect(conn, "/") + + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "If your email is in our system" + + assert Repo.get_by!(Accounts.UserToken, user_id: user.id).context == + "reset_password" + end + + test "does not send reset password token if email is invalid", %{conn: conn} do + {:ok, lv, _html} = live(conn, ~p"/users/reset_password") + + {:ok, conn} = + lv + |> form("#reset_password_form", user: %{"email" => "unknown@example.com"}) + |> render_submit() + |> follow_redirect(conn, "/") + + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "If your email is in our system" + assert Repo.all(Accounts.UserToken) == [] + end + end +end diff --git a/test/nulla_web/live/user_login_live_test.exs b/test/nulla_web/live/user_login_live_test.exs new file mode 100644 index 0000000..cc83e65 --- /dev/null +++ b/test/nulla_web/live/user_login_live_test.exs @@ -0,0 +1,87 @@ +defmodule NullaWeb.UserLoginLiveTest do + use NullaWeb.ConnCase, async: true + + import Phoenix.LiveViewTest + import Nulla.AccountsFixtures + + describe "Log in page" do + test "renders log in page", %{conn: conn} do + {:ok, _lv, html} = live(conn, ~p"/users/log_in") + + assert html =~ "Log in" + assert html =~ "Register" + assert html =~ "Forgot your password?" + end + + test "redirects if already logged in", %{conn: conn} do + result = + conn + |> log_in_user(user_fixture()) + |> live(~p"/users/log_in") + |> follow_redirect(conn, "/") + + assert {:ok, _conn} = result + end + end + + describe "user login" do + test "redirects if user login with valid credentials", %{conn: conn} do + password = "123456789abcd" + user = user_fixture(%{password: password}) + + {:ok, lv, _html} = live(conn, ~p"/users/log_in") + + form = + form(lv, "#login_form", user: %{email: user.email, password: password, remember_me: true}) + + conn = submit_form(form, conn) + + assert redirected_to(conn) == ~p"/" + end + + test "redirects to login page with a flash error if there are no valid credentials", %{ + conn: conn + } do + {:ok, lv, _html} = live(conn, ~p"/users/log_in") + + form = + form(lv, "#login_form", + user: %{email: "test@email.com", password: "123456", remember_me: true} + ) + + conn = submit_form(form, conn) + + assert Phoenix.Flash.get(conn.assigns.flash, :error) == "Invalid email or password" + + assert redirected_to(conn) == "/users/log_in" + end + end + + describe "login navigation" do + test "redirects to registration page when the Register button is clicked", %{conn: conn} do + {:ok, lv, _html} = live(conn, ~p"/users/log_in") + + {:ok, _login_live, login_html} = + lv + |> element(~s|main a:fl-contains("Sign up")|) + |> render_click() + |> follow_redirect(conn, ~p"/users/register") + + assert login_html =~ "Register" + end + + test "redirects to forgot password page when the Forgot Password button is clicked", %{ + conn: conn + } do + {:ok, lv, _html} = live(conn, ~p"/users/log_in") + + {:ok, conn} = + lv + |> element(~s|main a:fl-contains("Forgot your password?")|) + |> render_click() + |> follow_redirect(conn, ~p"/users/reset_password") + + assert conn.resp_body =~ "Forgot your password?" + end + end +end diff --git a/test/nulla_web/live/user_registration_live_test.exs b/test/nulla_web/live/user_registration_live_test.exs new file mode 100644 index 0000000..f85952d --- /dev/null +++ b/test/nulla_web/live/user_registration_live_test.exs @@ -0,0 +1,87 @@ +defmodule NullaWeb.UserRegistrationLiveTest do + use NullaWeb.ConnCase, async: true + + import Phoenix.LiveViewTest + import Nulla.AccountsFixtures + + describe "Registration page" do + test "renders registration page", %{conn: conn} do + {:ok, _lv, html} = live(conn, ~p"/users/register") + + assert html =~ "Register" + assert html =~ "Log in" + end + + test "redirects if already logged in", %{conn: conn} do + result = + conn + |> log_in_user(user_fixture()) + |> live(~p"/users/register") + |> follow_redirect(conn, "/") + + assert {:ok, _conn} = result + end + + test "renders errors for invalid data", %{conn: conn} do + {:ok, lv, _html} = live(conn, ~p"/users/register") + + result = + lv + |> element("#registration_form") + |> render_change(user: %{"email" => "with spaces", "password" => "too short"}) + + assert result =~ "Register" + assert result =~ "must have the @ sign and no spaces" + assert result =~ "should be at least 12 character" + end + end + + describe "register user" do + test "creates account and logs the user in", %{conn: conn} do + {:ok, lv, _html} = live(conn, ~p"/users/register") + + email = unique_user_email() + form = form(lv, "#registration_form", user: valid_user_attributes(email: email)) + render_submit(form) + conn = follow_trigger_action(form, conn) + + assert redirected_to(conn) == ~p"/" + + # Now do a logged in request and assert on the menu + conn = get(conn, "/") + response = html_response(conn, 200) + assert response =~ email + assert response =~ "Settings" + assert response =~ "Log out" + end + + test "renders errors for duplicated email", %{conn: conn} do + {:ok, lv, _html} = live(conn, ~p"/users/register") + + user = user_fixture(%{email: "test@email.com"}) + + result = + lv + |> form("#registration_form", + user: %{"email" => user.email, "password" => "valid_password"} + ) + |> render_submit() + + assert result =~ "has already been taken" + end + end + + describe "registration navigation" do + test "redirects to login page when the Log in button is clicked", %{conn: conn} do + {:ok, lv, _html} = live(conn, ~p"/users/register") + + {:ok, _login_live, login_html} = + lv + |> element(~s|main a:fl-contains("Log in")|) + |> render_click() + |> follow_redirect(conn, ~p"/users/log_in") + + assert login_html =~ "Log in" + end + end +end diff --git a/test/nulla_web/live/user_reset_password_live_test.exs b/test/nulla_web/live/user_reset_password_live_test.exs new file mode 100644 index 0000000..0039d31 --- /dev/null +++ b/test/nulla_web/live/user_reset_password_live_test.exs @@ -0,0 +1,118 @@ +defmodule NullaWeb.UserResetPasswordLiveTest do + use NullaWeb.ConnCase, async: true + + import Phoenix.LiveViewTest + import Nulla.AccountsFixtures + + alias Nulla.Accounts + + setup do + user = user_fixture() + + token = + extract_user_token(fn url -> + Accounts.deliver_user_reset_password_instructions(user, url) + end) + + %{token: token, user: user} + end + + describe "Reset password page" do + test "renders reset password with valid token", %{conn: conn, token: token} do + {:ok, _lv, html} = live(conn, ~p"/users/reset_password/#{token}") + + assert html =~ "Reset Password" + end + + test "does not render reset password with invalid token", %{conn: conn} do + {:error, {:redirect, to}} = live(conn, ~p"/users/reset_password/invalid") + + assert to == %{ + flash: %{"error" => "Reset password link is invalid or it has expired."}, + to: ~p"/" + } + end + + test "renders errors for invalid data", %{conn: conn, token: token} do + {:ok, lv, _html} = live(conn, ~p"/users/reset_password/#{token}") + + result = + lv + |> element("#reset_password_form") + |> render_change( + user: %{"password" => "secret12", "password_confirmation" => "secret123456"} + ) + + assert result =~ "should be at least 12 character" + assert result =~ "does not match password" + end + end + + describe "Reset Password" do + test "resets password once", %{conn: conn, token: token, user: user} do + {:ok, lv, _html} = live(conn, ~p"/users/reset_password/#{token}") + + {:ok, conn} = + lv + |> form("#reset_password_form", + user: %{ + "password" => "new valid password", + "password_confirmation" => "new valid password" + } + ) + |> render_submit() + |> follow_redirect(conn, ~p"/users/log_in") + + refute get_session(conn, :user_token) + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Password reset successfully" + assert Accounts.get_user_by_email_and_password(user.email, "new valid password") + end + + test "does not reset password on invalid data", %{conn: conn, token: token} do + {:ok, lv, _html} = live(conn, ~p"/users/reset_password/#{token}") + + result = + lv + |> form("#reset_password_form", + user: %{ + "password" => "too short", + "password_confirmation" => "does not match" + } + ) + |> render_submit() + + assert result =~ "Reset Password" + assert result =~ "should be at least 12 character(s)" + assert result =~ "does not match password" + end + end + + describe "Reset password navigation" do + test "redirects to login page when the Log in button is clicked", %{conn: conn, token: token} do + {:ok, lv, _html} = live(conn, ~p"/users/reset_password/#{token}") + + {:ok, conn} = + lv + |> element(~s|main a:fl-contains("Log in")|) + |> render_click() + |> follow_redirect(conn, ~p"/users/log_in") + + assert conn.resp_body =~ "Log in" + end + + test "redirects to registration page when the Register button is clicked", %{ + conn: conn, + token: token + } do + {:ok, lv, _html} = live(conn, ~p"/users/reset_password/#{token}") + + {:ok, conn} = + lv + |> element(~s|main a:fl-contains("Register")|) + |> render_click() + |> follow_redirect(conn, ~p"/users/register") + + assert conn.resp_body =~ "Register" + end + end +end diff --git a/test/nulla_web/live/user_settings_live_test.exs b/test/nulla_web/live/user_settings_live_test.exs new file mode 100644 index 0000000..ee8c098 --- /dev/null +++ b/test/nulla_web/live/user_settings_live_test.exs @@ -0,0 +1,210 @@ +defmodule NullaWeb.UserSettingsLiveTest do + use NullaWeb.ConnCase, async: true + + alias Nulla.Accounts + import Phoenix.LiveViewTest + import Nulla.AccountsFixtures + + describe "Settings page" do + test "renders settings page", %{conn: conn} do + {:ok, _lv, html} = + conn + |> log_in_user(user_fixture()) + |> live(~p"/users/settings") + + assert html =~ "Change Email" + assert html =~ "Change Password" + end + + test "redirects if user is not logged in", %{conn: conn} do + assert {:error, redirect} = live(conn, ~p"/users/settings") + + assert {:redirect, %{to: path, flash: flash}} = redirect + assert path == ~p"/users/log_in" + assert %{"error" => "You must log in to access this page."} = flash + end + end + + describe "update email form" do + setup %{conn: conn} do + password = valid_user_password() + user = user_fixture(%{password: password}) + %{conn: log_in_user(conn, user), user: user, password: password} + end + + test "updates the user email", %{conn: conn, password: password, user: user} do + new_email = unique_user_email() + + {:ok, lv, _html} = live(conn, ~p"/users/settings") + + result = + lv + |> form("#email_form", %{ + "current_password" => password, + "user" => %{"email" => new_email} + }) + |> render_submit() + + assert result =~ "A link to confirm your email" + assert Accounts.get_user_by_email(user.email) + end + + test "renders errors with invalid data (phx-change)", %{conn: conn} do + {:ok, lv, _html} = live(conn, ~p"/users/settings") + + result = + lv + |> element("#email_form") + |> render_change(%{ + "action" => "update_email", + "current_password" => "invalid", + "user" => %{"email" => "with spaces"} + }) + + assert result =~ "Change Email" + assert result =~ "must have the @ sign and no spaces" + end + + test "renders errors with invalid data (phx-submit)", %{conn: conn, user: user} do + {:ok, lv, _html} = live(conn, ~p"/users/settings") + + result = + lv + |> form("#email_form", %{ + "current_password" => "invalid", + "user" => %{"email" => user.email} + }) + |> render_submit() + + assert result =~ "Change Email" + assert result =~ "did not change" + assert result =~ "is not valid" + end + end + + describe "update password form" do + setup %{conn: conn} do + password = valid_user_password() + user = user_fixture(%{password: password}) + %{conn: log_in_user(conn, user), user: user, password: password} + end + + test "updates the user password", %{conn: conn, user: user, password: password} do + new_password = valid_user_password() + + {:ok, lv, _html} = live(conn, ~p"/users/settings") + + form = + form(lv, "#password_form", %{ + "current_password" => password, + "user" => %{ + "email" => user.email, + "password" => new_password, + "password_confirmation" => new_password + } + }) + + render_submit(form) + + new_password_conn = follow_trigger_action(form, conn) + + assert redirected_to(new_password_conn) == ~p"/users/settings" + + assert get_session(new_password_conn, :user_token) != get_session(conn, :user_token) + + assert Phoenix.Flash.get(new_password_conn.assigns.flash, :info) =~ + "Password updated successfully" + + assert Accounts.get_user_by_email_and_password(user.email, new_password) + end + + test "renders errors with invalid data (phx-change)", %{conn: conn} do + {:ok, lv, _html} = live(conn, ~p"/users/settings") + + result = + lv + |> element("#password_form") + |> render_change(%{ + "current_password" => "invalid", + "user" => %{ + "password" => "too short", + "password_confirmation" => "does not match" + } + }) + + assert result =~ "Change Password" + assert result =~ "should be at least 12 character(s)" + assert result =~ "does not match password" + end + + test "renders errors with invalid data (phx-submit)", %{conn: conn} do + {:ok, lv, _html} = live(conn, ~p"/users/settings") + + result = + lv + |> form("#password_form", %{ + "current_password" => "invalid", + "user" => %{ + "password" => "too short", + "password_confirmation" => "does not match" + } + }) + |> render_submit() + + assert result =~ "Change Password" + assert result =~ "should be at least 12 character(s)" + assert result =~ "does not match password" + assert result =~ "is not valid" + end + end + + describe "confirm email" do + setup %{conn: conn} do + user = user_fixture() + email = unique_user_email() + + token = + extract_user_token(fn url -> + Accounts.deliver_user_update_email_instructions(%{user | email: email}, user.email, url) + end) + + %{conn: log_in_user(conn, user), token: token, email: email, user: user} + end + + test "updates the user email once", %{conn: conn, user: user, token: token, email: email} do + {:error, redirect} = live(conn, ~p"/users/settings/confirm_email/#{token}") + + assert {:live_redirect, %{to: path, flash: flash}} = redirect + assert path == ~p"/users/settings" + assert %{"info" => message} = flash + assert message == "Email changed successfully." + refute Accounts.get_user_by_email(user.email) + assert Accounts.get_user_by_email(email) + + # use confirm token again + {:error, redirect} = live(conn, ~p"/users/settings/confirm_email/#{token}") + assert {:live_redirect, %{to: path, flash: flash}} = redirect + assert path == ~p"/users/settings" + assert %{"error" => message} = flash + assert message == "Email change link is invalid or it has expired." + end + + test "does not update email with invalid token", %{conn: conn, user: user} do + {:error, redirect} = live(conn, ~p"/users/settings/confirm_email/oops") + assert {:live_redirect, %{to: path, flash: flash}} = redirect + assert path == ~p"/users/settings" + assert %{"error" => message} = flash + assert message == "Email change link is invalid or it has expired." + assert Accounts.get_user_by_email(user.email) + end + + test "redirects if user is not logged in", %{token: token} do + conn = build_conn() + {:error, redirect} = live(conn, ~p"/users/settings/confirm_email/#{token}") + assert {:redirect, %{to: path, flash: flash}} = redirect + assert path == ~p"/users/log_in" + assert %{"error" => message} = flash + assert message == "You must log in to access this page." + end + end +end diff --git a/test/nulla_web/user_auth_test.exs b/test/nulla_web/user_auth_test.exs new file mode 100644 index 0000000..328a049 --- /dev/null +++ b/test/nulla_web/user_auth_test.exs @@ -0,0 +1,272 @@ +defmodule NullaWeb.UserAuthTest do + use NullaWeb.ConnCase, async: true + + alias Phoenix.LiveView + alias Nulla.Accounts + alias NullaWeb.UserAuth + import Nulla.AccountsFixtures + + @remember_me_cookie "_nulla_web_user_remember_me" + + setup %{conn: conn} do + conn = + conn + |> Map.replace!(:secret_key_base, NullaWeb.Endpoint.config(:secret_key_base)) + |> init_test_session(%{}) + + %{user: user_fixture(), conn: conn} + end + + describe "log_in_user/3" do + test "stores the user token in the session", %{conn: conn, user: user} do + conn = UserAuth.log_in_user(conn, user) + assert token = get_session(conn, :user_token) + assert get_session(conn, :live_socket_id) == "users_sessions:#{Base.url_encode64(token)}" + assert redirected_to(conn) == ~p"/" + assert Accounts.get_user_by_session_token(token) + end + + test "clears everything previously stored in the session", %{conn: conn, user: user} do + conn = conn |> put_session(:to_be_removed, "value") |> UserAuth.log_in_user(user) + refute get_session(conn, :to_be_removed) + end + + test "redirects to the configured path", %{conn: conn, user: user} do + conn = conn |> put_session(:user_return_to, "/hello") |> UserAuth.log_in_user(user) + assert redirected_to(conn) == "/hello" + end + + test "writes a cookie if remember_me is configured", %{conn: conn, user: user} do + conn = conn |> fetch_cookies() |> UserAuth.log_in_user(user, %{"remember_me" => "true"}) + assert get_session(conn, :user_token) == conn.cookies[@remember_me_cookie] + + assert %{value: signed_token, max_age: max_age} = conn.resp_cookies[@remember_me_cookie] + assert signed_token != get_session(conn, :user_token) + assert max_age == 5_184_000 + end + end + + describe "logout_user/1" do + test "erases session and cookies", %{conn: conn, user: user} do + user_token = Accounts.generate_user_session_token(user) + + conn = + conn + |> put_session(:user_token, user_token) + |> put_req_cookie(@remember_me_cookie, user_token) + |> fetch_cookies() + |> UserAuth.log_out_user() + + refute get_session(conn, :user_token) + refute conn.cookies[@remember_me_cookie] + assert %{max_age: 0} = conn.resp_cookies[@remember_me_cookie] + assert redirected_to(conn) == ~p"/" + refute Accounts.get_user_by_session_token(user_token) + end + + test "broadcasts to the given live_socket_id", %{conn: conn} do + live_socket_id = "users_sessions:abcdef-token" + NullaWeb.Endpoint.subscribe(live_socket_id) + + conn + |> put_session(:live_socket_id, live_socket_id) + |> UserAuth.log_out_user() + + assert_receive %Phoenix.Socket.Broadcast{event: "disconnect", topic: ^live_socket_id} + end + + test "works even if user is already logged out", %{conn: conn} do + conn = conn |> fetch_cookies() |> UserAuth.log_out_user() + refute get_session(conn, :user_token) + assert %{max_age: 0} = conn.resp_cookies[@remember_me_cookie] + assert redirected_to(conn) == ~p"/" + end + end + + describe "fetch_current_user/2" do + test "authenticates user from session", %{conn: conn, user: user} do + user_token = Accounts.generate_user_session_token(user) + conn = conn |> put_session(:user_token, user_token) |> UserAuth.fetch_current_user([]) + assert conn.assigns.current_user.id == user.id + end + + test "authenticates user from cookies", %{conn: conn, user: user} do + logged_in_conn = + conn |> fetch_cookies() |> UserAuth.log_in_user(user, %{"remember_me" => "true"}) + + user_token = logged_in_conn.cookies[@remember_me_cookie] + %{value: signed_token} = logged_in_conn.resp_cookies[@remember_me_cookie] + + conn = + conn + |> put_req_cookie(@remember_me_cookie, signed_token) + |> UserAuth.fetch_current_user([]) + + assert conn.assigns.current_user.id == user.id + assert get_session(conn, :user_token) == user_token + + assert get_session(conn, :live_socket_id) == + "users_sessions:#{Base.url_encode64(user_token)}" + end + + test "does not authenticate if data is missing", %{conn: conn, user: user} do + _ = Accounts.generate_user_session_token(user) + conn = UserAuth.fetch_current_user(conn, []) + refute get_session(conn, :user_token) + refute conn.assigns.current_user + end + end + + describe "on_mount :mount_current_user" do + test "assigns current_user based on a valid user_token", %{conn: conn, user: user} do + user_token = Accounts.generate_user_session_token(user) + session = conn |> put_session(:user_token, user_token) |> get_session() + + {:cont, updated_socket} = + UserAuth.on_mount(:mount_current_user, %{}, session, %LiveView.Socket{}) + + assert updated_socket.assigns.current_user.id == user.id + end + + test "assigns nil to current_user assign if there isn't a valid user_token", %{conn: conn} do + user_token = "invalid_token" + session = conn |> put_session(:user_token, user_token) |> get_session() + + {:cont, updated_socket} = + UserAuth.on_mount(:mount_current_user, %{}, session, %LiveView.Socket{}) + + assert updated_socket.assigns.current_user == nil + end + + test "assigns nil to current_user assign if there isn't a user_token", %{conn: conn} do + session = conn |> get_session() + + {:cont, updated_socket} = + UserAuth.on_mount(:mount_current_user, %{}, session, %LiveView.Socket{}) + + assert updated_socket.assigns.current_user == nil + end + end + + describe "on_mount :ensure_authenticated" do + test "authenticates current_user based on a valid user_token", %{conn: conn, user: user} do + user_token = Accounts.generate_user_session_token(user) + session = conn |> put_session(:user_token, user_token) |> get_session() + + {:cont, updated_socket} = + UserAuth.on_mount(:ensure_authenticated, %{}, session, %LiveView.Socket{}) + + assert updated_socket.assigns.current_user.id == user.id + end + + test "redirects to login page if there isn't a valid user_token", %{conn: conn} do + user_token = "invalid_token" + session = conn |> put_session(:user_token, user_token) |> get_session() + + socket = %LiveView.Socket{ + endpoint: NullaWeb.Endpoint, + assigns: %{__changed__: %{}, flash: %{}} + } + + {:halt, updated_socket} = UserAuth.on_mount(:ensure_authenticated, %{}, session, socket) + assert updated_socket.assigns.current_user == nil + end + + test "redirects to login page if there isn't a user_token", %{conn: conn} do + session = conn |> get_session() + + socket = %LiveView.Socket{ + endpoint: NullaWeb.Endpoint, + assigns: %{__changed__: %{}, flash: %{}} + } + + {:halt, updated_socket} = UserAuth.on_mount(:ensure_authenticated, %{}, session, socket) + assert updated_socket.assigns.current_user == nil + end + end + + describe "on_mount :redirect_if_user_is_authenticated" do + test "redirects if there is an authenticated user ", %{conn: conn, user: user} do + user_token = Accounts.generate_user_session_token(user) + session = conn |> put_session(:user_token, user_token) |> get_session() + + assert {:halt, _updated_socket} = + UserAuth.on_mount( + :redirect_if_user_is_authenticated, + %{}, + session, + %LiveView.Socket{} + ) + end + + test "doesn't redirect if there is no authenticated user", %{conn: conn} do + session = conn |> get_session() + + assert {:cont, _updated_socket} = + UserAuth.on_mount( + :redirect_if_user_is_authenticated, + %{}, + session, + %LiveView.Socket{} + ) + end + end + + describe "redirect_if_user_is_authenticated/2" do + test "redirects if user is authenticated", %{conn: conn, user: user} do + conn = conn |> assign(:current_user, user) |> UserAuth.redirect_if_user_is_authenticated([]) + assert conn.halted + assert redirected_to(conn) == ~p"/" + end + + test "does not redirect if user is not authenticated", %{conn: conn} do + conn = UserAuth.redirect_if_user_is_authenticated(conn, []) + refute conn.halted + refute conn.status + end + end + + describe "require_authenticated_user/2" do + test "redirects if user is not authenticated", %{conn: conn} do + conn = conn |> fetch_flash() |> UserAuth.require_authenticated_user([]) + assert conn.halted + + assert redirected_to(conn) == ~p"/users/log_in" + + assert Phoenix.Flash.get(conn.assigns.flash, :error) == + "You must log in to access this page." + end + + test "stores the path to redirect to on GET", %{conn: conn} do + halted_conn = + %{conn | path_info: ["foo"], query_string: ""} + |> fetch_flash() + |> UserAuth.require_authenticated_user([]) + + assert halted_conn.halted + assert get_session(halted_conn, :user_return_to) == "/foo" + + halted_conn = + %{conn | path_info: ["foo"], query_string: "bar=baz"} + |> fetch_flash() + |> UserAuth.require_authenticated_user([]) + + assert halted_conn.halted + assert get_session(halted_conn, :user_return_to) == "/foo?bar=baz" + + halted_conn = + %{conn | path_info: ["foo"], query_string: "bar", method: "POST"} + |> fetch_flash() + |> UserAuth.require_authenticated_user([]) + + assert halted_conn.halted + refute get_session(halted_conn, :user_return_to) + end + + test "does not redirect if user is authenticated", %{conn: conn, user: user} do + conn = conn |> assign(:current_user, user) |> UserAuth.require_authenticated_user([]) + refute conn.halted + refute conn.status + end + end +end diff --git a/test/support/conn_case.ex b/test/support/conn_case.ex index 10ed035..ce34e8d 100644 --- a/test/support/conn_case.ex +++ b/test/support/conn_case.ex @@ -35,4 +35,30 @@ defmodule NullaWeb.ConnCase do Nulla.DataCase.setup_sandbox(tags) {:ok, conn: Phoenix.ConnTest.build_conn()} end + + @doc """ + Setup helper that registers and logs in users. + + setup :register_and_log_in_user + + It stores an updated connection and a registered user in the + test context. + """ + def register_and_log_in_user(%{conn: conn}) do + user = Nulla.AccountsFixtures.user_fixture() + %{conn: log_in_user(conn, user), user: user} + end + + @doc """ + Logs the given `user` into the `conn`. + + It returns an updated `conn`. + """ + def log_in_user(conn, user) do + token = Nulla.Accounts.generate_user_session_token(user) + + conn + |> Phoenix.ConnTest.init_test_session(%{}) + |> Plug.Conn.put_session(:user_token, token) + end end diff --git a/test/support/fixtures/accounts_fixtures.ex b/test/support/fixtures/accounts_fixtures.ex new file mode 100644 index 0000000..e37764f --- /dev/null +++ b/test/support/fixtures/accounts_fixtures.ex @@ -0,0 +1,31 @@ +defmodule Nulla.AccountsFixtures do + @moduledoc """ + This module defines test helpers for creating + entities via the `Nulla.Accounts` context. + """ + + def unique_user_email, do: "user#{System.unique_integer()}@example.com" + def valid_user_password, do: "hello world!" + + def valid_user_attributes(attrs \\ %{}) do + Enum.into(attrs, %{ + email: unique_user_email(), + password: valid_user_password() + }) + end + + def user_fixture(attrs \\ %{}) do + {:ok, user} = + attrs + |> valid_user_attributes() + |> Nulla.Accounts.register_user() + + user + end + + def extract_user_token(fun) do + {:ok, captured_email} = fun.(&"[TOKEN]#{&1}[TOKEN]") + [_, token | _] = String.split(captured_email.text_body, "[TOKEN]") + token + end +end diff --git a/test/support/fixtures/activities_fixtures.ex b/test/support/fixtures/activities_fixtures.ex new file mode 100644 index 0000000..d9884ac --- /dev/null +++ b/test/support/fixtures/activities_fixtures.ex @@ -0,0 +1,25 @@ +defmodule Nulla.ActivitiesFixtures do + @moduledoc """ + This module defines test helpers for creating + entities via the `Nulla.Activities` context. + """ + + @doc """ + Generate a activity. + """ + def activity_fixture(attrs \\ %{}) do + {:ok, activity} = + attrs + |> Enum.into(%{ + actor: "some actor", + ap_id: "some ap_id", + cc: ["option1", "option2"], + object: "some object", + to: ["option1", "option2"], + type: "some type" + }) + |> Nulla.Activities.create_activity() + + activity + end +end diff --git a/test/support/fixtures/actors_fixtures.ex b/test/support/fixtures/actors_fixtures.ex new file mode 100644 index 0000000..2381cbe --- /dev/null +++ b/test/support/fixtures/actors_fixtures.ex @@ -0,0 +1,60 @@ +defmodule Nulla.ActorsFixtures do + alias Nulla.KeyGen + + @moduledoc """ + This module defines test helpers for creating + entities via the `Nulla.Actors` context. + """ + + @doc """ + Generate a actor. + """ + def actor_fixture(attrs \\ %{}) do + username = "test#{System.unique_integer()}" + {publicKeyPem, privateKeyPem} = KeyGen.gen() + + attrs = + Enum.into(attrs, %{ + acct: "#{username}@localhost", + ap_id: "http://localhost/users/#{username}", + attachment: [], + discoverable: true, + endpoints: %{}, + featured: "some featured", + featuredTags: "some featuredTags", + followers: "some followers", + following: "some following", + icon: %{}, + image: %{}, + inbox: "some inbox", + indexable: true, + manuallyApprovesFollowers: true, + memorial: true, + name: "some name", + outbox: "some outbox", + preferredUsername: username, + publicKey: %{ + "id" => "http://localhost/users/#{username}#main-key", + "owner" => "http://localhost/users/#{username}", + "publicKeyPem" => publicKeyPem + }, + privateKeyPem: privateKeyPem, + published: ~U[2025-06-30 13:31:00Z], + summary: "some summary", + tag: [], + type: "some type", + url: "some url", + vcard_Address: "some vcard_Address", + vcard_bday: ~D[2025-06-30] + }) + + case Nulla.Actors.create_actor(attrs) do + {:ok, actor} -> + actor + + {:error, changeset} -> + IO.inspect(changeset, label: "Actor creation failed") + raise "Failed to create actor fixture" + end + end +end diff --git a/test/support/fixtures/data.ex b/test/support/fixtures/data.ex deleted file mode 100644 index 11f26f5..0000000 --- a/test/support/fixtures/data.ex +++ /dev/null @@ -1,93 +0,0 @@ -defmodule Nulla.Fixtures.Data do - alias Nulla.KeyGen - alias Nulla.Models.User - alias Nulla.Models.Actor - alias Nulla.Models.Note - - def create_data do - {publicKeyPem, privateKeyPem} = KeyGen.gen() - - {:ok, actor} = - Actor.create_actor(%{ - domain: "localhost", - ap_id: "http://localhost/users/test", - type: "Person", - following: "http://localhost/users/test/following", - followers: "http://localhost/users/test/followers", - inbox: "http://localhost/users/test/inbox", - outbox: "http://localhost/users/test/outbox", - featured: "http://localhost/users/test/collections/featured", - featuredTags: "http://localhost/users/test/collections/tags", - preferredUsername: "test", - name: "Test", - summary: "Test User", - url: "http://localhost/@test", - manuallyApprovesFollowers: false, - discoverable: true, - indexable: true, - published: DateTime.utc_now(), - memorial: false, - publicKey: - Jason.OrderedObject.new( - id: "http://localhost/users/test#main-key", - owner: "http://localhost/users/test", - publicKeyPem: publicKeyPem - ), - endpoints: Jason.OrderedObject.new(sharedInbox: "http://localhost/inbox") - }) - - User.create_user(%{ - id: actor.id, - email: "test@localhost", - password: "password", - privateKeyPem: privateKeyPem, - last_active_at: DateTime.utc_now() - }) - - Note.create_note(%{ - visibility: "public", - content: "Hello World from Nulla!", - language: "en", - actor_id: actor.id - }) - - {publicKeyPem, privateKeyPem} = KeyGen.gen() - - {:ok, actor} = - Actor.create_actor(%{ - domain: "localhost", - ap_id: "http://localhost/users/test2", - type: "Person", - following: "http://localhost/users/test2/following", - followers: "http://localhost/users/test2/followers", - inbox: "http://localhost/users/test2/inbox", - outbox: "http://localhost/users/test2/outbox", - featured: "http://localhost/users/test2/collections/featured", - featuredTags: "http://localhost/users/test2/collections/tags", - preferredUsername: "test2", - name: "Test", - summary: "Test User", - url: "http://localhost/@test2", - manuallyApprovesFollowers: false, - discoverable: true, - indexable: true, - published: DateTime.utc_now(), - memorial: false, - publicKey: - Jason.OrderedObject.new( - id: "http://localhost/users/test2#main-key", - owner: "http://localhost/users/test2", - publicKeyPem: publicKeyPem - ), - endpoints: Jason.OrderedObject.new(sharedInbox: "http://localhost/inbox") - }) - - User.create_user(%{ - id: actor.id, - email: "test2@localhost", - password: "password", - privateKeyPem: privateKeyPem, - last_active_at: DateTime.utc_now() - }) - end -end diff --git a/test/support/fixtures/media_attachments_fixtures.ex b/test/support/fixtures/media_attachments_fixtures.ex new file mode 100644 index 0000000..c9bd54f --- /dev/null +++ b/test/support/fixtures/media_attachments_fixtures.ex @@ -0,0 +1,30 @@ +defmodule Nulla.MediaAttachmentsFixtures do + import Nulla.NotesFixtures + + @moduledoc """ + This module defines test helpers for creating + entities via the `Nulla.MediaAttachments` context. + """ + + @doc """ + Generate a media_attachment. + """ + def media_attachment_fixture(attrs \\ %{}) do + note = note_fixture() + + {:ok, media_attachment} = + attrs + |> Enum.into(%{ + height: 42, + mediaType: "some mediaType", + name: "some name", + type: "some type", + url: "some url", + width: 42, + note_id: note.id + }) + |> Nulla.MediaAttachments.create_media_attachment() + + media_attachment + end +end diff --git a/test/support/fixtures/notes_fixtures.ex b/test/support/fixtures/notes_fixtures.ex new file mode 100644 index 0000000..a3d34b1 --- /dev/null +++ b/test/support/fixtures/notes_fixtures.ex @@ -0,0 +1,33 @@ +defmodule Nulla.NotesFixtures do + import Nulla.ActorsFixtures + + @moduledoc """ + This module defines test helpers for creating + entities via the `Nulla.Notes` context. + """ + + @doc """ + Generate a note. + """ + def note_fixture(attrs \\ %{}) do + actor = actor_fixture() + + {:ok, note} = + attrs + |> Enum.into(%{ + cc: ["option1", "option2"], + content: "some content", + inReplyTo: "some inReplyTo", + language: "some language", + published: ~U[2025-07-01 09:17:00Z], + sensitive: true, + to: ["option1", "option2"], + url: "some url", + visibility: "some visibility", + actor_id: actor.id + }) + |> Nulla.Notes.create_note() + + note + end +end diff --git a/test/support/fixtures/relations_fixtures.ex b/test/support/fixtures/relations_fixtures.ex new file mode 100644 index 0000000..ca7ef41 --- /dev/null +++ b/test/support/fixtures/relations_fixtures.ex @@ -0,0 +1,38 @@ +defmodule Nulla.RelationsFixtures do + import Nulla.ActorsFixtures + + @moduledoc """ + This module defines test helpers for creating + entities via the `Nulla.Relations` context. + """ + + @doc """ + Generate a relation. + """ + def relation_fixture(attrs \\ %{}) do + local_actor = actor_fixture() + remote_actor = actor_fixture() + + {:ok, relation} = + attrs + |> Enum.into(%{ + blocked_by: true, + blocking: true, + domain_blocking: true, + followed_by: true, + following: true, + muting: true, + muting_notifications: true, + note: "some note", + notifying: true, + requested: true, + showing_replies: true, + showings_reblogs: true, + local_actor_id: local_actor.id, + remote_actor_id: remote_actor.id + }) + |> Nulla.Relations.create_relation() + + relation + end +end