diff --git a/lib/nulla/activitypub.ex b/lib/nulla/activitypub.ex
index 2bea870..66a439a 100644
--- a/lib/nulla/activitypub.ex
+++ b/lib/nulla/activitypub.ex
@@ -25,9 +25,9 @@ defmodule Nulla.ActivityPub do
]
end
- @spec ap_user(String.t(), Nulla.Models.User.t()) :: Jason.OrderedObject.t()
- def ap_user(domain, user) do
- Jason.OrderedObject.new([
+ @spec user(String.t(), Nulla.Models.User.t()) :: Jason.OrderedObject.t()
+ def user(domain, user) do
+ Jason.OrderedObject.new(
"@context": context(),
id: "https://#{domain}/@#{user.username}",
type: "Person",
@@ -45,51 +45,52 @@ defmodule Nulla.ActivityPub do
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 ->
+ 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} ->
+ 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}#{user.avatar}"
- ),
- image: Jason.OrderedObject.new(
- type: "Image",
- mediaType: MIME.from_path(user.banner),
- url: "https://#{domain}#{user.banner}"
- ),
+ endpoints: Jason.OrderedObject.new(sharedInbox: "https://#{domain}/inbox"),
+ icon:
+ Jason.OrderedObject.new(
+ type: "Image",
+ mediaType: MIME.from_path(user.avatar),
+ url: "https://#{domain}#{user.avatar}"
+ ),
+ image:
+ Jason.OrderedObject.new(
+ type: "Image",
+ mediaType: MIME.from_path(user.banner),
+ url: "https://#{domain}#{user.banner}"
+ ),
"vcard:bday": user.birthday,
"vcard:Address": user.location
- ])
+ )
end
@spec note(String.t(), Nulla.Models.User.t(), Nulla.Models.Note.t()) :: Jason.OrderedObject.t()
def note(domain, user, note) do
- Jason.OrderedObject.new([
+ Jason.OrderedObject.new(
"@context": [
"https://www.w3.org/ns/activitystreams",
- Jason.OrderedObject.new(
- sensitive: "as:sensitive"
- )
+ Jason.OrderedObject.new(sensitive: "as:sensitive")
],
id: "https://#{domain}/@#{user.username}/#{note.id}",
type: "Note",
@@ -106,16 +107,25 @@ defmodule Nulla.ActivityPub do
],
sensetive: false,
content: note.content,
- contentMap: Jason.OrderedObject.new(
- "#{note.language}": "
@rf@mastodonsocial.ru Вниманию новичкам!
Вам небольшое руководство о том, как импротировать пост, которого нет в вашей ленте.
"
- ),
+ contentMap: Jason.OrderedObject.new("#{note.language}": note.content),
attachment: [
Jason.OrderedObject.new(
type: "Document",
- mediaType: "video/mp4",
- url: "https://mastodon.ml/system/media_attachments/files/000/040/494/original/8c06de179c11daea.mp4"
+ mediaType: "#{note.media_attachment.mime_type}",
+ url: "https://#{domain}/files/#{note.media_attachment.file}"
)
]
- ])
+ )
+ end
+
+ def activity(domain, action) do
+ Jason.OrderedObject.new(
+ "@context": "https://www.w3.org/ns/activitystreams",
+ id: "https://#{domain}/activities/#{action.id}",
+ type: action.type,
+ actor: action.actor,
+ object: action.object,
+ to: action.to
+ )
end
end
diff --git a/lib/nulla/models/activity.ex b/lib/nulla/models/activity.ex
new file mode 100644
index 0000000..bf0101e
--- /dev/null
+++ b/lib/nulla/models/activity.ex
@@ -0,0 +1,21 @@
+defmodule Nulla.Models.Activity do
+ use Ecto.Schema
+ import Ecto.Changeset
+
+ schema "activities" do
+ field :type, :string
+ field :actor, :string
+ field :object, :map
+ field :cc, {:array, :string}, default: []
+
+ timestamps()
+ end
+
+ @doc false
+ def changeset(activity, attrs) do
+ activity
+ |> cast(attrs, [:type, :actor, :object, :to])
+ |> validate_required([:type, :actor, :object])
+ |> validate_inclusion(:type, ~w(Create Update Delete Undo Like Announce Follow Accept Reject))
+ end
+end
diff --git a/lib/nulla/models/instance_settings.ex b/lib/nulla/models/instance_settings.ex
index 96280c1..5ed8860 100644
--- a/lib/nulla/models/instance_settings.ex
+++ b/lib/nulla/models/instance_settings.ex
@@ -18,8 +18,26 @@ defmodule Nulla.Models.InstanceSettings do
@doc false
def changeset(instance_settings, attrs) do
instance_settings
- |> cast(attrs, [:name, :description, :domain, :registration, :max_characters, :max_upload_size, :public_key, :private_key])
- |> validate_required([:name, :description, :domain, :registration, :max_characters, :max_upload_size, :public_key, :private_key])
+ |> cast(attrs, [
+ :name,
+ :description,
+ :domain,
+ :registration,
+ :max_characters,
+ :max_upload_size,
+ :public_key,
+ :private_key
+ ])
+ |> validate_required([
+ :name,
+ :description,
+ :domain,
+ :registration,
+ :max_characters,
+ :max_upload_size,
+ :public_key,
+ :private_key
+ ])
end
def get_instance_settings!, do: Repo.one!(InstanceSettings)
diff --git a/lib/nulla/models/note.ex b/lib/nulla/models/note.ex
index f4d5960..965e642 100644
--- a/lib/nulla/models/note.ex
+++ b/lib/nulla/models/note.ex
@@ -7,7 +7,11 @@ defmodule Nulla.Models.Note do
schema "notes" do
field :content, :string
- field :visibility, Ecto.Enum, values: [:public, :unlisted, :followers, :private], default: :public
+
+ field :visibility, Ecto.Enum,
+ values: [:public, :unlisted, :followers, :private],
+ default: :public
+
field :sensitive, :boolean, default: false
field :language, :string
field :in_reply_to, :string
diff --git a/lib/nulla/models/user.ex b/lib/nulla/models/user.ex
index d350ad0..cd20f48 100644
--- a/lib/nulla/models/user.ex
+++ b/lib/nulla/models/user.ex
@@ -35,8 +35,46 @@ defmodule Nulla.Models.User do
@doc false
def changeset(user, attrs) do
user
- |> cast(attrs, [:username, :email, :password, :is_moderator, :realname, :bio, :location, :birthday, :fields, :follow_approval, :is_bot, :is_discoverable, :is_indexable, :is_memorial, :private_key, :public_key, :avatar, :banner])
- |> validate_required([:username, :email, :password, :is_moderator, :realname, :bio, :location, :birthday, :fields, :follow_approval, :is_bot, :is_discoverable, :is_indexable, :is_memorial, :private_key, :public_key, :avatar, :banner])
+ |> cast(attrs, [
+ :username,
+ :email,
+ :password,
+ :is_moderator,
+ :realname,
+ :bio,
+ :location,
+ :birthday,
+ :fields,
+ :follow_approval,
+ :is_bot,
+ :is_discoverable,
+ :is_indexable,
+ :is_memorial,
+ :private_key,
+ :public_key,
+ :avatar,
+ :banner
+ ])
+ |> validate_required([
+ :username,
+ :email,
+ :password,
+ :is_moderator,
+ :realname,
+ :bio,
+ :location,
+ :birthday,
+ :fields,
+ :follow_approval,
+ :is_bot,
+ :is_discoverable,
+ :is_indexable,
+ :is_memorial,
+ :private_key,
+ :public_key,
+ :avatar,
+ :banner
+ ])
end
def get_user_by_username!(username), do: Repo.get_by!(User, username: username)
diff --git a/lib/nulla/uploader.ex b/lib/nulla/uploader.ex
index 9704674..2abd925 100644
--- a/lib/nulla/uploader.ex
+++ b/lib/nulla/uploader.ex
@@ -11,7 +11,7 @@ defmodule Nulla.Uploader do
|> Enum.chunk_every(3)
|> Enum.map(&Enum.join/1)
- filename = String.slice(hash, 15..-1) <> file_type
+ filename = String.slice(hash, 15..-1//1) <> file_type
relative_path = Path.join(segments) <> "/" <> filename
dest_path = Path.join(["priv/static/files", relative_path])
diff --git a/lib/nulla_web/controllers/inbox_controller.ex b/lib/nulla_web/controllers/inbox_controller.ex
new file mode 100644
index 0000000..e4ed4c4
--- /dev/null
+++ b/lib/nulla_web/controllers/inbox_controller.ex
@@ -0,0 +1,25 @@
+defmodule NullaWeb.InboxController do
+ use NullaWeb, :controller
+
+ def receive(conn, %{"type" => "Follow"} = activity) do
+ # Check signature
+ # Verify actor and object
+ # Save follow to db
+ # Send Accept or Reject
+ json(conn, %{"status" => "Follow received"})
+ end
+
+ def receive(conn, %{"type" => "Like"} = activity) do
+ # Process Like
+ json(conn, %{"status" => "Like received"})
+ end
+
+ def receive(conn, %{"type" => "Create"} = activity) do
+ # Create object and save
+ json(conn, %{"status" => "Object created"})
+ end
+
+ def receive(conn, _params) do
+ json(conn, %{"status" => "Unhandled type"})
+ end
+end
diff --git a/lib/nulla_web/controllers/user_controller.ex b/lib/nulla_web/controllers/user_controller.ex
index 0f078e0..4c93d99 100644
--- a/lib/nulla_web/controllers/user_controller.ex
+++ b/lib/nulla_web/controllers/user_controller.ex
@@ -15,7 +15,7 @@ defmodule NullaWeb.UserController do
if accept in ["application/activity+json", "application/ld+json"] do
conn
|> put_resp_content_type("application/activity+json")
- |> json(ActivityPub.ap_user(domain, user))
+ |> json(ActivityPub.user(domain, user))
else
render(conn, :show, domain: domain, user: user, notes: notes, layout: false)
end
diff --git a/lib/nulla_web/controllers/user_html.ex b/lib/nulla_web/controllers/user_html.ex
index fe6103e..2f1ec2f 100644
--- a/lib/nulla_web/controllers/user_html.ex
+++ b/lib/nulla_web/controllers/user_html.ex
@@ -25,12 +25,19 @@ defmodule NullaWeb.UserHTML do
relative =
cond do
- diff == 0 -> "today"
- diff == 1 -> "1 day ago"
- diff < 30 -> "#{diff} days ago"
+ diff == 0 ->
+ "today"
+
+ diff == 1 ->
+ "1 day ago"
+
+ diff < 30 ->
+ "#{diff} days ago"
+
diff < 365 ->
months = Timex.diff(now, date, :months)
if months == 1, do: "1 month ago", else: "#{months} months ago"
+
true ->
years = Timex.diff(now, date, :years)
if years == 1, do: "1 year ago", else: "#{years} years ago"
@@ -46,7 +53,7 @@ defmodule NullaWeb.UserHTML do
def format_note_datetime_diff(datetime) do
now = Timex.now()
diff = Timex.diff(now, datetime, :seconds)
-
+
cond do
diff < 60 ->
"now"
@@ -59,15 +66,15 @@ defmodule NullaWeb.UserHTML do
hours = div(diff, 3600)
"#{hours}h ago"
- diff < 518400 ->
+ diff < 518_400 ->
days = div(diff, 86400)
"#{days}d ago"
- diff < 2419200 ->
- weeks = div(diff, 604800)
+ diff < 2_419_200 ->
+ weeks = div(diff, 604_800)
"#{weeks}w ago"
- diff < 28512000 ->
+ diff < 28_512_000 ->
months = Timex.diff(now, datetime, :months)
"#{months}mo ago"
diff --git a/lib/nulla_web/controllers/user_html/show.html.heex b/lib/nulla_web/controllers/user_html/show.html.heex
index 9ce9f87..9184f8b 100644
--- a/lib/nulla_web/controllers/user_html/show.html.heex
+++ b/lib/nulla_web/controllers/user_html/show.html.heex
@@ -1,8 +1,14 @@
-
+
-
+
@@ -12,8 +18,13 @@
-

-
+

+
@@ -27,28 +38,28 @@
<.icon name="hero-map-pin" class="mt-0.5 h-5 w-5 flex-none" />
-
<%= @user.location %>
+
{@user.location}
<% end %>
<%= if @user.birthday do %>
<.icon name="hero-cake" class="mt-0.5 h-5 w-5 flex-none" />
-
<%= format_birthdate(@user.birthday) %>
+
{format_birthdate(@user.birthday)}
<% end %>
<.icon name="hero-calendar" class="mt-0.5 h-5 w-5 flex-none" />
-
<%= format_registration_date(@user.inserted_at) %>
+
{format_registration_date(@user.inserted_at)}
<%= if @user.fields do %>
<%= for {key, value} <- @user.fields do %>
- - <%= key %>
+ - {key}
-
<%= if Regex.match?(~r{://}, value) do %>
- <%= Regex.replace(~r{^\w+://}, value, "") %>
+ {Regex.replace(~r{^\w+://}, value, "")}
<% else %>
- <%= value %>
+ {value}
<% end %>
<% end %>
@@ -70,15 +81,15 @@
<%= for note <- @notes do %>
-

+
- <%= @user.realname %>
+ {@user.realname}
- @<%= @user.username %>@<%= @domain %>
+ @{@user.username}@{@domain}
@@ -92,11 +103,11 @@
<% :private -> %>
<.icon name="hero-at-symbol" class="h-5 w-5" />
<% end %>
- <%= format_note_datetime_diff(note.inserted_at) %>
+ {format_note_datetime_diff(note.inserted_at)}
-
<%= note.content %>
+
{note.content}
@@ -110,8 +121,6 @@
diff --git a/lib/nulla_web/router.ex b/lib/nulla_web/router.ex
index 4f956cd..5af267b 100644
--- a/lib/nulla_web/router.ex
+++ b/lib/nulla_web/router.ex
@@ -11,13 +11,16 @@ defmodule NullaWeb.Router do
end
pipeline :api do
- plug :accepts, ["json"]
+ plug :accepts, ["activity+json", "ld+json"]
+
+ post "/inbox", InboxController, :receive
+ post "/@:username/inbox", InboxController, :receive
+ get "/@:username/outbox", OutboxController, :index
end
scope "/", NullaWeb do
pipe_through :browser
- get "/", PageController, :home
get "/@:username", UserController, :show
resources "/users", UserController
end
diff --git a/priv/repo/migrations/20250607124601_create_activities.exs b/priv/repo/migrations/20250607124601_create_activities.exs
new file mode 100644
index 0000000..0ed3cf2
--- /dev/null
+++ b/priv/repo/migrations/20250607124601_create_activities.exs
@@ -0,0 +1,17 @@
+defmodule Nulla.Repo.Migrations.CreateActivities do
+ use Ecto.Migration
+
+ def change do
+ create table(:activities) do
+ add :type, :string, null: false
+ add :actor, :string, null: false
+ add :object, :map, null: false
+ add :to, {:array, :string}, default: []
+
+ timestamps()
+ end
+
+ create index(:activities, [:actor])
+ create index(:activities, [:type])
+ end
+end