This commit is contained in:
Mirai Kumiko 2025-06-17 12:06:36 +02:00
parent 58049c93d4
commit 894866ca03
Signed by: miraikumiko
GPG key ID: 3F178B1B5E0CB278
22 changed files with 344 additions and 213 deletions

View file

@ -25,63 +25,35 @@ defmodule Nulla.ActivityPub do
] ]
end end
@spec user(String.t(), Nulla.Models.User.t()) :: Jason.OrderedObject.t() @spec actor(Nulla.Models.Actor.t()) :: Jason.OrderedObject.t()
def user(domain, user) do def actor(actor) do
Jason.OrderedObject.new( Jason.OrderedObject.new(
"@context": context(), "@context": context(),
id: "https://#{domain}/@#{user.username}", id: actor.ap_id,
type: "Person", type: actor.type,
following: "https://#{domain}/@#{user.username}/following", following: actor.following,
followers: "https://#{domain}/@#{user.username}/followers", followers: actor.followers,
inbox: "https://#{domain}/@#{user.username}/inbox", inbox: actor.inbox,
outbox: "https://#{domain}/@#{user.username}/outbox", outbox: actor.outbox,
featured: "https://#{domain}/@#{user.username}/collections/featured", featured: actor.featured,
preferredUsername: user.username, featuredTags: actor.featuredTags,
name: user.realname, preferredUsername: actor.preferredUsername,
summary: user.bio, name: actor.name,
url: "https://#{domain}/@#{user.username}", summary: actor.summary,
manuallyApprovesFollowers: user.follow_approval, url: actor.url,
discoverable: user.is_discoverable, manuallyApprovesFollowers: actor.manuallyApprovesFollowers,
indexable: user.is_indexable, discoverable: actor.discoverable,
published: DateTime.to_iso8601(user.inserted_at), indexable: actor.indexable,
memorial: user.is_memorial, published: DateTime.to_iso8601(actor.published),
publicKey: memorial: actor.memorial,
Jason.OrderedObject.new( publicKey: actor.publicKey,
id: "https://#{domain}/@#{user.username}#main-key", tag: actor.tag,
owner: "https://#{domain}/@#{user.username}", attachment: actor.attachment,
publicKeyPem: user.public_key endpoints: actor.endpoints,
), icon: actor.icon,
tag: image: actor.image,
Enum.map(user.tags, fn tag -> "vcard:bday": actor.vcard_bday,
Jason.OrderedObject.new( "vcard:Address": actor.vcard_Address
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
) )
end end
@ -110,18 +82,18 @@ defmodule Nulla.ActivityPub do
"https://www.w3.org/ns/activitystreams", "https://www.w3.org/ns/activitystreams",
Jason.OrderedObject.new(sensitive: "as:sensitive") 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", type: "Note",
summary: nil, summary: nil,
inReplyTo: nil, inReplyTo: nil,
published: note.inserted_at, published: note.inserted_at,
url: "https://#{domain}/@#{note.user.username}/#{note.id}", url: "https://#{domain}/@#{note.actor.preferredUsername}/#{note.id}",
attributedTo: "https://#{domain}/@#{note.user.username}", attributedTo: "https://#{domain}/users/#{note.actor.preferredUsername}",
to: [ to: [
"https://www.w3.org/ns/activitystreams#Public" "https://www.w3.org/ns/activitystreams#Public"
], ],
cc: [ cc: [
"https://#{domain}/@#{note.user.username}/followers" "https://#{domain}/users/#{note.actor.preferredUsername}/followers"
], ],
sensetive: false, sensetive: false,
content: note.content, content: note.content,
@ -141,35 +113,35 @@ defmodule Nulla.ActivityPub do
) )
end end
@spec following(String.t(), Nulla.Models.User.t(), Integer.t()) :: Jason.OrderedObject.t() @spec following(String.t(), Nulla.Models.Actor.t(), Integer.t()) :: Jason.OrderedObject.t()
def following(domain, user, total) do def following(domain, actor, total) do
Jason.OrderedObject.new( Jason.OrderedObject.new(
"@context": "https://www.w3.org/ns/activitystreams", "@context": "https://www.w3.org/ns/activitystreams",
id: "https://#{domain}/@#{user.username}/following", id: "https://#{domain}/users/#{actor.preferredUsername}/following",
type: "OrderedCollection", type: "OrderedCollection",
totalItems: total, totalItems: total,
first: "https://#{domain}/@#{user.username}/following?page=1" first: "https://#{domain}/users/#{actor.preferredUsername}/following?page=1"
) )
end end
@spec following( @spec following(
String.t(), String.t(),
Nulla.Models.User.t(), Nulla.Models.Actor.t(),
Integer.t(), Integer.t(),
List.t(), List.t(),
Integer.t(), Integer.t(),
Integer.t() Integer.t()
) :: Jason.OrderedObject.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 when is_integer(page) and page > 0 do
data = [ data = [
"@context": "https://www.w3.org/ns/activitystreams", "@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", type: "OrderedCollectionPage",
totalItems: total, totalItems: total,
next: "https://#{domain}/@#{user.username}/following?page=#{page + 1}", next: "https://#{domain}/users/#{actor.preferredUsername}/following?page=#{page + 1}",
prev: "https://#{domain}/@#{user.username}/following?page=#{page - 1}", prev: "https://#{domain}/users/#{actor.preferredUsername}/following?page=#{page - 1}",
partOf: "https://#{domain}/@#{user.username}/following", partOf: "https://#{domain}/users/#{actor.preferredUsername}/following",
orderedItems: following_list orderedItems: following_list
] ]
@ -192,35 +164,35 @@ defmodule Nulla.ActivityPub do
Jason.OrderedObject.new(data) Jason.OrderedObject.new(data)
end end
@spec followers(String.t(), Nulla.Models.User.t(), Integer.t()) :: Jason.OrderedObject.t() @spec followers(String.t(), Nulla.Models.Actor.t(), Integer.t()) :: Jason.OrderedObject.t()
def followers(domain, user, total) do def followers(domain, actor, total) do
Jason.OrderedObject.new( Jason.OrderedObject.new(
"@context": "https://www.w3.org/ns/activitystreams", "@context": "https://www.w3.org/ns/activitystreams",
id: "https://#{domain}/@#{user.username}/followers", id: "https://#{domain}/users/#{actor.preferredUsername}/followers",
type: "OrderedCollection", type: "OrderedCollection",
totalItems: total, totalItems: total,
first: "https://#{domain}/@#{user.username}/followers?page=1" first: "https://#{domain}/users/#{actor.preferredUsername}/followers?page=1"
) )
end end
@spec followers( @spec followers(
String.t(), String.t(),
Nulla.Models.User.t(), Nulla.Models.Actor.t(),
Integer.t(), Integer.t(),
List.t(), List.t(),
Integer.t(), Integer.t(),
Integer.t() Integer.t()
) :: Jason.OrderedObject.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 when is_integer(page) and page > 0 do
data = [ data = [
"@context": "https://www.w3.org/ns/activitystreams", "@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", type: "OrderedCollectionPage",
totalItems: total, totalItems: total,
next: "https://#{domain}/@#{user.username}/followers?page=#{page + 1}", next: "https://#{domain}/users/#{actor.preferredUsername}/followers?page=#{page + 1}",
prev: "https://#{domain}/@#{user.username}/followers?page=#{page - 1}", prev: "https://#{domain}/users/#{actor.preferredUsername}/followers?page=#{page - 1}",
partOf: "https://#{domain}/@#{user.username}/followers", partOf: "https://#{domain}/users/#{actor.preferredUsername}/followers",
orderedItems: followers_list orderedItems: followers_list
] ]
@ -243,15 +215,29 @@ defmodule Nulla.ActivityPub do
Jason.OrderedObject.new(data) Jason.OrderedObject.new(data)
end end
@spec webfinger(String.t(), String.t(), String.t()) :: Jason.OrderedObject.t() @spec webfinger(Nulla.Models.Actor.t()) :: Jason.OrderedObject.t()
def webfinger(domain, username, resource) do def webfinger(actor) do
Jason.OrderedObject.new( Jason.OrderedObject.new(
subject: resource, subject: "#{actor.preferredUsername}@#{actor.domain}",
aliases: [
"https://#{actor.domain}/@#{actor.preferredUsername}",
"https://#{actor.domain}/users/#{actor.preferredUsername}"
],
links: [ links: [
Jason.OrderedObject.new(
rel: "http://webfinger.net/rel/profile-page",
type: "text/html",
href: "https://#{actor.domain}/users/#{actor.preferredUsername}"
),
Jason.OrderedObject.new( Jason.OrderedObject.new(
rel: "self", rel: "self",
type: "application/activity+json", 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 def outbox(domain, username, total) do
Jason.OrderedObject.new( Jason.OrderedObject.new(
"@context": "https://www.w3.org/ns/activitystreams", "@context": "https://www.w3.org/ns/activitystreams",
id: "https://#{domain}/@#{username}/outbox", id: "https://#{domain}/users/#{username}/outbox",
type: "OrderedCollection", type: "OrderedCollection",
totalItems: total, totalItems: total,
first: "https://#{domain}/@#{username}/outbox?page=true", first: "https://#{domain}/users/#{username}/outbox?page=true",
last: "https://#{domain}/@#{username}/outbox?min_id=0&page=true" last: "https://#{domain}/users/#{username}/outbox?min_id=0&page=true"
) )
end end
@ -328,32 +314,49 @@ defmodule Nulla.ActivityPub do
Hashtag: "as:Hashtag" Hashtag: "as:Hashtag"
) )
], ],
id: "https://#{domain}/@#{username}/outbox?page=true", id: "https://#{domain}/users/#{username}/outbox?page=true",
type: "OrderedCollectionPage", type: "OrderedCollectionPage",
next: "https://#{domain}/@#{username}/outbox?max_id=#{max_id}&page=true", next: "https://#{domain}/users/#{username}/outbox?max_id=#{max_id}&page=true",
prev: "https://#{domain}/@#{username}/outbox?min_id=#{min_id}&page=true", prev: "https://#{domain}/users/#{username}/outbox?min_id=#{min_id}&page=true",
partOf: "https://#{domain}/@#{username}/outbox", partOf: "https://#{domain}/users/#{username}/outbox",
orderedItems: items orderedItems: items
) )
end end
@spec render_activity(String.t(), Note.t()) :: Jason.OrderedObject.t() @spec activity_note(Nulla.Models.Note.t()) :: Jason.OrderedObject.t()
def render_activity(domain, note) do def activity_note(note) do
Jason.OrderedObject.new( 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", type: "Create",
actor: "https://#{domain}/@#{note.user.username}", actor: "https://#{note.actor.domain}/users/#{note.actor.preferredUsername}",
published: note.inserted_at |> DateTime.to_iso8601(), published: note.inserted_at |> DateTime.to_iso8601(),
to: ["https://www.w3.org/ns/activitystreams#Public"], to: [
"https://www.w3.org/ns/activitystreams#Public"
],
object: object:
Jason.OrderedObject.new( 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", type: "Note",
content: note.content, content: note.content,
published: note.inserted_at |> DateTime.to_iso8601(), published: note.inserted_at |> DateTime.to_iso8601(),
attributedTo: "https://#{domain}/@#{note.user.username}", attributedTo: "https://#{note.actor.domain}/users/#{note.actor.preferredUsername}",
to: ["https://www.w3.org/ns/activitystreams#Public"] to: [
"https://www.w3.org/ns/activitystreams#Public"
]
) )
) )
end 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 end

