16 |
17 |
--------------------------------------------------------------------------------
/lib/circle/release.ex:
--------------------------------------------------------------------------------
1 | defmodule Circle.Release do
2 | @moduledoc """
3 | Used for executing DB release tasks when run in production without Mix
4 | installed.
5 | """
6 | @app :circle
7 |
8 | def migrate do
9 | load_app()
10 |
11 | for repo <- repos() do
12 | {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true))
13 | end
14 | end
15 |
16 | def rollback(repo, version) do
17 | load_app()
18 | {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version))
19 | end
20 |
21 | defp repos do
22 | Application.fetch_env!(@app, :ecto_repos)
23 | end
24 |
25 | defp load_app do
26 | Application.load(@app)
27 | end
28 | end
29 |
--------------------------------------------------------------------------------
/lib/circle_web/controllers/error_json.ex:
--------------------------------------------------------------------------------
1 | defmodule CircleWeb.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 |
--------------------------------------------------------------------------------
/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 :circle, CircleWeb.Endpoint, cache_static_manifest: "priv/static/cache_manifest.json"
9 |
10 | # Configures Swoosh API Client
11 | config :swoosh, api_client: Swoosh.ApiClient.Finch, finch_name: Circle.Finch
12 |
13 | # Disable Swoosh Local Memory Storage
14 | config :swoosh, local: false
15 |
16 | # Do not print debug messages in production
17 | config :logger, level: :info
18 |
19 | # Runtime production configuration, including reading
20 | # of environment variables, is done on config/runtime.exs.
21 |
--------------------------------------------------------------------------------
/lib/circle_web/gettext.ex:
--------------------------------------------------------------------------------
1 | defmodule CircleWeb.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 CircleWeb.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: :circle
24 | end
25 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20240907184934_create_videos.exs:
--------------------------------------------------------------------------------
1 | defmodule Circle.Repo.Migrations.CreateVideos do
2 | use Ecto.Migration
3 |
4 | def change do
5 | create table(:videos, primary_key: false) do
6 | add :id, :uuid, primary_key: true
7 | add :title, :string
8 |
9 | add :original_filename, :text, null: false
10 | add :original_extension, :string, null: false
11 | add :original_size, :bigint
12 |
13 | add :views_count, :bigint, default: 0, null: false
14 |
15 | add :original_uploaded_at, :utc_datetime
16 | add :web_uploaded_at, :utc_datetime
17 | add :preview_image_uploaded_at, :utc_datetime
18 |
19 | timestamps(type: :utc_datetime)
20 | end
21 |
22 | create index(:videos, :original_uploaded_at)
23 | create index(:videos, :web_uploaded_at)
24 | end
25 | end
26 |
--------------------------------------------------------------------------------
/lib/circle_web/controllers/error_html.ex:
--------------------------------------------------------------------------------
1 | defmodule CircleWeb.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 CircleWeb, :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/circle_web/controllers/error_html/404.html.heex
14 | # * lib/circle_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 |
--------------------------------------------------------------------------------
/lib/circle_web/components/layouts/root.html.heex:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | <.live_title suffix=" · Phoenix Framework">
8 | <%= assigns[:page_title] || "Circle" %>
9 |
10 |
11 | <%= if not is_nil(assigns[:video]) do %>
12 | <.video_meta title={@video.title} video_id={@video.id} />
13 | <% end %>
14 |
15 |
16 |
18 |
19 |
20 | <%= @inner_content %>
21 |
22 |
23 |
--------------------------------------------------------------------------------
/fly.toml:
--------------------------------------------------------------------------------
1 | # fly.toml app configuration file generated for circlevideos-prod on 2024-09-17T01:32:38+02:00
2 | #
3 | # See https://fly.io/docs/reference/configuration/ for information about how to use this file.
4 | #
5 |
6 | app = 'circlevideos-prod'
7 | primary_region = 'ams'
8 | kill_signal = 'SIGTERM'
9 |
10 | [build]
11 |
12 | [deploy]
13 | release_command = '/app/bin/migrate'
14 |
15 | [env]
16 | AWS_ENDPOINT_URL_S3 = 'fly.storage.tigris.dev'
17 | PHX_HOST = 'circle.poeticoding.com'
18 | PORT = '8080'
19 |
20 | [http_service]
21 | internal_port = 8080
22 | force_https = true
23 | auto_stop_machines = 'stop'
24 | auto_start_machines = true
25 | min_machines_running = 0
26 | processes = ['app']
27 |
28 | [http_service.concurrency]
29 | type = 'connections'
30 | hard_limit = 1000
31 | soft_limit = 1000
32 |
33 | [[vm]]
34 | memory = '1gb'
35 | cpu_kind = 'shared'
36 | cpus = 1
37 |
--------------------------------------------------------------------------------
/.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 | circle-*.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 | .env
--------------------------------------------------------------------------------
/assets/js/uploaders.js:
--------------------------------------------------------------------------------
1 | let Uploaders = {}
2 |
3 | Uploaders.Tigris = function(entries, onViewError){
4 | entries.forEach(entry => {
5 | let formData = new FormData()
6 | let {url, fields} = entry.meta
7 | Object.entries(fields).forEach(([key, val]) => formData.append(key, val))
8 | formData.append("file", entry.file)
9 | let xhr = new XMLHttpRequest()
10 | onViewError(() => xhr.abort())
11 | xhr.onload = () => {
12 | console.log("Uploaders.Tigris onload.status", xhr.status)
13 | console.log(entry)
14 | xhr.status === 200 ? entry.progress(100) : entry.error()
15 | }
16 |
17 | xhr.onerror = () => {
18 | console.log("Uploaders.Tigris XHR Error")
19 | entry.error()
20 | }
21 | xhr.upload.addEventListener("progress", (event) => {
22 | if(event.lengthComputable){
23 | let percent = Math.round((event.loaded / event.total) * 100)
24 | if(percent < 100){
25 | entry.progress(percent)
26 | }
27 | }
28 | })
29 |
30 | xhr.open("POST", url, true)
31 | xhr.send(formData)
32 | })
33 | }
34 |
35 | export default Uploaders;
--------------------------------------------------------------------------------
/lib/circle/videos/video.ex:
--------------------------------------------------------------------------------
1 | defmodule Circle.Videos.Video do
2 | use Ecto.Schema
3 | import Ecto.Changeset
4 | import Ecto.Query
5 |
6 | @type t :: %__MODULE__{}
7 |
8 | @primary_key {:id, :binary_id, autogenerate: true}
9 | schema "videos" do
10 | field :title, :string
11 |
12 | field :original_extension, :string
13 | field :original_filename, :string
14 | field :original_size, :integer
15 | field :original_uploaded_at, :utc_datetime
16 |
17 | field :web_uploaded_at, :utc_datetime
18 | field :preview_image_uploaded_at, :utc_datetime
19 |
20 | field :views_count, :integer
21 | timestamps(type: :utc_datetime)
22 | end
23 |
24 | @doc false
25 | def changeset(video, attrs) do
26 | video
27 | |> cast(attrs, [
28 | :title,
29 | :original_filename,
30 | :original_extension,
31 | :original_size,
32 | :original_uploaded_at,
33 | :web_uploaded_at,
34 | :preview_image_uploaded_at
35 | ])
36 | end
37 |
38 | def increment_views_count_query(video) do
39 | from(v in __MODULE__, where: v.id == ^video.id, update: [inc: [views_count: 1]])
40 | end
41 | end
42 |
--------------------------------------------------------------------------------
/lib/circle_web/controllers/video_html/show.html.heex:
--------------------------------------------------------------------------------
1 |
2 |
9 |
10 |
11 |
17 |
<%= @video.title %>
18 |
19 |
20 | <%= if @video.views_count do %>
21 | <%= ngettext("1 view", "%{count} views", @video.views_count) %>
22 | <% else %>
23 | No views yet
24 | <% end %>
25 |
26 |
27 |
28 |
--------------------------------------------------------------------------------
/test/support/conn_case.ex:
--------------------------------------------------------------------------------
1 | defmodule CircleWeb.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 CircleWeb.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 CircleWeb.Endpoint
24 |
25 | use CircleWeb, :verified_routes
26 |
27 | # Import conveniences for testing with connections
28 | import Plug.Conn
29 | import Phoenix.ConnTest
30 | import CircleWeb.ConnCase
31 | end
32 | end
33 |
34 | setup tags do
35 | Circle.DataCase.setup_sandbox(tags)
36 | {:ok, conn: Phoenix.ConnTest.build_conn()}
37 | end
38 | end
39 |
--------------------------------------------------------------------------------
/lib/circle_web/controllers/video_controller.ex:
--------------------------------------------------------------------------------
1 | defmodule CircleWeb.VideoController do
2 | use CircleWeb, :controller
3 |
4 | alias Circle.VideoStore
5 | alias Circle.Videos
6 | alias Circle.Videos.Video
7 |
8 | plug :fetch_video
9 |
10 | def show(conn, _params) do
11 | Videos.increment_views_count(conn.assigns.video)
12 | {:ok, url} = VideoStore.presigned_download_url(conn.assigns.video, :web)
13 | render(conn, "show.html", video_url: url)
14 | end
15 |
16 | def download(conn, _params) do
17 | {:ok, url} = VideoStore.presigned_download_url(conn.assigns.video, :web)
18 | redirect(conn, external: url)
19 | end
20 |
21 | def preview_image(conn, _params) do
22 | case VideoStore.get_preview_image(conn.assigns.video) do
23 | {:ok, image_data} ->
24 | send_download(conn, {:binary, image_data},
25 | filename: "preview.png",
26 | content_type: "image/png"
27 | )
28 |
29 | _ ->
30 | conn
31 | |> put_status(404)
32 | |> halt()
33 | end
34 | end
35 |
36 | defp fetch_video(conn, _opts) do
37 | case Videos.get_video(conn.params["id"]) do
38 | %Video{} = video ->
39 | assign(conn, :video, video)
40 |
41 | _ ->
42 | conn
43 | |> put_status(:not_found)
44 | |> halt()
45 | end
46 | end
47 | end
48 |
--------------------------------------------------------------------------------
/config/test.exs:
--------------------------------------------------------------------------------
1 | import Config
2 |
3 | # Configure your database
4 | #
5 | # The MIX_TEST_PARTITION environment variable can be used
6 | # to provide built-in test partitioning in CI environment.
7 | # Run `mix help test` for more information.
8 | config :circle, Circle.Repo,
9 | username: "postgres",
10 | password: "postgres",
11 | hostname: "localhost",
12 | database: "circle_test#{System.get_env("MIX_TEST_PARTITION")}",
13 | pool: Ecto.Adapters.SQL.Sandbox,
14 | pool_size: System.schedulers_online() * 2
15 |
16 | # We don't run a server during test. If one is required,
17 | # you can enable the server option below.
18 | config :circle, CircleWeb.Endpoint,
19 | http: [ip: {127, 0, 0, 1}, port: 4002],
20 | secret_key_base: "xo5uAdjOABu8GvgPqVhJr5Xy6NBK1tpnWs7lgP9XAv9clASWw7dj+DxIBAMzGj5j",
21 | server: false
22 |
23 | # In test we don't send emails
24 | config :circle, Circle.Mailer, adapter: Swoosh.Adapters.Test
25 |
26 | # Disable swoosh api client as it is only required for production adapters
27 | config :swoosh, :api_client, false
28 |
29 | # Print only warnings and errors during test
30 | config :logger, level: :warning
31 |
32 | # Initialize plugs at runtime for faster test compilation
33 | config :phoenix, :plug_init_mode, :runtime
34 |
35 | # Enable helpful, but potentially expensive runtime checks
36 | config :phoenix_live_view,
37 | enable_expensive_runtime_checks: true
38 |
--------------------------------------------------------------------------------
/.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/circle/application.ex:
--------------------------------------------------------------------------------
1 | defmodule Circle.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 | @flame_timeout 10 * 60_000
9 |
10 | @impl true
11 | def start(_type, _args) do
12 | flame_parent = FLAME.Parent.get()
13 |
14 | children =
15 | [
16 | CircleWeb.Telemetry,
17 | Circle.Repo,
18 | {DNSCluster, query: Application.get_env(:circle, :dns_cluster_query) || :ignore},
19 | {Phoenix.PubSub, name: Circle.PubSub},
20 | # Start the Finch HTTP client for sending emails
21 | {Finch, name: Circle.Finch},
22 | {
23 | FLAME.Pool,
24 | # 10 mins timeout
25 | name: Circle.FFMpegRunner,
26 | min: 0,
27 | max: 10,
28 | max_concurrency: 2,
29 | timeout: @flame_timeout,
30 | idle_shutdown_after: 30_000
31 | },
32 | !flame_parent && CircleWeb.Endpoint
33 | ]
34 | |> Enum.filter(& &1)
35 |
36 | # See https://hexdocs.pm/elixir/Supervisor.html
37 | # for other strategies and supported options
38 | opts = [strategy: :one_for_one, name: Circle.Supervisor]
39 | Supervisor.start_link(children, opts)
40 | end
41 |
42 | # Tell Phoenix to update the endpoint configuration
43 | # whenever the application is updated.
44 | @impl true
45 | def config_change(changed, _new, removed) do
46 | CircleWeb.Endpoint.config_change(changed, removed)
47 | :ok
48 | end
49 | end
50 |
--------------------------------------------------------------------------------
/lib/circle_web/endpoint.ex:
--------------------------------------------------------------------------------
1 | defmodule CircleWeb.Endpoint do
2 | use Phoenix.Endpoint, otp_app: :circle
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: "_circle_key",
10 | signing_salt: "85338qOi",
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: :circle,
25 | gzip: false,
26 | only: CircleWeb.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 | plug Phoenix.Ecto.CheckRepoStatus, otp_app: :circle
35 | end
36 |
37 | plug Phoenix.LiveDashboard.RequestLogger,
38 | param_key: "request_logger",
39 | cookie_key: "request_logger"
40 |
41 | plug Plug.RequestId
42 | plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint]
43 |
44 | plug Plug.Parsers,
45 | parsers: [:urlencoded, :multipart, :json],
46 | pass: ["*/*"],
47 | json_decoder: Phoenix.json_library()
48 |
49 | plug Plug.MethodOverride
50 | plug Plug.Head
51 | plug Plug.Session, @session_options
52 | plug CircleWeb.Router
53 | end
54 |
--------------------------------------------------------------------------------
/lib/circle/videos/ffmpeg/progress_collector.ex:
--------------------------------------------------------------------------------
1 | defmodule Circle.Videos.FFMpeg.ProgressCollector do
2 | @moduledoc """
3 | Module which implements the `Collectable` protocol
4 | to parse the progress from stdout when running FFMpeg with `System.cmd`.
5 | It then broadcasts via pubsub the progress.
6 | """
7 | require Logger
8 | alias Circle.Videos
9 |
10 | defstruct [:video_id, :total_frames]
11 |
12 | def new(video_id, total_frames), do: %__MODULE__{video_id: video_id, total_frames: total_frames}
13 |
14 | defimpl Collectable, for: __MODULE__ do
15 | def into(coll) do
16 | Logger.metadata(video_id: coll.video_id)
17 |
18 | # Initial state (empty buffer)
19 | {:ok,
20 | fn
21 | _, {:cont, output} when is_binary(output) ->
22 | # Send output to the pid whenever there's new output
23 |
24 | case Regex.run(~r/frame\=(\d+)/, output) do
25 | [_, frame_str] ->
26 | Videos.pubsub_broadcast(
27 | coll.video_id,
28 | {:postprocessing, coll.video_id, {:progress, progress(coll, frame_str)}}
29 | )
30 |
31 | :ok
32 |
33 | error ->
34 | Logger.error("ProgressCollector.into/1 error: #{inspect(error)}")
35 | error
36 | end
37 |
38 | _, :done ->
39 | # When done, we can also send a :done message to the pid
40 | Videos.pubsub_broadcast(
41 | coll.video_id,
42 | {:postprocessing, coll.video_id, {:progress, :done}}
43 | )
44 |
45 | :ok
46 |
47 | _, :halt ->
48 | :ok
49 |
50 | _, _ ->
51 | :ok
52 | end}
53 | end
54 |
55 | defp progress(coll, frame_str) do
56 | round(String.to_integer(frame_str) * 100 / coll.total_frames)
57 | end
58 | end
59 | end
60 |
--------------------------------------------------------------------------------
/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 | import Uploaders from "./uploaders"
26 | import videojs from 'video.js';
27 |
28 |
29 | let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
30 | let liveSocket = new LiveSocket("/live", Socket, {
31 | longPollFallbackMs: 2500,
32 | params: {_csrf_token: csrfToken},
33 | uploaders: Uploaders
34 | })
35 |
36 | // Show progress bar on live navigation and form submits
37 | topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"})
38 | window.addEventListener("phx:page-loading-start", _info => topbar.show(300))
39 | window.addEventListener("phx:page-loading-stop", _info => topbar.hide())
40 |
41 | // connect if there are any LiveViews on the page
42 | liveSocket.connect()
43 |
44 | // expose liveSocket on window for web console debug logs and latency simulation:
45 | // >> liveSocket.enableDebug()
46 | // >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session
47 | // >> liveSocket.disableLatencySim()
48 | window.liveSocket = liveSocket
49 |
50 |
--------------------------------------------------------------------------------
/test/support/data_case.ex:
--------------------------------------------------------------------------------
1 | defmodule Circle.DataCase do
2 | @moduledoc """
3 | This module defines the setup for tests requiring
4 | access to the application's data layer.
5 |
6 | You may define functions here to be used as helpers in
7 | your tests.
8 |
9 | Finally, if the test case interacts with the database,
10 | we enable the SQL sandbox, so changes done to the database
11 | are reverted at the end of every test. If you are using
12 | PostgreSQL, you can even run database tests asynchronously
13 | by setting `use Circle.DataCase, async: true`, although
14 | this option is not recommended for other databases.
15 | """
16 |
17 | use ExUnit.CaseTemplate
18 |
19 | using do
20 | quote do
21 | alias Circle.Repo
22 |
23 | import Ecto
24 | import Ecto.Changeset
25 | import Ecto.Query
26 | import Circle.DataCase
27 | end
28 | end
29 |
30 | setup tags do
31 | Circle.DataCase.setup_sandbox(tags)
32 | :ok
33 | end
34 |
35 | @doc """
36 | Sets up the sandbox based on the test tags.
37 | """
38 | def setup_sandbox(tags) do
39 | pid = Ecto.Adapters.SQL.Sandbox.start_owner!(Circle.Repo, shared: not tags[:async])
40 | on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end)
41 | end
42 |
43 | @doc """
44 | A helper that transforms changeset errors into a map of messages.
45 |
46 | assert {:error, changeset} = Accounts.create_user(%{password: "short"})
47 | assert "password is too short" in errors_on(changeset).password
48 | assert %{password: ["password is too short"]} = errors_on(changeset)
49 |
50 | """
51 | def errors_on(changeset) do
52 | Ecto.Changeset.traverse_errors(changeset, fn {message, opts} ->
53 | Regex.replace(~r"%{(\w+)}", message, fn _, key ->
54 | opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string()
55 | end)
56 | end)
57 | end
58 | end
59 |
--------------------------------------------------------------------------------
/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 :circle,
11 | ecto_repos: [Circle.Repo],
12 | generators: [timestamp_type: :utc_datetime]
13 |
14 | # Configures the endpoint
15 | config :circle, CircleWeb.Endpoint,
16 | url: [host: "localhost"],
17 | adapter: Bandit.PhoenixAdapter,
18 | render_errors: [
19 | formats: [html: CircleWeb.ErrorHTML, json: CircleWeb.ErrorJSON],
20 | layout: false
21 | ],
22 | pubsub_server: Circle.PubSub,
23 | live_view: [signing_salt: "1ByHOGAQ"]
24 |
25 | # Configures the mailer
26 | #
27 | # By default it uses the "Local" adapter which stores the emails
28 | # locally. You can see the emails in your browser, at "/dev/mailbox".
29 | #
30 | # For production it's recommended to configure a different adapter
31 | # at the `config/runtime.exs`.
32 | config :circle, Circle.Mailer, adapter: Swoosh.Adapters.Local
33 |
34 | # Configure esbuild (the version is required)
35 | config :esbuild,
36 | version: "0.17.11",
37 | circle: [
38 | args:
39 | ~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*),
40 | cd: Path.expand("../assets", __DIR__),
41 | env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)}
42 | ]
43 |
44 | # Configure tailwind (the version is required)
45 | config :tailwind,
46 | version: "3.4.3",
47 | circle: [
48 | args: ~w(
49 | --config=tailwind.config.js
50 | --input=css/app.css
51 | --output=../priv/static/assets/app.css
52 | ),
53 | cd: Path.expand("../assets", __DIR__)
54 | ]
55 |
56 | # Configures Elixir's Logger
57 | config :logger, :console,
58 | format: "$time $metadata[$level] $message\n",
59 | metadata: [:request_id, :video_id]
60 |
61 | # Use Jason for JSON parsing in Phoenix
62 | config :phoenix, :json_library, Jason
63 |
64 | # Import environment specific config. This must remain at the bottom
65 | # of this file so it overrides the configuration defined above.
66 | import_config "#{config_env()}.exs"
67 |
--------------------------------------------------------------------------------
/lib/circle_web/router.ex:
--------------------------------------------------------------------------------
1 | defmodule CircleWeb.Router do
2 | use CircleWeb, :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: {CircleWeb.Layouts, :root}
9 | plug :protect_from_forgery
10 | plug :put_secure_browser_headers
11 | end
12 |
13 | pipeline :api do
14 | plug :accepts, ["json"]
15 | end
16 |
17 | pipeline :protected do
18 | plug :auth
19 | end
20 |
21 | scope "/", CircleWeb do
22 | pipe_through [:browser, :protected]
23 |
24 | live "/videos/new", VideoLive.Upload, :new
25 | end
26 |
27 | scope "/", CircleWeb do
28 | pipe_through :browser
29 |
30 | get "/", PageController, :home
31 |
32 | get "/videos/:id", VideoController, :show
33 | get "/videos/:id/preview.jpg", VideoController, :preview_image
34 | get "/videos/:id/download", VideoController, :download
35 | end
36 |
37 | # Other scopes may use custom stacks.
38 | # scope "/api", CircleWeb do
39 | # pipe_through :api
40 | # end
41 |
42 | # Enable LiveDashboard and Swoosh mailbox preview in development
43 | if Application.compile_env(:circle, :dev_routes) do
44 | # If you want to use the LiveDashboard in production, you should put
45 | # it behind authentication and allow only admins to access it.
46 | # If your application does not have an admins-only section yet,
47 | # you can use Plug.BasicAuth to set up some basic authentication
48 | # as long as you are also using SSL (which you should anyway).
49 | import Phoenix.LiveDashboard.Router
50 |
51 | scope "/dev" do
52 | pipe_through :browser
53 |
54 | live_dashboard "/dashboard", metrics: CircleWeb.Telemetry
55 | forward "/mailbox", Plug.Swoosh.MailboxPreview
56 | end
57 | end
58 |
59 | # if user and password are defined, you get a basic auth to create a new video
60 | defp auth(conn, _opts) do
61 | user = Application.fetch_env!(:circle, :basic_auth) |> Keyword.get(:username)
62 | pass = Application.fetch_env!(:circle, :basic_auth) |> Keyword.get(:password)
63 |
64 | if is_nil(user) or is_nil(pass) do
65 | conn
66 | else
67 | Plug.BasicAuth.basic_auth(conn, Application.fetch_env!(:circle, :basic_auth))
68 | end
69 | end
70 | end
71 |
--------------------------------------------------------------------------------
/lib/circle_web/components/upload_components.ex:
--------------------------------------------------------------------------------
1 | defmodule CircleWeb.UploadComponents do
2 | use Phoenix.Component
3 |
4 | @doc """
5 | Shows the upload progress bar
6 |
7 | ## Examples
8 |
9 | <.progress_bar color="blue" label={entry.client_name} progress={entry.progress} />
10 | """
11 | attr :label, :string, required: true
12 | attr :progress, :integer, required: true
13 | attr :color, :string
14 |
15 | def progress_bar(assigns) do
16 | ~H"""
17 |
18 |
19 |
20 | <%= @label %>
21 |
22 |
23 |
24 |
25 |
26 | <%= @progress %>%
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 | """
35 | end
36 |
37 | attr :video_id, :string
38 | attr :title, :string
39 |
40 | def video_meta(assigns) do
41 | ~H"""
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 | """
64 | end
65 | end
66 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Circle
2 | 
3 |
4 | Related Article: **[Effortless Video Sharing with Phoenix LiveView and FLAME](https://www.poeticoding.com/effortless-video-sharing-with-phoenix-liveview-and-flame/)**
5 |
6 |
7 | > Transparency Notice: The article is sponsored by [Tigris](https://www.tigrisdata.com). They've provided resources and compensation, enabling me to explore and share this solution. Tigris is featured for its excellent fit in this use case, chosen based on its merits. I'm really glad for their support in creating content about innovative technologies. I encourage readers to explore all services mentioned to find the best fit for their projects.
8 |
9 | ## Running it yourself
10 |
11 | You first need a [fly.io](http://fly.io) account, with which you can also access to [Tigrisdata.com](https://www.tigrisdata.com).
12 |
13 | 1. To create the [fly.io](http://fly.io) app and the Tigris bucket is quite easy, you simply need to run the `fly launch` command in the root directory of the circle app.
14 | 2. During the app creation you need to set the Tigris bucket you want to create, and fly will do the rest for you (like setting all secrets and most of the environment variables you need).
15 |
16 | 
17 |
18 | 
19 |
20 | 3. The Fly deployment will likely fail because you need to set the `FLY_API_TOKEN`, which is the token the app requires to spawn new machines for FFmpeg processing.
21 |
22 | 
23 |
24 | To resolve this, run the following command to generate a new token and set it in the app's secrets:
25 |
26 | `fly secrets set FLY_API_TOKEN="$(fly auth token)"`
27 |
28 | After setting the token, execute `fly deploy` to rebuild and launch the app.
29 |
30 | 4. Before uploading videos, you need to set the correct CORS settings for the bucket. In the Fly app dashboard, navigate to the *Tigris Object Storage menu* and click on your bucket. This will open a TigrisData web page where you can configure the bucket's settings.
31 |
32 | 
33 |
34 | Under Regions choose “Global”. I’ve also set a TTL to autodelete the files after a certain time. At the moment the app doesn’t support files auto-expire/deletion out of the box.
35 |
36 |
37 | ---
38 |
39 | **Remember that, by default, anyone can access any page!**
40 | If you want to add basic protection to the upload page, you can set the `AUTH_USERNAME` and `AUTH_PASSWORD` env variables (set them as `fly secrets` ) to add basic authentication to the /videos/new route. It's a simple solution. While in production I'd prefer using accounts and LiveView sessions, this basic authentication should suffice for testing the app.
--------------------------------------------------------------------------------
/assets/tailwind.config.js:
--------------------------------------------------------------------------------
1 | // See the Tailwind configuration guide for advanced usage
2 | // https://tailwindcss.com/docs/configuration
3 |
4 | const plugin = require("tailwindcss/plugin")
5 | const fs = require("fs")
6 | const path = require("path")
7 |
8 | module.exports = {
9 | content: [
10 | "./js/**/*.js",
11 | "../lib/circle_web.ex",
12 | "../lib/circle_web/**/*.*ex"
13 | ],
14 | theme: {
15 | extend: {
16 | colors: {
17 | brand: "#FD4F00",
18 | }
19 | },
20 | },
21 | plugins: [
22 | require("@tailwindcss/forms"),
23 | // Allows prefixing tailwind classes with LiveView classes to add rules
24 | // only when LiveView classes are applied, for example:
25 | //
26 | //
27 | //
28 | plugin(({addVariant}) => addVariant("phx-click-loading", [".phx-click-loading&", ".phx-click-loading &"])),
29 | plugin(({addVariant}) => addVariant("phx-submit-loading", [".phx-submit-loading&", ".phx-submit-loading &"])),
30 | plugin(({addVariant}) => addVariant("phx-change-loading", [".phx-change-loading&", ".phx-change-loading &"])),
31 |
32 | // Embeds Heroicons (https://heroicons.com) into your app.css bundle
33 | // See your `CoreComponents.icon/1` for more information.
34 | //
35 | plugin(function({matchComponents, theme}) {
36 | let iconsDir = path.join(__dirname, "../deps/heroicons/optimized")
37 | let values = {}
38 | let icons = [
39 | ["", "/24/outline"],
40 | ["-solid", "/24/solid"],
41 | ["-mini", "/20/solid"],
42 | ["-micro", "/16/solid"]
43 | ]
44 | icons.forEach(([suffix, dir]) => {
45 | fs.readdirSync(path.join(iconsDir, dir)).forEach(file => {
46 | let name = path.basename(file, ".svg") + suffix
47 | values[name] = {name, fullPath: path.join(iconsDir, dir, file)}
48 | })
49 | })
50 | matchComponents({
51 | "hero": ({name, fullPath}) => {
52 | let content = fs.readFileSync(fullPath).toString().replace(/\r?\n|\r/g, "")
53 | let size = theme("spacing.6")
54 | if (name.endsWith("-mini")) {
55 | size = theme("spacing.5")
56 | } else if (name.endsWith("-micro")) {
57 | size = theme("spacing.4")
58 | }
59 | return {
60 | [`--hero-${name}`]: `url('data:image/svg+xml;utf8,${content}')`,
61 | "-webkit-mask": `var(--hero-${name})`,
62 | "mask": `var(--hero-${name})`,
63 | "mask-repeat": "no-repeat",
64 | "background-color": "currentColor",
65 | "vertical-align": "middle",
66 | "display": "inline-block",
67 | "width": size,
68 | "height": size
69 | }
70 | }
71 | }, {values})
72 | })
73 | ]
74 | }
75 |
--------------------------------------------------------------------------------
/lib/circle/video_store.ex:
--------------------------------------------------------------------------------
1 | defmodule Circle.VideoStore do
2 | alias Circle.SimpleS3Upload
3 |
4 | @tigris_host "fly.storage.tigris.dev"
5 |
6 | @presigned_url_default_options [
7 | expires_in: 300
8 | ]
9 |
10 | def bucket, do: System.fetch_env!("BUCKET_NAME")
11 |
12 | @doc """
13 | `:preview_image` videos' thumbnail
14 | `:original` original uploaded video
15 | `:web` video used by the app player
16 | """
17 | def key(video, :preview_image) do
18 | Path.join(["videos", video.id, "preview.jpg"])
19 | end
20 |
21 | def key(video, :original) do
22 | Path.join(["videos", video.id, "original#{video.original_extension}"])
23 | end
24 |
25 | def key(video, :web) do
26 | Path.join(["videos", video.id, "web.mp4"])
27 | end
28 |
29 | def put(key, content, options \\ []) do
30 | ExAws.S3.put_object(bucket(), key, content, options)
31 | |> ExAws.request()
32 | end
33 |
34 | def put_preview_image(video, data) do
35 | put(key(video, :preview_image), data, content_type: "image/jpg")
36 | end
37 |
38 | def get(key) do
39 | ExAws.S3.get_object(bucket(), key)
40 | |> ExAws.request()
41 | |> then(fn
42 | {:ok, %{body: data}} -> {:ok, data}
43 | error -> error
44 | end)
45 | end
46 |
47 | def get_preview_image(video) do
48 | video
49 | |> key(:preview_image)
50 | |> get()
51 | end
52 |
53 | def save_video_file(video, local_video_path, version) do
54 | local_video_path
55 | |> ExAws.S3.Upload.stream_file()
56 | |> ExAws.S3.upload(bucket(), key(video, version), content_type: "video/mp4")
57 | |> ExAws.request()
58 | end
59 |
60 | ## PRESIGNED URL
61 | def presigned_download_url(video, version \\ :original, opts \\ []) do
62 | opts = Keyword.merge(@presigned_url_default_options, opts)
63 |
64 | ExAws.Config.new(:s3)
65 | |> ExAws.S3.presigned_url(:get, bucket(), key(video, version), opts)
66 | end
67 |
68 | @spec presigned_upload_form_url(Video.t(), Phoenix.LiveView.UploadEntry.t(), integer()) :: map()
69 | def presigned_upload_form_url(video, entry, max_file_size) do
70 | bucket = bucket()
71 | key = key(video, :original)
72 |
73 | config = %{
74 | region: System.get_env("AWS_REGION", "auto"),
75 | access_key_id: System.fetch_env!("AWS_ACCESS_KEY_ID"),
76 | secret_access_key: System.fetch_env!("AWS_SECRET_ACCESS_KEY")
77 | }
78 |
79 | {:ok, fields} =
80 | SimpleS3Upload.sign_form_upload(config, bucket,
81 | key: key,
82 | content_type: entry.client_type,
83 | max_file_size: max_file_size,
84 | expires_in: :timer.hours(1)
85 | )
86 |
87 | host = Application.get_env(:ex_aws, :s3) |> Keyword.fetch!(:host)
88 | url = "https://#{bucket}.#{host}"
89 |
90 | %{
91 | uploader: "Tigris",
92 | key: key,
93 | url: url,
94 | fields: fields
95 | }
96 | end
97 | end
98 |
--------------------------------------------------------------------------------
/priv/static/images/logo.svg:
--------------------------------------------------------------------------------
1 |
7 |
--------------------------------------------------------------------------------
/lib/circle_web.ex:
--------------------------------------------------------------------------------
1 | defmodule CircleWeb 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 CircleWeb, :controller
9 | use CircleWeb, :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: CircleWeb.Layouts]
44 |
45 | import Plug.Conn
46 | import CircleWeb.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: {CircleWeb.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 | # Core UI components and translation
87 | import CircleWeb.CoreComponents
88 | import CircleWeb.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: CircleWeb.Endpoint,
102 | router: CircleWeb.Router,
103 | statics: CircleWeb.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 |
--------------------------------------------------------------------------------
/lib/circle_web/live/video_live/upload.html.heex:
--------------------------------------------------------------------------------
1 |
56 | Peace of mind from prototype to production.
57 |
58 |
59 | Build rich, interactive web applications quickly, with less code and fewer moving parts. Join our growing community of developers using Phoenix to craft APIs, HTML5 apps and more, for fun or at scale.
60 |