This commit is contained in:
Mirai Kumiko 2025-06-19 16:16:27 +02:00
parent 8f63a831c4
commit f963620cf0
Signed by: miraikumiko
GPG key ID: 3F178B1B5E0CB278
11 changed files with 135 additions and 20 deletions

View file

@ -87,12 +87,12 @@ 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: "#{note.actor.ap_id}/statuses/#{note.id}", id: "#{note.actor.ap_id}/notes/#{note.id}",
type: "Note", type: "Note",
summary: nil, summary: nil,
inReplyTo: nil, inReplyTo: note.inReplyTo,
published: note.inserted_at, published: note.inserted_at,
url: "#{note.actor.ap_id}/#{note.id}", url: note.url,
attributedTo: note.actor.ap_id, attributedTo: note.actor.ap_id,
to: [ to: [
"https://www.w3.org/ns/activitystreams#Public" "https://www.w3.org/ns/activitystreams#Public"
@ -100,7 +100,7 @@ defmodule Nulla.ActivityPub do
cc: [ cc: [
"#{note.actor.ap_id}/followers" "#{note.actor.ap_id}/followers"
], ],
sensetive: false, sensitive: note.sensitive,
content: note.content, content: note.content,
contentMap: Jason.OrderedObject.new("#{note.language}": note.content), contentMap: Jason.OrderedObject.new("#{note.language}": note.content),
attachment: attachment attachment: attachment
@ -329,7 +329,7 @@ defmodule Nulla.ActivityPub do
@spec activity_note(Note.t()) :: Jason.OrderedObject.t() @spec activity_note(Note.t()) :: Jason.OrderedObject.t()
def activity_note(note) do def activity_note(note) do
Jason.OrderedObject.new( Jason.OrderedObject.new(
id: "#{note.actor.ap_id}/statuses/#{note.id}/activity", id: "#{note.actor.ap_id}/notes/#{note.id}/activity",
type: "Create", type: "Create",
actor: note.actor.ap_id, actor: note.actor.ap_id,
published: note.inserted_at |> DateTime.to_iso8601(), published: note.inserted_at |> DateTime.to_iso8601(),
@ -338,7 +338,7 @@ defmodule Nulla.ActivityPub do
], ],
object: object:
Jason.OrderedObject.new( Jason.OrderedObject.new(
id: "#{note.actor.ap_id}/statuses/#{note.id}", id: "#{note.actor.ap_id}/notes/#{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(),

View file

@ -24,7 +24,7 @@ defmodule Nulla.Models.Activity do
end end
def create_activity(attrs) do def create_activity(attrs) do
id = Snowflake.next_id() id = Map.get(attrs, :id, Snowflake.next_id())
%__MODULE__{} %__MODULE__{}
|> __MODULE__.changeset(attrs) |> __MODULE__.changeset(attrs)

View file

@ -93,7 +93,7 @@ defmodule Nulla.Models.Actor do
end end
def create_actor(attrs) when is_map(attrs) do def create_actor(attrs) when is_map(attrs) do
id = Snowflake.next_id() id = Map.get(attrs, :id, Snowflake.next_id())
%__MODULE__{} %__MODULE__{}
|> changeset(attrs) |> changeset(attrs)

View file

@ -3,20 +3,22 @@ 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.Snowflake
alias Nulla.Models.Actor alias Nulla.Models.Actor
alias Nulla.Models.MediaAttachment alias Nulla.Models.MediaAttachment
@primary_key {:id, :integer, autogenerate: false} @primary_key {:id, :integer, autogenerate: false}
schema "notes" do schema "notes" do
field :content, :string field :inReplyTo, :string
field :url, :string
field :visibility, Ecto.Enum, field :visibility, Ecto.Enum,
values: [:public, :unlisted, :followers, :private], values: [:public, :unlisted, :followers, :private],
default: :public default: :public
field :sensitive, :boolean, default: false field :sensitive, :boolean, default: false
field :content, :string
field :language, :string field :language, :string
field :in_reply_to, :string
belongs_to :actor, Actor belongs_to :actor, Actor
has_many :media_attachments, MediaAttachment has_many :media_attachments, MediaAttachment
@ -27,11 +29,20 @@ 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, :actor_id]) |> cast(attrs, [:content, :visibility, :sensitive, :language, :inReplyTo, :actor_id])
|> validate_required([:content, :visibility, :sensitive, :language, :in_reply_to, :actor_id]) |> validate_required([:content, :visibility, :sensitive, :language, :actor_id])
end 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 def get_latest_notes(actor_id, limit \\ 20) do
from(n in __MODULE__, from(n in __MODULE__,

View file

@ -53,7 +53,7 @@ defmodule Nulla.Models.Relation do
end end
def create_relation(attrs) do def create_relation(attrs) do
id = Snowflake.next_id() id = Map.get(attrs, :id, Snowflake.next_id())
%__MODULE__{} %__MODULE__{}
|> __MODULE__.changeset(attrs) |> __MODULE__.changeset(attrs)

View file

@ -95,3 +95,9 @@ defmodule NullaWeb.ActorHTML do
end end
end end
end end
defmodule NullaWeb.NoteHTML do
use NullaWeb, :html
embed_templates "templates/note/*"
end

View file

@ -0,0 +1,22 @@
<main class="grid grid-cols-[25%_50%_25%]">
<div class="flex flex-col items-center mt-5 gap-5">
<input
placeholder="Search"
class="border border-gray-300 px-4 py-3 rounded-xl outline-none w-[90%]"
/>
<div class="text-sm rounded-xl border border-gray-300 p-2 w-[90%]">
<textarea
placeholder="What's on your mind?"
class="h-[150px] w-full resize-none border-none focus:ring-0"
></textarea>
<div>
<button class="text-white bg-black px-3 py-1 rounded-xl">Post</button>
</div>
</div>
</div>
<div class="relative border border-gray-300 shadow-md mt-5 rounded-t-xl overflow-hidden">
</div>
<div class="flex flex-col items-center mt-5 gap-5">
<div class="text-sm rounded-xl border border-gray-300 p-4 w-[90%] h-[300px]"></div>
</div>
</main>

View file

@ -6,12 +6,12 @@ defmodule NullaWeb.NoteController do
def show(conn, %{"username" => username, "id" => id}) do def show(conn, %{"username" => username, "id" => id}) do
accept = List.first(get_req_header(conn, "accept")) 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 conn
|> put_status(:not_found) |> put_status(:not_found)
|> json(%{error: "Note not found"}) |> json(%{error: "Not Found"})
|> halt() |> halt()
end end

View file

@ -35,7 +35,7 @@ defmodule NullaWeb.Router do
get "/followers", FollowController, :followers get "/followers", FollowController, :followers
post "/inbox", InboxController, :inbox post "/inbox", InboxController, :inbox
get "/outbox", OutboxController, :outbox get "/outbox", OutboxController, :outbox
get "/statuses/:id", NoteController, :show get "/notes/:id", NoteController, :show
end end
scope "/@:username" do scope "/@:username" do

View file

@ -4,11 +4,12 @@ defmodule Nulla.Repo.Migrations.CreateNotes do
def change do def change do
create table(:notes, primary_key: false) do create table(:notes, primary_key: false) do
add :id, :bigint, primary_key: true add :id, :bigint, primary_key: true
add :content, :text add :inReplyTo, :string
add :url, :string
add :visibility, :string, default: "public" add :visibility, :string, default: "public"
add :sensitive, :boolean, default: false add :sensitive, :boolean, default: false
add :content, :text
add :language, :string add :language, :string
add :in_reply_to, :string
add :actor_id, references(:actors, on_delete: :delete_all) add :actor_id, references(:actors, on_delete: :delete_all)
timestamps(type: :utc_datetime) timestamps(type: :utc_datetime)

View file

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