View file

@ -0,0 +1,5 @@
defmodule Nulla.HTTPSignature do
def verify(_conn, _actor) do
:ok
end
end

View file

@ -1,9 +1,11 @@
defmodule Nulla.Models.Activity do defmodule Nulla.Models.Activity do
use Ecto.Schema use Ecto.Schema
import Ecto.Changeset import Ecto.Changeset
alias Nulla.SnowFlake
@primary_key {:id, :integer, autogenerate: false} @primary_key {:id, :integer, autogenerate: false}
schema "activities" do schema "activities" do
field :ap_id, :string
field :type, :string field :type, :string
field :actor, :string field :actor, :string
field :object, :map field :object, :map
@ -15,8 +17,17 @@ defmodule Nulla.Models.Activity do
@doc false @doc false
def changeset(activity, attrs) do def changeset(activity, attrs) do
activity activity
|> cast(attrs, [:type, :actor, :object, :to]) |> cast(attrs, [:ap_id, :type, :actor, :object, :to])
|> validate_required([:type, :actor, :object]) |> validate_required([:ap_id, :type, :actor, :object])
|> validate_inclusion(:type, ~w(Create Update Delete Undo Like Announce Follow Accept Reject)) |> validate_inclusion(:type, ~w(Create Update Delete Undo Like Announce Follow Accept Reject))
end end
def create_activity(attrs) do
id = Snowflake.next_id()
%__MODULE__{}
|> __MODULE__.changeset(attrs)
|> Ecto.Changeset.put_change(:id, id)
|> Repo.insert()
end
end end

View file

@ -1,14 +1,14 @@
defmodule Nulla.Models.Actor do defmodule Nulla.Models.Actor do
use Ecto.Schema use Ecto.Schema
import Ecto.Changeset import Ecto.Changeset
import Ecto.Query
alias Nulla.Repo alias Nulla.Repo
alias Nulla.Snowflake alias Nulla.Snowflake
alias Nulla.Models.User
alias Nulla.Models.Note alias Nulla.Models.Note
@primary_key {:id, :integer, autogenerate: false} @primary_key {:id, :integer, autogenerate: false}
schema "actors" do schema "actors" do
field :domain, :string
field :ap_id, :string
field :type, :string field :type, :string
field :following, :string field :following, :string
field :followers, :string field :followers, :string
@ -34,7 +34,6 @@ defmodule Nulla.Models.Actor do
field :vcard_bday, :date field :vcard_bday, :date
field :vcard_Address, :string field :vcard_Address, :string
has_one :user, User
has_many :notes, Note has_many :notes, Note
has_many :media_attachments, through: [:notes, :media_attachments] has_many :media_attachments, through: [:notes, :media_attachments]
end end
@ -44,6 +43,8 @@ defmodule Nulla.Models.Actor do
actor actor
|> cast(attrs, [ |> cast(attrs, [
:id, :id,
:domain,
:ap_id,
:type, :type,
:following, :following,
:followers, :followers,
@ -71,6 +72,8 @@ defmodule Nulla.Models.Actor do
]) ])
|> validate_required([ |> validate_required([
:id, :id,
:domain,
:ap_id,
:type, :type,
:following, :following,
:followers, :followers,
@ -98,7 +101,7 @@ defmodule Nulla.Models.Actor do
]) ])
end end
def create_user(attrs) when is_map(attrs) do def create_actor(attrs) when is_map(attrs) do
id = Snowflake.next_id() id = Snowflake.next_id()
%__MODULE__{} %__MODULE__{}
@ -106,4 +109,8 @@ defmodule Nulla.Models.Actor do
|> Ecto.Changeset.put_change(:id, id) |> Ecto.Changeset.put_change(:id, id)
|> Repo.insert() |> Repo.insert()
end end
def get_actor(username, domain) do
Repo.get_by(__MODULE__, preferredUsername: username, domain: domain)
end
end end

View file

@ -1,13 +1,14 @@
defmodule Nulla.Models.Follow do defmodule Nulla.Models.Follow do
use Ecto.Schema use Ecto.Schema
import Ecto.Changeset import Ecto.Changeset
alias Nulla.Repo
alias Nulla.Snowflake alias Nulla.Snowflake
alias Nulla.Models.Follow alias Nulla.Models.Actor
@primary_key {:id, :integer, autogenerate: false} @primary_key {:id, :integer, autogenerate: false}
schema "follows" do schema "follows" do
belongs_to :user, Nulla.Models.User belongs_to :follower, Actor
belongs_to :target, Nulla.Models.User belongs_to :followed, Actor
timestamps() timestamps()
end end
@ -23,8 +24,8 @@ defmodule Nulla.Models.Follow do
def create_follow(attrs) do def create_follow(attrs) do
id = Snowflake.next_id() id = Snowflake.next_id()
%Follow{} %__MODULE__{}
|> Follow.changeset(attrs) |> __MODULE__.changeset(attrs)
|> Ecto.Changeset.put_change(:id, id) |> Ecto.Changeset.put_change(:id, id)
|> Repo.insert() |> Repo.insert()
end end

View file

@ -3,7 +3,8 @@ defmodule Nulla.Models.Note do
import Ecto.Changeset import Ecto.Changeset
import Ecto.Query import Ecto.Query
alias Nulla.Repo alias Nulla.Repo
alias Nulla.Models.Note alias Nulla.Models.Actor
alias Nulla.Models.MediaAttachment
@primary_key {:id, :integer, autogenerate: false} @primary_key {:id, :integer, autogenerate: false}
schema "notes" do schema "notes" do
@ -17,8 +18,8 @@ defmodule Nulla.Models.Note do
field :language, :string field :language, :string
field :in_reply_to, :string field :in_reply_to, :string
belongs_to :user, Nulla.Models.User belongs_to :actor, Actor
has_many :media_attachments, Nulla.Models.MediaAttachment has_many :media_attachments, MediaAttachment
timestamps(type: :utc_datetime) timestamps(type: :utc_datetime)
end end
@ -26,32 +27,32 @@ defmodule Nulla.Models.Note do
@doc false @doc false
def changeset(note, attrs) do def changeset(note, attrs) do
note note
|> cast(attrs, [: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, :user_id]) |> validate_required([:content, :visibility, :sensitive, :language, :in_reply_to, :actor_id])
end 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 def get_latest_notes(actor_id, limit \\ 20) do
from(n in Note, from(n in __MODULE__,
where: n.user_id == ^user_id, where: n.actor_id == ^actor_id,
order_by: [desc: n.inserted_at], order_by: [desc: n.inserted_at],
limit: ^limit limit: ^limit
) )
|> Repo.all() |> Repo.all()
end end
def get_before_notes(user_id, max_id, limit \\ 20) do def get_before_notes(actor_id, max_id, limit \\ 20) do
from(n in Note, from(n in __MODULE__,
where: n.user_id == ^user_id and n.id < ^max_id, where: n.actor_id == ^actor_id and n.id < ^max_id,
order_by: [desc: n.inserted_at], order_by: [desc: n.inserted_at],
limit: ^limit limit: ^limit
) )
|> Nulla.Repo.all() |> Repo.all()
end end
def get_total_notes_count(user_id) do def get_total_notes_count(actor_id) do
from(n in Note, where: n.user_id == ^user_id) from(n in __MODULE__, where: n.actor_id == ^actor_id)
|> Repo.aggregate(:count, :id) |> Repo.aggregate(:count, :id)
end end
end end

View file

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

View file

@ -3,7 +3,6 @@ defmodule Nulla.Models.User do
import Ecto.Changeset import Ecto.Changeset
import Ecto.Query import Ecto.Query
alias Nulla.Repo alias Nulla.Repo
alias Nulla.Snowflake
alias Nulla.Models.User alias Nulla.Models.User
alias Nulla.Models.Actor alias Nulla.Models.Actor
alias Nulla.Models.Session 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!(username), do: Repo.get_by!(User, username: username)
def get_user_by_username_and_domain(username, domain) do def get_user_by_username_and_domain(username, domain) do
from(u in User, from(u in User,
where: u.username == ^username and u.domain == ^domain where: u.username == ^username and u.domain == ^domain

View file

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

View file

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

View file

@ -2,15 +2,15 @@ defmodule NullaWeb.FollowController do
use NullaWeb, :controller use NullaWeb, :controller
alias Nulla.ActivityPub alias Nulla.ActivityPub
alias Nulla.Utils alias Nulla.Utils
alias Nulla.Models.User alias Nulla.Models.Actor
alias Nulla.Models.InstanceSettings alias Nulla.Models.InstanceSettings
def following(conn, %{"username" => username, "page" => page_param}) do def following(conn, %{"username" => username, "page" => page_param}) do
instance_settings = InstanceSettings.get_instance_settings!() instance_settings = InstanceSettings.get_instance_settings!()
domain = instance_settings.domain domain = instance_settings.domain
offset = instance_settings.offset offset = instance_settings.offset
user = User.get_user_by_username!(username) actor = Actor.get_actor(username, domain)
total = Utils.count_following_by_username!(user.username) total = Utils.count_following_by_username!(actor.preferredUsername)
page = page =
case Integer.parse(page_param) do case Integer.parse(page_param) do
@ -18,30 +18,30 @@ defmodule NullaWeb.FollowController do
_ -> 1 _ -> 1
end end
following_list = Utils.get_following_users_by_username!(user.username, page) following_list = Utils.get_following_users_by_username!(actor.preferredUsername, page)
conn conn
|> put_resp_content_type("application/activity+json") |> 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 end
def following(conn, %{"username" => username}) do def following(conn, %{"username" => username}) do
instance_settings = InstanceSettings.get_instance_settings!() instance_settings = InstanceSettings.get_instance_settings!()
domain = instance_settings.domain domain = instance_settings.domain
user = User.get_user_by_username!(username) actor = Actor.get_actor(username, domain)
total = Utils.count_following_by_username!(user.username) total = Utils.count_following_by_username!(actor.preferredUsername)
conn conn
|> put_resp_content_type("application/activity+json") |> put_resp_content_type("application/activity+json")
|> json(ActivityPub.following(domain, user, total)) |> json(ActivityPub.following(domain, actor, total))
end end
def followers(conn, %{"username" => username, "page" => page_param}) do def followers(conn, %{"username" => username, "page" => page_param}) do
instance_settings = InstanceSettings.get_instance_settings!() instance_settings = InstanceSettings.get_instance_settings!()
domain = instance_settings.domain domain = instance_settings.domain
offset = instance_settings.offset offset = instance_settings.offset
user = User.get_user_by_username!(username) actor = Actor.get_actor(username, domain)
total = Utils.count_followers_by_username!(user.username) total = Utils.count_followers_by_username!(actor.preferredUsername)
page = page =
case Integer.parse(page_param) do case Integer.parse(page_param) do
@ -49,21 +49,21 @@ defmodule NullaWeb.FollowController do
_ -> 1 _ -> 1
end end
followers_list = Utils.get_followers_by_username!(user.username, page) followers_list = Utils.get_followers_by_username!(actor.preferredUsername, page)
conn conn
|> put_resp_content_type("application/activity+json") |> 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 end
def followers(conn, %{"username" => username}) do def followers(conn, %{"username" => username}) do
instance_settings = InstanceSettings.get_instance_settings!() instance_settings = InstanceSettings.get_instance_settings!()
domain = instance_settings.domain domain = instance_settings.domain
user = User.get_user_by_username!(username) actor = Actor.get_actor(username, domain)
total = Utils.count_followers_by_username!(user.username) total = Utils.count_followers_by_username!(actor.preferredUsername)
conn conn
|> put_resp_content_type("application/activity+json") |> put_resp_content_type("application/activity+json")
|> json(ActivityPub.followers(domain, user, total)) |> json(ActivityPub.followers(domain, actor, total))
end end
end end

View file

@ -1,19 +1,49 @@
defmodule NullaWeb.InboxController do defmodule NullaWeb.InboxController do
use NullaWeb, :controller use NullaWeb, :controller
alias Nulla.Models.Follow alias Nulla.HTTPSignature
alias Nulla.ActivityPub
alias Nulla.Utils alias Nulla.Utils
alias Nulla.Models.Actor
alias Nulla.Models.Relation
def inbox( def inbox(
conn, conn,
%{"type" => "Follow", "actor" => actor_uri, "object" => target_uri} = activity %{"id" => follow_id, "type" => "Follow", "actor" => actor_uri, "object" => target_uri}
) do ) do
with {:ok, target_user} <- Utils.resolve_local_actor(target_uri), with {:ok, target_actor} <- Utils.resolve_local_actor(target_uri),
{:ok, remote_actor} <- Utils.fetch_remote_actor(actor_uri), {:ok, remote_actor_json} <- Utils.fetch_remote_actor(actor_uri),
:ok <- HTTPSignature.verify(conn, remote_actor), :ok <- HTTPSignature.verify(conn, remote_actor_json),
remote_user <- Follow.create_remote_user(remote_actor), remote_actor <-
follow <- Follow.create_follow(%{user: remote_user, target: target_user}), Actor.create_actor(
:ok <- Utils.send_accept_activity(remote_actor, target_user, follow, activity) do remote_actor_json
json(conn, %{"status" => "Follow accepted"}) |> 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 else
error -> error ->
IO.inspect(error, label: "Follow error") IO.inspect(error, label: "Follow error")

View file

@ -1,6 +1,5 @@
defmodule NullaWeb.NodeinfoController do defmodule NullaWeb.NodeinfoController do
use NullaWeb, :controller use NullaWeb, :controller
alias Nulla.Repo
alias Nulla.ActivityPub alias Nulla.ActivityPub
alias Nulla.Models.User alias Nulla.Models.User
alias Nulla.Models.InstanceSettings alias Nulla.Models.InstanceSettings

View file

@ -1,7 +1,7 @@
defmodule NullaWeb.OutboxController do defmodule NullaWeb.OutboxController do
use NullaWeb, :controller use NullaWeb, :controller
alias Nulla.ActivityPub alias Nulla.ActivityPub
alias Nulla.Models.User alias Nulla.Models.Actor
alias Nulla.Models.Note alias Nulla.Models.Note
alias Nulla.Models.InstanceSettings alias Nulla.Models.InstanceSettings
@ -10,17 +10,17 @@ defmodule NullaWeb.OutboxController do
"true" -> "true" ->
instance_settings = InstanceSettings.get_instance_settings!() instance_settings = InstanceSettings.get_instance_settings!()
domain = instance_settings.domain 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"]) max_id = params["max_id"] && String.to_integer(params["max_id"])
notes = notes =
if max_id do if max_id do
Note.get_before_notes(user.id, max_id) Note.get_before_notes(actor.id, max_id)
else else
Note.get_latest_notes(user.id) Note.get_latest_notes(actor.id)
end end
items = Enum.map(notes, &ActivityPub.render_activity(&1, domain)) items = Enum.map(notes, &ActivityPub.activity_note(&1))
next_max_id = next_max_id =
case List.last(notes) do case List.last(notes) do
@ -44,8 +44,8 @@ defmodule NullaWeb.OutboxController do
_ -> _ ->
instance_settings = InstanceSettings.get_instance_settings!() instance_settings = InstanceSettings.get_instance_settings!()
domain = instance_settings.domain domain = instance_settings.domain
user = User.get_user_by_username!(username) actor = Actor.get_actor(username, domain)
total = Note.get_total_notes_count(user.id) total = Note.get_total_notes_count(actor.id)
conn conn
|> put_resp_content_type("application/activity+json") |> put_resp_content_type("application/activity+json")

View file

@ -1,36 +1,3 @@
defmodule NullaWeb.UserController do defmodule NullaWeb.UserController do
use NullaWeb, :controller 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 end

View file

@ -1,24 +1,23 @@
defmodule NullaWeb.WebfingerController do defmodule NullaWeb.WebfingerController do
use NullaWeb, :controller use NullaWeb, :controller
alias Nulla.Repo
alias Nulla.ActivityPub alias Nulla.ActivityPub
alias Nulla.Models.User alias Nulla.Models.Actor
alias Nulla.Models.InstanceSettings alias Nulla.Models.InstanceSettings
def index(conn, %{"resource" => resource}) do def index(conn, %{"resource" => resource}) do
case Regex.run(~r/^acct:([^@]+)@(.+)$/, resource) do case Regex.run(~r/^acct:([^@]+)@(.+)$/, resource) do
[_, username, domain] -> [_, username, domain] ->
case User.get_user_by_username(username) do case Actor.get_actor(username, domain) do
nil -> nil ->
conn conn
|> put_status(:not_found) |> put_status(:not_found)
|> json(%{error: "Not Found"}) |> json(%{error: "Not Found"})
user -> %Actor{} = actor ->
instance_settings = InstanceSettings.get_instance_settings!() instance_settings = InstanceSettings.get_instance_settings!()
if domain == instance_settings.domain do if domain == instance_settings.domain do
json(conn, ActivityPub.webfinger(domain, username, resource)) json(conn, ActivityPub.webfinger(actor))
else else
conn conn
|> put_status(:not_found) |> put_status(:not_found)

View file

@ -29,7 +29,7 @@ defmodule NullaWeb.Router do
end end
scope "/users/:username" do scope "/users/:username" do
get "/", UserController, :show get "/", ActorController, :show
get "/following", FollowController, :following get "/following", FollowController, :following
get "/followers", FollowController, :followers get "/followers", FollowController, :followers
post "/inbox", InboxController, :inbox post "/inbox", InboxController, :inbox
@ -38,7 +38,7 @@ defmodule NullaWeb.Router do
end end
scope "/@:username" do scope "/@:username" do
get "/", UserController, :show get "/", ActorController, :show
get "/following", FollowController, :following get "/following", FollowController, :following
get "/followers", FollowController, :followers get "/followers", FollowController, :followers
post "/inbox", InboxController, :inbox post "/inbox", InboxController, :inbox

View file

@ -4,14 +4,16 @@ defmodule Nulla.Repo.Migrations.CreateActors do
def change do def change do
create table(:actors, primary_key: false) do create table(:actors, primary_key: false) do
add :id, :bigint, primary_key: true add :id, :bigint, primary_key: true
add :type, :string add :domain, :string
add :following, :string add :ap_id, :string, null: false
add :followers, :string add :type, :string, null: false
add :inbox, :string add :following, :string, null: false
add :outbox, :string add :followers, :string, null: false
add :inbox, :string, null: false
add :outbox, :string, null: false
add :featured, :string add :featured, :string
add :featuredTags, :string add :featuredTags, :string
add :preferredUsername, :string add :preferredUsername, :string, null: false
add :name, :string add :name, :string
add :summary, :string add :summary, :string
add :url, :string add :url, :string

View file

@ -9,11 +9,11 @@ defmodule Nulla.Repo.Migrations.CreateNotes do
add :sensitive, :boolean, default: false add :sensitive, :boolean, default: false
add :language, :string add :language, :string
add :in_reply_to, :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) timestamps(type: :utc_datetime)
end end
create index(:notes, [:user_id]) create index(:notes, [:actor_id])
end end
end end

View file

@ -4,13 +4,13 @@ defmodule Nulla.Repo.Migrations.CreateFollows do
def change do def change do
create table(:follows, primary_key: false) do create table(:follows, primary_key: false) do
add :id, :bigint, primary_key: true add :id, :bigint, primary_key: true
add :user_id, references(:users, on_delete: :delete_all), null: false add :follower_id, references(:actors, on_delete: :delete_all), null: false
add :target_id, references(:users, on_delete: :delete_all), null: false add :following_id, references(:actors, on_delete: :delete_all), null: false
timestamps() timestamps()
end end
create unique_index(:follows, [:user_id, :target_id]) create unique_index(:follows, [:follower_id, :following_id])
create index(:follows, [:target_id]) create index(:follows, [:following_id])
end end
end end

View file

@ -4,15 +4,16 @@ defmodule Nulla.Repo.Migrations.CreateActivities do
def change do def change do
create table(:activities, primary_key: false) do create table(:activities, primary_key: false) do
add :id, :bigint, primary_key: true add :id, :bigint, primary_key: true
add :ap_id, :string, null: false
add :type, :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 :object, :map, null: false
add :to, {:array, :string}, default: [] add :to, {:array, :string}, default: []
timestamps() timestamps()
end end
create index(:activities, [:actor])
create index(:activities, [:type]) create index(:activities, [:type])
create index(:activities, [:actor_id])
end end
end end

View file

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