diff --git a/changelog.d/cache-control-immutable.add b/changelog.d/cache-control-immutable.add new file mode 100644 index 000000000..516db67bf --- /dev/null +++ b/changelog.d/cache-control-immutable.add @@ -0,0 +1 @@ +Add immutable tag on cache-control header for several endpoints that's serving the same exact things. \ No newline at end of file diff --git a/lib/pleroma/constants.ex b/lib/pleroma/constants.ex index f78b84099..178dd6094 100644 --- a/lib/pleroma/constants.ex +++ b/lib/pleroma/constants.ex @@ -29,7 +29,7 @@ defmodule Pleroma.Constants do const(static_only_files, do: - ~w(index.html robots.txt static static-fe finmoji emoji packs sounds images instance sw.js sw-pleroma.js favicon.png schemas doc embed.js embed.css) + ~w(index.html robots.txt static static-fe finmoji emoji packs sounds images instance sw.js sw-pleroma.js schemas doc embed.js embed.css) ) const(status_updatable_fields, diff --git a/lib/pleroma/reverse_proxy.ex b/lib/pleroma/reverse_proxy.ex index bb55a4984..c7ee47c6e 100644 --- a/lib/pleroma/reverse_proxy.ex +++ b/lib/pleroma/reverse_proxy.ex @@ -12,7 +12,7 @@ defmodule Pleroma.ReverseProxy do @keep_resp_headers @resp_cache_headers ++ ~w(content-length content-type content-disposition content-encoding) ++ ~w(content-range accept-ranges vary) - @default_cache_control_header "public, max-age=1209600" + @default_cache_control_header "public, max-age=1209600, immutable" @valid_resp_codes [200, 206, 304] @max_read_duration :timer.seconds(30) @max_body_length :infinity diff --git a/lib/pleroma/web/endpoint.ex b/lib/pleroma/web/endpoint.ex index bab3c9fd0..81a9d3a09 100644 --- a/lib/pleroma/web/endpoint.ex +++ b/lib/pleroma/web/endpoint.ex @@ -46,8 +46,9 @@ defmodule Pleroma.Web.Endpoint do plug(Pleroma.Web.Plugs.HTTPSecurityPlug) plug(Pleroma.Web.Plugs.UploadedMedia) - @static_cache_control "public, max-age=1209600" + @static_cache_control "public, max-age=1209600, immutable" @static_cache_disabled "public, no-cache" + @favicon_cache_control "public, max=age=86400, immutable" # cache for a day # InstanceStatic needs to be before Plug.Static to be able to override shipped-static files # If you're adding new paths to `only:` you'll need to configure them in InstanceStatic as well @@ -64,6 +65,15 @@ defmodule Pleroma.Web.Endpoint do } ) + plug(Pleroma.Web.Plugs.FaviconPlug, + at: "/", + only: ["favicon.png"], + cache_control_for_etags: @favicon_cache_control, + headers: %{ + "cache-control" => @favicon_cache_control + } + ) + plug(Pleroma.Web.Plugs.InstanceStatic, at: "/", gzip: true, diff --git a/lib/pleroma/web/plugs/favicon_plug.ex b/lib/pleroma/web/plugs/favicon_plug.ex new file mode 100644 index 000000000..e2e1f1adb --- /dev/null +++ b/lib/pleroma/web/plugs/favicon_plug.ex @@ -0,0 +1,71 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2026 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Plugs.FaviconPlug do + @behaviour Plug + + @moduledoc """ + This is a shim to call `Plug.Static` but with runtime `from` configuration for instance favicon. + + Serves default or custom favicon.png with cacheable cache-control. + """ + + import Plug.Conn, only: [put_resp_header: 3, send_resp: 3, halt: 1] + + require Logger + + def init(opts) do + opts + |> Keyword.put(:from, "__unconfigured_favicon_static_plug") + |> Plug.Static.init() + end + + def call(%{request_path: "/favicon.png"} = conn, opts) do + case find_favicon_dir() do + {:ok, dir} -> + call_static(conn, opts, dir) + + :error -> + # Favicon should always be available and this should never occur. + # If it does, halt the pipeline before having unintended side-effects. + Logger.error("No favicon.png found! Is the default favicon deleted?") + + conn + |> send_resp(404, "Not found") + |> halt() + end + end + + def call(conn, _) do + conn + end + + defp find_favicon_dir do + instance_dir = Pleroma.Config.get([:instance, :static_dir], "instance/static") + instance_path = Path.join(instance_dir, "favicon.png") + + priv_dir = Application.app_dir(:pleroma, "priv/static") + priv_path = Path.join(priv_dir, "favicon.png") + + cond do + File.exists?(instance_path) -> {:ok, instance_dir} + File.exists?(priv_path) -> {:ok, priv_dir} + true -> :error + end + end + + defp call_static(conn, opts, from) do + opts = + opts + |> Map.put(:from, from) + |> Map.put(:content_types, false) + + conn = set_content_type(conn) + Plug.Static.call(conn, opts) + end + + defp set_content_type(conn) do + put_resp_header(conn, "content-type", "image/png") + end +end diff --git a/lib/pleroma/web/plugs/uploaded_media.ex b/lib/pleroma/web/plugs/uploaded_media.ex index abacf965b..5a3ea12aa 100644 --- a/lib/pleroma/web/plugs/uploaded_media.ex +++ b/lib/pleroma/web/plugs/uploaded_media.ex @@ -17,7 +17,7 @@ defmodule Pleroma.Web.Plugs.UploadedMedia do # no slashes @path "media" - @default_cache_control_header "public, max-age=1209600" + @default_cache_control_header "public, max-age=1209600, immutable" def init(_opts) do static_plug_opts = diff --git a/test/pleroma/reverse_proxy_test.exs b/test/pleroma/reverse_proxy_test.exs index 8dbe9c6bf..ec4470379 100644 --- a/test/pleroma/reverse_proxy_test.exs +++ b/test/pleroma/reverse_proxy_test.exs @@ -294,7 +294,7 @@ defmodule Pleroma.ReverseProxyTest do |> expect(:stream_body, fn _ -> :done end) conn = ReverseProxy.call(conn, "/cache") - assert {"cache-control", "public, max-age=1209600"} in conn.resp_headers + assert {"cache-control", "public, max-age=1209600, immutable"} in conn.resp_headers end end diff --git a/test/pleroma/web/plugs/favicon_plug_test.exs b/test/pleroma/web/plugs/favicon_plug_test.exs new file mode 100644 index 000000000..520501250 --- /dev/null +++ b/test/pleroma/web/plugs/favicon_plug_test.exs @@ -0,0 +1,63 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2026 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Plugs.FaviconPlugTest do + use Pleroma.Web.ConnCase + + @dir "test/tmp/favicon_static" + + setup do + Pleroma.Backports.mkdir_p!(@dir) + + on_exit(fn -> File.rm_rf!(@dir) end) + end + + describe "default favicon" do + test "returns favicon", %{conn: conn} do + conn = get(conn, "/favicon.png") + body_size = byte_size(conn.resp_body) + + assert conn.status == 200 + assert body_size == 1583 + assert response_content_type(conn, :png) + end + + test "returns correct cache-control", %{conn: conn} do + conn = get(conn, "/favicon.png") + cache = get_resp_header(conn, "cache-control") + + assert conn.status == 200 + assert cache == ["public, max=age=86400, immutable"] + end + end + + describe "custom favicon" do + setup do + favicon_path = Path.join(@dir, "favicon.png") + donor_image = "test/fixtures/image.png" + + File.cp!(donor_image, favicon_path) + clear_config([:instance, :static_dir], @dir) + + on_exit(fn -> File.rm!(favicon_path) end) + end + + test "returns favicon", %{conn: conn} do + conn = get(conn, "/favicon.png") + body_size = byte_size(conn.resp_body) + + assert conn.status == 200 + assert body_size == 104_426 + assert response_content_type(conn, :png) + end + + test "returns correct cache-control", %{conn: conn} do + conn = get(conn, "/favicon.png") + cache = get_resp_header(conn, "cache-control") + + assert conn.status == 200 + assert cache == ["public, max=age=86400, immutable"] + end + end +end