diff --git a/lib/textbin/pastes.ex b/lib/textbin/pastes.ex index efcacdb..eb1d19c 100644 --- a/lib/textbin/pastes.ex +++ b/lib/textbin/pastes.ex @@ -413,6 +413,7 @@ defmodule Textbin.Pastes do struct(p, [ :id, :title, + :reference_url, :content_type, :syntax_highlight, :audience, diff --git a/lib/textbin_web/controllers/apiv1/paste_controller.ex b/lib/textbin_web/controllers/apiv1/paste_controller.ex index 6e5e156..5605117 100644 --- a/lib/textbin_web/controllers/apiv1/paste_controller.ex +++ b/lib/textbin_web/controllers/apiv1/paste_controller.ex @@ -350,6 +350,62 @@ defmodule TextbinWeb.ApiV1.PasteController do defp delete_scoped_paste(scope, paste, _params), do: Pastes.delete_personal_paste(scope, paste) + def deleted(conn, params) do + with_paste_scope(conn, params, fn scope -> + render(conn, :deleted, + pastes: Pastes.list_removed_pastes(scope), + organization_id: scope.organization.id, + workspace_id: scope.workspace.id, + recovery_window_days: scope.workspace.recovery_window_days + ) + end) + end + + # Restoring and deleting for good answer 404 alike for an unknown id, a paste + # the caller cannot manage, and one whose recovery window has passed, so the + # response never confirms another member's paste exists. + def restore(conn, %{"id" => id} = params) do + with_paste_scope(conn, params, fn scope -> + with {:ok, paste_id} <- Ecto.UUID.cast(id), + {:ok, paste} <- Pastes.restore_paste(scope, paste_id) do + render(conn, :restored, + paste: paste, + organization_id: scope.organization.id, + workspace_id: scope.workspace.id + ) + else + :error -> render_invalid_paste_id(conn) + {:error, _reason} -> render_removed_paste_not_found(conn) + end + end) + end + + def purge(conn, %{"id" => id} = params) do + with_paste_scope(conn, params, fn scope -> + with {:ok, paste_id} <- Ecto.UUID.cast(id), + {:ok, _paste} <- Pastes.purge_paste(scope, paste_id) do + send_resp(conn, :no_content, "") + else + :error -> + render_invalid_paste_id(conn) + + {:error, :not_found} -> + render_removed_paste_not_found(conn) + + {:error, _reason} -> + conn + |> put_status(:service_unavailable) + |> json(%{errors: %{detail: "Paste could not be permanently deleted"}}) + end + end) + end + + defp render_removed_paste_not_found(conn) do + conn + |> put_status(:not_found) + |> json(%{errors: %{detail: "Removed paste not found"}}) + end + defp validate_paste_size(%{"data" => data}) when is_binary(data) do if byte_size(data) <= max_paste_bytes() do :ok diff --git a/lib/textbin_web/controllers/apiv1/paste_json.ex b/lib/textbin_web/controllers/apiv1/paste_json.ex index 2f82ed2..d0daf04 100644 --- a/lib/textbin_web/controllers/apiv1/paste_json.ex +++ b/lib/textbin_web/controllers/apiv1/paste_json.ex @@ -1,4 +1,6 @@ defmodule TextbinWeb.ApiV1.PasteJSON do + use TextbinWeb, :verified_routes + alias Textbin.Pastes.ContentType alias Textbin.Pastes.Paste @@ -14,6 +16,28 @@ defmodule TextbinWeb.ApiV1.PasteJSON do %{data: data(paste, assigns)} end + def restored(%{paste: paste} = assigns) do + %{data: metadata(paste, assigns)} + end + + # Removed pastes never include content; restore one to read it again. + def deleted(%{pastes: pastes, recovery_window_days: window_days} = assigns) do + %{ + data: + for paste <- pastes do + removed_on = Paste.removed_on(paste) + + paste + |> metadata(assigns) + |> Map.merge(%{ + removed_at: timestamp(removed_on), + removal_reason: Paste.removal_reason(paste), + purge_after: timestamp(DateTime.add(removed_on, window_days, :day)) + }) + end + } + end + defp data(%Paste{} = paste, assigns) do metadata = metadata(paste, assigns) @@ -31,6 +55,10 @@ defmodule TextbinWeb.ApiV1.PasteJSON do defp metadata(%Paste{} = paste, assigns) do %{ id: paste.id, + # The browser link people share, and the exact stored bytes. Both are + # absolute so CLI and script output can be pasted anywhere. + url: url(~p"/pastes/#{paste.id}"), + raw_url: url(~p"/pastes/#{paste.id}/raw"), organization_id: assigns.organization_id, workspace_id: assigns.workspace_id, title: paste.title, diff --git a/lib/textbin_web/router.ex b/lib/textbin_web/router.ex index 12014cc..3c9e53e 100644 --- a/lib/textbin_web/router.ex +++ b/lib/textbin_web/router.ex @@ -44,6 +44,14 @@ defmodule TextbinWeb.Router do get "/organizations/:id/workspaces", OrganizationController, :workspaces get "/organizations/:id/audit-events", OrganizationController, :audit_events post "/workspaces/:workspace_id/recovery", OrganizationController, :recover_workspace + # Removed pastes are their own collection so "deleted" never collides + # with a paste id; these routes must precede the /:id routes below. + get "/workspaces/:workspace_id/pastes/deleted", PasteController, :deleted + post "/workspaces/:workspace_id/pastes/deleted/:id/restore", PasteController, :restore + delete "/workspaces/:workspace_id/pastes/deleted/:id", PasteController, :purge + get "/pastes/deleted", PasteController, :deleted + post "/pastes/deleted/:id/restore", PasteController, :restore + delete "/pastes/deleted/:id", PasteController, :purge get "/workspaces/:workspace_id/pastes", PasteController, :index post "/workspaces/:workspace_id/pastes", PasteController, :create get "/workspaces/:workspace_id/pastes/:id", PasteController, :show diff --git a/test/textbin_web/controllers/apiv1/paste_controller_test.exs b/test/textbin_web/controllers/apiv1/paste_controller_test.exs index 49fc108..1b2a968 100644 --- a/test/textbin_web/controllers/apiv1/paste_controller_test.exs +++ b/test/textbin_web/controllers/apiv1/paste_controller_test.exs @@ -519,6 +519,125 @@ defmodule TextbinWeb.ApiV1.PasteControllerTest do end end + describe "paste links" do + test "responses carry the share and raw URLs", %{conn: conn, scope: scope} do + {:ok, paste} = Pastes.create_paste(scope, %{data: "linked"}) + + assert %{"data" => data} = conn |> get(~p"/api/v1/pastes/#{paste.id}") |> json_response(200) + assert data["url"] == "http://localhost:4002/pastes/#{paste.id}" + assert data["raw_url"] == "http://localhost:4002/pastes/#{paste.id}/raw" + + conn = post(conn, ~p"/api/v1/pastes", %{data: "created"}) + assert %{"data" => %{"id" => id, "url" => url}} = json_response(conn, 201) + assert url =~ ~r{/pastes/#{id}$} + end + end + + describe "removed pastes" do + test "lists deleted and expired pastes with their reason and purge date", %{ + conn: conn, + scope: scope + } do + reference_url = "https://tracker.example.com/INC-2041" + + {:ok, deleted} = + Pastes.create_paste(scope, %{ + data: "deleted", + title: "Gone", + reference_url: reference_url + }) + + {:ok, _} = Pastes.delete_paste(scope, deleted) + {:ok, expired} = Pastes.create_paste(scope, %{data: "expired"}) + + expired + |> Ecto.Changeset.change(expires_at: DateTime.add(Paste.utc_now_ms(), -1, :day)) + |> Repo.update!() + + {:ok, _active} = Pastes.create_paste(scope, %{data: "still here"}) + + assert %{"data" => removed} = conn |> get(~p"/api/v1/pastes/deleted") |> json_response(200) + assert Enum.map(removed, & &1["id"]) |> Enum.sort() == Enum.sort([deleted.id, expired.id]) + + gone = Enum.find(removed, &(&1["id"] == deleted.id)) + assert gone["title"] == "Gone" + assert gone["reference_url"] == reference_url + assert gone["removal_reason"] == "deleted" + refute Map.has_key?(gone, "data") + refute Map.has_key?(gone, "data_base64") + refute Map.has_key?(gone, "data_encoding") + + {:ok, removed_at, _} = DateTime.from_iso8601(gone["removed_at"]) + {:ok, purge_after, _} = DateTime.from_iso8601(gone["purge_after"]) + assert DateTime.diff(purge_after, removed_at, :day) == 30 + + assert Enum.find(removed, &(&1["id"] == expired.id))["removal_reason"] == "expired" + end + + test "restores a deleted paste so it can be read again", %{conn: conn, scope: scope} do + {:ok, paste} = Pastes.create_paste(scope, %{data: "bring back"}) + {:ok, _} = Pastes.delete_paste(scope, paste) + + conn = post(conn, ~p"/api/v1/pastes/deleted/#{paste.id}/restore") + assert %{"data" => %{"id" => id}} = json_response(conn, 200) + assert id == paste.id + assert Pastes.get_paste(scope, paste.id) + end + + test "permanently deletes a removed paste", %{conn: conn, scope: scope} do + {:ok, paste} = Pastes.create_paste(scope, %{data: "forget me"}) + {:ok, _} = Pastes.delete_paste(scope, paste) + + conn = delete(conn, ~p"/api/v1/pastes/deleted/#{paste.id}") + assert response(conn, 204) + refute Repo.get(Paste, paste.id) + end + + test "active, unknown and other people's pastes are not found", %{conn: conn, scope: scope} do + {:ok, active} = Pastes.create_paste(scope, %{data: "active"}) + other_scope = user_scope_fixture() + {:ok, other} = Pastes.create_paste(other_scope, %{data: "theirs"}) + {:ok, _} = Pastes.delete_paste(other_scope, other) + + for id <- [active.id, other.id, Ecto.UUID.generate()] do + assert %{"errors" => %{"detail" => "Removed paste not found"}} = + conn |> post(~p"/api/v1/pastes/deleted/#{id}/restore") |> json_response(404) + + assert conn |> delete(~p"/api/v1/pastes/deleted/#{id}") |> json_response(404) + end + + assert Pastes.get_paste(scope, active.id) + assert Repo.get(Paste, other.id) + assert conn |> post(~p"/api/v1/pastes/deleted/not-a-uuid/restore") |> json_response(400) + end + + test "workspace routes act on that workspace's removed pastes", %{ + conn: conn, + scope: scope + } do + {:ok, organization} = + Organizations.create_organization(scope, %{name: "API team", slug: "api-team"}) + + [workspace] = organization.workspaces + {:ok, team_scope} = Organizations.resolve_workspace_scope(scope, workspace) + {:ok, paste} = Pastes.create_paste(team_scope, %{data: "team paste"}) + {:ok, _} = Pastes.delete_paste(team_scope, paste) + + assert %{"data" => []} = conn |> get(~p"/api/v1/pastes/deleted") |> json_response(200) + + assert %{"data" => [%{"id" => id, "workspace_id" => workspace_id}]} = + conn + |> get(~p"/api/v1/workspaces/#{workspace.id}/pastes/deleted") + |> json_response(200) + + assert id == paste.id and workspace_id == workspace.id + + conn = post(conn, ~p"/api/v1/workspaces/#{workspace.id}/pastes/deleted/#{paste.id}/restore") + assert json_response(conn, 200) + assert Pastes.get_paste(team_scope, paste.id) + end + end + describe "delete paste" do test "deletes chosen paste", %{conn: conn, scope: scope} do {:ok, paste} = Pastes.create_paste(scope, %{data: "some data"})