Merge pull request 'metrics' (#375) from stats into develop
authorfloatingghost <hannah@coffee-and-dreams.uk>
Fri, 16 Dec 2022 12:34:16 +0000 (12:34 +0000)
committerfloatingghost <hannah@coffee-and-dreams.uk>
Fri, 16 Dec 2022 12:34:16 +0000 (12:34 +0000)
Reviewed-on: https://akkoma.dev/AkkomaGang/akkoma/pulls/375

35 files changed:
.gitattributes
.gitignore
CHANGELOG.md
config/config.exs
config/description.exs
docs/docs/administration/monitoring.md [new file with mode: 0644]
docs/docs/configuration/cheatsheet.md
lib/pleroma/application.ex
lib/pleroma/http.ex
lib/pleroma/web/akkoma_api/controllers/metrics_controller.ex [new file with mode: 0644]
lib/pleroma/web/endpoint.ex
lib/pleroma/web/o_auth/o_auth_controller.ex
lib/pleroma/web/o_auth/scopes.ex
lib/pleroma/web/plugs/csp_nonce_plug.ex [new file with mode: 0644]
lib/pleroma/web/plugs/http_security_plug.ex
lib/pleroma/web/plugs/rate_limiter.ex
lib/pleroma/web/preload.ex
lib/pleroma/web/router.ex
lib/pleroma/web/telemetry.ex [new file with mode: 0644]
lib/pleroma/web/templates/layout/app.html.eex
lib/pleroma/web/templates/layout/static_fe.html.eex
lib/pleroma/web/templates/o_auth/mfa/recovery.html.eex
lib/pleroma/web/templates/o_auth/mfa/totp.html.eex
lib/pleroma/web/templates/o_auth/o_auth/oob_authorization_created.html.eex
lib/pleroma/web/templates/o_auth/o_auth/oob_token_exists.html.eex
lib/pleroma/web/templates/o_auth/o_auth/show.html.eex
mix.exs
mix.lock
priv/static/static-fe/forms.css [new file with mode: 0644]
priv/static/static-fe/static-fe.css
scripts/create_metrics_app.sh [new file with mode: 0755]
test/pleroma/web/akkoma_api/metrics_controller_test.exs [new file with mode: 0644]
test/pleroma/web/feed/user_controller_test.exs
test/pleroma/web/o_auth/o_auth_controller_test.exs
test/support/factory.ex

index 7273afe43e9bec216cf5e6ff20e3a0d205fe09b0..ac67c53c24f55f13fe5dd141589f091d6d5a5bb9 100644 (file)
@@ -7,5 +7,4 @@
 *.js.map binary
 *.css binary
 
-priv/static/instance/static.css diff=css
-priv/static/static-fe/static-fe.css diff=css
+*.css diff=css
index 14373fb8c557f12924e3bc3b683fa61a9db12547..95b236af62e286baa85790f5d0b8b51d31e6c984 100644 (file)
@@ -76,3 +76,4 @@ docs/site
 
 # docker stuff
 docker-db
+*.iml
index 0ec7e29b3e2516f5c2c067617098f6c3177a154d..f673adc9e3e5a747928f8a850a2f09bfa055739b 100644 (file)
@@ -6,12 +6,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
 
 ## 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
index a0176a72df9e60700d2ddb5cc28bd6fa1ff0e650..cb19edde32ad3e1afb80d29043f2d11dd094b3f2 100644 (file)
@@ -259,7 +259,8 @@ config :pleroma, :instance,
   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: [
index 4d4306fba7eba0b6fe6ae01e073e43795d35d6a1..1059039e7afaf77c050c0268563b2465ae9d6dd8 100644 (file)
@@ -964,6 +964,11 @@ config :pleroma, :config_description, [
         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)"
       }
     ]
   },
diff --git a/docs/docs/administration/monitoring.md b/docs/docs/administration/monitoring.md
new file mode 100644 (file)
index 0000000..fceb8c3
--- /dev/null
@@ -0,0 +1,33 @@
+# 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
index d812750434229ee93a1522c213aa2ab2caa3d805..22fc4ecbef851e14ca66ecf161ab37d7c5a1b163 100644 (file)
@@ -62,6 +62,7 @@ To add configuration to your config file, you can copy it from the base config.
 * `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).
index 48a2623ceb8e74c84868f932b196a55885dd1db4..02336d6d1f26acb957c8393c3e8e3e96f461ea24 100644 (file)
@@ -73,7 +73,8 @@ defmodule Pleroma.Application do
           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) ++
index d8028651c881034c9d6cbe3a11bdbf3cc366e5a6..6ae1cdebb02bfdf8c211bddb087e9b76018e9272 100644 (file)
@@ -65,7 +65,7 @@ defmodule Pleroma.HTTP do
     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
diff --git a/lib/pleroma/web/akkoma_api/controllers/metrics_controller.ex b/lib/pleroma/web/akkoma_api/controllers/metrics_controller.ex
new file mode 100644 (file)
index 0000000..cc7a616
--- /dev/null
@@ -0,0 +1,24 @@
+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
index baf0c5651ee43b80e19f37f308c423870ded69a2..e3a251ca196be2138a60a8c832915f8aec0e748c 100644 (file)
@@ -13,6 +13,7 @@ defmodule Pleroma.Web.Endpoint do
 
   plug(Pleroma.Web.Plugs.SetLocalePlug)
   plug(CORSPlug)
+  plug(Pleroma.Web.Plugs.CSPNoncePlug)
   plug(Pleroma.Web.Plugs.HTTPSecurityPlug)
   plug(Pleroma.Web.Plugs.UploadedMedia)
 
index 8f32e7219965f719a4984fa12a26a36fa6c66f82..3943ca44973e67884f5a64212f8e332637081947 100644 (file)
@@ -211,11 +211,11 @@ defmodule Pleroma.Web.OAuth.OAuthController do
          {: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
@@ -605,7 +605,7 @@ defmodule Pleroma.Web.OAuth.OAuthController do
   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
@@ -637,15 +637,16 @@ defmodule Pleroma.Web.OAuth.OAuthController do
     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
index ada43eae9df4ebb1894f6524c4106a63f737d940..7fe04b9127fd9905bb01b5738a58b33c57ed643f 100644 (file)
@@ -56,12 +56,20 @@ defmodule Pleroma.Web.OAuth.Scopes 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}
diff --git a/lib/pleroma/web/plugs/csp_nonce_plug.ex b/lib/pleroma/web/plugs/csp_nonce_plug.ex
new file mode 100644 (file)
index 0000000..bc2c6fc
--- /dev/null
@@ -0,0 +1,21 @@
+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
index 47874a980147561439e1894a4bcddd2134edc4b3..5f0b775bea9e1025b28bdbc03111308743f7c02a 100644 (file)
@@ -13,7 +13,7 @@ defmodule Pleroma.Web.Plugs.HTTPSecurityPlug do
   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
@@ -36,7 +36,8 @@ defmodule Pleroma.Web.Plugs.HTTPSecurityPlug do
     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()
@@ -47,7 +48,7 @@ defmodule Pleroma.Web.Plugs.HTTPSecurityPlug do
       {"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=()"}
     ]
 
@@ -77,19 +78,18 @@ defmodule Pleroma.Web.Plugs.HTTPSecurityPlug do
     "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'"
 
@@ -111,11 +111,14 @@ defmodule Pleroma.Web.Plugs.HTTPSecurityPlug do
         ["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"]
@@ -126,6 +129,8 @@ defmodule Pleroma.Web.Plugs.HTTPSecurityPlug do
     |> 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()
index 5bebe0ad53fa2ba7d28ddd681eea2a055e0c2955..3c82654b4eb264859c5675d20007a8426a6c9330 100644 (file)
@@ -197,12 +197,18 @@ defmodule Pleroma.Web.Plugs.RateLimiter do
     })
   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")
index 34a181e17dae558aa446fea7bc668feff7628180..e554965a26a12f42498ca952103f1835db85fabf 100644 (file)
@@ -5,7 +5,7 @@
 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 =
@@ -20,16 +20,17 @@ defmodule Pleroma.Web.Preload do
     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
index e790b1cdb221581364ab77ef17c10c7c6766d8bf..f47041b0b8cc1eb1757072e8ce511fb658ea1070 100644 (file)
@@ -467,6 +467,7 @@ defmodule Pleroma.Web.Router do
 
   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)
@@ -867,7 +868,11 @@ defmodule Pleroma.Web.Router do
 
   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
@@ -906,6 +911,7 @@ defmodule Pleroma.Web.Router do
   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)
diff --git a/lib/pleroma/web/telemetry.ex b/lib/pleroma/web/telemetry.ex
new file mode 100644 (file)
index 0000000..5b01ee1
--- /dev/null
@@ -0,0 +1,131 @@
+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
index e33bada858e979173d92ef45e65c55bf3f077340..31e6ec52bd067f6d308d984739481626fc045e31 100644 (file)
@@ -4,17 +4,33 @@
     <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>
index 3d55393f0f846bf8b9f63acb45d4f07dd50ddce4..d159eb901cd1dc25020d56ff37baa6f834e7883e 100644 (file)
@@ -20,8 +20,8 @@
     </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">
index e45d13bdfae20f1c9e920c4c0512f17c6b9ab4ba..ee40cf2774256c3dfd816021dbb836f2915a5f8f 100644 (file)
@@ -1,24 +1,29 @@
-<%= 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>
index 50e6c04b64dd7d96776e66372a80e40cef6be42b..734e621123769615aa8adb5e74bff6b82938cd14 100644 (file)
@@ -1,24 +1,28 @@
-<%= 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>
index 76ed3fda5e7c0f36decbb3219a8bef08285098d4..17e54fb4287ce6744afc59264a87503c318ba7c5 100644 (file)
@@ -1,2 +1,8 @@
-<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>
index 754bf2eb0959085cf4d6add5663a82fe2432a310..11671fa1c349dba48153fa4964e105aa4f5f8147 100644 (file)
@@ -1,2 +1,8 @@
-<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>
index a2f41618e66f0ec0f3f637a10162966ae542ff1d..986e6ffcecc1c5b31d4362e78096f0dc5484c4ce 100644 (file)
 <%= 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 %>
diff --git a/mix.exs b/mix.exs
index 00a250002cb272dd93ec936c4dc74c7fe8265561..4898591b1d79eb4256256f9ae92de4ec1d7d34a6 100644 (file)
--- a/mix.exs
+++ b/mix.exs
@@ -161,6 +161,9 @@ defmodule Pleroma.Mixfile do
        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"},
index 0e3ac3514ad22fffa317b9f6a8259529ff9a999c..4fa4c05ec999032daa5ccb4ae9204b0b63354f6c 100644 (file)
--- a/mix.lock
+++ b/mix.lock
   "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"},
diff --git a/priv/static/static-fe/forms.css b/priv/static/static-fe/forms.css
new file mode 100644 (file)
index 0000000..196713e
--- /dev/null
@@ -0,0 +1,158 @@
+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
index 657556077f3a06687670005a6e7e389c29dbc746..606c07d7ed1ef402d82d1ede156ca83f3afcfa9b 100644 (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);
@@ -119,7 +121,7 @@ nav {
     padding-right: 5px
 }
 
-body > .container {
+body>.container {
     display: grid;
     grid-template-columns: minmax(25em, 45em) 25em;
     grid-template-areas: "content sidebar";
@@ -155,6 +157,10 @@ body > .container {
     box-shadow: var(--panelHeaderShadow);
 }
 
+.panel-content {
+    padding: 1em;
+}
+
 .about-content {
     padding: 0.6em;
 }
@@ -169,6 +175,18 @@ body > .container {
     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 {
@@ -193,6 +211,7 @@ body > .container {
 .repeat-header .right-side {
     color: var(--faint);
 }
+
 .repeat-header .u-photo {
     height: 20px;
     width: 20px;
@@ -255,6 +274,7 @@ body > .container {
 .reply-to-link {
     color: var(--faint);
 }
+
 .reply-to-link:hover {
     text-decoration: underline;
 }
@@ -280,11 +300,13 @@ body > .container {
     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;
 }
 
@@ -307,7 +329,7 @@ header a:hover, .h-card a:hover {
     min-width: 0;
 }
 
-.attachment > * {
+.attachment>* {
     width: 100%;
     object-fit: contain;
 }
@@ -322,6 +344,7 @@ header a:hover, .h-card a:hover {
     display: flex;
     align-items: center;
 }
+
 .nsfw-banner div {
     width: 100%;
     text-align: center;
@@ -330,6 +353,7 @@ header a:hover, .h-card a:hover {
 .nsfw-banner:not(:hover) {
     background-color: var(--background);
 }
+
 .nsfw-banner:hover div {
     display: none;
 }
@@ -342,10 +366,12 @@ header a:hover, .h-card a:hover {
     word-break: break-word;
     z-index: 1;
 }
+
 .poll-option .percentage {
     width: 3.5em;
     flex-shrink: 0;
 }
+
 .poll-option .fill {
     height: 100%;
     position: absolute;
@@ -362,7 +388,8 @@ header a:hover, .h-card a:hover {
     display: flex;
     margin-top: 0.75em;
 }
-.status-actions > * {
+
+.status-actions>* {
     max-width: 4em;
     flex: 1;
     display: flex;
@@ -458,11 +485,11 @@ summary {
     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;
@@ -600,7 +627,7 @@ summary {
 }
 
 @media (max-width: 800px) {
-    body > .container {
+    body>.container {
         display: block;
     }
 
@@ -624,4 +651,4 @@ img:not(.u-photo, .fa-icon) {
 .username img:not(.u-photo) {
     width: 16px;
     height: 16px;
-}
+}
\ No newline at end of file
diff --git a/scripts/create_metrics_app.sh b/scripts/create_metrics_app.sh
new file mode 100755 (executable)
index 0000000..c42f5dd
--- /dev/null
@@ -0,0 +1,65 @@
+#!/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
+"
diff --git a/test/pleroma/web/akkoma_api/metrics_controller_test.exs b/test/pleroma/web/akkoma_api/metrics_controller_test.exs
new file mode 100644 (file)
index 0000000..9482f13
--- /dev/null
@@ -0,0 +1,33 @@
+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
index 6e3f790b2f0ed1890266b78081bd97ddbfa39857..451ce45aa24cdb7fa804712957ff4547fcbac88c 100644 (file)
@@ -184,14 +184,15 @@ defmodule Pleroma.Web.Feed.UserControllerTest do
       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
index 5a1258ec31b460302dfcb1d04be838470ef8d8a7..d3cc0acb2a39316a058804af3a12268563310644 100644 (file)
@@ -693,45 +693,76 @@ defmodule Pleroma.Web.OAuth.OAuthControllerTest do
 
   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
@@ -831,6 +862,33 @@ defmodule Pleroma.Web.OAuth.OAuthControllerTest 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"])
@@ -855,7 +913,7 @@ defmodule Pleroma.Web.OAuth.OAuthControllerTest 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
 
     test "returns 401 for scopes beyond app scopes hierarchy", %{conn: conn} do
@@ -882,7 +940,7 @@ defmodule Pleroma.Web.OAuth.OAuthControllerTest 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
 
index 808f8f8879e9148bca2dd0e63e6dd5f0c8fed596..84e076137401a17e834eb21c39dba7e94d343c02 100644 (file)
@@ -554,7 +554,7 @@ defmodule Pleroma.Factory do
     %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"