Compare commits
No commits in common. "82f55f7afea2b9d3e8af3f7b0a7adcd894b47845" and "ddfb58c04119348b085d87647458252dcc981773" have entirely different histories.
82f55f7afe
...
ddfb58c041
137 changed files with 3305 additions and 6517 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
@ -28,10 +28,12 @@ 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/
|
||||
|
||||
|
|
7
LICENSE
Normal file
7
LICENSE
Normal file
|
@ -0,0 +1,7 @@
|
|||
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
319
README.md
|
@ -1,18 +1,315 @@
|
|||
# Nulla
|
||||
|
||||
To start your Phoenix server:
|
||||
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.
|
||||
|
||||
* Run `mix setup` to install and setup dependencies
|
||||
* Start Phoenix endpoint with `mix phx.server` or inside IEx with `iex -S mix phx.server`
|
||||
## TODO
|
||||
|
||||
Now you can visit [`localhost:4000`](http://localhost:4000) from your browser.
|
||||
### API
|
||||
|
||||
Ready to run in production? Please [check our deployment guides](https://hexdocs.pm/phoenix/deployment.html).
|
||||
#### accounts
|
||||
|
||||
## Learn more
|
||||
- [ ] 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)
|
||||
|
||||
* 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
|
||||
#### 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.
|
||||
|
|
|
@ -20,20 +20,11 @@ config :nulla, NullaWeb.Endpoint,
|
|||
layout: false
|
||||
],
|
||||
pubsub_server: Nulla.PubSub,
|
||||
live_view: [signing_salt: "rmaJ4fGm"]
|
||||
live_view: [signing_salt: "jcAt5/U+"]
|
||||
|
||||
# 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
|
||||
|
@ -73,6 +64,11 @@ 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"
|
||||
|
|
|
@ -16,14 +16,18 @@ 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.
|
||||
http: [ip: {127, 0, 0, 1}, port: 4000],
|
||||
url: [host: host, port: port],
|
||||
http: [ip: {127, 0, 0, 1}, port: port],
|
||||
check_origin: false,
|
||||
code_reloader: true,
|
||||
debug_errors: true,
|
||||
secret_key_base: "lqclOxhFj2lh7RLyFeE47uMJTjyt298gBeH659w0B3/RgGH+bWB9GmYGl4ILMkqK",
|
||||
secret_key_base: "VfFSj33PMul7V6oKoeanGdabenUTRUabPkosFKft2PqKMlMKPW5s7Ls0OtFcgSSO",
|
||||
watchers: [
|
||||
esbuild: {Esbuild, :install_and_run, [:nulla, ~w(--sourcemap=inline --watch)]},
|
||||
tailwind: {Tailwind, :install_and_run, [:nulla, ~w(--watch)]}
|
||||
|
|
|
@ -1,8 +1,5 @@
|
|||
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
|
||||
|
@ -20,7 +17,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: "DshBXGErBJik2O+qnSRsffwx9/TZDy8anaxLwB0uQSEXJsYmCtZFwR89fL5LtoSA",
|
||||
secret_key_base: "En2KMJ4iAUgGiOa8ADt/0nmQ6yC63U2COOGVSXR+A9nENgS1+6O2HjzwAseCSBCW",
|
||||
server: false
|
||||
|
||||
# In test we don't send emails
|
||||
|
|
|
@ -1,377 +0,0 @@
|
|||
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
|
|
@ -1,171 +0,0 @@
|
|||
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
|
|
@ -1,79 +0,0 @@
|
|||
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
|
|
@ -1,179 +0,0 @@
|
|||
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
|
|
@ -1,104 +0,0 @@
|
|||
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
|
354
lib/nulla/activitypub.ex
Normal file
354
lib/nulla/activitypub.ex
Normal file
|
@ -0,0 +1,354 @@
|
|||
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
|
|
@ -1,104 +0,0 @@
|
|||
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
|
|
@ -1,95 +0,0 @@
|
|||
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
|
|
@ -1,104 +0,0 @@
|
|||
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
|
|
@ -1,7 +1,7 @@
|
|||
defmodule Nulla.Activities.Activity do
|
||||
defmodule Nulla.Models.Activity do
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
alias Ecto.Changeset
|
||||
alias Nulla.Repo
|
||||
alias Nulla.Snowflake
|
||||
alias Nulla.Types.StringOrJson
|
||||
|
||||
|
@ -15,20 +15,23 @@ defmodule Nulla.Activities.Activity do
|
|||
field :to, {:array, :string}
|
||||
field :cc, {:array, :string}
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
timestamps()
|
||||
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
|
||||
|
||||
defp maybe_put_id(%Changeset{data: %{id: nil}} = changeset) do
|
||||
change(changeset, id: Snowflake.next_id())
|
||||
end
|
||||
def create_activity(attrs) do
|
||||
id = Map.get(attrs, :id, Snowflake.next_id())
|
||||
|
||||
defp maybe_put_id(changeset), do: changeset
|
||||
%__MODULE__{}
|
||||
|> __MODULE__.changeset(attrs)
|
||||
|> put_change(:id, id)
|
||||
|> Repo.insert()
|
||||
end
|
||||
end
|
164
lib/nulla/models/actor.ex
Normal file
164
lib/nulla/models/actor.ex
Normal file
|
@ -0,0 +1,164 @@
|
|||
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
|
24
lib/nulla/models/bookmarks.ex
Normal file
24
lib/nulla/models/bookmarks.ex
Normal file
|
@ -0,0 +1,24 @@
|
|||
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
|
20
lib/nulla/models/hashtag.ex
Normal file
20
lib/nulla/models/hashtag.ex
Normal file
|
@ -0,0 +1,20 @@
|
|||
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
|
44
lib/nulla/models/instance_settings.ex
Normal file
44
lib/nulla/models/instance_settings.ex
Normal file
|
@ -0,0 +1,44 @@
|
|||
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
|
|
@ -1,9 +1,9 @@
|
|||
defmodule Nulla.MediaAttachments.MediaAttachment do
|
||||
defmodule Nulla.Models.MediaAttachment do
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
alias Ecto.Changeset
|
||||
alias Nulla.Repo
|
||||
alias Nulla.Snowflake
|
||||
alias Nulla.Notes.Note
|
||||
alias Nulla.Models.Note
|
||||
|
||||
@primary_key {:id, :integer, autogenerate: false}
|
||||
schema "media_attachments" do
|
||||
|
@ -19,17 +19,18 @@ defmodule Nulla.MediaAttachments.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
|
||||
|
||||
defp maybe_put_id(%Changeset{data: %{id: nil}} = changeset) do
|
||||
change(changeset, id: Snowflake.next_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), do: changeset
|
||||
%__MODULE__{}
|
||||
|> changeset(attrs)
|
||||
|> put_change(:id, id)
|
||||
|> Repo.insert()
|
||||
end
|
||||
end
|
25
lib/nulla/models/moderation_log.ex
Normal file
25
lib/nulla/models/moderation_log.ex
Normal file
|
@ -0,0 +1,25 @@
|
|||
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
|
134
lib/nulla/models/note.ex
Normal file
134
lib/nulla/models/note.ex
Normal file
|
@ -0,0 +1,134 @@
|
|||
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
|
25
lib/nulla/models/notification.ex
Normal file
25
lib/nulla/models/notification.ex
Normal file
|
@ -0,0 +1,25 @@
|
|||
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
|
136
lib/nulla/models/relation.ex
Normal file
136
lib/nulla/models/relation.ex
Normal file
|
@ -0,0 +1,136 @@
|
|||
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
|
23
lib/nulla/models/session.ex
Normal file
23
lib/nulla/models/session.ex
Normal file
|
@ -0,0 +1,23 @@
|
|||
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
|
65
lib/nulla/models/user.ex
Normal file
65
lib/nulla/models/user.ex
Normal file
|
@ -0,0 +1,65 @@
|
|||
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
|
|
@ -1,104 +0,0 @@
|
|||
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
|
|
@ -1,60 +0,0 @@
|
|||
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
|
|
@ -1,104 +0,0 @@
|
|||
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
|
|
@ -1,71 +0,0 @@
|
|||
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
|
|
@ -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!(ActivityJSON.activitypub(activity))
|
||||
body = Jason.encode!(ActivityPub.activity(activity))
|
||||
headers = HTTPSignature.make_headers(body, inbox, publicKeyId, privateKeyPem)
|
||||
request = Finch.build(method, inbox, headers, body)
|
||||
|
||||
|
|
92
lib/nulla/uploader.ex
Normal file
92
lib/nulla/uploader.ex
Normal file
|
@ -0,0 +1,92 @@
|
|||
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
|
25
lib/nulla/utils.ex
Normal file
25
lib/nulla/utils.ex
Normal file
|
@ -0,0 +1,25 @@
|
|||
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
|
|
@ -17,7 +17,7 @@ defmodule NullaWeb do
|
|||
those modules here.
|
||||
"""
|
||||
|
||||
def static_paths, do: ~w(assets fonts images favicon.ico robots.txt)
|
||||
def static_paths, do: ~w(assets system fonts images favicon.ico robots.txt)
|
||||
|
||||
def router do
|
||||
quote do
|
||||
|
|
|
@ -12,47 +12,6 @@
|
|||
</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>
|
||||
|
|
125
lib/nulla_web/components/templates.ex
Normal file
125
lib/nulla_web/components/templates.ex
Normal file
|
@ -0,0 +1,125 @@
|
|||
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
|
127
lib/nulla_web/components/templates/actor/show.html.heex
Normal file
127
lib/nulla_web/components/templates/actor/show.html.heex
Normal file
|
@ -0,0 +1,127 @@
|
|||
<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>
|
23
lib/nulla_web/components/templates/note/show.html.heex
Normal file
23
lib/nulla_web/components/templates/note/show.html.heex
Normal file
|
@ -0,0 +1,23 @@
|
|||
<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>
|
3
lib/nulla_web/components/templates/page/home.html.heex
Normal file
3
lib/nulla_web/components/templates/page/home.html.heex
Normal file
|
@ -0,0 +1,3 @@
|
|||
<main>
|
||||
<.flash_group flash={@flash} />
|
||||
</main>
|
12
lib/nulla_web/components/templates/page/sign_in.html.heex
Normal file
12
lib/nulla_web/components/templates/page/sign_in.html.heex
Normal file
|
@ -0,0 +1,12 @@
|
|||
<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>
|
22
lib/nulla_web/components/templates/page/sign_up.html.heex
Normal file
22
lib/nulla_web/components/templates/page/sign_up.html.heex
Normal file
|
@ -0,0 +1,22 @@
|
|||
<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>
|
|
@ -1,43 +0,0 @@
|
|||
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
|
|
@ -1,41 +0,0 @@
|
|||
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
|
21
lib/nulla_web/controllers/activitypub/actor_controller.ex
Normal file
21
lib/nulla_web/controllers/activitypub/actor_controller.ex
Normal file
|
@ -0,0 +1,21 @@
|
|||
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
|
67
lib/nulla_web/controllers/activitypub/follow_controller.ex
Normal file
67
lib/nulla_web/controllers/activitypub/follow_controller.ex
Normal file
|
@ -0,0 +1,67 @@
|
|||
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
|
18
lib/nulla_web/controllers/activitypub/hostmeta_controller.ex
Normal file
18
lib/nulla_web/controllers/activitypub/hostmeta_controller.ex
Normal file
|
@ -0,0 +1,18 @@
|
|||
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
|
215
lib/nulla_web/controllers/activitypub/inbox_controller.ex
Normal file
215
lib/nulla_web/controllers/activitypub/inbox_controller.ex
Normal file
|
@ -0,0 +1,215 @@
|
|||
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
|
29
lib/nulla_web/controllers/activitypub/nodeinfo_controller.ex
Normal file
29
lib/nulla_web/controllers/activitypub/nodeinfo_controller.ex
Normal file
|
@ -0,0 +1,29 @@
|
|||
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
|
44
lib/nulla_web/controllers/activitypub/note_controller.ex
Normal file
44
lib/nulla_web/controllers/activitypub/note_controller.ex
Normal file
|
@ -0,0 +1,44 @@
|
|||
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
|
51
lib/nulla_web/controllers/activitypub/outbox_controller.ex
Normal file
51
lib/nulla_web/controllers/activitypub/outbox_controller.ex
Normal file
|
@ -0,0 +1,51 @@
|
|||
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
|
|
@ -0,0 +1,39 @@
|
|||
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
|
|
@ -1,43 +0,0 @@
|
|||
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
|
|
@ -1,49 +0,0 @@
|
|||
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
|
33
lib/nulla_web/controllers/api/note_controller.ex
Normal file
33
lib/nulla_web/controllers/api/note_controller.ex
Normal file
|
@ -0,0 +1,33 @@
|
|||
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
|
|
@ -1,25 +0,0 @@
|
|||
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
|
|
@ -1,24 +0,0 @@
|
|||
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
|
|
@ -1,45 +0,0 @@
|
|||
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
|
|
@ -1,29 +0,0 @@
|
|||
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
|
|
@ -1,43 +0,0 @@
|
|||
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
|
|
@ -1,32 +0,0 @@
|
|||
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
|
|
@ -1,9 +0,0 @@
|
|||
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
|
|
@ -1,10 +0,0 @@
|
|||
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
|
|
@ -1,222 +0,0 @@
|
|||
<.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 & 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>
|
|
@ -1,43 +0,0 @@
|
|||
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
|
|
@ -1,35 +0,0 @@
|
|||
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
|
|
@ -1,42 +0,0 @@
|
|||
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
|
32
lib/nulla_web/controllers/web/actor_controller.ex
Normal file
32
lib/nulla_web/controllers/web/actor_controller.ex
Normal file
|
@ -0,0 +1,32 @@
|
|||
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
|
66
lib/nulla_web/controllers/web/auth_controller.ex
Normal file
66
lib/nulla_web/controllers/web/auth_controller.ex
Normal file
|
@ -0,0 +1,66 @@
|
|||
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
|
44
lib/nulla_web/controllers/web/note_controller.ex
Normal file
44
lib/nulla_web/controllers/web/note_controller.ex
Normal file
|
@ -0,0 +1,44 @@
|
|||
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
|
15
lib/nulla_web/controllers/web/page_controller.ex
Normal file
15
lib/nulla_web/controllers/web/page_controller.ex
Normal file
|
@ -0,0 +1,15 @@
|
|||
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
|
|
@ -7,7 +7,7 @@ defmodule NullaWeb.Endpoint do
|
|||
@session_options [
|
||||
store: :cookie,
|
||||
key: "_nulla_key",
|
||||
signing_salt: "FuDu07Pq",
|
||||
signing_salt: "/+24FZnt",
|
||||
same_site: "Lax"
|
||||
]
|
||||
|
||||
|
|
|
@ -1,51 +0,0 @@
|
|||
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
|
|
@ -1,58 +0,0 @@
|
|||
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
|
|
@ -1,50 +0,0 @@
|
|||
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
|
|
@ -1,43 +0,0 @@
|
|||
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
|
|
@ -1,87 +0,0 @@
|
|||
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
|
|
@ -1,89 +0,0 @@
|
|||
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
|
|
@ -1,167 +0,0 @@
|
|||
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
|
|
@ -1,37 +1,66 @@
|
|||
defmodule NullaWeb.Router do
|
||||
use NullaWeb, :router
|
||||
|
||||
import NullaWeb.UserAuth
|
||||
|
||||
pipeline :browser do
|
||||
plug :accepts, ["html"]
|
||||
plug :accepts, ["html", "json", "activity+json"]
|
||||
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
|
||||
|
||||
scope "/", NullaWeb do
|
||||
pipeline :activitypub do
|
||||
plug :accepts, ["activity+json"]
|
||||
end
|
||||
|
||||
scope "/", NullaWeb.Web, as: :web 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 do
|
||||
scope "/api", NullaWeb.Api, as: :api do
|
||||
pipe_through :api
|
||||
|
||||
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]
|
||||
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
|
||||
end
|
||||
|
||||
# Enable LiveDashboard and Swoosh mailbox preview in development
|
||||
|
@ -50,42 +79,4 @@ 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
|
||||
|
|
|
@ -1,245 +0,0 @@
|
|||
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
|
4
mix.exs
4
mix.exs
|
@ -32,7 +32,6 @@ 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"},
|
||||
|
@ -58,7 +57,8 @@ defmodule Nulla.MixProject do
|
|||
{:gettext, "~> 0.26"},
|
||||
{:jason, "~> 1.2"},
|
||||
{:dns_cluster, "~> 0.1.1"},
|
||||
{:bandit, "~> 1.5"}
|
||||
{:bandit, "~> 1.5"},
|
||||
{:argon2_elixir, "~> 4.1"}
|
||||
]
|
||||
end
|
||||
|
||||
|
|
10
mix.lock
10
mix.lock
|
@ -1,13 +1,13 @@
|
|||
%{
|
||||
"argon2_elixir": {:hex, :argon2_elixir, "4.1.3", "4f28318286f89453364d7fbb53e03d4563fd7ed2438a60237eba5e426e97785f", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "7c295b8d8e0eaf6f43641698f962526cdf87c6feb7d14bd21e599271b510608c"},
|
||||
"bandit": {:hex, :bandit, "1.7.0", "d1564f30553c97d3e25f9623144bb8df11f3787a26733f00b21699a128105c0c", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "3e2f7a98c7a11f48d9d8c037f7177cd39778e74d55c7af06fe6227c742a8168a"},
|
||||
"bcrypt_elixir": {:hex, :bcrypt_elixir, "3.3.2", "d50091e3c9492d73e17fc1e1619a9b09d6a5ef99160eb4d736926fd475a16ca3", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "471be5151874ae7931911057d1467d908955f93554f7a6cd1b7d804cac8cef53"},
|
||||
"castore": {:hex, :castore, "1.0.14", "4582dd7d630b48cf5e1ca8d3d42494db51e406b7ba704e81fbd401866366896a", [:mix], [], "hexpm", "7bc1b65249d31701393edaaac18ec8398d8974d52c647b7904d01b964137b9f4"},
|
||||
"comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"},
|
||||
"db_connection": {:hex, :db_connection, "2.8.0", "64fd82cfa6d8e25ec6660cea73e92a4cbc6a18b31343910427b702838c4b33b2", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "008399dae5eee1bf5caa6e86d204dcb44242c82b1ed5e22c881f2c34da201b15"},
|
||||
"db_connection": {:hex, :db_connection, "2.7.0", "b99faa9291bb09892c7da373bb82cba59aefa9b36300f6145c5f201c7adf48ec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "dcf08f31b2701f857dfc787fbad78223d61a32204f217f15e881dd93e4bdd3ff"},
|
||||
"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.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"},
|
||||
"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"},
|
||||
"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.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_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_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"},
|
||||
|
|
|
@ -0,0 +1,45 @@
|
|||
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
|
15
priv/repo/migrations/20250606103649_create_hashtags.exs
Normal file
15
priv/repo/migrations/20250606103649_create_hashtags.exs
Normal file
|
@ -0,0 +1,15 @@
|
|||
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
|
|
@ -4,26 +4,25 @@ defmodule Nulla.Repo.Migrations.CreateActors do
|
|||
def change do
|
||||
create table(:actors, primary_key: false) do
|
||||
add :id, :bigint, primary_key: true
|
||||
add :acct, :string
|
||||
add :ap_id, :string
|
||||
add :type, :string
|
||||
add :following, :string
|
||||
add :followers, :string
|
||||
add :inbox, :string
|
||||
add :outbox, :string
|
||||
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 :featured, :string
|
||||
add :featuredTags, :string
|
||||
add :preferredUsername, :string
|
||||
add :preferredUsername, :string, null: false
|
||||
add :name, :string
|
||||
add :summary, :string
|
||||
add :summary, :text
|
||||
add :url, :string
|
||||
add :manuallyApprovesFollowers, :boolean, default: false, null: false
|
||||
add :discoverable, :boolean, default: true, null: false
|
||||
add :indexable, :boolean, default: true, null: false
|
||||
add :manuallyApprovesFollowers, :boolean
|
||||
add :discoverable, :boolean, default: true
|
||||
add :indexable, :boolean, default: true
|
||||
add :published, :utc_datetime
|
||||
add :memorial, :boolean, default: false, null: false
|
||||
add :memorial, :boolean, default: false
|
||||
add :publicKey, :map
|
||||
add :privateKeyPem, :text
|
||||
add :tag, {:array, :map}, default: []
|
||||
add :attachment, {:array, :map}, default: []
|
||||
add :endpoints, :map
|
||||
|
@ -31,11 +30,9 @@ 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, [:acct])
|
||||
create unique_index(:actors, [:preferredUsername, :domain])
|
||||
create unique_index(:actors, [:ap_id])
|
||||
end
|
||||
end
|
15
priv/repo/migrations/20250615131158_create_users.exs
Normal file
15
priv/repo/migrations/20250615131158_create_users.exs
Normal file
|
@ -0,0 +1,15 @@
|
|||
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
|
|
@ -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, null: false
|
||||
add :content, :string
|
||||
add :sensitive, :boolean, default: false
|
||||
add :content, :text
|
||||
add :language, :string
|
||||
add :actor_id, references(:actors, on_delete: :delete_all)
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
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
|
18
priv/repo/migrations/20250615131610_create_sessions.exs
Normal file
18
priv/repo/migrations/20250615131610_create_sessions.exs
Normal file
|
@ -0,0 +1,18 @@
|
|||
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
|
|
@ -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
|
||||
add :mediaType, :string
|
||||
add :url, :string
|
||||
add :type, :string, null: false
|
||||
add :mediaType, :string, null: false
|
||||
add :url, :string, null: false
|
||||
add :name, :string
|
||||
add :width, :integer
|
||||
add :height, :integer
|
||||
|
@ -14,5 +14,7 @@ defmodule Nulla.Repo.Migrations.CreateMediaAttachments do
|
|||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
create index(:media_attachments, [:note_id])
|
||||
end
|
||||
end
|
15
priv/repo/migrations/20250615131800_create_bookmarks.exs
Normal file
15
priv/repo/migrations/20250615131800_create_bookmarks.exs
Normal file
|
@ -0,0 +1,15 @@
|
|||
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
|
|
@ -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
|
||||
add :type, :string
|
||||
add :actor, :string
|
||||
add :object, :string
|
||||
add :ap_id, :string, null: false
|
||||
add :type, :string, null: false
|
||||
add :actor, :string, null: false
|
||||
add :object, :text, null: false
|
||||
add :to, {:array, :string}
|
||||
add :cc, {:array, :string}
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
timestamps()
|
||||
end
|
||||
|
||||
create index(:activities, [:ap_id])
|
19
priv/repo/migrations/20250615132202_create_notifications.exs
Normal file
19
priv/repo/migrations/20250615132202_create_notifications.exs
Normal file
|
@ -0,0 +1,19 @@
|
|||
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
|
31
priv/repo/migrations/20250617091354_create_relations.exs
Normal file
31
priv/repo/migrations/20250617091354_create_relations.exs
Normal file
|
@ -0,0 +1,31 @@
|
|||
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
|
|
@ -1,30 +0,0 @@
|
|||
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
|
|
@ -1,30 +0,0 @@
|
|||
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: 152 B After Width: | Height: | Size: 161 KiB |
BIN
priv/static/images/default-avatar.jpg
Normal file
BIN
priv/static/images/default-avatar.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 7.7 KiB |
BIN
priv/static/images/default-header.jpg
Normal file
BIN
priv/static/images/default-header.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 9.2 KiB |
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue