This commit is contained in:
Mirai Kumiko 2025-06-18 09:13:59 +02:00
parent 3a57d74357
commit df548a4943
Signed by: miraikumiko
GPG key ID: 3F178B1B5E0CB278
12 changed files with 137 additions and 179 deletions

View file

@ -1,4 +1,9 @@
defmodule Nulla.ActivityPub do
alias Nulla.Models.Actor
alias Nulla.Models.Activity
alias Nulla.Models.Note
alias Nulla.Models.InstanceSettings
@spec context() :: list()
defp context do
[
@ -25,7 +30,7 @@ defmodule Nulla.ActivityPub do
]
end
@spec actor(Nulla.Models.Actor.t()) :: Jason.OrderedObject.t()
@spec actor(Actor.t()) :: Jason.OrderedObject.t()
def actor(actor) do
Jason.OrderedObject.new(
"@context": context(),
@ -57,7 +62,7 @@ defmodule Nulla.ActivityPub do
)
end
@spec note(String.t(), Nulla.Models.Note.t()) :: Jason.OrderedObject.t()
@spec note(String.t(), Note.t()) :: Jason.OrderedObject.t()
def note(domain, note) do
attachment =
case note.media_attachments do
@ -102,7 +107,7 @@ defmodule Nulla.ActivityPub do
)
end
@spec activity(String.t(), Nulla.Models.Activity.t()) :: Jason.OrderedObject.t()
@spec activity(String.t(), Activity.t()) :: Jason.OrderedObject.t()
def activity(domain, activity) do
Jason.OrderedObject.new(
"@context": "https://www.w3.org/ns/activitystreams",
@ -113,35 +118,28 @@ defmodule Nulla.ActivityPub do
)
end
@spec following(String.t(), Nulla.Models.Actor.t(), Integer.t()) :: Jason.OrderedObject.t()
def following(domain, actor, total) do
@spec following(Actor.t(), Integer.t()) :: Jason.OrderedObject.t()
def following(actor, total) do
Jason.OrderedObject.new(
"@context": "https://www.w3.org/ns/activitystreams",
id: "https://#{domain}/users/#{actor.preferredUsername}/following",
id: "https://#{actor.domain}/users/#{actor.preferredUsername}/following",
type: "OrderedCollection",
totalItems: total,
first: "https://#{domain}/users/#{actor.preferredUsername}/following?page=1"
first: "https://#{actor.domain}/users/#{actor.preferredUsername}/following?page=1"
)
end
@spec following(
String.t(),
Nulla.Models.Actor.t(),
Integer.t(),
List.t(),
Integer.t(),
Integer.t()
) :: Jason.OrderedObject.t()
def following(domain, actor, total, following_list, page, offset)
when is_integer(page) and page > 0 do
@spec following(Actor.t(), Integer.t(), List.t(), Integer.t(), Integer.t()) ::
Jason.OrderedObject.t()
def following(actor, total, following_list, page, limit) when is_integer(page) and page > 0 do
data = [
"@context": "https://www.w3.org/ns/activitystreams",
id: "https://#{domain}/@#{actor.preferredUsername}/following?page=#{page}",
id: "https://#{actor.domain}/@#{actor.preferredUsername}/following?page=#{page}",
type: "OrderedCollectionPage",
totalItems: total,
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",
next: "https://#{actor.domain}/users/#{actor.preferredUsername}/following?page=#{page + 1}",
prev: "https://#{actor.domain}/users/#{actor.preferredUsername}/following?page=#{page - 1}",
partOf: "https://#{actor.domain}/users/#{actor.preferredUsername}/following",
orderedItems: following_list
]
@ -153,7 +151,7 @@ defmodule Nulla.ActivityPub do
end
data =
if page * offset > total do
if page * limit > total do
data
|> Keyword.delete(:next)
|> Keyword.delete(:prev)
@ -164,35 +162,29 @@ defmodule Nulla.ActivityPub do
Jason.OrderedObject.new(data)
end
@spec followers(String.t(), Nulla.Models.Actor.t(), Integer.t()) :: Jason.OrderedObject.t()
def followers(domain, actor, total) do
@spec followers(Actor.t(), Integer.t()) :: Jason.OrderedObject.t()
def followers(actor, total) do
Jason.OrderedObject.new(
"@context": "https://www.w3.org/ns/activitystreams",
id: "https://#{domain}/users/#{actor.preferredUsername}/followers",
id: "https://#{actor.domain}/users/#{actor.preferredUsername}/followers",
type: "OrderedCollection",
totalItems: total,
first: "https://#{domain}/users/#{actor.preferredUsername}/followers?page=1"
first: "https://#{actor.domain}/users/#{actor.preferredUsername}/followers?page=1"
)
end
@spec followers(
String.t(),
Nulla.Models.Actor.t(),
Integer.t(),
List.t(),
Integer.t(),
Integer.t()
) :: Jason.OrderedObject.t()
def followers(domain, actor, total, followers_list, page, offset)
@spec followers(Actor.t(), Integer.t(), List.t(), Integer.t(), Integer.t()) ::
Jason.OrderedObject.t()
def followers(actor, total, followers_list, page, limit)
when is_integer(page) and page > 0 do
data = [
"@context": "https://www.w3.org/ns/activitystreams",
id: "https://#{domain}/users#{actor.preferredUsername}/followers?page=#{page}",
id: "https://#{actor.domain}/users#{actor.preferredUsername}/followers?page=#{page}",
type: "OrderedCollectionPage",
totalItems: total,
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",
next: "https://#{actor.domain}/users/#{actor.preferredUsername}/followers?page=#{page + 1}",
prev: "https://#{actor.domain}/users/#{actor.preferredUsername}/followers?page=#{page - 1}",
partOf: "https://#{actor.domain}/users/#{actor.preferredUsername}/followers",
orderedItems: followers_list
]
@ -204,7 +196,7 @@ defmodule Nulla.ActivityPub do
end
data =
if page * offset > total do
if page * limit > total do
data
|> Keyword.delete(:next)
|> Keyword.delete(:prev)
@ -215,7 +207,7 @@ defmodule Nulla.ActivityPub do
Jason.OrderedObject.new(data)
end
@spec webfinger(Nulla.Models.Actor.t()) :: Jason.OrderedObject.t()
@spec webfinger(Actor.t()) :: Jason.OrderedObject.t()
def webfinger(actor) do
Jason.OrderedObject.new(
subject: "#{actor.preferredUsername}@#{actor.domain}",
@ -255,8 +247,7 @@ defmodule Nulla.ActivityPub do
)
end
@spec nodeinfo(String.t(), Map.t(), Nulla.Models.InstanceSettings.t()) ::
Jason.OrderedObject.t()
@spec nodeinfo(String.t(), Map.t(), InstanceSettings.t()) :: Jason.OrderedObject.t()
def nodeinfo(version, users, instance) do
Jason.OrderedObject.new(
version: "2.0",
@ -323,7 +314,7 @@ defmodule Nulla.ActivityPub do
)
end
@spec activity_note(Nulla.Models.Note.t()) :: Jason.OrderedObject.t()
@spec activity_note(Note.t()) :: Jason.OrderedObject.t()
def activity_note(note) do
Jason.OrderedObject.new(
id:
@ -349,7 +340,7 @@ defmodule Nulla.ActivityPub do
)
end
@spec follow_accept(Nulla.Models.Activity.t()) :: Jason.OrderedObject.t()
@spec follow_accept(Activity.t()) :: Jason.OrderedObject.t()
def follow_accept(activity) do
Jason.OrderedObject.new(
"@context": "https://www.w3.org/ns/activitystreams",

View file

@ -28,7 +28,7 @@ defmodule Nulla.Models.Activity do
%__MODULE__{}
|> __MODULE__.changeset(attrs)
|> Changeset.put_change(:id, id)
|> put_change(:id, id)
|> Repo.insert()
end
end

View file

@ -106,7 +106,7 @@ defmodule Nulla.Models.Actor do
%__MODULE__{}
|> changeset(attrs)
|> Changeset.put_change(:id, id)
|> put_change(:id, id)
|> Repo.insert()
end

View file

@ -11,7 +11,7 @@ defmodule Nulla.Models.InstanceSettings do
field :registration, :boolean, default: false
field :max_characters, :integer, default: 5000
field :max_upload_size, :integer, default: 50
field :api_offset, :integer, default: 100
field :api_limit, :integer, default: 100
field :public_key, :string
field :private_key, :string
end
@ -26,7 +26,7 @@ defmodule Nulla.Models.InstanceSettings do
:registration,
:max_characters,
:max_upload_size,
:api_offset,
:api_limit,
:public_key,
:private_key
])
@ -37,7 +37,7 @@ defmodule Nulla.Models.InstanceSettings do
:registration,
:max_characters,
:max_upload_size,
:api_offset,
:api_limit,
:public_key,
:private_key
])

View file

@ -1,6 +1,7 @@
defmodule Nulla.Models.Relation do
use Ecto.Schema
import Ecto.Changeset
import Ecto.Query
alias Nulla.Repo
alias Nulla.Snowflake
alias Nulla.Models.Actor
@ -21,8 +22,8 @@ defmodule Nulla.Models.Relation do
field :requested, :boolean, default: false
field :note, :string
belongs_to :local_actor_id, Actor
belongs_to :remote_actor_id, Actor
belongs_to :local_actor, Actor
belongs_to :remote_actor, Actor
timestamps()
end
@ -44,19 +45,65 @@ defmodule Nulla.Models.Relation do
:domain_blocking,
:requested,
:note,
:source_id,
:target_id
:local_actor_id,
:remote_actor_id
])
|> validate_required([:id, :local_actor_id, :remote_actor_id])
|> unique_constraint([:local_actor_id, :remote_actor_id])
end
def create_relation(attrs) do
def create_relation(attrs) do
id = Snowflake.next_id()
%__MODULE__{}
|> __MODULE__.changeset(attrs)
|> Changeset.put_change(:id, id)
|> put_change(:id, id)
|> Repo.insert()
end
end
def count_following(local_actor_id) do
__MODULE__
|> where([r], r.local_actor_id == ^local_actor_id and r.following == true)
|> select([r], count(r.id))
|> Repo.one()
end
def get_following(local_actor_id, page, limit) when is_integer(page) and page > 0 do
offset = (page - 1) * limit
query =
from r in __MODULE__,
join: a in Actor,
on: a.id == r.remote_actor_id,
where: r.local_actor_id == ^local_actor_id and r.following == true,
order_by: [asc: a.published],
offset: ^offset,
limit: ^limit,
select: a
Repo.all(query)
end
def count_followers(local_actor_id) do
__MODULE__
|> where([r], r.local_actor_id == ^local_actor_id and r.followed_by == true)
|> select([r], count(r.id))
|> Repo.one()
end
def get_followers(local_actor_id, page, limit) when is_integer(page) and page > 0 do
offset = (page - 1) * limit
query =
from r in __MODULE__,
join: a in Actor,
on: a.id == r.remote_actor_id,
where: r.local_actor_id == ^local_actor_id and r.followed_by == true,
order_by: [asc: a.published],
offset: ^offset,
limit: ^limit,
select: a
Repo.all(query)
end
end

