├── test ├── test_helper.exs ├── og_image_web │ └── controllers │ │ ├── page_controller_test.exs │ │ ├── error_json_test.exs │ │ └── error_html_test.exs ├── support │ └── conn_case.ex └── og_image │ └── image_cache_test.exs ├── rel ├── overlays │ └── bin │ │ ├── server.bat │ │ └── server └── env.sh.eex ├── priv ├── static │ ├── favicon.ico │ ├── images │ │ └── green-texture.jpg │ └── robots.txt ├── fonts │ ├── InterVariable.woff2 │ └── InterVariable-Italic.woff2 ├── js │ ├── package.json │ ├── emojify.js │ ├── take-screenshot.js │ └── package-lock.json └── gettext │ ├── en │ └── LC_MESSAGES │ │ └── errors.po │ └── errors.pot ├── script ├── server └── bootstrap ├── .formatter.exs ├── lib ├── og_image.ex ├── og_image_web │ ├── controllers │ │ ├── page_controller.ex │ │ ├── page_html.ex │ │ ├── image_controller.ex │ │ ├── error_json.ex │ │ ├── error_html.ex │ │ ├── image_helpers.ex │ │ ├── image_html.ex │ │ ├── page_html │ │ │ └── home.html.heex │ │ └── image_renderer.ex │ ├── components │ │ ├── layouts │ │ │ ├── image.html.heex │ │ │ └── root.html.heex │ │ ├── layouts.ex │ │ └── shared_components.ex │ ├── gettext.ex │ ├── router.ex │ ├── endpoint.ex │ └── telemetry.ex ├── og_image │ ├── scrubber.ex │ ├── application.ex │ └── image_cache.ex └── og_image_web.ex ├── assets ├── tailwind.config.js ├── css │ └── app.css ├── js │ └── app.js └── vendor │ └── topbar.js ├── config ├── prod.exs ├── test.exs ├── config.exs ├── dev.exs └── runtime.exs ├── LICENSE.md ├── .gitignore ├── .dockerignore ├── mix.exs ├── Dockerfile ├── README.md ├── .credo.exs └── mix.lock /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /rel/overlays/bin/server.bat: -------------------------------------------------------------------------------- 1 | set PHX_SERVER=true 2 | call "%~dp0\og_image" start 3 | -------------------------------------------------------------------------------- /priv/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/svycal/og-image/HEAD/priv/static/favicon.ico -------------------------------------------------------------------------------- /script/server: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # script/server: Boots the server. 4 | 5 | iex -S mix phx.server 6 | -------------------------------------------------------------------------------- /priv/fonts/InterVariable.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/svycal/og-image/HEAD/priv/fonts/InterVariable.woff2 -------------------------------------------------------------------------------- /priv/static/images/green-texture.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/svycal/og-image/HEAD/priv/static/images/green-texture.jpg -------------------------------------------------------------------------------- /priv/fonts/InterVariable-Italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/svycal/og-image/HEAD/priv/fonts/InterVariable-Italic.woff2 -------------------------------------------------------------------------------- /rel/overlays/bin/server: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -eu 3 | 4 | cd -P -- "$(dirname -- "$0")" 5 | PHX_SERVER=true exec ./og_image start 6 | -------------------------------------------------------------------------------- /test/og_image_web/controllers/page_controller_test.exs: -------------------------------------------------------------------------------- 1 | defmodule OgImageWeb.PageControllerTest do 2 | use OgImageWeb.ConnCase 3 | end 4 | -------------------------------------------------------------------------------- /script/bootstrap: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # script/bootstrap: Installs dependencies in the local environment. 4 | 5 | mix deps.get 6 | cd priv/js && npm install 7 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | import_deps: [:phoenix], 3 | plugins: [Phoenix.LiveView.HTMLFormatter], 4 | inputs: ["*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}"] 5 | ] 6 | -------------------------------------------------------------------------------- /priv/js/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "engines": { 4 | "node": "18.x" 5 | }, 6 | "dependencies": { 7 | "puppeteer-core": "^14.4.1", 8 | "@twemoji/api": "^15" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /priv/static/robots.txt: -------------------------------------------------------------------------------- 1 | # See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file 2 | # 3 | # To ban all spiders from the entire site uncomment the next two lines: 4 | # User-agent: * 5 | # Disallow: / 6 | -------------------------------------------------------------------------------- /lib/og_image.ex: -------------------------------------------------------------------------------- 1 | defmodule OgImage do 2 | @moduledoc """ 3 | OgImage keeps the contexts that define your domain 4 | and business logic. 5 | 6 | Contexts are also responsible for managing your data, regardless 7 | if it comes from the database, an external API or others. 8 | """ 9 | end 10 | -------------------------------------------------------------------------------- /lib/og_image_web/controllers/page_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule OgImageWeb.PageController do 2 | use OgImageWeb, :controller 3 | 4 | def home(conn, _params) do 5 | conn 6 | |> assign(:page_title, "Open Graph Image Generator by SavvyCal") 7 | |> render(:home, layout: false) 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/og_image_web/controllers/page_html.ex: -------------------------------------------------------------------------------- 1 | defmodule OgImageWeb.PageHTML do 2 | @moduledoc """ 3 | This module contains pages rendered by PageController. 4 | 5 | See the `page_html` directory for all templates available. 6 | """ 7 | use OgImageWeb, :html 8 | 9 | embed_templates "page_html/*" 10 | end 11 | -------------------------------------------------------------------------------- /rel/env.sh.eex: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # configure node for distributed erlang with IPV6 support 4 | export ERL_AFLAGS="-proto_dist inet6_tcp" 5 | export ECTO_IPV6="true" 6 | export DNS_CLUSTER_QUERY="${FLY_APP_NAME}.internal" 7 | export RELEASE_DISTRIBUTION="name" 8 | export RELEASE_NODE="${FLY_APP_NAME}-${FLY_IMAGE_REF##*-}@${FLY_PRIVATE_IP}" 9 | -------------------------------------------------------------------------------- /priv/js/emojify.js: -------------------------------------------------------------------------------- 1 | const twemoji = require("@twemoji/api"); 2 | 3 | /** 4 | * Takes a string and converts emoji to images. 5 | * 6 | * @param {*} text - the text to convert. 7 | * @returns a string with the emoji replaced with images. 8 | */ 9 | function emojify(text) { 10 | return twemoji.parse(text); 11 | } 12 | 13 | module.exports = emojify; 14 | -------------------------------------------------------------------------------- /assets/tailwind.config.js: -------------------------------------------------------------------------------- 1 | // See the Tailwind configuration guide for advanced usage 2 | // https://tailwindcss.com/docs/configuration 3 | 4 | module.exports = { 5 | content: [ 6 | "./js/**/*.js", 7 | "../lib/og_image_web.ex", 8 | "../lib/og_image_web/**/*.*ex", 9 | ], 10 | theme: { 11 | extend: {}, 12 | }, 13 | plugins: [], 14 | }; 15 | -------------------------------------------------------------------------------- /lib/og_image_web/components/layouts/image.html.heex: -------------------------------------------------------------------------------- 1 | 2 | 3 | 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 |
29 | <.savvycal_logo /> 30 |
31 |
32 |

