From ca231cf20b2f1b991f804ee99d76c66b7fde0dd4 Mon Sep 17 00:00:00 2001 From: Darwin Wu Date: Thu, 13 Aug 2026 23:27:58 +0000 Subject: [PATCH 1/3] feat: add paste audience policy Amp-Thread-ID: https://ampcode.com/threads/T-019fe90d-2481-7078-be8d-337ebc9d3545 --- lib/textbin/organizations.ex | 47 +++++++++-- lib/textbin/organizations/workspace.ex | 31 ++++++- lib/textbin/pastes.ex | 84 +++++++++++++------ lib/textbin/pastes/paste.ex | 32 +++++-- .../controllers/apiv1/paste_controller.ex | 3 +- .../controllers/apiv1/paste_json.ex | 6 +- lib/textbin_web/live/ui/paste_live.ex | 10 ++- .../live/ui/paste_live/detail.html.heex | 4 +- .../live/ui/paste_live/index.html.heex | 10 +-- lib/textbin_web/live/ui/workspace_live.ex | 41 +++++++-- ...260813230000_add_paste_audience_policy.exs | 59 +++++++++++++ .../pastes/expiration_cleaner_test.exs | 2 +- test/textbin/pastes_test.exs | 79 +++++++++++++++-- .../apiv1/paste_controller_test.exs | 19 ++++- .../controllers/paste_controller_test.exs | 2 +- test/textbin_web/live/ui/paste_live_test.exs | 32 +++---- .../live/ui/workspace_live_test.exs | 45 ++++++++++ 17 files changed, 416 insertions(+), 90 deletions(-) create mode 100644 priv/repo/migrations/20260813230000_add_paste_audience_policy.exs diff --git a/lib/textbin/organizations.ex b/lib/textbin/organizations.ex index 9d503f4..42b5eb9 100644 --- a/lib/textbin/organizations.ex +++ b/lib/textbin/organizations.ex @@ -12,6 +12,7 @@ defmodule Textbin.Organizations do alias Textbin.Accounts.{Scope, User} alias Textbin.Organizations.{Organization, OrganizationMembership, Policy} alias Textbin.Organizations.{Workspace, WorkspaceMembership} + alias Textbin.Pastes.Paste alias Textbin.Repo def create_organization(%Scope{user: %User{} = creator}, attrs) do @@ -282,14 +283,21 @@ defmodule Textbin.Organizations do def join_workspace(_, _), do: {:error, :not_found} def change_workspace_visibility(%Scope{} = scope, %Workspace{} = workspace, visibility) do + change_workspace_settings(scope, workspace, %{visibility: visibility}) + end + + def change_workspace_visibility(_, _, _), do: {:error, :not_found} + + def change_workspace_settings(%Scope{} = scope, %Workspace{} = workspace, attrs) + when is_map(attrs) do workspace_transaction( scope, workspace, - &change_workspace_visibility_in_transaction(&1, &2, &3, &4, visibility) + &change_workspace_settings_in_transaction(&1, &2, &3, &4, attrs) ) end - def change_workspace_visibility(_, _, _), do: {:error, :not_found} + def change_workspace_settings(_, _, _), do: {:error, :not_found} def delete_workspace(%Scope{} = scope, %Workspace{} = workspace) do workspace_transaction(scope, workspace, &delete_workspace_in_transaction/4) @@ -482,20 +490,43 @@ defmodule Textbin.Organizations do end end - defp change_workspace_visibility_in_transaction( + defp change_workspace_settings_in_transaction( _organization, _org_actor, actor, workspace, - visibility + attrs ) do - with :ok <- Policy.authorize_workspace_change(actor) do - workspace - |> Workspace.changeset(%{visibility: visibility}) - |> Repo.update() + with :ok <- Policy.authorize_workspace_change(actor), + {:ok, updated_workspace} <- + workspace + |> Workspace.changeset(attrs) + |> Repo.update() do + clamp_paste_audiences(updated_workspace) + {:ok, updated_workspace} end end + defp clamp_paste_audiences(%Workspace{id: workspace_id, external_sharing_policy: "disabled"}) do + Repo.update_all( + from(paste in Paste, + where: paste.workspace_id == ^workspace_id and paste.audience != "workspace" + ), + set: [audience: "workspace"] + ) + end + + defp clamp_paste_audiences(%Workspace{id: workspace_id, external_sharing_policy: "unlisted"}) do + Repo.update_all( + from(paste in Paste, + where: paste.workspace_id == ^workspace_id and paste.audience == "public" + ), + set: [audience: "unlisted"] + ) + end + + defp clamp_paste_audiences(%Workspace{external_sharing_policy: "public"}), do: {0, nil} + defp delete_workspace_in_transaction(_organization, _org_actor, actor, workspace) do with :ok <- Policy.authorize_workspace_change(actor), false <- workspace.is_default do diff --git a/lib/textbin/organizations/workspace.ex b/lib/textbin/organizations/workspace.ex index 220416a..4d4d422 100644 --- a/lib/textbin/organizations/workspace.ex +++ b/lib/textbin/organizations/workspace.ex @@ -6,11 +6,13 @@ defmodule Textbin.Organizations.Workspace do @primary_key {:id, :binary_id, autogenerate: true} @foreign_key_type :binary_id @visibilities ["open", "private"] + @external_sharing_policies ["disabled", "unlisted", "public"] schema "workspaces" do field :name, :string field :slug, :string field :visibility, :string, default: "open" + field :external_sharing_policy, :string, default: "disabled" field :is_default, :boolean, default: false belongs_to :organization, Textbin.Organizations.Organization @@ -23,12 +25,21 @@ defmodule Textbin.Organizations.Workspace do def changeset(workspace, attrs) do workspace - |> cast(attrs, [:name, :slug, :visibility]) - |> validate_required([:organization_id, :name, :slug, :visibility, :is_default]) + |> cast(attrs, [:name, :slug, :visibility, :external_sharing_policy]) + |> put_default_external_sharing_policy(attrs) + |> validate_required([ + :organization_id, + :name, + :slug, + :visibility, + :external_sharing_policy, + :is_default + ]) |> validate_length(:name, max: 160) |> validate_length(:slug, max: 100) |> validate_format(:slug, ~r/^[a-z0-9]+(?:-[a-z0-9]+)*$/) |> validate_inclusion(:visibility, @visibilities) + |> validate_inclusion(:external_sharing_policy, @external_sharing_policies) |> validate_default_visibility() |> unique_constraint([:organization_id, :slug]) |> unique_constraint(:organization_id, name: :workspaces_one_default_per_organization) @@ -36,6 +47,22 @@ defmodule Textbin.Organizations.Workspace do |> foreign_key_constraint(:created_by_id) end + defp put_default_external_sharing_policy(changeset, attrs) do + if policy_provided?(attrs) or changeset.data.id do + changeset + else + policy = if get_field(changeset, :visibility) == "private", do: "disabled", else: "public" + put_change(changeset, :external_sharing_policy, policy) + end + end + + defp policy_provided?(attrs) when is_map(attrs) do + Map.has_key?(attrs, "external_sharing_policy") or + Map.has_key?(attrs, :external_sharing_policy) + end + + defp policy_provided?(_attrs), do: false + defp validate_default_visibility(changeset) do if get_field(changeset, :is_default) && get_field(changeset, :visibility) != "open" do add_error(changeset, :visibility, "must be open for the default workspace") diff --git a/lib/textbin/pastes.ex b/lib/textbin/pastes.ex index 90f0e5e..6add419 100644 --- a/lib/textbin/pastes.ex +++ b/lib/textbin/pastes.ex @@ -22,14 +22,14 @@ defmodule Textbin.Pastes do @default_max_paste_bytes 1_048_576 def list_pastes(%Scope{user: %User{}} = scope) do - case authorized_workspace_id(scope) do - {:ok, workspace_id} -> + case authorized_workspace(scope) do + {:ok, workspace} -> now = Paste.utc_now_ms() Repo.all( from p in Paste, where: - p.workspace_id == ^workspace_id and + p.workspace_id == ^workspace.id and (is_nil(p.expires_at) or p.expires_at > ^now), order_by: [desc: p.inserted_at] ) @@ -41,14 +41,14 @@ defmodule Textbin.Pastes do end def list_paste_metadata(%Scope{user: %User{}} = scope) do - case authorized_workspace_id(scope) do - {:ok, workspace_id} -> + case authorized_workspace(scope) do + {:ok, workspace} -> now = Paste.utc_now_ms() Repo.all( from p in Paste, where: - p.workspace_id == ^workspace_id and + p.workspace_id == ^workspace.id and (is_nil(p.expires_at) or p.expires_at > ^now), select: struct(p, [ @@ -58,7 +58,7 @@ defmodule Textbin.Pastes do :sha256, :content_type, :syntax_highlight, - :visibility, + :audience, :expires_at, :workspace_id, :created_by_user_id, @@ -74,14 +74,14 @@ defmodule Textbin.Pastes do end def get_paste(%Scope{user: %User{}} = scope, id) when is_binary(id) do - with {:ok, workspace_id} <- authorized_workspace_id(scope), + with {:ok, workspace} <- authorized_workspace(scope), {:ok, paste_id} <- Ecto.UUID.cast(id) do now = Paste.utc_now_ms() Repo.one( from p in Paste, where: - p.id == ^paste_id and p.workspace_id == ^workspace_id and + p.id == ^paste_id and p.workspace_id == ^workspace.id and (is_nil(p.expires_at) or p.expires_at > ^now) ) |> load_data() @@ -123,10 +123,15 @@ defmodule Textbin.Pastes do def get_shared_paste(_current_scope, _id), do: nil def create_paste(%Scope{user: %User{} = user} = scope, attrs \\ %{}) do - with {:ok, workspace_id} <- authorized_workspace_id(scope) do + with {:ok, workspace} <- authorized_workspace(scope) do attrs = attrs_with_defaults(attrs, user, ContentType.text_safe?(attr_data(attrs))) - paste = new_paste(user, workspace_id) - changeset = paste |> Paste.changeset(attrs) |> validate_data_size() + paste = new_paste(user, workspace.id) + + changeset = + paste + |> Paste.changeset(attrs) + |> validate_audience(workspace) + |> validate_data_size() if changeset.valid? do store_paste(changeset) @@ -143,15 +148,16 @@ defmodule Textbin.Pastes do attrs \\ %{} ) when is_binary(path) and is_integer(size_bytes) and is_binary(sha256) do - with {:ok, workspace_id} <- authorized_workspace_id(scope) do + with {:ok, workspace} <- authorized_workspace(scope) do text_safe? = match?({:ok, true}, ContentType.text_safe_file(path)) attrs = attrs_with_defaults(attrs, user, text_safe?) - paste = new_paste(user, workspace_id) + paste = new_paste(user, workspace.id) storage_key = "pastes/#{paste.id}" changeset = %{paste | storage_key: storage_key} |> Paste.changeset(attrs) + |> validate_audience(workspace) |> validate_uploaded_file(path, metadata) if changeset.valid? do @@ -303,7 +309,12 @@ defmodule Textbin.Pastes do created_by_user_id: paste.created_by_user_id || user.id } - Paste.changeset(paste, attrs_with_visibility(attrs, user)) + changeset = Paste.changeset(paste, attrs_with_audience(attrs, user)) + + case authorized_workspace(scope) do + {:ok, workspace} -> validate_audience(changeset, workspace) + {:error, :not_found} -> changeset + end end def prepare_paste(%Scope{user: %User{} = user} = scope), @@ -317,14 +328,15 @@ defmodule Textbin.Pastes do } end - defp authorized_workspace_id(%Scope{workspace: %{id: workspace_id}} = scope) do + defp authorized_workspace(%Scope{workspace: %{id: workspace_id}} = scope) do case Organizations.resolve_workspace_scope(scope, workspace_id) do - {:ok, _resolved_scope} -> {:ok, workspace_id} + {:ok, resolved_scope} -> {:ok, resolved_scope.workspace} {:error, :not_found} -> {:error, :not_found} end end - defp authorized_workspace_id(%Scope{user: user}), do: {:ok, personal_workspace_id(user)} + defp authorized_workspace(%Scope{user: user}), + do: {:ok, Organizations.get_personal_default_workspace!(user)} defp scope_workspace_id(%Scope{workspace: %{id: workspace_id}}), do: workspace_id defp scope_workspace_id(%Scope{user: user}), do: personal_workspace_id(user) @@ -573,7 +585,7 @@ defmodule Textbin.Pastes do defp attrs_with_defaults(attrs, user, text_safe?) do attrs |> attrs_with_default_ttl(user) - |> attrs_with_visibility(user) + |> attrs_with_audience(user) |> attrs_with_content_type(text_safe?) end @@ -601,19 +613,37 @@ defmodule Textbin.Pastes do if Enum.any?(Map.keys(attrs), &is_binary/1), do: "expires_in", else: :expires_in end - defp attrs_with_visibility(attrs, %User{} = user) when is_map(attrs) do - visibility = if User.guest?(user), do: "unlisted", else: visibility_value(attrs) + defp attrs_with_audience(attrs, %User{} = user) when is_map(attrs) do + audience = if User.guest?(user), do: "unlisted", else: audience_value(attrs) - Map.put(attrs, attr_key(attrs, "visibility", :visibility), visibility || "private") + Map.put(attrs, attr_key(attrs, "audience", :audience), audience || "workspace") end - defp visibility_value(attrs) do - case Map.get(attrs, "visibility") || Map.get(attrs, :visibility) do + defp audience_value(attrs) do + case Map.get(attrs, "audience") || Map.get(attrs, :audience) || + Map.get(attrs, "visibility") || Map.get(attrs, :visibility) do "" -> nil - visibility -> visibility + "private" -> "workspace" + audience -> audience end end + defp validate_audience(changeset, workspace) do + audience = Ecto.Changeset.get_field(changeset, :audience) + + if Keyword.has_key?(changeset.errors, :audience) or + audience_allowed?(audience, workspace.external_sharing_policy) do + changeset + else + Ecto.Changeset.add_error(changeset, :audience, "is disabled by the workspace policy") + end + end + + defp audience_allowed?("workspace", _policy), do: true + defp audience_allowed?("unlisted", policy), do: policy in ["unlisted", "public"] + defp audience_allowed?("public", "public"), do: true + defp audience_allowed?(_audience, _policy), do: false + defp attr_key(attrs, string_key, atom_key) do if Enum.any?(Map.keys(attrs), &is_binary/1), do: string_key, else: atom_key end @@ -639,11 +669,11 @@ defmodule Textbin.Pastes do query, [paste], paste.workspace_id in subquery(workspace_ids) or - paste.visibility in ["unlisted", "public"] + paste.audience in ["unlisted", "public"] ) end defp allow_shared_access(query, nil) do - where(query, [paste], paste.visibility in ["unlisted", "public"]) + where(query, [paste], paste.audience in ["unlisted", "public"]) end end diff --git a/lib/textbin/pastes/paste.ex b/lib/textbin/pastes/paste.ex index 12102e3..4731053 100644 --- a/lib/textbin/pastes/paste.ex +++ b/lib/textbin/pastes/paste.ex @@ -11,7 +11,7 @@ defmodule Textbin.Pastes.Paste do # Store timestamps at millisecond precision so API output and database values # stay stable across adapters and reloads. @timestamps_opts [type: :utc_datetime_usec, autogenerate: {__MODULE__, :utc_now_ms, []}] - @visibility_values ["private", "unlisted", "public"] + @audiences ["workspace", "unlisted", "public"] @ttl_presets %{ "10m" => 10 * 60, "1h" => 60 * 60, @@ -29,7 +29,7 @@ defmodule Textbin.Pastes.Paste do field :sha256, :binary field :content_type, :string, default: "text/plain" field :syntax_highlight, :string, default: "plain" - field :visibility, :string, default: "private" + field :audience, :string, source: :visibility, default: "workspace" field :expires_at, :utc_datetime_usec field :expires_in, :string, virtual: true @@ -40,19 +40,41 @@ defmodule Textbin.Pastes.Paste do end def changeset(paste, attrs) do + attrs = normalize_legacy_visibility(attrs) + paste - |> cast(attrs, [:data, :content_type, :syntax_highlight, :visibility, :expires_in]) + |> cast(attrs, [:data, :content_type, :syntax_highlight, :audience, :expires_in]) |> put_expires_at(attrs) - |> validate_required([:content_type, :syntax_highlight, :visibility, :workspace_id]) + |> validate_required([:content_type, :syntax_highlight, :audience, :workspace_id]) |> validate_content_location() |> validate_length(:content_type, max: 255) |> validate_change(:content_type, &validate_content_type/2) - |> validate_inclusion(:visibility, @visibility_values) + |> validate_inclusion(:audience, @audiences) |> validate_expiration() |> foreign_key_constraint(:workspace_id) |> foreign_key_constraint(:created_by_user_id) end + defp normalize_legacy_visibility(attrs) when is_map(attrs) do + cond do + Map.has_key?(attrs, "audience") or Map.has_key?(attrs, :audience) -> + attrs + + Map.has_key?(attrs, "visibility") -> + Map.put(attrs, "audience", legacy_audience(Map.get(attrs, "visibility"))) + + Map.has_key?(attrs, :visibility) -> + Map.put(attrs, :audience, legacy_audience(Map.get(attrs, :visibility))) + + true -> + attrs + end + end + + defp normalize_legacy_visibility(attrs), do: attrs + defp legacy_audience("private"), do: "workspace" + defp legacy_audience(audience), do: audience + defp validate_content_location(changeset) do if get_field(changeset, :data) || get_field(changeset, :storage_key) do changeset diff --git a/lib/textbin_web/controllers/apiv1/paste_controller.ex b/lib/textbin_web/controllers/apiv1/paste_controller.ex index 64b54dd..ad99ec0 100644 --- a/lib/textbin_web/controllers/apiv1/paste_controller.ex +++ b/lib/textbin_web/controllers/apiv1/paste_controller.ex @@ -72,7 +72,7 @@ defmodule TextbinWeb.ApiV1.PasteController do defp paste_attrs(conn, params) when map_size(params) == 0 or is_map_key(params, "syntax_highlight") or is_map_key(params, "content_type") or - is_map_key(params, "visibility") or + is_map_key(params, "audience") or is_map_key(params, "visibility") or is_map_key(params, "expires_in") or is_map_key(params, "ttl") do case read_request_body(conn) do {:ok, path, metadata, conn} -> @@ -107,6 +107,7 @@ defmodule TextbinWeb.ApiV1.PasteController do attrs |> put_string_param(params, "content_type") |> put_string_param(params, "syntax_highlight") + |> put_string_param(params, "audience") |> put_string_param(params, "visibility") |> put_string_param(params, "expires_in") |> put_string_param(params, "ttl") diff --git a/lib/textbin_web/controllers/apiv1/paste_json.ex b/lib/textbin_web/controllers/apiv1/paste_json.ex index 17203db..6cb30a3 100644 --- a/lib/textbin_web/controllers/apiv1/paste_json.ex +++ b/lib/textbin_web/controllers/apiv1/paste_json.ex @@ -33,13 +33,17 @@ defmodule TextbinWeb.ApiV1.PasteJSON do id: paste.id, content_type: paste.content_type, syntax_highlight: paste.syntax_highlight, - visibility: paste.visibility, + audience: paste.audience, + visibility: legacy_visibility(paste.audience), expires_at: timestamp(paste.expires_at), inserted_at: timestamp(paste.inserted_at), updated_at: timestamp(paste.updated_at) } end + defp legacy_visibility("workspace"), do: "private" + defp legacy_visibility(audience), do: audience + defp timestamp(nil), do: nil # Keep timestamps serialized at milliseconds; clients should not observe diff --git a/lib/textbin_web/live/ui/paste_live.ex b/lib/textbin_web/live/ui/paste_live.ex index d49680e..ad44ffe 100644 --- a/lib/textbin_web/live/ui/paste_live.ex +++ b/lib/textbin_web/live/ui/paste_live.ex @@ -264,11 +264,15 @@ defmodule TextbinWeb.UI.PasteLive do ] end - defp paste_visibility_options(%Textbin.Accounts.User{} = user) do - if Textbin.Accounts.User.guest?(user) do + defp paste_audience_options(scope) do + if Textbin.Accounts.User.guest?(scope.user) do [{"Unlisted", "unlisted"}] else - [{"Private", "private"}, {"Unlisted", "unlisted"}, {"Public", "public"}] + case scope.workspace.external_sharing_policy do + "disabled" -> [{"Workspace", "workspace"}] + "unlisted" -> [{"Workspace", "workspace"}, {"Unlisted", "unlisted"}] + "public" -> [{"Workspace", "workspace"}, {"Unlisted", "unlisted"}, {"Public", "public"}] + end end end diff --git a/lib/textbin_web/live/ui/paste_live/detail.html.heex b/lib/textbin_web/live/ui/paste_live/detail.html.heex index a70b3a9..fde361e 100644 --- a/lib/textbin_web/live/ui/paste_live/detail.html.heex +++ b/lib/textbin_web/live/ui/paste_live/detail.html.heex @@ -13,8 +13,8 @@ {@paste.content_type} {@paste.syntax_highlight} - - {String.capitalize(@paste.visibility)} + + {String.capitalize(@paste.audience)}