Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"]
13 changes: 12 additions & 1 deletion assets/vendor/heroicons.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
9 changes: 8 additions & 1 deletion config/config.exs
Original file line number Diff line number Diff line change
Expand Up @@ -90,14 +90,21 @@ 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",
vmemo: [
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)
Expand Down
35 changes: 35 additions & 0 deletions config/nginx/dev.conf
Original file line number Diff line number Diff line change
@@ -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;
}
}
}
50 changes: 50 additions & 0 deletions config/nginx/prod.conf
Original file line number Diff line number Diff line change
@@ -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;
}
}
}
2 changes: 1 addition & 1 deletion config/runtime.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
17 changes: 16 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
26 changes: 25 additions & 1 deletion docs/guides/deployment/docker.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down
12 changes: 11 additions & 1 deletion docs/guides/development/setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions docs/guides/self-hosting/zeabur/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
6 changes: 6 additions & 0 deletions docs/guides/self-hosting/zeabur/vmemo.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
65 changes: 36 additions & 29 deletions lib/vmemo_web/controllers/file_controller.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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") != []

Expand Down Expand Up @@ -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
Loading