Compare commits

...

No commits in common. "ddfb58c04119348b085d87647458252dcc981773" and "82f55f7afea2b9d3e8af3f7b0a7adcd894b47845" have entirely different histories.

137 changed files with 6517 additions and 3305 deletions

4
.gitignore vendored
View file

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

View file

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

319
README.md
View file

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

View file

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

View file

@ -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)]}

View file

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

377
lib/nulla/accounts.ex Normal file
View file

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

171
lib/nulla/accounts/user.ex Normal file
View file

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

View file

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

View file

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

104
lib/nulla/activities.ex Normal file
View file

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

View file

@ -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())
defp maybe_put_id(%Changeset{data: %{id: nil}} = changeset) do
change(changeset, id: Snowflake.next_id())
end
%__MODULE__{}
|> __MODULE__.changeset(attrs)
|> put_change(:id, id)
|> Repo.insert()
end
defp maybe_put_id(changeset), do: changeset
end

View file

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

104
lib/nulla/actors.ex Normal file
View file

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

95
lib/nulla/actors/actor.ex Normal file
View file

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

View file

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

View file

@ -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())
defp maybe_put_id(%Changeset{data: %{id: nil}} = changeset) do
change(changeset, id: Snowflake.next_id())
end
%__MODULE__{}
|> changeset(attrs)
|> put_change(:id, id)
|> Repo.insert()
end
defp maybe_put_id(changeset), do: changeset
end

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

104
lib/nulla/notes.ex Normal file
View file

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

60
lib/nulla/notes/note.ex Normal file
View file

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

104
lib/nulla/relations.ex Normal file
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -12,6 +12,47 @@
</script>
</head>
<body class="bg-white">
<ul class="relative z-10 flex items-center gap-4 px-4 sm:px-6 lg:px-8 justify-end">
<%= if @current_user do %>
<li class="text-[0.8125rem] leading-6 text-zinc-900">
{@current_user.email}
</li>
<li>
<.link
href={~p"/users/settings"}
class="text-[0.8125rem] leading-6 text-zinc-900 font-semibold hover:text-zinc-700"
>
Settings
</.link>
</li>
<li>
<.link
href={~p"/users/log_out"}
method="delete"
class="text-[0.8125rem] leading-6 text-zinc-900 font-semibold hover:text-zinc-700"
>
Log out
</.link>
</li>
<% else %>
<li>
<.link
href={~p"/users/register"}
class="text-[0.8125rem] leading-6 text-zinc-900 font-semibold hover:text-zinc-700"
>
Register
</.link>
</li>
<li>
<.link
href={~p"/users/log_in"}
class="text-[0.8125rem] leading-6 text-zinc-900 font-semibold hover:text-zinc-700"
>
Log in
</.link>
</li>
<% end %>
</ul>
{@inner_content}
</body>
</html>

View file

@ -1,125 +0,0 @@
defmodule NullaWeb.PageHTML do
use NullaWeb, :html
embed_templates "templates/page/*"
end
defmodule NullaWeb.ActorHTML do
use NullaWeb, :html
embed_templates "templates/actor/*"
def avatar(url) do
if url do
url
else
"/images/default-avatar.jpg"
end
end
def profile_header(url) do
if url do
url
else
"/images/default-header.jpg"
end
end
def format_birthdate(date) do
formatted = Date.to_string(date) |> String.replace("-", "/")
today = Date.utc_today()
age =
today.year - date.year -
if {today.month, today.day} < {date.month, date.day}, do: 1, else: 0
"#{formatted} (#{age} years old)"
end
def format_registration_date(date) do
now = Date.utc_today()
formatted = Date.to_string(date) |> String.replace("-", "/")
diff_days = Date.diff(now, date)
cond do
diff_days == 0 ->
"#{formatted} (today)"
diff_days == 1 ->
"#{formatted} (1 day ago)"
diff_days < 30 ->
"#{formatted} (#{diff_days} days ago)"
diff_days < 365 ->
year_diff = now.year - date.year
month_diff = now.month - date.month
day_correction = if now.day < date.day, do: -1, else: 0
months = year_diff * 12 + month_diff + day_correction
if months == 1 do
"#{formatted} (1 month ago)"
else
"#{formatted} (#{months} months ago)"
end
true ->
year_diff = now.year - date.year
years =
if {now.month, now.day} < {date.month, date.day}, do: year_diff - 1, else: year_diff
if years == 1 do
"#{formatted} (1 year ago)"
else
"#{formatted} (#{years} years ago)"
end
end
end
def format_note_datetime(datetime) do
Calendar.strftime(datetime, "%d %B %Y, %H:%M")
end
def format_note_datetime_diff(datetime) do
now = DateTime.utc_now()
diff_seconds = DateTime.diff(now, datetime)
cond do
diff_seconds < 60 ->
"now"
diff_seconds < 3600 ->
minutes = div(diff_seconds, 60)
"#{minutes}m ago"
diff_seconds < 86400 ->
hours = div(diff_seconds, 3600)
"#{hours}h ago"
diff_seconds < 604_800 ->
days = div(diff_seconds, 86400)
"#{days}d ago"
diff_seconds < 2_592_000 ->
weeks = div(diff_seconds, 604_800)
"#{weeks}w ago"
diff_seconds < 31_536_000 ->
months = div(diff_seconds, 2_592_000)
"#{months}mo ago"
true ->
years = div(diff_seconds, 31_536_000)
"#{years}y ago"
end
end
end
defmodule NullaWeb.NoteHTML do
use NullaWeb, :html
embed_templates "templates/note/*"
end

View file

