From f493bb4be2b5ef130c2c2209056fa62084a49399 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=A9=E6=B5=B7=20=E5=8E=9F?= Date: Fri, 12 Jun 2026 00:12:08 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix(storage):=20=E4=BC=98=E5=8C=96=E5=9B=BE?= =?UTF-8?q?=E7=89=87=E5=AD=98=E5=82=A8=E5=8A=A0=E9=80=9F=E4=B8=8E=E9=83=A8?= =?UTF-8?q?=E7=BD=B2=E4=BB=A3=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 5 +- assets/vendor/heroicons.js | 13 +- config/config.exs | 9 +- config/nginx/dev.conf | 35 +++++ config/nginx/prod.conf | 50 +++++++ config/runtime.exs | 2 +- docker-compose.yml | 17 ++- docs/guides/deployment/docker.md | 26 +++- docs/guides/development/setup.md | 12 +- docs/guides/self-hosting/zeabur/README.md | 5 + docs/guides/self-hosting/zeabur/vmemo.yml | 6 + lib/vmemo_web/controllers/file_controller.ex | 65 +++++---- lib/vmemo_web/router.ex | 7 +- others/e2e-test/global-setup.ts | 15 +- .../e2e-test/tests/photos-index-page.spec.ts | 16 +- .../photos-index-page-macbook-13-darwin.png | Bin 11361 -> 9183 bytes rel/entrypoint.sh | 5 + .../controllers/file_controller_test.exs | 137 ++++++++++++++---- 18 files changed, 352 insertions(+), 73 deletions(-) create mode 100644 config/nginx/dev.conf create mode 100644 config/nginx/prod.conf diff --git a/Dockerfile b/Dockerfile index 432ea425..89e77d7f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -28,7 +28,7 @@ RUN mix release FROM base AS runner RUN apt-get update -y && \ - apt-get install -y libstdc++6 openssl libncurses6 libtinfo6 locales ca-certificates imagemagick && \ + apt-get install -y libstdc++6 openssl libncurses6 libtinfo6 locales ca-certificates imagemagick nginx && \ apt-get clean && rm -rf /var/lib/apt/lists/* # Set the locale @@ -41,10 +41,13 @@ ENV LC_ALL=en_US.UTF-8 WORKDIR /app COPY --from=builder /app/_build/prod/rel/vmemo /app +COPY config/nginx/prod.conf /etc/nginx/nginx.conf COPY rel/entrypoint.sh /usr/local/bin/entrypoint.sh RUN chmod +x /usr/local/bin/entrypoint.sh ENV HOME=/app +ENV VMEMO_ENABLE_NGINX=true +ENV PHX_PORT=4001 ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] CMD ["start"] diff --git a/assets/vendor/heroicons.js b/assets/vendor/heroicons.js index 296f80e4..fae6cb7a 100644 --- a/assets/vendor/heroicons.js +++ b/assets/vendor/heroicons.js @@ -2,8 +2,19 @@ const plugin = require("tailwindcss/plugin") const fs = require("fs") const path = require("path") +function resolveDepsRoot() { + let envDepsRoot = process.env.MIX_DEPS_PATH && path.resolve(process.env.MIX_DEPS_PATH) + let localDepsRoot = path.join(__dirname, "../../deps") + + if (envDepsRoot && fs.existsSync(path.join(envDepsRoot, "heroicons"))) { + return envDepsRoot + } + + return localDepsRoot +} + module.exports = plugin(function({matchComponents, theme}) { - let iconsDir = path.join(__dirname, "../../deps/heroicons/optimized") + let iconsDir = path.join(resolveDepsRoot(), "heroicons/optimized") let values = {} let icons = [ ["", "/24/outline"], diff --git a/config/config.exs b/config/config.exs index 4003bc9b..4ffc70b6 100644 --- a/config/config.exs +++ b/config/config.exs @@ -90,6 +90,13 @@ config :vmemo, VmemoWeb.Endpoint, # at the `config/runtime.exs`. config :vmemo, Vmemo.Mailer, adapter: Swoosh.Adapters.Local +deps_path = + case System.get_env("MIX_DEPS_PATH") do + nil -> Path.expand("../deps", __DIR__) + "" -> Path.expand("../deps", __DIR__) + path -> Path.expand(path) + end + # Configure esbuild (the version is required) config :esbuild, version: "0.17.11", @@ -97,7 +104,7 @@ config :esbuild, args: ~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*), cd: Path.expand("../assets", __DIR__), - env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)} + env: %{"NODE_PATH" => deps_path} ] # Configure tailwind (the version is required) diff --git a/config/nginx/dev.conf b/config/nginx/dev.conf new file mode 100644 index 00000000..f52f7d52 --- /dev/null +++ b/config/nginx/dev.conf @@ -0,0 +1,35 @@ +events {} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + map $http_upgrade $connection_upgrade { + default upgrade; + "" close; + } + + server { + listen 80; + server_name localhost; + client_max_body_size 50m; + + location /storage/v1/_internal/ { + internal; + alias /app/storage/v1/; + } + + location / { + proxy_pass http://host.docker.internal:4000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Port $server_port; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + } + } +} diff --git a/config/nginx/prod.conf b/config/nginx/prod.conf new file mode 100644 index 00000000..1178e718 --- /dev/null +++ b/config/nginx/prod.conf @@ -0,0 +1,50 @@ +events {} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + map $http_upgrade $connection_upgrade { + default upgrade; + "" close; + } + + map $http_x_forwarded_proto $proxy_x_forwarded_proto { + default $http_x_forwarded_proto; + "" $scheme; + } + + map $http_x_forwarded_host $proxy_x_forwarded_host { + default $http_x_forwarded_host; + "" $host; + } + + map $http_x_forwarded_port $proxy_x_forwarded_port { + default $http_x_forwarded_port; + "" $server_port; + } + + server { + listen 4000; + server_name _; + client_max_body_size 50m; + + location /storage/v1/_internal/ { + internal; + alias /app/storage/v1/; + } + + location / { + proxy_pass http://127.0.0.1:4001; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Host $proxy_x_forwarded_host; + proxy_set_header X-Forwarded-Port $proxy_x_forwarded_port; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + } + } +} diff --git a/config/runtime.exs b/config/runtime.exs index 25c308ed..af2f4699 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -165,7 +165,7 @@ if config_env() == :prod do root_source_code_paths: [File.cwd!()] host = System.get_env("PHX_HOST") || "vmemo.app" - port = String.to_integer(System.get_env("PORT") || "4000") + port = String.to_integer(System.get_env("PHX_PORT") || System.get_env("PORT") || "4000") config :vmemo, VmemoWeb.Endpoint, url: [host: host, port: 443, scheme: "https"], diff --git a/docker-compose.yml b/docker-compose.yml index 8776ea02..e882c0a7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,21 @@ -# Default `docker compose up` starts postgres + typesense only. +# Default `docker compose up` starts local dependency services. +# Use `docker compose --profile proxy up -d` to add the local Nginx proxy. # For test DB/search: `docker compose --profile test up` (or `COMPOSE_PROFILES=test`). services: + nginx: + profiles: + - proxy + image: nginx:1.27.5-alpine + hostname: nginx + restart: on-failure + ports: + - "4080:80" + volumes: + - ./config/nginx/dev.conf:/etc/nginx/nginx.conf:ro + - ./storage/v1:/app/storage/v1:ro + extra_hosts: + - "host.docker.internal:host-gateway" + postgres: image: postgres:18 hostname: postgres diff --git a/docs/guides/deployment/docker.md b/docs/guides/deployment/docker.md index 1ca4b487..7bf8f076 100644 --- a/docs/guides/deployment/docker.md +++ b/docs/guides/deployment/docker.md @@ -48,7 +48,8 @@ docker run --rm -p 4000:4000 \ Release startup behavior: 1. `bin/vmemo eval "Vmemo.Release.migrate()"` -2. `bin/vmemo start` +2. Start Nginx when `VMEMO_ENABLE_NGINX=true` +3. `bin/vmemo start` Remote IEx: @@ -93,6 +94,7 @@ docker manifest inspect thaddeusjiang/vmemo:latest >/dev/null && echo ok - `rel/entrypoint.sh` exists and is executable. - Entrypoint runs `bin/vmemo eval "Vmemo.Release.migrate()"`. - Dockerfile runner starts via `ENTRYPOINT + CMD ["start"]`. +- Dockerfile runner starts Nginx before Phoenix when `VMEMO_ENABLE_NGINX=true`. - Dockerfile runner includes ImageMagick (`magick`) for AI vision request preprocessing. ### Required Environment Variables @@ -109,6 +111,28 @@ docker manifest inspect thaddeusjiang/vmemo:latest >/dev/null && echo ok Optional: `MOONDREAM_URL`, `OPENROUTER_VISION_MODEL`, `SENTRY_ENV` +Storage acceleration: + +- Browser-facing storage URLs stay under `/storage/v1`. +- Phoenix performs the lightweight owner check, then returns `X-Accel-Redirect` + under `/storage/v1/_internal`. +- Nginx sends the file bytes from the app's `storage/v1` directory. The production + Docker image enables this by default: Nginx listens on `4000`, while Phoenix + listens internally on `PHX_PORT=4001`. + +Example Nginx location: + +```nginx +location /storage/v1/_internal/ { + internal; + alias /app/storage/v1/; +} +``` + +Keep `/storage/v1/_internal/` internal-only. Browser-facing requests should continue to use +`/storage/v1/:user_id/images/:filename` and `/storage/v1/:user_id/avatars/:filename` +so they pass through Phoenix authorization before Nginx serves the file bytes. + ### Troubleshooting Migration failure: diff --git a/docs/guides/development/setup.md b/docs/guides/development/setup.md index 9eabb85a..a84ada2a 100644 --- a/docs/guides/development/setup.md +++ b/docs/guides/development/setup.md @@ -19,11 +19,21 @@ From repository root: ```bash mise trust && mise install -docker compose up -d +docker compose --profile proxy up -d mix setup iex -S mix phx.server ``` +Open the app through the local reverse proxy: + +```text +http://localhost:4080 +``` + +Phoenix still runs on `http://localhost:4000`, but storage image responses use +`X-Accel-Redirect`. Use the Nginx proxy on port `4080` when validating image +loading performance or `/storage/v1` image URLs locally. + Test dependencies: ```bash diff --git a/docs/guides/self-hosting/zeabur/README.md b/docs/guides/self-hosting/zeabur/README.md index 3a8ad000..28562ad6 100644 --- a/docs/guides/self-hosting/zeabur/README.md +++ b/docs/guides/self-hosting/zeabur/README.md @@ -15,3 +15,8 @@ SENTRY_DSN= # optional RESEND_API_KEY= # optional OPENROUTER_API_KEY= # optional ``` + +The template exposes the Vmemo service on port `4000`. The production image starts +Nginx on that public port and runs Phoenix internally on `4001`, so `/storage/v1` +image requests keep the normal browser URL while Nginx handles the internal +`X-Accel-Redirect` file response. diff --git a/docs/guides/self-hosting/zeabur/vmemo.yml b/docs/guides/self-hosting/zeabur/vmemo.yml index 9e2d28b8..561e7181 100644 --- a/docs/guides/self-hosting/zeabur/vmemo.yml +++ b/docs/guides/self-hosting/zeabur/vmemo.yml @@ -50,6 +50,12 @@ spec: PHX_SERVER: default: "true" expose: false + PHX_PORT: + default: "4001" + expose: false + VMEMO_ENABLE_NGINX: + default: "true" + expose: false SECRET_KEY_BASE: default: ${TYPESENSE_SERVICE_API_KEY}_${TYPESENSE_SERVICE_API_KEY} expose: false diff --git a/lib/vmemo_web/controllers/file_controller.ex b/lib/vmemo_web/controllers/file_controller.ex index 536330b0..2cb464be 100644 --- a/lib/vmemo_web/controllers/file_controller.ex +++ b/lib/vmemo_web/controllers/file_controller.ex @@ -2,6 +2,7 @@ defmodule VmemoWeb.FileController do use VmemoWeb, :controller @storage_root Path.expand("storage/v1") + @storage_accel_redirect_prefix "/storage/v1/_internal" @allowed_mime_types %{ ".png" => "image/png", ".jpg" => "image/jpeg", @@ -14,6 +15,7 @@ defmodule VmemoWeb.FileController do def show(conn, %{"user_id" => user_id, "filename" => filename}) do with {:ok, safe_user_id} <- normalize_user_id(user_id), + :ok <- authorize_storage_user(conn, safe_user_id), {:ok, safe_filename} <- normalize_filename(filename), {:ok, file_path} <- image_path(safe_user_id, safe_filename), {:ok, resolved_path} <- resolve_image_path(file_path) do @@ -25,6 +27,7 @@ defmodule VmemoWeb.FileController do def show_avatar(conn, %{"user_id" => user_id, "filename" => filename}) do with {:ok, safe_user_id} <- normalize_user_id(user_id), + :ok <- authorize_storage_user(conn, safe_user_id), {:ok, safe_filename} <- normalize_filename(filename), {:ok, file_path} <- avatar_path(safe_user_id, safe_filename) do if File.exists?(file_path) do @@ -39,19 +42,12 @@ defmodule VmemoWeb.FileController do defp send_storage_file(conn, file_path) do with {:ok, stat} <- File.stat(file_path), - {:ok, file_bin} <- read_file_binary(file_path, stat.size), etag <- build_etag(file_path, stat), last_modified <- build_last_modified(stat), false <- fresh?(conn, etag, stat) do - conn = - conn - |> put_resp_header("content-type", detect_safe_mime(file_path)) - |> put_resp_header("content-disposition", "inline") - |> put_resp_header("cache-control", "public, max-age=31536000, immutable") - |> put_resp_header("etag", etag) - |> put_resp_header("last-modified", last_modified) - - send_resp(conn, 200, file_bin) + conn + |> put_storage_headers(file_path, etag, last_modified) + |> send_storage_body(file_path) else true -> conn @@ -67,6 +63,36 @@ defmodule VmemoWeb.FileController do end end + defp authorize_storage_user(conn, user_id) do + case conn.assigns[:current_user] do + %{id: ^user_id} -> :ok + %{id: current_user_id} when is_binary(current_user_id) -> :error + _ -> :error + end + end + + defp put_storage_headers(conn, file_path, etag, last_modified) do + conn + |> put_resp_header("content-type", detect_safe_mime(file_path)) + |> put_resp_header("content-disposition", "inline") + |> put_resp_header("cache-control", "public, max-age=31536000, immutable") + |> put_resp_header("etag", etag) + |> put_resp_header("last-modified", last_modified) + end + + defp send_storage_body(conn, file_path) do + conn + |> put_resp_header("x-accel-redirect", storage_accel_redirect_path(file_path)) + |> send_resp(200, "") + end + + defp storage_accel_redirect_path(file_path) do + file_path + |> Path.expand() + |> Path.relative_to(@storage_root) + |> then(&Path.join(@storage_accel_redirect_prefix, &1)) + end + defp fresh?(conn, etag, stat) do if_none_match_present? = get_req_header(conn, "if-none-match") != [] @@ -212,23 +238,4 @@ defmodule VmemoWeb.FileController do extension = Path.extname(file_path) |> String.downcase() Map.get(@allowed_mime_types, extension, "application/octet-stream") end - - defp read_file_binary(file_path, size) - when is_binary(file_path) and is_integer(size) and size >= 0 do - case :file.open(String.to_charlist(file_path), [:read, :binary]) do - {:ok, io} -> - try do - case :file.read(io, size) do - {:ok, data} -> {:ok, data} - :eof -> {:ok, <<>>} - {:error, _} = error -> error - end - after - :file.close(io) - end - - {:error, _} = error -> - error - end - end end diff --git a/lib/vmemo_web/router.ex b/lib/vmemo_web/router.ex index 722f8ac5..961497b7 100644 --- a/lib/vmemo_web/router.ex +++ b/lib/vmemo_web/router.ex @@ -28,6 +28,11 @@ defmodule VmemoWeb.Router do plug VmemoWeb.ApiAuth end + pipeline :storage do + plug :fetch_session + plug :fetch_current_user + end + # MCP pipeline - optional authentication for MCP server # Allows unauthenticated access for public tools, but sets actor if API token is provided # Only supports StreamableHttp (POST requests), not SSE (GET requests) @@ -160,7 +165,7 @@ defmodule VmemoWeb.Router do end scope "/storage/v1/", VmemoWeb do - pipe_through :browser + pipe_through :storage get "/:user_id/images/:filename", FileController, :show get "/:user_id/avatars/:filename", FileController, :show_avatar diff --git a/others/e2e-test/global-setup.ts b/others/e2e-test/global-setup.ts index 9dc6a716..1a8b8318 100644 --- a/others/e2e-test/global-setup.ts +++ b/others/e2e-test/global-setup.ts @@ -20,11 +20,16 @@ export default async function globalSetup(config: FullConfig) { try { await page.goto("/login", { waitUntil: "domcontentloaded" }); - const loginButton = page.getByRole("button", { name: /Login/i }); - await expect(loginButton).toBeVisible({ timeout: 10_000 }); - await page.getByLabel("Email").fill(email); - await page.getByLabel("Password").fill(password); - await loginButton.click(); + const csrfToken = await page.locator('input[name="_csrf_token"]').inputValue(); + await page.request.post("/login", { + form: { + _csrf_token: csrfToken, + "user[email]": email, + "user[password]": password, + "user[remember_me]": "false", + }, + }); + await page.goto("/home", { waitUntil: "domcontentloaded" }); await expect(page).toHaveURL(/\/home/, { timeout: 10_000 }); lastError = undefined; break; diff --git a/others/e2e-test/tests/photos-index-page.spec.ts b/others/e2e-test/tests/photos-index-page.spec.ts index ed6bb33a..05fba532 100644 --- a/others/e2e-test/tests/photos-index-page.spec.ts +++ b/others/e2e-test/tests/photos-index-page.spec.ts @@ -1,7 +1,19 @@ -import { test } from "@playwright/test"; +import { expect, test } from "@playwright/test"; import { expectVisual, gotoAndAssertAttached } from "./visual-helpers.js"; test("photos index page visual snapshot", async ({ page }) => { await gotoAndAssertAttached(page, "/images", page.locator("#infinite-scroll")); - await expectVisual(page, "photos-index-page", [page.locator("#waterfall-images")]); + const firstImage = page.locator("#waterfall-images img").first(); + + await expect(firstImage).toBeVisible({ timeout: 20_000 }); + await expect + .poll(() => + firstImage.evaluate((image) => (image as HTMLImageElement).naturalWidth), + ) + .toBeGreaterThan(0); + + await expectVisual(page, "photos-index-page", [ + page.locator(".page-shell"), + page.getByLabel("Notifications"), + ]); }); diff --git a/others/e2e-test/tests/photos-index-page.spec.ts-snapshots/photos-index-page-macbook-13-darwin.png b/others/e2e-test/tests/photos-index-page.spec.ts-snapshots/photos-index-page-macbook-13-darwin.png index 54075f20d086aac4e7b386868c999ad988de2d92..f65368daf00a7cac41ba130a3afaad60fe738d73 100644 GIT binary patch literal 9183 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 aAjq#q{$%#qy#OtcOBda~Bc8wU)Bga|6+o~6 literal 11361 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 VeZFvL5R5=fuABc>a?R!G{{SHqk2(MV diff --git a/rel/entrypoint.sh b/rel/entrypoint.sh index 60872bf9..ae2001e3 100644 --- a/rel/entrypoint.sh +++ b/rel/entrypoint.sh @@ -3,4 +3,9 @@ set -eu /app/bin/vmemo eval "Vmemo.Release.migrate()" +if [ "${1:-}" = "start" ] && [ "${VMEMO_ENABLE_NGINX:-}" = "true" ]; then + nginx -t + nginx +fi + exec /app/bin/vmemo "$@" diff --git a/test/vmemo_web/controllers/file_controller_test.exs b/test/vmemo_web/controllers/file_controller_test.exs index 56feaf46..c36802c9 100644 --- a/test/vmemo_web/controllers/file_controller_test.exs +++ b/test/vmemo_web/controllers/file_controller_test.exs @@ -1,31 +1,42 @@ defmodule VmemoWeb.FileControllerTest do - use VmemoWeb.ConnCase, async: true + use VmemoWeb.ConnCase, async: false + + import Vmemo.AccountFixtures @base_dir Path.join(["storage", "v1"]) - setup do - user_id = "test-user-#{System.unique_integer([:positive])}" - image_dir = Path.join([@base_dir, user_id, "images"]) + setup %{conn: conn} do + user = user_fixture() + other_user = user_fixture() + conn = log_in_user(conn, user) + + image_dir = Path.join([@base_dir, user.id, "images"]) File.mkdir_p!(image_dir) on_exit(fn -> - File.rm_rf!(Path.join([@base_dir, user_id])) + File.rm_rf!(Path.join([@base_dir, user.id])) + File.rm_rf!(Path.join([@base_dir, other_user.id])) end) - {:ok, user_id: user_id, image_dir: image_dir} + {:ok, conn: conn, user: user, other_user: other_user, image_dir: image_dir} end - test "returns original image when thumbnail is missing", %{ + test "accelerates fallback original image when thumbnail is missing", %{ conn: conn, - user_id: user_id, + user: user, image_dir: image_dir } do original = Path.join(image_dir, "sample.png") File.write!(original, "png-data") - conn = get(conn, ~p"/storage/v1/#{user_id}/images/sample--m.png") + conn = get(conn, ~p"/storage/v1/#{user.id}/images/sample--m.png") + + assert response(conn, 200) == "" + + assert get_resp_header(conn, "x-accel-redirect") == [ + "/storage/v1/_internal/#{user.id}/images/sample.png" + ] - assert response(conn, 200) == "png-data" assert get_resp_header(conn, "content-type") == ["image/png"] assert get_resp_header(conn, "content-disposition") == ["inline"] assert get_resp_header(conn, "cache-control") == ["public, max-age=31536000, immutable"] @@ -35,37 +46,47 @@ defmodule VmemoWeb.FileControllerTest do test "falls back to another extension when exact original does not exist", %{ conn: conn, - user_id: user_id, + user: user, image_dir: image_dir } do png = Path.join(image_dir, "sample.png") File.write!(png, "png-fallback") - conn = get(conn, ~p"/storage/v1/#{user_id}/images/sample--m.webp") + conn = get(conn, ~p"/storage/v1/#{user.id}/images/sample--m.webp") + + assert response(conn, 200) == "" + + assert get_resp_header(conn, "x-accel-redirect") == [ + "/storage/v1/_internal/#{user.id}/images/sample.png" + ] - assert response(conn, 200) == "png-fallback" assert get_resp_header(conn, "content-type") == ["image/png"] end - test "serves tiff images with image/tiff content type", %{ + test "accelerates tiff images with image/tiff content type", %{ conn: conn, - user_id: user_id, + user: user, image_dir: image_dir } do tiff = Path.join(image_dir, "sample.tiff") File.write!(tiff, "tiff-data") - conn = get(conn, ~p"/storage/v1/#{user_id}/images/sample.tiff") + conn = get(conn, ~p"/storage/v1/#{user.id}/images/sample.tiff") + + assert response(conn, 200) == "" + + assert get_resp_header(conn, "x-accel-redirect") == [ + "/storage/v1/_internal/#{user.id}/images/sample.tiff" + ] - assert response(conn, 200) == "tiff-data" assert get_resp_header(conn, "content-type") == ["image/tiff"] end test "returns 404 with no-store when both thumbnail and original are missing", %{ conn: conn, - user_id: user_id + user: user } do - conn = get(conn, ~p"/storage/v1/#{user_id}/images/not-found--s.png") + conn = get(conn, ~p"/storage/v1/#{user.id}/images/not-found--s.png") assert response(conn, 404) == "File not found" assert get_resp_header(conn, "cache-control") == ["no-store"] @@ -74,40 +95,98 @@ defmodule VmemoWeb.FileControllerTest do test "returns 304 when if-none-match matches etag", %{ conn: conn, - user_id: user_id, + user: user, image_dir: image_dir } do original = Path.join(image_dir, "etag.png") File.write!(original, "etag-data") - first = get(conn, ~p"/storage/v1/#{user_id}/images/etag.png") - assert response(first, 200) == "etag-data" + first = get(conn, ~p"/storage/v1/#{user.id}/images/etag.png") + assert response(first, 200) == "" [etag] = get_resp_header(first, "etag") second = conn |> put_req_header("if-none-match", etag) - |> get(~p"/storage/v1/#{user_id}/images/etag.png") + |> get(~p"/storage/v1/#{user.id}/images/etag.png") assert response(second, 304) == "" assert get_resp_header(second, "cache-control") == ["public, max-age=0, must-revalidate"] end - test "returns 404 for invalid filename pattern", %{conn: conn, user_id: user_id} do - conn = get(conn, "/storage/v1/#{user_id}/images/evil file.png") + test "returns 404 for invalid filename pattern", %{conn: conn, user: user} do + conn = get(conn, "/storage/v1/#{user.id}/images/evil file.png") assert response(conn, 404) == "File not found" end - test "serves avatar when file exists", %{conn: conn, user_id: user_id} do - avatar_dir = Path.join([@base_dir, user_id, "avatars"]) + test "accelerates avatar when file exists", %{conn: conn, user: user} do + avatar_dir = Path.join([@base_dir, user.id, "avatars"]) File.mkdir_p!(avatar_dir) avatar = Path.join(avatar_dir, "me.jpg") File.write!(avatar, "jpg-data") - conn = get(conn, ~p"/storage/v1/#{user_id}/avatars/me.jpg") + conn = get(conn, ~p"/storage/v1/#{user.id}/avatars/me.jpg") + + assert response(conn, 200) == "" + + assert get_resp_header(conn, "x-accel-redirect") == [ + "/storage/v1/_internal/#{user.id}/avatars/me.jpg" + ] - assert response(conn, 200) == "jpg-data" assert get_resp_header(conn, "content-type") == ["image/jpeg"] assert get_resp_header(conn, "cache-control") == ["public, max-age=31536000, immutable"] end + + test "does not serve image files to anonymous users", %{ + user: user, + image_dir: image_dir + } do + original = Path.join(image_dir, "private.png") + File.write!(original, "private-data") + + conn = + Phoenix.ConnTest.build_conn() + |> get(~p"/storage/v1/#{user.id}/images/private.png") + + assert response(conn, 404) == "File not found" + assert get_resp_header(conn, "cache-control") == ["no-store"] + end + + test "does not serve another user's image files", %{ + other_user: other_user, + user: user, + image_dir: image_dir + } do + original = Path.join(image_dir, "private.png") + File.write!(original, "private-data") + + conn = + Phoenix.ConnTest.build_conn() + |> log_in_user(other_user) + |> get(~p"/storage/v1/#{user.id}/images/private.png") + + assert response(conn, 404) == "File not found" + assert get_resp_header(conn, "cache-control") == ["no-store"] + end + + test "uses x-accel-redirect by default", %{ + conn: conn, + user: user, + image_dir: image_dir + } do + original = Path.join(image_dir, "accelerated.png") + File.write!(original, "accelerated-data") + + conn = get(conn, ~p"/storage/v1/#{user.id}/images/accelerated.png") + + assert response(conn, 200) == "" + + assert get_resp_header(conn, "x-accel-redirect") == [ + "/storage/v1/_internal/#{user.id}/images/accelerated.png" + ] + + assert get_resp_header(conn, "content-type") == ["image/png"] + assert get_resp_header(conn, "content-disposition") == ["inline"] + assert get_resp_header(conn, "cache-control") == ["public, max-age=31536000, immutable"] + end end From 3de0285fb6dbb50a3e6c082cd8346bf1f4590dd9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=A9=E6=B5=B7=20=E5=8E=9F?= Date: Sat, 13 Jun 2026 21:09:51 +0900 Subject: [PATCH 2/2] =?UTF-8?q?test(e2e):=20=E6=B7=BB=E5=8A=A0=E5=9B=BE?= =?UTF-8?q?=E7=89=87=E5=B1=95=E7=A4=BA=E6=80=A7=E8=83=BD=E9=AA=8C=E8=AF=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- others/e2e-test/README.md | 23 +++ others/e2e-test/package.json | 3 +- .../tests/photos-index-performance.spec.ts | 173 ++++++++++++++++++ 3 files changed, 198 insertions(+), 1 deletion(-) create mode 100644 others/e2e-test/tests/photos-index-performance.spec.ts diff --git a/others/e2e-test/README.md b/others/e2e-test/README.md index dbf998a9..53b62a22 100644 --- a/others/e2e-test/README.md +++ b/others/e2e-test/README.md @@ -29,6 +29,9 @@ bun run e2e -- tests/home-page.spec.ts # UI mode (scoped) bun run e2e:ui -- tests/home-page.spec.ts + +# Image display performance probe +E2E_BASE_URL=http://localhost:4000 bun run perf:images ``` Do not run the full suite unless explicitly requested. @@ -46,6 +49,26 @@ For local prerequisites and execution flow, follow: - `/.agents/skills/vmemo-e2e-testing/SKILL.md` +## Image Performance Probe + +`bun run perf:images` verifies the `/images` page against a running Vmemo target and prints a JSON report with: + +- first image ready time +- `/storage/v1` response status, duration, content type, server, and bytes +- browser image resource timing and decoded dimensions + +The default budgets target the local Docker e2e environment: + +```bash +PHOTOS_INDEX_READY_BUDGET_MS=3000 \ +STORAGE_IMAGE_RESPONSE_BUDGET_MS=1500 \ +PHOTOS_INDEX_MIN_IMAGES=1 \ +E2E_BASE_URL=http://localhost:4000 \ +bun run perf:images +``` + +The report is also attached to the Playwright output as `photos-index-image-performance.json`. + ## CI Notes - CI e2e workflow trigger label: `run-e2e-test` diff --git a/others/e2e-test/package.json b/others/e2e-test/package.json index e2cc46f2..aedc8aea 100644 --- a/others/e2e-test/package.json +++ b/others/e2e-test/package.json @@ -5,7 +5,8 @@ "scripts": { "e2e": "playwright test", "e2e:ui": "playwright test --ui", - "e2e:update-snapshots": "playwright test --update-snapshots" + "e2e:update-snapshots": "playwright test --update-snapshots", + "perf:images": "playwright test tests/photos-index-performance.spec.ts --project=macbook-13" }, "devDependencies": { "@playwright/test": "^1.58.2", diff --git a/others/e2e-test/tests/photos-index-performance.spec.ts b/others/e2e-test/tests/photos-index-performance.spec.ts new file mode 100644 index 00000000..36751d93 --- /dev/null +++ b/others/e2e-test/tests/photos-index-performance.spec.ts @@ -0,0 +1,173 @@ +import { expect, test, type Request } from "@playwright/test"; + +type BrowserImageMetric = { + url: string; + naturalWidth: number; + naturalHeight: number; + complete: boolean; + durationMs: number; + transferSize: number; + encodedBodySize: number; + decodedBodySize: number; +}; + +type ResponseMetric = { + url: string; + status: number; + durationMs: number; + contentLength: number | null; + contentType: string | null; + server: string | null; +}; + +const pageReadyBudgetMs = readNumberEnv("PHOTOS_INDEX_READY_BUDGET_MS", 3_000); +const imageResponseBudgetMs = readNumberEnv("STORAGE_IMAGE_RESPONSE_BUDGET_MS", 1_500); +const minExpectedImages = readNumberEnv("PHOTOS_INDEX_MIN_IMAGES", 1); + +test("photos index image display performance", async ({ page }, testInfo) => { + const storageResponses: ResponseMetric[] = []; + const storageRequestStartMs = new Map(); + + page.on("request", (request) => { + if (isStorageImageUrl(request.url())) { + storageRequestStartMs.set(request, Date.now()); + } + }); + + page.on("response", (response) => { + const url = response.url(); + + if (!isStorageImageUrl(url)) { + return; + } + + const request = response.request(); + const headers = response.headers(); + const startedAt = storageRequestStartMs.get(request); + storageRequestStartMs.delete(request); + + storageResponses.push({ + url: new URL(url).pathname, + status: response.status(), + durationMs: startedAt ? Date.now() - startedAt : 0, + contentLength: parseNullableInteger(headers["content-length"]), + contentType: headers["content-type"] ?? null, + server: headers.server ?? null, + }); + }); + + const startMs = Date.now(); + + await page.goto("/images", { waitUntil: "domcontentloaded" }); + await expect(page.locator("#infinite-scroll")).toHaveCount(1, { timeout: 20_000 }); + + const firstImage = page.locator("#waterfall-images img").first(); + await expect(firstImage).toBeVisible({ timeout: 20_000 }); + await expect + .poll(() => + firstImage.evaluate( + (image) => (image as HTMLImageElement).complete && (image as HTMLImageElement).naturalWidth > 0, + ), + ) + .toBe(true); + + const firstImageReadyMs = Date.now() - startMs; + const browserImages = await collectBrowserImageMetrics(page); + const loadedImages = browserImages.filter((image) => image.complete && image.naturalWidth > 0); + const report = buildReport(firstImageReadyMs, browserImages, storageResponses); + + console.log(JSON.stringify(report, null, 2)); + + await testInfo.attach("photos-index-image-performance.json", { + body: JSON.stringify(report, null, 2), + contentType: "application/json", + }); + + expect(loadedImages.length).toBeGreaterThanOrEqual(minExpectedImages); + expect(storageResponses.length).toBeGreaterThanOrEqual(minExpectedImages); + expect(storageResponses.map((response) => response.status)).toEqual( + expect.arrayContaining([200]), + ); + expect(storageResponses.every((response) => response.status < 400)).toBe(true); + expect(firstImageReadyMs).toBeLessThanOrEqual(pageReadyBudgetMs); + expect(maxDuration(storageResponses)).toBeLessThanOrEqual(imageResponseBudgetMs); +}); + +async function collectBrowserImageMetrics(page: import("@playwright/test").Page) { + return page.locator("#waterfall-images img").evaluateAll((images) => + images.map((image) => { + const htmlImage = image as HTMLImageElement; + const entry = performance + .getEntriesByName(htmlImage.currentSrc) + .at(-1) as PerformanceResourceTiming | undefined; + + return { + url: new URL(htmlImage.currentSrc).pathname, + naturalWidth: htmlImage.naturalWidth, + naturalHeight: htmlImage.naturalHeight, + complete: htmlImage.complete, + durationMs: Math.round(entry?.duration ?? 0), + transferSize: entry?.transferSize ?? 0, + encodedBodySize: entry?.encodedBodySize ?? 0, + decodedBodySize: entry?.decodedBodySize ?? 0, + } satisfies BrowserImageMetric; + }), + ); +} + +function buildReport( + firstImageReadyMs: number, + browserImages: BrowserImageMetric[], + storageResponses: ResponseMetric[], +) { + const totalEncodedBytes = browserImages.reduce( + (sum, image) => sum + image.encodedBodySize, + 0, + ); + + return { + budgets: { + pageReadyBudgetMs, + imageResponseBudgetMs, + minExpectedImages, + }, + summary: { + firstImageReadyMs, + imageCount: browserImages.length, + storageRequestCount: storageResponses.length, + maxBrowserImageDurationMs: maxDuration(browserImages), + maxStorageResponseDurationMs: maxDuration(storageResponses), + totalEncodedBytes, + }, + browserImages, + storageResponses, + }; +} + +function maxDuration(metrics: Array<{ durationMs: number }>) { + return metrics.reduce((max, metric) => Math.max(max, metric.durationMs), 0); +} + +function readNumberEnv(name: string, fallback: number) { + const value = process.env[name]; + + if (!value) { + return fallback; + } + + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : fallback; +} + +function parseNullableInteger(value: string | undefined) { + if (!value) { + return null; + } + + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) ? parsed : null; +} + +function isStorageImageUrl(url: string) { + return url.includes("/storage/v1/") && !url.includes("/storage/v1/_internal/"); +}