@ -0,0 +1,24 @@
|
||||
image: elixir:1.5
|
||||
|
||||
services:
|
||||
- postgres:9.6.2
|
||||
|
||||
variables:
|
||||
POSTGRES_DB: pleroma_test
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
|
||||
stages:
|
||||
- test
|
||||
|
||||
before_script:
|
||||
- mix local.hex --force
|
||||
- mix local.rebar --force
|
||||
- mix deps.get
|
||||
- MIX_ENV=test mix ecto.create
|
||||
- MIX_ENV=test mix ecto.migrate
|
||||
|
||||
unit-testing:
|
||||
stage: test
|
||||
script:
|
||||
- MIX_ENV=test mix test
|
@ -0,0 +1,22 @@
|
||||
defmodule Mix.Tasks.GenerateConfig do
|
||||
use Mix.Task
|
||||
|
||||
@shortdoc "Generates a new config"
|
||||
def run(_) do
|
||||
IO.puts("Answer a few questions to generate a new config\n")
|
||||
IO.puts("--- THIS WILL OVERWRITE YOUR config/generated_config.exs! ---\n")
|
||||
domain = IO.gets("What is your domain name? (e.g. pleroma.soykaf.com): ") |> String.trim
|
||||
name = IO.gets("What is the name of your instance? (e.g. Pleroma/Soykaf): ") |> String.trim
|
||||
email = IO.gets("What's your admin email address: ") |> String.trim
|
||||
secret = :crypto.strong_rand_bytes(64) |> Base.encode64 |> binary_part(0, 64)
|
||||
dbpass = :crypto.strong_rand_bytes(64) |> Base.encode64 |> binary_part(0, 64)
|
||||
|
||||
resultSql = EEx.eval_file("lib/mix/tasks/sample_psql.eex", [dbpass: dbpass])
|
||||
result = EEx.eval_file("lib/mix/tasks/sample_config.eex", [domain: domain, email: email, name: name, secret: secret, dbpass: dbpass])
|
||||
|
||||
IO.puts("\nWriting config to config/generated_config.exs.\n\nCheck it and configure your database, then copy it to either config/dev.secret.exs or config/prod.secret.exs")
|
||||
File.write("config/generated_config.exs", result)
|
||||
IO.puts("\nWriting setup_db.psql, please run it as postgre superuser, i.e.: sudo su postgres -c 'psql -f config/setup_db.psql'")
|
||||
File.write("config/setup_db.psql", resultSql)
|
||||
end
|
||||
end
|
@ -0,0 +1,20 @@
|
||||
use Mix.Config
|
||||
|
||||
config :pleroma, Pleroma.Web.Endpoint,
|
||||
url: [host: "<%= domain %>", scheme: "https", port: 443],
|
||||
secret_key_base: "<%= secret %>"
|
||||
|
||||
config :pleroma, :instance,
|
||||
name: "<%= name %>",
|
||||
email: "<%= email %>",
|
||||
limit: 5000,
|
||||
registrations_open: true
|
||||
|
||||
# Configure your database
|
||||
config :pleroma, Pleroma.Repo,
|
||||
adapter: Ecto.Adapters.Postgres,
|
||||
username: "pleroma",
|
||||
password: "<%= dbpass %>",
|
||||
database: "pleroma_dev",
|
||||
hostname: "localhost",
|
||||
pool_size: 10
|
@ -0,0 +1,8 @@
|
||||
CREATE USER pleroma WITH ENCRYPTED PASSWORD '<%= dbpass %>' CREATEDB;
|
||||
-- in case someone runs this second time accidentally
|
||||
ALTER USER pleroma WITH ENCRYPTED PASSWORD '<%= dbpass %>' CREATEDB;
|
||||
CREATE DATABASE pleroma_dev;
|
||||
ALTER DATABASE pleroma_dev OWNER TO pleroma;
|
||||
\c pleroma_dev;
|
||||
--Extensions made by ecto.migrate that need superuser access
|
||||
CREATE EXTENSION IF NOT EXISTS citext;
|
@ -0,0 +1,44 @@
|
||||
defmodule Pleroma.PasswordResetToken do
|
||||
use Ecto.Schema
|
||||
|
||||
import Ecto.Changeset
|
||||
|
||||
alias Pleroma.{User, PasswordResetToken, Repo}
|
||||
|
||||
schema "password_reset_tokens" do
|
||||
belongs_to :user, User
|
||||
field :token, :string
|
||||
field :used, :boolean, default: false
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
||||
def create_token(%User{} = user) do
|
||||
token = :crypto.strong_rand_bytes(32) |> Base.url_encode64
|
||||
|
||||
token = %PasswordResetToken{
|
||||
user_id: user.id,
|
||||
used: false,
|
||||
token: token
|
||||
}
|
||||
|
||||
Repo.insert(token)
|
||||
end
|
||||
|
||||
def used_changeset(struct) do
|
||||
struct
|
||||
|> cast(%{}, [])
|
||||
|> put_change(:used, true)
|
||||
end
|
||||
|
||||
def reset_password(token, data) do
|
||||
with %{used: false} = token <- Repo.get_by(PasswordResetToken, %{token: token}),
|
||||
%User{} = user <- Repo.get(User, token.user_id),
|
||||
{:ok, _user} <- User.reset_password(user, data),
|
||||
{:ok, token} <- Repo.update(used_changeset(token)) do
|
||||
{:ok, token}
|
||||
else
|
||||
_e -> {:error, token}
|
||||
end
|
||||
end
|
||||
end
|
@ -0,0 +1,46 @@
|
||||
defmodule Pleroma.Web.ChatChannel do
|
||||
use Phoenix.Channel
|
||||
alias Pleroma.Web.ChatChannel.ChatChannelState
|
||||
alias Pleroma.User
|
||||
|
||||
def join("chat:public", _message, socket) do
|
||||
send(self(), :after_join)
|
||||
{:ok, socket}
|
||||
end
|
||||
|
||||
def handle_info(:after_join, socket) do
|
||||
push socket, "messages", %{messages: ChatChannelState.messages()}
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_in("new_msg", %{"text" => text}, %{assigns: %{user_name: user_name}} = socket) do
|
||||
author = User.get_cached_by_nickname(user_name)
|
||||
author = Pleroma.Web.MastodonAPI.AccountView.render("account.json", user: author)
|
||||
message = ChatChannelState.add_message(%{text: text, author: author})
|
||||
|
||||
broadcast! socket, "new_msg", message
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
defmodule Pleroma.Web.ChatChannel.ChatChannelState do
|
||||
use Agent
|
||||
@max_messages 20
|
||||
|
||||
def start_link do
|
||||
Agent.start_link(fn -> %{max_id: 1, messages: []} end, name: __MODULE__)
|
||||
end
|
||||
|
||||
def add_message(message) do
|
||||
Agent.get_and_update(__MODULE__, fn state ->
|
||||
id = state[:max_id] + 1
|
||||
message = Map.put(message, "id", id)
|
||||
messages = [message | state[:messages]] |> Enum.take(@max_messages)
|
||||
{message, %{max_id: id, messages: messages}}
|
||||
end)
|
||||
end
|
||||
|
||||
def messages() do
|
||||
Agent.get(__MODULE__, fn state -> state[:messages] |> Enum.reverse end)
|
||||
end
|
||||
end
|
@ -0,0 +1,41 @@
|
||||
defmodule Pleroma.Web.MastodonAPI.MastodonSocket do
|
||||
use Phoenix.Socket
|
||||
|
||||
alias Pleroma.Web.OAuth.Token
|
||||
alias Pleroma.{User, Repo}
|
||||
|
||||
transport :streaming, Phoenix.Transports.WebSocket.Raw,
|
||||
timeout: :infinity # We never receive data.
|
||||
|
||||
def connect(params, socket) do
|
||||
with token when not is_nil(token) <- params["access_token"],
|
||||
%Token{user_id: user_id} <- Repo.get_by(Token, token: token),
|
||||
%User{} = user <- Repo.get(User, user_id),
|
||||
stream when stream in ["public", "public:local", "user"] <- params["stream"] do
|
||||
socket = socket
|
||||
|> assign(:topic, params["stream"])
|
||||
|> assign(:user, user)
|
||||
Pleroma.Web.Streamer.add_socket(params["stream"], socket)
|
||||
{:ok, socket}
|
||||
else
|
||||
_e -> :error
|
||||
end
|
||||
end
|
||||
|
||||
def id(_), do: nil
|
||||
|
||||
def handle(:text, message, _state) do
|
||||
IO.inspect message
|
||||
#| :ok
|
||||
#| state
|
||||
#| {:text, message}
|
||||
#| {:text, message, state}
|
||||
#| {:close, "Goodbye!"}
|
||||
{:text, message}
|
||||
end
|
||||
|
||||
def handle(:closed, _, %{socket: socket}) do
|
||||
topic = socket.assigns[:topic]
|
||||
Pleroma.Web.Streamer.remove_socket(topic, socket)
|
||||
end
|
||||
end
|
@ -0,0 +1,5 @@
|
||||
defmodule Pleroma.Web.MastodonAPI.MastodonView do
|
||||
use Pleroma.Web, :view
|
||||
import Phoenix.HTML
|
||||
import Phoenix.HTML.Form
|
||||
end
|
@ -0,0 +1,112 @@
|
||||
defmodule Pleroma.Web.Streamer do
|
||||
use GenServer
|
||||
require Logger
|
||||
alias Pleroma.{User, Notification}
|
||||
|
||||
def start_link do
|
||||
spawn(fn ->
|
||||
Process.sleep(1000 * 30) # 30 seconds
|
||||
GenServer.cast(__MODULE__, %{action: :ping})
|
||||
end)
|
||||
GenServer.start_link(__MODULE__, %{}, name: __MODULE__)
|
||||
end
|
||||
|
||||
def add_socket(topic, socket) do
|
||||
GenServer.cast(__MODULE__, %{action: :add, socket: socket, topic: topic})
|
||||
end
|
||||
|
||||
def remove_socket(topic, socket) do
|
||||
GenServer.cast(__MODULE__, %{action: :remove, socket: socket, topic: topic})
|
||||
end
|
||||
|
||||
def stream(topic, item) do
|
||||
GenServer.cast(__MODULE__, %{action: :stream, topic: topic, item: item})
|
||||
end
|
||||
|
||||
def handle_cast(%{action: :ping}, topics) do
|
||||
Map.values(topics)
|
||||
|> List.flatten
|
||||
|> Enum.each(fn (socket) ->
|
||||
Logger.debug("Sending keepalive ping")
|
||||
send socket.transport_pid, {:text, ""}
|
||||
end)
|
||||
spawn(fn ->
|
||||
Process.sleep(1000 * 30) # 30 seconds
|
||||
GenServer.cast(__MODULE__, %{action: :ping})
|
||||
end)
|
||||
{:noreply, topics}
|
||||
end
|
||||
|
||||
def handle_cast(%{action: :stream, topic: "user", item: %Notification{} = item}, topics) do
|
||||
topic = "user:#{item.user_id}"
|
||||
Enum.each(topics[topic] || [], fn (socket) ->
|
||||
json = %{
|
||||
event: "notification",
|
||||
payload: Pleroma.Web.MastodonAPI.MastodonAPIController.render_notification(socket.assigns["user"], item) |> Poison.encode!
|
||||
} |> Poison.encode!
|
||||
|
||||
send socket.transport_pid, {:text, json}
|
||||
end)
|
||||
{:noreply, topics}
|
||||
end
|
||||
|
||||
def handle_cast(%{action: :stream, topic: "user", item: item}, topics) do
|
||||
Logger.debug("Trying to push to users")
|
||||
recipient_topics = User.get_recipients_from_activity(item)
|
||||
|> Enum.map(fn (%{id: id}) -> "user:#{id}" end)
|
||||
|
||||
Enum.each(recipient_topics, fn (topic) ->
|
||||
push_to_socket(topics, topic, item)
|
||||
end)
|
||||
{:noreply, topics}
|
||||
end
|
||||
|
||||
def handle_cast(%{action: :stream, topic: topic, item: item}, topics) do
|
||||
Logger.debug("Trying to push to #{topic}")
|
||||
Logger.debug("Pushing item to #{topic}")
|
||||
push_to_socket(topics, topic, item)
|
||||
{:noreply, topics}
|
||||
end
|
||||
|
||||
def handle_cast(%{action: :add, topic: topic, socket: socket}, sockets) do
|
||||
topic = internal_topic(topic, socket)
|
||||
sockets_for_topic = sockets[topic] || []
|
||||
sockets_for_topic = Enum.uniq([socket | sockets_for_topic])
|
||||
sockets = Map.put(sockets, topic, sockets_for_topic)
|
||||
Logger.debug("Got new conn for #{topic}")
|
||||
IO.inspect(sockets)
|
||||
{:noreply, sockets}
|
||||
end
|
||||
|
||||
def handle_cast(%{action: :remove, topic: topic, socket: socket}, sockets) do
|
||||
topic = internal_topic(topic, socket)
|
||||
sockets_for_topic = sockets[topic] || []
|
||||
sockets_for_topic = List.delete(sockets_for_topic, socket)
|
||||
sockets = Map.put(sockets, topic, sockets_for_topic)
|
||||
Logger.debug("Removed conn for #{topic}")
|
||||
IO.inspect(sockets)
|
||||
{:noreply, sockets}
|
||||
end
|
||||
|
||||
def handle_cast(m, state) do
|
||||
IO.inspect("Unknown: #{inspect(m)}, #{inspect(state)}")
|
||||
{:noreply, state}
|
||||
end
|
||||
|
||||
def push_to_socket(topics, topic, item) do
|
||||
Enum.each(topics[topic] || [], fn (socket) ->
|
||||
json = %{
|
||||
event: "update",
|
||||
payload: Pleroma.Web.MastodonAPI.StatusView.render("status.json", activity: item, for: socket.assigns[:user]) |> Poison.encode!
|
||||
} |> Poison.encode!
|
||||
|
||||
send socket.transport_pid, {:text, json}
|
||||
end)
|
||||
end
|
||||
|
||||
defp internal_topic("user", socket) do
|
||||
"user:#{socket.assigns[:user].id}"
|
||||
end
|
||||
|
||||
defp internal_topic(topic, _), do: topic
|
||||
end
|
@ -0,0 +1,19 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang='en'>
|
||||
<head>
|
||||
<meta charset='utf-8'>
|
||||
<meta content='width=device-width, initial-scale=1' name='viewport'>
|
||||
<link rel="stylesheet" media="all" href="/packs/common.css" />
|
||||
<link rel="stylesheet" media="all" href="/packs/default.css" />
|
||||
<link rel="stylesheet" media="all" href="/packs/pl-dark-masto-fe.css" />
|
||||
|
||||
<script src="/packs/common.js"></script>
|
||||
<script src="/packs/locale_en.js"></script>
|
||||
<script id='initial-state' type='application/json'><%= raw @initial_state %></script>
|
||||
<script src="/packs/application.js"></script>
|
||||
</head>
|
||||
<body class='app-body'>
|
||||
<div class='app-holder' data-props='{"locale":"en"}' id='mastodon'>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
@ -0,0 +1,11 @@
|
||||
<h2>Login in to Mastodon Frontend</h2>
|
||||
<%= if @error do %>
|
||||
<h2><%= @error %></h2>
|
||||
<% end %>
|
||||
<%= form_for @conn, mastodon_api_path(@conn, :login), [as: "authorization"], fn f -> %>
|
||||
<%= text_input f, :name, placeholder: "Username" %>
|
||||
<br>
|
||||
<%= password_input f, :password, placeholder: "Password" %>
|
||||
<br>
|
||||
<%= submit "Log in" %>
|
||||
<% end %>
|
@ -0,0 +1 @@
|
||||
<h2>Invalid Token</h2>
|
@ -0,0 +1,12 @@
|
||||
<h2>Password Reset for <%= @user.nickname %></h2>
|
||||
<%= form_for @conn, util_path(@conn, :password_reset), [as: "data"], fn f -> %>
|
||||
<%= label f, :password, "Password" %>
|
||||
<%= password_input f, :password %>
|
||||
<br>
|
||||
|
||||
<%= label f, :password_confirmation, "Confirmation" %>
|
||||
<%= password_input f, :password_confirmation %>
|
||||
<br>
|
||||
<%= hidden_input f, :token, value: @token.token %>
|
||||
<%= submit "Reset" %>
|
||||
<% end %>
|
@ -0,0 +1 @@
|
||||
<h2>Password reset failed</h2>
|
@ -0,0 +1 @@
|
||||
<h2>Password changed!</h2>
|
@ -0,0 +1,4 @@
|
||||
defmodule Pleroma.Web.TwitterAPI.UtilView do
|
||||
use Pleroma.Web, :view
|
||||
import Phoenix.HTML.Form
|
||||
end
|
@ -0,0 +1,77 @@
|
||||
defmodule Phoenix.Transports.WebSocket.Raw do
|
||||
import Plug.Conn, only: [
|
||||
fetch_query_params: 1,
|
||||
send_resp: 3
|
||||
]
|
||||
alias Phoenix.Socket.Transport
|
||||
|
||||
def default_config do
|
||||
[
|
||||
timeout: 60_000,
|
||||
transport_log: false,
|
||||
cowboy: Phoenix.Endpoint.CowboyWebSocket
|
||||
]
|
||||
end
|
||||
|
||||
def init(%Plug.Conn{method: "GET"} = conn, {endpoint, handler, transport}) do
|
||||
{_, opts} = handler.__transport__(transport)
|
||||
|
||||
conn = conn
|
||||
|> fetch_query_params
|
||||
|> Transport.transport_log(opts[:transport_log])
|
||||
|> Transport.force_ssl(handler, endpoint, opts)
|
||||
|> Transport.check_origin(handler, endpoint, opts)
|
||||
|
||||
case conn do
|
||||
%{halted: false} = conn ->
|
||||
case Transport.connect(endpoint, handler, transport, __MODULE__, nil, conn.params) do
|
||||
{:ok, socket} ->
|
||||
{:ok, conn, {__MODULE__, {socket, opts}}}
|
||||
:error ->
|
||||
send_resp(conn, :forbidden, "")
|
||||
{:error, conn}
|
||||
end
|
||||
_ ->
|
||||
{:error, conn}
|
||||
end
|
||||
end
|
||||
|
||||
def init(conn, _) do
|
||||
send_resp(conn, :bad_request, "")
|
||||
{:error, conn}
|
||||
end
|
||||
|
||||
def ws_init({socket, config}) do
|
||||
Process.flag(:trap_exit, true)
|
||||
{:ok, %{socket: socket}, config[:timeout]}
|
||||
end
|
||||
|
||||
def ws_handle(op, data, state) do
|
||||
state.socket.handler
|
||||
|> apply(:handle, [op, data, state])
|
||||
|> case do
|
||||
{op, data} ->
|
||||
{:reply, {op, data}, state}
|
||||
{op, data, state} ->
|
||||
{:reply, {op, data}, state}
|
||||
%{} = state ->
|
||||
{:ok, state}
|
||||
_ ->
|
||||
{:ok, state}
|
||||
end
|
||||
end
|
||||
|
||||
def ws_info({_,_} = tuple, state) do
|
||||
{:reply, tuple, state}
|
||||
end
|
||||
|
||||
def ws_info(_tuple, state), do: {:ok, state}
|
||||
|
||||
def ws_close(state) do
|
||||
ws_handle(:closed, :normal, state)
|
||||
end
|
||||
|
||||
def ws_terminate(reason, state) do
|
||||
ws_handle(:closed, reason, state)
|
||||
end
|
||||
end
|
@ -0,0 +1,13 @@
|
||||
defmodule Pleroma.Repo.Migrations.CreatePasswordResetTokens do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
create table(:password_reset_tokens) do
|
||||
add :token, :string
|
||||
add :user_id, references(:users)
|
||||
add :used, :boolean, default: false
|
||||
|
||||
timestamps()
|
||||
end
|
||||
end
|
||||
end
|
@ -0,0 +1,10 @@
|
||||
defmodule Pleroma.Repo.Migrations.AddSecondObjectIndexToActivty do
|
||||
use Ecto.Migration
|
||||
|
||||
@disable_ddl_transaction true
|
||||
|
||||
def change do
|
||||
drop_if_exists index(:activities, ["(data->'object'->>'id')", "(data->>'type')"], name: :activities_create_objects_index)
|
||||
create index(:activities, ["(coalesce(data->'object'->>'id', data->>'object'))"], name: :activities_create_objects_index, concurrently: true)
|
||||
end
|
||||
end
|
@ -0,0 +1,7 @@
|
||||
defmodule Pleroma.Repo.Migrations.DropObjectIndex do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
drop_if_exists index(:objects, [:data], using: :gin)
|
||||
end
|
||||
end
|
@ -0,0 +1,9 @@
|
||||
defmodule Pleroma.Repo.Migrations.AddObjectActorIndex do
|
||||
use Ecto.Migration
|
||||
|
||||
@disable_ddl_transaction true
|
||||
|
||||
def change do
|
||||
create index(:objects, ["(data->>'actor')", "(data->>'type')"], concurrently: true, name: :objects_actor_type)
|
||||
end
|
||||
end
|
@ -0,0 +1,20 @@
|
||||
defmodule Pleroma.Repo.Migrations.AddActorToActivity do
|
||||
use Ecto.Migration
|
||||
|
||||
@disable_ddl_transaction true
|
||||
|
||||
def up do
|
||||
alter table(:activities) do
|
||||
add :actor, :string
|
||||
end
|
||||
|
||||
create index(:activities, [:actor, "id DESC NULLS LAST"], concurrently: true)
|
||||
end
|
||||
|
||||
def down do
|
||||
drop index(:activities, [:actor, "id DESC NULLS LAST"])
|
||||
alter table(:activities) do
|
||||
remove :actor
|
||||
end
|
||||
end
|
||||
end
|
@ -0,0 +1,26 @@
|
||||
defmodule Pleroma.Repo.Migrations.FillActorField do
|
||||
use Ecto.Migration
|
||||
|
||||
alias Pleroma.{Repo, Activity}
|
||||
|
||||
def up do
|
||||
max = Repo.aggregate(Activity, :max, :id)
|
||||
if max do
|
||||
IO.puts("#{max} activities")
|
||||
chunks = 0..(round(max / 10_000))
|
||||
|
||||
Enum.each(chunks, fn (i) ->
|
||||
min = i * 10_000
|
||||
max = min + 10_000
|
||||
execute("""
|
||||
update activities set actor = data->>'actor' where id > #{min} and id <= #{max};
|
||||
""")
|
||||
|> IO.inspect
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
def down do
|
||||
end
|
||||
end
|
||||
|
@ -0,0 +1,8 @@
|
||||
defmodule Pleroma.Repo.Migrations.AddSortIndexToActivities do
|
||||
use Ecto.Migration
|
||||
@disable_ddl_transaction true
|
||||
|
||||
def change do
|
||||
create index(:activities, ["id desc nulls last"], concurrently: true)
|
||||
end
|
||||
end
|
@ -0,0 +1,7 @@
|
||||
defmodule Pleroma.Repo.Migrations.AddLocalIndexToUser do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
create index(:users, [:local])
|
||||
end
|
||||
end
|
After Width: | Height: | Size: 3.2 KiB |
After Width: | Height: | Size: 4.3 KiB |
After Width: | Height: | Size: 1.6 KiB |
After Width: | Height: | Size: 1.7 KiB |
After Width: | Height: | Size: 1.5 KiB |
After Width: | Height: | Size: 1.5 KiB |
After Width: | Height: | Size: 2.2 KiB |
After Width: | Height: | Size: 1.9 KiB |
After Width: | Height: | Size: 2.8 KiB |
After Width: | Height: | Size: 3.1 KiB |
After Width: | Height: | Size: 1.8 KiB |
After Width: | Height: | Size: 2.8 KiB |
After Width: | Height: | Size: 2.1 KiB |
After Width: | Height: | Size: 2.1 KiB |
After Width: | Height: | Size: 2.5 KiB |
After Width: | Height: | Size: 2.4 KiB |
After Width: | Height: | Size: 2.0 KiB |
After Width: | Height: | Size: 32 KiB |
After Width: | Height: | Size: 9.6 KiB |
After Width: | Height: | Size: 1.5 KiB |
After Width: | Height: | Size: 7.9 KiB |
After Width: | Height: | Size: 2.5 KiB |
After Width: | Height: | Size: 7.9 KiB |
After Width: | Height: | Size: 20 KiB |
After Width: | Height: | Size: 1.3 KiB |
After Width: | Height: | Size: 4.1 KiB |
After Width: | Height: | Size: 2.8 KiB |
After Width: | Height: | Size: 4.5 KiB |
After Width: | Height: | Size: 8.1 KiB |
After Width: | Height: | Size: 1.3 KiB |
After Width: | Height: | Size: 5.3 KiB |
After Width: | Height: | Size: 2.4 KiB |