mix format
This commit is contained in:
parent
7d8cb33405
commit
4fb1e200f1
12 changed files with 221 additions and 69 deletions
|
@ -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}": "<p>@rf@mastodonsocial.ru Вниманию новичкам!</p><p>Вам небольшое руководство о том, как импротировать пост, которого нет в вашей ленте.</p>"
|
||||
),
|
||||
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
|
||||
|
|
21
lib/nulla/models/activity.ex
Normal file
21
lib/nulla/models/activity.ex
Normal file
|
@ -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
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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])
|
||||
|
|
25
lib/nulla_web/controllers/inbox_controller.ex
Normal file
25
lib/nulla_web/controllers/inbox_controller.ex
Normal file
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
|
@ -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"
|
||||
|
||||
|
|
|
@ -1,8 +1,14 @@
|
|||
<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%]"/>
|
||||
<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>
|
||||
<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>
|
||||
|
@ -12,8 +18,13 @@
|
|||
<div class="relative w-full aspect-[3/1]">
|
||||
<img src={"/files/#{@user.banner}"} 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={"/files/#{@user.avatar}"} 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">Follow</button>
|
||||
<img
|
||||
src={"/files/#{@user.avatar}"}
|
||||
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">
|
||||
Follow
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-[4.5vw] px-4 flex flex-col">
|
||||
|
@ -27,28 +38,28 @@
|
|||
<dt class="flex items-center gap-2">
|
||||
<.icon name="hero-map-pin" class="mt-0.5 h-5 w-5 flex-none" />
|
||||
</dt>
|
||||
<dd><%= @user.location %></dd>
|
||||
<dd>{@user.location}</dd>
|
||||
<% end %>
|
||||
<%= if @user.birthday do %>
|
||||
<dt class="flex items-center gap-2">
|
||||
<.icon name="hero-cake" class="mt-0.5 h-5 w-5 flex-none" />
|
||||
</dt>
|
||||
<dd><%= format_birthdate(@user.birthday) %></dd>
|
||||
<dd>{format_birthdate(@user.birthday)}</dd>
|
||||
<% end %>
|
||||
<dt class="flex items-center gap-2">
|
||||
<.icon name="hero-calendar" class="mt-0.5 h-5 w-5 flex-none" />
|
||||
</dt>
|
||||
<dd><%= format_registration_date(@user.inserted_at) %></dd>
|
||||
<dd>{format_registration_date(@user.inserted_at)}</dd>
|
||||
</dl>
|
||||
<%= if @user.fields do %>
|
||||
<dl class="mt-5 grid grid-cols-[max-content,1fr] gap-x-5 gap-y-2 items-center">
|
||||
<%= for {key, value} <- @user.fields do %>
|
||||
<dt><%= key %></dt>
|
||||
<dt>{key}</dt>
|
||||
<dd>
|
||||
<%= if Regex.match?(~r{://}, value) do %>
|
||||
<a href={value} class="text-[#1D9BF0]"><%= Regex.replace(~r{^\w+://}, value, "") %></a>
|
||||
<a href={value} class="text-[#1D9BF0]">{Regex.replace(~r{^\w+://}, value, "")}</a>
|
||||
<% else %>
|
||||
<%= value %>
|
||||
{value}
|
||||
<% end %>
|
||||
</dd>
|
||||
<% end %>
|
||||
|
@ -70,15 +81,15 @@
|
|||
<%= for note <- @notes do %>
|
||||
<div class="p-4 border-b border-gray-300">
|
||||
<div class="flex items-start space-x-4">
|
||||
<img src={"/files/#{@user.avatar}"} class="rounded-full w-[58px] h-[58px]"/>
|
||||
<img src={"/files/#{@user.avatar}"} 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">
|
||||
<span class="font-semibold text-gray-900 text-sm">
|
||||
<%= @user.realname %>
|
||||
{@user.realname}
|
||||
</span>
|
||||
<span class="text-gray-500 text-sm">
|
||||
@<%= @user.username %>@<%= @domain %>
|
||||
@{@user.username}@{@domain}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2 text-sm text-gray-500">
|
||||
|
@ -92,11 +103,11 @@
|
|||
<% :private -> %>
|
||||
<.icon name="hero-at-symbol" class="h-5 w-5" />
|
||||
<% end %>
|
||||
<span><%= format_note_datetime_diff(note.inserted_at) %></span>
|
||||
<span>{format_note_datetime_diff(note.inserted_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-gray-800">
|
||||
<p><%= note.content %></p>
|
||||
<p>{note.content}</p>
|
||||
</div>
|
||||
<div class="flex gap-10 mt-4">
|
||||
<button><.icon name="hero-chat-bubble-left" class="h-5 w-5" /></button>
|
||||
|
@ -110,8 +121,6 @@
|
|||
</div>
|
||||
</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 class="text-sm rounded-xl border border-gray-300 p-4 w-[90%] h-[300px]"></div>
|
||||
</div>
|
||||
</main>
|
||||
|
|
|
@ -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
|
||||
|
|
17
priv/repo/migrations/20250607124601_create_activities.exs
Normal file
17
priv/repo/migrations/20250607124601_create_activities.exs
Normal file
|
@ -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
|
Loading…
Add table
Add a link
Reference in a new issue