Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions lib/textbin/pastes.ex
Original file line number Diff line number Diff line change
Expand Up @@ -413,6 +413,7 @@ defmodule Textbin.Pastes do
struct(p, [
:id,
:title,
:reference_url,
:content_type,
:syntax_highlight,
:audience,
Expand Down
56 changes: 56 additions & 0 deletions lib/textbin_web/controllers/apiv1/paste_controller.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 28 additions & 0 deletions lib/textbin_web/controllers/apiv1/paste_json.ex
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
defmodule TextbinWeb.ApiV1.PasteJSON do
use TextbinWeb, :verified_routes

alias Textbin.Pastes.ContentType
alias Textbin.Pastes.Paste

Expand All @@ -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)

Expand All @@ -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,
Expand Down
8 changes: 8 additions & 0 deletions lib/textbin_web/router.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
119 changes: 119 additions & 0 deletions test/textbin_web/controllers/apiv1/paste_controller_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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"})
Expand Down
Loading