View file

@ -69,7 +69,7 @@ defmodule Nulla.Models.User do
def update_last_active(user) do
user
|> Changeset.change(last_active_at: DateTime.utc_now())
|> change(last_active_at: DateTime.utc_now())
|> Repo.update()
end
end

View file

@ -1,102 +1,16 @@
defmodule Nulla.Utils do
import Ecto.Query
alias Nulla.Repo
alias Nulla.Models.User
alias Nulla.Models.Follow
alias Nulla.Models.Actor
alias Nulla.Models.InstanceSettings
def count_following_by_username!(username) do
case Repo.get_by(User, username: username) do
nil ->
{:error, :user_not_found}
%User{id: user_id} ->
count =
Follow
|> where([f], f.user_id == ^user_id)
|> select([f], count(f.id))
|> Repo.one()
count
end
end
def get_following_users_by_username!(username, page) when is_integer(page) and page > 0 do
case Repo.get_by(User, username: username) do
nil ->
{:error, :user_not_found}
%User{id: user_id} ->
instance_settings = InstanceSettings.get_instance_settings!()
per_page = instance_settings.api_offset
offset = (page - 1) * per_page
query =
from(
[f, u] in from(f in Follow,
join: u in User,
on: u.id == f.target_id,
where: f.user_id == ^user_id,
order_by: [asc: u.inserted_at],
offset: ^offset,
limit: ^per_page,
select: u
)
)
users = Repo.all(query)
users
end
end
def count_followers_by_username!(username) do
case Repo.get_by(User, username: username) do
nil ->
0
%User{id: user_id} ->
from(f in Follow, where: f.target_id == ^user_id)
|> select([f], count(f.id))
|> Repo.one()
end
end
def get_followers_by_username!(username, page) when is_integer(page) and page > 0 do
case Repo.get_by(User, username: username) do
nil ->
{:error, :user_not_found}
%User{id: user_id} ->
instance_settings = InstanceSettings.get_instance_settings!()
per_page = instance_settings.api_offset
offset = (page - 1) * per_page
query =
from f in Follow,
where: f.target_id == ^user_id,
join: u in User,
on: u.id == f.user_id,
order_by: [asc: u.inserted_at],
offset: ^offset,
limit: ^per_page,
select: u
users = Repo.all(query)
users
end
end
def resolve_local_actor("https://" <> _ = uri) do
case URI.parse(uri).path do
"/@" <> username ->
instance_settings = InstanceSettings.get_instance_settings!()
domain = instance_settings.domain
case User.get_user_by_username_and_domain(username, domain) do
case Actor.get_actor(username, domain) do
nil -> {:error, :not_found}
user -> {:ok, user}
user -> user
end
_ ->
@ -110,7 +24,7 @@ defmodule Nulla.Utils do
{"Accept", "application/activity+json"}
])
case Finch.request(request, MyApp.Finch) do
case Finch.request(request, Finch) do
{:ok, %Finch.Response{status: 200, body: body}} ->
case Jason.decode(body) do
{:ok, data} -> {:ok, data}

View file

@ -18,33 +18,37 @@ defmodule NullaWeb.ActorHTML do
def format_registration_date(date) do
now = Date.utc_today()
formatted = Date.to_string(date) |> String.replace("-", "/")
diff_days = Date.diff(now, date)
cond do
diff_days == 0 ->
"#{formatted} (today)"
diff_days == 1 ->
"#{formatted} (1 day ago)"
diff_days < 30 ->
"#{formatted} (#{diff_days} days ago)"
diff_days < 365 ->
year_diff = now.year - date.year
month_diff = now.month - date.month
day_correction = if now.day < date.day, do: -1, else: 0
months = year_diff * 12 + month_diff + day_correction
if months == 1 do
"#{formatted} (1 month ago)"
else
"#{formatted} (#{months} months ago)"
end
true ->
year_diff = now.year - date.year
years = if {now.month, now.day} < {date.month, date.day}, do: year_diff - 1, else: year_diff
years =
if {now.month, now.day} < {date.month, date.day}, do: year_diff - 1, else: year_diff
if years == 1 do
"#{formatted} (1 year ago)"
else

View file

@ -19,7 +19,8 @@
<img src={@actor.image["url"]} class="w-full h-full object-cover" />
<div class="absolute inset-0 flex items-end justify-between px-4 pb-2 pointer-events-none">
<img
src={@actor.icon["url"]}}
src={@actor.icon["url"]}
}
class="translate-y-1/2 rounded-full border-4 border-white w-[8.33vw] h-[8.33vw] min-w-[80px] min-h-[80px] max-w-[160px] max-h-[160px] pointer-events-auto"
/>
<button class="px-8 py-2 rounded-full text-sm font-semibold border transition bg-black text-white border-black hover:bg-gray-900 pointer-events-auto">
@ -81,7 +82,7 @@
<%= for note <- @notes do %>
<div class="p-4 border-b border-gray-300">
<div class="flex items-start space-x-4">
<img src={@actor.icon["url"]}} class="rounded-full w-[58px] h-[58px]" />
<img src={@actor.icon["url"]} } class="rounded-full w-[58px] h-[58px]" />
<div class="flex-1">
<div class="flex justify-between items-start">
<div class="flex items-center space-x-2">

View file

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

View file

@ -11,7 +11,7 @@ defmodule NullaWeb.InboxController do
conn,
%{"id" => follow_id, "type" => "Follow", "actor" => actor_uri, "object" => target_uri}
) do
with {:ok, target_actor} <- Utils.resolve_local_actor(target_uri),
with {:ok, local_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 <-
@ -31,14 +31,15 @@ defmodule NullaWeb.InboxController do
accept_activity <-
Activity.create_activity(%{
type: "Accept",
actor: target_actor.id,
actor: local_actor.id,
object: follow_activity
}),
relation <- Relation.create_relation(%{
followed_by: true,
local_actor_id: target_actor.id,
remote_actor_id: remote_actor.id
}) do
_ <-
Relation.create_relation(%{
followed_by: true,
local_actor_id: local_actor.id,
remote_actor_id: remote_actor.id
}) do
conn
|> put_resp_content_type("application/activity+json")
|> send_resp(

View file

@ -10,7 +10,7 @@ defmodule Nulla.Repo.Migrations.CreateInstanceSettings do
add :registration, :boolean, default: false, null: false
add :max_characters, :integer, default: 5000, null: false
add :max_upload_size, :integer, default: 50, null: false
add :api_offset, :integer, default: 100, null: false
add :api_limit, :integer, default: 100, null: false
add :public_key, :text
add :private_key, :text
@ -35,7 +35,7 @@ defmodule Nulla.Repo.Migrations.CreateInstanceSettings do
sql = """
INSERT INTO instance_settings (
id, name, description, domain, registration,
max_characters, max_upload_size, api_offset,
max_characters, max_upload_size, api_limit,
public_key, private_key, inserted_at, updated_at
) VALUES (
1, 'Nulla', 'Freedom Social Network', '#{domain}', false,