diff --git a/lib/nulla/activitypub.ex b/lib/nulla/activitypub.ex index cbf5cfe..16183a4 100644 --- a/lib/nulla/activitypub.ex +++ b/lib/nulla/activitypub.ex @@ -25,63 +25,35 @@ defmodule Nulla.ActivityPub do ] end - @spec user(String.t(), Nulla.Models.User.t()) :: Jason.OrderedObject.t() - def user(domain, user) do + @spec actor(Nulla.Models.Actor.t()) :: Jason.OrderedObject.t() + def actor(actor) do Jason.OrderedObject.new( "@context": context(), - id: "https://#{domain}/@#{user.username}", - type: "Person", - following: "https://#{domain}/@#{user.username}/following", - followers: "https://#{domain}/@#{user.username}/followers", - inbox: "https://#{domain}/@#{user.username}/inbox", - outbox: "https://#{domain}/@#{user.username}/outbox", - featured: "https://#{domain}/@#{user.username}/collections/featured", - preferredUsername: user.username, - name: user.realname, - summary: user.bio, - url: "https://#{domain}/@#{user.username}", - manuallyApprovesFollowers: user.follow_approval, - discoverable: user.is_discoverable, - indexable: user.is_indexable, - published: DateTime.to_iso8601(user.inserted_at), - memorial: user.is_memorial, - publicKey: - Jason.OrderedObject.new( - id: "https://#{domain}/@#{user.username}#main-key", - owner: "https://#{domain}/@#{user.username}", - publicKeyPem: user.public_key - ), - tag: - Enum.map(user.tags, fn tag -> - Jason.OrderedObject.new( - type: "Hashtag", - href: "https://#{domain}/tags/#{tag}", - name: "##{tag}" - ) - end), - attachment: - Enum.map(user.fields, fn {name, value} -> - Jason.OrderedObject.new( - type: "PropertyValue", - name: name, - value: value - ) - end), - endpoints: Jason.OrderedObject.new(sharedInbox: "https://#{domain}/inbox"), - icon: - Jason.OrderedObject.new( - type: "Image", - mediaType: MIME.from_path(user.avatar), - url: "https://#{domain}/files/#{user.avatar}" - ), - image: - Jason.OrderedObject.new( - type: "Image", - mediaType: MIME.from_path(user.banner), - url: "https://#{domain}/files/#{user.banner}" - ), - "vcard:bday": user.birthday, - "vcard:Address": user.location + 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: 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 @@ -110,18 +82,18 @@ defmodule Nulla.ActivityPub do "https://www.w3.org/ns/activitystreams", Jason.OrderedObject.new(sensitive: "as:sensitive") ], - id: "https://#{domain}/@#{note.user.username}/#{note.id}", + id: "https://#{domain}/users/#{note.actor.preferredUsername}/statuses/#{note.id}", type: "Note", summary: nil, inReplyTo: nil, published: note.inserted_at, - url: "https://#{domain}/@#{note.user.username}/#{note.id}", - attributedTo: "https://#{domain}/@#{note.user.username}", + url: "https://#{domain}/@#{note.actor.preferredUsername}/#{note.id}", + attributedTo: "https://#{domain}/users/#{note.actor.preferredUsername}", to: [ "https://www.w3.org/ns/activitystreams#Public" ], cc: [ - "https://#{domain}/@#{note.user.username}/followers" + "https://#{domain}/users/#{note.actor.preferredUsername}/followers" ], sensetive: false, content: note.content, @@ -141,35 +113,35 @@ defmodule Nulla.ActivityPub do ) end - @spec following(String.t(), Nulla.Models.User.t(), Integer.t()) :: Jason.OrderedObject.t() - def following(domain, user, total) do + @spec following(String.t(), Nulla.Models.Actor.t(), Integer.t()) :: Jason.OrderedObject.t() + def following(domain, actor, total) do Jason.OrderedObject.new( "@context": "https://www.w3.org/ns/activitystreams", - id: "https://#{domain}/@#{user.username}/following", + id: "https://#{domain}/users/#{actor.preferredUsername}/following", type: "OrderedCollection", totalItems: total, - first: "https://#{domain}/@#{user.username}/following?page=1" + first: "https://#{domain}/users/#{actor.preferredUsername}/following?page=1" ) end @spec following( String.t(), - Nulla.Models.User.t(), + Nulla.Models.Actor.t(), Integer.t(), List.t(), Integer.t(), Integer.t() ) :: Jason.OrderedObject.t() - def following(domain, user, total, following_list, page, offset) + def following(domain, actor, total, following_list, page, offset) when is_integer(page) and page > 0 do data = [ "@context": "https://www.w3.org/ns/activitystreams", - id: "https://#{domain}/@#{user.username}/following?page=#{page}", + id: "https://#{domain}/@#{actor.preferredUsername}/following?page=#{page}", type: "OrderedCollectionPage", totalItems: total, - next: "https://#{domain}/@#{user.username}/following?page=#{page + 1}", - prev: "https://#{domain}/@#{user.username}/following?page=#{page - 1}", - partOf: "https://#{domain}/@#{user.username}/following", + next: "https://#{domain}/users/#{actor.preferredUsername}/following?page=#{page + 1}", + prev: "https://#{domain}/users/#{actor.preferredUsername}/following?page=#{page - 1}", + partOf: "https://#{domain}/users/#{actor.preferredUsername}/following", orderedItems: following_list ] @@ -192,35 +164,35 @@ defmodule Nulla.ActivityPub do Jason.OrderedObject.new(data) end - @spec followers(String.t(), Nulla.Models.User.t(), Integer.t()) :: Jason.OrderedObject.t() - def followers(domain, user, total) do + @spec followers(String.t(), Nulla.Models.Actor.t(), Integer.t()) :: Jason.OrderedObject.t() + def followers(domain, actor, total) do Jason.OrderedObject.new( "@context": "https://www.w3.org/ns/activitystreams", - id: "https://#{domain}/@#{user.username}/followers", + id: "https://#{domain}/users/#{actor.preferredUsername}/followers", type: "OrderedCollection", totalItems: total, - first: "https://#{domain}/@#{user.username}/followers?page=1" + first: "https://#{domain}/users/#{actor.preferredUsername}/followers?page=1" ) end @spec followers( String.t(), - Nulla.Models.User.t(), + Nulla.Models.Actor.t(), Integer.t(), List.t(), Integer.t(), Integer.t() ) :: Jason.OrderedObject.t() - def followers(domain, user, total, followers_list, page, offset) + def followers(domain, actor, total, followers_list, page, offset) when is_integer(page) and page > 0 do data = [ "@context": "https://www.w3.org/ns/activitystreams", - id: "https://#{domain}/@#{user.username}/followers?page=#{page}", + id: "https://#{domain}/users#{actor.preferredUsername}/followers?page=#{page}", type: "OrderedCollectionPage", totalItems: total, - next: "https://#{domain}/@#{user.username}/followers?page=#{page + 1}", - prev: "https://#{domain}/@#{user.username}/followers?page=#{page - 1}", - partOf: "https://#{domain}/@#{user.username}/followers", + next: "https://#{domain}/users/#{actor.preferredUsername}/followers?page=#{page + 1}", + prev: "https://#{domain}/users/#{actor.preferredUsername}/followers?page=#{page - 1}", + partOf: "https://#{domain}/users/#{actor.preferredUsername}/followers", orderedItems: followers_list ] @@ -243,15 +215,29 @@ defmodule Nulla.ActivityPub do Jason.OrderedObject.new(data) end - @spec webfinger(String.t(), String.t(), String.t()) :: Jason.OrderedObject.t() - def webfinger(domain, username, resource) do + @spec webfinger(Nulla.Models.Actor.t()) :: Jason.OrderedObject.t() + def webfinger(actor) do Jason.OrderedObject.new( - subject: resource, + subject: "#{actor.preferredUsername}@#{actor.domain}", + aliases: [ + "https://#{actor.domain}/@#{actor.preferredUsername}", + "https://#{actor.domain}/users/#{actor.preferredUsername}" + ], links: [ + Jason.OrderedObject.new( + rel: "http://webfinger.net/rel/profile-page", + type: "text/html", + href: "https://#{actor.domain}/users/#{actor.preferredUsername}" + ), Jason.OrderedObject.new( rel: "self", type: "application/activity+json", - href: "https://#{domain}/@#{username}" + href: "https://#{actor.domain}/users/#{actor.preferredUsername}" + ), + Jason.OrderedObject.new( + rel: "http://webfinger.net/rel/avatar", + type: actor.icon.mediaType, + href: actor.icon.url ) ] ) @@ -309,11 +295,11 @@ defmodule Nulla.ActivityPub do def outbox(domain, username, total) do Jason.OrderedObject.new( "@context": "https://www.w3.org/ns/activitystreams", - id: "https://#{domain}/@#{username}/outbox", + id: "https://#{domain}/users/#{username}/outbox", type: "OrderedCollection", totalItems: total, - first: "https://#{domain}/@#{username}/outbox?page=true", - last: "https://#{domain}/@#{username}/outbox?min_id=0&page=true" + first: "https://#{domain}/users/#{username}/outbox?page=true", + last: "https://#{domain}/users/#{username}/outbox?min_id=0&page=true" ) end @@ -328,32 +314,49 @@ defmodule Nulla.ActivityPub do Hashtag: "as:Hashtag" ) ], - id: "https://#{domain}/@#{username}/outbox?page=true", + id: "https://#{domain}/users/#{username}/outbox?page=true", type: "OrderedCollectionPage", - next: "https://#{domain}/@#{username}/outbox?max_id=#{max_id}&page=true", - prev: "https://#{domain}/@#{username}/outbox?min_id=#{min_id}&page=true", - partOf: "https://#{domain}/@#{username}/outbox", + next: "https://#{domain}/users/#{username}/outbox?max_id=#{max_id}&page=true", + prev: "https://#{domain}/users/#{username}/outbox?min_id=#{min_id}&page=true", + partOf: "https://#{domain}/users/#{username}/outbox", orderedItems: items ) end - @spec render_activity(String.t(), Note.t()) :: Jason.OrderedObject.t() - def render_activity(domain, note) do + @spec activity_note(Nulla.Models.Note.t()) :: Jason.OrderedObject.t() + def activity_note(note) do Jason.OrderedObject.new( - id: "https://#{domain}/@#{note.user.username}/#{note.id}/activity", + id: + "https://#{note.actor.domain}/users/#{note.actor.preferredUsername}/#{note.id}/activity", type: "Create", - actor: "https://#{domain}/@#{note.user.username}", + actor: "https://#{note.actor.domain}/users/#{note.actor.preferredUsername}", published: note.inserted_at |> DateTime.to_iso8601(), - to: ["https://www.w3.org/ns/activitystreams#Public"], + to: [ + "https://www.w3.org/ns/activitystreams#Public" + ], object: Jason.OrderedObject.new( - id: "https://#{domain}/@#{note.user.username}/#{note.id}", + id: + "https://#{note.actor.domain}/users/#{note.actor.preferredUsername}/statuses/#{note.id}", type: "Note", content: note.content, published: note.inserted_at |> DateTime.to_iso8601(), - attributedTo: "https://#{domain}/@#{note.user.username}", - to: ["https://www.w3.org/ns/activitystreams#Public"] + attributedTo: "https://#{note.actor.domain}/users/#{note.actor.preferredUsername}", + to: [ + "https://www.w3.org/ns/activitystreams#Public" + ] ) ) end + + @spec follow_accept(Nulla.Models.Activity.t()) :: Jason.OrderedObject.t() + def follow_accept(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 end diff --git a/lib/nulla/httpsignature.ex b/lib/nulla/httpsignature.ex new file mode 100644 index 0000000..8831606 --- /dev/null +++ b/lib/nulla/httpsignature.ex @@ -0,0 +1,5 @@ +defmodule Nulla.HTTPSignature do + def verify(_conn, _actor) do + :ok + end +end diff --git a/lib/nulla/models/activity.ex b/lib/nulla/models/activity.ex index e41b879..cfd208e 100644 --- a/lib/nulla/models/activity.ex +++ b/lib/nulla/models/activity.ex @@ -1,9 +1,11 @@ defmodule Nulla.Models.Activity do use Ecto.Schema import Ecto.Changeset + alias Nulla.SnowFlake @primary_key {:id, :integer, autogenerate: false} schema "activities" do + field :ap_id, :string field :type, :string field :actor, :string field :object, :map @@ -15,8 +17,17 @@ defmodule Nulla.Models.Activity do @doc false def changeset(activity, attrs) do activity - |> cast(attrs, [:type, :actor, :object, :to]) - |> validate_required([:type, :actor, :object]) + |> cast(attrs, [:ap_id, :type, :actor, :object, :to]) + |> validate_required([:ap_id, :type, :actor, :object]) |> validate_inclusion(:type, ~w(Create Update Delete Undo Like Announce Follow Accept Reject)) end + + def create_activity(attrs) do + id = Snowflake.next_id() + + %__MODULE__{} + |> __MODULE__.changeset(attrs) + |> Ecto.Changeset.put_change(:id, id) + |> Repo.insert() + end end diff --git a/lib/nulla/models/actor.ex b/lib/nulla/models/actor.ex index 653c37f..ace64be 100644 --- a/lib/nulla/models/actor.ex +++ b/lib/nulla/models/actor.ex @@ -1,14 +1,14 @@ defmodule Nulla.Models.Actor do use Ecto.Schema import Ecto.Changeset - import Ecto.Query alias Nulla.Repo alias Nulla.Snowflake - alias Nulla.Models.User 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 @@ -34,7 +34,6 @@ defmodule Nulla.Models.Actor do field :vcard_bday, :date field :vcard_Address, :string - has_one :user, User has_many :notes, Note has_many :media_attachments, through: [:notes, :media_attachments] end @@ -44,6 +43,8 @@ defmodule Nulla.Models.Actor do actor |> cast(attrs, [ :id, + :domain, + :ap_id, :type, :following, :followers, @@ -71,6 +72,8 @@ defmodule Nulla.Models.Actor do ]) |> validate_required([ :id, + :domain, + :ap_id, :type, :following, :followers, @@ -98,7 +101,7 @@ defmodule Nulla.Models.Actor do ]) end - def create_user(attrs) when is_map(attrs) do + def create_actor(attrs) when is_map(attrs) do id = Snowflake.next_id() %__MODULE__{} @@ -106,4 +109,8 @@ defmodule Nulla.Models.Actor do |> Ecto.Changeset.put_change(:id, id) |> Repo.insert() end + + def get_actor(username, domain) do + Repo.get_by(__MODULE__, preferredUsername: username, domain: domain) + end end diff --git a/lib/nulla/models/follow.ex b/lib/nulla/models/follow.ex index bcd92f7..b976f59 100644 --- a/lib/nulla/models/follow.ex +++ b/lib/nulla/models/follow.ex @@ -1,13 +1,14 @@ defmodule Nulla.Models.Follow do use Ecto.Schema import Ecto.Changeset + alias Nulla.Repo alias Nulla.Snowflake - alias Nulla.Models.Follow + alias Nulla.Models.Actor @primary_key {:id, :integer, autogenerate: false} schema "follows" do - belongs_to :user, Nulla.Models.User - belongs_to :target, Nulla.Models.User + belongs_to :follower, Actor + belongs_to :followed, Actor timestamps() end @@ -23,8 +24,8 @@ defmodule Nulla.Models.Follow do def create_follow(attrs) do id = Snowflake.next_id() - %Follow{} - |> Follow.changeset(attrs) + %__MODULE__{} + |> __MODULE__.changeset(attrs) |> Ecto.Changeset.put_change(:id, id) |> Repo.insert() end diff --git a/lib/nulla/models/note.ex b/lib/nulla/models/note.ex index 7a7a20f..51637e8 100644 --- a/lib/nulla/models/note.ex +++ b/lib/nulla/models/note.ex @@ -3,7 +3,8 @@ defmodule Nulla.Models.Note do import Ecto.Changeset import Ecto.Query alias Nulla.Repo - alias Nulla.Models.Note + alias Nulla.Models.Actor + alias Nulla.Models.MediaAttachment @primary_key {:id, :integer, autogenerate: false} schema "notes" do @@ -17,8 +18,8 @@ defmodule Nulla.Models.Note do field :language, :string field :in_reply_to, :string - belongs_to :user, Nulla.Models.User - has_many :media_attachments, Nulla.Models.MediaAttachment + belongs_to :actor, Actor + has_many :media_attachments, MediaAttachment timestamps(type: :utc_datetime) end @@ -26,32 +27,32 @@ defmodule Nulla.Models.Note do @doc false def changeset(note, attrs) do note - |> cast(attrs, [:content, :visibility, :sensitive, :language, :in_reply_to, :user_id]) - |> validate_required([:content, :visibility, :sensitive, :language, :in_reply_to, :user_id]) + |> cast(attrs, [:content, :visibility, :sensitive, :language, :in_reply_to, :actor_id]) + |> validate_required([:content, :visibility, :sensitive, :language, :in_reply_to, :actor_id]) end - def get_note!(id), do: Repo.get!(Note, id) + def get_note!(id), do: Repo.get!(__MODULE__, id) - def get_latest_notes(user_id, limit \\ 20) do - from(n in Note, - where: n.user_id == ^user_id, + 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 ) |> Repo.all() end - def get_before_notes(user_id, max_id, limit \\ 20) do - from(n in Note, - where: n.user_id == ^user_id and n.id < ^max_id, + 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 ) - |> Nulla.Repo.all() + |> Repo.all() end - def get_total_notes_count(user_id) do - from(n in Note, where: n.user_id == ^user_id) + def get_total_notes_count(actor_id) do + from(n in __MODULE__, where: n.actor_id == ^actor_id) |> Repo.aggregate(:count, :id) end end diff --git a/lib/nulla/models/relation.ex b/lib/nulla/models/relation.ex new file mode 100644 index 0000000..4b80963 --- /dev/null +++ b/lib/nulla/models/relation.ex @@ -0,0 +1,30 @@ +defmodule Nulla.Models.Relation do + use Ecto.Schema + import Ecto.Changeset + alias Nulla.Models.Actor + alias Nulla.Models.Activity + + @primary_key {:id, :integer, autogenerate: false} + schema "relations" do + field :type, :string + field :status, :string + + belongs_to :source, Actor, foreign_key: :source_id, type: :integer + belongs_to :target, Actor, foreign_key: :target_id, type: :integer + belongs_to :activity, Activity, foreign_key: :activity_id, type: :integer + + timestamps() + end + + def changeset(relation, attrs) do + relation + |> cast(attrs, [:id, :source_id, :target_id, :type, :status, :activity_id]) + |> validate_required([:id, :source_id, :target_id, :type]) + |> validate_inclusion(:type, ~w(follow block mute friend_request)) + |> validate_inclusion(:status, ~w(pending accepted rejected active)) + |> foreign_key_constraint(:source_id) + |> foreign_key_constraint(:target_id) + |> foreign_key_constraint(:activity_id) + |> unique_constraint([:source_id, :target_id, :type]) + end +end diff --git a/lib/nulla/models/user.ex b/lib/nulla/models/user.ex index 4328761..2d7742e 100644 --- a/lib/nulla/models/user.ex +++ b/lib/nulla/models/user.ex @@ -3,7 +3,6 @@ defmodule Nulla.Models.User do import Ecto.Changeset import Ecto.Query alias Nulla.Repo - alias Nulla.Snowflake alias Nulla.Models.User alias Nulla.Models.Actor alias Nulla.Models.Session @@ -48,8 +47,6 @@ defmodule Nulla.Models.User do def get_user_by_username(username), do: Repo.get_by(User, username: username) - def get_user_by_username!(username), do: Repo.get_by!(User, username: username) - def get_user_by_username_and_domain(username, domain) do from(u in User, where: u.username == ^username and u.domain == ^domain diff --git a/lib/nulla_web/controllers/actor_controller.ex b/lib/nulla_web/controllers/actor_controller.ex new file mode 100644 index 0000000..c79ae22 --- /dev/null +++ b/lib/nulla_web/controllers/actor_controller.ex @@ -0,0 +1,43 @@ +defmodule NullaWeb.ActorController do + use NullaWeb, :controller + alias Nulla.ActivityPub + alias Nulla.Utils + alias Nulla.Models.Actor + alias Nulla.Models.Note + alias Nulla.Models.InstanceSettings + + def show(conn, %{"username" => username}) do + accept = List.first(get_req_header(conn, "accept")) + instance_settings = InstanceSettings.get_instance_settings!() + domain = instance_settings.domain + + case Actor.get_actor(username, domain) do + nil -> + conn + |> put_status(:not_found) + |> json(%{error: "Not Found"}) + + %Actor{} = actor -> + if accept in ["application/activity+json", "application/ld+json"] do + conn + |> put_resp_content_type("application/activity+json") + |> send_resp(200, Jason.encode!(ActivityPub.actor(actor))) + else + notes = Note.get_latest_notes(actor.id) + following = Utils.count_following_by_username!(actor.preferredUsername) + followers = Utils.count_followers_by_username!(actor.preferredUsername) + + render( + conn, + :show, + domain: domain, + actor: actor, + notes: notes, + following: following, + followers: followers, + layout: false + ) + end + end + end +end diff --git a/lib/nulla_web/controllers/auth_controller.ex b/lib/nulla_web/controllers/auth_controller.ex new file mode 100644 index 0000000..3edddb1 --- /dev/null +++ b/lib/nulla_web/controllers/auth_controller.ex @@ -0,0 +1,12 @@ +defmodule NullaWeb.AuthController do + use NullaWeb, :controller + + def sign_in do + end + + def sign_out do + end + + def sign_up do + end +end diff --git a/lib/nulla_web/controllers/follow_controller.ex b/lib/nulla_web/controllers/follow_controller.ex index 6f8a76a..dce9ed9 100644 --- a/lib/nulla_web/controllers/follow_controller.ex +++ b/lib/nulla_web/controllers/follow_controller.ex @@ -2,15 +2,15 @@ defmodule NullaWeb.FollowController do use NullaWeb, :controller alias Nulla.ActivityPub alias Nulla.Utils - alias Nulla.Models.User + alias Nulla.Models.Actor alias Nulla.Models.InstanceSettings def following(conn, %{"username" => username, "page" => page_param}) do instance_settings = InstanceSettings.get_instance_settings!() domain = instance_settings.domain offset = instance_settings.offset - user = User.get_user_by_username!(username) - total = Utils.count_following_by_username!(user.username) + actor = Actor.get_actor(username, domain) + total = Utils.count_following_by_username!(actor.preferredUsername) page = case Integer.parse(page_param) do @@ -18,30 +18,30 @@ defmodule NullaWeb.FollowController do _ -> 1 end - following_list = Utils.get_following_users_by_username!(user.username, page) + following_list = Utils.get_following_users_by_username!(actor.preferredUsername, page) conn |> put_resp_content_type("application/activity+json") - |> json(ActivityPub.following(domain, user, total, following_list, page, offset)) + |> json(ActivityPub.following(domain, actor, total, following_list, page, offset)) end def following(conn, %{"username" => username}) do instance_settings = InstanceSettings.get_instance_settings!() domain = instance_settings.domain - user = User.get_user_by_username!(username) - total = Utils.count_following_by_username!(user.username) + actor = Actor.get_actor(username, domain) + total = Utils.count_following_by_username!(actor.preferredUsername) conn |> put_resp_content_type("application/activity+json") - |> json(ActivityPub.following(domain, user, total)) + |> json(ActivityPub.following(domain, actor, total)) end def followers(conn, %{"username" => username, "page" => page_param}) do instance_settings = InstanceSettings.get_instance_settings!() domain = instance_settings.domain offset = instance_settings.offset - user = User.get_user_by_username!(username) - total = Utils.count_followers_by_username!(user.username) + actor = Actor.get_actor(username, domain) + total = Utils.count_followers_by_username!(actor.preferredUsername) page = case Integer.parse(page_param) do @@ -49,21 +49,21 @@ defmodule NullaWeb.FollowController do _ -> 1 end - followers_list = Utils.get_followers_by_username!(user.username, page) + followers_list = Utils.get_followers_by_username!(actor.preferredUsername, page) conn |> put_resp_content_type("application/activity+json") - |> json(ActivityPub.followers(domain, user, total, followers_list, page, offset)) + |> json(ActivityPub.followers(domain, actor, total, followers_list, page, offset)) end def followers(conn, %{"username" => username}) do instance_settings = InstanceSettings.get_instance_settings!() domain = instance_settings.domain - user = User.get_user_by_username!(username) - total = Utils.count_followers_by_username!(user.username) + actor = Actor.get_actor(username, domain) + total = Utils.count_followers_by_username!(actor.preferredUsername) conn |> put_resp_content_type("application/activity+json") - |> json(ActivityPub.followers(domain, user, total)) + |> json(ActivityPub.followers(domain, actor, total)) end end diff --git a/lib/nulla_web/controllers/inbox_controller.ex b/lib/nulla_web/controllers/inbox_controller.ex index d0ba0e3..fd0d7a8 100644 --- a/lib/nulla_web/controllers/inbox_controller.ex +++ b/lib/nulla_web/controllers/inbox_controller.ex @@ -1,19 +1,49 @@ defmodule NullaWeb.InboxController do use NullaWeb, :controller - alias Nulla.Models.Follow + alias Nulla.HTTPSignature + alias Nulla.ActivityPub alias Nulla.Utils + alias Nulla.Models.Actor + alias Nulla.Models.Relation def inbox( conn, - %{"type" => "Follow", "actor" => actor_uri, "object" => target_uri} = activity + %{"id" => follow_id, "type" => "Follow", "actor" => actor_uri, "object" => target_uri} ) do - with {:ok, target_user} <- Utils.resolve_local_actor(target_uri), - {:ok, remote_actor} <- Utils.fetch_remote_actor(actor_uri), - :ok <- HTTPSignature.verify(conn, remote_actor), - remote_user <- Follow.create_remote_user(remote_actor), - follow <- Follow.create_follow(%{user: remote_user, target: target_user}), - :ok <- Utils.send_accept_activity(remote_actor, target_user, follow, activity) do - json(conn, %{"status" => "Follow accepted"}) + with {:ok, target_actor} <- Utils.resolve_local_actor(target_uri), + {:ok, remote_actor_json} <- Utils.fetch_remote_actor(actor_uri), + :ok <- HTTPSignature.verify(conn, remote_actor_json), + remote_actor <- + Actor.create_actor( + remote_actor_json + |> Map.put("ap_id", remote_actor_json["id"]) + |> Map.delete("id") + |> Map.put("domain", URI.parse(remote_actor_json["id"]).host) + ), + follow_activity <- + Activity.create_activity(%{ + ap_id: follow_id, + type: "Follow", + actor: actor_uri, + object: target_uri + }), + accept_activity <- + Activity.create_activity(%{ + type: "Accept", + actor: target_uri, + object: follow_activity + }), + relation <- Relation.create_relation(%{ + id: 1, + follower: remote_actor.id, + followed: target_actor.id + }) do + conn + |> put_resp_content_type("application/activity+json") + |> send_resp( + 200, + Jason.encode!(ActivityPub.follow_accept(accept_activity)) + ) else error -> IO.inspect(error, label: "Follow error") diff --git a/lib/nulla_web/controllers/nodeinfo_controller.ex b/lib/nulla_web/controllers/nodeinfo_controller.ex index 46a3bed..ff9c113 100644 --- a/lib/nulla_web/controllers/nodeinfo_controller.ex +++ b/lib/nulla_web/controllers/nodeinfo_controller.ex @@ -1,6 +1,5 @@ defmodule NullaWeb.NodeinfoController do use NullaWeb, :controller - alias Nulla.Repo alias Nulla.ActivityPub alias Nulla.Models.User alias Nulla.Models.InstanceSettings diff --git a/lib/nulla_web/controllers/outbox_controller.ex b/lib/nulla_web/controllers/outbox_controller.ex index b62214b..505a1fb 100644 --- a/lib/nulla_web/controllers/outbox_controller.ex +++ b/lib/nulla_web/controllers/outbox_controller.ex @@ -1,7 +1,7 @@ defmodule NullaWeb.OutboxController do use NullaWeb, :controller alias Nulla.ActivityPub - alias Nulla.Models.User + alias Nulla.Models.Actor alias Nulla.Models.Note alias Nulla.Models.InstanceSettings @@ -10,17 +10,17 @@ defmodule NullaWeb.OutboxController do "true" -> instance_settings = InstanceSettings.get_instance_settings!() domain = instance_settings.domain - user = User.get_user_by_username!(username) + actor = Actor.get_actor(username, domain) max_id = params["max_id"] && String.to_integer(params["max_id"]) notes = if max_id do - Note.get_before_notes(user.id, max_id) + Note.get_before_notes(actor.id, max_id) else - Note.get_latest_notes(user.id) + Note.get_latest_notes(actor.id) end - items = Enum.map(notes, &ActivityPub.render_activity(&1, domain)) + items = Enum.map(notes, &ActivityPub.activity_note(&1)) next_max_id = case List.last(notes) do @@ -44,8 +44,8 @@ defmodule NullaWeb.OutboxController do _ -> instance_settings = InstanceSettings.get_instance_settings!() domain = instance_settings.domain - user = User.get_user_by_username!(username) - total = Note.get_total_notes_count(user.id) + actor = Actor.get_actor(username, domain) + total = Note.get_total_notes_count(actor.id) conn |> put_resp_content_type("application/activity+json") diff --git a/lib/nulla_web/controllers/user_controller.ex b/lib/nulla_web/controllers/user_controller.ex index 9263318..10d530d 100644 --- a/lib/nulla_web/controllers/user_controller.ex +++ b/lib/nulla_web/controllers/user_controller.ex @@ -1,36 +1,3 @@ defmodule NullaWeb.UserController do use NullaWeb, :controller - alias Nulla.ActivityPub - alias Nulla.Utils - alias Nulla.Models.User - alias Nulla.Models.Note - alias Nulla.Models.InstanceSettings - - def show(conn, %{"username" => username}) do - accept = List.first(get_req_header(conn, "accept")) - instance_settings = InstanceSettings.get_instance_settings!() - domain = instance_settings.domain - user = User.get_user_by_username!(username) - notes = Note.get_notes(user.id) - - if accept in ["application/activity+json", "application/ld+json"] do - conn - |> put_resp_content_type("application/activity+json") - |> send_resp(200, Jason.encode!(ActivityPub.user(domain, user))) - else - following = Utils.count_following_by_username!(user.username) - followers = Utils.count_followers_by_username!(user.username) - - render( - conn, - :show, - domain: domain, - user: user, - notes: notes, - following: following, - followers: followers, - layout: false - ) - end - end end diff --git a/lib/nulla_web/controllers/webfinger_controller.ex b/lib/nulla_web/controllers/webfinger_controller.ex index e11afeb..cc7854f 100644 --- a/lib/nulla_web/controllers/webfinger_controller.ex +++ b/lib/nulla_web/controllers/webfinger_controller.ex @@ -1,24 +1,23 @@ defmodule NullaWeb.WebfingerController do use NullaWeb, :controller - alias Nulla.Repo alias Nulla.ActivityPub - alias Nulla.Models.User + alias Nulla.Models.Actor alias Nulla.Models.InstanceSettings def index(conn, %{"resource" => resource}) do case Regex.run(~r/^acct:([^@]+)@(.+)$/, resource) do [_, username, domain] -> - case User.get_user_by_username(username) do + case Actor.get_actor(username, domain) do nil -> conn |> put_status(:not_found) |> json(%{error: "Not Found"}) - user -> + %Actor{} = actor -> instance_settings = InstanceSettings.get_instance_settings!() if domain == instance_settings.domain do - json(conn, ActivityPub.webfinger(domain, username, resource)) + json(conn, ActivityPub.webfinger(actor)) else conn |> put_status(:not_found) diff --git a/lib/nulla_web/router.ex b/lib/nulla_web/router.ex index edaaf09..ed3b543 100644 --- a/lib/nulla_web/router.ex +++ b/lib/nulla_web/router.ex @@ -29,7 +29,7 @@ defmodule NullaWeb.Router do end scope "/users/:username" do - get "/", UserController, :show + get "/", ActorController, :show get "/following", FollowController, :following get "/followers", FollowController, :followers post "/inbox", InboxController, :inbox @@ -38,7 +38,7 @@ defmodule NullaWeb.Router do end scope "/@:username" do - get "/", UserController, :show + get "/", ActorController, :show get "/following", FollowController, :following get "/followers", FollowController, :followers post "/inbox", InboxController, :inbox diff --git a/priv/repo/migrations/20250615130714_create_actors.exs b/priv/repo/migrations/20250615130714_create_actors.exs index 355f3e0..d94ca47 100644 --- a/priv/repo/migrations/20250615130714_create_actors.exs +++ b/priv/repo/migrations/20250615130714_create_actors.exs @@ -4,14 +4,16 @@ defmodule Nulla.Repo.Migrations.CreateActors do def change do create table(:actors, primary_key: false) do add :id, :bigint, primary_key: true - 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 :url, :string diff --git a/priv/repo/migrations/20250615131431_create_notes.exs b/priv/repo/migrations/20250615131431_create_notes.exs index 975fd6f..a930b14 100644 --- a/priv/repo/migrations/20250615131431_create_notes.exs +++ b/priv/repo/migrations/20250615131431_create_notes.exs @@ -9,11 +9,11 @@ defmodule Nulla.Repo.Migrations.CreateNotes do add :sensitive, :boolean, default: false add :language, :string add :in_reply_to, :string - add :user_id, references(:users, on_delete: :delete_all) + add :actor_id, references(:actors, on_delete: :delete_all) timestamps(type: :utc_datetime) end - create index(:notes, [:user_id]) + create index(:notes, [:actor_id]) end end diff --git a/priv/repo/migrations/20250615131836_create_follows.exs b/priv/repo/migrations/20250615131836_create_follows.exs index b39244e..c12b6ba 100644 --- a/priv/repo/migrations/20250615131836_create_follows.exs +++ b/priv/repo/migrations/20250615131836_create_follows.exs @@ -4,13 +4,13 @@ defmodule Nulla.Repo.Migrations.CreateFollows do def change do create table(:follows, primary_key: false) do add :id, :bigint, primary_key: true - add :user_id, references(:users, on_delete: :delete_all), null: false - add :target_id, references(:users, on_delete: :delete_all), null: false + add :follower_id, references(:actors, on_delete: :delete_all), null: false + add :following_id, references(:actors, on_delete: :delete_all), null: false timestamps() end - create unique_index(:follows, [:user_id, :target_id]) - create index(:follows, [:target_id]) + create unique_index(:follows, [:follower_id, :following_id]) + create index(:follows, [:following_id]) end end diff --git a/priv/repo/migrations/20250615131856_create_activities.exs b/priv/repo/migrations/20250615131856_create_activities.exs index 25addfd..b07c822 100644 --- a/priv/repo/migrations/20250615131856_create_activities.exs +++ b/priv/repo/migrations/20250615131856_create_activities.exs @@ -4,15 +4,16 @@ defmodule Nulla.Repo.Migrations.CreateActivities do def change do create table(:activities, primary_key: false) do add :id, :bigint, primary_key: true + add :ap_id, :string, null: false add :type, :string, null: false - add :actor, :string, null: false + add :actor_id, references(:actors, type: :bigint, on_delete: :nothing), null: false add :object, :map, null: false add :to, {:array, :string}, default: [] timestamps() end - create index(:activities, [:actor]) create index(:activities, [:type]) + create index(:activities, [:actor_id]) end end diff --git a/priv/repo/migrations/20250617091354_create_relations.exs b/priv/repo/migrations/20250617091354_create_relations.exs new file mode 100644 index 0000000..b4c66bf --- /dev/null +++ b/priv/repo/migrations/20250617091354_create_relations.exs @@ -0,0 +1,23 @@ +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 :source_id, :bigint, null: false + add :target_id, :bigint, null: false + add :type, :string, null: false + add :status, :string, null: false + add :activity_id, :bigint + + timestamps() + end + + create index(:relations, [:source_id]) + create index(:relations, [:target_id]) + create index(:relations, [:type]) + create index(:relations, [:activity_id]) + + create unique_index(:relations, [:source_id, :target_id, :type]) + end +end