33 | <%= @text %> 34 |

35 |
36 | 37 | """ 38 | end 39 | 40 | @doc """ 41 | A fallback image. 42 | """ 43 | def fallback(assigns) do 44 | ~H""" 45 | 46 |
47 | <.savvycal_logo height="148" /> 48 |
49 | 50 | """ 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # This file excludes paths from the Docker build context. 2 | # 3 | # By default, Docker's build context includes all files (and folders) in the 4 | # current directory. Even if a file isn't copied into the container it is still sent to 5 | # the Docker daemon. 6 | # 7 | # There are multiple reasons to exclude files from the build context: 8 | # 9 | # 1. Prevent nested folders from being copied into the container (ex: exclude 10 | # /assets/node_modules when copying /assets) 11 | # 2. Reduce the size of the build context and improve build time (ex. /build, /deps, /doc) 12 | # 3. Avoid sending files containing sensitive information 13 | # 14 | # More information on using .dockerignore is available here: 15 | # https://docs.docker.com/engine/reference/builder/#dockerignore-file 16 | 17 | .dockerignore 18 | 19 | # Ignore git, but keep git HEAD and refs to access current commit hash if needed: 20 | # 21 | # $ cat .git/HEAD | awk '{print ".git/"$2}' | xargs cat 22 | # d0b8727759e1e0e7aa3d41707d12376e373d5ecc 23 | .git 24 | !.git/HEAD 25 | !.git/refs 26 | 27 | # Common development/test artifacts 28 | /cover/ 29 | /doc/ 30 | /test/ 31 | /tmp/ 32 | .elixir_ls 33 | 34 | # Mix artifacts 35 | /_build/ 36 | /deps/ 37 | *.ez 38 | 39 | # Generated on crash by the VM 40 | erl_crash.dump 41 | 42 | # Static artifacts - These should be fetched and built inside the Docker image 43 | /assets/node_modules/ 44 | /priv/static/assets/ 45 | /priv/static/cache_manifest.json 46 | -------------------------------------------------------------------------------- /lib/og_image_web/endpoint.ex: -------------------------------------------------------------------------------- 1 | defmodule OgImageWeb.Endpoint do 2 | use Phoenix.Endpoint, otp_app: :og_image 3 | 4 | # The session will be stored in the cookie and signed, 5 | # this means its contents can be read but not tampered with. 6 | # Set :encryption_salt if you would also like to encrypt it. 7 | @session_options [ 8 | store: :cookie, 9 | key: "_og_image_key", 10 | signing_salt: "qt5i1r0g", 11 | same_site: "Lax" 12 | ] 13 | 14 | # socket "/live", Phoenix.LiveView.Socket, 15 | # websocket: [connect_info: [session: @session_options]], 16 | # longpoll: [connect_info: [session: @session_options]] 17 | 18 | # Serve at "/" the static files from "priv/static" directory. 19 | # 20 | # You should set gzip to true if you are running phx.digest 21 | # when deploying your static files in production. 22 | plug Plug.Static, 23 | at: "/", 24 | from: :og_image, 25 | gzip: false, 26 | only: OgImageWeb.static_paths() 27 | 28 | # Code reloading can be explicitly enabled under the 29 | # :code_reloader configuration of your endpoint. 30 | if code_reloading? do 31 | socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket 32 | plug Phoenix.LiveReloader 33 | plug Phoenix.CodeReloader 34 | end 35 | 36 | plug Plug.RequestId 37 | plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint] 38 | 39 | plug Plug.Parsers, 40 | parsers: [:urlencoded, :multipart, :json], 41 | pass: ["*/*"], 42 | json_decoder: Phoenix.json_library() 43 | 44 | plug Plug.MethodOverride 45 | plug Plug.Head 46 | plug Plug.Session, @session_options 47 | plug OgImageWeb.Router 48 | end 49 | -------------------------------------------------------------------------------- /assets/js/app.js: -------------------------------------------------------------------------------- 1 | // If you want to use Phoenix channels, run `mix help phx.gen.channel` 2 | // to get started and then uncomment the line below. 3 | // import "./user_socket.js" 4 | 5 | // You can include dependencies in two ways. 6 | // 7 | // The simplest option is to put them in assets/vendor and 8 | // import them using relative paths: 9 | // 10 | // import "../vendor/some-package.js" 11 | // 12 | // Alternatively, you can `npm install some-package --prefix assets` and import 13 | // them using a path starting with the package name: 14 | // 15 | // import "some-package" 16 | // 17 | 18 | // Include phoenix_html to handle method=PUT/DELETE in forms and buttons. 19 | import "phoenix_html" 20 | // Establish Phoenix Socket and LiveView configuration. 21 | // import {Socket} from "phoenix" 22 | // import {LiveSocket} from "phoenix_live_view" 23 | // import topbar from "../vendor/topbar" 24 | 25 | // let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") 26 | // let liveSocket = new LiveSocket("/live", Socket, { 27 | // longPollFallbackMs: 2500, 28 | // params: {_csrf_token: csrfToken} 29 | // }) 30 | 31 | // Show progress bar on live navigation and form submits 32 | // topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"}) 33 | // window.addEventListener("phx:page-loading-start", _info => topbar.show(300)) 34 | // window.addEventListener("phx:page-loading-stop", _info => topbar.hide()) 35 | 36 | // connect if there are any LiveViews on the page 37 | // liveSocket.connect() 38 | 39 | // expose liveSocket on window for web console debug logs and latency simulation: 40 | // >> liveSocket.enableDebug() 41 | // >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session 42 | // >> liveSocket.disableLatencySim() 43 | // window.liveSocket = liveSocket 44 | 45 | -------------------------------------------------------------------------------- /lib/og_image_web/controllers/page_html/home.html.heex: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | <.savvycal_logomark /> 5 |
6 | 7 |

8 | Open Graph Image Generator 9 | 10 | is a little web service for generating dynamic social preview images, like this one! 11 | 12 |

13 | 14 |
15 |
16 | OG image example 20 |
21 |
22 | Source: 23 | 27 | https://og-image.savvycal.com/image?template=simple_green&text=The+fresh+way+to+find+a+time+to+meet 28 | 29 |
30 |
31 | 32 |

33 | 37 | Fork the repo on GitHub 38 | 39 | and follow the instructions in the README to start generating your own images! 40 |

41 | 42 |

43 | This project is maintained with ♥️ by 44 | 48 | SavvyCal 49 | . 50 |

51 |
52 |
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 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 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 | 37 | 38 | 39 | 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 | ![OG image example](https://og-image.savvycal.com/image?template=simple_green&text=The+fresh+way+to+find+a+time+to+meet.) 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 `