diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..61a7393 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,45 @@ +# This file excludes paths from the Docker build context. +# +# By default, Docker's build context includes all files (and folders) in the +# current directory. Even if a file isn't copied into the container it is still sent to +# the Docker daemon. +# +# There are multiple reasons to exclude files from the build context: +# +# 1. Prevent nested folders from being copied into the container (ex: exclude +# /assets/node_modules when copying /assets) +# 2. Reduce the size of the build context and improve build time (ex. /build, /deps, /doc) +# 3. Avoid sending files containing sensitive information +# +# More information on using .dockerignore is available here: +# https://docs.docker.com/engine/reference/builder/#dockerignore-file + +.dockerignore + +# Ignore git, but keep git HEAD and refs to access current commit hash if needed: +# +# $ cat .git/HEAD | awk '{print ".git/"$2}' | xargs cat +# d0b8727759e1e0e7aa3d41707d12376e373d5ecc +.git +!.git/HEAD +!.git/refs + +# Common development/test artifacts +/cover/ +/doc/ +/test/ +/tmp/ +.elixir_ls + +# Mix artifacts +/_build/ +/deps/ +*.ez + +# Generated on crash by the VM +erl_crash.dump + +# Static artifacts - These should be fetched and built inside the Docker image +/assets/node_modules/ +/priv/static/assets/ +/priv/static/cache_manifest.json diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..dafac20 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,97 @@ +# Find eligible builder and runner images on Docker Hub. We use Ubuntu/Debian +# instead of Alpine to avoid DNS resolution issues in production. +# +# https://hub.docker.com/r/hexpm/elixir/tags?page=1&name=ubuntu +# https://hub.docker.com/_/ubuntu?tab=tags +# +# This file is based on these images: +# +# - https://hub.docker.com/r/hexpm/elixir/tags - for the build image +# - https://hub.docker.com/_/debian?tab=tags&page=1&name=bullseye-20240130-slim - for the release image +# - https://pkgs.org/ - resource for finding needed packages +# - Ex: hexpm/elixir:1.15.7-erlang-26.2.1-debian-bullseye-20240130-slim +# +ARG ELIXIR_VERSION=1.15.7 +ARG OTP_VERSION=26.2.1 +ARG DEBIAN_VERSION=bullseye-20240130-slim + +ARG BUILDER_IMAGE="hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-debian-${DEBIAN_VERSION}" +ARG RUNNER_IMAGE="debian:${DEBIAN_VERSION}" + +FROM ${BUILDER_IMAGE} as builder + +# install build dependencies +RUN apt-get update -y && apt-get install -y build-essential git \ + && apt-get clean && rm -f /var/lib/apt/lists/*_* + +# prepare build dir +WORKDIR /app + +# install hex + rebar +RUN mix local.hex --force && \ + mix local.rebar --force + +# set build ENV +ENV MIX_ENV="prod" + +# install mix dependencies +COPY mix.exs mix.lock ./ +RUN mix deps.get --only $MIX_ENV +RUN mkdir config + +# copy compile-time config files before we compile dependencies +# to ensure any relevant config change will trigger the dependencies +# to be re-compiled. +COPY config/config.exs config/${MIX_ENV}.exs config/ +RUN mix deps.compile + +COPY priv priv + +COPY lib lib + +COPY assets assets + +# compile assets +RUN mix assets.deploy + +# Compile the release +RUN mix compile + +# Changes to config/runtime.exs don't require recompiling the code +COPY config/runtime.exs config/ + +COPY rel rel +RUN mix release + +# start a new build stage so that the final image will only contain +# the compiled release and other runtime necessities +FROM ${RUNNER_IMAGE} + +RUN apt-get update -y && \ + apt-get install -y libstdc++6 openssl libncurses5 locales ca-certificates \ + && apt-get clean && rm -f /var/lib/apt/lists/*_* + +# Set the locale +RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen + +ENV LANG en_US.UTF-8 +ENV LANGUAGE en_US:en +ENV LC_ALL en_US.UTF-8 + +WORKDIR "/app" +RUN chown nobody /app + +# set runner ENV +ENV MIX_ENV="prod" + +# Only copy the final release from the build stage +COPY --from=builder --chown=nobody:root /app/_build/${MIX_ENV}/rel/postland ./ + +USER nobody + +# If using an environment that doesn't automatically reap zombie processes, it is +# advised to add an init process such as tini via `apt-get install` +# above and adding an entrypoint. See https://github.com/krallin/tini for details +# ENTRYPOINT ["/tini", "--"] + +CMD ["/app/bin/server"] diff --git a/fly.toml b/fly.toml new file mode 100644 index 0000000..78693f1 --- /dev/null +++ b/fly.toml @@ -0,0 +1,37 @@ +# fly.toml app configuration file generated for postland on 2024-09-25T19:39:34-05:00 +# +# See https://fly.io/docs/reference/configuration/ for information about how to use this file. +# + +app = 'postland' +primary_region = 'dfw' +kill_signal = 'SIGTERM' + +[build] + +[env] + DATABASE_PATH = '/mnt/name/name.db' + PHX_HOST = 'postland.fly.dev' + PORT = '8080' + +[[mounts]] + source = 'name' + destination = '/mnt/name' + +[http_service] + internal_port = 8080 + force_https = true + auto_stop_machines = 'stop' + auto_start_machines = true + min_machines_running = 0 + processes = ['app'] + + [http_service.concurrency] + type = 'connections' + hard_limit = 1000 + soft_limit = 1000 + +[[vm]] + memory = '1gb' + cpu_kind = 'shared' + cpus = 1 diff --git a/lib/activity_pub/headers.ex b/lib/activity_pub/headers.ex index de6f3c3..24025a9 100644 --- a/lib/activity_pub/headers.ex +++ b/lib/activity_pub/headers.ex @@ -91,7 +91,6 @@ defmodule ActivityPub.Headers do case Req.get(request) do {:ok, result} -> - dbg(result) key_map = result.body["publicKey"] if key_map["id"] == key_id do diff --git a/lib/postland/activities.ex b/lib/postland/activities.ex new file mode 100644 index 0000000..0b216be --- /dev/null +++ b/lib/postland/activities.ex @@ -0,0 +1,10 @@ +defmodule Postland.Activities do + alias Postland.Activity + alias Postland.Repo + + def record_activity(params) do + params + |> Activity.changeset() + |> Repo.insert() + end +end diff --git a/lib/postland/activity.ex b/lib/postland/activity.ex new file mode 100644 index 0000000..2da246a --- /dev/null +++ b/lib/postland/activity.ex @@ -0,0 +1,25 @@ +defmodule Postland.Activity do + use Ecto.Schema + + import Ecto.Changeset + + @primary_key {:id, :binary_id, autogenerate: false} + schema "activities" do + field :actor_id, :string + field :type, :string + field :data, :map + end + + def changeset(attrs) do + attrs = %{ + "actor_id" => Map.get(attrs, "actor"), + "type" => Map.get(attrs, "type"), + "data" => attrs + } + + %__MODULE__{} + |> cast(attrs, [:actor_id, :type, :data]) + |> validate_required(:data) + |> validate_required(:type) + end +end diff --git a/lib/postland/application.ex b/lib/postland/application.ex index bc98395..f7485d6 100644 --- a/lib/postland/application.ex +++ b/lib/postland/application.ex @@ -21,6 +21,8 @@ defmodule Postland.Application do Postland.Setup ] + Postland.Release.migrate() + # See https://hexdocs.pm/elixir/Supervisor.html # for other strategies and supported options opts = [strategy: :one_for_one, name: Postland.Supervisor] diff --git a/lib/postland/release.ex b/lib/postland/release.ex new file mode 100644 index 0000000..44dfad7 --- /dev/null +++ b/lib/postland/release.ex @@ -0,0 +1,41 @@ +defmodule Postland.Release do + @moduledoc """ + Used for executing DB release tasks when run in production without Mix + installed. + """ + @app :postland + + def create_db do + load_app() + + case Postland.Repo.__adapter__().storage_up(Postland.Repo.config()) do + {:error, :already_up} -> + :ok + other -> + other + end + end + + def migrate do + load_app() + + create_db() + + for repo <- repos() do + {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true)) + end + end + + def rollback(repo, version) do + load_app() + {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version)) + end + + defp repos do + Application.fetch_env!(@app, :ecto_repos) + end + + defp load_app do + Application.load(@app) + end +end diff --git a/lib/postland_web/components/layouts/root.html.heex b/lib/postland_web/components/layouts/root.html.heex index 8d2d1a6..e08cef8 100644 --- a/lib/postland_web/components/layouts/root.html.heex +++ b/lib/postland_web/components/layouts/root.html.heex @@ -39,14 +39,6 @@ <% else %> -
  • - <.link - href={~p"/users/register"} - class="text-[0.8125rem] leading-6 font-semibold hover:text-purple-300" - > - Register - -
  • <.link href={~p"/users/log_in"} diff --git a/lib/postland_web/controllers/actor_json.ex b/lib/postland_web/controllers/actor_json.ex index 0f75937..ad0e7e9 100644 --- a/lib/postland_web/controllers/actor_json.ex +++ b/lib/postland_web/controllers/actor_json.ex @@ -15,6 +15,7 @@ defmodule PostlandWeb.ActorJSON do "type" => "Person", "preferredUsername" => user.username, "inbox" => url(~p"/inbox"), + "outbox" => url(~p"/outbox"), "publicKey" => %{ "id" => url(~p"/actor#main-key"), "owner" => url(~p"/actor"), diff --git a/lib/postland_web/controllers/inbox_controller.ex b/lib/postland_web/controllers/inbox_controller.ex new file mode 100644 index 0000000..f43525a --- /dev/null +++ b/lib/postland_web/controllers/inbox_controller.ex @@ -0,0 +1,22 @@ +defmodule PostlandWeb.InboxController do + use PostlandWeb, :controller + + require Logger + + alias ActivityPub.Headers + alias Postland.Activities + + def post(conn, params) do + if Headers.verify(conn.method, conn.request_path, conn.req_headers) do + case Activities.record_activity(params) do + {:ok, _activity} -> + render(conn, :ok) + error -> + Logger.error(error) + render(conn, :unprocessable_entity) + end + else + render(conn, :forbidden) + end + end +end diff --git a/lib/postland_web/controllers/outbox_controller.ex b/lib/postland_web/controllers/outbox_controller.ex new file mode 100644 index 0000000..f8690a9 --- /dev/null +++ b/lib/postland_web/controllers/outbox_controller.ex @@ -0,0 +1,21 @@ +defmodule PostlandWeb.OutboxController do + use PostlandWeb, :controller + + alias ActivityPub.Headers + + def get(conn, _params) do + json = %{ + "@context" => "https://www.w3.org/ns/activitystreams", + "summary" => "", + "type" => "OrderedCollection", + "totalItems" => 0, + "orderedItems" => [] + } + + if Headers.verify(conn.method, conn.request_path, conn.req_headers) do + render(conn, json) + else + render(conn, :forbidden) + end + end +end diff --git a/lib/postland_web/router.ex b/lib/postland_web/router.ex index 2743bd4..60a9c50 100644 --- a/lib/postland_web/router.ex +++ b/lib/postland_web/router.ex @@ -27,6 +27,8 @@ defmodule PostlandWeb.Router do get "/.well-known/webfinger", WebfingerController, :get get "/actor", ActorController, :get + get "/inbox", InboxController, :post + get "/outbox", OutboxController, :get end scope "/", PostlandWeb do diff --git a/priv/repo/migrations/20240924132400_create_activities.exs b/priv/repo/migrations/20240924132400_create_activities.exs new file mode 100644 index 0000000..f06afa1 --- /dev/null +++ b/priv/repo/migrations/20240924132400_create_activities.exs @@ -0,0 +1,15 @@ +defmodule Postland.Repo.Migrations.CreateActivities do + use Ecto.Migration + + def change do + create table("activities", primary_key: false) do + add :id, :text, primary_key: true + add :actor_id, :text + add :type, :text, null: false + add :data, :map + end + + create index("activities", [:actor_id, :type]) + create index("activities", [:actor_id]) + end +end diff --git a/rel/env.sh.eex b/rel/env.sh.eex new file mode 100755 index 0000000..efeb7ff --- /dev/null +++ b/rel/env.sh.eex @@ -0,0 +1,13 @@ +#!/bin/sh + +# configure node for distributed erlang with IPV6 support +export ERL_AFLAGS="-proto_dist inet6_tcp" +export ECTO_IPV6="true" +export DNS_CLUSTER_QUERY="${FLY_APP_NAME}.internal" +export RELEASE_DISTRIBUTION="name" +export RELEASE_NODE="${FLY_APP_NAME}-${FLY_IMAGE_REF##*-}@${FLY_PRIVATE_IP}" + +# Uncomment to send crash dumps to stderr +# This can be useful for debugging, but may log sensitive information +# export ERL_CRASH_DUMP=/dev/stderr +# export ERL_CRASH_DUMP_BYTES=4096 diff --git a/rel/overlays/bin/migrate b/rel/overlays/bin/migrate new file mode 100755 index 0000000..cbbfe34 --- /dev/null +++ b/rel/overlays/bin/migrate @@ -0,0 +1,5 @@ +#!/bin/sh +set -eu + +cd -P -- "$(dirname -- "$0")" +exec ./postland eval Postland.Release.migrate diff --git a/rel/overlays/bin/migrate.bat b/rel/overlays/bin/migrate.bat new file mode 100755 index 0000000..6cfd9b7 --- /dev/null +++ b/rel/overlays/bin/migrate.bat @@ -0,0 +1 @@ +call "%~dp0\postland" eval Postland.Release.migrate diff --git a/rel/overlays/bin/server b/rel/overlays/bin/server new file mode 100755 index 0000000..07c46bc --- /dev/null +++ b/rel/overlays/bin/server @@ -0,0 +1,5 @@ +#!/bin/sh +set -eu + +cd -P -- "$(dirname -- "$0")" +PHX_SERVER=true exec ./postland start diff --git a/rel/overlays/bin/server.bat b/rel/overlays/bin/server.bat new file mode 100755 index 0000000..2bb369d --- /dev/null +++ b/rel/overlays/bin/server.bat @@ -0,0 +1,2 @@ +set PHX_SERVER=true +call "%~dp0\postland" start diff --git a/test/activity_pub/headers_test.exs b/test/activity_pub/headers_test.exs index b276be9..f7cb091 100644 --- a/test/activity_pub/headers_test.exs +++ b/test/activity_pub/headers_test.exs @@ -13,10 +13,13 @@ defmodule ActivityPub.HeadersTest do |> :public_key.pem_entry_decode() actor_fetcher = fn _ -> - Postland.KeyFixtures.public() - |> :public_key.pem_decode() - |> hd() - |> :public_key.pem_entry_decode() + pub_key = + Postland.KeyFixtures.public() + |> :public_key.pem_decode() + |> hd() + |> :public_key.pem_entry_decode() + + {:ok, pub_key} end check all( @@ -25,7 +28,7 @@ defmodule ActivityPub.HeadersTest do host1 <- StreamData.string([?a..?z, ?0..?9, ?-]), host2 <- StreamData.string([?a..?z, ?0..?9, ?-]) ) do - url = URI.parse("#{host1}/inbox") + url = URI.parse("#{host1}.com/inbox") actor_url = "#{host2}/actor" headers = signing_headers(method, url, body, actor_url, private) diff --git a/test/postland_web/live/user_login_live_test.exs b/test/postland_web/live/user_login_live_test.exs index 34f6ec2..b3bb092 100644 --- a/test/postland_web/live/user_login_live_test.exs +++ b/test/postland_web/live/user_login_live_test.exs @@ -5,13 +5,6 @@ defmodule PostlandWeb.UserLoginLiveTest do import Postland.AccountsFixtures describe "Log in page" do - test "renders log in page", %{conn: conn} do - {:ok, _lv, html} = live(conn, ~p"/users/log_in") - - assert html =~ "Log in" - assert html =~ "Register" - end - test "redirects if already logged in", %{conn: conn} do result = conn