diff --git a/lib/nulla/activitypub.ex b/lib/nulla/activitypub.ex index fd50758..0787fc5 100644 --- a/lib/nulla/activitypub.ex +++ b/lib/nulla/activitypub.ex @@ -87,12 +87,12 @@ defmodule Nulla.ActivityPub do "https://www.w3.org/ns/activitystreams", Jason.OrderedObject.new(sensitive: "as:sensitive") ], - id: "#{note.actor.ap_id}/statuses/#{note.id}", + id: "#{note.actor.ap_id}/notes/#{note.id}", type: "Note", summary: nil, - inReplyTo: nil, + inReplyTo: note.inReplyTo, published: note.inserted_at, - url: "#{note.actor.ap_id}/#{note.id}", + url: note.url, attributedTo: note.actor.ap_id, to: [ "https://www.w3.org/ns/activitystreams#Public" @@ -100,7 +100,7 @@ defmodule Nulla.ActivityPub do cc: [ "#{note.actor.ap_id}/followers" ], - sensetive: false, + sensitive: note.sensitive, content: note.content, contentMap: Jason.OrderedObject.new("#{note.language}": note.content), attachment: attachment @@ -329,7 +329,7 @@ defmodule Nulla.ActivityPub do @spec activity_note(Note.t()) :: Jason.OrderedObject.t() def activity_note(note) do Jason.OrderedObject.new( - id: "#{note.actor.ap_id}/statuses/#{note.id}/activity", + id: "#{note.actor.ap_id}/notes/#{note.id}/activity", type: "Create", actor: note.actor.ap_id, published: note.inserted_at |> DateTime.to_iso8601(), @@ -338,7 +338,7 @@ defmodule Nulla.ActivityPub do ], object: Jason.OrderedObject.new( - id: "#{note.actor.ap_id}/statuses/#{note.id}", + id: "#{note.actor.ap_id}/notes/#{note.id}", type: "Note", content: note.content, published: note.inserted_at |> DateTime.to_iso8601(), diff --git a/lib/nulla/models/activity.ex b/lib/nulla/models/activity.ex index f752917..71a192d 100644 --- a/lib/nulla/models/activity.ex +++ b/lib/nulla/models/activity.ex @@ -24,7 +24,7 @@ defmodule Nulla.Models.Activity do end def create_activity(attrs) do - id = Snowflake.next_id() + id = Map.get(attrs, :id, Snowflake.next_id()) %__MODULE__{} |> __MODULE__.changeset(attrs) diff --git a/lib/nulla/models/actor.ex b/lib/nulla/models/actor.ex index d77fcb0..e173673 100644 --- a/lib/nulla/models/actor.ex +++ b/lib/nulla/models/actor.ex @@ -93,7 +93,7 @@ defmodule Nulla.Models.Actor do end def create_actor(attrs) when is_map(attrs) do - id = Snowflake.next_id() + id = Map.get(attrs, :id, Snowflake.next_id()) %__MODULE__{} |> changeset(attrs) diff --git a/lib/nulla/models/note.ex b/lib/nulla/models/note.ex index 51637e8..18a26cf 100644 --- a/lib/nulla/models/note.ex +++ b/lib/nulla/models/note.ex @@ -3,20 +3,22 @@ defmodule Nulla.Models.Note do import Ecto.Changeset import Ecto.Query alias Nulla.Repo + alias Nulla.Snowflake alias Nulla.Models.Actor alias Nulla.Models.MediaAttachment @primary_key {:id, :integer, autogenerate: false} schema "notes" do - field :content, :string + field :inReplyTo, :string + field :url, :string field :visibility, Ecto.Enum, values: [:public, :unlisted, :followers, :private], default: :public field :sensitive, :boolean, default: false + field :content, :string field :language, :string - field :in_reply_to, :string belongs_to :actor, Actor has_many :media_attachments, MediaAttachment @@ -27,11 +29,20 @@ defmodule Nulla.Models.Note do @doc false def changeset(note, attrs) do note - |> cast(attrs, [:content, :visibility, :sensitive, :language, :in_reply_to, :actor_id]) - |> validate_required([:content, :visibility, :sensitive, :language, :in_reply_to, :actor_id]) + |> cast(attrs, [:content, :visibility, :sensitive, :language, :inReplyTo, :actor_id]) + |> validate_required([:content, :visibility, :sensitive, :language, :actor_id]) end - def get_note!(id), do: Repo.get!(__MODULE__, id) + def create_note(attrs) when is_map(attrs) do + id = Map.get(attrs, :id, Snowflake.next_id()) + + %__MODULE__{} + |> changeset(attrs) + |> put_change(:id, id) + |> Repo.insert() + end + + def get_note(id), do: Repo.get(__MODULE__, id) def get_latest_notes(actor_id, limit \\ 20) do from(n in __MODULE__, diff --git a/lib/nulla/models/relation.ex b/lib/nulla/models/relation.ex index 3799af9..600abca 100644 --- a/lib/nulla/models/relation.ex +++ b/lib/nulla/models/relation.ex @@ -53,7 +53,7 @@ defmodule Nulla.Models.Relation do end def create_relation(attrs) do - id = Snowflake.next_id() + id = Map.get(attrs, :id, Snowflake.next_id()) %__MODULE__{} |> __MODULE__.changeset(attrs) diff --git a/lib/nulla_web/components/templates.ex b/lib/nulla_web/components/templates.ex index 6a61d9b..3ba56db 100644 --- a/lib/nulla_web/components/templates.ex +++ b/lib/nulla_web/components/templates.ex @@ -95,3 +95,9 @@ defmodule NullaWeb.ActorHTML do end end end + +defmodule NullaWeb.NoteHTML do + use NullaWeb, :html + + embed_templates "templates/note/*" +end diff --git a/lib/nulla_web/components/templates/note/show.html.heex b/lib/nulla_web/components/templates/note/show.html.heex new file mode 100644 index 0000000..52faab3 --- /dev/null +++ b/lib/nulla_web/components/templates/note/show.html.heex @@ -0,0 +1,22 @@ +
+
+ +
+ +
+ +
+
+
+
+
+
+
+
+
diff --git a/lib/nulla_web/controllers/note_controller.ex b/lib/nulla_web/controllers/note_controller.ex index c0237f5..1ee7130 100644 --- a/lib/nulla_web/controllers/note_controller.ex +++ b/lib/nulla_web/controllers/note_controller.ex @@ -6,12 +6,12 @@ defmodule NullaWeb.NoteController do def show(conn, %{"username" => username, "id" => id}) do accept = List.first(get_req_header(conn, "accept")) - note = Note.get_note!(id) |> Repo.preload([:user, :media_attachments]) + note = Note.get_note(id) |> Repo.preload([:actor, :media_attachments]) - if username != note.user.username do + if username != note.actor.preferredUsername do conn |> put_status(:not_found) - |> json(%{error: "Note not found"}) + |> json(%{error: "Not Found"}) |> halt() end diff --git a/lib/nulla_web/router.ex b/lib/nulla_web/router.ex index cd80eb6..d912802 100644 --- a/lib/nulla_web/router.ex +++ b/lib/nulla_web/router.ex @@ -35,7 +35,7 @@ defmodule NullaWeb.Router do get "/followers", FollowController, :followers post "/inbox", InboxController, :inbox get "/outbox", OutboxController, :outbox - get "/statuses/:id", NoteController, :show + get "/notes/:id", NoteController, :show end scope "/@:username" do diff --git a/priv/repo/migrations/20250615131431_create_notes.exs b/priv/repo/migrations/20250615131431_create_notes.exs index a930b14..d872437 100644 --- a/priv/repo/migrations/20250615131431_create_notes.exs +++ b/priv/repo/migrations/20250615131431_create_notes.exs @@ -4,11 +4,12 @@ defmodule Nulla.Repo.Migrations.CreateNotes do def change do create table(:notes, primary_key: false) do add :id, :bigint, primary_key: true - add :content, :text + add :inReplyTo, :string + add :url, :string add :visibility, :string, default: "public" add :sensitive, :boolean, default: false + add :content, :text add :language, :string - add :in_reply_to, :string add :actor_id, references(:actors, on_delete: :delete_all) timestamps(type: :utc_datetime) diff --git a/test/nulla_web/controllers/note_controller_test.exs b/test/nulla_web/controllers/note_controller_test.exs new file mode 100644 index 0000000..d3f87ee --- /dev/null +++ b/test/nulla_web/controllers/note_controller_test.exs @@ -0,0 +1,75 @@ +defmodule NullaWeb.NoteControllerTest do + use NullaWeb.ConnCase + alias Nulla.KeyGen + alias Nulla.Snowflake + alias Nulla.Models.Actor + alias Nulla.Models.Note + + describe "GET /notes/id" do + test "returns ActivityPub JSON with note", %{conn: conn} do + {publicKeyPem, _privateKeyPem} = KeyGen.gen() + + {:ok, actor} = + Actor.create_actor(%{ + domain: "localhost", + ap_id: "http://localhost/users/test", + type: "Person", + following: "http://localhost/users/test/following", + followers: "http://localhost/users/test/followers", + inbox: "http://localhost/users/test/inbox", + outbox: "http://localhost/users/test/outbox", + featured: "http://localhost/users/test/collections/featured", + featuredTags: "http://localhost/users/test/collections/tags", + preferredUsername: "test", + name: "Test", + summary: "Test User", + url: "http://localhost/@test", + manuallyApprovesFollowers: false, + discoverable: true, + indexable: true, + published: DateTime.utc_now(), + memorial: false, + publicKey: + Jason.OrderedObject.new( + id: "http://localhost/users/test#main-key", + owner: "http://localhost/users/test", + publicKeyPem: publicKeyPem + ), + endpoints: Jason.OrderedObject.new(sharedInbox: "http://localhost/inbox") + }) + + note_id = Snowflake.next_id() + + {:ok, note} = + Note.create_note(%{ + id: note_id, + url: "#{actor.url}/#{note_id}", + content: "Hello World from Nulla!", + language: "en", + actor_id: actor.id + }) + + conn = + conn + |> put_req_header("accept", "application/activity+json") + |> get(~p"/users/#{actor.preferredUsername}/notes/#{note.id}") + + assert response = json_response(conn, 200) + + assert is_list(response["@context"]) + assert response["id"] == "http://localhost/users/test/notes/#{note.id}" + assert response["type"] == "Note" + assert response["summary"] == nil + assert response["inReplyTo"] == nil + assert {:ok, _dt, _offset} = DateTime.from_iso8601(response["published"]) + assert response["url"] == note.url + assert response["attributedTo"] == "http://localhost/users/test" + assert is_list(response["to"]) + assert is_list(response["cc"]) + assert response["sensitive"] == false + assert is_binary(response["content"]) + assert is_map(response["contentMap"]) + assert is_list(response["attachment"]) + end + end +end