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 @@
+