diff --git a/lib/textbin/organizations.ex b/lib/textbin/organizations.ex index 9d503f4..d902a96 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,47 @@ 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 + updated_at = Paste.utc_now_ms() + + Repo.update_all( + from(paste in Paste, + where: paste.workspace_id == ^workspace_id and paste.audience != "workspace" + ), + set: [audience: "workspace", updated_at: updated_at] + ) + end + + defp clamp_paste_audiences(%Workspace{id: workspace_id, external_sharing_policy: "unlisted"}) do + updated_at = Paste.utc_now_ms() + + Repo.update_all( + from(paste in Paste, + where: paste.workspace_id == ^workspace_id and paste.audience == "public" + ), + set: [audience: "unlisted", updated_at: updated_at] + ) + 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..79f7edb 100644 --- a/lib/textbin/pastes.ex +++ b/lib/textbin/pastes.ex @@ -13,6 +13,7 @@ defmodule Textbin.Pastes do alias Textbin.Storage alias Textbin.Storage.IntegrityError alias Textbin.Organizations + alias Textbin.Organizations.Workspace alias Textbin.Organizations.WorkspaceMembership alias Textbin.Organizations.Policy @@ -22,14 +23,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 +42,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 +59,7 @@ defmodule Textbin.Pastes do :sha256, :content_type, :syntax_highlight, - :visibility, + :audience, :expires_at, :workspace_id, :created_by_user_id, @@ -74,14 +75,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,13 +124,18 @@ 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) + store_paste(scope, changeset) else {:error, changeset} end @@ -143,19 +149,20 @@ 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 - store_paste_file(changeset, path, metadata) + store_paste_file(scope, changeset, path, metadata) else {:error, changeset} end @@ -303,7 +310,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 +329,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) @@ -333,24 +346,24 @@ defmodule Textbin.Pastes do Organizations.get_personal_default_workspace!(user).id end - defp store_paste(changeset) do + defp store_paste(scope, changeset) do data = Ecto.Changeset.get_field(changeset, :data) metadata = content_metadata(data) if inline_data?(changeset, data) do - insert_inline_paste(changeset, metadata, data) + insert_inline_paste(scope, changeset, metadata, data) else - store_blob_paste(changeset, data) + store_blob_paste(scope, changeset, data) end end - defp store_blob_paste(changeset, data) do + defp store_blob_paste(scope, changeset, data) do storage_key = "pastes/#{changeset.data.id}" with_pending_upload(storage_key, changeset, fn -> case Storage.put(storage_key, data) do {:ok, stored_metadata} -> - insert_stored_paste(changeset, storage_key, stored_metadata, data) + insert_stored_paste(scope, changeset, storage_key, stored_metadata, data) {:error, reason} -> storage_error(changeset, storage_key, reason) @@ -358,22 +371,22 @@ defmodule Textbin.Pastes do end) end - defp store_paste_file(changeset, path, metadata) do + defp store_paste_file(scope, changeset, path, metadata) do if inline_size?(metadata) do - store_inline_file(changeset, path, metadata) + store_inline_file(scope, changeset, path, metadata) else - store_blob_file(changeset, path, metadata) + store_blob_file(scope, changeset, path, metadata) end end - defp store_inline_file(changeset, path, expected_metadata) do + defp store_inline_file(scope, changeset, path, expected_metadata) do with {:ok, data} <- File.read(path), metadata = content_metadata(data), true <- metadata == expected_metadata do if inline_data?(changeset, data) do - insert_inline_paste(changeset, metadata, data) + insert_inline_paste(scope, changeset, metadata, data) else - store_blob_file(changeset, path, metadata) + store_blob_file(scope, changeset, path, metadata) end else false -> @@ -384,13 +397,13 @@ defmodule Textbin.Pastes do end end - defp store_blob_file(changeset, path, metadata) do + defp store_blob_file(scope, changeset, path, metadata) do storage_key = changeset.data.storage_key with_pending_upload(storage_key, changeset, fn -> case Storage.put_file(storage_key, path, metadata) do {:ok, stored_metadata} when stored_metadata == metadata -> - insert_stored_paste(changeset, storage_key, stored_metadata, nil) + insert_stored_paste(scope, changeset, storage_key, stored_metadata, nil) {:ok, _stored_metadata} -> storage_metadata_error(changeset, storage_key) @@ -404,13 +417,21 @@ defmodule Textbin.Pastes do end) end - defp insert_inline_paste(changeset, metadata, data) do - changeset - |> Ecto.Changeset.put_change(:data, data) - |> Ecto.Changeset.put_change(:storage_key, nil) - |> Ecto.Changeset.put_change(:size_bytes, metadata.size_bytes) - |> Ecto.Changeset.put_change(:sha256, metadata.sha256) - |> Repo.insert() + defp insert_inline_paste(scope, changeset, metadata, data) do + changeset = + changeset + |> Ecto.Changeset.put_change(:data, data) + |> Ecto.Changeset.put_change(:storage_key, nil) + |> Ecto.Changeset.put_change(:size_bytes, metadata.size_bytes) + |> Ecto.Changeset.put_change(:sha256, metadata.sha256) + + Repo.transact(fn -> + with {:ok, workspace} <- lock_authorized_workspace(scope, changeset.data.workspace_id) do + changeset + |> validate_audience(workspace) + |> Repo.insert() + end + end) end defp with_storage_compensation(storage_key, operation) do @@ -438,7 +459,7 @@ defmodule Textbin.Pastes do end end - defp insert_stored_paste(changeset, storage_key, metadata, data) do + defp insert_stored_paste(scope, changeset, storage_key, metadata, data) do stored_changeset = changeset |> Ecto.Changeset.put_change(:data, nil) @@ -446,17 +467,21 @@ defmodule Textbin.Pastes do |> Ecto.Changeset.put_change(:size_bytes, metadata.size_bytes) |> Ecto.Changeset.put_change(:sha256, metadata.sha256) - Repo.transaction(fn -> - with {:ok, paste} <- Repo.insert(stored_changeset), + Repo.transact(fn -> + with {:ok, workspace} <- lock_authorized_workspace(scope, changeset.data.workspace_id), + {:ok, paste} <- + stored_changeset + |> validate_audience(workspace) + |> Repo.insert(), {1, nil} <- Repo.delete_all( from upload in PendingUpload, where: upload.storage_key == ^storage_key and is_nil(upload.claimed_at) ) do - paste + {:ok, paste} else - {:error, insert_changeset} -> Repo.rollback(insert_changeset) - {0, nil} -> Repo.rollback(:upload_claimed) + {:error, reason} -> {:error, reason} + {0, nil} -> {:error, :upload_claimed} end end) |> finalize_insert(stored_changeset, storage_key, data) @@ -573,7 +598,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,16 +626,50 @@ 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 lock_authorized_workspace(scope, workspace_id) do + workspace = + Repo.one( + from workspace in Workspace, + where: workspace.id == ^workspace_id, + lock: "FOR UPDATE" + ) + + with %Workspace{} <- workspace, + {:ok, %Workspace{id: ^workspace_id} = current_workspace} <- authorized_workspace(scope) do + {:ok, current_workspace} + else + _error -> {:error, :not_found} end end @@ -635,15 +694,25 @@ defmodule Textbin.Pastes do where: membership.user_id == ^user_id, select: membership.workspace_id - where( - query, - [paste], + query + |> join(:inner, [paste], workspace in Workspace, on: workspace.id == paste.workspace_id) + |> where( + [paste, workspace], paste.workspace_id in subquery(workspace_ids) or - paste.visibility in ["unlisted", "public"] + (paste.audience == "unlisted" and + workspace.external_sharing_policy in ["unlisted", "public"]) or + (paste.audience == "public" and workspace.external_sharing_policy == "public") ) end defp allow_shared_access(query, nil) do - where(query, [paste], paste.visibility in ["unlisted", "public"]) + query + |> join(:inner, [paste], workspace in Workspace, on: workspace.id == paste.workspace_id) + |> where( + [paste, workspace], + (paste.audience == "unlisted" and + workspace.external_sharing_policy in ["unlisted", "public"]) or + (paste.audience == "public" and workspace.external_sharing_policy == "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..601e811 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") @@ -373,12 +374,31 @@ defmodule TextbinWeb.ApiV1.PasteController do end defp render_changeset_errors(conn, changeset) do + changeset = maybe_alias_legacy_visibility_errors(changeset, conn.params) + conn |> put_status(:unprocessable_entity) |> put_view(json: TextbinWeb.ChangesetJSON) |> render(:error, changeset: changeset) end + defp maybe_alias_legacy_visibility_errors(changeset, params) do + params = Map.get(params, "paste", params) + + if is_map(params) and Map.has_key?(params, "visibility") and + not Map.has_key?(params, "audience") do + errors = + Enum.map(changeset.errors, fn + {:audience, error} -> {:visibility, error} + error -> error + end) + + %{changeset | errors: errors} + else + changeset + end + end + defp max_paste_bytes do Application.get_env(:textbin, :max_paste_bytes, @default_max_paste_bytes) end 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)} <.input - field={@paste_form[:visibility]} + field={@paste_form[:audience]} type="select" - label="Visibility" - options={paste_visibility_options(@current_scope.user)} + label="Audience" + options={paste_audience_options(@current_scope)} disabled={guest_user?(@current_scope.user)} /> <.input @@ -108,8 +108,8 @@ {paste.syntax_highlight} - - {String.capitalize(paste.visibility)} + + {String.capitalize(paste.audience)} assign(:member_form, to_form(%{"email" => ""}, as: :member)) |> assign( :settings_form, - to_form(%{"visibility" => scope.workspace.visibility}, as: :workspace) + to_form( + %{ + "visibility" => scope.workspace.visibility, + "external_sharing_policy" => scope.workspace.external_sharing_policy + }, + as: :workspace + ) ) |> stream(:members, Organizations.list_workspace_members(scope), reset: true)} end @@ -81,11 +87,11 @@ defmodule TextbinWeb.UI.WorkspaceLive do end end - def handle_event("update_settings", %{"workspace" => %{"visibility" => visibility}}, socket) do - case Organizations.change_workspace_visibility( + def handle_event("update_settings", %{"workspace" => workspace_params}, socket) do + case Organizations.change_workspace_settings( socket.assigns.current_scope, socket.assigns.current_scope.workspace, - visibility + workspace_params ) do {:ok, workspace} -> scope = %{socket.assigns.current_scope | workspace: workspace} @@ -95,7 +101,13 @@ defmodule TextbinWeb.UI.WorkspaceLive do |> assign(:current_scope, scope) |> assign( :settings_form, - to_form(%{"visibility" => workspace.visibility}, as: :workspace) + to_form( + %{ + "visibility" => workspace.visibility, + "external_sharing_policy" => workspace.external_sharing_policy + }, + as: :workspace + ) ) |> put_flash(:info, "Workspace settings updated")} @@ -187,26 +199,39 @@ defmodule TextbinWeb.UI.WorkspaceLive do <.form - :if={@workspace_owner? && !@current_scope.workspace.is_default} + :if={@workspace_owner?} for={@settings_form} id="workspace-settings-form" phx-submit="update_settings" > <.input + :if={!@current_scope.workspace.is_default} field={@settings_form[:visibility]} type="select" label="Visibility" options={[{"Open", "open"}, {"Private", "private"}]} /> + <.input + field={@settings_form[:external_sharing_policy]} + type="select" + label="External sharing" + options={[ + {"Disabled", "disabled"}, + {"Unlisted links", "unlisted"}, + {"Public", "public"} + ]} + /> <.button variant="primary" phx-disable-with="Saving...">Save settings - Visibility: {String.capitalize(@current_scope.workspace.visibility)} + Visibility: {String.capitalize(@current_scope.workspace.visibility)} ยท External sharing: {String.capitalize( + @current_scope.workspace.external_sharing_policy + )} diff --git a/priv/repo/migrations/20260813230000_add_paste_audience_policy.exs b/priv/repo/migrations/20260813230000_add_paste_audience_policy.exs new file mode 100644 index 0000000..bf89210 --- /dev/null +++ b/priv/repo/migrations/20260813230000_add_paste_audience_policy.exs @@ -0,0 +1,59 @@ +defmodule Textbin.Repo.Migrations.AddPasteAudiencePolicy do + use Ecto.Migration + + def up do + drop constraint(:pastes, :pastes_visibility_check) + + execute "UPDATE pastes SET visibility = 'workspace' WHERE visibility = 'private'" + + alter table(:pastes) do + modify :visibility, :string, default: "workspace" + end + + create constraint(:pastes, :pastes_visibility_check, + check: "visibility IN ('workspace', 'unlisted', 'public')" + ) + + alter table(:workspaces) do + add :external_sharing_policy, :string, null: false, default: "disabled" + end + + execute """ + UPDATE workspaces + SET external_sharing_policy = 'public' + WHERE visibility = 'open' + """ + + execute """ + UPDATE pastes + SET visibility = 'workspace' + FROM workspaces + WHERE pastes.workspace_id = workspaces.id + AND workspaces.external_sharing_policy = 'disabled' + """ + + create constraint(:workspaces, :workspaces_external_sharing_policy_check, + check: "external_sharing_policy IN ('disabled', 'unlisted', 'public')" + ) + end + + def down do + drop constraint(:workspaces, :workspaces_external_sharing_policy_check) + + alter table(:workspaces) do + remove :external_sharing_policy + end + + drop constraint(:pastes, :pastes_visibility_check) + + execute "UPDATE pastes SET visibility = 'private' WHERE visibility = 'workspace'" + + alter table(:pastes) do + modify :visibility, :string, default: "private" + end + + create constraint(:pastes, :pastes_visibility_check, + check: "visibility IN ('private', 'unlisted', 'public')" + ) + end +end diff --git a/priv/repo/structure.sql b/priv/repo/structure.sql index af4b586..304a48c 100644 --- a/priv/repo/structure.sql +++ b/priv/repo/structure.sql @@ -2,7 +2,7 @@ -- PostgreSQL database dump -- -\restrict yRP5uiwmpNsaIzzSBvERZeQaEjY2Xzf4el9f0OuYdk4WFCKNvj4XuRZ6MJQVd1c +\restrict d2Kca5aP8CA1b0dmbtfbdgBrYgBf1SlPA7bk82WA5USx94XSpmpC5iUAzYa6cqc -- Dumped from database version 17.10 -- Dumped by pg_dump version 17.10 @@ -80,7 +80,7 @@ CREATE TABLE public.pastes ( updated_at timestamp(3) without time zone NOT NULL, syntax_highlight text DEFAULT 'plain'::text NOT NULL, expires_at timestamp(3) without time zone DEFAULT NULL::timestamp without time zone, - visibility character varying(255) DEFAULT 'private'::character varying NOT NULL, + visibility character varying(255) DEFAULT 'workspace'::character varying NOT NULL, storage_key character varying(255), size_bytes bigint, sha256 bytea, @@ -88,7 +88,7 @@ CREATE TABLE public.pastes ( workspace_id uuid NOT NULL, created_by_user_id uuid, CONSTRAINT pastes_content_location_check CHECK (((data IS NOT NULL) OR (storage_key IS NOT NULL))), - CONSTRAINT pastes_visibility_check CHECK (((visibility)::text = ANY ((ARRAY['private'::character varying, 'unlisted'::character varying, 'public'::character varying])::text[]))) + CONSTRAINT pastes_visibility_check CHECK (((visibility)::text = ANY ((ARRAY['workspace'::character varying, 'unlisted'::character varying, 'public'::character varying])::text[]))) ); @@ -177,7 +177,9 @@ CREATE TABLE public.workspaces ( is_default boolean DEFAULT false NOT NULL, inserted_at timestamp(0) without time zone NOT NULL, updated_at timestamp(0) without time zone NOT NULL, + external_sharing_policy character varying(255) DEFAULT 'disabled'::character varying NOT NULL, CONSTRAINT workspaces_default_visibility_check CHECK (((NOT is_default) OR ((visibility)::text = 'open'::text))), + CONSTRAINT workspaces_external_sharing_policy_check CHECK (((external_sharing_policy)::text = ANY ((ARRAY['disabled'::character varying, 'unlisted'::character varying, 'public'::character varying])::text[]))), CONSTRAINT workspaces_visibility_check CHECK (((visibility)::text = ANY ((ARRAY['open'::character varying, 'private'::character varying])::text[]))) ); @@ -479,7 +481,7 @@ ALTER TABLE ONLY public.workspaces -- PostgreSQL database dump complete -- -\unrestrict yRP5uiwmpNsaIzzSBvERZeQaEjY2Xzf4el9f0OuYdk4WFCKNvj4XuRZ6MJQVd1c +\unrestrict d2Kca5aP8CA1b0dmbtfbdgBrYgBf1SlPA7bk82WA5USx94XSpmpC5iUAzYa6cqc INSERT INTO public."schema_migrations" (version) VALUES (20260706061942); INSERT INTO public."schema_migrations" (version) VALUES (20260709081001); @@ -497,3 +499,4 @@ INSERT INTO public."schema_migrations" (version) VALUES (20260806100000); INSERT INTO public."schema_migrations" (version) VALUES (20260810090000); INSERT INTO public."schema_migrations" (version) VALUES (20260810120000); INSERT INTO public."schema_migrations" (version) VALUES (20260812120000); +INSERT INTO public."schema_migrations" (version) VALUES (20260813230000); diff --git a/test/textbin/organizations/concurrency_test.exs b/test/textbin/organizations/concurrency_test.exs index e4833c7..7e2de06 100644 --- a/test/textbin/organizations/concurrency_test.exs +++ b/test/textbin/organizations/concurrency_test.exs @@ -4,7 +4,8 @@ defmodule Textbin.Organizations.ConcurrencyTest do alias Ecto.Adapters.SQL.Sandbox alias Textbin.Accounts.Scope alias Textbin.Organizations - alias Textbin.Organizations.{OrganizationMembership, WorkspaceMembership} + alias Textbin.Organizations.{OrganizationMembership, Workspace, WorkspaceMembership} + alias Textbin.Pastes alias Textbin.Repo import Ecto.Query @@ -285,6 +286,60 @@ defmodule Textbin.Organizations.ConcurrencyTest do ) == 1 end + test "paste creation rechecks policy after concurrent sharing is disabled" do + owner = tracked_user_fixture() + + {:ok, organization} = + Organizations.create_organization(Scope.for_user(owner), %{ + name: "Sharing race", + slug: "sharing-race-#{System.unique_integer([:positive])}" + }) + + track_organization(organization) + workspace = hd(organization.workspaces) + {:ok, scope} = Organizations.resolve_workspace_scope(Scope.for_user(owner), workspace) + parent = self() + + {:ok, task} = + Repo.transaction(fn -> + workspace = + Repo.one!( + from workspace in Workspace, + where: workspace.id == ^workspace.id, + lock: "FOR UPDATE" + ) + + workspace + |> Workspace.changeset(%{external_sharing_policy: "disabled"}) + |> Repo.update!() + + task = + Task.async(fn -> + :ok = Sandbox.checkout(Repo, sandbox: false) + + try do + %{rows: [[backend_pid]]} = Repo.query!("SELECT pg_backend_pid()") + send(parent, {:creator_ready, backend_pid}) + Pastes.create_paste(scope, %{data: "raced public paste", audience: "public"}) + after + Sandbox.checkin(Repo) + end + end) + + assert_receive {:creator_ready, backend_pid}, 5_000 + await_lock_waits!([backend_pid], 5_000) + task + end) + + assert {:error, changeset} = Task.await(task, 10_000) + assert {"is disabled by the workspace policy", _} = changeset.errors[:audience] + + refute Repo.exists?( + from paste in Textbin.Pastes.Paste, + where: paste.workspace_id == ^workspace.id and paste.audience == "public" + ) + end + test "workspace creation racing organization removal cannot orphan membership" do owner = tracked_user_fixture() creator = tracked_user_fixture() diff --git a/test/textbin/pastes/expiration_cleaner_test.exs b/test/textbin/pastes/expiration_cleaner_test.exs index dc6b08d..348009e 100644 --- a/test/textbin/pastes/expiration_cleaner_test.exs +++ b/test/textbin/pastes/expiration_cleaner_test.exs @@ -14,7 +14,7 @@ defmodule Textbin.Pastes.ExpirationCleanerTest do Repo.insert!(%Paste{ data: "expired", syntax_highlight: "plain", - visibility: "private", + audience: "workspace", workspace_id: workspace.id, created_by_user_id: user.id, expires_at: DateTime.add(Paste.utc_now_ms(), -1, :second) diff --git a/test/textbin/pastes_test.exs b/test/textbin/pastes_test.exs index 65248c6..fb8977e 100644 --- a/test/textbin/pastes_test.exs +++ b/test/textbin/pastes_test.exs @@ -108,6 +108,27 @@ defmodule Textbin.PastesTest do end end + test "get_shared_paste/2 enforces the workspace policy for inconsistent external rows", %{ + scope: scope, + workspace: workspace + } do + {:ok, workspace} = + Organizations.change_workspace_settings(scope, workspace, %{ + external_sharing_policy: "disabled" + }) + + paste = + Repo.insert!(%Paste{ + data: "must remain internal", + audience: "public", + workspace_id: workspace.id, + created_by_user_id: scope.user.id + }) + + refute Pastes.get_shared_paste(nil, paste.id) + assert Pastes.get_shared_paste(scope, paste.id).id == paste.id + end + test "get_shared_paste/2 allows an owner to access a private paste", %{scope: scope} do {:ok, paste} = Pastes.create_paste(scope, %{data: "private", visibility: "private"}) @@ -142,7 +163,7 @@ defmodule Textbin.PastesTest do Repo.insert!(%Paste{ data: "expired shared data", syntax_highlight: "plain", - visibility: "public", + audience: "public", workspace_id: workspace.id, created_by_user_id: scope.user.id, expires_at: DateTime.add(DateTime.utc_now(), -1, :second) @@ -166,7 +187,7 @@ defmodule Textbin.PastesTest do assert Repo.get!(Paste, paste.id).data == "some data" assert paste.content_type == "text/plain" assert paste.syntax_highlight == "plain" - assert paste.visibility == "private" + assert paste.audience == "workspace" assert paste.workspace_id == personal_workspace_fixture(scope.user).id assert paste.created_by_user_id == scope.user.id assert {microsecond, 6} = paste.inserted_at.microsecond @@ -174,6 +195,71 @@ defmodule Textbin.PastesTest do assert is_nil(paste.expires_at) end + test "workspace sharing policy limits paste audiences", %{scope: scope} do + {:ok, organization} = + Organizations.create_organization(scope, %{name: "Audience", slug: "audience"}) + + {:ok, workspace} = + Organizations.create_workspace(scope, organization, %{ + name: "Private", + slug: "private", + visibility: "private" + }) + + {:ok, workspace_scope} = Organizations.resolve_workspace_scope(scope, workspace) + + assert {:ok, %Paste{audience: "workspace"}} = + Pastes.create_paste(workspace_scope, %{data: "members only", audience: "workspace"}) + + for audience <- ["unlisted", "public"] do + assert {:error, changeset} = + Pastes.create_paste(workspace_scope, %{data: audience, audience: audience}) + + assert %{audience: ["is disabled by the workspace policy"]} = errors_on(changeset) + end + + assert {:ok, unlisted_workspace} = + Organizations.change_workspace_settings(scope, workspace, %{ + external_sharing_policy: "unlisted" + }) + + {:ok, unlisted_scope} = Organizations.resolve_workspace_scope(scope, unlisted_workspace) + + assert {:ok, %Paste{audience: "unlisted"}} = + Pastes.create_paste(unlisted_scope, %{data: "by link", audience: "unlisted"}) + + assert {:error, changeset} = + Pastes.create_paste(unlisted_scope, %{data: "discoverable", audience: "public"}) + + assert %{audience: ["is disabled by the workspace policy"]} = errors_on(changeset) + end + + test "removed members retain external link access but lose workspace audience access", %{ + scope: owner_scope + } do + member = user_fixture() + member_scope = user_scope_fixture(member) + organization = Organizations.get_personal_organization!(owner_scope.user) + + {:ok, memberships} = + Organizations.add_organization_member(owner_scope, organization, member) + + pastes = + for audience <- ["workspace", "unlisted", "public"], into: %{} do + {:ok, paste} = + Pastes.create_paste(owner_scope, %{data: audience, audience: audience}) + + {audience, paste} + end + + assert Pastes.get_shared_paste(member_scope, pastes["workspace"].id) + Repo.delete!(memberships.workspace) + + refute Pastes.get_shared_paste(member_scope, pastes["workspace"].id) + assert Pastes.get_shared_paste(member_scope, pastes["unlisted"].id) + assert Pastes.get_shared_paste(member_scope, pastes["public"].id) + end + test "create_paste/2 stores content at the inline threshold in PostgreSQL", %{scope: scope} do data = String.duplicate("a", 8_192) @@ -257,7 +343,7 @@ defmodule Textbin.PastesTest do assert {:ok, %Paste{} = paste} = Pastes.create_paste(scope, %{data: visibility, visibility: visibility}) - assert paste.visibility == visibility + assert paste.audience == if(visibility == "private", do: "workspace", else: visibility) end end @@ -270,7 +356,7 @@ defmodule Textbin.PastesTest do %{data: "guest visibility"} |> Map.put(:visibility, requested_visibility) - assert {:ok, %Paste{visibility: "unlisted"}} = Pastes.create_paste(guest_scope, attrs) + assert {:ok, %Paste{audience: "unlisted"}} = Pastes.create_paste(guest_scope, attrs) end end @@ -400,7 +486,7 @@ defmodule Textbin.PastesTest do assert {:error, changeset} = Pastes.create_paste(scope, %{data: "bad visibility", visibility: "secret"}) - assert %{visibility: ["is invalid"]} = errors_on(changeset) + assert %{audience: ["is invalid"]} = errors_on(changeset) end test "expired pastes are excluded from scoped reads", %{scope: scope, workspace: workspace} do @@ -438,7 +524,7 @@ defmodule Textbin.PastesTest do Repo.insert!(%Paste{ data: "legacy content", syntax_highlight: "plain", - visibility: "private", + audience: "workspace", workspace_id: workspace.id, created_by_user_id: scope.user.id }) @@ -582,7 +668,7 @@ defmodule Textbin.PastesTest do Repo.insert!(%Paste{ data: data, syntax_highlight: "plain", - visibility: "private", + audience: "workspace", workspace_id: workspace.id, created_by_user_id: scope.user.id, expires_at: expires_at diff --git a/test/textbin_web/controllers/apiv1/paste_controller_test.exs b/test/textbin_web/controllers/apiv1/paste_controller_test.exs index 802ec86..1eae8ed 100644 --- a/test/textbin_web/controllers/apiv1/paste_controller_test.exs +++ b/test/textbin_web/controllers/apiv1/paste_controller_test.exs @@ -216,7 +216,8 @@ defmodule TextbinWeb.ApiV1.PasteControllerTest do conn = post(conn, ~p"/api/v1/pastes", %{data: visibility, visibility: visibility}) assert %{"id" => id, "visibility" => ^visibility} = json_response(conn, 201)["data"] - assert Pastes.get_paste!(scope, id).visibility == visibility + expected_audience = if visibility == "private", do: "workspace", else: visibility + assert Pastes.get_paste!(scope, id).audience == expected_audience end end @@ -227,7 +228,19 @@ defmodule TextbinWeb.ApiV1.PasteControllerTest do |> post(~p"/api/v1/pastes?visibility=public", "public streamed data") assert %{"id" => id, "visibility" => "public"} = json_response(conn, 201)["data"] - assert Pastes.get_paste!(scope, id).visibility == "public" + assert Pastes.get_paste!(scope, id).audience == "public" + end + + test "accepts and returns the audience concept", %{conn: conn, scope: scope} do + conn = post(conn, ~p"/api/v1/pastes", %{data: "members", audience: "workspace"}) + + assert %{ + "id" => id, + "audience" => "workspace", + "visibility" => "private" + } = json_response(conn, 201)["data"] + + assert Pastes.get_paste!(scope, id).audience == "workspace" end test "uses the user's default expiration", %{conn: conn, scope: scope} do diff --git a/test/textbin_web/controllers/paste_controller_test.exs b/test/textbin_web/controllers/paste_controller_test.exs index 23051a3..bc48e29 100644 --- a/test/textbin_web/controllers/paste_controller_test.exs +++ b/test/textbin_web/controllers/paste_controller_test.exs @@ -115,6 +115,25 @@ defmodule TextbinWeb.PasteControllerTest do assert response(other_conn, 404) == "Not Found" end + test "raw enforces disabled sharing for an inconsistent public row", %{scope: scope} do + workspace = personal_workspace_fixture(scope.user) + + {:ok, workspace} = + Textbin.Organizations.change_workspace_settings(scope, workspace, %{ + external_sharing_policy: "disabled" + }) + + paste = + Repo.insert!(%Paste{ + data: "internal raw", + audience: "public", + workspace_id: workspace.id, + created_by_user_id: scope.user.id + }) + + assert response(get(build_conn(), ~p"/pastes/#{paste.id}/raw"), 404) == "Not Found" + end + test "raw hides expired pastes", %{scope: scope} do workspace = personal_workspace_fixture(scope.user) @@ -122,7 +141,7 @@ defmodule TextbinWeb.PasteControllerTest do Repo.insert!(%Paste{ data: "expired raw", syntax_highlight: "plain", - visibility: "public", + audience: "public", workspace_id: workspace.id, created_by_user_id: scope.user.id, expires_at: DateTime.add(DateTime.utc_now(), -1, :second) diff --git a/test/textbin_web/live/ui/paste_live_test.exs b/test/textbin_web/live/ui/paste_live_test.exs index f3b141a..6f104a3 100644 --- a/test/textbin_web/live/ui/paste_live_test.exs +++ b/test/textbin_web/live/ui/paste_live_test.exs @@ -38,7 +38,7 @@ defmodule TextbinWeb.UI.PasteLiveTest do {:ok, view, _html} = live(recycle(conn), path) assert has_element?(view, "#paste-form") - assert has_element?(view, "#paste_visibility[disabled] option[value='unlisted']") + assert has_element?(view, "#paste_audience[disabled] option[value='unlisted']") view |> form("#paste-form", %{ @@ -58,7 +58,7 @@ defmodule TextbinWeb.UI.PasteLiveTest do assert paste.data == "guest paste" assert paste.created_by_user.kind == "guest" - assert paste.visibility == "unlisted" + assert paste.audience == "unlisted" assert DateTime.diff(paste.expires_at, DateTime.utc_now(), :second) in 21_590..21_600 assert has_element?(view, "##{stream_id(paste)}", "plain") end @@ -75,7 +75,7 @@ defmodule TextbinWeb.UI.PasteLiveTest do assert has_element?(view, "#pastes-list") assert has_element?(view, "##{stream_id(paste)}", paste.id) assert has_element?(view, "##{stream_id(paste)}", "elixir") - assert has_element?(view, "#paste-visibility-#{paste.id}", "Private") + assert has_element?(view, "#paste-audience-#{paste.id}", "Workspace") assert has_element?(view, "#paste-expires-at-#{paste.id}", "Never expires") assert has_element?(view, "##{stream_id(paste)} a[href='#{path}/#{paste.id}']") refute has_element?(view, "##{stream_id(paste)}", "live paste data") @@ -121,7 +121,7 @@ defmodule TextbinWeb.UI.PasteLiveTest do "paste" => %{ "data" => "team paste", "syntax_highlight" => "plain", - "visibility" => "private", + "audience" => "workspace", "expires_in" => "never" } }) @@ -154,7 +154,7 @@ defmodule TextbinWeb.UI.PasteLiveTest do "paste" => %{ "data" => "created after revocation", "syntax_highlight" => "plain", - "visibility" => "private", + "audience" => "workspace", "expires_in" => "never" } }) @@ -235,16 +235,16 @@ defmodule TextbinWeb.UI.PasteLiveTest do {_path, {:ok, view, _html}} = live_personal_workspace(conn, user) assert has_element?(view, "#paste-form") - assert has_element?(view, "#paste_visibility option[value='private']") - assert has_element?(view, "#paste_visibility option[value='unlisted']") - assert has_element?(view, "#paste_visibility option[value='public']") + assert has_element?(view, "#paste_audience option[value='workspace']") + assert has_element?(view, "#paste_audience option[value='unlisted']") + assert has_element?(view, "#paste_audience option[value='public']") view |> form("#paste-form", %{ "paste" => %{ "data" => "created from the browser", "syntax_highlight" => "markdown", - "visibility" => "public", + "audience" => "public", "expires_in" => "never" } }) @@ -253,13 +253,13 @@ defmodule TextbinWeb.UI.PasteLiveTest do assert [paste] = Pastes.list_pastes(scope) assert paste.data == "created from the browser" assert paste.syntax_highlight == "markdown" - assert paste.visibility == "public" + assert paste.audience == "public" assert is_nil(paste.expires_at) assert has_element?(view, "##{stream_id(paste)}", "markdown") - assert has_element?(view, "#paste-visibility-#{paste.id}", "Public") + assert has_element?(view, "#paste-audience-#{paste.id}", "Public") end - test "form validation reuses the workspace resolved at mount", %{conn: conn, user: user} do + test "form validation revalidates the workspace resolved at mount", %{conn: conn, user: user} do {_path, {:ok, view, _html}} = live_personal_workspace(conn, user) handler_id = "paste-form-query-test-#{System.unique_integer([:positive])}" @@ -282,7 +282,7 @@ defmodule TextbinWeb.UI.PasteLiveTest do |> render_change() end - refute_receive {:repo_query, _metadata}, 100 + assert_receive {:repo_query, _metadata}, 100 end test "creates a paste with the user's default expiration", %{scope: scope} do @@ -317,7 +317,7 @@ defmodule TextbinWeb.UI.PasteLiveTest do assert has_element?(view, "h1", paste.id) assert has_element?(view, "span", "json") - assert has_element?(view, "#paste-visibility", "Private") + assert has_element?(view, "#paste-audience", "Workspace") assert has_element?(view, "#paste-expires-at", "Never expires") assert has_element?(view, "#paste-data .lumis code.language-json") assert has_element?(view, "#paste-data .l-line[data-line='1']") @@ -333,7 +333,7 @@ defmodule TextbinWeb.UI.PasteLiveTest do Pastes.create_paste(scope, %{ data: "", content_type: "text/html", - visibility: "public" + audience: "public" }) {:ok, view, _html} = live(conn, ~p"/pastes/#{paste.id}") @@ -366,7 +366,7 @@ defmodule TextbinWeb.UI.PasteLiveTest do {:ok, view, _html} = live(build_conn(), ~p"/pastes/#{paste.id}") - assert has_element?(view, "#paste-visibility", String.capitalize(visibility)) + assert has_element?(view, "#paste-audience", String.capitalize(visibility)) assert has_element?(view, "#paste-data .l-line[data-line='1']") assert has_element?(view, "#paste-data", "#{visibility} paste") assert has_element?(view, "#copy-paste-content[phx-hook='CopyToClipboard']") @@ -390,6 +390,27 @@ defmodule TextbinWeb.UI.PasteLiveTest do end end + test "shared HTML enforces disabled sharing for an inconsistent public row", %{scope: scope} do + workspace = personal_workspace_fixture(scope.user) + + {:ok, workspace} = + Organizations.change_workspace_settings(scope, workspace, %{ + external_sharing_policy: "disabled" + }) + + paste = + Repo.insert!(%Paste{ + data: "internal page", + audience: "public", + workspace_id: workspace.id, + created_by_user_id: scope.user.id + }) + + assert_raise Ecto.NoResultsError, fn -> + live(build_conn(), ~p"/pastes/#{paste.id}") + end + end + test "copy button exposes the exact stored paste data", %{scope: scope} do for data <- ["abc", "abc\n"] do {:ok, paste} = diff --git a/test/textbin_web/live/ui/workspace_live_test.exs b/test/textbin_web/live/ui/workspace_live_test.exs index 33bfa8e..633abcb 100644 --- a/test/textbin_web/live/ui/workspace_live_test.exs +++ b/test/textbin_web/live/ui/workspace_live_test.exs @@ -6,6 +6,7 @@ defmodule TextbinWeb.UI.WorkspaceLiveTest do alias Textbin.Organizations alias Textbin.Organizations.WorkspaceMembership + alias Textbin.Pastes.Paste alias Textbin.Repo setup %{conn: conn} do @@ -135,6 +136,58 @@ defmodule TextbinWeb.UI.WorkspaceLiveTest do assert Repo.reload!(context.workspace).visibility == "open" end + test "workspace owners tighten external sharing and clamp existing paste audiences", context do + old_updated_at = DateTime.add(DateTime.utc_now(), -60, :second) + + public_paste = + Repo.insert!(%Paste{ + data: "public", + audience: "public", + workspace_id: context.workspace.id, + created_by_user_id: context.owner.id, + inserted_at: old_updated_at, + updated_at: old_updated_at + }) + + unlisted_paste = + Repo.insert!(%Paste{ + data: "unlisted", + audience: "unlisted", + workspace_id: context.workspace.id, + created_by_user_id: context.owner.id, + inserted_at: old_updated_at, + updated_at: old_updated_at + }) + + {:ok, view, _html} = live(context.conn, workspace_path(context, "settings")) + + view + |> form("#workspace-settings-form", %{ + "workspace" => %{ + "visibility" => "private", + "external_sharing_policy" => "unlisted" + } + }) + |> render_submit() + + assert Repo.reload!(public_paste).audience == "unlisted" + assert Repo.reload!(unlisted_paste).audience == "unlisted" + assert DateTime.after?(Repo.reload!(public_paste).updated_at, old_updated_at) + + view + |> form("#workspace-settings-form", %{ + "workspace" => %{ + "visibility" => "private", + "external_sharing_policy" => "disabled" + } + }) + |> render_submit() + + assert Repo.reload!(public_paste).audience == "workspace" + assert Repo.reload!(unlisted_paste).audience == "workspace" + assert DateTime.after?(Repo.reload!(unlisted_paste).updated_at, old_updated_at) + end + test "members can view settings but cannot mutate them", context do member = add_organization_member(context, user_fixture())
- Visibility: {String.capitalize(@current_scope.workspace.visibility)} + Visibility: {String.capitalize(@current_scope.workspace.visibility)} ยท External sharing: {String.capitalize( + @current_scope.workspace.external_sharing_policy + )}