@ -1,127 +0,0 @@
<main class="grid grid-cols-[25%_50%_25%]">
<div class="flex flex-col items-center mt-5 gap-5">
<input
placeholder="Search"
class="border border-gray-300 px-4 py-3 rounded-xl outline-none w-[90%]"
/>
<div class="text-sm rounded-xl border border-gray-300 p-2 w-[90%]">
<textarea
placeholder="What's on your mind?"
class="h-[150px] w-full resize-none border-none focus:ring-0"
></textarea>
<div>
<button class="text-white bg-black px-3 py-1 rounded-xl">Post</button>
</div>
</div>
</div>
<div class="relative border border-gray-300 shadow-md mt-5 rounded-t-xl overflow-hidden">
<div class="relative w-full aspect-[3/1]">
<img src={profile_header(@actor.image["url"])} class="w-full h-full object-cover" />
<div class="absolute inset-0 flex items-end justify-between px-4 pb-2 pointer-events-none">
<img
src={avatar(@actor.icon["url"])}
}
class="translate-y-1/2 rounded-full border-4 border-white w-[8.33vw] h-[8.33vw] min-w-[80px] min-h-[80px] max-w-[160px] max-h-[160px] pointer-events-auto"
/>
<button class="px-8 py-2 rounded-full text-sm font-semibold border transition bg-black text-white border-black hover:bg-gray-900 pointer-events-auto">
Follow
</button>
</div>
</div>
<div class="mt-[4.5vw] px-4 flex flex-col">
<span class="text-xl font-bold">{@actor.name}</span>
<span class="text-gray-500">@{@actor.preferredUsername}@{@actor.domain}</span>
<div class="text-sm pt-2">
<p>{@actor.summary}</p>
</div>
<dl class="mt-2 text-sm text-gray-700 grid grid-cols-[auto,1fr] gap-x-2 gap-y-1 items-center">
<%= if @actor.vcard_Address do %>
<dt class="flex items-center gap-2">
<.icon name="hero-map-pin" class="mt-0.5 h-5 w-5 flex-none" />
</dt>
<dd>{@actor.vcard_Address}</dd>
<% end %>
<%= if @actor.vcard_bday do %>
<dt class="flex items-center gap-2">
<.icon name="hero-cake" class="mt-0.5 h-5 w-5 flex-none" />
</dt>
<dd>{format_birthdate(@actor.vcard_bday)}</dd>
<% end %>
<dt class="flex items-center gap-2">
<.icon name="hero-calendar" class="mt-0.5 h-5 w-5 flex-none" />
</dt>
<dd>{format_registration_date(@actor.published)}</dd>
</dl>
<%= if @actor.attachment do %>
<dl class="mt-5 grid grid-cols-[max-content,1fr] gap-x-5 gap-y-2 items-center">
<%= for %{"type" => "PropertyValue", "name" => name, "value" => value} <- @actor.attachment do %>
<dt>{name}</dt>
<dd>
<%= if Regex.match?(~r{://}, value) do %>
<a href={value} class="text-[#1D9BF0]">{Regex.replace(~r{^\w+://}, value, "")}</a>
<% else %>
{value}
<% end %>
</dd>
<% end %>
</dl>
<% end %>
<div class="flex mt-5 gap-3">
<a href={~p"/@#{@actor.preferredUsername}"}>{length(@notes)} Posts</a>
<a href={~p"/@#{@actor.preferredUsername}/following"}>{@following} Following</a>
<a href={~p"/@#{@actor.preferredUsername}/followers"}>{@followers} Followers</a>
</div>
</div>
<div class="flex justify-between px-20 py-2 mt-5 border-y border-gray-300">
<a href={~p"/@#{@actor.preferredUsername}/featured"}>Featured</a>
<a href={~p"/@#{@actor.preferredUsername}"}>Posts</a>
<a href={~p"/@#{@actor.preferredUsername}/with_replies"}>Posts and replies</a>
<a href={~p"/@#{@actor.preferredUsername}/media"}>Media</a>
</div>
<div>
<%= for note <- @notes do %>
<div class="p-4 border-b border-gray-300">
<div class="flex items-start space-x-4">
<img src={avatar(@actor.icon["url"])} } class="rounded-full w-[58px] h-[58px]" />
<div class="flex-1">
<div class="flex justify-between items-start">
<div class="flex items-center space-x-2">
<span class="font-semibold text-gray-900 text-sm">
{@actor.name}
</span>
<span class="text-gray-500 text-sm">
@{@actor.preferredUsername}@{@actor.domain}
</span>
</div>
<div class="flex items-center space-x-2 text-sm text-gray-500">
<%= case note.visibility do %>
<% "public" -> %>
<.icon name="hero-globe-americas" class="h-5 w-5" />
<% "unlisted" -> %>
<.icon name="hero-moon" class="h-5 w-5" />
<% "followers" -> %>
<.icon name="hero-lock-closed" class="h-5 w-5" />
<% "private" -> %>
<.icon name="hero-at-symbol" class="h-5 w-5" />
<% end %>
<span>{format_note_datetime_diff(note.inserted_at)}</span>
</div>
</div>
<div class="text-gray-800">
<p>{note.content}</p>
</div>
<div class="flex gap-10 mt-4">
<button><.icon name="hero-chat-bubble-left" class="h-5 w-5" /></button>
<button><.icon name="hero-arrow-path-rounded-square" class="h-5 w-5" /></button>
<button><.icon name="hero-plus" class="h-5 w-5" /></button>
</div>
</div>
</div>
</div>
<% end %>
</div>
</div>
<div class="flex flex-col items-center mt-5 gap-5">
<div class="text-sm rounded-xl border border-gray-300 p-4 w-[90%] h-[300px]"></div>
</div>
</main>

View file

@ -1,23 +0,0 @@
<main class="grid grid-cols-[25%_50%_25%]">
<div class="flex flex-col items-center mt-5 gap-5">
<input
placeholder="Search"
class="border border-gray-300 px-4 py-3 rounded-xl outline-none w-[90%]"
/>
<div class="text-sm rounded-xl border border-gray-300 p-2 w-[90%]">
<textarea
placeholder="What's on your mind?"
class="h-[150px] w-full resize-none border-none focus:ring-0"
></textarea>
<div>
<button class="text-white bg-black px-3 py-1 rounded-xl">Post</button>
</div>
</div>
</div>
<div class="relative border border-gray-300 shadow-md mt-5 rounded-t-xl overflow-hidden">
{@note.content}
</div>
<div class="flex flex-col items-center mt-5 gap-5">
<div class="text-sm rounded-xl border border-gray-300 p-4 w-[90%] h-[300px]"></div>
</div>
</main>

View file

@ -1,3 +0,0 @@
<main>
<.flash_group flash={@flash} />
</main>

View file

@ -1,12 +0,0 @@
<main class="flex min-h-screen items-center justify-center">
<.flash_group flash={@flash} />
<form class="flex flex-col gap-2" action="/auth/sign_in" method="post">
<input type="hidden" name="_csrf_token" value={get_csrf_token()} />
<label for="email">E-mail address *</label>
<input id="email" name="email" type="email" maxlength="50" required />
<label for="password">Password *</label>
<input id="password" name="password" type="password" minlength="8" maxlength="50" required />
<button class="text-white bg-black py-2 border" type="submit">Log in</button>
</form>
</main>

View file

@ -1,22 +0,0 @@
<main class="flex min-h-screen items-center justify-center">
<.flash_group flash={@flash} />
<form class="flex flex-col gap-2" action="/auth" method="post">
<input type="hidden" name="_csrf_token" value={get_csrf_token()} />
<label for="username">Username *</label>
<input id="username" name="username" type="text" maxlength="30" required />
<label for="email">E-mail address *</label>
<input id="email" name="email" type="email" maxlength="50" required />
<label for="password">Password *</label>
<input id="password" name="password" type="password" minlength="8" maxlength="50" required />
<input
id="password_confirm"
name="password_confirm"
type="password"
minlength="8"
maxlength="50"
placeholder="Confirm password"
/>
<button class="text-white bg-black py-2 border" type="submit">Sign up</button>
</form>
</main>

View file

@ -0,0 +1,43 @@
defmodule NullaWeb.ActivityController do
use NullaWeb, :controller
alias Nulla.Activities
alias Nulla.Activities.Activity
action_fallback NullaWeb.FallbackController
def index(conn, _params) do
activities = Activities.list_activities()
render(conn, :index, activities: activities)
end
def create(conn, %{"activity" => activity_params}) do
with {:ok, %Activity{} = activity} <- Activities.create_activity(activity_params) do
conn
|> put_status(:created)
|> put_resp_header("location", ~p"/api/activities/#{activity}")
|> render(:show, activity: activity)
end
end
def show(conn, %{"id" => id}) do
activity = Activities.get_activity!(id)
render(conn, :show, activity: activity)
end
def update(conn, %{"id" => id, "activity" => activity_params}) do
activity = Activities.get_activity!(id)
with {:ok, %Activity{} = activity} <- Activities.update_activity(activity, activity_params) do
render(conn, :show, activity: activity)
end
end
def delete(conn, %{"id" => id}) do
activity = Activities.get_activity!(id)
with {:ok, %Activity{}} <- Activities.delete_activity(activity) do
send_resp(conn, :no_content, "")
end
end
end

View file

@ -0,0 +1,41 @@
defmodule NullaWeb.ActivityJSON do
alias Nulla.Activities.Activity
@doc """
Renders a list of activities.
"""
def index(%{activities: activities}) do
%{data: for(activity <- activities, do: data(activity))}
end
@doc """
Renders a single activity.
"""
def show(%{activity: activity}) do
%{data: data(activity)}
end
defp data(%Activity{} = activity) do
%{
id: activity.id,
ap_id: activity.ap_id,
type: activity.type,
actor: activity.actor,
object: activity.object,
to: activity.to,
cc: activity.cc
}
end
def activitypub(%Activity{} = activity) do
Jason.OrderedObject.new(
"@context": "https://www.w3.org/ns/activitystreams",
id: activity.ap_id,
type: activity.type,
actor: activity.actor,
object: activity.object,
to: activity.to,
cc: activity.cc
)
end
end

View file

@ -1,21 +0,0 @@
defmodule NullaWeb.ActivityPub.ActorController do
use NullaWeb, :controller
alias Nulla.ActivityPub
alias Nulla.Models.Actor
def show(conn, %{"username" => username}) do
domain = NullaWeb.Endpoint.host()
case Actor.get_actor(preferredUsername: username, domain: domain) do
nil ->
conn
|> put_status(:not_found)
|> json(%{error: "Not Found"})
%Actor{} = actor ->
conn
|> put_resp_content_type("application/activity+json")
|> send_resp(200, Jason.encode!(ActivityPub.actor(actor)))
end
end
end

View file

@ -1,67 +0,0 @@
defmodule NullaWeb.ActivityPub.FollowController do
use NullaWeb, :controller
alias Nulla.ActivityPub
alias Nulla.Models.Actor
alias Nulla.Models.Relation
alias Nulla.Models.InstanceSettings
def following(conn, %{"username" => username, "page" => page_param}) do
instance_settings = InstanceSettings.get_instance_settings!()
domain = NullaWeb.Endpoint.host()
limit = instance_settings.api_limit
actor = Actor.get_actor(preferredUsername: username, domain: domain)
total = Relation.count_following(actor.id)
page =
case Integer.parse(page_param) do
{int, _} when int > 0 -> int
_ -> 1
end
following_list = Enum.map(Relation.get_following(actor.id, page, limit), & &1.ap_id)
conn
|> put_resp_content_type("application/activity+json")
|> json(ActivityPub.following(actor, total, following_list, page, limit))
end
def following(conn, %{"username" => username}) do
domain = NullaWeb.Endpoint.host()
actor = Actor.get_actor(preferredUsername: username, domain: domain)
total = Relation.count_following(actor.id)
conn
|> put_resp_content_type("application/activity+json")
|> json(ActivityPub.following(actor, total))
end
def followers(conn, %{"username" => username, "page" => page_param}) do
instance_settings = InstanceSettings.get_instance_settings!()
domain = NullaWeb.Endpoint.host()
limit = instance_settings.api_limit
actor = Actor.get_actor(preferredUsername: username, domain: domain)
total = Relation.count_followers(actor.id)
page =
case Integer.parse(page_param) do
{int, _} when int > 0 -> int
_ -> 1
end
followers_list = Enum.map(Relation.get_followers(actor.id, page, limit), & &1.ap_id)
conn
|> put_resp_content_type("application/activity+json")
|> json(ActivityPub.followers(actor, total, followers_list, page, limit))
end
def followers(conn, %{"username" => username}) do
domain = NullaWeb.Endpoint.host()
actor = Actor.get_actor(preferredUsername: username, domain: domain)
total = Relation.count_followers(actor.id)
conn
|> put_resp_content_type("application/activity+json")
|> json(ActivityPub.followers(actor, total))
end
end

View file

@ -1,18 +0,0 @@
defmodule NullaWeb.ActivityPub.HostmetaController do
use NullaWeb, :controller
def index(conn, _params) do
domain = NullaWeb.Endpoint.host()
xml = """
<?xml version="1.0" encoding="UTF-8"?>
<XRD xmlns="http://docs.oasis-open.org/ns/xri/xrd-1.0">
<Link rel="lrdd" type="application/xrd+xml" template="https://#{domain}/.well-known/webfinger?resource={uri}"/>
</XRD>
"""
conn
|> put_resp_content_type("application/xrd+xml")
|> send_resp(200, xml)
end
end

View file

@ -1,215 +0,0 @@
defmodule NullaWeb.ActivityPub.InboxController do
use NullaWeb, :controller
alias Nulla.Snowflake
alias Nulla.HTTPSignature
alias Nulla.Sender
alias Nulla.Utils
alias Nulla.Models.User
alias Nulla.Models.Actor
alias Nulla.Models.Relation
alias Nulla.Models.Activity
def inbox(conn, %{
"id" => _create_id,
"type" => "Create",
"actor" => _actor_uri,
"object" => _target_uri
}) do
send_resp(conn, 200, "")
end
def inbox(conn, %{
"id" => _read_id,
"type" => "Read",
"actor" => _actor_uri,
"object" => _target_uri
}) do
send_resp(conn, 200, "")
end
def inbox(conn, %{
"id" => _update_id,
"type" => "Update",
"actor" => _actor_uri,
"object" => _target_uri
}) do
send_resp(conn, 200, "")
end
def inbox(conn, %{
"id" => _delete_id,
"type" => "Delete",
"actor" => _actor_uri,
"object" => _target_uri
}) do
send_resp(conn, 200, "")
end
def inbox(conn, %{
"id" => _add_id,
"type" => "Add",
"actor" => _actor_uri,
"object" => _target_uri
}) do
send_resp(conn, 200, "")
end
def inbox(conn, %{
"id" => _view_id,
"type" => "View",
"actor" => _actor_uri,
"object" => _target_uri
}) do
send_resp(conn, 200, "")
end
def inbox(conn, %{
"id" => _move_id,
"type" => "Move",
"actor" => _actor_uri,
"object" => _target_uri
}) do
send_resp(conn, 200, "")
end
def inbox(conn, %{
"id" => _undo_id,
"type" => "Undo",
"actor" => _actor_uri,
"object" => _target_uri
}) do
send_resp(conn, 200, "")
end
def inbox(conn, %{
"id" => follow_id,
"type" => "Follow",
"actor" => actor_uri,
"object" => target_uri
}) do
accept_id = Snowflake.next_id()
with local_actor <- Actor.get_actor(ap_id: target_uri),
{:ok, remote_actor_json} <- Utils.fetch_remote_actor(actor_uri),
:ok <- HTTPSignature.verify(conn, remote_actor_json["publicKey"]["publicKeyPem"]),
{:ok, remote_actor} <- Actor.get_or_create_actor(remote_actor_json),
{:ok, follow_activity} <-
Activity.create_activity(%{
ap_id: follow_id,
type: "Follow",
actor: remote_actor.ap_id,
object: target_uri
}),
{:ok, accept_activity} <-
Activity.create_activity(%{
id: accept_id,
ap_id: "https://#{local_actor.domain}/activities/accept/#{accept_id}",
type: "Accept",
actor: local_actor.ap_id,
object: Jason.encode!(follow_activity)
}),
{:ok, _relation} <-
Relation.get_or_create_relation(local_actor.id, remote_actor.id, followed_by: true) do
user = User.get_user(id: local_actor.id)
Sender.send_activity(
:post,
remote_actor.inbox,
accept_activity,
local_actor.publicKey["id"],
user.privateKeyPem
)
send_resp(conn, 200, "")
else
error ->
IO.inspect(error, label: "Follow error")
json(conn, %{"error" => "Failed to process Follow"})
end
end
def inbox(conn, %{
"id" => _accept_id,
"type" => "Accept",
"actor" => _actor_uri,
"object" => _target_uri
}) do
send_resp(conn, 200, "")
end
def inbox(conn, %{
"id" => _reject_id,
"type" => "Reject",
"actor" => _actor_uri,
"object" => _target_uri
}) do
send_resp(conn, 200, "")
end
def inbox(conn, %{
"id" => _block_id,
"type" => "Block",
"actor" => _actor_uri,
"object" => _target_uri
}) do
send_resp(conn, 200, "")
end
def inbox(conn, %{
"id" => _join_id,
"type" => "Join",
"actor" => _actor_uri,
"object" => _target_uri
}) do
send_resp(conn, 200, "")
end
def inbox(conn, %{
"id" => _leave_id,
"type" => "Leave",
"actor" => _actor_uri,
"object" => _target_uri
}) do
send_resp(conn, 200, "")
end
def inbox(conn, %{
"id" => _like_id,
"type" => "Like",
"actor" => _actor_uri,
"object" => _target_uri
}) do
send_resp(conn, 200, "")
end
def inbox(conn, %{
"id" => _dislike_id,
"type" => "Dislike",
"actor" => _actor_uri,
"object" => _target_uri
}) do
send_resp(conn, 200, "")
end
def inbox(conn, %{
"id" => _announce_id,
"type" => "Announce",
"actor" => _actor_uri,
"object" => _target_uri
}) do
send_resp(conn, 200, "")
end
def inbox(conn, %{
"id" => _question_id,
"type" => "Question",
"actor" => _actor_uri,
"object" => _target_uri
}) do
send_resp(conn, 200, "")
end
def inbox(conn, _params) do
send_resp(conn, 400, "")
end
end

View file

@ -1,29 +0,0 @@
defmodule NullaWeb.ActivityPub.NodeinfoController do
use NullaWeb, :controller
alias Nulla.ActivityPub
alias Nulla.Models.User
alias Nulla.Models.InstanceSettings
def index(conn, _params) do
domain = NullaWeb.Endpoint.host()
json(conn, ActivityPub.nodeinfo(domain))
end
def show(conn, _params) do
version = Application.spec(:nulla, :vsn) |> to_string()
total = User.get_total_users_count()
month = User.get_active_users_count(30)
halfyear = User.get_active_users_count(180)
users = %{
total: total,
month: month,
halfyear: halfyear
}
instance_settings = InstanceSettings.get_instance_settings!()
json(conn, ActivityPub.nodeinfo(version, users, instance_settings))
end
end

View file

@ -1,44 +0,0 @@
defmodule NullaWeb.ActivityPub.NoteController do
use NullaWeb, :controller
alias Nulla.Repo
alias Nulla.ActivityPub
alias Nulla.Models.Note
def show(conn, %{"username" => username, "id" => id}) do
case Integer.parse(id) do
{int_id, ""} ->
note = Note.get_note(id: int_id) |> Repo.preload([:actor, :media_attachments])
cond do
is_nil(note) ->
conn
|> put_status(:not_found)
|> json(%{error: "Not Found"})
|> halt()
username != note.actor.preferredUsername ->
conn
|> put_status(:not_found)
|> json(%{error: "Not Found"})
|> halt()
true ->
format = Phoenix.Controller.get_format(conn)
if format == "activity+json" do
conn
|> put_resp_content_type("application/activity+json")
|> json(ActivityPub.note(note))
else
render(conn, :show, note: note, layout: false)
end
end
_ ->
conn
|> put_status(:not_found)
|> json(%{error: "Not Found"})
|> halt()
end
end
end

View file

@ -1,51 +0,0 @@
defmodule NullaWeb.ActivityPub.OutboxController do
use NullaWeb, :controller
alias Nulla.ActivityPub
alias Nulla.Models.Actor
alias Nulla.Models.Note
def outbox(conn, %{"username" => username} = params) do
domain = NullaWeb.Endpoint.host()
actor = Actor.get_actor(preferredUsername: username, domain: domain)
case Map.get(params, "page") do
"true" ->
max_id = params["max_id"] && String.to_integer(params["max_id"])
notes =
if max_id do
Note.get_before_notes(actor.id, max_id)
else
Note.get_latest_notes(actor.id)
end
items = Enum.map(notes, &ActivityPub.activity_note(&1))
next_max_id =
case List.last(notes) do
nil -> 0
last -> last.id
end
min_id =
case List.first(notes) do
nil -> 0
first -> first.id
end
conn
|> put_resp_content_type("application/activity+json")
|> send_resp(
200,
Jason.encode!(ActivityPub.outbox(actor, next_max_id, min_id || 0, items))
)
_ ->
total = Note.get_total_notes_count(actor.id)
conn
|> put_resp_content_type("application/activity+json")
|> send_resp(200, Jason.encode!(ActivityPub.outbox(actor, total)))
end
end
end

View file

@ -1,39 +0,0 @@
defmodule NullaWeb.ActivityPub.WebfingerController do
use NullaWeb, :controller
alias Nulla.ActivityPub
alias Nulla.Models.Actor
def index(conn, %{"resource" => resource}) do
case Regex.run(~r/^acct:(.+)@(.+)$/, resource) do
[_, preferredUsername, actor_domain] ->
case Actor.get_actor(preferredUsername: preferredUsername, domain: actor_domain) do
nil ->
conn
|> put_resp_content_type("text/plain")
|> send_resp(404, "")
%Actor{} = actor ->
domain = NullaWeb.Endpoint.host()
if actor_domain == domain do
json(conn, ActivityPub.webfinger(actor))
else
conn
|> put_resp_content_type("text/plain")
|> send_resp(404, "")
end
end
_ ->
conn
|> put_resp_content_type("text/plain")
|> send_resp(400, "")
end
end
def index(conn, _params) do
conn
|> put_resp_content_type("text/plain")
|> send_resp(400, "")
end
end

View file

@ -0,0 +1,43 @@
defmodule NullaWeb.ActorController do
use NullaWeb, :controller
alias Nulla.Actors
alias Nulla.Actors.Actor
action_fallback NullaWeb.FallbackController
def index(conn, _params) do
actors = Actors.list_actors()
render(conn, :index, actors: actors)
end
def create(conn, %{"actor" => actor_params}) do
with {:ok, %Actor{} = actor} <- Actors.create_actor(actor_params) do
conn
|> put_status(:created)
|> put_resp_header("location", ~p"/api/actors/#{actor}")
|> render(:show, actor: actor)
end
end
def show(conn, %{"id" => id}) do
actor = Actors.get_actor!(id)
render(conn, :show, actor: actor)
end
def update(conn, %{"id" => id, "actor" => actor_params}) do
actor = Actors.get_actor!(id)
with {:ok, %Actor{} = actor} <- Actors.update_actor(actor, actor_params) do
render(conn, :show, actor: actor)
end
end
def delete(conn, %{"id" => id}) do
actor = Actors.get_actor!(id)
with {:ok, %Actor{}} <- Actors.delete_actor(actor) do
send_resp(conn, :no_content, "")
end
end
end

View file

@ -0,0 +1,49 @@
defmodule NullaWeb.ActorJSON do
alias Nulla.Actors.Actor
@doc """
Renders a list of actors.
"""
def index(%{actors: actors}) do
%{data: for(actor <- actors, do: data(actor))}
end
@doc """
Renders a single actor.
"""
def show(%{actor: actor}) do
%{data: data(actor)}
end
defp data(%Actor{} = actor) do
%{
id: actor.id,
acct: actor.acct,
ap_id: actor.ap_id,
type: actor.type,
following: actor.following,
followers: actor.followers,
inbox: actor.inbox,
outbox: actor.outbox,
featured: actor.featured,
featuredTags: actor.featuredTags,
preferredUsername: actor.preferredUsername,
name: actor.name,
summary: actor.summary,
url: actor.url,
manuallyApprovesFollowers: actor.manuallyApprovesFollowers,
discoverable: actor.discoverable,
indexable: actor.indexable,
published: actor.published,
memorial: actor.memorial,
publicKey: actor.publicKey,
tag: actor.tag,
attachment: actor.attachment,
endpoints: actor.endpoints,
icon: actor.icon,
image: actor.image,
vcard_bday: actor.vcard_bday,
vcard_Address: actor.vcard_Address
}
end
end

View file

@ -1,33 +0,0 @@
defmodule NullaWeb.Api.NoteController do
use NullaWeb, :controller
alias Nulla.Models.Note
def index(conn, _params) do
json(conn, [])
end
def show(conn, %{"id" => id}) do
note = Note.get_note(id: id)
case note do
nil ->
conn
|> put_status(:not_found)
|> json(%{error: "Not Found"})
%Note{} = note ->
IO.inspect note
json(conn, %{})
end
end
def create(_conn, _params) do
end
def update(_conn, _params) do
end
def delete(_conn, _params) do
end
end

View file

@ -0,0 +1,25 @@
defmodule NullaWeb.ChangesetJSON do
@doc """
Renders changeset errors.
"""
def error(%{changeset: changeset}) do
# When encoded, the changeset returns its errors
# as a JSON object. So we just pass it forward.
%{errors: Ecto.Changeset.traverse_errors(changeset, &translate_error/1)}
end
defp translate_error({msg, opts}) do
# You can make use of gettext to translate error messages by
# uncommenting and adjusting the following code:
# if count = opts[:count] do
# Gettext.dngettext(NullaWeb.Gettext, "errors", msg, msg, count, opts)
# else
# Gettext.dgettext(NullaWeb.Gettext, "errors", msg, opts)
# end
Enum.reduce(opts, msg, fn {key, value}, acc ->
String.replace(acc, "%{#{key}}", fn _ -> to_string(value) end)
end)
end
end

View file

@ -0,0 +1,24 @@
defmodule NullaWeb.FallbackController do
@moduledoc """
Translates controller action results into valid `Plug.Conn` responses.
See `Phoenix.Controller.action_fallback/1` for more details.
"""
use NullaWeb, :controller
# This clause handles errors returned by Ecto's insert/update/delete.
def call(conn, {:error, %Ecto.Changeset{} = changeset}) do
conn
|> put_status(:unprocessable_entity)
|> put_view(json: NullaWeb.ChangesetJSON)
|> render(:error, changeset: changeset)
end
# This clause is an example of how to handle resources that cannot be found.
def call(conn, {:error, :not_found}) do
conn
|> put_status(:not_found)
|> put_view(html: NullaWeb.ErrorHTML, json: NullaWeb.ErrorJSON)
|> render(:"404")
end
end

View file

@ -0,0 +1,45 @@
defmodule NullaWeb.MediaAttachmentController do
use NullaWeb, :controller
alias Nulla.MediaAttachments
alias Nulla.MediaAttachments.MediaAttachment
action_fallback NullaWeb.FallbackController
def index(conn, _params) do
media_attachments = MediaAttachments.list_media_attachments()
render(conn, :index, media_attachments: media_attachments)
end
def create(conn, %{"media_attachment" => media_attachment_params}) do
with {:ok, %MediaAttachment{} = media_attachment} <-
MediaAttachments.create_media_attachment(media_attachment_params) do
conn
|> put_status(:created)
|> put_resp_header("location", ~p"/api/media_attachments/#{media_attachment}")
|> render(:show, media_attachment: media_attachment)
end
end
def show(conn, %{"id" => id}) do
media_attachment = MediaAttachments.get_media_attachment!(id)
render(conn, :show, media_attachment: media_attachment)
end
def update(conn, %{"id" => id, "media_attachment" => media_attachment_params}) do
media_attachment = MediaAttachments.get_media_attachment!(id)
with {:ok, %MediaAttachment{} = media_attachment} <-
MediaAttachments.update_media_attachment(media_attachment, media_attachment_params) do
render(conn, :show, media_attachment: media_attachment)
end
end
def delete(conn, %{"id" => id}) do
media_attachment = MediaAttachments.get_media_attachment!(id)
with {:ok, %MediaAttachment{}} <- MediaAttachments.delete_media_attachment(media_attachment) do
send_resp(conn, :no_content, "")
end
end
end

View file

@ -0,0 +1,29 @@
defmodule NullaWeb.MediaAttachmentJSON do
alias Nulla.MediaAttachments.MediaAttachment
@doc """
Renders a list of media_attachments.
"""
def index(%{media_attachments: media_attachments}) do
%{data: for(media_attachment <- media_attachments, do: data(media_attachment))}
end
@doc """
Renders a single media_attachment.
"""
def show(%{media_attachment: media_attachment}) do
%{data: data(media_attachment)}
end
defp data(%MediaAttachment{} = media_attachment) do
%{
id: media_attachment.id,
type: media_attachment.type,
mediaType: media_attachment.mediaType,
url: media_attachment.url,
name: media_attachment.name,
width: media_attachment.width,
height: media_attachment.height
}
end
end

View file

@ -0,0 +1,43 @@
defmodule NullaWeb.NoteController do
use NullaWeb, :controller
alias Nulla.Notes
alias Nulla.Notes.Note
action_fallback NullaWeb.FallbackController
def index(conn, _params) do
notes = Notes.list_notes()
render(conn, :index, notes: notes)
end
def create(conn, %{"note" => note_params}) do
with {:ok, %Note{} = note} <- Notes.create_note(note_params) do
conn
|> put_status(:created)
|> put_resp_header("location", ~p"/api/notes/#{note}")
|> render(:show, note: note)
end
end
def show(conn, %{"id" => id}) do
note = Notes.get_note!(id)
render(conn, :show, note: note)
end
def update(conn, %{"id" => id, "note" => note_params}) do
note = Notes.get_note!(id)
with {:ok, %Note{} = note} <- Notes.update_note(note, note_params) do
render(conn, :show, note: note)
end
end
def delete(conn, %{"id" => id}) do
note = Notes.get_note!(id)
with {:ok, %Note{}} <- Notes.delete_note(note) do
send_resp(conn, :no_content, "")
end
end
end

View file

@ -0,0 +1,32 @@
defmodule NullaWeb.NoteJSON do
alias Nulla.Notes.Note
@doc """
Renders a list of notes.
"""
def index(%{notes: notes}) do
%{data: for(note <- notes, do: data(note))}
end
@doc """
Renders a single note.
"""
def show(%{note: note}) do
%{data: data(note)}
end
defp data(%Note{} = note) do
%{
id: note.id,
inReplyTo: note.inReplyTo,
published: note.published,
url: note.url,
visibility: note.visibility,
to: note.to,
cc: note.cc,
sensitive: note.sensitive,
content: note.content,
language: note.language
}
end
end

View file

@ -0,0 +1,9 @@
defmodule NullaWeb.PageController do
use NullaWeb, :controller
def home(conn, _params) do
# The home page is often custom made,
# so skip the default app layout.
render(conn, :home, layout: false)
end
end

View file

@ -0,0 +1,10 @@
defmodule NullaWeb.PageHTML do
@moduledoc """
This module contains pages rendered by PageController.
See the `page_html` directory for all templates available.
"""
use NullaWeb, :html
embed_templates "page_html/*"
end

View file

@ -0,0 +1,222 @@
<.flash_group flash={@flash} />
<div class="left-[40rem] fixed inset-y-0 right-0 z-0 hidden lg:block xl:left-[50rem]">
<svg
viewBox="0 0 1480 957"
fill="none"
aria-hidden="true"
class="absolute inset-0 h-full w-full"
preserveAspectRatio="xMinYMid slice"
>
<path fill="#EE7868" d="M0 0h1480v957H0z" />
<path
d="M137.542 466.27c-582.851-48.41-988.806-82.127-1608.412 658.2l67.39 810 3083.15-256.51L1535.94-49.622l-98.36 8.183C1269.29 281.468 734.115 515.799 146.47 467.012l-8.928-.742Z"
fill="#FF9F92"
/>
<path
d="M371.028 528.664C-169.369 304.988-545.754 149.198-1361.45 665.565l-182.58 792.025 3014.73 694.98 389.42-1689.25-96.18-22.171C1505.28 697.438 924.153 757.586 379.305 532.09l-8.277-3.426Z"
fill="#FA8372"
/>
<path
d="M359.326 571.714C-104.765 215.795-428.003-32.102-1349.55 255.554l-282.3 1224.596 3047.04 722.01 312.24-1354.467C1411.25 1028.3 834.355 935.995 366.435 577.166l-7.109-5.452Z"
fill="#E96856"
fill-opacity=".6"
/>
<path
d="M1593.87 1236.88c-352.15 92.63-885.498-145.85-1244.602-613.557l-5.455-7.105C-12.347 152.31-260.41-170.8-1225-131.458l-368.63 1599.048 3057.19 704.76 130.31-935.47Z"
fill="#C42652"
fill-opacity=".2"
/>
<path
d="M1411.91 1526.93c-363.79 15.71-834.312-330.6-1085.883-863.909l-3.822-8.102C72.704 125.95-101.074-242.476-1052.01-408.907l-699.85 1484.267 2837.75 1338.01 326.02-886.44Z"
fill="#A41C42"
fill-opacity=".2"
/>
<path
d="M1116.26 1863.69c-355.457-78.98-720.318-535.27-825.287-1115.521l-1.594-8.816C185.286 163.833 112.786-237.016-762.678-643.898L-1822.83 608.665 571.922 2635.55l544.338-771.86Z"
fill="#A41C42"
fill-opacity=".2"
/>
</svg>
</div>
<div class="px-4 py-10 sm:px-6 sm:py-28 lg:px-8 xl:px-28 xl:py-32">
<div class="mx-auto max-w-xl lg:mx-0">
<svg viewBox="0 0 71 48" class="h-12" aria-hidden="true">
<path
d="m26.371 33.477-.552-.1c-3.92-.729-6.397-3.1-7.57-6.829-.733-2.324.597-4.035 3.035-4.148 1.995-.092 3.362 1.055 4.57 2.39 1.557 1.72 2.984 3.558 4.514 5.305 2.202 2.515 4.797 4.134 8.347 3.634 3.183-.448 5.958-1.725 8.371-3.828.363-.316.761-.592 1.144-.886l-.241-.284c-2.027.63-4.093.841-6.205.735-3.195-.16-6.24-.828-8.964-2.582-2.486-1.601-4.319-3.746-5.19-6.611-.704-2.315.736-3.934 3.135-3.6.948.133 1.746.56 2.463 1.165.583.493 1.143 1.015 1.738 1.493 2.8 2.25 6.712 2.375 10.265-.068-5.842-.026-9.817-3.24-13.308-7.313-1.366-1.594-2.7-3.216-4.095-4.785-2.698-3.036-5.692-5.71-9.79-6.623C12.8-.623 7.745.14 2.893 2.361 1.926 2.804.997 3.319 0 4.149c.494 0 .763.006 1.032 0 2.446-.064 4.28 1.023 5.602 3.024.962 1.457 1.415 3.104 1.761 4.798.513 2.515.247 5.078.544 7.605.761 6.494 4.08 11.026 10.26 13.346 2.267.852 4.591 1.135 7.172.555ZM10.751 3.852c-.976.246-1.756-.148-2.56-.962 1.377-.343 2.592-.476 3.897-.528-.107.848-.607 1.306-1.336 1.49Zm32.002 37.924c-.085-.626-.62-.901-1.04-1.228-1.857-1.446-4.03-1.958-6.333-2-1.375-.026-2.735-.128-4.031-.61-.595-.22-1.26-.505-1.244-1.272.015-.78.693-1 1.31-1.184.505-.15 1.026-.247 1.6-.382-1.46-.936-2.886-1.065-4.787-.3-2.993 1.202-5.943 1.06-8.926-.017-1.684-.608-3.179-1.563-4.735-2.408l-.043.03a2.96 2.96 0 0 0 .04-.029c-.038-.117-.107-.12-.197-.054l.122.107c1.29 2.115 3.034 3.817 5.004 5.271 3.793 2.8 7.936 4.471 12.784 3.73A66.714 66.714 0 0 1 37 40.877c1.98-.16 3.866.398 5.753.899Zm-9.14-30.345c-.105-.076-.206-.266-.42-.069 1.745 2.36 3.985 4.098 6.683 5.193 4.354 1.767 8.773 2.07 13.293.51 3.51-1.21 6.033-.028 7.343 3.38.19-3.955-2.137-6.837-5.843-7.401-2.084-.318-4.01.373-5.962.94-5.434 1.575-10.485.798-15.094-2.553Zm27.085 15.425c.708.059 1.416.123 2.124.185-1.6-1.405-3.55-1.517-5.523-1.404-3.003.17-5.167 1.903-7.14 3.972-1.739 1.824-3.31 3.87-5.903 4.604.043.078.054.117.066.117.35.005.699.021 1.047.005 3.768-.17 7.317-.965 10.14-3.7.89-.86 1.685-1.817 2.544-2.71.716-.746 1.584-1.159 2.645-1.07Zm-8.753-4.67c-2.812.246-5.254 1.409-7.548 2.943-1.766 1.18-3.654 1.738-5.776 1.37-.374-.066-.75-.114-1.124-.17l-.013.156c.135.07.265.151.405.207.354.14.702.308 1.07.395 4.083.971 7.992.474 11.516-1.803 2.221-1.435 4.521-1.707 7.013-1.336.252.038.503.083.756.107.234.022.479.255.795.003-2.179-1.574-4.526-2.096-7.094-1.872Zm-10.049-9.544c1.475.051 2.943-.142 4.486-1.059-.452.04-.643.04-.827.076-2.126.424-4.033-.04-5.733-1.383-.623-.493-1.257-.974-1.889-1.457-2.503-1.914-5.374-2.555-8.514-2.5.05.154.054.26.108.315 3.417 3.455 7.371 5.836 12.369 6.008Zm24.727 17.731c-2.114-2.097-4.952-2.367-7.578-.537 1.738.078 3.043.632 4.101 1.728.374.388.763.768 1.182 1.106 1.6 1.29 4.311 1.352 5.896.155-1.861-.726-1.861-.726-3.601-2.452Zm-21.058 16.06c-1.858-3.46-4.981-4.24-8.59-4.008a9.667 9.667 0 0 1 2.977 1.39c.84.586 1.547 1.311 2.243 2.055 1.38 1.473 3.534 2.376 4.962 2.07-.656-.412-1.238-.848-1.592-1.507Zm17.29-19.32c0-.023.001-.045.003-.068l-.006.006.006-.006-.036-.004.021.018.012.053Zm-20 14.744a7.61 7.61 0 0 0-.072-.041.127.127 0 0 0 .015.043c.005.008.038 0 .058-.002Zm-.072-.041-.008-.034-.008.01.008-.01-.022-.006.005.026.024.014Z"
fill="#FD4F00"
/>
</svg>
<h1 class="text-brand mt-10 flex items-center text-sm font-semibold leading-6">
Phoenix Framework
<small class="bg-brand/5 text-[0.8125rem] ml-3 rounded-full px-2 font-medium leading-6">
v{Application.spec(:phoenix, :vsn)}
</small>
</h1>
<p class="text-[2rem] mt-4 font-semibold leading-10 tracking-tighter text-zinc-900 text-balance">
Peace of mind from prototype to production.
</p>
<p class="mt-4 text-base leading-7 text-zinc-600">
Build rich, interactive web applications quickly, with less code and fewer moving parts. Join our growing community of developers using Phoenix to craft APIs, HTML5 apps and more, for fun or at scale.
</p>
<div class="flex">
<div class="w-full sm:w-auto">
<div class="mt-10 grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-3">
<a
href="https://hexdocs.pm/phoenix/overview.html"
class="group relative rounded-2xl px-6 py-4 text-sm font-semibold leading-6 text-zinc-900 sm:py-6"
>
<span class="absolute inset-0 rounded-2xl bg-zinc-50 transition group-hover:bg-zinc-100 sm:group-hover:scale-105">
</span>
<span class="relative flex items-center gap-4 sm:flex-col">
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" class="h-6 w-6">
<path d="m12 4 10-2v18l-10 2V4Z" fill="#18181B" fill-opacity=".15" />
<path
d="M12 4 2 2v18l10 2m0-18v18m0-18 10-2v18l-10 2"
stroke="#18181B"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
Guides &amp; Docs
</span>
</a>
<a
href="https://github.com/phoenixframework/phoenix"
class="group relative rounded-2xl px-6 py-4 text-sm font-semibold leading-6 text-zinc-900 sm:py-6"
>
<span class="absolute inset-0 rounded-2xl bg-zinc-50 transition group-hover:bg-zinc-100 sm:group-hover:scale-105">
</span>
<span class="relative flex items-center gap-4 sm:flex-col">
<svg viewBox="0 0 24 24" aria-hidden="true" class="h-6 w-6">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M12 0C5.37 0 0 5.506 0 12.303c0 5.445 3.435 10.043 8.205 11.674.6.107.825-.262.825-.585 0-.292-.015-1.261-.015-2.291C6 21.67 5.22 20.346 4.98 19.654c-.135-.354-.72-1.446-1.23-1.738-.42-.23-1.02-.8-.015-.815.945-.015 1.62.892 1.845 1.261 1.08 1.86 2.805 1.338 3.495 1.015.105-.8.42-1.338.765-1.645-2.67-.308-5.46-1.37-5.46-6.075 0-1.338.465-2.446 1.23-3.307-.12-.308-.54-1.569.12-3.26 0 0 1.005-.323 3.3 1.26.96-.276 1.98-.415 3-.415s2.04.139 3 .416c2.295-1.6 3.3-1.261 3.3-1.261.66 1.691.24 2.952.12 3.26.765.861 1.23 1.953 1.23 3.307 0 4.721-2.805 5.767-5.475 6.075.435.384.81 1.122.81 2.276 0 1.645-.015 2.968-.015 3.383 0 .323.225.707.825.585a12.047 12.047 0 0 0 5.919-4.489A12.536 12.536 0 0 0 24 12.304C24 5.505 18.63 0 12 0Z"
fill="#18181B"
/>
</svg>
Source Code
</span>
</a>
<a
href={"https://github.com/phoenixframework/phoenix/blob/v#{Application.spec(:phoenix, :vsn)}/CHANGELOG.md"}
class="group relative rounded-2xl px-6 py-4 text-sm font-semibold leading-6 text-zinc-900 sm:py-6"
>
<span class="absolute inset-0 rounded-2xl bg-zinc-50 transition group-hover:bg-zinc-100 sm:group-hover:scale-105">
</span>
<span class="relative flex items-center gap-4 sm:flex-col">
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" class="h-6 w-6">
<path
d="M12 1v6M12 17v6"
stroke="#18181B"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<circle
cx="12"
cy="12"
r="4"
fill="#18181B"
fill-opacity=".15"
stroke="#18181B"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
Changelog
</span>
</a>
</div>
<div class="mt-10 grid grid-cols-1 gap-y-4 text-sm leading-6 text-zinc-700 sm:grid-cols-2">
<div>
<a
href="https://twitter.com/elixirphoenix"
class="group -mx-2 -my-0.5 inline-flex items-center gap-3 rounded-lg px-2 py-0.5 hover:bg-zinc-50 hover:text-zinc-900"
>
<svg
viewBox="0 0 16 16"
aria-hidden="true"
class="h-4 w-4 fill-zinc-400 group-hover:fill-zinc-600"
>
<path d="M5.403 14c5.283 0 8.172-4.617 8.172-8.62 0-.131 0-.262-.008-.391A6.033 6.033 0 0 0 15 3.419a5.503 5.503 0 0 1-1.65.477 3.018 3.018 0 0 0 1.263-1.676 5.579 5.579 0 0 1-1.824.736 2.832 2.832 0 0 0-1.63-.916 2.746 2.746 0 0 0-1.821.319A2.973 2.973 0 0 0 8.076 3.78a3.185 3.185 0 0 0-.182 1.938 7.826 7.826 0 0 1-3.279-.918 8.253 8.253 0 0 1-2.64-2.247 3.176 3.176 0 0 0-.315 2.208 3.037 3.037 0 0 0 1.203 1.836A2.739 2.739 0 0 1 1.56 6.22v.038c0 .7.23 1.377.65 1.919.42.54 1.004.912 1.654 1.05-.423.122-.866.14-1.297.052.184.602.541 1.129 1.022 1.506a2.78 2.78 0 0 0 1.662.598 5.656 5.656 0 0 1-2.007 1.074A5.475 5.475 0 0 1 1 12.64a7.827 7.827 0 0 0 4.403 1.358" />
</svg>
Follow on Twitter
</a>
</div>
<div>
<a
href="https://elixirforum.com"
class="group -mx-2 -my-0.5 inline-flex items-center gap-3 rounded-lg px-2 py-0.5 hover:bg-zinc-50 hover:text-zinc-900"
>
<svg
viewBox="0 0 16 16"
aria-hidden="true"
class="h-4 w-4 fill-zinc-400 group-hover:fill-zinc-600"
>
<path d="M8 13.833c3.866 0 7-2.873 7-6.416C15 3.873 11.866 1 8 1S1 3.873 1 7.417c0 1.081.292 2.1.808 2.995.606 1.05.806 2.399.086 3.375l-.208.283c-.285.386-.01.905.465.85.852-.098 2.048-.318 3.137-.81a3.717 3.717 0 0 1 1.91-.318c.263.027.53.041.802.041Z" />
</svg>
Discuss on the Elixir Forum
</a>
</div>
<div>
<a
href="https://web.libera.chat/#elixir"
class="group -mx-2 -my-0.5 inline-flex items-center gap-3 rounded-lg px-2 py-0.5 hover:bg-zinc-50 hover:text-zinc-900"
>
<svg
viewBox="0 0 16 16"
aria-hidden="true"
class="h-4 w-4 fill-zinc-400 group-hover:fill-zinc-600"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M6.356 2.007a.75.75 0 0 1 .637.849l-1.5 10.5a.75.75 0 1 1-1.485-.212l1.5-10.5a.75.75 0 0 1 .848-.637ZM11.356 2.008a.75.75 0 0 1 .637.848l-1.5 10.5a.75.75 0 0 1-1.485-.212l1.5-10.5a.75.75 0 0 1 .848-.636Z"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M14 5.25a.75.75 0 0 1-.75.75h-9.5a.75.75 0 0 1 0-1.5h9.5a.75.75 0 0 1 .75.75ZM13 10.75a.75.75 0 0 1-.75.75h-9.5a.75.75 0 0 1 0-1.5h9.5a.75.75 0 0 1 .75.75Z"
/>
</svg>
Chat on Libera IRC
</a>
</div>
<div>
<a
href="https://discord.gg/elixir"
class="group -mx-2 -my-0.5 inline-flex items-center gap-3 rounded-lg px-2 py-0.5 hover:bg-zinc-50 hover:text-zinc-900"
>
<svg
viewBox="0 0 16 16"
aria-hidden="true"
class="h-4 w-4 fill-zinc-400 group-hover:fill-zinc-600"
>
<path d="M13.545 2.995c-1.02-.46-2.114-.8-3.257-.994a.05.05 0 0 0-.052.024c-.141.246-.297.567-.406.82a12.377 12.377 0 0 0-3.658 0 8.238 8.238 0 0 0-.412-.82.052.052 0 0 0-.052-.024 13.315 13.315 0 0 0-3.257.994.046.046 0 0 0-.021.018C.356 6.063-.213 9.036.066 11.973c.001.015.01.029.02.038a13.353 13.353 0 0 0 3.996 1.987.052.052 0 0 0 .056-.018c.308-.414.582-.85.818-1.309a.05.05 0 0 0-.028-.069 8.808 8.808 0 0 1-1.248-.585.05.05 0 0 1-.005-.084c.084-.062.168-.126.248-.191a.05.05 0 0 1 .051-.007c2.619 1.176 5.454 1.176 8.041 0a.05.05 0 0 1 .053.006c.08.065.164.13.248.192a.05.05 0 0 1-.004.084c-.399.23-.813.423-1.249.585a.05.05 0 0 0-.027.07c.24.457.514.893.817 1.307a.051.051 0 0 0 .056.019 13.31 13.31 0 0 0 4.001-1.987.05.05 0 0 0 .021-.037c.334-3.396-.559-6.345-2.365-8.96a.04.04 0 0 0-.021-.02Zm-8.198 7.19c-.789 0-1.438-.712-1.438-1.587 0-.874.637-1.586 1.438-1.586.807 0 1.45.718 1.438 1.586 0 .875-.637 1.587-1.438 1.587Zm5.316 0c-.788 0-1.438-.712-1.438-1.587 0-.874.637-1.586 1.438-1.586.807 0 1.45.718 1.438 1.586 0 .875-.63 1.587-1.438 1.587Z" />
</svg>
Join our Discord server
</a>
</div>
<div>
<a
href="https://fly.io/docs/elixir/getting-started/"
class="group -mx-2 -my-0.5 inline-flex items-center gap-3 rounded-lg px-2 py-0.5 hover:bg-zinc-50 hover:text-zinc-900"
>
<svg
viewBox="0 0 20 20"
aria-hidden="true"
class="h-4 w-4 fill-zinc-400 group-hover:fill-zinc-600"
>
<path d="M1 12.5A4.5 4.5 0 005.5 17H15a4 4 0 001.866-7.539 3.504 3.504 0 00-4.504-4.272A4.5 4.5 0 004.06 8.235 4.502 4.502 0 001 12.5z" />
</svg>
Deploy your application
</a>
</div>
</div>
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,43 @@
defmodule NullaWeb.RelationController do
use NullaWeb, :controller
alias Nulla.Relations
alias Nulla.Relations.Relation
action_fallback NullaWeb.FallbackController
def index(conn, _params) do
relations = Relations.list_relations()
render(conn, :index, relations: relations)
end
def create(conn, %{"relation" => relation_params}) do
with {:ok, %Relation{} = relation} <- Relations.create_relation(relation_params) do
conn
|> put_status(:created)
|> put_resp_header("location", ~p"/api/relations/#{relation}")
|> render(:show, relation: relation)
end
end
def show(conn, %{"id" => id}) do
relation = Relations.get_relation!(id)
render(conn, :show, relation: relation)
end
def update(conn, %{"id" => id, "relation" => relation_params}) do
relation = Relations.get_relation!(id)
with {:ok, %Relation{} = relation} <- Relations.update_relation(relation, relation_params) do
render(conn, :show, relation: relation)
end
end
def delete(conn, %{"id" => id}) do
relation = Relations.get_relation!(id)
with {:ok, %Relation{}} <- Relations.delete_relation(relation) do
send_resp(conn, :no_content, "")
end
end
end

View file

@ -0,0 +1,35 @@
defmodule NullaWeb.RelationJSON do
alias Nulla.Relations.Relation
@doc """
Renders a list of relations.
"""
def index(%{relations: relations}) do
%{data: for(relation <- relations, do: data(relation))}
end
@doc """
Renders a single relation.
"""
def show(%{relation: relation}) do
%{data: data(relation)}
end
defp data(%Relation{} = relation) do
%{
id: relation.id,
following: relation.following,
followed_by: relation.followed_by,
showing_replies: relation.showing_replies,
showings_reblogs: relation.showings_reblogs,
notifying: relation.notifying,
muting: relation.muting,
muting_notifications: relation.muting_notifications,
blocking: relation.blocking,
blocked_by: relation.blocked_by,
domain_blocking: relation.domain_blocking,
requested: relation.requested,
note: relation.note
}
end
end

View file

@ -0,0 +1,42 @@
defmodule NullaWeb.UserSessionController do
use NullaWeb, :controller
alias Nulla.Accounts
alias NullaWeb.UserAuth
def create(conn, %{"_action" => "registered"} = params) do
create(conn, params, "Account created successfully!")
end
def create(conn, %{"_action" => "password_updated"} = params) do
conn
|> put_session(:user_return_to, ~p"/users/settings")
|> create(params, "Password updated successfully!")
end
def create(conn, params) do
create(conn, params, "Welcome back!")
end
defp create(conn, %{"user" => user_params}, info) do
%{"email" => email, "password" => password} = user_params
if user = Accounts.get_user_by_email_and_password(email, password) do
conn
|> put_flash(:info, info)
|> UserAuth.log_in_user(user, user_params)
else
# In order to prevent user enumeration attacks, don't disclose whether the email is registered.
conn
|> put_flash(:error, "Invalid email or password")
|> put_flash(:email, String.slice(email, 0, 160))
|> redirect(to: ~p"/users/log_in")
end
end
def delete(conn, _params) do
conn
|> put_flash(:info, "Logged out successfully.")
|> UserAuth.log_out_user()
end
end

View file

@ -1,32 +0,0 @@
defmodule NullaWeb.Web.ActorController do
use NullaWeb, :controller
alias Nulla.Models.Actor
alias Nulla.Models.Relation
alias Nulla.Models.Note
def show(conn, %{"username" => username}) do
domain = NullaWeb.Endpoint.host()
case Actor.get_actor(preferredUsername: username, domain: domain) do
nil ->
conn
|> put_status(:not_found)
|> json(%{error: "Not Found"})
%Actor{} = actor ->
following = Relation.count_following(actor.id)
followers = Relation.count_followers(actor.id)
notes = Note.get_latest_notes(actor.id)
render(
conn,
:show,
actor: actor,
following: following,
followers: followers,
notes: notes,
layout: false
)
end
end
end

View file

@ -1,66 +0,0 @@
defmodule NullaWeb.Web.AuthController do
use NullaWeb, :controller
alias Nulla.Models.User
alias Nulla.Models.Actor
alias Nulla.Models.InstanceSettings
def sign_in(conn, %{"email" => email, "password" => password}) do
user = User.get_user(email: email)
if user do
if Argon2.verify_pass(password, user.password) do
conn
|> put_session(:user_id, user.id)
|> redirect(to: ~p"/")
else
conn
|> put_flash(:error, "Invalid login or password.")
|> redirect(to: ~p"/auth/sign_in")
end
else
conn
|> put_flash(:error, "User not exist.")
|> redirect(to: ~p"/auth/sign_in")
end
end
def sign_out(conn, _params) do
conn
|> configure_session(drop: true)
|> put_flash(:info, "You have been logged out.")
|> redirect(to: ~p"/")
end
def sign_up(conn, %{"username" => username, "email" => email, "password" => password}) do
instance_settings = InstanceSettings.get_instance_settings!()
if not instance_settings.registration do
conn
|> put_flash(:error, "Registration is disabled.")
|> redirect(to: ~p"/")
else
domain = NullaWeb.Endpoint.host()
hashed_password = Argon2.hash_pwd_salt(password)
{publicKeyPem, privateKeyPem} = Nulla.KeyGen.gen()
with {:ok, actor} <- Actor.create_actor_minimal(username, domain, publicKeyPem),
{:ok, user} <-
User.create_user(%{
id: actor.id,
email: email,
password: hashed_password,
privateKeyPem: privateKeyPem,
last_active_at: DateTime.utc_now()
}) do
conn
|> put_session(:user_id, user.id)
|> put_flash(:info, "You're registred!")
|> redirect(to: ~p"/")
else
{:error, %Ecto.Changeset{} = changeset} ->
render(conn, "sign_up.html", changeset: changeset)
end
end
end
end

View file

@ -1,44 +0,0 @@
defmodule NullaWeb.Web.NoteController do
use NullaWeb, :controller
alias Nulla.Repo
alias Nulla.ActivityPub
alias Nulla.Models.Note
def show(conn, %{"username" => username, "id" => id}) do
case Integer.parse(id) do
{int_id, ""} ->
note = Note.get_note(id: int_id) |> Repo.preload([:actor, :media_attachments])
cond do
is_nil(note) ->
conn
|> put_status(:not_found)
|> json(%{error: "Not Found"})
|> halt()
username != note.actor.preferredUsername ->
conn
|> put_status(:not_found)
|> json(%{error: "Not Found"})
|> halt()
true ->
format = Phoenix.Controller.get_format(conn)
if format == "activity+json" do
conn
|> put_resp_content_type("application/activity+json")
|> json(ActivityPub.note(note))
else
render(conn, :show, note: note, layout: false)
end
end
_ ->
conn
|> put_status(:not_found)
|> json(%{error: "Not Found"})
|> halt()
end
end
end

View file

@ -1,15 +0,0 @@
defmodule NullaWeb.Web.PageController do
use NullaWeb, :controller
def home(conn, _params) do
render(conn, :home, layout: false)
end
def sign_in(conn, _params) do
render(conn, :sign_in, layout: false)
end
def sign_up(conn, _params) do
render(conn, :sign_up, layout: false)
end
end

View file

@ -7,7 +7,7 @@ defmodule NullaWeb.Endpoint do
@session_options [
store: :cookie,
key: "_nulla_key",
signing_salt: "/+24FZnt",
signing_salt: "FuDu07Pq",
same_site: "Lax"
]

View file

@ -0,0 +1,51 @@
defmodule NullaWeb.UserConfirmationInstructionsLive do
use NullaWeb, :live_view
alias Nulla.Accounts
def render(assigns) do
~H"""
<div class="mx-auto max-w-sm">
<.header class="text-center">
No confirmation instructions received?
<:subtitle>We'll send a new confirmation link to your inbox</:subtitle>
</.header>
<.simple_form for={@form} id="resend_confirmation_form" phx-submit="send_instructions">
<.input field={@form[:email]} type="email" placeholder="Email" required />
<:actions>
<.button phx-disable-with="Sending..." class="w-full">
Resend confirmation instructions
</.button>
</:actions>
</.simple_form>
<p class="text-center mt-4">
<.link href={~p"/users/register"}>Register</.link>
| <.link href={~p"/users/log_in"}>Log in</.link>
</p>
</div>
"""
end
def mount(_params, _session, socket) do
{:ok, assign(socket, form: to_form(%{}, as: "user"))}
end
def handle_event("send_instructions", %{"user" => %{"email" => email}}, socket) do
if user = Accounts.get_user_by_email(email) do
Accounts.deliver_user_confirmation_instructions(
user,
&url(~p"/users/confirm/#{&1}")
)
end
info =
"If your email is in our system and it has not been confirmed yet, you will receive an email with instructions shortly."
{:noreply,
socket
|> put_flash(:info, info)
|> redirect(to: ~p"/")}
end
end

View file

@ -0,0 +1,58 @@
defmodule NullaWeb.UserConfirmationLive do
use NullaWeb, :live_view
alias Nulla.Accounts
def render(%{live_action: :edit} = assigns) do
~H"""
<div class="mx-auto max-w-sm">
<.header class="text-center">Confirm Account</.header>
<.simple_form for={@form} id="confirmation_form" phx-submit="confirm_account">
<input type="hidden" name={@form[:token].name} value={@form[:token].value} />
<:actions>
<.button phx-disable-with="Confirming..." class="w-full">Confirm my account</.button>
</:actions>
</.simple_form>
<p class="text-center mt-4">
<.link href={~p"/users/register"}>Register</.link>
| <.link href={~p"/users/log_in"}>Log in</.link>
</p>
</div>
"""
end
def mount(%{"token" => token}, _session, socket) do
form = to_form(%{"token" => token}, as: "user")
{:ok, assign(socket, form: form), temporary_assigns: [form: nil]}
end
# Do not log in the user after confirmation to avoid a
# leaked token giving the user access to the account.
def handle_event("confirm_account", %{"user" => %{"token" => token}}, socket) do
case Accounts.confirm_user(token) do
{:ok, _} ->
{:noreply,
socket
|> put_flash(:info, "User confirmed successfully.")
|> redirect(to: ~p"/")}
:error ->
# If there is a current user and the account was already confirmed,
# then odds are that the confirmation link was already visited, either
# by some automation or by the user themselves, so we redirect without
# a warning message.
case socket.assigns do
%{current_user: %{confirmed_at: confirmed_at}} when not is_nil(confirmed_at) ->
{:noreply, redirect(socket, to: ~p"/")}
%{} ->
{:noreply,
socket
|> put_flash(:error, "User confirmation link is invalid or it has expired.")
|> redirect(to: ~p"/")}
end
end
end
end

View file

@ -0,0 +1,50 @@
defmodule NullaWeb.UserForgotPasswordLive do
use NullaWeb, :live_view
alias Nulla.Accounts
def render(assigns) do
~H"""
<div class="mx-auto max-w-sm">
<.header class="text-center">
Forgot your password?
<:subtitle>We'll send a password reset link to your inbox</:subtitle>
</.header>
<.simple_form for={@form} id="reset_password_form" phx-submit="send_email">
<.input field={@form[:email]} type="email" placeholder="Email" required />
<:actions>
<.button phx-disable-with="Sending..." class="w-full">
Send password reset instructions
</.button>
</:actions>
</.simple_form>
<p class="text-center text-sm mt-4">
<.link href={~p"/users/register"}>Register</.link>
| <.link href={~p"/users/log_in"}>Log in</.link>
</p>
</div>
"""
end
def mount(_params, _session, socket) do
{:ok, assign(socket, form: to_form(%{}, as: "user"))}
end
def handle_event("send_email", %{"user" => %{"email" => email}}, socket) do
if user = Accounts.get_user_by_email(email) do
Accounts.deliver_user_reset_password_instructions(
user,
&url(~p"/users/reset_password/#{&1}")
)
end
info =
"If your email is in our system, you will receive instructions to reset your password shortly."
{:noreply,
socket
|> put_flash(:info, info)
|> redirect(to: ~p"/")}
end
end

View file

@ -0,0 +1,43 @@
defmodule NullaWeb.UserLoginLive do
use NullaWeb, :live_view
def render(assigns) do
~H"""
<div class="mx-auto max-w-sm">
<.header class="text-center">
Log in to account
<:subtitle>
Don't have an account?
<.link navigate={~p"/users/register"} class="font-semibold text-brand hover:underline">
Sign up
</.link>
for an account now.
</:subtitle>
</.header>
<.simple_form for={@form} id="login_form" action={~p"/users/log_in"} phx-update="ignore">
<.input field={@form[:email]} type="email" label="Email" required />
<.input field={@form[:password]} type="password" label="Password" required />
<:actions>
<.input field={@form[:remember_me]} type="checkbox" label="Keep me logged in" />
<.link href={~p"/users/reset_password"} class="text-sm font-semibold">
Forgot your password?
</.link>
</:actions>
<:actions>
<.button phx-disable-with="Logging in..." class="w-full">
Log in <span aria-hidden="true"></span>
</.button>
</:actions>
</.simple_form>
</div>
"""
end
def mount(_params, _session, socket) do
email = Phoenix.Flash.get(socket.assigns.flash, :email)
form = to_form(%{"email" => email}, as: "user")
{:ok, assign(socket, form: form), temporary_assigns: [form: form]}
end
end

View file

@ -0,0 +1,87 @@
defmodule NullaWeb.UserRegistrationLive do
use NullaWeb, :live_view
alias Nulla.Accounts
alias Nulla.Accounts.User
def render(assigns) do
~H"""
<div class="mx-auto max-w-sm">
<.header class="text-center">
Register for an account
<:subtitle>
Already registered?
<.link navigate={~p"/users/log_in"} class="font-semibold text-brand hover:underline">
Log in
</.link>
to your account now.
</:subtitle>
</.header>
<.simple_form
for={@form}
id="registration_form"
phx-submit="save"
phx-change="validate"
phx-trigger-action={@trigger_submit}
action={~p"/users/log_in?_action=registered"}
method="post"
>
<.error :if={@check_errors}>
Oops, something went wrong! Please check the errors below.
</.error>
<.input field={@form[:email]} type="email" label="Email" required />
<.input field={@form[:password]} type="password" label="Password" required />
<:actions>
<.button phx-disable-with="Creating account..." class="w-full">Create an account</.button>
</:actions>
</.simple_form>
</div>
"""
end
def mount(_params, _session, socket) do
changeset = Accounts.change_user_registration(%User{})
socket =
socket
|> assign(trigger_submit: false, check_errors: false)
|> assign_form(changeset)
{:ok, socket, temporary_assigns: [form: nil]}
end
def handle_event("save", %{"user" => user_params}, socket) do
case Accounts.register_user(user_params) do
{:ok, user} ->
{:ok, _} =
Accounts.deliver_user_confirmation_instructions(
user,
&url(~p"/users/confirm/#{&1}")
)
changeset = Accounts.change_user_registration(user)
{:noreply, socket |> assign(trigger_submit: true) |> assign_form(changeset)}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, socket |> assign(check_errors: true) |> assign_form(changeset)}
end
end
def handle_event("validate", %{"user" => user_params}, socket) do
changeset = Accounts.change_user_registration(%User{}, user_params)
{:noreply, assign_form(socket, Map.put(changeset, :action, :validate))}
end
defp assign_form(socket, %Ecto.Changeset{} = changeset) do
form = to_form(changeset, as: "user")
if changeset.valid? do
assign(socket, form: form, check_errors: false)
else
assign(socket, form: form)
end
end
end

View file

@ -0,0 +1,89 @@
defmodule NullaWeb.UserResetPasswordLive do
use NullaWeb, :live_view
alias Nulla.Accounts
def render(assigns) do
~H"""
<div class="mx-auto max-w-sm">
<.header class="text-center">Reset Password</.header>
<.simple_form
for={@form}
id="reset_password_form"
phx-submit="reset_password"
phx-change="validate"
>
<.error :if={@form.errors != []}>
Oops, something went wrong! Please check the errors below.
</.error>
<.input field={@form[:password]} type="password" label="New password" required />
<.input
field={@form[:password_confirmation]}
type="password"
label="Confirm new password"
required
/>
<:actions>
<.button phx-disable-with="Resetting..." class="w-full">Reset Password</.button>
</:actions>
</.simple_form>
<p class="text-center text-sm mt-4">
<.link href={~p"/users/register"}>Register</.link>
| <.link href={~p"/users/log_in"}>Log in</.link>
</p>
</div>
"""
end
def mount(params, _session, socket) do
socket = assign_user_and_token(socket, params)
form_source =
case socket.assigns do
%{user: user} ->
Accounts.change_user_password(user)
_ ->
%{}
end
{:ok, assign_form(socket, form_source), temporary_assigns: [form: nil]}
end
# Do not log in the user after reset password to avoid a
# leaked token giving the user access to the account.
def handle_event("reset_password", %{"user" => user_params}, socket) do
case Accounts.reset_user_password(socket.assigns.user, user_params) do
{:ok, _} ->
{:noreply,
socket
|> put_flash(:info, "Password reset successfully.")
|> redirect(to: ~p"/users/log_in")}
{:error, changeset} ->
{:noreply, assign_form(socket, Map.put(changeset, :action, :insert))}
end
end
def handle_event("validate", %{"user" => user_params}, socket) do
changeset = Accounts.change_user_password(socket.assigns.user, user_params)
{:noreply, assign_form(socket, Map.put(changeset, :action, :validate))}
end
defp assign_user_and_token(socket, %{"token" => token}) do
if user = Accounts.get_user_by_reset_password_token(token) do
assign(socket, user: user, token: token)
else
socket
|> put_flash(:error, "Reset password link is invalid or it has expired.")
|> redirect(to: ~p"/")
end
end
defp assign_form(socket, %{} = source) do
assign(socket, :form, to_form(source, as: "user"))
end
end

View file

@ -0,0 +1,167 @@
defmodule NullaWeb.UserSettingsLive do
use NullaWeb, :live_view
alias Nulla.Accounts
def render(assigns) do
~H"""
<.header class="text-center">
Account Settings
<:subtitle>Manage your account email address and password settings</:subtitle>
</.header>
<div class="space-y-12 divide-y">
<div>
<.simple_form
for={@email_form}
id="email_form"
phx-submit="update_email"
phx-change="validate_email"
>
<.input field={@email_form[:email]} type="email" label="Email" required />
<.input
field={@email_form[:current_password]}
name="current_password"
id="current_password_for_email"
type="password"
label="Current password"
value={@email_form_current_password}
required
/>
<:actions>
<.button phx-disable-with="Changing...">Change Email</.button>
</:actions>
</.simple_form>
</div>
<div>
<.simple_form
for={@password_form}
id="password_form"
action={~p"/users/log_in?_action=password_updated"}
method="post"
phx-change="validate_password"
phx-submit="update_password"
phx-trigger-action={@trigger_submit}
>
<input
name={@password_form[:email].name}
type="hidden"
id="hidden_user_email"
value={@current_email}
/>
<.input field={@password_form[:password]} type="password" label="New password" required />
<.input
field={@password_form[:password_confirmation]}
type="password"
label="Confirm new password"
/>
<.input
field={@password_form[:current_password]}
name="current_password"
type="password"
label="Current password"
id="current_password_for_password"
value={@current_password}
required
/>
<:actions>
<.button phx-disable-with="Changing...">Change Password</.button>
</:actions>
</.simple_form>
</div>
</div>
"""
end
def mount(%{"token" => token}, _session, socket) do
socket =
case Accounts.update_user_email(socket.assigns.current_user, token) do
:ok ->
put_flash(socket, :info, "Email changed successfully.")
:error ->
put_flash(socket, :error, "Email change link is invalid or it has expired.")
end
{:ok, push_navigate(socket, to: ~p"/users/settings")}
end
def mount(_params, _session, socket) do
user = socket.assigns.current_user
email_changeset = Accounts.change_user_email(user)
password_changeset = Accounts.change_user_password(user)
socket =
socket
|> assign(:current_password, nil)
|> assign(:email_form_current_password, nil)
|> assign(:current_email, user.email)
|> assign(:email_form, to_form(email_changeset))
|> assign(:password_form, to_form(password_changeset))
|> assign(:trigger_submit, false)
{:ok, socket}
end
def handle_event("validate_email", params, socket) do
%{"current_password" => password, "user" => user_params} = params
email_form =
socket.assigns.current_user
|> Accounts.change_user_email(user_params)
|> Map.put(:action, :validate)
|> to_form()
{:noreply, assign(socket, email_form: email_form, email_form_current_password: password)}
end
def handle_event("update_email", params, socket) do
%{"current_password" => password, "user" => user_params} = params
user = socket.assigns.current_user
case Accounts.apply_user_email(user, password, user_params) do
{:ok, applied_user} ->
Accounts.deliver_user_update_email_instructions(
applied_user,
user.email,
&url(~p"/users/settings/confirm_email/#{&1}")
)
info = "A link to confirm your email change has been sent to the new address."
{:noreply, socket |> put_flash(:info, info) |> assign(email_form_current_password: nil)}
{:error, changeset} ->
{:noreply, assign(socket, :email_form, to_form(Map.put(changeset, :action, :insert)))}
end
end
def handle_event("validate_password", params, socket) do
%{"current_password" => password, "user" => user_params} = params
password_form =
socket.assigns.current_user
|> Accounts.change_user_password(user_params)
|> Map.put(:action, :validate)
|> to_form()
{:noreply, assign(socket, password_form: password_form, current_password: password)}
end
def handle_event("update_password", params, socket) do
%{"current_password" => password, "user" => user_params} = params
user = socket.assigns.current_user
case Accounts.update_user_password(user, password, user_params) do
{:ok, user} ->
password_form =
user
|> Accounts.change_user_password(user_params)
|> to_form()
{:noreply, assign(socket, trigger_submit: true, password_form: password_form)}
{:error, changeset} ->
{:noreply, assign(socket, password_form: to_form(changeset))}
end
end
end

View file

@ -1,66 +1,37 @@
defmodule NullaWeb.Router do
use NullaWeb, :router
import NullaWeb.UserAuth
pipeline :browser do
plug :accepts, ["html", "json", "activity+json"]
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_live_flash
plug :put_root_layout, html: {NullaWeb.Layouts, :root}
plug :protect_from_forgery
plug :put_secure_browser_headers
plug :fetch_current_user
end
pipeline :api do
plug :accepts, ["json"]
plug :fetch_api_user
end
pipeline :activitypub do
plug :accepts, ["activity+json"]
end
scope "/", NullaWeb.Web, as: :web do
scope "/", NullaWeb do
pipe_through :browser
get "/", PageController, :home
scope "/auth" do
post "/", AuthController, :sign_up
post "/sign_in", AuthController, :sign_in
delete "/sign_out", AuthController, :sign_out
get "/sign_up", PageController, :sign_up
get "/sign_in", PageController, :sign_in
end
scope "/@:username" do
get "/", ActorController, :show
get "/:id", NoteController, :show
end
end
scope "/api", NullaWeb.Api, as: :api do
scope "/api", NullaWeb do
pipe_through :api
resources "/notes", NoteController
end
scope "/", NullaWeb.ActivityPub, as: :activitypub do
pipe_through :activitypub
get "/.well-known/host-meta", HostmetaController, :index
get "/.well-known/webfinger", WebfingerController, :index
get "/.well-known/nodeinfo", NodeinfoController, :index
get "/nodeinfo/2.0", NodeinfoController, :show
post "/inbox", InboxController, :inbox
scope "/users/:username" do
get "/", ActorController, :show
get "/following", FollowController, :following
get "/followers", FollowController, :followers
post "/inbox", InboxController, :inbox
get "/outbox", OutboxController, :outbox
get "/notes/:id", NoteController, :show
end
resources "/actors", ActorController, except: [:new, :edit]
resources "/notes", NoteController, except: [:new, :edit]
resources "/media_attachments", MediaAttachmentController, except: [:new, :edit]
resources "/relations", RelationController, except: [:new, :edit]
resources "/activities", ActivityController, except: [:new, :edit]
end
# Enable LiveDashboard and Swoosh mailbox preview in development
@ -79,4 +50,42 @@ defmodule NullaWeb.Router do
forward "/mailbox", Plug.Swoosh.MailboxPreview
end
end
## Authentication routes
scope "/", NullaWeb do
pipe_through [:browser, :redirect_if_user_is_authenticated]
live_session :redirect_if_user_is_authenticated,
on_mount: [{NullaWeb.UserAuth, :redirect_if_user_is_authenticated}] do
live "/users/register", UserRegistrationLive, :new
live "/users/log_in", UserLoginLive, :new
live "/users/reset_password", UserForgotPasswordLive, :new
live "/users/reset_password/:token", UserResetPasswordLive, :edit
end
post "/users/log_in", UserSessionController, :create
end
scope "/", NullaWeb do
pipe_through [:browser, :require_authenticated_user]
live_session :require_authenticated_user,
on_mount: [{NullaWeb.UserAuth, :ensure_authenticated}] do
live "/users/settings", UserSettingsLive, :edit
live "/users/settings/confirm_email/:token", UserSettingsLive, :confirm_email
end
end
scope "/", NullaWeb do
pipe_through [:browser]
delete "/users/log_out", UserSessionController, :delete
live_session :current_user,
on_mount: [{NullaWeb.UserAuth, :mount_current_user}] do
live "/users/confirm/:token", UserConfirmationLive, :edit
live "/users/confirm", UserConfirmationInstructionsLive, :new
end
end
end

245
lib/nulla_web/user_auth.ex Normal file
View file

@ -0,0 +1,245 @@
defmodule NullaWeb.UserAuth do
use NullaWeb, :verified_routes
import Plug.Conn
import Phoenix.Controller
alias Nulla.Accounts
# Make the remember me cookie valid for 60 days.
# If you want bump or reduce this value, also change
# the token expiry itself in UserToken.
@max_age 60 * 60 * 24 * 60
@remember_me_cookie "_nulla_web_user_remember_me"
@remember_me_options [sign: true, max_age: @max_age, same_site: "Lax"]
@doc """
Logs the user in.
It renews the session ID and clears the whole session
to avoid fixation attacks. See the renew_session
function to customize this behaviour.
It also sets a `:live_socket_id` key in the session,
so LiveView sessions are identified and automatically
disconnected on log out. The line can be safely removed
if you are not using LiveView.
"""
def log_in_user(conn, user, params \\ %{}) do
token = Accounts.generate_user_session_token(user)
user_return_to = get_session(conn, :user_return_to)
conn
|> renew_session()
|> put_token_in_session(token)
|> maybe_write_remember_me_cookie(token, params)
|> redirect(to: user_return_to || signed_in_path(conn))
end
defp maybe_write_remember_me_cookie(conn, token, %{"remember_me" => "true"}) do
put_resp_cookie(conn, @remember_me_cookie, token, @remember_me_options)
end
defp maybe_write_remember_me_cookie(conn, _token, _params) do
conn
end
# This function renews the session ID and erases the whole
# session to avoid fixation attacks. If there is any data
# in the session you may want to preserve after log in/log out,
# you must explicitly fetch the session data before clearing
# and then immediately set it after clearing, for example:
#
# defp renew_session(conn) do
# preferred_locale = get_session(conn, :preferred_locale)
#
# conn
# |> configure_session(renew: true)
# |> clear_session()
# |> put_session(:preferred_locale, preferred_locale)
# end
#
defp renew_session(conn) do
delete_csrf_token()
conn
|> configure_session(renew: true)
|> clear_session()
end
@doc """
Logs the user out.
It clears all session data for safety. See renew_session.
"""
def log_out_user(conn) do
user_token = get_session(conn, :user_token)
user_token && Accounts.delete_user_session_token(user_token)
if live_socket_id = get_session(conn, :live_socket_id) do
NullaWeb.Endpoint.broadcast(live_socket_id, "disconnect", %{})
end
conn
|> renew_session()
|> delete_resp_cookie(@remember_me_cookie)
|> redirect(to: ~p"/")
end
@doc """
Authenticates the user by looking into the session
and remember me token.
"""
def fetch_current_user(conn, _opts) do
{user_token, conn} = ensure_user_token(conn)
user = user_token && Accounts.get_user_by_session_token(user_token)
assign(conn, :current_user, user)
end
defp ensure_user_token(conn) do
if token = get_session(conn, :user_token) do
{token, conn}
else
conn = fetch_cookies(conn, signed: [@remember_me_cookie])
if token = conn.cookies[@remember_me_cookie] do
{token, put_token_in_session(conn, token)}
else
{nil, conn}
end
end
end
@doc """
Handles mounting and authenticating the current_user in LiveViews.
## `on_mount` arguments
* `:mount_current_user` - Assigns current_user
to socket assigns based on user_token, or nil if
there's no user_token or no matching user.
* `:ensure_authenticated` - Authenticates the user from the session,
and assigns the current_user to socket assigns based
on user_token.
Redirects to login page if there's no logged user.
* `:redirect_if_user_is_authenticated` - Authenticates the user from the session.
Redirects to signed_in_path if there's a logged user.
## Examples
Use the `on_mount` lifecycle macro in LiveViews to mount or authenticate
the current_user:
defmodule NullaWeb.PageLive do
use NullaWeb, :live_view
on_mount {NullaWeb.UserAuth, :mount_current_user}
...
end
Or use the `live_session` of your router to invoke the on_mount callback:
live_session :authenticated, on_mount: [{NullaWeb.UserAuth, :ensure_authenticated}] do
live "/profile", ProfileLive, :index
end
"""
def on_mount(:mount_current_user, _params, session, socket) do
{:cont, mount_current_user(socket, session)}
end
def on_mount(:ensure_authenticated, _params, session, socket) do
socket = mount_current_user(socket, session)
if socket.assigns.current_user do
{:cont, socket}
else
socket =
socket
|> Phoenix.LiveView.put_flash(:error, "You must log in to access this page.")
|> Phoenix.LiveView.redirect(to: ~p"/users/log_in")
{:halt, socket}
end
end
def on_mount(:redirect_if_user_is_authenticated, _params, session, socket) do
socket = mount_current_user(socket, session)
if socket.assigns.current_user do
{:halt, Phoenix.LiveView.redirect(socket, to: signed_in_path(socket))}
else
{:cont, socket}
end
end
defp mount_current_user(socket, session) do
Phoenix.Component.assign_new(socket, :current_user, fn ->
if user_token = session["user_token"] do
Accounts.get_user_by_session_token(user_token)
end
end)
end
@doc """
Used for routes that require the user to not be authenticated.
"""
def redirect_if_user_is_authenticated(conn, _opts) do
if conn.assigns[:current_user] do
conn
|> redirect(to: signed_in_path(conn))
|> halt()
else
conn
end
end
@doc """
Used for routes that require the user to be authenticated.
If you want to enforce the user email is confirmed before
they use the application at all, here would be a good place.
"""
def require_authenticated_user(conn, _opts) do
if conn.assigns[:current_user] do
conn
else
conn
|> put_flash(:error, "You must log in to access this page.")
|> maybe_store_return_to()
|> redirect(to: ~p"/users/log_in")
|> halt()
end
end
def fetch_api_user(conn, _opts) do
if Mix.env() != :test do
with ["Bearer " <> token] <- get_req_header(conn, "authorization"),
{:ok, user} <- Accounts.fetch_user_by_api_token(token) do
assign(conn, :current_user, user)
else
_ ->
conn
|> send_resp(:unauthorized, "No access for you")
|> halt()
end
else
conn
end
end
defp put_token_in_session(conn, token) do
conn
|> put_session(:user_token, token)
|> put_session(:live_socket_id, "users_sessions:#{Base.url_encode64(token)}")
end
defp maybe_store_return_to(%{method: "GET"} = conn) do
put_session(conn, :user_return_to, current_path(conn))
end
defp maybe_store_return_to(conn), do: conn
defp signed_in_path(_conn), do: ~p"/"
end

View file

@ -32,6 +32,7 @@ defmodule Nulla.MixProject do
# Type `mix help deps` for examples and options.
defp deps do
[
{:bcrypt_elixir, "~> 3.0"},
{:phoenix, "~> 1.7.21"},
{:phoenix_ecto, "~> 4.5"},
{:ecto_sql, "~> 3.10"},
@ -57,8 +58,7 @@ defmodule Nulla.MixProject do
{:gettext, "~> 0.26"},
{:jason, "~> 1.2"},
{:dns_cluster, "~> 0.1.1"},
{:bandit, "~> 1.5"},
{:argon2_elixir, "~> 4.1"}
{:bandit, "~> 1.5"}
]
end

View file

@ -1,13 +1,13 @@
%{
"argon2_elixir": {:hex, :argon2_elixir, "4.1.3", "4f28318286f89453364d7fbb53e03d4563fd7ed2438a60237eba5e426e97785f", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "7c295b8d8e0eaf6f43641698f962526cdf87c6feb7d14bd21e599271b510608c"},
"bandit": {:hex, :bandit, "1.7.0", "d1564f30553c97d3e25f9623144bb8df11f3787a26733f00b21699a128105c0c", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "3e2f7a98c7a11f48d9d8c037f7177cd39778e74d55c7af06fe6227c742a8168a"},
"bcrypt_elixir": {:hex, :bcrypt_elixir, "3.3.2", "d50091e3c9492d73e17fc1e1619a9b09d6a5ef99160eb4d736926fd475a16ca3", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "471be5151874ae7931911057d1467d908955f93554f7a6cd1b7d804cac8cef53"},
"castore": {:hex, :castore, "1.0.14", "4582dd7d630b48cf5e1ca8d3d42494db51e406b7ba704e81fbd401866366896a", [:mix], [], "hexpm", "7bc1b65249d31701393edaaac18ec8398d8974d52c647b7904d01b964137b9f4"},
"comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"},
"db_connection": {:hex, :db_connection, "2.7.0", "b99faa9291bb09892c7da373bb82cba59aefa9b36300f6145c5f201c7adf48ec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "dcf08f31b2701f857dfc787fbad78223d61a32204f217f15e881dd93e4bdd3ff"},
"db_connection": {:hex, :db_connection, "2.8.0", "64fd82cfa6d8e25ec6660cea73e92a4cbc6a18b31343910427b702838c4b33b2", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "008399dae5eee1bf5caa6e86d204dcb44242c82b1ed5e22c881f2c34da201b15"},
"decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"},
"dns_cluster": {:hex, :dns_cluster, "0.1.3", "0bc20a2c88ed6cc494f2964075c359f8c2d00e1bf25518a6a6c7fd277c9b0c66", [:mix], [], "hexpm", "46cb7c4a1b3e52c7ad4cbe33ca5079fbde4840dedeafca2baf77996c2da1bc33"},
"ecto": {:hex, :ecto, "3.13.1", "ebb11c2f0307ff62e8aaba57def59ad920a3cbd89d002b1118944cbf598c13c7", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d9ea5075a6f3af9cd2cdbabe8a0759eb73b485e981fd7c03014f79479ac85340"},
"ecto_sql": {:hex, :ecto_sql, "3.13.1", "d3d76d78afd2757644b5c4f7ca37f90bcf1e05d05a06cca8526e30cefb0034a1", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "5fba0174284fd339f69376b0405942036ce5f0ff7d59402a6ccf3b7ce2903198"},
"ecto": {:hex, :ecto, "3.13.2", "7d0c0863f3fc8d71d17fc3ad3b9424beae13f02712ad84191a826c7169484f01", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "669d9291370513ff56e7b7e7081b7af3283d02e046cf3d403053c557894a0b3e"},
"ecto_sql": {:hex, :ecto_sql, "3.13.2", "a07d2461d84107b3d037097c822ffdd36ed69d1cf7c0f70e12a3d1decf04e2e1", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "539274ab0ecf1a0078a6a72ef3465629e4d6018a3028095dc90f60a19c371717"},
"elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"},
"esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"},
"expo": {:hex, :expo, "1.1.0", "f7b9ed7fb5745ebe1eeedf3d6f29226c5dd52897ac67c0f8af62a07e661e5c75", [:mix], [], "hexpm", "fbadf93f4700fb44c331362177bdca9eeb8097e8b0ef525c9cc501cb9917c960"},
@ -23,7 +23,7 @@
"nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
"nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
"phoenix": {:hex, :phoenix, "1.7.21", "14ca4f1071a5f65121217d6b57ac5712d1857e40a0833aff7a691b7870fc9a3b", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "336dce4f86cba56fed312a7d280bf2282c720abb6074bdb1b61ec8095bdd0bc9"},
"phoenix_ecto": {:hex, :phoenix_ecto, "4.6.4", "dcf3483ab45bab4c15e3a47c34451392f64e433846b08469f5d16c2a4cd70052", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "f5b8584c36ccc9b903948a696fc9b8b81102c79c7c0c751a9f00cdec55d5f2d7"},
"phoenix_ecto": {:hex, :phoenix_ecto, "4.6.5", "c4ef322acd15a574a8b1a08eff0ee0a85e73096b53ce1403b6563709f15e1cea", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "26ec3208eef407f31b748cadd044045c6fd485fbff168e35963d2f9dfff28d4b"},
"phoenix_html": {:hex, :phoenix_html, "4.2.1", "35279e2a39140068fc03f8874408d58eef734e488fc142153f055c5454fd1c08", [:mix], [], "hexpm", "cff108100ae2715dd959ae8f2a8cef8e20b593f8dfd031c9cba92702cf23e053"},
"phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.7", "405880012cb4b706f26dd1c6349125bfc903fb9e44d1ea668adaf4e04d4884b7", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "3a8625cab39ec261d48a13b7468dc619c0ede099601b084e343968309bd4d7d7"},
"phoenix_live_reload": {:hex, :phoenix_live_reload, "1.6.0", "2791fac0e2776b640192308cc90c0dbcf67843ad51387ed4ecae2038263d708d", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "b3a1fa036d7eb2f956774eda7a7638cf5123f8f2175aca6d6420a7f95e598e1c"},

View file

@ -1,45 +0,0 @@
defmodule Nulla.Repo.Migrations.CreateInstanceSettings do
use Ecto.Migration
def change do
create table(:instance_settings, primary_key: false) do
add :id, :integer, primary_key: true
add :name, :string, default: "Nulla", null: false
add :description, :text, default: "Freedom Social Network", null: false
add :registration, :boolean, default: false, null: false
add :max_characters, :integer, default: 5000, null: false
add :max_upload_size, :integer, default: 50, null: false
add :api_limit, :integer, default: 100, null: false
add :public_key, :text
add :private_key, :text
timestamps()
end
execute "ALTER TABLE instance_settings ADD CONSTRAINT single_row CHECK (id = 1);"
flush()
execute(fn ->
{public_key, private_key} = Nulla.KeyGen.gen()
now = DateTime.utc_now()
esc = fn str -> "'#{String.replace(str, "'", "''")}'" end
sql = """
INSERT INTO instance_settings (
id, name, description, registration,
max_characters, max_upload_size, api_limit,
public_key, private_key, inserted_at, updated_at
) VALUES (
1, 'Nulla', 'Freedom Social Network', false,
5000, 50, 100,
#{esc.(public_key)}, #{esc.(private_key)},
'#{now}', '#{now}'
)
"""
Ecto.Adapters.SQL.query!(Nulla.Repo, sql, [])
end)
end
end

View file

@ -1,15 +0,0 @@
defmodule Nulla.Repo.Migrations.CreateHashtags do
use Ecto.Migration
def change do
create table(:hashtags, primary_key: false) do
add :id, :bigint, primary_key: true
add :tag, :string, null: false
add :usage_count, :integer, default: 0, null: false
timestamps()
end
create unique_index(:hashtags, [:tag])
end
end

View file

@ -1,15 +0,0 @@
defmodule Nulla.Repo.Migrations.CreateUsers do
use Ecto.Migration
def change do
create table(:users, primary_key: false) do
add :id, :bigint, primary_key: true
add :email, :string
add :password, :string
add :privateKeyPem, :text
add :last_active_at, :utc_datetime
timestamps(type: :utc_datetime)
end
end
end

View file

@ -1,20 +0,0 @@
defmodule Nulla.Repo.Migrations.CreateModerationLogs do
use Ecto.Migration
def change do
create table(:moderation_logs, primary_key: false) do
add :id, :bigint, primary_key: true
add :moderator_id, references(:users, on_delete: :nilify_all), null: false
add :target_type, :string, null: false
add :target_id, :string, null: false
add :action, :string, null: false
add :reason, :text
add :metadata, :map
timestamps()
end
create index(:moderation_logs, [:moderator_id])
create index(:moderation_logs, [:target_type, :target_id])
end
end

View file

@ -1,18 +0,0 @@
defmodule Nulla.Repo.Migrations.CreateSessions do
use Ecto.Migration
def change do
create table(:sessions, primary_key: false) do
add :id, :bigint, primary_key: true
add :user_id, references(:users, on_delete: :delete_all), null: false
add :token, :string, null: false
add :user_agent, :string
add :ip, :string
timestamps(type: :utc_datetime)
end
create index(:sessions, [:user_id])
create unique_index(:sessions, [:token])
end
end

View file

@ -1,15 +0,0 @@
defmodule Nulla.Repo.Migrations.CreateBookmarks do
use Ecto.Migration
def change do
create table(:bookmarks, primary_key: false) do
add :id, :bigint, primary_key: true
add :url, :string
add :user_id, references(:users, on_delete: :delete_all)
timestamps(type: :utc_datetime)
end
create index(:bookmarks, [:user_id])
end
end

View file

@ -1,19 +0,0 @@
defmodule Nulla.Repo.Migrations.CreateNotifications do
use Ecto.Migration
def change do
create table(:notifications, primary_key: false) do
add :id, :bigint, primary_key: true
add :user_id, references(:users, on_delete: :delete_all), null: false
add :actor_id, references(:actors, on_delete: :nilify_all)
add :type, :string, null: false
add :data, :map
add :read, :boolean, default: false, null: false
timestamps()
end
create index(:notifications, [:user_id])
create index(:notifications, [:actor_id])
end
end

View file

@ -1,31 +0,0 @@
defmodule Nulla.Repo.Migrations.CreateActorRelations do
use Ecto.Migration
def change do
create table(:relations, primary_key: false) do
add :id, :bigint, primary_key: true
add :following, :boolean, null: false, default: false
add :followed_by, :boolean, null: false, default: false
add :showing_replies, :boolean, null: false, default: true
add :showing_reblogs, :boolean, null: false, default: true
add :notifying, :boolean, null: false, default: false
add :muting, :boolean, null: false, default: false
add :muting_notifications, :boolean, null: false, default: false
add :blocking, :boolean, null: false, default: false
add :blocked_by, :boolean, null: false, default: false
add :domain_blocking, :boolean, null: false, default: false
add :requested, :boolean, null: false, default: false
add :note, :string
add :local_actor_id, references(:actors, type: :bigint), null: false
add :remote_actor_id, references(:actors, type: :bigint), null: false
timestamps()
end
create index(:relations, [:local_actor_id])
create index(:relations, [:remote_actor_id])
create unique_index(:relations, [:local_actor_id, :remote_actor_id])
end
end

View file

@ -0,0 +1,30 @@
defmodule Nulla.Repo.Migrations.CreateUsersAuthTables do
use Ecto.Migration
def change do
execute "CREATE EXTENSION IF NOT EXISTS citext", ""
create table(:users, primary_key: false) do
add :id, :bigint, primary_key: true
add :email, :citext, null: false
add :hashed_password, :string, null: false
add :confirmed_at, :utc_datetime
timestamps(type: :utc_datetime)
end
create unique_index(:users, [:email])
create table(:users_tokens) do
add :user_id, references(:users, on_delete: :delete_all), null: false
add :token, :binary, null: false
add :context, :string, null: false
add :sent_to, :string
timestamps(type: :utc_datetime, updated_at: false)
end
create index(:users_tokens, [:user_id])
create unique_index(:users_tokens, [:context, :token])
end
end

View file

@ -4,25 +4,26 @@ defmodule Nulla.Repo.Migrations.CreateActors do
def change do
create table(:actors, primary_key: false) do
add :id, :bigint, primary_key: true
add :domain, :string
add :ap_id, :string, null: false
add :type, :string, null: false
add :following, :string, null: false
add :followers, :string, null: false
add :inbox, :string, null: false
add :outbox, :string, null: false
add :acct, :string
add :ap_id, :string
add :type, :string
add :following, :string
add :followers, :string
add :inbox, :string
add :outbox, :string
add :featured, :string
add :featuredTags, :string
add :preferredUsername, :string, null: false
add :preferredUsername, :string
add :name, :string
add :summary, :text
add :summary, :string
add :url, :string
add :manuallyApprovesFollowers, :boolean
add :discoverable, :boolean, default: true
add :indexable, :boolean, default: true
add :manuallyApprovesFollowers, :boolean, default: false, null: false
add :discoverable, :boolean, default: true, null: false
add :indexable, :boolean, default: true, null: false
add :published, :utc_datetime
add :memorial, :boolean, default: false
add :memorial, :boolean, default: false, null: false
add :publicKey, :map
add :privateKeyPem, :text
add :tag, {:array, :map}, default: []
add :attachment, {:array, :map}, default: []
add :endpoints, :map
@ -30,9 +31,11 @@ defmodule Nulla.Repo.Migrations.CreateActors do
add :image, :map
add :vcard_bday, :date
add :vcard_Address, :string
timestamps(type: :utc_datetime)
end
create unique_index(:actors, [:preferredUsername, :domain])
create unique_index(:actors, [:acct])
create unique_index(:actors, [:ap_id])
end
end

View file

@ -10,8 +10,8 @@ defmodule Nulla.Repo.Migrations.CreateNotes do
add :visibility, :string
add :to, {:array, :string}
add :cc, {:array, :string}
add :sensitive, :boolean, default: false
add :content, :text
add :sensitive, :boolean, default: false, null: false
add :content, :string
add :language, :string
add :actor_id, references(:actors, on_delete: :delete_all)

View file

@ -4,9 +4,9 @@ defmodule Nulla.Repo.Migrations.CreateMediaAttachments do
def change do
create table(:media_attachments, primary_key: false) do
add :id, :bigint, primary_key: true
add :type, :string, null: false
add :mediaType, :string, null: false
add :url, :string, null: false
add :type, :string
add :mediaType, :string
add :url, :string
add :name, :string
add :width, :integer
add :height, :integer
@ -14,7 +14,5 @@ defmodule Nulla.Repo.Migrations.CreateMediaAttachments do
timestamps(type: :utc_datetime)
end
create index(:media_attachments, [:note_id])
end
end

View file

@ -4,14 +4,14 @@ defmodule Nulla.Repo.Migrations.CreateActivities do
def change do
create table(:activities, primary_key: false) do
add :id, :bigint, primary_key: true
add :ap_id, :string, null: false
add :type, :string, null: false
add :actor, :string, null: false
add :object, :text, null: false
add :ap_id, :string
add :type, :string
add :actor, :string
add :object, :string
add :to, {:array, :string}
add :cc, {:array, :string}
timestamps()
timestamps(type: :utc_datetime)
end
create index(:activities, [:ap_id])

View file

@ -0,0 +1,30 @@
defmodule Nulla.Repo.Migrations.CreateRelations do
use Ecto.Migration
def change do
create table(:relations) do
add :following, :boolean, default: false, null: false
add :followed_by, :boolean, default: false, null: false
add :showing_replies, :boolean, default: false, null: false
add :showings_reblogs, :boolean, default: false, null: false
add :notifying, :boolean, default: false, null: false
add :muting, :boolean, default: false, null: false
add :muting_notifications, :boolean, default: false, null: false
add :blocking, :boolean, default: false, null: false
add :blocked_by, :boolean, default: false, null: false
add :domain_blocking, :boolean, default: false, null: false
add :requested, :boolean, default: false, null: false
add :note, :string
add :local_actor_id, references(:actors, type: :bigint), null: false
add :remote_actor_id, references(:actors, type: :bigint), null: false
timestamps(type: :utc_datetime)
end
create index(:relations, [:local_actor_id])
create index(:relations, [:remote_actor_id])
create unique_index(:relations, [:local_actor_id, :remote_actor_id])
end
end

Binary file not shown.

Before

Width:  |  Height:  |  Size: 161 KiB

After

Width:  |  Height:  |  Size: 152 B

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.2 KiB

Some files were not shown because too many files have changed in this diff Show more