From 1421499d668294d8583975ad1944c7c0f1807411 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 23:55:08 +0000 Subject: [PATCH 1/3] feat: include share and raw URLs in paste API responses Every paste in the API now carries url, the browser link people share, and raw_url, the exact stored bytes, as absolute URLs. The CLI and scripts can print or open them without rebuilding paths from the server address. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019fnjMN7WpEjQJD2zveakV6 --- .../controllers/apiv1/paste_json.ex | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) 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, From cc00c47ab9bc90eeced7973895d9f617316735af Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 23:55:08 +0000 Subject: [PATCH 2/3] feat: list, restore and permanently delete removed pastes over the API Removed pastes are their own collection, for the personal workspace and for any joined workspace: GET /api/v1/pastes/deleted POST /api/v1/pastes/deleted/:id/restore DELETE /api/v1/pastes/deleted/:id plus the same under /api/v1/workspaces/:workspace_id. Listings carry the removal reason, when it happened and when the paste is purged, never the content. Restore and permanent delete follow the same rules as the Deleted tab, and answer 404 alike for an unknown id, another member's paste and an expired recovery window, so they never confirm a paste exists. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019fnjMN7WpEjQJD2zveakV6 --- .../controllers/apiv1/paste_controller.ex | 56 +++++++++ lib/textbin_web/router.ex | 8 ++ .../apiv1/paste_controller_test.exs | 108 ++++++++++++++++++ 3 files changed, 172 insertions(+) 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/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..c2e0c3e 100644 --- a/test/textbin_web/controllers/apiv1/paste_controller_test.exs +++ b/test/textbin_web/controllers/apiv1/paste_controller_test.exs @@ -519,6 +519,114 @@ 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 + {:ok, deleted} = Pastes.create_paste(scope, %{data: "deleted", title: "Gone"}) + {: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["removal_reason"] == "deleted" + refute Map.has_key?(gone, "data") + + {: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"}) From 1e438ad2b49f4ceded4ff02df4fa3c2370cc901f Mon Sep 17 00:00:00 2001 From: "Darwin D. Wu" Date: Sat, 10 Oct 2026 17:23:24 -0700 Subject: [PATCH 3/3] fix: preserve references in removed paste API metadata Include reference_url in the content-free removed-paste projection. Extend the API regression to assert the stored reference is returned while all content encoding fields remain absent. The regression failed before the fix; 563 ExUnit tests and Credo pass afterward. --- lib/textbin/pastes.ex | 1 + .../controllers/apiv1/paste_controller_test.exs | 13 ++++++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) 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/test/textbin_web/controllers/apiv1/paste_controller_test.exs b/test/textbin_web/controllers/apiv1/paste_controller_test.exs index c2e0c3e..1b2a968 100644 --- a/test/textbin_web/controllers/apiv1/paste_controller_test.exs +++ b/test/textbin_web/controllers/apiv1/paste_controller_test.exs @@ -538,7 +538,15 @@ defmodule TextbinWeb.ApiV1.PasteControllerTest do conn: conn, scope: scope } do - {:ok, deleted} = Pastes.create_paste(scope, %{data: "deleted", title: "Gone"}) + 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"}) @@ -553,8 +561,11 @@ defmodule TextbinWeb.ApiV1.PasteControllerTest do 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"])