From 4af88f3e1d7da5f4a5ae8baa04e8ac7ae3409499 Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Sat, 5 Jul 2025 15:20:40 +0200 Subject: [PATCH] Update --- .gitignore | 3 + config/config.exs | 5 + lib/nulla/accounts.ex | 18 ++ lib/nulla/accounts/user.ex | 1 + lib/nulla/activities.ex | 25 ++ lib/nulla/actors.ex | 2 + lib/nulla/relations.ex | 50 ++++ lib/nulla/relations/relation.ex | 2 + lib/nulla/sender.ex | 4 +- lib/nulla/utils.ex | 25 ++ lib/nulla_web.ex | 2 +- .../controllers/activitypub/activity_json.ex | 21 ++ .../activitypub/actor_controller.ex | 22 ++ .../controllers/activitypub/actor_json.ex | 54 ++++ .../activitypub/follow_controller.ex | 64 +++++ .../controllers/activitypub/follow_json.ex | 84 ++++++ .../activitypub/inbox_controller.ex | 243 ++++++++++++++++++ .../activitypub/note_controller.ex | 38 +++ .../controllers/activitypub/note_json.ex | 50 ++++ .../activitypub/outbox_controller.ex | 57 ++++ .../controllers/activitypub/outbox_json.ex | 44 ++++ .../{ => api}/activity_controller.ex | 2 +- .../controllers/{ => api}/activity_json.ex | 14 +- .../controllers/{ => api}/actor_controller.ex | 2 +- .../controllers/{ => api}/actor_json.ex | 2 +- .../{ => api}/media_attachment_controller.ex | 2 +- .../{ => api}/media_attachment_json.ex | 5 +- .../controllers/{ => api}/note_controller.ex | 2 +- .../controllers/{ => api}/note_json.ex | 2 +- .../{ => api}/relation_controller.ex | 2 +- .../controllers/{ => api}/relation_json.ex | 6 +- .../generic/hostmeta_controller.ex | 18 ++ .../generic/nodeinfo_controller.ex | 28 ++ .../controllers/generic/nodeinfo_json.ex | 52 ++++ .../generic/webfinger_controller.ex | 32 +++ .../controllers/generic/webfinger_json.ex | 50 ++++ lib/nulla_web/router.ex | 28 +- ...0250701093122_create_users_auth_tables.exs | 1 + .../20250702152953_create_relations.exs | 3 +- .../{ => api}/activity_controller_test.exs | 2 +- .../{ => api}/actor_controller_test.exs | 2 +- .../media_attachment_controller_test.exs | 2 +- .../{ => api}/note_controller_test.exs | 2 +- .../{ => api}/relation_controller_test.exs | 2 +- 44 files changed, 1041 insertions(+), 34 deletions(-) create mode 100644 lib/nulla/utils.ex create mode 100644 lib/nulla_web/controllers/activitypub/activity_json.ex create mode 100644 lib/nulla_web/controllers/activitypub/actor_controller.ex create mode 100644 lib/nulla_web/controllers/activitypub/actor_json.ex create mode 100644 lib/nulla_web/controllers/activitypub/follow_controller.ex create mode 100644 lib/nulla_web/controllers/activitypub/follow_json.ex create mode 100644 lib/nulla_web/controllers/activitypub/inbox_controller.ex create mode 100644 lib/nulla_web/controllers/activitypub/note_controller.ex create mode 100644 lib/nulla_web/controllers/activitypub/note_json.ex create mode 100644 lib/nulla_web/controllers/activitypub/outbox_controller.ex create mode 100644 lib/nulla_web/controllers/activitypub/outbox_json.ex rename lib/nulla_web/controllers/{ => api}/activity_controller.ex (96%) rename lib/nulla_web/controllers/{ => api}/activity_json.ex (62%) rename lib/nulla_web/controllers/{ => api}/actor_controller.ex (96%) rename lib/nulla_web/controllers/{ => api}/actor_json.ex (97%) rename lib/nulla_web/controllers/{ => api}/media_attachment_controller.ex (96%) rename lib/nulla_web/controllers/{ => api}/media_attachment_json.ex (84%) rename lib/nulla_web/controllers/{ => api}/note_controller.ex (96%) rename lib/nulla_web/controllers/{ => api}/note_json.ex (94%) rename lib/nulla_web/controllers/{ => api}/relation_controller.ex (96%) rename lib/nulla_web/controllers/{ => api}/relation_json.ex (84%) create mode 100644 lib/nulla_web/controllers/generic/hostmeta_controller.ex create mode 100644 lib/nulla_web/controllers/generic/nodeinfo_controller.ex create mode 100644 lib/nulla_web/controllers/generic/nodeinfo_json.ex create mode 100644 lib/nulla_web/controllers/generic/webfinger_controller.ex create mode 100644 lib/nulla_web/controllers/generic/webfinger_json.ex rename test/nulla_web/controllers/{ => api}/activity_controller_test.exs (98%) rename test/nulla_web/controllers/{ => api}/actor_controller_test.exs (99%) rename test/nulla_web/controllers/{ => api}/media_attachment_controller_test.exs (98%) rename test/nulla_web/controllers/{ => api}/note_controller_test.exs (98%) rename test/nulla_web/controllers/{ => api}/relation_controller_test.exs (98%) diff --git a/.gitignore b/.gitignore index f3111c9..6de3d05 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,9 @@ nulla-*.tar # Ignore assets that are produced by build tools. /priv/static/assets/ +# The directory of uploaded files +/priv/static/system/ + # Ignore digested assets cache. /priv/static/cache_manifest.json diff --git a/config/config.exs b/config/config.exs index 8ab193c..edaf84c 100644 --- a/config/config.exs +++ b/config/config.exs @@ -73,6 +73,11 @@ config :logger, :console, # Use Jason for JSON parsing in Phoenix config :phoenix, :json_library, Jason +# Custom mime types +config :mime, :types, %{ + "application/activity+json" => ["activity+json"] +} + # Import environment specific config. This must remain at the bottom # of this file so it overrides the configuration defined above. import_config "#{config_env()}.exs" diff --git a/lib/nulla/accounts.ex b/lib/nulla/accounts.ex index b6e5d8b..bd3ca54 100644 --- a/lib/nulla/accounts.ex +++ b/lib/nulla/accounts.ex @@ -3,6 +3,7 @@ defmodule Nulla.Accounts do The Accounts context. """ + import Ecto.Changeset import Ecto.Query, warn: false alias Nulla.Repo @@ -374,4 +375,21 @@ defmodule Nulla.Accounts do _ -> :error end end + + def get_total_users_count() do + Repo.aggregate(from(u in User), :count, :id) + end + + def get_active_users_count(days) do + cutoff = DateTime.add(DateTime.utc_now(), -days * 86400, :second) + + from(u in User, where: u.last_active_at > ^cutoff) + |> Repo.aggregate(:count, :id) + end + + def update_last_active(user) do + user + |> change(last_active_at: DateTime.utc_now()) + |> Repo.update() + end end diff --git a/lib/nulla/accounts/user.ex b/lib/nulla/accounts/user.ex index cace737..f7e778a 100644 --- a/lib/nulla/accounts/user.ex +++ b/lib/nulla/accounts/user.ex @@ -11,6 +11,7 @@ defmodule Nulla.Accounts.User do field :hashed_password, :string, redact: true field :current_password, :string, virtual: true, redact: true field :confirmed_at, :utc_datetime + field :last_active_at, :utc_datetime timestamps(type: :utc_datetime) end diff --git a/lib/nulla/activities.ex b/lib/nulla/activities.ex index c6119fd..7cd65d5 100644 --- a/lib/nulla/activities.ex +++ b/lib/nulla/activities.ex @@ -101,4 +101,29 @@ defmodule Nulla.Activities do def change_activity(%Activity{} = activity, attrs \\ %{}) do Activity.changeset(activity, attrs) end + + def get_latest_activities(actor_ap_id, limit \\ 20) do + from(a in Activity, + where: a.actor == ^actor_ap_id, + order_by: [desc: a.inserted_at], + limit: ^limit, + preload: [:actor, :media_attachments] + ) + |> Repo.all() + end + + def get_before_activities(actor_ap_id, max_id, limit \\ 20) do + from(a in Activity, + where: a.actor == ^actor_ap_id and a.id < ^max_id, + order_by: [desc: a.inserted_at], + limit: ^limit, + preload: [:actor, :media_attachments] + ) + |> Repo.all() + end + + def get_total_activities_count(actor_ap_id) do + from(a in Activity, where: a.actor == ^actor_ap_id) + |> Repo.aggregate(:count, :id) + end end diff --git a/lib/nulla/actors.ex b/lib/nulla/actors.ex index 9064c46..f357ab1 100644 --- a/lib/nulla/actors.ex +++ b/lib/nulla/actors.ex @@ -37,6 +37,8 @@ defmodule Nulla.Actors do """ def get_actor!(id), do: Repo.get!(Actor, id) + def get_actor_by(by) when is_map(by) or is_list(by), do: Repo.get_by(Actor, by) + @doc """ Creates a actor. diff --git a/lib/nulla/relations.ex b/lib/nulla/relations.ex index 088d01a..16b3cc0 100644 --- a/lib/nulla/relations.ex +++ b/lib/nulla/relations.ex @@ -1,4 +1,6 @@ defmodule Nulla.Relations do + alias Nulla.Actors.Actor + @moduledoc """ The Relations context. """ @@ -37,6 +39,8 @@ defmodule Nulla.Relations do """ def get_relation!(id), do: Repo.get!(Relation, id) + def get_relation_by(by) when is_map(by) or is_list(by), do: Repo.get_by(Relation, by) + @doc """ Creates a relation. @@ -101,4 +105,50 @@ defmodule Nulla.Relations do def change_relation(%Relation{} = relation, attrs \\ %{}) do Relation.changeset(relation, attrs) end + + def count_following(local_actor_id) do + Relation + |> 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 Relation, + 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 + Relation + |> 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 Relation, + join: a in Actor, + on: a.id == r.remote_actor_id, + where: r.local_actor_id == ^local_actor_id and r.followed_by == true, + order_by: [asc: a.published], + offset: ^offset, + limit: ^limit, + select: a + + Repo.all(query) + end end diff --git a/lib/nulla/relations/relation.ex b/lib/nulla/relations/relation.ex index 8ac7e38..5dc4c7e 100644 --- a/lib/nulla/relations/relation.ex +++ b/lib/nulla/relations/relation.ex @@ -5,6 +5,7 @@ defmodule Nulla.Relations.Relation do alias Nulla.Snowflake alias Nulla.Actors.Actor + @primary_key {:id, :integer, autogenerate: false} schema "relations" do field :following, :boolean, default: false field :followed_by, :boolean, default: false @@ -61,6 +62,7 @@ defmodule Nulla.Relations.Relation do :local_actor_id, :remote_actor_id ]) + |> unique_constraint([:local_actor_id, :remote_actor_id]) end defp maybe_put_id(%Changeset{data: %{id: nil}} = changeset) do diff --git a/lib/nulla/sender.ex b/lib/nulla/sender.ex index 110fa2c..2bfcc7d 100644 --- a/lib/nulla/sender.ex +++ b/lib/nulla/sender.ex @@ -1,9 +1,9 @@ defmodule Nulla.Sender do alias Nulla.HTTPSignature - alias NullaWeb.ActivityJSON + alias NullaWeb.ActivityPub.ActivityJSON def send_activity(method, inbox, activity, publicKeyId, privateKeyPem) do - body = Jason.encode!(ActivityJSON.activitypub(activity)) + body = Jason.encode!(ActivityJSON.show(activity)) headers = HTTPSignature.make_headers(body, inbox, publicKeyId, privateKeyPem) request = Finch.build(method, inbox, headers, body) diff --git a/lib/nulla/utils.ex b/lib/nulla/utils.ex new file mode 100644 index 0000000..4dd8d22 --- /dev/null +++ b/lib/nulla/utils.ex @@ -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 diff --git a/lib/nulla_web.ex b/lib/nulla_web.ex index 862aea6..0c0b0cb 100644 --- a/lib/nulla_web.ex +++ b/lib/nulla_web.ex @@ -17,7 +17,7 @@ defmodule NullaWeb do those modules here. """ - def static_paths, do: ~w(assets fonts images favicon.ico robots.txt) + def static_paths, do: ~w(assets system fonts images favicon.ico robots.txt) def router do quote do diff --git a/lib/nulla_web/controllers/activitypub/activity_json.ex b/lib/nulla_web/controllers/activitypub/activity_json.ex new file mode 100644 index 0000000..dfbf375 --- /dev/null +++ b/lib/nulla_web/controllers/activitypub/activity_json.ex @@ -0,0 +1,21 @@ +defmodule NullaWeb.ActivityPub.ActivityJSON do + alias Nulla.Activities.Activity + + @doc """ + Renders a single activity. + """ + def show(activity) do + data(activity) + end + + defp data(%Activity{} = activity) do + Jason.OrderedObject.new( + id: activity.ap_id, + type: activity.type, + actor: activity.actor, + object: activity.object, + to: activity.to, + cc: activity.cc + ) + end +end diff --git a/lib/nulla_web/controllers/activitypub/actor_controller.ex b/lib/nulla_web/controllers/activitypub/actor_controller.ex new file mode 100644 index 0000000..54dc462 --- /dev/null +++ b/lib/nulla_web/controllers/activitypub/actor_controller.ex @@ -0,0 +1,22 @@ +defmodule NullaWeb.ActivityPub.ActorController do + use NullaWeb, :controller + alias NullaWeb.ActivityPub.ActorJSON + alias Nulla.Actors + alias Nulla.Actors.Actor + + def show(conn, %{"username" => username}) do + domain = NullaWeb.Endpoint.host() + + case Actors.get_actor_by(acct: "#{username}@#{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!(ActorJSON.show(actor))) + end + end +end diff --git a/lib/nulla_web/controllers/activitypub/actor_json.ex b/lib/nulla_web/controllers/activitypub/actor_json.ex new file mode 100644 index 0000000..5e3b5c7 --- /dev/null +++ b/lib/nulla_web/controllers/activitypub/actor_json.ex @@ -0,0 +1,54 @@ +defmodule NullaWeb.ActivityPub.ActorJSON do + alias Nulla.Actors.Actor + + @doc """ + Renders a single actor. + """ + def show(actor) do + data(actor) + end + + defp data(%Actor{} = actor) do + Jason.OrderedObject.new( + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + Jason.OrderedObject.new( + manuallyApprovesFollowers: "as:manuallyApprovesFollowers", + alsoKnownAs: %{"@id" => "as:alsoKnownAs", "@type" => "@id"}, + movedTo: %{"@id" => "as:movedTo", "@type" => "@id"}, + schema: "http://schema.org#", + PropertyValue: "schema:PropertyValue", + value: "schema:value", + Hashtag: "as:Hashtag", + vcard: "http://www.w3.org/2006/vcard/ns#" + ) + ], + ap_id: actor.ap_id, + type: actor.type, + following: actor.following, + followers: actor.followers, + inbox: actor.inbox, + outbox: actor.outbox, + featured: actor.featured, + featuredTags: actor.featuredTags, + preferredUsername: actor.preferredUsername, + name: actor.name, + summary: actor.summary, + url: actor.url, + manuallyApprovesFollowers: actor.manuallyApprovesFollowers, + discoverable: actor.discoverable, + indexable: actor.indexable, + published: actor.published, + memorial: actor.memorial, + publicKey: actor.publicKey, + tag: actor.tag, + attachment: actor.attachment, + endpoints: actor.endpoints, + icon: actor.icon, + image: actor.image, + "vcard:bday": actor.vcard_bday, + "vcard:Address": actor.vcard_Address + ) + end +end diff --git a/lib/nulla_web/controllers/activitypub/follow_controller.ex b/lib/nulla_web/controllers/activitypub/follow_controller.ex new file mode 100644 index 0000000..57f3d97 --- /dev/null +++ b/lib/nulla_web/controllers/activitypub/follow_controller.ex @@ -0,0 +1,64 @@ +defmodule NullaWeb.ActivityPub.FollowController do + use NullaWeb, :controller + alias NullaWeb.ActivityPub.FollowJSON + alias Nulla.Actors + alias Nulla.Relations + + def following(conn, %{"username" => username, "page" => page_param}) do + domain = NullaWeb.Endpoint.host() + limit = Application.get_env(:nulla, :instance)[:api_limit] + actor = Actors.get_actor_by(acct: "#{username}@#{domain}") + total = Relations.count_following(actor.id) + + page = + case Integer.parse(page_param) do + {int, _} when int > 0 -> int + _ -> 1 + end + + following_list = Enum.map(Relations.get_following(actor.id, page, limit), & &1.ap_id) + + conn + |> put_resp_content_type("application/activity+json") + |> json(FollowJSON.following(actor, total, following_list, page, limit)) + end + + def following(conn, %{"username" => username}) do + domain = NullaWeb.Endpoint.host() + actor = Actors.get_actor_by(acct: "#{username}@#{domain}") + total = Relations.count_following(actor.id) + + conn + |> put_resp_content_type("application/activity+json") + |> json(FollowJSON.following(actor, total)) + end + + def followers(conn, %{"username" => username, "page" => page_param}) do + domain = NullaWeb.Endpoint.host() + limit = Application.get_env(:nulla, :instance)[:api_limit] + actor = Actors.get_actor_by(acct: "#{username}@#{domain}") + total = Relations.count_followers(actor.id) + + page = + case Integer.parse(page_param) do + {int, _} when int > 0 -> int + _ -> 1 + end + + followers_list = Enum.map(Relations.get_followers(actor.id, page, limit), & &1.ap_id) + + conn + |> put_resp_content_type("application/activity+json") + |> json(FollowJSON.followers(actor, total, followers_list, page, limit)) + end + + def followers(conn, %{"username" => username}) do + domain = NullaWeb.Endpoint.host() + actor = Actors.get_actor_by(acct: "#{username}@#{domain}") + total = Relations.count_followers(actor.id) + + conn + |> put_resp_content_type("application/activity+json") + |> json(FollowJSON.followers(actor, total)) + end +end diff --git a/lib/nulla_web/controllers/activitypub/follow_json.ex b/lib/nulla_web/controllers/activitypub/follow_json.ex new file mode 100644 index 0000000..0930a27 --- /dev/null +++ b/lib/nulla_web/controllers/activitypub/follow_json.ex @@ -0,0 +1,84 @@ +defmodule NullaWeb.ActivityPub.FollowJSON do + 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 + + 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 + + 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 + + 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 +end diff --git a/lib/nulla_web/controllers/activitypub/inbox_controller.ex b/lib/nulla_web/controllers/activitypub/inbox_controller.ex new file mode 100644 index 0000000..4d05bb7 --- /dev/null +++ b/lib/nulla_web/controllers/activitypub/inbox_controller.ex @@ -0,0 +1,243 @@ +defmodule NullaWeb.ActivityPub.InboxController do + use NullaWeb, :controller + alias Nulla.Snowflake + alias Nulla.HTTPSignature + alias Nulla.Sender + alias Nulla.Utils + alias Nulla.Actors + alias Nulla.Relations + alias Nulla.Activities + + 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 <- Actors.get_actor_by(ap_id: target_uri), + {:ok, remote_actor_json} <- Utils.fetch_remote_actor(actor_uri), + :ok <- HTTPSignature.verify(conn, remote_actor_json["publicKey"]["publicKeyPem"]) do + remote_actor = + case Actors.get_actor_by(ap_id: remote_actor_json["id"]) do + nil -> + case Actors.create_actor(remote_actor_json) do + {:ok, actor} -> actor + {:error, error} -> {:error, error} + end + + actor -> + actor + end + + with {:ok, follow_activity} <- + Activities.create_activity(%{ + ap_id: follow_id, + type: "Follow", + actor: remote_actor.ap_id, + object: target_uri + }), + {:ok, accept_activity} <- + Activities.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) + }) do + {:ok, _relation} = + case Relations.get_relation_by(%{ + local_actor_id: local_actor.id, + remote_actor_id: remote_actor.id + }) do + nil -> + case Relations.create_relation(%{ + local_actor_id: local_actor.id, + remote_actor_id: remote_actor.id, + followed_by: true + }) do + {:ok, relation} -> relation + {:error, changeset} -> {:error, {:relation_creation_failed, changeset}} + end + + relation -> + Relations.update_relation(relation, %{followed_by: true}) + end + + Sender.send_activity( + :post, + remote_actor.inbox, + accept_activity, + local_actor.publicKey["id"], + local_actor.privateKeyPem + ) + + send_resp(conn, 200, "") + else + error -> + json(conn, %{"error" => "Failed to process Follow: #{error}"}) + end + else + error -> + json(conn, %{"error" => "Failed to process Follow: #{error}"}) + end + end + + def inbox(conn, %{ + "id" => _accept_id, + "type" => "Accept", + "actor" => _actor_uri, + "object" => _target_uri + }) do + send_resp(conn, 200, "") + end + + def inbox(conn, %{ + "id" => _reject_id, + "type" => "Reject", + "actor" => _actor_uri, + "object" => _target_uri + }) do + send_resp(conn, 200, "") + end + + def inbox(conn, %{ + "id" => _block_id, + "type" => "Block", + "actor" => _actor_uri, + "object" => _target_uri + }) do + send_resp(conn, 200, "") + end + + def inbox(conn, %{ + "id" => _join_id, + "type" => "Join", + "actor" => _actor_uri, + "object" => _target_uri + }) do + send_resp(conn, 200, "") + end + + def inbox(conn, %{ + "id" => _leave_id, + "type" => "Leave", + "actor" => _actor_uri, + "object" => _target_uri + }) do + send_resp(conn, 200, "") + end + + def inbox(conn, %{ + "id" => _like_id, + "type" => "Like", + "actor" => _actor_uri, + "object" => _target_uri + }) do + send_resp(conn, 200, "") + end + + def inbox(conn, %{ + "id" => _dislike_id, + "type" => "Dislike", + "actor" => _actor_uri, + "object" => _target_uri + }) do + send_resp(conn, 200, "") + end + + def inbox(conn, %{ + "id" => _announce_id, + "type" => "Announce", + "actor" => _actor_uri, + "object" => _target_uri + }) do + send_resp(conn, 200, "") + end + + def inbox(conn, %{ + "id" => _question_id, + "type" => "Question", + "actor" => _actor_uri, + "object" => _target_uri + }) do + send_resp(conn, 200, "") + end + + def inbox(conn, _params) do + send_resp(conn, 400, "") + end +end diff --git a/lib/nulla_web/controllers/activitypub/note_controller.ex b/lib/nulla_web/controllers/activitypub/note_controller.ex new file mode 100644 index 0000000..58a1d27 --- /dev/null +++ b/lib/nulla_web/controllers/activitypub/note_controller.ex @@ -0,0 +1,38 @@ +defmodule NullaWeb.ActivityPub.NoteController do + use NullaWeb, :controller + alias Nulla.Repo + alias NullaWeb.ActivityPub.NoteJSON + alias Nulla.Notes + + def show(conn, %{"username" => username, "id" => id}) do + case Integer.parse(id) do + {int_id, ""} -> + note = Notes.get_note!(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 -> + conn + |> put_resp_content_type("application/activity+json") + |> json(NoteJSON.show(note)) + end + + _ -> + conn + |> put_status(:not_found) + |> json(%{error: "Not Found"}) + |> halt() + end + end +end diff --git a/lib/nulla_web/controllers/activitypub/note_json.ex b/lib/nulla_web/controllers/activitypub/note_json.ex new file mode 100644 index 0000000..c2c6436 --- /dev/null +++ b/lib/nulla_web/controllers/activitypub/note_json.ex @@ -0,0 +1,50 @@ +defmodule NullaWeb.ActivityPub.NoteJSON do + alias Nulla.Notes.Note + + @doc """ + Renders a single note. + """ + def show(note) do + data(note) + end + + defp data(%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 +end diff --git a/lib/nulla_web/controllers/activitypub/outbox_controller.ex b/lib/nulla_web/controllers/activitypub/outbox_controller.ex new file mode 100644 index 0000000..3d29f88 --- /dev/null +++ b/lib/nulla_web/controllers/activitypub/outbox_controller.ex @@ -0,0 +1,57 @@ +defmodule NullaWeb.ActivityPub.OutboxController do + use NullaWeb, :controller + alias NullaWeb.ActivityPub.OutboxJSON + alias Nulla.Actors + alias Nulla.Activities + alias Nulla.Actors.Actor + + def index(conn, %{"username" => username, "page" => "true"} = params) do + domain = NullaWeb.Endpoint.host() + actor = Actors.get_actor_by(acct: "#{username}@#{domain}") + + max_id = params["max_id"] && String.to_integer(params["max_id"]) + + activities = + if max_id do + Activities.get_before_activities(actor.ap_id, max_id) + else + Activities.get_latest_activities(actor.ap_id) + end + + next_max_id = + case List.last(activities) do + nil -> 0 + last -> last.id + end + + min_id = + case List.first(activities) do + nil -> 0 + first -> first.id + end + + conn + |> put_resp_content_type("application/activity+json") + |> send_resp( + 200, + Jason.encode!(OutboxJSON.show(actor, activities, next_max_id, min_id)) + ) + end + + def index(conn, %{"username" => username}) do + domain = NullaWeb.Endpoint.host() + actor = Actors.get_actor_by(acct: "#{username}@#{domain}") + + case actor do + %Actor{} = actor -> + total = Activities.get_total_activities_count(actor.ap_id) + + conn + |> put_resp_content_type("application/activity+json") + |> send_resp(200, Jason.encode!(OutboxJSON.index(actor, total))) + + _ -> + send_resp(conn, 404, "") + end + end +end diff --git a/lib/nulla_web/controllers/activitypub/outbox_json.ex b/lib/nulla_web/controllers/activitypub/outbox_json.ex new file mode 100644 index 0000000..de78046 --- /dev/null +++ b/lib/nulla_web/controllers/activitypub/outbox_json.ex @@ -0,0 +1,44 @@ +defmodule NullaWeb.ActivityPub.OutboxJSON do + @doc """ + Renders an outbox. + """ + def index(actor, total) do + Jason.OrderedObject.new( + "@context": "https://www.w3.org/ns/activitystreams", + id: actor.outbox, + type: "OrderedCollection", + totalItems: total, + first: "#{actor.outbox}?page=true", + last: "#{actor.outbox}?min_id=0&page=true" + ) + end + + def show(actor, activities, max_id, min_id) do + Jason.OrderedObject.new( + "@context": [ + "https://www.w3.org/ns/activitystreams", + Jason.OrderedObject.new( + sensitive: "as:sensitive", + Hashtag: "as:Hashtag" + ) + ], + id: "#{actor.outbox}?page=true", + type: "OrderedCollectionPage", + next: "#{actor.outbox}?max_id=#{max_id}&page=true", + prev: "#{actor.outbox}?min_id=#{min_id}&page=true", + partOf: actor.outbox, + orderedItems: Enum.map(activities, &data/1) + ) + end + + defp data(activity) do + Jason.OrderedObject.new( + id: activity.ap_id, + type: activity.type, + actor: activity.actor, + object: activity.object, + to: activity.to, + cc: activity.cc + ) + end +end diff --git a/lib/nulla_web/controllers/activity_controller.ex b/lib/nulla_web/controllers/api/activity_controller.ex similarity index 96% rename from lib/nulla_web/controllers/activity_controller.ex rename to lib/nulla_web/controllers/api/activity_controller.ex index 9f25573..ed690bf 100644 --- a/lib/nulla_web/controllers/activity_controller.ex +++ b/lib/nulla_web/controllers/api/activity_controller.ex @@ -1,4 +1,4 @@ -defmodule NullaWeb.ActivityController do +defmodule NullaWeb.Api.ActivityController do use NullaWeb, :controller alias Nulla.Activities diff --git a/lib/nulla_web/controllers/activity_json.ex b/lib/nulla_web/controllers/api/activity_json.ex similarity index 62% rename from lib/nulla_web/controllers/activity_json.ex rename to lib/nulla_web/controllers/api/activity_json.ex index 4713bed..c0dfdeb 100644 --- a/lib/nulla_web/controllers/activity_json.ex +++ b/lib/nulla_web/controllers/api/activity_json.ex @@ -1,4 +1,4 @@ -defmodule NullaWeb.ActivityJSON do +defmodule NullaWeb.Api.ActivityJSON do alias Nulla.Activities.Activity @doc """ @@ -26,16 +26,4 @@ defmodule NullaWeb.ActivityJSON do cc: activity.cc } end - - def activitypub(%Activity{} = activity) do - Jason.OrderedObject.new( - "@context": "https://www.w3.org/ns/activitystreams", - id: activity.ap_id, - type: activity.type, - actor: activity.actor, - object: activity.object, - to: activity.to, - cc: activity.cc - ) - end end diff --git a/lib/nulla_web/controllers/actor_controller.ex b/lib/nulla_web/controllers/api/actor_controller.ex similarity index 96% rename from lib/nulla_web/controllers/actor_controller.ex rename to lib/nulla_web/controllers/api/actor_controller.ex index cf9028e..7d87eac 100644 --- a/lib/nulla_web/controllers/actor_controller.ex +++ b/lib/nulla_web/controllers/api/actor_controller.ex @@ -1,4 +1,4 @@ -defmodule NullaWeb.ActorController do +defmodule NullaWeb.Api.ActorController do use NullaWeb, :controller alias Nulla.Actors diff --git a/lib/nulla_web/controllers/actor_json.ex b/lib/nulla_web/controllers/api/actor_json.ex similarity index 97% rename from lib/nulla_web/controllers/actor_json.ex rename to lib/nulla_web/controllers/api/actor_json.ex index ca59e62..abdde70 100644 --- a/lib/nulla_web/controllers/actor_json.ex +++ b/lib/nulla_web/controllers/api/actor_json.ex @@ -1,4 +1,4 @@ -defmodule NullaWeb.ActorJSON do +defmodule NullaWeb.Api.ActorJSON do alias Nulla.Actors.Actor @doc """ diff --git a/lib/nulla_web/controllers/media_attachment_controller.ex b/lib/nulla_web/controllers/api/media_attachment_controller.ex similarity index 96% rename from lib/nulla_web/controllers/media_attachment_controller.ex rename to lib/nulla_web/controllers/api/media_attachment_controller.ex index 840f503..e23466a 100644 --- a/lib/nulla_web/controllers/media_attachment_controller.ex +++ b/lib/nulla_web/controllers/api/media_attachment_controller.ex @@ -1,4 +1,4 @@ -defmodule NullaWeb.MediaAttachmentController do +defmodule NullaWeb.Api.MediaAttachmentController do use NullaWeb, :controller alias Nulla.MediaAttachments diff --git a/lib/nulla_web/controllers/media_attachment_json.ex b/lib/nulla_web/controllers/api/media_attachment_json.ex similarity index 84% rename from lib/nulla_web/controllers/media_attachment_json.ex rename to lib/nulla_web/controllers/api/media_attachment_json.ex index 8c24f1f..0ae1df8 100644 --- a/lib/nulla_web/controllers/media_attachment_json.ex +++ b/lib/nulla_web/controllers/api/media_attachment_json.ex @@ -1,4 +1,4 @@ -defmodule NullaWeb.MediaAttachmentJSON do +defmodule NullaWeb.Api.MediaAttachmentJSON do alias Nulla.MediaAttachments.MediaAttachment @doc """ @@ -23,7 +23,8 @@ defmodule NullaWeb.MediaAttachmentJSON do url: media_attachment.url, name: media_attachment.name, width: media_attachment.width, - height: media_attachment.height + height: media_attachment.height, + note_id: media_attachment.note_id } end end diff --git a/lib/nulla_web/controllers/note_controller.ex b/lib/nulla_web/controllers/api/note_controller.ex similarity index 96% rename from lib/nulla_web/controllers/note_controller.ex rename to lib/nulla_web/controllers/api/note_controller.ex index 925ebe3..dc90321 100644 --- a/lib/nulla_web/controllers/note_controller.ex +++ b/lib/nulla_web/controllers/api/note_controller.ex @@ -1,4 +1,4 @@ -defmodule NullaWeb.NoteController do +defmodule NullaWeb.Api.NoteController do use NullaWeb, :controller alias Nulla.Notes diff --git a/lib/nulla_web/controllers/note_json.ex b/lib/nulla_web/controllers/api/note_json.ex similarity index 94% rename from lib/nulla_web/controllers/note_json.ex rename to lib/nulla_web/controllers/api/note_json.ex index a7773d9..c329cb6 100644 --- a/lib/nulla_web/controllers/note_json.ex +++ b/lib/nulla_web/controllers/api/note_json.ex @@ -1,4 +1,4 @@ -defmodule NullaWeb.NoteJSON do +defmodule NullaWeb.Api.NoteJSON do alias Nulla.Notes.Note @doc """ diff --git a/lib/nulla_web/controllers/relation_controller.ex b/lib/nulla_web/controllers/api/relation_controller.ex similarity index 96% rename from lib/nulla_web/controllers/relation_controller.ex rename to lib/nulla_web/controllers/api/relation_controller.ex index cc5f968..6ac4682 100644 --- a/lib/nulla_web/controllers/relation_controller.ex +++ b/lib/nulla_web/controllers/api/relation_controller.ex @@ -1,4 +1,4 @@ -defmodule NullaWeb.RelationController do +defmodule NullaWeb.Api.RelationController do use NullaWeb, :controller alias Nulla.Relations diff --git a/lib/nulla_web/controllers/relation_json.ex b/lib/nulla_web/controllers/api/relation_json.ex similarity index 84% rename from lib/nulla_web/controllers/relation_json.ex rename to lib/nulla_web/controllers/api/relation_json.ex index 42ab693..11e1a8b 100644 --- a/lib/nulla_web/controllers/relation_json.ex +++ b/lib/nulla_web/controllers/api/relation_json.ex @@ -1,4 +1,4 @@ -defmodule NullaWeb.RelationJSON do +defmodule NullaWeb.Api.RelationJSON do alias Nulla.Relations.Relation @doc """ @@ -29,7 +29,9 @@ defmodule NullaWeb.RelationJSON do blocked_by: relation.blocked_by, domain_blocking: relation.domain_blocking, requested: relation.requested, - note: relation.note + note: relation.note, + local_actor_id: relation.local_actor_id, + remote_actor_id: relation.remote_actor_id } end end diff --git a/lib/nulla_web/controllers/generic/hostmeta_controller.ex b/lib/nulla_web/controllers/generic/hostmeta_controller.ex new file mode 100644 index 0000000..5319a2e --- /dev/null +++ b/lib/nulla_web/controllers/generic/hostmeta_controller.ex @@ -0,0 +1,18 @@ +defmodule NullaWeb.Generic.HostmetaController do + use NullaWeb, :controller + + def index(conn, _params) do + url = NullaWeb.Endpoint.url() + + xml = """ + + + + + """ + + conn + |> put_resp_content_type("application/xrd+xml") + |> send_resp(200, xml) + end +end diff --git a/lib/nulla_web/controllers/generic/nodeinfo_controller.ex b/lib/nulla_web/controllers/generic/nodeinfo_controller.ex new file mode 100644 index 0000000..ea6bf7b --- /dev/null +++ b/lib/nulla_web/controllers/generic/nodeinfo_controller.ex @@ -0,0 +1,28 @@ +defmodule NullaWeb.Generic.NodeinfoController do + use NullaWeb, :controller + alias Nulla.Accounts + alias NullaWeb.Generic.NodeinfoJSON + + def index(conn, _params) do + url = NullaWeb.Endpoint.url() + + json(conn, NodeinfoJSON.index(url)) + end + + def show(conn, _params) do + version = Application.spec(:nulla, :vsn) |> to_string() + total = Accounts.get_total_users_count() + month = Accounts.get_active_users_count(30) + halfyear = Accounts.get_active_users_count(180) + + users = %{ + total: total, + month: month, + halfyear: halfyear + } + + instance = Application.get_env(:nulla, :instance) |> Map.new() + + json(conn, NodeinfoJSON.show(version, users, instance)) + end +end diff --git a/lib/nulla_web/controllers/generic/nodeinfo_json.ex b/lib/nulla_web/controllers/generic/nodeinfo_json.ex new file mode 100644 index 0000000..0e875f3 --- /dev/null +++ b/lib/nulla_web/controllers/generic/nodeinfo_json.ex @@ -0,0 +1,52 @@ +defmodule NullaWeb.Generic.NodeinfoJSON do + @doc """ + Renders a nodeinfo. + """ + def index(url) do + Jason.OrderedObject.new( + links: [ + Jason.OrderedObject.new( + rel: "http://nodeinfo.diaspora.software/ns/schema/2.0", + href: "#{url}/nodeinfo/2.0" + ) + ] + ) + end + + @doc """ + Renders a nodeinfo. + """ + def show(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 +end diff --git a/lib/nulla_web/controllers/generic/webfinger_controller.ex b/lib/nulla_web/controllers/generic/webfinger_controller.ex new file mode 100644 index 0000000..586ce4d --- /dev/null +++ b/lib/nulla_web/controllers/generic/webfinger_controller.ex @@ -0,0 +1,32 @@ +defmodule NullaWeb.Generic.WebfingerController do + use NullaWeb, :controller + alias Nulla.Actors + alias Nulla.Actors.Actor + alias NullaWeb.Generic.WebfingerJSON + + def index(conn, %{"resource" => resource}) do + case Regex.run(~r/^acct:(.+)$/, resource) do + [_, acct] -> + case Actors.get_actor_by(acct: acct) do + nil -> + conn + |> put_resp_content_type("text/plain") + |> send_resp(404, "") + + %Actor{} = actor -> + json(conn, WebfingerJSON.show(actor)) + end + + _ -> + conn + |> put_resp_content_type("text/plain") + |> send_resp(400, "") + end + end + + def index(conn, _params) do + conn + |> put_resp_content_type("text/plain") + |> send_resp(400, "") + end +end diff --git a/lib/nulla_web/controllers/generic/webfinger_json.ex b/lib/nulla_web/controllers/generic/webfinger_json.ex new file mode 100644 index 0000000..9802481 --- /dev/null +++ b/lib/nulla_web/controllers/generic/webfinger_json.ex @@ -0,0 +1,50 @@ +defmodule NullaWeb.Generic.WebfingerJSON do + alias Nulla.Actors.Actor + + @doc """ + Renders a webfinger. + """ + def show(actor) do + data(actor) + end + + defp data(%Actor{} = actor) do + data = [ + subject: actor.acct, + 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 +end diff --git a/lib/nulla_web/router.ex b/lib/nulla_web/router.ex index 0693036..09ced6c 100644 --- a/lib/nulla_web/router.ex +++ b/lib/nulla_web/router.ex @@ -18,13 +18,17 @@ defmodule NullaWeb.Router do plug :fetch_api_user end + pipeline :activitypub do + plug :accepts, ["activity+json"] + end + scope "/", NullaWeb do pipe_through :browser get "/", PageController, :home end - scope "/api", NullaWeb do + scope "/api", NullaWeb.Api, as: :api do pipe_through :api resources "/actors", ActorController, except: [:new, :edit] @@ -34,6 +38,28 @@ defmodule NullaWeb.Router do resources "/activities", ActivityController, except: [:new, :edit] end + scope "/", NullaWeb.ActivityPub, as: :activitypub do + pipe_through :activitypub + + 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, :index + get "/notes/:id", NoteController, :show + end + end + + scope "/", NullaWeb.Generic, as: :generic do + 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 + end + # Enable LiveDashboard and Swoosh mailbox preview in development if Application.compile_env(:nulla, :dev_routes) do # If you want to use the LiveDashboard in production, you should put diff --git a/priv/repo/migrations/20250701093122_create_users_auth_tables.exs b/priv/repo/migrations/20250701093122_create_users_auth_tables.exs index 7068f76..43b6690 100644 --- a/priv/repo/migrations/20250701093122_create_users_auth_tables.exs +++ b/priv/repo/migrations/20250701093122_create_users_auth_tables.exs @@ -9,6 +9,7 @@ defmodule Nulla.Repo.Migrations.CreateUsersAuthTables do add :email, :citext, null: false add :hashed_password, :string, null: false add :confirmed_at, :utc_datetime + add :last_active_at, :utc_datetime timestamps(type: :utc_datetime) end diff --git a/priv/repo/migrations/20250702152953_create_relations.exs b/priv/repo/migrations/20250702152953_create_relations.exs index a8d7caf..97368bd 100644 --- a/priv/repo/migrations/20250702152953_create_relations.exs +++ b/priv/repo/migrations/20250702152953_create_relations.exs @@ -2,7 +2,8 @@ defmodule Nulla.Repo.Migrations.CreateRelations do use Ecto.Migration def change do - create table(:relations) do + create table(:relations, primary_key: false) do + add :id, :bigint, primary_key: true add :following, :boolean, default: false, null: false add :followed_by, :boolean, default: false, null: false add :showing_replies, :boolean, default: false, null: false diff --git a/test/nulla_web/controllers/activity_controller_test.exs b/test/nulla_web/controllers/api/activity_controller_test.exs similarity index 98% rename from test/nulla_web/controllers/activity_controller_test.exs rename to test/nulla_web/controllers/api/activity_controller_test.exs index 1ae210c..c2ff27c 100644 --- a/test/nulla_web/controllers/activity_controller_test.exs +++ b/test/nulla_web/controllers/api/activity_controller_test.exs @@ -1,4 +1,4 @@ -defmodule NullaWeb.ActivityControllerTest do +defmodule NullaWeb.Api.ActivityControllerTest do use NullaWeb.ConnCase import Nulla.ActivitiesFixtures diff --git a/test/nulla_web/controllers/actor_controller_test.exs b/test/nulla_web/controllers/api/actor_controller_test.exs similarity index 99% rename from test/nulla_web/controllers/actor_controller_test.exs rename to test/nulla_web/controllers/api/actor_controller_test.exs index 39f5224..2f0364d 100644 --- a/test/nulla_web/controllers/actor_controller_test.exs +++ b/test/nulla_web/controllers/api/actor_controller_test.exs @@ -1,4 +1,4 @@ -defmodule NullaWeb.ActorControllerTest do +defmodule NullaWeb.Api.ActorControllerTest do use NullaWeb.ConnCase import Nulla.ActorsFixtures diff --git a/test/nulla_web/controllers/media_attachment_controller_test.exs b/test/nulla_web/controllers/api/media_attachment_controller_test.exs similarity index 98% rename from test/nulla_web/controllers/media_attachment_controller_test.exs rename to test/nulla_web/controllers/api/media_attachment_controller_test.exs index b443cc7..dd25989 100644 --- a/test/nulla_web/controllers/media_attachment_controller_test.exs +++ b/test/nulla_web/controllers/api/media_attachment_controller_test.exs @@ -1,4 +1,4 @@ -defmodule NullaWeb.MediaAttachmentControllerTest do +defmodule NullaWeb.Api.MediaAttachmentControllerTest do use NullaWeb.ConnCase import Nulla.NotesFixtures diff --git a/test/nulla_web/controllers/note_controller_test.exs b/test/nulla_web/controllers/api/note_controller_test.exs similarity index 98% rename from test/nulla_web/controllers/note_controller_test.exs rename to test/nulla_web/controllers/api/note_controller_test.exs index b5855fa..fe2afce 100644 --- a/test/nulla_web/controllers/note_controller_test.exs +++ b/test/nulla_web/controllers/api/note_controller_test.exs @@ -1,4 +1,4 @@ -defmodule NullaWeb.NoteControllerTest do +defmodule NullaWeb.Api.NoteControllerTest do use NullaWeb.ConnCase import Nulla.ActorsFixtures diff --git a/test/nulla_web/controllers/relation_controller_test.exs b/test/nulla_web/controllers/api/relation_controller_test.exs similarity index 98% rename from test/nulla_web/controllers/relation_controller_test.exs rename to test/nulla_web/controllers/api/relation_controller_test.exs index a221d33..1c04ca2 100644 --- a/test/nulla_web/controllers/relation_controller_test.exs +++ b/test/nulla_web/controllers/api/relation_controller_test.exs @@ -1,4 +1,4 @@ -defmodule NullaWeb.RelationControllerTest do +defmodule NullaWeb.Api.RelationControllerTest do use NullaWeb.ConnCase import Nulla.ActorsFixtures