4 |
5 |
6 |
7 | <.image_template_styles />
8 |
9 | <%= @inner_content %>
10 |
11 |
--------------------------------------------------------------------------------
/priv/gettext/en/LC_MESSAGES/errors.po:
--------------------------------------------------------------------------------
1 | ## `msgid`s in this file come from POT (.pot) files.
2 | ##
3 | ## Do not add, change, or remove `msgid`s manually here as
4 | ## they're tied to the ones in the corresponding POT file
5 | ## (with the same domain).
6 | ##
7 | ## Use `mix gettext.extract --merge` or `mix gettext.merge`
8 | ## to merge POT files into PO files.
9 | msgid ""
10 | msgstr ""
11 | "Language: en\n"
12 |
--------------------------------------------------------------------------------
/priv/gettext/errors.pot:
--------------------------------------------------------------------------------
1 | ## This is a PO Template file.
2 | ##
3 | ## `msgid`s here are often extracted from source code.
4 | ## Add new translations manually only if they're dynamic
5 | ## translations that can't be statically extracted.
6 | ##
7 | ## Run `mix gettext.extract` to bring this file up to
8 | ## date. Leave `msgstr`s empty as changing them here has no
9 | ## effect: edit them in PO (`.po`) files instead.
10 |
11 |
--------------------------------------------------------------------------------
/test/og_image_web/controllers/error_json_test.exs:
--------------------------------------------------------------------------------
1 | defmodule OgImageWeb.ErrorJSONTest do
2 | use OgImageWeb.ConnCase, async: true
3 |
4 | test "renders 404" do
5 | assert OgImageWeb.ErrorJSON.render("404.json", %{}) == %{errors: %{detail: "Not Found"}}
6 | end
7 |
8 | test "renders 500" do
9 | assert OgImageWeb.ErrorJSON.render("500.json", %{}) ==
10 | %{errors: %{detail: "Internal Server Error"}}
11 | end
12 | end
13 |
--------------------------------------------------------------------------------
/lib/og_image/scrubber.ex:
--------------------------------------------------------------------------------
1 | defmodule OgImage.Scrubber do
2 | @moduledoc """
3 | The HTML sanitization scrubber.
4 | """
5 |
6 | require HtmlSanitizeEx.Scrubber.Meta
7 | alias HtmlSanitizeEx.Scrubber.Meta
8 |
9 | Meta.remove_cdata_sections_before_scrub()
10 | Meta.strip_comments()
11 |
12 | Meta.allow_tag_with_these_attributes("br", [])
13 | Meta.allow_tag_with_these_attributes("em", [])
14 |
15 | Meta.strip_everything_not_covered()
16 | end
17 |
--------------------------------------------------------------------------------
/test/og_image_web/controllers/error_html_test.exs:
--------------------------------------------------------------------------------
1 | defmodule OgImageWeb.ErrorHTMLTest do
2 | use OgImageWeb.ConnCase, async: true
3 |
4 | # Bring render_to_string/4 for testing custom views
5 | import Phoenix.Template
6 |
7 | test "renders 404.html" do
8 | assert render_to_string(OgImageWeb.ErrorHTML, "404", "html", []) == "Not Found"
9 | end
10 |
11 | test "renders 500.html" do
12 | assert render_to_string(OgImageWeb.ErrorHTML, "500", "html", []) == "Internal Server Error"
13 | end
14 | end
15 |
--------------------------------------------------------------------------------
/assets/css/app.css:
--------------------------------------------------------------------------------
1 | @import "tailwindcss/base";
2 | @import "tailwindcss/components";
3 | @import "tailwindcss/utilities";
4 |
5 | /**
6 | * Style `` tags nested in headlines.
7 | */
8 | h1 > em {
9 | @apply not-italic underline;
10 | }
11 |
12 | /**
13 | * Inline styles for emoji.
14 | *
15 | * See: https://github.com/jdecked/twemoji?tab=readme-ov-file#inline-styles
16 | */
17 | img.emoji {
18 | height: 1em;
19 | width: 1em;
20 | margin: 0 .05em 0 .1em;
21 | vertical-align: -0.1em;
22 | display: inline;
23 | }
24 |
--------------------------------------------------------------------------------
/config/prod.exs:
--------------------------------------------------------------------------------
1 | import Config
2 |
3 | # Note we also include the path to a cache manifest
4 | # containing the digested version of static files. This
5 | # manifest is generated by the `mix assets.deploy` task,
6 | # which you should run after static files are built and
7 | # before starting your production server.
8 | config :og_image, OgImageWeb.Endpoint, cache_static_manifest: "priv/static/cache_manifest.json"
9 |
10 | # Do not print debug messages in production
11 | config :logger, level: :info
12 |
13 | # Runtime production configuration, including reading
14 | # of environment variables, is done on config/runtime.exs.
15 |
--------------------------------------------------------------------------------
/lib/og_image_web/controllers/image_controller.ex:
--------------------------------------------------------------------------------
1 | defmodule OgImageWeb.ImageController do
2 | use OgImageWeb, :controller
3 |
4 | import OgImageWeb.ImageHelpers
5 | import OgImageWeb.ImageRenderer
6 |
7 | def show(conn, %{"template" => "light", "text" => text}) do
8 | conn
9 | |> assign(:text, prepare_html(text))
10 | |> render_image(:light)
11 | end
12 |
13 | def show(conn, %{"template" => "dark", "text" => text}) do
14 | conn
15 | |> assign(:text, prepare_html(text))
16 | |> render_image(:dark)
17 | end
18 |
19 | # -- Add more templates here --
20 |
21 | def show(conn, _params) do
22 | render_image(conn, :fallback)
23 | end
24 | end
25 |
--------------------------------------------------------------------------------
/lib/og_image_web/components/layouts/root.html.heex:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | <.live_title>
8 | <%= assigns[:page_title] || "Open Graph Image Generator" %>
9 |
10 |
11 |
13 |
14 |
15 | <%= @inner_content %>
16 |
17 |
18 |
--------------------------------------------------------------------------------
/config/test.exs:
--------------------------------------------------------------------------------
1 | import Config
2 |
3 | # We don't run a server during test. If one is required,
4 | # you can enable the server option below.
5 | config :og_image, OgImageWeb.Endpoint,
6 | http: [ip: {127, 0, 0, 1}, port: 4002],
7 | secret_key_base: "C0FwOX0Xz72hR4wm9Gq8e1Mjdn+8rnrvQFXN58GnUXWhofY0W8V0vcwlloyYJGKg",
8 | server: false
9 |
10 | # Print only warnings and errors during test
11 | config :logger, level: :warning
12 |
13 | # Initialize plugs at runtime for faster test compilation
14 | config :phoenix, :plug_init_mode, :runtime
15 |
16 | config :phoenix_live_view,
17 | # Enable helpful, but potentially expensive runtime checks
18 | enable_expensive_runtime_checks: true
19 |
20 | config :honeybadger, environment_name: :test
21 |
--------------------------------------------------------------------------------
/lib/og_image_web/controllers/error_json.ex:
--------------------------------------------------------------------------------
1 | defmodule OgImageWeb.ErrorJSON do
2 | @moduledoc """
3 | This module is invoked by your endpoint in case of errors on JSON requests.
4 |
5 | See config/config.exs.
6 | """
7 |
8 | # If you want to customize a particular status code,
9 | # you may add your own clauses, such as:
10 | #
11 | # def render("500.json", _assigns) do
12 | # %{errors: %{detail: "Internal Server Error"}}
13 | # end
14 |
15 | # By default, Phoenix returns the status message from
16 | # the template name. For example, "404.json" becomes
17 | # "Not Found".
18 | def render(template, _assigns) do
19 | %{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}}
20 | end
21 | end
22 |
--------------------------------------------------------------------------------
/lib/og_image_web/gettext.ex:
--------------------------------------------------------------------------------
1 | defmodule OgImageWeb.Gettext do
2 | @moduledoc """
3 | A module providing Internationalization with a gettext-based API.
4 |
5 | By using [Gettext](https://hexdocs.pm/gettext),
6 | your module gains a set of macros for translations, for example:
7 |
8 | import OgImageWeb.Gettext
9 |
10 | # Simple translation
11 | gettext("Here is the string to translate")
12 |
13 | # Plural translation
14 | ngettext("Here is the string to translate",
15 | "Here are the strings to translate",
16 | 3)
17 |
18 | # Domain-based translation
19 | dgettext("errors", "Here is the error message to translate")
20 |
21 | See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage.
22 | """
23 | use Gettext, otp_app: :og_image
24 | end
25 |
--------------------------------------------------------------------------------
/lib/og_image_web/router.ex:
--------------------------------------------------------------------------------
1 | defmodule OgImageWeb.Router do
2 | use OgImageWeb, :router
3 |
4 | pipeline :browser do
5 | plug :accepts, ["html"]
6 | plug :fetch_session
7 | plug :fetch_live_flash
8 | plug :put_root_layout, html: {OgImageWeb.Layouts, :root}
9 | plug :protect_from_forgery
10 | plug :put_secure_browser_headers
11 | end
12 |
13 | pipeline :image do
14 | plug :accepts, ["html", "png"]
15 | plug :put_root_layout, false
16 | plug :put_layout, html: {OgImageWeb.Layouts, :image}
17 | end
18 |
19 | scope "/", OgImageWeb do
20 | pipe_through :browser
21 |
22 | get "/", PageController, :home
23 | end
24 |
25 | scope "/", OgImageWeb do
26 | pipe_through :image
27 |
28 | get "/image", ImageController, :show
29 | get "/preview", ImageController, :show
30 | end
31 | end
32 |
--------------------------------------------------------------------------------
/lib/og_image_web/controllers/error_html.ex:
--------------------------------------------------------------------------------
1 | defmodule OgImageWeb.ErrorHTML do
2 | @moduledoc """
3 | This module is invoked by your endpoint in case of errors on HTML requests.
4 |
5 | See config/config.exs.
6 | """
7 | use OgImageWeb, :html
8 |
9 | # If you want to customize your error pages,
10 | # uncomment the embed_templates/1 call below
11 | # and add pages to the error directory:
12 | #
13 | # * lib/og_image_web/controllers/error_html/404.html.heex
14 | # * lib/og_image_web/controllers/error_html/500.html.heex
15 | #
16 | # embed_templates "error_html/*"
17 |
18 | # The default is to render a plain text page based on
19 | # the template name. For example, "404.html" becomes
20 | # "Not Found".
21 | def render(template, _assigns) do
22 | Phoenix.Controller.status_message_from_template(template)
23 | end
24 | end
25 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | Copyright 2024 SavvyCal, Inc.
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4 |
5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6 |
7 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
8 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # The directory Mix will write compiled artifacts to.
2 | /_build/
3 |
4 | # If you run "mix test --cover", coverage assets end up here.
5 | /cover/
6 |
7 | # The directory Mix downloads your dependencies sources to.
8 | /deps/
9 |
10 | # Where 3rd-party dependencies like ExDoc output generated docs.
11 | /doc/
12 |
13 | # Ignore .fetch files in case you like to edit your project deps locally.
14 | /.fetch
15 |
16 | # If the VM crashes, it generates a dump, let's ignore it too.
17 | erl_crash.dump
18 |
19 | # Also ignore archive artifacts (built via "mix archive.build").
20 | *.ez
21 |
22 | # Temporary files, for example, from tests.
23 | /tmp/
24 |
25 | # Ignore package tarball (built via "mix hex.build").
26 | og_image-*.tar
27 |
28 | # Ignore assets that are produced by build tools.
29 | /priv/static/assets/
30 |
31 | # Ignore digested assets cache.
32 | /priv/static/cache_manifest.json
33 |
34 | # In case you use Node.js/npm, you want to ignore these.
35 | npm-debug.log
36 | /assets/node_modules/
37 |
38 | /priv/js/node_modules
39 |
40 | # OS Noise
41 | .DS_Store
42 |
--------------------------------------------------------------------------------
/lib/og_image_web/controllers/image_helpers.ex:
--------------------------------------------------------------------------------
1 | defmodule OgImageWeb.ImageHelpers do
2 | @moduledoc """
3 | A collection of helpers for preparing template data.
4 | """
5 |
6 | alias HtmlSanitizeEx.Scrubber
7 |
8 | @doc """
9 | Convert emojis to tags.
10 | """
11 | @spec emojify(value :: any()) :: String.t() | nil
12 | def emojify(value) when is_binary(value) do
13 | NodeJS.call!("emojify", [value], binary: true)
14 | end
15 |
16 | def emojify(_), do: nil
17 |
18 | @doc """
19 | Takes input that might contain HTML and prepares it for rendering by scrubbing
20 | any unacceptable tags and converting emoji to images.
21 | """
22 | @spec prepare_html(value :: any(), default :: any()) :: Phoenix.HTML.safe() | nil
23 | def prepare_html(value, default \\ nil)
24 |
25 | def prepare_html(value, _default) when is_binary(value) and value != "" do
26 | value
27 | |> Scrubber.scrub(OgImage.Scrubber)
28 | |> emojify()
29 | |> Phoenix.HTML.raw()
30 | end
31 |
32 | def prepare_html(_, default) when is_binary(default), do: Phoenix.HTML.raw(default)
33 | def prepare_html(_, default), do: default
34 | end
35 |
--------------------------------------------------------------------------------
/test/support/conn_case.ex:
--------------------------------------------------------------------------------
1 | defmodule OgImageWeb.ConnCase do
2 | @moduledoc """
3 | This module defines the test case to be used by
4 | tests that require setting up a connection.
5 |
6 | Such tests rely on `Phoenix.ConnTest` and also
7 | import other functionality to make it easier
8 | to build common data structures and query the data layer.
9 |
10 | Finally, if the test case interacts with the database,
11 | we enable the SQL sandbox, so changes done to the database
12 | are reverted at the end of every test. If you are using
13 | PostgreSQL, you can even run database tests asynchronously
14 | by setting `use OgImageWeb.ConnCase, async: true`, although
15 | this option is not recommended for other databases.
16 | """
17 |
18 | use ExUnit.CaseTemplate
19 |
20 | using do
21 | quote do
22 | # The default endpoint for testing
23 | @endpoint OgImageWeb.Endpoint
24 |
25 | use OgImageWeb, :verified_routes
26 |
27 | # Import conveniences for testing with connections
28 | import Plug.Conn
29 | import Phoenix.ConnTest
30 | import OgImageWeb.ConnCase
31 | end
32 | end
33 |
34 | setup _tags do
35 | {:ok, conn: Phoenix.ConnTest.build_conn()}
36 | end
37 | end
38 |
--------------------------------------------------------------------------------
/lib/og_image/application.ex:
--------------------------------------------------------------------------------
1 | defmodule OgImage.Application do
2 | # See https://hexdocs.pm/elixir/Application.html
3 | # for more information on OTP Applications
4 | @moduledoc false
5 |
6 | use Application
7 |
8 | @impl true
9 | def start(_type, _args) do
10 | children = [
11 | OgImageWeb.Telemetry,
12 | {DNSCluster, query: Application.get_env(:og_image, :dns_cluster_query) || :ignore},
13 | {Phoenix.PubSub, name: OgImage.PubSub},
14 | # Start a worker by calling: OgImage.Worker.start_link(arg)
15 | # {OgImage.Worker, arg},
16 | # Start to serve requests, typically the last entry
17 | OgImageWeb.Endpoint,
18 | # NodeJS processes
19 | {NodeJS.Supervisor,
20 | path: Path.join([Application.app_dir(:og_image), "priv/js"]), pool_size: 4}
21 | ]
22 |
23 | # See https://hexdocs.pm/elixir/Supervisor.html
24 | # for other strategies and supported options
25 | opts = [strategy: :one_for_one, name: OgImage.Supervisor]
26 | Supervisor.start_link(children, opts)
27 | end
28 |
29 | # Tell Phoenix to update the endpoint configuration
30 | # whenever the application is updated.
31 | @impl true
32 | def config_change(changed, _new, removed) do
33 | OgImageWeb.Endpoint.config_change(changed, removed)
34 | :ok
35 | end
36 | end
37 |
--------------------------------------------------------------------------------
/lib/og_image_web/controllers/image_html.ex:
--------------------------------------------------------------------------------
1 | defmodule OgImageWeb.ImageHTML do
2 | use OgImageWeb, :html
3 |
4 | @doc """
5 | A logo and text on a light background.
6 | """
7 | def light(assigns) do
8 | ~H"""
9 |
10 |
11 | <.savvycal_logo />
12 |
13 |
14 |
15 | <%= @text %>
16 |
17 |
18 |
19 | """
20 | end
21 |
22 | @doc """
23 | A logo and text on a dark background.
24 | """
25 | def dark(assigns) do
26 | ~H"""
27 |
28 |
53 |
--------------------------------------------------------------------------------
/config/config.exs:
--------------------------------------------------------------------------------
1 | # This file is responsible for configuring your application
2 | # and its dependencies with the aid of the Config module.
3 | #
4 | # This configuration file is loaded before any dependency and
5 | # is restricted to this project.
6 |
7 | # General application configuration
8 | import Config
9 |
10 | config :og_image, env: Mix.env()
11 |
12 | # Configures the image cache
13 | config :og_image, :image_cache,
14 | version: "1",
15 | max_bytes: 50_000_000,
16 | enabled: true
17 |
18 | config :og_image,
19 | generators: [timestamp_type: :utc_datetime]
20 |
21 | # Configures the endpoint
22 | config :og_image, OgImageWeb.Endpoint,
23 | url: [host: "localhost"],
24 | adapter: Bandit.PhoenixAdapter,
25 | render_errors: [
26 | formats: [html: OgImageWeb.ErrorHTML, json: OgImageWeb.ErrorJSON],
27 | layout: false
28 | ],
29 | pubsub_server: OgImage.PubSub,
30 | live_view: [signing_salt: "df+TKlLj"]
31 |
32 | # Configure esbuild (the version is required)
33 | config :esbuild,
34 | version: "0.17.11",
35 | og_image: [
36 | args:
37 | ~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*),
38 | cd: Path.expand("../assets", __DIR__),
39 | env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)}
40 | ]
41 |
42 | # Configure tailwind (the version is required)
43 | config :tailwind,
44 | version: "3.4.0",
45 | og_image: [
46 | args: ~w(
47 | --config=tailwind.config.js
48 | --input=css/app.css
49 | --output=../priv/static/assets/app.css
50 | ),
51 | cd: Path.expand("../assets", __DIR__)
52 | ]
53 |
54 | # Configures Elixir's Logger
55 | config :logger, :console,
56 | format: "$time $metadata[$level] $message\n",
57 | metadata: [:request_id]
58 |
59 | # Use Jason for JSON parsing in Phoenix
60 | config :phoenix, :json_library, Jason
61 |
62 | # Honeybadger
63 | config :honeybadger, exclude_envs: [:dev, :test]
64 |
65 | # Import environment specific config. This must remain at the bottom
66 | # of this file so it overrides the configuration defined above.
67 | import_config "#{config_env()}.exs"
68 |
--------------------------------------------------------------------------------
/lib/og_image_web/telemetry.ex:
--------------------------------------------------------------------------------
1 | defmodule OgImageWeb.Telemetry do
2 | use Supervisor
3 | import Telemetry.Metrics
4 |
5 | def start_link(arg) do
6 | Supervisor.start_link(__MODULE__, arg, name: __MODULE__)
7 | end
8 |
9 | @impl true
10 | def init(_arg) do
11 | children = [
12 | # Telemetry poller will execute the given period measurements
13 | # every 10_000ms. Learn more here: https://hexdocs.pm/telemetry_metrics
14 | {:telemetry_poller, measurements: periodic_measurements(), period: 10_000}
15 | # Add reporters as children of your supervision tree.
16 | # {Telemetry.Metrics.ConsoleReporter, metrics: metrics()}
17 | ]
18 |
19 | Supervisor.init(children, strategy: :one_for_one)
20 | end
21 |
22 | def metrics do
23 | [
24 | # Phoenix Metrics
25 | summary("phoenix.endpoint.start.system_time",
26 | unit: {:native, :millisecond}
27 | ),
28 | summary("phoenix.endpoint.stop.duration",
29 | unit: {:native, :millisecond}
30 | ),
31 | summary("phoenix.router_dispatch.start.system_time",
32 | tags: [:route],
33 | unit: {:native, :millisecond}
34 | ),
35 | summary("phoenix.router_dispatch.exception.duration",
36 | tags: [:route],
37 | unit: {:native, :millisecond}
38 | ),
39 | summary("phoenix.router_dispatch.stop.duration",
40 | tags: [:route],
41 | unit: {:native, :millisecond}
42 | ),
43 | summary("phoenix.socket_connected.duration",
44 | unit: {:native, :millisecond}
45 | ),
46 | summary("phoenix.channel_joined.duration",
47 | unit: {:native, :millisecond}
48 | ),
49 | summary("phoenix.channel_handled_in.duration",
50 | tags: [:event],
51 | unit: {:native, :millisecond}
52 | ),
53 |
54 | # VM Metrics
55 | summary("vm.memory.total", unit: {:byte, :kilobyte}),
56 | summary("vm.total_run_queue_lengths.total"),
57 | summary("vm.total_run_queue_lengths.cpu"),
58 | summary("vm.total_run_queue_lengths.io")
59 | ]
60 | end
61 |
62 | defp periodic_measurements do
63 | [
64 | # A module, function and arguments to be invoked periodically.
65 | # This function must call :telemetry.execute/3 and a metric must be added above.
66 | # {OgImageWeb, :count_users, []}
67 | ]
68 | end
69 | end
70 |
--------------------------------------------------------------------------------
/lib/og_image_web/components/layouts.ex:
--------------------------------------------------------------------------------
1 | defmodule OgImageWeb.Layouts do
2 | @moduledoc """
3 | This module holds different layouts used by your application.
4 |
5 | See the `layouts` directory for all templates available. The "root" layout is
6 | a skeleton rendered as part of the application router. The "app" layout is set
7 | as the default layout on both `use OgImageWeb, :controller` and `use
8 | OgImageWeb, :live_view`.
9 | """
10 |
11 | use OgImageWeb, :html
12 |
13 | embed_templates "layouts/*"
14 |
15 | @doc """
16 | Renders a
54 | """
55 | end
56 |
57 | # Private helpers
58 |
59 | defp app_css do
60 | File.read!(Path.join([Application.app_dir(:og_image), "priv/static/assets/app.css"]))
61 | end
62 |
63 | defp font_to_base64_url(file_name) do
64 | data =
65 | [Application.app_dir(:og_image), "priv/fonts/#{file_name}"]
66 | |> Path.join()
67 | |> File.read!()
68 | |> Base.encode64()
69 |
70 | "data:font/woff2;charset=utf-8;base64,#{data}"
71 | end
72 |
73 | defp image_to_base64_url(file_name) do
74 | content_type = MIME.from_path(file_name)
75 |
76 | data =
77 | [Application.app_dir(:og_image), "priv/static/images/#{file_name}"]
78 | |> Path.join()
79 | |> File.read!()
80 | |> Base.encode64()
81 |
82 | "data:#{content_type};base64,#{data}"
83 | end
84 | end
85 |
--------------------------------------------------------------------------------
/mix.exs:
--------------------------------------------------------------------------------
1 | defmodule OgImage.MixProject do
2 | use Mix.Project
3 |
4 | def project do
5 | [
6 | app: :og_image,
7 | version: "0.1.0",
8 | elixir: "~> 1.14",
9 | elixirc_paths: elixirc_paths(Mix.env()),
10 | start_permanent: Mix.env() == :prod,
11 | aliases: aliases(),
12 | deps: deps()
13 | ]
14 | end
15 |
16 | # Configuration for the OTP application.
17 | #
18 | # Type `mix help compile.app` for more information.
19 | def application do
20 | [
21 | mod: {OgImage.Application, []},
22 | extra_applications: [:logger, :runtime_tools]
23 | ]
24 | end
25 |
26 | # Specifies which paths to compile per environment.
27 | defp elixirc_paths(:test), do: ["lib", "test/support"]
28 | defp elixirc_paths(_), do: ["lib"]
29 |
30 | # Specifies your project dependencies.
31 | #
32 | # Type `mix help deps` for examples and options.
33 | defp deps do
34 | [
35 | {:phoenix, "~> 1.7.12"},
36 | {:phoenix_html, "~> 4.0"},
37 | {:phoenix_live_reload, "~> 1.2", only: :dev},
38 | {:phoenix_live_view, "~> 0.20.2"},
39 | {:floki, ">= 0.30.0", only: :test},
40 | {:esbuild, "~> 0.8", runtime: Mix.env() == :dev},
41 | {:tailwind, "~> 0.2", runtime: Mix.env() == :dev},
42 | {:heroicons,
43 | github: "tailwindlabs/heroicons",
44 | tag: "v2.1.1",
45 | sparse: "optimized",
46 | app: false,
47 | compile: false,
48 | depth: 1},
49 | {:telemetry_metrics, "~> 1.0"},
50 | {:telemetry_poller, "~> 1.0"},
51 | {:gettext, "~> 0.20"},
52 | {:jason, "~> 1.2"},
53 | {:dns_cluster, "~> 0.1.1"},
54 | {:bandit, "~> 1.2"},
55 | {:nodejs, "~> 2.0"},
56 | {:html_sanitize_ex, "~> 1.4"},
57 | {:new_relic_agent, "~> 1.0"},
58 | {:honeybadger, "~> 0.21"},
59 | {:logflare_logger_backend, "~> 0.11.0"},
60 | {:credo, "~> 1.7", only: [:dev, :test], runtime: false}
61 | ]
62 | end
63 |
64 | # Aliases are shortcuts or tasks specific to the current project.
65 | # For example, to install project dependencies and perform other setup tasks, run:
66 | #
67 | # $ mix setup
68 | #
69 | # See the documentation for `Mix` for more info on aliases.
70 | defp aliases do
71 | [
72 | setup: ["deps.get", "assets.setup", "assets.build"],
73 | "assets.setup": ["tailwind.install --if-missing", "esbuild.install --if-missing"],
74 | "assets.build": ["tailwind og_image", "esbuild og_image"],
75 | "assets.deploy": [
76 | "tailwind og_image --minify",
77 | "esbuild og_image --minify",
78 | "phx.digest"
79 | ]
80 | ]
81 | end
82 | end
83 |
--------------------------------------------------------------------------------
/config/dev.exs:
--------------------------------------------------------------------------------
1 | import Config
2 |
3 | # For development, we disable any cache and enable
4 | # debugging and code reloading.
5 | #
6 | # The watchers configuration can be used to run external
7 | # watchers to your application. For example, we can use it
8 | # to bundle .js and .css sources.
9 | config :og_image, OgImageWeb.Endpoint,
10 | # Binding to loopback ipv4 address prevents access from other machines.
11 | # Change to `ip: {0, 0, 0, 0}` to allow access from other machines.
12 | http: [ip: {127, 0, 0, 1}, port: 4000],
13 | check_origin: false,
14 | code_reloader: true,
15 | debug_errors: true,
16 | secret_key_base: "M9fRJh+mb8+t132zYRXJF7YLVHGi4DsX7fhbHeZgvwS85Q0nZHmDavou9R8UKm1u",
17 | watchers: [
18 | esbuild: {Esbuild, :install_and_run, [:og_image, ~w(--sourcemap=inline --watch)]},
19 | tailwind: {Tailwind, :install_and_run, [:og_image, ~w(--watch)]}
20 | ]
21 |
22 | # ## SSL Support
23 | #
24 | # In order to use HTTPS in development, a self-signed
25 | # certificate can be generated by running the following
26 | # Mix task:
27 | #
28 | # mix phx.gen.cert
29 | #
30 | # Run `mix help phx.gen.cert` for more information.
31 | #
32 | # The `http:` config above can be replaced with:
33 | #
34 | # https: [
35 | # port: 4001,
36 | # cipher_suite: :strong,
37 | # keyfile: "priv/cert/selfsigned_key.pem",
38 | # certfile: "priv/cert/selfsigned.pem"
39 | # ],
40 | #
41 | # If desired, both `http:` and `https:` keys can be
42 | # configured to run both http and https servers on
43 | # different ports.
44 |
45 | # Watch static and templates for browser reloading.
46 | config :og_image, OgImageWeb.Endpoint,
47 | live_reload: [
48 | patterns: [
49 | ~r"priv/static/(?!uploads/).*(js|css|png|jpeg|jpg|gif|svg)$",
50 | ~r"priv/gettext/.*(po)$",
51 | ~r"lib/og_image_web/(controllers|live|components)/.*(ex|heex)$"
52 | ]
53 | ]
54 |
55 | # Enable dev routes for dashboard and mailbox
56 | config :og_image, dev_routes: true
57 |
58 | # Do not include metadata nor timestamps in development logs
59 | config :logger, :console, format: "[$level] $message\n"
60 |
61 | # Set a higher stacktrace during development. Avoid configuring such
62 | # in production as building large stacktraces may be expensive.
63 | config :phoenix, :stacktrace_depth, 20
64 |
65 | # Initialize plugs at runtime for faster development compilation
66 | config :phoenix, :plug_init_mode, :runtime
67 |
68 | config :phoenix_live_view,
69 | # Include HEEx debug annotations as HTML comments in rendered markup
70 | debug_heex_annotations: true,
71 | # Enable helpful, but potentially expensive runtime checks
72 | enable_expensive_runtime_checks: true
73 |
74 | config :honeybadger, environment_name: :dev
75 |
--------------------------------------------------------------------------------
/lib/og_image_web/controllers/image_renderer.ex:
--------------------------------------------------------------------------------
1 | defmodule OgImageWeb.ImageRenderer do
2 | @moduledoc """
3 | Functions responsible for rendering images.
4 | """
5 |
6 | import Phoenix.Controller
7 | import Phoenix.Template, only: [render_to_string: 4]
8 | import Plug.Conn
9 |
10 | alias OgImage.ImageCache
11 | alias OgImageWeb.ImageHTML
12 |
13 | @doc """
14 | Renders the image template as either a PNG or HTML response (depending on the path).
15 | """
16 | @spec render_image(Plug.Conn.t(), template :: atom()) :: Plug.Conn.t()
17 | def render_image(%{path_info: ["image"]} = conn, template) do
18 | image =
19 | if cache_enabled?() do
20 | maybe_get_cached_image(conn, template)
21 | else
22 | generate_image(conn, template)
23 | end
24 |
25 | conn
26 | |> put_resp_content_type("image/png", nil)
27 | |> put_resp_header(
28 | "cache-control",
29 | "public, immutable, no-transform, s-maxage=31536000, max-age=31536000"
30 | )
31 | |> send_resp(200, image)
32 | end
33 |
34 | # When the request path is `/preview`, return the HTML representation
35 | def render_image(%{path_info: ["preview"]} = conn, template) do
36 | render(conn, template)
37 | end
38 |
39 | # Private helpers
40 |
41 | defp maybe_get_cached_image(conn, template) do
42 | cache_key = generate_cache_key(conn)
43 | cache_dir = cache_dir()
44 |
45 | case ImageCache.get_path(cache_dir, cache_key) do
46 | nil ->
47 | generate_image(conn, template)
48 |
49 | path ->
50 | case File.read(path) do
51 | {:ok, data} -> Base.decode64!(data)
52 | {:error, _} -> generate_image(conn, template)
53 | end
54 | end
55 | end
56 |
57 | defp generate_image(conn, template) do
58 | assigns = Map.put(conn.assigns, :layout, {OgImageWeb.Layouts, "image"})
59 | html = render_to_string(ImageHTML, to_string(template), "html", assigns)
60 | image_data = NodeJS.call!("take-screenshot", [html], binary: true)
61 |
62 | if cache_enabled?() do
63 | cache_key = generate_cache_key(conn)
64 | cache_dir = cache_dir()
65 | ImageCache.put(cache_dir, cache_key, image_data, cache_max_bytes())
66 | end
67 |
68 | Base.decode64!(image_data)
69 | end
70 |
71 | defp generate_cache_key(%{query_string: query_string} = _conn) do
72 | version = Application.get_env(:og_image, :image_cache)[:version] || "1"
73 |
74 | query_string_hash =
75 | :sha256
76 | |> :crypto.hash(query_string)
77 | |> Base.url_encode64(padding: false)
78 |
79 | "#{version}.#{query_string_hash}"
80 | end
81 |
82 | defp cache_enabled? do
83 | !!Application.get_env(:og_image, :image_cache)[:enabled]
84 | end
85 |
86 | defp cache_max_bytes do
87 | Application.get_env(:og_image, :image_cache)[:max_bytes] || 1_000_000
88 | end
89 |
90 | defp cache_dir do
91 | Path.join(System.tmp_dir!(), "og_image_cache")
92 | end
93 | end
94 |
--------------------------------------------------------------------------------
/lib/og_image_web.ex:
--------------------------------------------------------------------------------
1 | defmodule OgImageWeb do
2 | @moduledoc """
3 | The entrypoint for defining your web interface, such
4 | as controllers, components, channels, and so on.
5 |
6 | This can be used in your application as:
7 |
8 | use OgImageWeb, :controller
9 | use OgImageWeb, :html
10 |
11 | The definitions below will be executed for every controller,
12 | component, etc, so keep them short and clean, focused
13 | on imports, uses and aliases.
14 |
15 | Do NOT define functions inside the quoted expressions
16 | below. Instead, define additional modules and import
17 | those modules here.
18 | """
19 |
20 | def static_paths, do: ~w(assets fonts images favicon.ico robots.txt)
21 |
22 | def router do
23 | quote do
24 | use Phoenix.Router, helpers: false
25 |
26 | # Import common connection and controller functions to use in pipelines
27 | import Plug.Conn
28 | import Phoenix.Controller
29 | import Phoenix.LiveView.Router
30 | end
31 | end
32 |
33 | def channel do
34 | quote do
35 | use Phoenix.Channel
36 | end
37 | end
38 |
39 | def controller do
40 | quote do
41 | use Phoenix.Controller,
42 | formats: [:html, :json],
43 | layouts: [html: OgImageWeb.Layouts]
44 |
45 | import Plug.Conn
46 | import OgImageWeb.Gettext
47 |
48 | unquote(verified_routes())
49 | end
50 | end
51 |
52 | def live_view do
53 | quote do
54 | use Phoenix.LiveView,
55 | layout: {OgImageWeb.Layouts, :app}
56 |
57 | unquote(html_helpers())
58 | end
59 | end
60 |
61 | def live_component do
62 | quote do
63 | use Phoenix.LiveComponent
64 |
65 | unquote(html_helpers())
66 | end
67 | end
68 |
69 | def html do
70 | quote do
71 | use Phoenix.Component
72 |
73 | # Import convenience functions from controllers
74 | import Phoenix.Controller,
75 | only: [get_csrf_token: 0, view_module: 1, view_template: 1]
76 |
77 | # Include general helpers for rendering HTML
78 | unquote(html_helpers())
79 | end
80 | end
81 |
82 | defp html_helpers do
83 | quote do
84 | # HTML escaping functionality
85 | import Phoenix.HTML
86 |
87 | import OgImageWeb.SharedComponents
88 | import OgImageWeb.Gettext
89 |
90 | # Shortcut for generating JS commands
91 | alias Phoenix.LiveView.JS
92 |
93 | # Routes generation with the ~p sigil
94 | unquote(verified_routes())
95 | end
96 | end
97 |
98 | def verified_routes do
99 | quote do
100 | use Phoenix.VerifiedRoutes,
101 | endpoint: OgImageWeb.Endpoint,
102 | router: OgImageWeb.Router,
103 | statics: OgImageWeb.static_paths()
104 | end
105 | end
106 |
107 | @doc """
108 | When used, dispatch to the appropriate controller/live_view/etc.
109 | """
110 | defmacro __using__(which) when is_atom(which) do
111 | apply(__MODULE__, which, [])
112 | end
113 | end
114 |
--------------------------------------------------------------------------------
/config/runtime.exs:
--------------------------------------------------------------------------------
1 | import Config
2 |
3 | # config/runtime.exs is executed for all environments, including
4 | # during releases. It is executed after compilation and before the
5 | # system starts, so it is typically used to load production configuration
6 | # and secrets from environment variables or elsewhere. Do not define
7 | # any compile-time configuration in here, as it won't be applied.
8 | # The block below contains prod specific runtime configuration.
9 |
10 | # ## Using releases
11 | #
12 | # If you use `mix release`, you need to explicitly enable the server
13 | # by passing the PHX_SERVER=true when you start it:
14 | #
15 | # PHX_SERVER=true bin/og_image start
16 | #
17 | # Alternatively, you can use `mix phx.gen.release` to generate a `bin/server`
18 | # script that automatically sets the env var above.
19 | if System.get_env("PHX_SERVER") do
20 | config :og_image, OgImageWeb.Endpoint, server: true
21 | end
22 |
23 | if config_env() == :prod do
24 | # The secret key base is used to sign/encrypt cookies and other secrets.
25 | # A default value is used in config/dev.exs and config/test.exs but you
26 | # want to use a different value for prod and you most likely don't want
27 | # to check this value into version control, so we use an environment
28 | # variable instead.
29 | secret_key_base =
30 | System.get_env("SECRET_KEY_BASE") ||
31 | raise """
32 | environment variable SECRET_KEY_BASE is missing.
33 | You can generate one by calling: mix phx.gen.secret
34 | """
35 |
36 | host = System.get_env("PHX_HOST") || "example.com"
37 | port = String.to_integer(System.get_env("PORT") || "8080")
38 |
39 | config :og_image, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY")
40 |
41 | config :og_image, OgImageWeb.Endpoint,
42 | url: [host: host, port: 443, scheme: "https"],
43 | http: [
44 | # Enable IPv6 and bind on all interfaces.
45 | # Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access.
46 | # See the documentation on https://hexdocs.pm/bandit/Bandit.html#t:options/0
47 | # for details about using IPv6 vs IPv4 and loopback vs public addresses.
48 | ip: {0, 0, 0, 0, 0, 0, 0, 0},
49 | port: port
50 | ],
51 | secret_key_base: secret_key_base
52 |
53 | maybe_logger_backends =
54 | if System.get_env("LOGFLARE_API_KEY"), do: [LogflareLogger.HttpBackend], else: []
55 |
56 | config :logger, backends: maybe_logger_backends
57 |
58 | # ## SSL Support
59 | #
60 | # To get SSL working, you will need to add the `https` key
61 | # to your endpoint configuration:
62 | #
63 | # config :og_image, OgImageWeb.Endpoint,
64 | # https: [
65 | # ...,
66 | # port: 443,
67 | # cipher_suite: :strong,
68 | # keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"),
69 | # certfile: System.get_env("SOME_APP_SSL_CERT_PATH")
70 | # ]
71 | #
72 | # The `cipher_suite` is set to `:strong` to support only the
73 | # latest and more secure SSL ciphers. This means old browsers
74 | # and clients may not be supported. You can set it to
75 | # `:compatible` for wider support.
76 | #
77 | # `:keyfile` and `:certfile` expect an absolute path to the key
78 | # and cert in disk or a relative path inside priv, for example
79 | # "priv/ssl/server.key". For all supported SSL configuration
80 | # options, see https://hexdocs.pm/plug/Plug.SSL.html#configure/1
81 | #
82 | # We also recommend setting `force_ssl` in your config/prod.exs,
83 | # ensuring no data is ever sent via http, always redirecting to https:
84 | #
85 | # config :og_image, OgImageWeb.Endpoint,
86 | # force_ssl: [hsts: true]
87 | #
88 | # Check `Plug.SSL` for all available options in `force_ssl`.
89 | end
90 |
--------------------------------------------------------------------------------
/priv/js/take-screenshot.js:
--------------------------------------------------------------------------------
1 | const core = require("puppeteer-core");
2 | const fs = require("fs");
3 | const os = require("node:os");
4 |
5 | const executablePath =
6 | process.platform === "win32"
7 | ? "C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe"
8 | : process.platform === "linux"
9 | ? "/usr/bin/google-chrome"
10 | : "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
11 |
12 | /**
13 | * Takes a rendered screenshot of an HTML page.
14 | *
15 | * @param {string} html - the contents of the page.
16 | * @param {boolean} isDev - whether we are in development mode.
17 | * @returns a Base64 encoded string of the screenshot.
18 | */
19 | async function takeScreenshot(html) {
20 | let file;
21 |
22 | const browser = await core.launch({
23 | executablePath,
24 | headless: true,
25 | args: [
26 | "--disable-gpu",
27 | "--disable-dev-shm-usage",
28 | "--disable-setuid-sandbox",
29 | "--no-sandbox",
30 | ],
31 | });
32 |
33 | try {
34 | const page = await browser.newPage();
35 |
36 | // Set the viewport size to match standard open graph image cards
37 | await page.setViewport({ width: 1200, height: 630 });
38 |
39 | // Set the content to our rendered HTML
40 | await page.setContent(html, { waitUntil: "domcontentloaded" });
41 |
42 | // Wait until all images and fonts have loaded
43 | //
44 | // See: https://github.blog/2021-06-22-framework-building-open-graph-images/#some-performance-gotchas
45 | await page.evaluate(async () => {
46 | const selectors = Array.from(document.querySelectorAll("img"));
47 | await Promise.all([
48 | document.fonts.ready,
49 | ...selectors.map((img) => {
50 | // Image has already finished loading, let’s see if it worked
51 | if (img.complete) {
52 | // Image loaded and has presence
53 | if (img.naturalHeight !== 0) return;
54 | // Image failed, so it has no height
55 | throw new Error("Image failed to load");
56 | }
57 | // Image hasn’t loaded yet, added an event listener to know when it does
58 | return new Promise((resolve, reject) => {
59 | img.addEventListener("load", resolve);
60 | img.addEventListener("error", reject);
61 | });
62 | }),
63 | ]);
64 | });
65 |
66 | // Take the screenshot of the page
67 | file = await page.screenshot({ type: "png", encoding: "base64" });
68 |
69 | await page.close();
70 | } finally {
71 | await browser.close();
72 | }
73 |
74 | // Sometimes this fails with `ENOTEMPTY: directory not empty`. This is not
75 | // really supposed to happen, but I suspect when it does it's due to a race
76 | // condition of some kind where a directory is getting modified during the
77 | // recursive delete operation. We can just swallow this and figure it will
78 | // eventually succeed on subsequent requests. It might be a good idea to move
79 | // this into an Oban-managed cron task so that it won't affect the performance
80 | // of the screenshot process.
81 | try {
82 | deletePuppeteerProfiles();
83 | } catch {}
84 |
85 | return file;
86 | }
87 |
88 | /**
89 | * Delete puppeteer profiles from temp directory to free up space
90 | * See: https://github.com/puppeteer/puppeteer/issues/6414
91 | */
92 | function deletePuppeteerProfiles() {
93 | const tmpdir = os.tmpdir();
94 |
95 | fs.readdirSync(tmpdir).forEach((file) => {
96 | if (file.startsWith("puppeteer_dev_chrome_profile")) {
97 | fs.rmSync(`/${tmpdir}/${file}`, { recursive: true, force: true });
98 | }
99 | });
100 | }
101 |
102 | module.exports = takeScreenshot;
103 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | # Find eligible builder and runner images on Docker Hub. We use Ubuntu/Debian instead of
2 | # Alpine to avoid DNS resolution issues in production.
3 | #
4 | # https://hub.docker.com/r/hexpm/elixir/tags?page=1&name=ubuntu
5 | # https://hub.docker.com/_/ubuntu?tab=tags
6 | #
7 | #
8 | # This file is based on these images:
9 | #
10 | # - https://hub.docker.com/r/hexpm/elixir/tags - for the build image
11 | # - https://hub.docker.com/_/debian?tab=tags&page=1&name=bullseye-20210902-slim - for the release image
12 | # - https://pkgs.org/ - resource for finding needed packages
13 | # - Ex: hexpm/elixir:1.13.0-erlang-24.1.7-debian-bullseye-20210902-slim
14 | #
15 | ARG ELIXIR_VERSION=1.16.0
16 | ARG OTP_VERSION=26.2.1
17 | ARG DEBIAN_VERSION=bullseye-20231009-slim
18 |
19 | ARG BUILDER_IMAGE="hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-debian-${DEBIAN_VERSION}"
20 | ARG RUNNER_IMAGE="debian:${DEBIAN_VERSION}"
21 |
22 | FROM ${BUILDER_IMAGE} as builder
23 |
24 | # install build dependencies
25 | RUN apt-get update -y && apt-get install -y build-essential git curl \
26 | && apt-get clean && rm -f /var/lib/apt/lists/*_*
27 |
28 | # install node
29 | # https://github.com/nodesource/distributions#installation-instructions
30 | RUN curl -fsSL https://deb.nodesource.com/setup_18.x | bash - \
31 | && apt-get install -y nodejs
32 |
33 | # install latest npm
34 | RUN npm install -g npm
35 |
36 | # prepare build dir
37 | WORKDIR /app
38 |
39 | # install hex + rebar
40 | RUN mix local.hex --force && \
41 | mix local.rebar --force
42 |
43 | # set build ENV
44 | ENV MIX_ENV="prod"
45 |
46 | # install mix dependencies
47 | COPY mix.exs mix.lock ./
48 | RUN mix deps.get --only $MIX_ENV
49 | RUN mkdir config
50 |
51 | # copy compile-time config files before we compile dependencies
52 | # to ensure any relevant config change will trigger the dependencies
53 | # to be re-compiled.
54 | COPY config/config.exs config/${MIX_ENV}.exs config/
55 | RUN mix deps.compile
56 |
57 | COPY priv priv
58 |
59 | # install npm dependencies
60 | RUN npm --prefix ./priv/js ci --progress=false --no-audit --loglevel=error --cache .npm
61 |
62 | COPY lib lib
63 |
64 | COPY assets assets
65 |
66 | # compile assets
67 | RUN mix assets.deploy
68 |
69 | # Compile the release
70 | RUN mix compile
71 |
72 | # Changes to config/runtime.exs don't require recompiling the code
73 | COPY config/runtime.exs config/
74 |
75 | COPY rel rel
76 | RUN mix release
77 |
78 | # start a new build stage so that the final image will only contain
79 | # the compiled release and other runtime necessities
80 | FROM ${RUNNER_IMAGE}
81 |
82 | # https://dev.to/cloudx/how-to-use-puppeteer-inside-a-docker-container-568c
83 | RUN apt-get update -y \
84 | && apt-get install -y libstdc++6 openssl libncurses5 locales ca-certificates curl gnupg procps \
85 | && curl --location --silent https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add - \
86 | && sh -c 'echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list' \
87 | && apt-get update \
88 | && apt-get install google-chrome-stable -y --no-install-recommends \
89 | && apt-get clean && rm -f /var/lib/apt/lists/*_*
90 |
91 | # install node
92 | # https://github.com/nodesource/distributions#installation-instructions
93 | RUN curl -fsSL https://deb.nodesource.com/setup_18.x | bash - \
94 | && apt-get install -y nodejs
95 |
96 | # Set the locale
97 | RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen
98 |
99 | ENV LANG en_US.UTF-8
100 | ENV LANGUAGE en_US:en
101 | ENV LC_ALL en_US.UTF-8
102 |
103 | WORKDIR "/app"
104 | RUN chown nobody /app
105 |
106 | # set runner ENV
107 | ENV MIX_ENV="prod"
108 | ENV NODE_ENV="production"
109 |
110 | # Only copy the final release from the build stage
111 | COPY --from=builder --chown=nobody:root /app/_build/${MIX_ENV}/rel/og_image ./
112 |
113 | USER nobody
114 |
115 | CMD ["/app/bin/server"]
116 | # Appended by flyctl
117 | ENV ECTO_IPV6 true
118 | ENV ERL_AFLAGS "-proto_dist inet6_tcp"
119 |
--------------------------------------------------------------------------------
/lib/og_image_web/components/shared_components.ex:
--------------------------------------------------------------------------------
1 | defmodule OgImageWeb.SharedComponents do
2 | @moduledoc """
3 | Shared components for templates.
4 | """
5 |
6 | use Phoenix.Component
7 |
8 | @doc """
9 | Renders the SavvyCal logo.
10 | """
11 | attr :height, :string, default: "64"
12 |
13 | def savvycal_logo(assigns) do
14 | ~H"""
15 |
26 | """
27 | end
28 |
29 | @doc """
30 | Renders the SavvyCal logomark.
31 | """
32 | attr :height, :string, default: "56"
33 |
34 | def savvycal_logomark(assigns) do
35 | ~H"""
36 |
40 | """
41 | end
42 | end
43 |
--------------------------------------------------------------------------------
/assets/vendor/topbar.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @license MIT
3 | * topbar 2.0.0, 2023-02-04
4 | * https://buunguyen.github.io/topbar
5 | * Copyright (c) 2021 Buu Nguyen
6 | */
7 | (function (window, document) {
8 | "use strict";
9 |
10 | // https://gist.github.com/paulirish/1579671
11 | (function () {
12 | var lastTime = 0;
13 | var vendors = ["ms", "moz", "webkit", "o"];
14 | for (var x = 0; x < vendors.length && !window.requestAnimationFrame; ++x) {
15 | window.requestAnimationFrame =
16 | window[vendors[x] + "RequestAnimationFrame"];
17 | window.cancelAnimationFrame =
18 | window[vendors[x] + "CancelAnimationFrame"] ||
19 | window[vendors[x] + "CancelRequestAnimationFrame"];
20 | }
21 | if (!window.requestAnimationFrame)
22 | window.requestAnimationFrame = function (callback, element) {
23 | var currTime = new Date().getTime();
24 | var timeToCall = Math.max(0, 16 - (currTime - lastTime));
25 | var id = window.setTimeout(function () {
26 | callback(currTime + timeToCall);
27 | }, timeToCall);
28 | lastTime = currTime + timeToCall;
29 | return id;
30 | };
31 | if (!window.cancelAnimationFrame)
32 | window.cancelAnimationFrame = function (id) {
33 | clearTimeout(id);
34 | };
35 | })();
36 |
37 | var canvas,
38 | currentProgress,
39 | showing,
40 | progressTimerId = null,
41 | fadeTimerId = null,
42 | delayTimerId = null,
43 | addEvent = function (elem, type, handler) {
44 | if (elem.addEventListener) elem.addEventListener(type, handler, false);
45 | else if (elem.attachEvent) elem.attachEvent("on" + type, handler);
46 | else elem["on" + type] = handler;
47 | },
48 | options = {
49 | autoRun: true,
50 | barThickness: 3,
51 | barColors: {
52 | 0: "rgba(26, 188, 156, .9)",
53 | ".25": "rgba(52, 152, 219, .9)",
54 | ".50": "rgba(241, 196, 15, .9)",
55 | ".75": "rgba(230, 126, 34, .9)",
56 | "1.0": "rgba(211, 84, 0, .9)",
57 | },
58 | shadowBlur: 10,
59 | shadowColor: "rgba(0, 0, 0, .6)",
60 | className: null,
61 | },
62 | repaint = function () {
63 | canvas.width = window.innerWidth;
64 | canvas.height = options.barThickness * 5; // need space for shadow
65 |
66 | var ctx = canvas.getContext("2d");
67 | ctx.shadowBlur = options.shadowBlur;
68 | ctx.shadowColor = options.shadowColor;
69 |
70 | var lineGradient = ctx.createLinearGradient(0, 0, canvas.width, 0);
71 | for (var stop in options.barColors)
72 | lineGradient.addColorStop(stop, options.barColors[stop]);
73 | ctx.lineWidth = options.barThickness;
74 | ctx.beginPath();
75 | ctx.moveTo(0, options.barThickness / 2);
76 | ctx.lineTo(
77 | Math.ceil(currentProgress * canvas.width),
78 | options.barThickness / 2
79 | );
80 | ctx.strokeStyle = lineGradient;
81 | ctx.stroke();
82 | },
83 | createCanvas = function () {
84 | canvas = document.createElement("canvas");
85 | var style = canvas.style;
86 | style.position = "fixed";
87 | style.top = style.left = style.right = style.margin = style.padding = 0;
88 | style.zIndex = 100001;
89 | style.display = "none";
90 | if (options.className) canvas.classList.add(options.className);
91 | document.body.appendChild(canvas);
92 | addEvent(window, "resize", repaint);
93 | },
94 | topbar = {
95 | config: function (opts) {
96 | for (var key in opts)
97 | if (options.hasOwnProperty(key)) options[key] = opts[key];
98 | },
99 | show: function (delay) {
100 | if (showing) return;
101 | if (delay) {
102 | if (delayTimerId) return;
103 | delayTimerId = setTimeout(() => topbar.show(), delay);
104 | } else {
105 | showing = true;
106 | if (fadeTimerId !== null) window.cancelAnimationFrame(fadeTimerId);
107 | if (!canvas) createCanvas();
108 | canvas.style.opacity = 1;
109 | canvas.style.display = "block";
110 | topbar.progress(0);
111 | if (options.autoRun) {
112 | (function loop() {
113 | progressTimerId = window.requestAnimationFrame(loop);
114 | topbar.progress(
115 | "+" + 0.05 * Math.pow(1 - Math.sqrt(currentProgress), 2)
116 | );
117 | })();
118 | }
119 | }
120 | },
121 | progress: function (to) {
122 | if (typeof to === "undefined") return currentProgress;
123 | if (typeof to === "string") {
124 | to =
125 | (to.indexOf("+") >= 0 || to.indexOf("-") >= 0
126 | ? currentProgress
127 | : 0) + parseFloat(to);
128 | }
129 | currentProgress = to > 1 ? 1 : to;
130 | repaint();
131 | return currentProgress;
132 | },
133 | hide: function () {
134 | clearTimeout(delayTimerId);
135 | delayTimerId = null;
136 | if (!showing) return;
137 | showing = false;
138 | if (progressTimerId != null) {
139 | window.cancelAnimationFrame(progressTimerId);
140 | progressTimerId = null;
141 | }
142 | (function loop() {
143 | if (topbar.progress("+.1") >= 1) {
144 | canvas.style.opacity -= 0.05;
145 | if (canvas.style.opacity <= 0.05) {
146 | canvas.style.display = "none";
147 | fadeTimerId = null;
148 | return;
149 | }
150 | }
151 | fadeTimerId = window.requestAnimationFrame(loop);
152 | })();
153 | },
154 | };
155 |
156 | if (typeof module === "object" && typeof module.exports === "object") {
157 | module.exports = topbar;
158 | } else if (typeof define === "function" && define.amd) {
159 | define(function () {
160 | return topbar;
161 | });
162 | } else {
163 | this.topbar = topbar;
164 | }
165 | }.call(this, window, document));
166 |
--------------------------------------------------------------------------------
/lib/og_image/image_cache.ex:
--------------------------------------------------------------------------------
1 | defmodule OgImage.ImageCache do
2 | @moduledoc """
3 | Directory-backed cache with a max size (bytes) and FIFO eviction (oldest first),
4 | scoped to a single BEAM node (per-machine).
5 |
6 | - Atomic writes: temp file + rename.
7 | - FIFO = file `mtime` (oldest first).
8 | - Local, non-blocking eviction lock via ETS (no cross-node coordination).
9 | """
10 |
11 | @lock_table :image_cache_locks
12 | @default_ext "bin"
13 |
14 | ## ——— Public API ———
15 |
16 | @doc """
17 | Store `data` under `key` in `cache_dir`, enforce `max_bytes`, and return the absolute path.
18 |
19 | * `key` is hashed to a safe filename; optionally pass `ext` like \"png\".
20 | * If the cache is (briefly) over capacity due to a skipped eviction (another
21 | process is evicting), the next write will clean it up.
22 | """
23 | def put(cache_dir, key, data, max_bytes, ext \\ @default_ext)
24 | when is_binary(cache_dir) and is_binary(key) and is_integer(max_bytes) and max_bytes > 0 do
25 | File.mkdir_p!(cache_dir)
26 |
27 | fname = filename_for(key, ext)
28 | final_path = Path.join(cache_dir, fname)
29 |
30 | # Atomic write: temp then rename
31 | tmp = Path.join(cache_dir, ".#{fname}.#{System.unique_integer([:positive])}.tmp")
32 | File.write!(tmp, data, [:binary])
33 | File.rename!(tmp, final_path)
34 |
35 | enforce_capacity(cache_dir, max_bytes)
36 | final_path
37 | end
38 |
39 | @doc """
40 | Returns the path for `key` if it exists, else `nil`.
41 | """
42 | def get_path(cache_dir, key, ext \\ @default_ext) do
43 | path = Path.join(cache_dir, filename_for(key, ext))
44 | if File.regular?(path), do: path, else: nil
45 | end
46 |
47 | @doc """
48 | Enforces the byte cap by deleting oldest files until total <= max_bytes.
49 |
50 | Concurrency: uses a **local ETS lock** per `cache_dir`. If another process on
51 | this node is evicting, this call will be a no-op (fast exit).
52 | """
53 | def enforce_capacity(cache_dir, max_bytes) when is_integer(max_bytes) and max_bytes > 0 do
54 | ensure_lock_table!()
55 |
56 | lock_id = {:image_cache_lock, Path.expand(cache_dir)}
57 |
58 | case try_acquire_lock(lock_id) do
59 | :acquired ->
60 | try do
61 | do_enforce_capacity(cache_dir, max_bytes)
62 | after
63 | release_lock(lock_id)
64 | end
65 |
66 | :busy ->
67 | # Someone else is evicting. Skip—best effort. Next write will re-check.
68 | :ok
69 | end
70 | end
71 |
72 | ## ——— Internal: eviction + helpers ———
73 |
74 | defp do_enforce_capacity(cache_dir, max_bytes) do
75 | files =
76 | cache_dir
77 | |> list_cache_files()
78 | |> Enum.map(&file_info/1)
79 | |> Enum.reject(&is_nil/1)
80 |
81 | total = Enum.reduce(files, 0, fn %{size: s}, acc -> acc + s end)
82 |
83 | if total <= max_bytes do
84 | :ok
85 | else
86 | evict_until(sorted_oldest_first(files), total, max_bytes)
87 | end
88 | end
89 |
90 | defp evict_until([], _total, _max), do: :ok
91 |
92 | defp evict_until([%{path: path, size: size} | rest], total, max) when total > max do
93 | # Ignore errors—another process may have already removed it
94 | _ = File.rm(path)
95 | evict_until(rest, max(total - size, 0), max)
96 | end
97 |
98 | defp evict_until(_files, _total, _max), do: :ok
99 |
100 | defp sorted_oldest_first(files), do: Enum.sort_by(files, & &1.mtime_posix)
101 |
102 | defp filename_for(key, ext) do
103 | # urlsafe base64 of sha256(key) → short, filesystem-safe; add an ext for convenience
104 | hash = :crypto.hash(:sha256, key) |> Base.url_encode64(padding: false)
105 | "#{hash}.#{ext}"
106 | end
107 |
108 | defp list_cache_files(dir) do
109 | case File.ls(dir) do
110 | {:ok, names} ->
111 | names
112 | # ignore temp/hidden
113 | |> Enum.reject(&String.starts_with?(&1, "."))
114 | |> Enum.map(&Path.join(dir, &1))
115 | |> Enum.filter(&File.regular?/1)
116 |
117 | _ ->
118 | []
119 | end
120 | end
121 |
122 | defp file_info(path) do
123 | # Use posix seconds for stable comparisons
124 | case File.stat(path, time: :posix) do
125 | {:ok, %File.Stat{size: size, mtime: mtime}} -> %{path: path, size: size, mtime_posix: mtime}
126 | _ -> nil
127 | end
128 | end
129 |
130 | ## ——— Local ETS lock (per-node) ———
131 | ##
132 | ## - Non-blocking: first caller acquires; others see :busy and skip eviction.
133 | ## - Crash-safety: if the process dies, the entry is GC'd when the ETS table is deleted
134 | ## on shutdown. For long-running nodes, that's fine because we always release in `after`.
135 | ## - If you prefer blocking, see the commented “blocking retry” helper below.
136 |
137 | defp ensure_lock_table!() do
138 | case :ets.info(@lock_table) do
139 | :undefined ->
140 | # public for simplicity; protected would also be fine
141 | :ets.new(@lock_table, [:named_table, :public, read_concurrency: true])
142 |
143 | _ ->
144 | :ok
145 | end
146 | end
147 |
148 | defp try_acquire_lock(lock_id) do
149 | # Insert new row with owner pid; if it already exists, we’re busy.
150 | # {lock_id, owner_pid}
151 | case :ets.insert_new(@lock_table, {lock_id, self()}) do
152 | true -> :acquired
153 | false -> :busy
154 | end
155 | end
156 |
157 | defp release_lock(lock_id), do: :ets.delete(@lock_table, lock_id)
158 |
159 | # ——— Optional: blocking retry (use instead of `:busy` fast-exit) ———
160 | # defp acquire_lock_with_retry(lock_id, timeout_ms \\ 2_000) do
161 | # started = System.monotonic_time(:millisecond)
162 | # do_acquire(lock_id, started, timeout_ms)
163 | # end
164 | #
165 | # defp do_acquire(lock_id, started, timeout_ms) do
166 | # case try_acquire_lock(lock_id) do
167 | # :acquired -> :acquired
168 | # :busy ->
169 | # if System.monotonic_time(:millisecond) - started > timeout_ms do
170 | # :busy
171 | # else
172 | # Process.sleep(25)
173 | # do_acquire(lock_id, started, timeout_ms)
174 | # end
175 | # end
176 | # end
177 | end
178 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Open Graph Image Generator by [SavvyCal](https://savvycal.com/?utm_source=github&utm_medium=oss&utm_campaign=og-image)
2 |
3 | `og-image` is a web service for generating [Open Graph images](https://opengraphprotocol.org/) for your webpages.
4 | This project was originally inspired by [Vercel OG image](https://github.com/vercel/og-image), with some additional features:
5 |
6 | ✅ Extensible templating system \
7 | ✅ [Tailwind CSS](https://tailwindcss.com/) for styling image templates \
8 | ✅ Emoji support \
9 | ✅ Caching \
10 | ✅ Ready for deployment to [Fly](https://fly.io/)
11 |
12 | The result: beautiful open graph images like this one, generated from custom HTML/CSS templates!
13 |
14 | 
15 |
16 | Source: https://og-image.savvycal.com/image?template=simple_green&text=The+fresh+way+to+find+a+time+to+meet.
17 |
18 | ## Getting started
19 |
20 | Fork this repository and clone it locally. You'll need the following prerequisites installed:
21 |
22 | - [Elixir](https://elixir-lang.org/install.html)
23 | - [Google Chrome](https://www.google.com/chrome/index.html)
24 | - [Node.js (18.x)](https://github.com/nvm-sh/nvm#installing-and-updating)
25 |
26 | Run the bootstrap script to install dependencies:
27 |
28 | ```bash
29 | script/bootstrap
30 | ```
31 |
32 | Then, run the following to boot the server:
33 |
34 | ```bash
35 | script/server
36 | ```
37 |
38 | Visit [http://localhost:4000/image?template=light&text=Hello+World!](http://localhost:4000/image?template=light&text=Hello+World!) to see it in action!
39 |
40 | ## Creating your own templates
41 |
42 | This projects contains `light` and `dark` templates that display a logo and some user-supplied text. These are just a starting point to give you a sense for how it works. Adding new templates and modifying existing ones is easy!
43 |
44 | To get started, open the [`OgImageWeb.ImageController`](https://github.com/svycal/og-image/blob/main/lib/og_image_web/controllers/image_controller.ex) file.
45 |
46 | ```elixir
47 | defmodule OgImageWeb.ImageController do
48 | use OgImageWeb, :controller
49 |
50 | import OgImageWeb.ImageHelpers
51 | import OgImageWeb.ImageRenderer
52 |
53 | # Match on the `template` param to decide which template to render. The
54 | # `render_image` function is a special helper that either renders the PNG
55 | # (when path is `/image`) or renders the HTML (when path is `/preview`).
56 |
57 | def show(conn, %{"template" => "light", "text" => text}) do
58 | conn
59 | |> assign(:text, prepare_html(text))
60 | |> render_image(:light)
61 | end
62 |
63 | def show(conn, %{"template" => "dark", "text" => text}) do
64 | conn
65 | |> assign(:text, prepare_html(text))
66 | |> render_image(:dark)
67 | end
68 |
69 | # -- Add more templates here --
70 |
71 | def show(conn, _params) do
72 | render_image(conn, :fallback)
73 | end
74 | end
75 | ```
76 |
77 | The template markup is defined in the [`OgImageWeb.ImageHTML`](https://github.com/svycal/og-image/blob/main/lib/og_image_web/controllers/image_html.ex) module.
78 |
79 | ```elixir
80 | defmodule OgImageWeb.ImageHTML do
81 | use OgImageWeb, :html
82 |
83 | @doc """
84 | A logo and text on a light background.
85 | """
86 | def light(assigns) do
87 | ~H"""
88 |
89 |
90 | <.savvycal_logo />
91 |
92 |
93 |
94 | <%= @text %>
95 |
96 |
97 |
98 | """
99 | end
100 |
101 | # -- truncated for brevity --
102 | end
103 | ```
104 |
105 | These templates are wired up for Tailwind CSS by default. You're welcome to define reuable components and helper functions (like we've done with the `<.savvycal_logo />` component, which is defined in the `OgImageWeb.SharedComponents` module).
106 |
107 | The image controller serves content over two different routes:
108 |
109 | - `/preview` for an HTML preview of the image contents
110 | - `/image` for the actual rendered image (in PNG format)
111 |
112 | > [!TIP]
113 | > Use the Responsive Mode and set the viewport to `1200 x 630` pixels to see the HTML preview in the same dimensions as the PNG image. This is great for testing and dialing in your designs quickly (without re-rendering the PNG on every change).
114 |
115 | ## Customizing styles
116 |
117 | The CSS styles for image templates are defined in the [`OgImageWeb.Layouts.image_template_styles/1`](https://github.com/svycal/og-image/blob/main/lib/og_image_web/components/layouts.ex) component. For performance, all definitions (including fonts) are inlined inside a `