*.js.map binary
*.css binary
-priv/static/instance/static.css diff=css
-priv/static/static-fe/static-fe.css diff=css
+*.css diff=css
# docker stuff
docker-db
+*.iml
## Unreleased
+### Added
+- Prometheus metrics exporting from `/api/v1/akkoma/metrics`
+
### Removed
- Non-finch HTTP adapters
- Legacy redirect from /api/pleroma/admin to /api/v1/pleroma/admin
### Changed
- Return HTTP error 413 when uploading an avatar or banner that's above the configured upload limit instead of a 500.
+- Non-admin users now cannot register `admin` scope tokens (not security-critical, they didn't work before, but you _could_ create them)
### Upgrade notes
- Ensure `config :tesla, :adapter` is either unset, or set to `{Tesla.Adapter.Finch, name: MyFinch}` in your .exs config
profile_directory: true,
privileged_staff: false,
local_bubble: [],
- max_frontend_settings_json_chars: 100_000
+ max_frontend_settings_json_chars: 100_000,
+ export_prometheus_metrics: true
config :pleroma, :welcome,
direct_message: [
type: {:list, :string},
description:
"List of instances that make up your local bubble (closely-related instances). Used to populate the 'bubble' timeline (domain only)."
+ },
+ %{
+ key: :export_prometheus_metrics,
+ type: :boolean,
+ description: "Enable prometheus metrics (at /api/v1/akkoma/metrics)"
}
]
},
--- /dev/null
+# Monitoring Akkoma
+
+If you run akkoma, you may be inclined to collect metrics to ensure your instance is running smoothly,
+and that there's nothing quietly failing in the background.
+
+To facilitate this, akkoma exposes prometheus metrics to be scraped.
+
+## Prometheus
+
+See: [export_prometheus_metrics](../configuration/cheatsheet#instance)
+
+To scrape prometheus metrics, we need an oauth2 token with the `admin:metrics` scope.
+
+consider using [constanze](https://akkoma.dev/AkkomaGang/constanze) to make this easier -
+
+```bash
+constanze token --client-app --scopes "admin:metrics" --client-name "Prometheus"
+```
+
+or see `scripts/create_metrics_app.sh` in the source tree for the process to get this token.
+
+Once you have your token of the form `Bearer $ACCESS_TOKEN`, you can use that in your prometheus config:
+
+```yaml
+- job_name: akkoma
+ scheme: https
+ authorization:
+ credentials: $ACCESS_TOKEN # this should have the bearer prefix removed
+ metrics_path: /api/v1/akkoma/metrics
+ static_configs:
+ - targets:
+ - example.com
+```
\ No newline at end of file
* `password_reset_token_validity`: The time after which reset tokens aren't accepted anymore, in seconds (default: one day).
* `local_bubble`: Array of domains representing instances closely related to yours. Used to populate the `bubble` timeline. e.g `["example.com"]`, (default: `[]`)
* `languages`: List of Language Codes used by the instance. This is used to try and set a default language from the frontend. It will try and find the first match between the languages set here and the user's browser languages. It will default to the first language in this setting if there is no match.. (default `["en"]`)
+* `export_prometheus_metrics`: Enable prometheus metrics, served at `/api/v1/akkoma/metrics`, requiring the `admin:metrics` oauth scope.
## :database
* `improved_hashtag_timeline`: Setting to force toggle / force disable improved hashtags timeline. `:enabled` forces hashtags to be fetched from `hashtags` table for hashtags timeline. `:disabled` forces object-embedded hashtags to be used (slower). Keep it `:auto` for automatic behaviour (it is auto-set to `:enabled` [unless overridden] when HashtagsTableMigrator completes).
Pleroma.JobQueueMonitor,
{Majic.Pool, [name: Pleroma.MajicPool, pool_size: Config.get([:majic_pool, :size], 2)]},
{Oban, Config.get(Oban)},
- Pleroma.Web.Endpoint
+ Pleroma.Web.Endpoint,
+ Pleroma.Web.Telemetry
] ++
elasticsearch_children() ++
task_children(@mix_env) ++
options = put_in(options[:adapter], adapter_opts)
params = options[:params] || []
request = build_request(method, headers, options, url, body, params)
- client = Tesla.client([Tesla.Middleware.FollowRedirects])
+ client = Tesla.client([Tesla.Middleware.FollowRedirects, Tesla.Middleware.Telemetry])
request(client, request)
end
--- /dev/null
+defmodule Pleroma.Web.AkkomaAPI.MetricsController do
+ use Pleroma.Web, :controller
+
+ alias Pleroma.Web.Plugs.OAuthScopesPlug
+ alias Pleroma.Config
+
+ plug(
+ OAuthScopesPlug,
+ %{scopes: ["admin:metrics"]}
+ when action in [
+ :show
+ ]
+ )
+
+ def show(conn, _params) do
+ if Config.get([:instance, :export_prometheus_metrics], true) do
+ conn
+ |> text(TelemetryMetricsPrometheus.Core.scrape())
+ else
+ conn
+ |> send_resp(404, "Not Found")
+ end
+ end
+end
plug(Pleroma.Web.Plugs.SetLocalePlug)
plug(CORSPlug)
+ plug(Pleroma.Web.Plugs.CSPNoncePlug)
plug(Pleroma.Web.Plugs.HTTPSecurityPlug)
plug(Pleroma.Web.Plugs.UploadedMedia)
{:error, scopes_issue},
%{"authorization" => _} = params
)
- when scopes_issue in [:unsupported_scopes, :missing_scopes] do
+ when scopes_issue in [:unsupported_scopes, :missing_scopes, :user_is_not_an_admin] do
# Per https://github.com/tootsuite/mastodon/blob/
# 51e154f5e87968d6bb115e053689767ab33e80cd/app/controllers/api/base_controller.rb#L39
conn
- |> put_flash(:error, dgettext("errors", "This action is outside the authorized scopes"))
+ |> put_flash(:error, dgettext("errors", "This action is outside of authorized scopes"))
|> put_status(:unauthorized)
|> authorize(params)
end
defp do_create_authorization(%User{} = user, %App{} = app, requested_scopes)
when is_list(requested_scopes) do
with {:account_status, :active} <- {:account_status, User.account_status(user)},
- {:ok, scopes} <- validate_scopes(app, requested_scopes),
+ {:ok, scopes} <- validate_scopes(user, app, requested_scopes),
{:ok, auth} <- Authorization.create_authorization(app, user, scopes) do
{:ok, auth}
end
end
end
- @spec validate_scopes(App.t(), map() | list()) ::
+ @spec validate_scopes(User.t(), App.t(), map() | list()) ::
{:ok, list()} | {:error, :missing_scopes | :unsupported_scopes}
- defp validate_scopes(%App{} = app, params) when is_map(params) do
+ defp validate_scopes(%User{} = user, %App{} = app, params) when is_map(params) do
requested_scopes = Scopes.fetch_scopes(params, app.scopes)
- validate_scopes(app, requested_scopes)
+ validate_scopes(user, app, requested_scopes)
end
- defp validate_scopes(%App{} = app, requested_scopes) when is_list(requested_scopes) do
- Scopes.validate(requested_scopes, app.scopes)
+ defp validate_scopes(%User{} = user, %App{} = app, requested_scopes)
+ when is_list(requested_scopes) do
+ Scopes.validate(requested_scopes, app.scopes, user)
end
def default_redirect_uri(%App{} = app) do
@doc """
Validates scopes.
"""
- @spec validate(list() | nil, list()) ::
- {:ok, list()} | {:error, :missing_scopes | :unsupported_scopes}
- def validate(blank_scopes, _app_scopes) when blank_scopes in [nil, []],
+ @spec validate(list() | nil, list(), Pleroma.User.t()) ::
+ {:ok, list()} | {:error, :missing_scopes | :unsupported_scopes, :user_is_not_an_admin}
+ def validate(blank_scopes, _app_scopes, _user) when blank_scopes in [nil, []],
do: {:error, :missing_scopes}
- def validate(scopes, app_scopes) do
+ def validate(scopes, app_scopes, %Pleroma.User{is_admin: is_admin}) do
+ if !is_admin && contains_admin_scopes?(scopes) do
+ {:error, :user_is_not_an_admin}
+ else
+ validate_scopes_are_supported(scopes, app_scopes)
+ end
+ end
+
+ defp validate_scopes_are_supported(scopes, app_scopes) do
case OAuthScopesPlug.filter_descendants(scopes, app_scopes) do
^scopes -> {:ok, scopes}
_ -> {:error, :unsupported_scopes}
--- /dev/null
+defmodule Pleroma.Web.Plugs.CSPNoncePlug do
+ import Plug.Conn
+
+ def init(opts) do
+ opts
+ end
+
+ def call(conn, _opts) do
+ assign_csp_nonce(conn)
+ end
+
+ defp assign_csp_nonce(conn) do
+ nonce =
+ :crypto.strong_rand_bytes(128)
+ |> Base.url_encode64()
+ |> binary_part(0, 15)
+
+ conn
+ |> assign(:csp_nonce, nonce)
+ end
+end
def call(conn, _options) do
if Config.get([:http_security, :enabled]) do
conn
- |> merge_resp_headers(headers())
+ |> merge_resp_headers(headers(conn))
|> maybe_send_sts_header(Config.get([:http_security, :sts]))
else
conn
end
end
- def headers do
+ @spec headers(Plug.Conn.t()) :: [{String.t(), String.t()}]
+ def headers(conn) do
referrer_policy = Config.get([:http_security, :referrer_policy])
report_uri = Config.get([:http_security, :report_uri])
custom_http_frontend_headers = custom_http_frontend_headers()
{"x-frame-options", "DENY"},
{"x-content-type-options", "nosniff"},
{"referrer-policy", referrer_policy},
- {"content-security-policy", csp_string()},
+ {"content-security-policy", csp_string(conn)},
{"permissions-policy", "interest-cohort=()"}
]
"default-src 'none'",
"base-uri 'none'",
"frame-ancestors 'none'",
- "style-src 'self' 'unsafe-inline'",
- "font-src 'self'",
"manifest-src 'self'"
]
@csp_start [Enum.join(static_csp_rules, ";") <> ";"]
- defp csp_string do
+ defp csp_string(conn) do
scheme = Config.get([Pleroma.Web.Endpoint, :url])[:scheme]
static_url = Pleroma.Web.Endpoint.static_url()
websocket_url = Pleroma.Web.Endpoint.websocket_url()
report_uri = Config.get([:http_security, :report_uri])
-
+ %{assigns: %{csp_nonce: nonce}} = conn
+ nonce_tag = "nonce-" <> nonce
img_src = "img-src 'self' data: blob:"
media_src = "media-src 'self'"
["connect-src 'self' blob: ", static_url, ?\s, websocket_url]
end
+ style_src = "style-src 'self' 'unsafe-inline'"
+ font_src = "font-src 'self' data:"
+
script_src =
if Config.get(:env) == :dev do
- "script-src 'self' 'unsafe-eval'"
+ "script-src 'self' 'unsafe-eval' '#{nonce_tag}'"
else
- "script-src 'self'"
+ "script-src 'self' '#{nonce_tag}'"
end
report = if report_uri, do: ["report-uri ", report_uri, ";report-to csp-endpoint"]
|> add_csp_param(media_src)
|> add_csp_param(connect_src)
|> add_csp_param(script_src)
+ |> add_csp_param(font_src)
+ |> add_csp_param(style_src)
|> add_csp_param(insecure)
|> add_csp_param(report)
|> :erlang.iolist_to_binary()
})
end
- defp ip(%{remote_ip: remote_ip}) do
+ defp ip(%{remote_ip: remote_ip}) when is_binary(remote_ip) do
+ remote_ip
+ end
+
+ defp ip(%{remote_ip: remote_ip}) when is_tuple(remote_ip) do
remote_ip
|> Tuple.to_list()
|> Enum.join(".")
end
+ defp ip(_), do: nil
+
defp render_throttled_error(conn) do
conn
|> render_error(:too_many_requests, "Throttled")
defmodule Pleroma.Web.Preload do
alias Phoenix.HTML
- def build_tags(_conn, params) do
+ def build_tags(%{assigns: %{csp_nonce: nonce}} = conn, params) do
preload_data =
Enum.reduce(Pleroma.Config.get([__MODULE__, :providers], []), %{}, fn parser, acc ->
terms =
rendered_html =
preload_data
|> Jason.encode!()
- |> build_script_tag()
+ |> build_script_tag(nonce)
|> HTML.safe_to_string()
rendered_html
end
- def build_script_tag(content) do
+ def build_script_tag(content, nonce) do
HTML.Tag.content_tag(:script, HTML.raw(content),
id: "initial-results",
- type: "application/json"
+ type: "application/json",
+ nonce: nonce
)
end
end
scope "/api/v1/akkoma", Pleroma.Web.AkkomaAPI do
pipe_through(:authenticated_api)
+ get("/metrics", MetricsController, :show)
get("/translation/languages", TranslationController, :languages)
get("/frontend_settings/:frontend_name", FrontendSettingsController, :list_profiles)
scope "/" do
pipe_through([:pleroma_html, :authenticate, :require_admin])
- live_dashboard("/phoenix/live_dashboard")
+
+ live_dashboard("/phoenix/live_dashboard",
+ metrics: {Pleroma.Web.Telemetry, :live_dashboard_metrics},
+ csp_nonce_assign_key: :csp_nonce
+ )
end
# Test-only routes needed to test action dispatching and plug chain execution
scope "/", Pleroma.Web.Fallback do
get("/registration/:token", RedirectController, :registration_page)
get("/:maybe_nickname_or_id", RedirectController, :redirector_with_meta)
+ get("/api/*path", RedirectController, :api_not_implemented)
get("/*path", RedirectController, :redirector_with_preload)
options("/*path", RedirectController, :empty)
--- /dev/null
+defmodule Pleroma.Web.Telemetry do
+ use Supervisor
+ import Telemetry.Metrics
+ alias Pleroma.Stats
+
+ def start_link(arg) do
+ Supervisor.start_link(__MODULE__, arg, name: __MODULE__)
+ end
+
+ @impl true
+ def init(_arg) do
+ children = [
+ {:telemetry_poller, measurements: periodic_measurements(), period: 10_000},
+ {TelemetryMetricsPrometheus.Core, metrics: prometheus_metrics()}
+ ]
+
+ Supervisor.init(children, strategy: :one_for_one)
+ end
+
+ # A seperate set of metrics for distributions because phoenix dashboard does NOT handle them well
+ defp distribution_metrics do
+ [
+ distribution(
+ "phoenix.router_dispatch.stop.duration",
+ # event_name: [:pleroma, :repo, :query, :total_time],
+ measurement: :duration,
+ unit: {:native, :second},
+ tags: [:route],
+ reporter_options: [
+ buckets: [0.1, 0.2, 0.5, 1, 2.5, 5, 10, 25, 50, 100, 250, 500, 1000]
+ ]
+ ),
+
+ # Database Time Metrics
+ distribution(
+ "pleroma.repo.query.total_time",
+ # event_name: [:pleroma, :repo, :query, :total_time],
+ measurement: :total_time,
+ unit: {:native, :millisecond},
+ reporter_options: [
+ buckets: [0.1, 0.2, 0.5, 1, 2.5, 5, 10, 25, 50, 100, 250, 500, 1000]
+ ]
+ ),
+ distribution(
+ "pleroma.repo.query.queue_time",
+ # event_name: [:pleroma, :repo, :query, :total_time],
+ measurement: :queue_time,
+ unit: {:native, :millisecond},
+ reporter_options: [
+ buckets: [0.01, 0.025, 0.05, 0.1, 0.2, 0.5, 1, 2.5, 5, 10]
+ ]
+ ),
+ distribution(
+ "oban_job_exception",
+ event_name: [:oban, :job, :exception],
+ measurement: :duration,
+ tags: [:worker],
+ tag_values: fn tags -> Map.put(tags, :worker, tags.job.worker) end,
+ unit: {:native, :second},
+ reporter_options: [
+ buckets: [0.01, 0.025, 0.05, 0.1, 0.2, 0.5, 1, 2.5, 5, 10]
+ ]
+ ),
+ distribution(
+ "tesla_request_completed",
+ event_name: [:tesla, :request, :stop],
+ measurement: :duration,
+ tags: [:response_code],
+ tag_values: fn tags -> Map.put(tags, :response_code, tags.env.status) end,
+ unit: {:native, :second},
+ reporter_options: [
+ buckets: [0.01, 0.025, 0.05, 0.1, 0.2, 0.5, 1, 2.5, 5, 10]
+ ]
+ ),
+ distribution(
+ "oban_job_completion",
+ event_name: [:oban, :job, :stop],
+ measurement: :duration,
+ tags: [:worker],
+ tag_values: fn tags -> Map.put(tags, :worker, tags.job.worker) end,
+ unit: {:native, :second},
+ reporter_options: [
+ buckets: [0.01, 0.025, 0.05, 0.1, 0.2, 0.5, 1, 2.5, 5, 10]
+ ]
+ )
+ ]
+ end
+
+ defp summary_metrics do
+ [
+ # Phoenix Metrics
+ summary("phoenix.endpoint.stop.duration",
+ unit: {:native, :millisecond}
+ ),
+ summary("phoenix.router_dispatch.stop.duration",
+ tags: [:route],
+ unit: {:native, :millisecond}
+ ),
+ summary("pleroma.repo.query.total_time", unit: {:native, :millisecond}),
+ summary("pleroma.repo.query.decode_time", unit: {:native, :millisecond}),
+ summary("pleroma.repo.query.query_time", unit: {:native, :millisecond}),
+ summary("pleroma.repo.query.queue_time", unit: {:native, :millisecond}),
+ summary("pleroma.repo.query.idle_time", unit: {:native, :millisecond}),
+
+ # VM Metrics
+ summary("vm.memory.total", unit: {:byte, :kilobyte}),
+ summary("vm.total_run_queue_lengths.total"),
+ summary("vm.total_run_queue_lengths.cpu"),
+ summary("vm.total_run_queue_lengths.io"),
+ last_value("pleroma.local_users.total"),
+ last_value("pleroma.domains.total"),
+ last_value("pleroma.local_statuses.total")
+ ]
+ end
+
+ def prometheus_metrics, do: summary_metrics() ++ distribution_metrics()
+ def live_dashboard_metrics, do: summary_metrics()
+
+ defp periodic_measurements do
+ [
+ {__MODULE__, :instance_stats, []}
+ ]
+ end
+
+ def instance_stats do
+ stats = Stats.get_stats()
+ :telemetry.execute([:pleroma, :local_users], %{total: stats.user_count}, %{})
+ :telemetry.execute([:pleroma, :domains], %{total: stats.domain_count}, %{})
+ :telemetry.execute([:pleroma, :local_statuses], %{total: stats.status_count}, %{})
+ end
+end
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,minimal-ui">
<title><%= Pleroma.Config.get([:instance, :name]) %></title>
- <link rel="stylesheet" href="/instance/static.css">
+ <link rel="stylesheet" href="/static-fe/static-fe.css">
+ <link rel="stylesheet" href="/static-fe/forms.css">
</head>
<body>
- <div class="instance-header">
- <a class="instance-header__content" href="/">
- <img class="instance-header__thumbnail" src="<%= Pleroma.Config.get([:instance, :instance_thumbnail]) %>">
- <h1 class="instance-header__title"><%= Pleroma.Config.get([:instance, :name]) %></h1>
- </a>
- </div>
+
+ <div class="background-image"></div>
+ <nav>
+ <div class="inner-nav">
+ <a class="site-brand" href="/">
+ <img class="favicon" src="/favicon.png" />
+ <span><%= Pleroma.Config.get([:instance, :name]) %></span>
+ </a>
+ </div>
+ </nav>
<div class="container">
- <%= @inner_content %>
+ <div class="underlay"></div>
+ <div class="column main flex">
+ <div class="panel oauth">
+ <%= @inner_content %>
+ </div>
+ </div>
</div>
</body>
+
+ <style>
+ :root {
+ --background-image: url("<%= Pleroma.Config.get([:instance, :background_image]) %>");
+ }
+ </style>
</html>
</nav>
<div class="container">
<div class="underlay"></div>
- <div class="column main">
- <%= @inner_content %>
+ <div class="column main">
+ <%= @inner_content %>
</div>
<div class="column sidebar">
<div class="about panel">
-<%= if get_flash(@conn, :info) do %>
-<p class="alert alert-info" role="alert"><%= get_flash(@conn, :info) %></p>
-<% end %>
-<%= if get_flash(@conn, :error) do %>
-<p class="alert alert-danger" role="alert"><%= get_flash(@conn, :error) %></p>
-<% end %>
+<div>
+ <%= if get_flash(@conn, :info) do %>
+ <p class="alert alert-info" role="alert"><%= get_flash(@conn, :info) %></p>
+ <% end %>
+ <%= if get_flash(@conn, :error) do %>
+ <p class="alert alert-danger" role="alert"><%= get_flash(@conn, :error) %></p>
+ <% end %>
+ <div class="panel-heading">
+ <%= Gettext.dpgettext("static_pages", "mfa recover page title", "Two-factor recovery") %>
+ </div>
+ <div class="panel-content">
+ <%= form_for @conn, Routes.mfa_verify_path(@conn, :verify), [as: "mfa"], fn f -> %>
+ <div class="input">
+ <%= label f, :code, Gettext.dpgettext("static_pages", "mfa recover recovery code prompt", "Recovery code") %>
+ <%= text_input f, :code, [autocomplete: false, autocorrect: "off", autocapitalize: "off", autofocus: true, spellcheck: false] %>
+ <%= hidden_input f, :mfa_token, value: @mfa_token %>
+ <%= hidden_input f, :state, value: @state %>
+ <%= hidden_input f, :redirect_uri, value: @redirect_uri %>
+ <%= hidden_input f, :challenge_type, value: "recovery" %>
+ </div>
-<h2><%= Gettext.dpgettext("static_pages", "mfa recover page title", "Two-factor recovery") %></h2>
+ <%= submit Gettext.dpgettext("static_pages", "mfa recover verify recovery code button", "Verify") %>
+ <% end %>
+ <a href="<%= Routes.mfa_path(@conn, :show, %{challenge_type: "totp", mfa_token: @mfa_token, state: @state, redirect_uri: @redirect_uri}) %>">
+ <%= Gettext.dpgettext("static_pages", "mfa recover use 2fa code link", "Enter a two-factor code") %>
+ </a>
-<%= form_for @conn, Routes.mfa_verify_path(@conn, :verify), [as: "mfa"], fn f -> %>
-<div class="input">
- <%= label f, :code, Gettext.dpgettext("static_pages", "mfa recover recovery code prompt", "Recovery code") %>
- <%= text_input f, :code, [autocomplete: false, autocorrect: "off", autocapitalize: "off", autofocus: true, spellcheck: false] %>
- <%= hidden_input f, :mfa_token, value: @mfa_token %>
- <%= hidden_input f, :state, value: @state %>
- <%= hidden_input f, :redirect_uri, value: @redirect_uri %>
- <%= hidden_input f, :challenge_type, value: "recovery" %>
+ </div>
</div>
-
-<%= submit Gettext.dpgettext("static_pages", "mfa recover verify recovery code button", "Verify") %>
-<% end %>
-<a href="<%= Routes.mfa_path(@conn, :show, %{challenge_type: "totp", mfa_token: @mfa_token, state: @state, redirect_uri: @redirect_uri}) %>">
- <%= Gettext.dpgettext("static_pages", "mfa recover use 2fa code link", "Enter a two-factor code") %>
-</a>
-<%= if get_flash(@conn, :info) do %>
-<p class="alert alert-info" role="alert"><%= get_flash(@conn, :info) %></p>
-<% end %>
-<%= if get_flash(@conn, :error) do %>
-<p class="alert alert-danger" role="alert"><%= get_flash(@conn, :error) %></p>
-<% end %>
+<div>
+ <%= if get_flash(@conn, :info) do %>
+ <p class="alert alert-info" role="alert"><%= get_flash(@conn, :info) %></p>
+ <% end %>
+ <%= if get_flash(@conn, :error) do %>
+ <p class="alert alert-danger" role="alert"><%= get_flash(@conn, :error) %></p>
+ <% end %>
+ <div class="panel-heading">
+ <%= Gettext.dpgettext("static_pages", "mfa auth page title", "Two-factor authentication") %>
+ </div>
+ <div class="panel-content">
+ <%= form_for @conn, Routes.mfa_verify_path(@conn, :verify), [as: "mfa"], fn f -> %>
+ <div class="input">
+ <%= label f, :code, Gettext.dpgettext("static_pages", "mfa auth code prompt", "Authentication code") %>
+ <%= text_input f, :code, [autocomplete: "one-time-code", autocorrect: "off", autocapitalize: "off", autofocus: true, pattern: "[0-9]*", spellcheck: false] %>
+ <%= hidden_input f, :mfa_token, value: @mfa_token %>
+ <%= hidden_input f, :state, value: @state %>
+ <%= hidden_input f, :redirect_uri, value: @redirect_uri %>
+ <%= hidden_input f, :challenge_type, value: "totp" %>
+ </div>
-<h2><%= Gettext.dpgettext("static_pages", "mfa auth page title", "Two-factor authentication") %></h2>
-
-<%= form_for @conn, Routes.mfa_verify_path(@conn, :verify), [as: "mfa"], fn f -> %>
-<div class="input">
- <%= label f, :code, Gettext.dpgettext("static_pages", "mfa auth code prompt", "Authentication code") %>
- <%= text_input f, :code, [autocomplete: "one-time-code", autocorrect: "off", autocapitalize: "off", autofocus: true, pattern: "[0-9]*", spellcheck: false] %>
- <%= hidden_input f, :mfa_token, value: @mfa_token %>
- <%= hidden_input f, :state, value: @state %>
- <%= hidden_input f, :redirect_uri, value: @redirect_uri %>
- <%= hidden_input f, :challenge_type, value: "totp" %>
+ <%= submit Gettext.dpgettext("static_pages", "mfa auth verify code button", "Verify") %>
+ <% end %>
+ <a href="<%= Routes.mfa_path(@conn, :show, %{challenge_type: "recovery", mfa_token: @mfa_token, state: @state, redirect_uri: @redirect_uri}) %>">
+ <%= Gettext.dpgettext("static_pages", "mfa auth page use recovery code link", "Enter a two-factor recovery code") %>
+ </a>
+ </div>
</div>
-
-<%= submit Gettext.dpgettext("static_pages", "mfa auth verify code button", "Verify") %>
-<% end %>
-<a href="<%= Routes.mfa_path(@conn, :show, %{challenge_type: "recovery", mfa_token: @mfa_token, state: @state, redirect_uri: @redirect_uri}) %>">
- <%= Gettext.dpgettext("static_pages", "mfa auth page use recovery code link", "Enter a two-factor recovery code") %>
-</a>
-<h1><%= Gettext.dpgettext("static_pages", "oauth authorized page title", "Successfully authorized") %></h1>
-<h2><%= raw Gettext.dpgettext("static_pages", "oauth token code message", "Token code is <br>%{token}", token: safe_to_string(html_escape(@auth.token))) %></h2>
+<div>
+ <div class="panel-heading">
+ <%= Gettext.dpgettext("static_pages", "oauth authorized page title", "Successfully authorized") %>
+ </div>
+ <div class="panel-content">
+ <%= raw Gettext.dpgettext("static_pages", "oauth token code message", "Token code is <br>%{token}", token: safe_to_string(html_escape(@auth.token))) %>
+ </div>
+</div>
-<h1><%= Gettext.dpgettext("static_pages", "oauth authorization exists page title", "Authorization exists") %></h1>
-<h2><%= raw Gettext.dpgettext("static_pages", "oauth token code message", "Token code is <br>%{token}", token: safe_to_string(html_escape(@token.token))) %></h2>
+<div>
+ <div class="panel-heading">
+ <%= Gettext.dpgettext("static_pages", "oauth authorization exists page title", "Authorization exists") %>
+ </div>
+ <div class="panel-content">
+ <%= raw Gettext.dpgettext("static_pages", "oauth token code message", "Token code is <br>%{token}", token: safe_to_string(html_escape(@token.token))) %>
+ </div>
+</div>
<%= if @user do %>
<div class="account-header">
<div class="account-header__banner" style="background-image: url('<%= Pleroma.User.banner_url(@user) %>')"></div>
- <div class="account-header__avatar" style="background-image: url('<%= Pleroma.User.avatar_url(@user) %>')"></div>
- <div class="account-header__meta">
+ <div class="account-header__avatar" style="background-image: url('<%= Pleroma.User.avatar_url(@user) %>')">
+ <div class="account-header__meta">
<div class="account-header__display-name"><%= @user.name %></div>
<div class="account-header__nickname">@<%= @user.nickname %>@<%= Pleroma.User.get_host(@user) %></div>
</div>
+ </div>
+
</div>
<% end %>
<div class="container__content">
<%= if @app do %>
- <p><%= raw Gettext.dpgettext("static_pages", "oauth authorize message", "Application <strong>%{client_name}</strong> is requesting access to your account.", client_name: safe_to_string(html_escape(@app.client_name))) %></p>
- <%= render @view_module, "_scopes.html", Map.merge(assigns, %{form: f}) %>
+ <div class="panel-heading">
+ <p><%= raw Gettext.dpgettext("static_pages", "oauth authorize message", "Application <strong>%{client_name}</strong> is requesting access to your account.", client_name: safe_to_string(html_escape(@app.client_name))) %></p>
+ </div>
<% end %>
- <%= if @user do %>
- <div class="actions">
- <a class="button button--cancel" href="/">
- <%= Gettext.dpgettext("static_pages", "oauth authorize cancel button", "Cancel") %>
- </a>
- <%= submit Gettext.dpgettext("static_pages", "oauth authorize approve button", "Approve"), class: "button--approve" %>
- </div>
- <% else %>
- <%= if @params["registration"] in ["true", true] do %>
- <h3><%= Gettext.dpgettext("static_pages", "oauth register page title", "This is the first time you visit! Please enter your Pleroma handle.") %></h3>
- <p><%= Gettext.dpgettext("static_pages", "oauth register nickname unchangeable warning", "Choose carefully! You won't be able to change this later. You will be able to change your display name, though.") %></p>
- <div class="input">
- <%= label f, :nickname, Gettext.dpgettext("static_pages", "oauth register nickname prompt", "Pleroma Handle") %>
- <%= text_input f, :nickname, placeholder: "lain", autocomplete: "username" %>
+ <div class="panel-content">
+ <%= render @view_module, "_scopes.html", Map.merge(assigns, %{form: f}) %>
+ <%= if @user do %>
+ <div class="actions">
+ <a class="button button-cancel" href="/">
+ <%= Gettext.dpgettext("static_pages", "oauth authorize cancel button", "Cancel") %>
+ </a>
+ <%= submit Gettext.dpgettext("static_pages", "oauth authorize approve button", "Approve"), class: "button--approve" %>
</div>
- <%= hidden_input f, :name, value: @params["name"] %>
- <%= hidden_input f, :password, value: @params["password"] %>
- <br>
<% else %>
- <div class="input">
- <%= label f, :name, Gettext.dpgettext("static_pages", "oauth login username prompt", "Username") %>
- <%= text_input f, :name %>
- </div>
- <div class="input">
- <%= label f, :password, Gettext.dpgettext("static_pages", "oauth login password prompt", "Password") %>
- <%= password_input f, :password %>
- </div>
- <%= submit Gettext.dpgettext("static_pages", "oauth login button", "Log In") %>
+ <%= if @params["registration"] in ["true", true] do %>
+ <h3><%= Gettext.dpgettext("static_pages", "oauth register page title", "This is your first visit! Please enter your Akkoma handle.") %></h3>
+ <p><%= Gettext.dpgettext("static_pages", "oauth register nickname unchangeable warning", "Choose carefully! You won't be able to change this later. You will be able to change your display name, though.") %></p>
+ <div class="input">
+ <%= label f, :nickname, Gettext.dpgettext("static_pages", "oauth register nickname prompt", "Pleroma Handle") %>
+ <%= text_input f, :nickname, placeholder: "lain", autocomplete: "username" %>
+ </div>
+ <%= hidden_input f, :name, value: @params["name"] %>
+ <%= hidden_input f, :password, value: @params["password"] %>
+ <br>
+ <% else %>
+ <div class="input">
+ <%= label f, :name, Gettext.dpgettext("static_pages", "oauth login username prompt", "Username") %>
+ <%= text_input f, :name %>
+ </div>
+ <div class="input">
+ <%= label f, :password, Gettext.dpgettext("static_pages", "oauth login password prompt", "Password") %>
+ <%= password_input f, :password %>
+ </div>
+ <%= submit Gettext.dpgettext("static_pages", "oauth login button", "Log In") %>
+ <% end %>
<% end %>
- <% end %>
+ </div>
</div>
<%= hidden_input f, :client_id, value: @client_id %>
git: "https://akkoma.dev/AkkomaGang/linkify.git", branch: "bugfix/line-ending-buffer"},
{:http_signatures, "~> 0.1.1"},
{:telemetry, "~> 0.3"},
+ {:telemetry_poller, "~> 0.4"},
+ {:telemetry_metrics, "~> 0.4"},
+ {:telemetry_metrics_prometheus_core, "~> 1.1.0"},
{:poolboy, "~> 1.5"},
{:recon, "~> 2.5"},
{:joken, "~> 2.0"},
"table_rex": {:hex, :table_rex, "3.1.1", "0c67164d1714b5e806d5067c1e96ff098ba7ae79413cc075973e17c38a587caa", [:mix], [], "hexpm", "678a23aba4d670419c23c17790f9dcd635a4a89022040df7d5d772cb21012490"},
"telemetry": {:hex, :telemetry, "0.4.3", "a06428a514bdbc63293cd9a6263aad00ddeb66f608163bdec7c8995784080818", [:rebar3], [], "hexpm", "eb72b8365ffda5bed68a620d1da88525e326cb82a75ee61354fc24b844768041"},
"telemetry_metrics": {:hex, :telemetry_metrics, "0.6.1", "315d9163a1d4660aedc3fee73f33f1d355dcc76c5c3ab3d59e76e3edf80eef1f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7be9e0871c41732c233be71e4be11b96e56177bf15dde64a8ac9ce72ac9834c6"},
+ "telemetry_metrics_prometheus": {:hex, :telemetry_metrics_prometheus, "1.1.0", "1cc23e932c1ef9aa3b91db257ead31ea58d53229d407e059b29bb962c1505a13", [:mix], [{:plug_cowboy, "~> 2.1", [hex: :plug_cowboy, repo: "hexpm", optional: false]}, {:telemetry_metrics_prometheus_core, "~> 1.0", [hex: :telemetry_metrics_prometheus_core, repo: "hexpm", optional: false]}], "hexpm", "d43b3659b3244da44fe0275b717701542365d4519b79d9ce895b9719c1ce4d26"},
+ "telemetry_metrics_prometheus_core": {:hex, :telemetry_metrics_prometheus_core, "1.1.0", "4e15f6d7dbedb3a4e3aed2262b7e1407f166fcb9c30ca3f96635dfbbef99965c", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "0dd10e7fe8070095df063798f82709b0a1224c31b8baf6278b423898d591a069"},
+ "telemetry_poller": {:hex, :telemetry_poller, "0.5.1", "21071cc2e536810bac5628b935521ff3e28f0303e770951158c73eaaa01e962a", [:rebar3], [{:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "4cab72069210bc6e7a080cec9afffad1b33370149ed5d379b81c7c5f0c663fd4"},
"temple": {:git, "https://akkoma.dev/AkkomaGang/temple.git", "066a699ade472d8fa42a9d730b29a61af9bc8b59", [ref: "066a699ade472d8fa42a9d730b29a61af9bc8b59"]},
"tesla": {:hex, :tesla, "1.4.4", "bb89aa0c9745190930366f6a2ac612cdf2d0e4d7fff449861baa7875afd797b2", [:mix], [{:castore, "~> 0.1", [hex: :castore, repo: "hexpm", optional: true]}, {:exjsx, ">= 3.0.0", [hex: :exjsx, repo: "hexpm", optional: true]}, {:finch, "~> 0.3", [hex: :finch, repo: "hexpm", optional: true]}, {:fuse, "~> 2.4", [hex: :fuse, repo: "hexpm", optional: true]}, {:gun, "~> 1.3", [hex: :gun, repo: "hexpm", optional: true]}, {:hackney, "~> 1.6", [hex: :hackney, repo: "hexpm", optional: true]}, {:ibrowse, "4.4.0", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:poison, ">= 1.0.0", [hex: :poison, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "d5503a49f9dec1b287567ea8712d085947e247cb11b06bc54adb05bfde466457"},
"timex": {:hex, :timex, "3.7.9", "790cdfc4acfce434e442f98c02ea6d84d0239073bfd668968f82ac63e9a6788d", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.10", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 1.1", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm", "64691582e5bb87130f721fc709acfb70f24405833998fabf35be968984860ce1"},
--- /dev/null
+form {
+ width: 100%;
+}
+
+.input {
+ color: var(--muted-text-color);
+ display: flex;
+ margin-left: 1em;
+ margin-right: 1em;
+ flex-direction: column;
+}
+
+input {
+ padding: 10px;
+ margin-top: 5px;
+ margin-bottom: 10px;
+ background-color: var(--background-color);
+ color: var(--primary-text-color);
+ border: 0;
+ transition-property: border-bottom;
+ transition-duration: 0.35s;
+ border-bottom: 2px solid #2a384a;
+ font-size: 14px;
+ width: inherit;
+ box-sizing: border-box;
+}
+
+.scopes-input {
+ display: flex;
+ flex-direction: column;
+ margin: 1em 0;
+ color: var(--muted-text-color);
+}
+
+.scopes-input label:first-child {
+ height: 2em;
+}
+
+.scopes {
+ display: flex;
+ flex-wrap: wrap;
+ color: var(--primary-text-color);
+}
+
+.scope {
+ display: flex;
+ flex-basis: 100%;
+ height: 2em;
+ align-items: center;
+}
+
+.scope:before {
+ color: var(--primary-text-color);
+ content: "✔\fe0e";
+ margin-left: 1em;
+ margin-right: 1em;
+}
+
+[type="checkbox"]+label {
+ display: none;
+ cursor: pointer;
+ margin: 0.5em;
+}
+
+[type="checkbox"] {
+ display: none;
+}
+
+[type="checkbox"]+label:before {
+ cursor: pointer;
+ display: inline-block;
+ color: white;
+ background-color: var(--background-color);
+ border: 4px solid var(--background-color);
+ box-shadow: 0px 0px 1px 0 var(--brand-color);
+ width: 1.2em;
+ height: 1.2em;
+ margin-right: 1.0em;
+ content: "";
+ transition-property: background-color;
+ transition-duration: 0.35s;
+ color: var(--background-color);
+ margin-bottom: -0.2em;
+ border-radius: 2px;
+}
+
+[type="checkbox"]:checked+label:before {
+ background-color: var(--brand-color);
+}
+
+a.button,
+button {
+ width: 100%;
+ background-color: #1c2a3a;
+ color: var(--primary-text-color);
+ border-radius: 4px;
+ border: none;
+ padding: 10px 16px;
+ margin-top: 20px;
+ margin-bottom: 20px;
+ text-transform: uppercase;
+ font-size: 16px;
+ box-shadow: 0px 0px 2px 0px black,
+ 0px 1px 0px 0px rgba(255, 255, 255, 0.2) inset,
+ 0px -1px 0px 0px rgba(0, 0, 0, 0.2) inset;
+}
+
+a.button:hover,
+button:hover {
+ cursor: pointer;
+ box-shadow: 0px 0px 0px 1px var(--brand-color),
+ 0px 1px 0px 0px rgba(255, 255, 255, 0.2) inset,
+ 0px -1px 0px 0px rgba(0, 0, 0, 0.2) inset;
+}
+
+.actions {
+ display: flex;
+ flex-grow: 1;
+}
+
+.actions button,
+.actions a.button {
+ width: auto;
+ margin-left: 2%;
+ width: 45%;
+ text-align: center;
+}
+
+.account-header__banner {
+ width: 100%;
+ height: 80px;
+ background-size: cover;
+ background-position: center;
+}
+
+.account-header__avatar {
+ width: 64px;
+ height: 64px;
+ background-size: cover;
+ background-position: center;
+ margin: -60px 10px 10px;
+ border: 6px solid var(--foreground-color);
+ border-radius: 999px;
+}
+
+.account-header__meta {
+ padding: 12px 20px 17px 70px;
+}
+
+.account-header__display-name {
+ font-size: 20px;
+ font-weight: bold;
+}
+
+.account-header__nickname {
+ font-size: 14px;
+ color: var(--muted-text-color);
+}
\ No newline at end of file
--profileTint: rgba(15, 22, 30, 0.5);
--btnText: rgba(185, 185, 186, 1);
--btn: rgba(21, 30, 43, 1);
- --btnShadow: 0px 0px 2px 0px rgba(0, 0, 0, 1) , 0px 1px 0px 0px rgba(255, 255, 255, 0.2) inset, 0px -1px 0px 0px rgba(0, 0, 0, 0.2) inset;
+ --btnShadow: 0px 0px 2px 0px rgba(0, 0, 0, 1), 0px 1px 0px 0px rgba(255, 255, 255, 0.2) inset, 0px -1px 0px 0px rgba(0, 0, 0, 0.2) inset;
--btnHoverShadow: 0px 0px 1px 2px rgba(185, 185, 186, 0.4) inset, 0px 1px 0px 0px rgba(255, 255, 255, 0.2) inset, 0px -1px 0px 0px rgba(0, 0, 0, 0.2) inset;
--lightText: rgba(236, 236, 236, 1);
- --panelShadow: 0px 0px 3px 0px rgba(0, 0, 0, 0.5) , 0px 4px 6px 3px rgba(0, 0, 0, 0.3);
- --panelHeaderShadow: 0px 1px 3px 0px rgba(0, 0, 0, 0.4) , 0px 1px 0px 0px rgba(255, 255, 255, 0.2) inset;
+ --panelShadow: 0px 0px 3px 0px rgba(0, 0, 0, 0.5), 0px 4px 6px 3px rgba(0, 0, 0, 0.3);
+ --panelHeaderShadow: 0px 1px 3px 0px rgba(0, 0, 0, 0.4), 0px 1px 0px 0px rgba(255, 255, 255, 0.2) inset;
--topBar: rgba(21, 30, 43, 1);
--topBarText: rgba(159, 159, 161, 1);
- --topBarShadow: 0px 1px 4px 0px rgba(0, 0, 0, 0.4) , 0px 2px 7px 0px rgba(0, 0, 0, 0.3);
+ --topBarShadow: 0px 1px 4px 0px rgba(0, 0, 0, 0.4), 0px 2px 7px 0px rgba(0, 0, 0, 0.3);
--underlay: rgba(9, 14, 20, 0.6);
--background: rgba(15, 22, 30, 1);
--faint: rgba(185, 185, 186, 0.5);
--border: rgba(26, 37, 53, 1);
--poll: rgba(99, 84, 72, 1);
}
+
@media (prefers-color-scheme: light) {
:root {
- --icon-filter: invert(67%) sepia(7%) saturate(525%) hue-rotate(173deg) brightness(90%) contrast(92%);;
+ --icon-filter: invert(67%) sepia(7%) saturate(525%) hue-rotate(173deg) brightness(90%) contrast(92%);
+ ;
--wallpaper: rgba(248, 250, 252, 1);
--alertNeutral: rgba(48, 64, 85, 0.5);
--alertNeutralText: rgba(0, 0, 0, 1);
--profileTint: rgba(242, 246, 249, 0.5);
--btnText: rgba(48, 64, 85, 1);
--btn: rgba(214, 223, 237, 1);
- --btnShadow: 0px 0px 2px 0px rgba(0, 0, 0, 0.2) , 0px 1px 0px 0px rgba(255, 255, 255, 0.5) inset, 0px -1px 0px 0px rgba(0, 0, 0, 0.2) inset;
- --btnHoverShadow: 0px 0px 2px 0px rgba(0, 0, 0, 0.2) , 0px 0px 1px 2px rgba(255, 195, 159, 1) inset, 0px -1px 0px 0px rgba(0, 0, 0, 0.2) inset;
+ --btnShadow: 0px 0px 2px 0px rgba(0, 0, 0, 0.2), 0px 1px 0px 0px rgba(255, 255, 255, 0.5) inset, 0px -1px 0px 0px rgba(0, 0, 0, 0.2) inset;
+ --btnHoverShadow: 0px 0px 2px 0px rgba(0, 0, 0, 0.2), 0px 0px 1px 2px rgba(255, 195, 159, 1) inset, 0px -1px 0px 0px rgba(0, 0, 0, 0.2) inset;
--lightText: rgba(11, 14, 19, 1);
- --panelShadow: 0px 1px 3px 0px rgba(0, 0, 0, 0.5) , 0px 3px 6px 1px rgba(0, 0, 0, 0.2);
+ --panelShadow: 0px 1px 3px 0px rgba(0, 0, 0, 0.5), 0px 3px 6px 1px rgba(0, 0, 0, 0.2);
--panelHeaderShadow: 0px 1px 0px 0px rgba(255, 255, 255, 0.5) inset, 0px 1px 3px 0px rgba(0, 0, 0, 0.3);
--topBar: rgba(214, 223, 237, 1);
--topBarText: rgba(48, 64, 85, 1);
padding-right: 5px
}
-body > .container {
+body>.container {
display: grid;
grid-template-columns: minmax(25em, 45em) 25em;
grid-template-areas: "content sidebar";
box-shadow: var(--panelHeaderShadow);
}
+.panel-content {
+ padding: 1em;
+}
+
.about-content {
padding: 0.6em;
}
padding-left: 0.5em;
}
+.column.flex {
+ grid-column-end: sidebar-end;
+}
+
+.scopes-input {
+ display: flex;
+ flex-direction: column;
+ margin: 1em 0;
+ color: var(--muted-text-color);
+}
+
+
.status-container,
.repeat-header,
.user-card {
.repeat-header .right-side {
color: var(--faint);
}
+
.repeat-header .u-photo {
height: 20px;
width: 20px;
.reply-to-link {
color: var(--faint);
}
+
.reply-to-link:hover {
text-decoration: underline;
}
margin-bottom: 8px;
}
-header a, .h-card a {
+header a,
+.h-card a {
text-decoration: none;
}
-header a:hover, .h-card a:hover {
+header a:hover,
+.h-card a:hover {
text-decoration: underline;
}
min-width: 0;
}
-.attachment > * {
+.attachment>* {
width: 100%;
object-fit: contain;
}
display: flex;
align-items: center;
}
+
.nsfw-banner div {
width: 100%;
text-align: center;
.nsfw-banner:not(:hover) {
background-color: var(--background);
}
+
.nsfw-banner:hover div {
display: none;
}
word-break: break-word;
z-index: 1;
}
+
.poll-option .percentage {
width: 3.5em;
flex-shrink: 0;
}
+
.poll-option .fill {
height: 100%;
position: absolute;
display: flex;
margin-top: 0.75em;
}
-.status-actions > * {
+
+.status-actions>* {
max-width: 4em;
flex: 1;
display: flex;
right: 0;
bottom: 0;
background-image: linear-gradient(to bottom, var(--profileTint), var(--profileTint)),
- var(--user-banner);
+ var(--user-banner);
background-size: cover;
background-color: var(--profileBg);
-webkit-mask: linear-gradient(to top, white, transparent) bottom no-repeat,
- linear-gradient(to top, white, white);
+ linear-gradient(to top, white, white);
-webkit-mask-composite: xor;
-webkit-mask-size: 100% 60%;
z-index: -2;
}
@media (max-width: 800px) {
- body > .container {
+ body>.container {
display: block;
}
.username img:not(.u-photo) {
width: 16px;
height: 16px;
-}
+}
\ No newline at end of file
--- /dev/null
+#!/bin/sh
+
+read -p "Instance URL (e.g https://example.com): " INSTANCE_URL
+
+echo "Creating oauth app..."
+
+RESP=$(curl \
+ -XPOST \
+ $INSTANCE_URL/api/v1/apps \
+ --silent \
+ --data-urlencode 'client_name=fedibash' \
+ --data-urlencode 'redirect_uris=urn:ietf:wg:oauth:2.0:oob' \
+ --data-urlencode 'scopes=admin:metrics' \
+ --header "Content-Type: application/x-www-form-urlencoded"
+)
+
+client_id=$(echo $RESP | jq -r .client_id)
+client_secret=$(echo $RESP | jq -r .client_secret)
+
+if [ -z "$client_id" ]; then
+ echo "Could not create an app"
+ echo "$RESP"
+ exit 1
+fi
+
+echo "Please visit the following URL and input the code provided"
+AUTH_URL="$INSTANCE_URL/oauth/authorize?client_id=$client_id&redirect_uri=urn:ietf:wg:oauth:2.0:oob&scope=admin:metrics&response_type=code"
+if [ ! -z "$BROWSER" ]; then
+ $BROWSER $AUTH_URL
+fi;
+
+echo $AUTH_URL
+
+read -p "Code: " CODE
+
+echo "Requesting code..."
+
+RESP=$(curl \
+ -XPOST \
+ $INSTANCE_URL/oauth/token \
+ --silent \
+ --header "Content-Type: application/x-www-form-urlencoded" \
+ --data-urlencode "client_id=$client_id" \
+ --data-urlencode "client_secret=$client_secret" \
+ --data-urlencode "code=$CODE" \
+ --data-urlencode "grant_type=authorization_code" \
+ --data-urlencode 'redirect_uri=urn:ietf:wg:oauth:2.0:oob' \
+ --data-urlencode "scope=admin:metrics"
+)
+echo $RESP
+ACCESS_TOKEN="$(echo $RESP | jq -r .access_token)"
+
+echo "Token is $ACCESS_TOKEN"
+DOMAIN=$(echo $INSTANCE_URL | sed -e 's/^https:\/\///')
+
+echo "Use the following config in your prometheus.yml:
+- job_name: akkoma
+ scheme: https
+ authorization:
+ credentials: $ACCESS_TOKEN
+ metrics_path: /api/v1/akkoma/metrics
+ static_configs:
+ - targets:
+ - $DOMAIN
+"
--- /dev/null
+defmodule Pleroma.Web.AkkomaAPI.MetricsControllerTest do
+ use Pleroma.Web.ConnCase, async: true
+
+ describe "GET /api/v1/akkoma/metrics" do
+ test "should return metrics when the user has admin:metrics" do
+ %{conn: conn} = oauth_access(["admin:metrics"])
+
+ resp =
+ conn
+ |> get("/api/v1/akkoma/metrics")
+ |> text_response(200)
+
+ assert resp =~ "# HELP"
+ end
+
+ test "should not allow users that do not have the admin:metrics scope" do
+ %{conn: conn} = oauth_access(["read:metrics"])
+
+ conn
+ |> get("/api/v1/akkoma/metrics")
+ |> json_response(403)
+ end
+
+ test "should be disabled by export_prometheus_metrics" do
+ clear_config([:instance, :export_prometheus_metrics], false)
+ %{conn: conn} = oauth_access(["admin:metrics"])
+
+ conn
+ |> get("/api/v1/akkoma/metrics")
+ |> response(404)
+ end
+ end
+end
note_activity = insert(:note_activity)
user = User.get_cached_by_ap_id(note_activity.data["actor"])
+ %{assigns: %{csp_nonce: nonce}} = resp_conn = get(conn, "/users/#{user.nickname}")
+
response =
- conn
- |> get("/users/#{user.nickname}")
+ resp_conn
|> response(200)
assert response ==
Pleroma.Web.Fallback.RedirectController.redirector_with_meta(
- conn,
+ assign(conn, :csp_nonce, nonce),
%{user: user}
).resp_body
end
describe "POST /oauth/authorize" do
test "redirects with oauth authorization, " <>
- "granting requested app-supported scopes to both admin- and non-admin users" do
+ "granting requested app-supported scopes to both admin users" do
app_scopes = ["read", "write", "admin", "secret_scope"]
app = insert(:oauth_app, scopes: app_scopes)
redirect_uri = OAuthController.default_redirect_uri(app)
+ scopes_subset = ["read:subscope", "write", "admin"]
+ admin = insert(:user, is_admin: true)
+
+ # In case scope param is missing, expecting _all_ app-supported scopes to be granted
+ conn =
+ post(
+ build_conn(),
+ "/oauth/authorize",
+ %{
+ "authorization" => %{
+ "name" => admin.nickname,
+ "password" => "test",
+ "client_id" => app.client_id,
+ "redirect_uri" => redirect_uri,
+ "scope" => scopes_subset,
+ "state" => "statepassed"
+ }
+ }
+ )
+
+ target = redirected_to(conn)
+ assert target =~ redirect_uri
+
+ query = URI.parse(target).query |> URI.query_decoder() |> Map.new()
+
+ assert %{"state" => "statepassed", "code" => code} = query
+ auth = Repo.get_by(Authorization, token: code)
+ assert auth
+ assert auth.scopes == scopes_subset
+ end
+
+ test "redirects with oauth authorization, " <>
+ "granting requested app-supported scopes for non-admin users" do
+ app_scopes = ["read", "write", "secret_scope", "admin"]
+ app = insert(:oauth_app, scopes: app_scopes)
+ redirect_uri = OAuthController.default_redirect_uri(app)
non_admin = insert(:user, is_admin: false)
- admin = insert(:user, is_admin: true)
- scopes_subset = ["read:subscope", "write", "admin"]
+ scopes_subset = ["read:subscope", "write"]
# In case scope param is missing, expecting _all_ app-supported scopes to be granted
- for user <- [non_admin, admin],
- {requested_scopes, expected_scopes} <-
- %{scopes_subset => scopes_subset, nil: app_scopes} do
- conn =
- post(
- build_conn(),
- "/oauth/authorize",
- %{
- "authorization" => %{
- "name" => user.nickname,
- "password" => "test",
- "client_id" => app.client_id,
- "redirect_uri" => redirect_uri,
- "scope" => requested_scopes,
- "state" => "statepassed"
- }
+ conn =
+ post(
+ build_conn(),
+ "/oauth/authorize",
+ %{
+ "authorization" => %{
+ "name" => non_admin.nickname,
+ "password" => "test",
+ "client_id" => app.client_id,
+ "redirect_uri" => redirect_uri,
+ "scope" => scopes_subset,
+ "state" => "statepassed"
}
- )
+ }
+ )
- target = redirected_to(conn)
- assert target =~ redirect_uri
+ target = redirected_to(conn)
+ assert target =~ redirect_uri
- query = URI.parse(target).query |> URI.query_decoder() |> Map.new()
+ query = URI.parse(target).query |> URI.query_decoder() |> Map.new()
- assert %{"state" => "statepassed", "code" => code} = query
- auth = Repo.get_by(Authorization, token: code)
- assert auth
- assert auth.scopes == expected_scopes
- end
+ assert %{"state" => "statepassed", "code" => code} = query
+ auth = Repo.get_by(Authorization, token: code)
+ assert auth
+ assert auth.scopes == scopes_subset
end
test "authorize from cookie" do
assert result =~ "Invalid Username/Password"
end
+ test "returns 401 when attempting to use an admin scope with a non-admin", %{conn: conn} do
+ user = insert(:user)
+ app = insert(:oauth_app, scopes: ["admin"])
+ redirect_uri = OAuthController.default_redirect_uri(app)
+
+ result =
+ conn
+ |> post("/oauth/authorize", %{
+ "authorization" => %{
+ "name" => user.nickname,
+ "password" => "test",
+ "client_id" => app.client_id,
+ "redirect_uri" => redirect_uri,
+ "state" => "statepassed",
+ "scope" => Enum.join(app.scopes, " ")
+ }
+ })
+ |> html_response(:unauthorized)
+
+ # Keep the details
+ assert result =~ app.client_id
+ assert result =~ redirect_uri
+
+ # Error message
+ assert result =~ "outside of authorized scopes"
+ end
+
test "returns 401 for missing scopes" do
user = insert(:user, is_admin: false)
app = insert(:oauth_app, scopes: ["read", "write", "admin"])
assert result =~ redirect_uri
# Error message
- assert result =~ "This action is outside the authorized scopes"
+ assert result =~ "This action is outside of authorized scopes"
end
test "returns 401 for scopes beyond app scopes hierarchy", %{conn: conn} do
assert result =~ redirect_uri
# Error message
- assert result =~ "This action is outside the authorized scopes"
+ assert result =~ "This action is outside of authorized scopes"
end
end
%Pleroma.Web.OAuth.App{
client_name: sequence(:client_name, &"Some client #{&1}"),
redirect_uris: "https://example.com/callback",
- scopes: ["read", "write", "follow", "push", "admin"],
+ scopes: ["read", "write", "follow", "push"],
website: "https://example.com",
client_id: Ecto.UUID.generate(),
client_secret: "aaa;/&bbb"