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
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,8 @@ The parts that bite if you miss them:
is what actually refuses writes.
- **A staff claim link grants a listing with no review** (`ToolClaimInvite`, `/claim/<token>`).
Only its hash is stored, it never expires and it redeems once. The off switch is the admin's
revoke action, not a TTL.
revoke action, not a TTL. It is emailed **in the request, never queued** - django-q keeps task
arguments in the database, which would store the raw link.

## Owner badges

Expand Down
10 changes: 8 additions & 2 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,10 @@ Things to know:

Staff can hand a listing straight to its owner. **Create claim link** in the staff panel (or the
`catalog_create_claim_invite` MCP tool) takes the owner's email and returns a URL of the form
`<FRONTEND_URL>/claim/<token>`. Staff send it themselves: the server sends no mail. The owner signs
`<FRONTEND_URL>/claim/<token>`. The server emails it to that address (`claim_invite`) and reports
`emailed`; when that is false, staff send the link themselves. In the panel, typing part of a name or
email also offers existing accounts (`GET /catalog/staff/users`), and picking one fills in its
address. The owner signs
in on that page and accepts, which gives them an **approved** `ToolClaim` on the spot. There is no
code to enter and no review, because sending the link was the review.

Expand Down Expand Up @@ -292,6 +295,7 @@ production. Tests always use the in-memory backend.
| `submission_received` | a tool is submitted (`POST /catalog/submissions`) | `contact_email`, else the account |
| `submission_staff_notice` | the same, with a link to the row in the admin | every active `is_staff` account |
| `claim_code` | a vendor claims a listing | the claimant's work email |
| `claim_invite` | staff mint a claim link | the address the link was minted for |
| `benchmark_sent` | a benchmark submission is sent (`finalize`) | the submitter |
| `benchmark_scored` | scoring settles: waiting for review, or failed with each case's error | the submitter |
| `benchmark_staff_notice` | the same, only when it is waiting for review; links to the admin via `BACKEND_URL` | every active `is_staff` account |
Expand All @@ -309,7 +313,9 @@ re-queued with the admin's **Re-score** action, `services.rescore`. It runs only
submission that is scoring, failed or awaiting review, and settles it - emails included -
like any other.

All but `claim_code` go through the django-q2 queue after the transaction commits, so a
`claim_code` and `claim_invite` are sent inside the request, never queued: a task's arguments are
kept in the database, and both carry a credential the server otherwise stores only as a hash. All
the others go through the django-q2 queue after the transaction commits, so a
sign-in or a submission never waits on - or fails with - the mail provider, and a
rolled-back request mails nobody. On the host that means they only leave while
`make backend-worker` runs (or with `Q_SYNC=true` in `backend/.env`); the Docker stack
Expand Down
18 changes: 18 additions & 0 deletions backend/apps/catalog/claims.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,24 @@ def issue_claim_invite(*, tool, email, user):
return invite, f"{settings.FRONTEND_URL}/claim/{raw}"


def email_claim_invite(invite, url) -> bool:
"""Send the link to its recipient. Returns whether the mail went out.

Sent in the request, never through the queue: a task's arguments are kept in
the database, and the link is a credential that never expires. A failure is
reported rather than raised, because the caller still holds the link and can
send it by hand.
"""
try:
send_templated_email(
"claim_invite", {"tool_name": invite.tool.name, "url": url}, to=[invite.email]
)
except Exception:
logger.exception("Could not email claim invite %s", invite.pk)
return False
return True


def find_claim_invite(raw):
"""The live invite behind a link, or None. A revoked one is as good as unknown."""
return (
Expand Down
8 changes: 6 additions & 2 deletions backend/apps/catalog/mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -544,14 +544,17 @@ class RemoveToolFacetResult(Struct):

class CreateClaimInviteParams(Struct):
slug: SLUG
email: Annotated[str, Meta(description="The owner's address, where you will send the link.")]
email: Annotated[str, Meta(description="The owner's address. The link is emailed there.")]


class ClaimInviteResult(Struct):
id: int
tool: str
email: str
url: Annotated[str, Meta(description="The only copy of the link: it is not stored.")]
emailed: Annotated[
bool, Meta(description="Whether we emailed it. If false, send the link yourself.")
]
created_at: str


Expand Down Expand Up @@ -1006,7 +1009,8 @@ def catalog_remove_tool_facet(request, params: ToolFacetParams) -> RemoveToolFac
"Mint a one-time claim link for a listing's owner. Whoever opens it "
"while signed in becomes an approved maintainer with no further "
"review, so send it only to someone you have verified. It never "
"expires; this tool does not send mail - pass the url on yourself. "
"expires. It is emailed to the address; if `emailed` is false, "
"pass the url on yourself. "
"A link sent to the wrong person is revoked in the admin."
),
read_only=False,
Expand Down
12 changes: 11 additions & 1 deletion backend/apps/catalog/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@

from datetime import date, datetime
from decimal import Decimal
from uuid import UUID

from ninja import Schema
from ninja import Field, Schema


class VendorOut(Schema):
Expand Down Expand Up @@ -567,13 +568,22 @@ class StaffClaimInviteIn(Schema):
email: str


class StaffUserOut(Schema):
id: UUID
email: str
name: str


class StaffClaimInviteOut(Schema):
"""Carries the only copy of the link: the server keeps its hash."""

id: int
tool: str
email: str
url: str
emailed: bool = Field(
..., description="False when the mail failed: staff send the link by hand."
)
created_at: datetime


Expand Down
16 changes: 16 additions & 0 deletions backend/apps/catalog/staff.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

from decimal import Decimal, InvalidOperation

from django.contrib.auth import get_user_model
from django.core.exceptions import ValidationError as DjangoValidationError
from django.core.validators import validate_email
from django.db import IntegrityError, transaction
Expand Down Expand Up @@ -910,6 +911,20 @@ def _screenshot(shot):
# --- Claim links ---------------------------------------------------------------


def find_users(*, q, limit=10):
"""Active accounts whose email or name contains `q`, for choosing an invite's recipient."""
q = (q or "").strip()
if not q:
return []
found = (
get_user_model()
.objects.filter(is_active=True)
.filter(Q(email__icontains=q) | Q(name__icontains=q))
.order_by("email")[:limit]
)
return [{"id": account.pk, "email": account.email, "name": account.name} for account in found]


def create_claim_invite(*, user, slug, email):
"""Mint a one-time link that grants this listing to whoever redeems it.

Expand All @@ -930,5 +945,6 @@ def create_claim_invite(*, user, slug, email):
"tool": tool.slug,
"email": invite.email,
"url": url,
"emailed": claims.email_claim_invite(invite, url),
"created_at": invite.created_at.isoformat(),
}
10 changes: 10 additions & 0 deletions backend/apps/catalog/staff_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
StaffToolReviewIn,
StaffToolReviewOut,
StaffToolUpdateOut,
StaffUserOut,
)

router = Router(tags=["catalog-staff"], auth=StaffJWTAuth())
Expand Down Expand Up @@ -190,6 +191,15 @@ def staff_upload_tool_logo(request: HttpRequest, slug: str, image: File[Uploaded
# --- Claim links -------------------------------------------------------------


@router.get(
"/users",
response=list[StaffUserOut],
summary="Find accounts by email or name, to choose a claim link's recipient",
)
def staff_find_users(request: HttpRequest, q: str = ""):
return staff.find_users(q=q)


@router.post(
"/tools/{slug}/claim-invites",
response={201: StaffClaimInviteOut},
Expand Down
207 changes: 207 additions & 0 deletions backend/apps/core/templates/email/claim_invite/body.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
<!doctype html>
<html lang="en" dir="auto" xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">

<head>
<title>Maintain the {{ tool_name }} listing on Redaction Tools</title>
<!--[if !mso]><!-->
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<!--<![endif]-->
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style type="text/css">
#outlook a {
padding: 0;
}

body {
margin: 0;
padding: 0;
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
}

table,
td {
border-collapse: collapse;
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
}

img {
border: 0;
height: auto;
line-height: 100%;
outline: none;
text-decoration: none;
-ms-interpolation-mode: bicubic;
}

p {
display: block;
margin: 13px 0;
}
</style>
<!--[if mso]>
<noscript>
<xml>
<o:OfficeDocumentSettings>
<o:AllowPNG/>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
</noscript>
<![endif]-->
<!--[if lte mso 11]>
<style type="text/css">
.mj-outlook-group-fix { width:100% !important; }
</style>
<![endif]-->
<!--[if !mso]><!-->
<link href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700" rel="stylesheet" type="text/css">
<style type="text/css">
@import url(https://fonts.googleapis.com/css?family=Roboto:300,400,500,700);
</style>
<!--<![endif]-->
<style type="text/css">
@media only screen and (min-width:480px) {
.mj-column-per-100 {
width: 100% !important;
max-width: 100%;
}
}
</style>
<style media="screen and (min-width:480px)">
.moz-text-html .mj-column-per-100 {
width: 100% !important;
max-width: 100%;
}
</style>
</head>

<body style="word-spacing:normal;background-color:#f4f4f5;">
<div style="display:none;font-size:1px;color:#ffffff;line-height:1px;max-height:0px;max-width:0px;opacity:0;overflow:hidden;">You have been invited to maintain the {{ tool_name }} listing</div>
<div aria-label="Maintain the {{ tool_name }} listing on Redaction Tools" aria-roledescription="email" role="article" lang="en" dir="auto" style="word-spacing:normal;background-color:#f4f4f5;">
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:624px;" width="624" bgcolor="#f4f4f5" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#f4f4f5;background-color:#f4f4f5;margin:0px auto;max-width:624px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#f4f4f5;background-color:#f4f4f5;width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:32px 32px 0;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:560px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" width="100%">
<tbody>
<tr>
<td style="background-color:#ffffff;border-bottom:1px solid #e4e4e7;vertical-align:top;padding:20px 32px;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="" width="100%">
<tbody>
<tr>
<td align="left" style="font-size:0px;padding:0;word-break:break-word;">
<div style="font-family:-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;font-size:18px;font-weight:600;line-height:32px;text-align:left;color:#18181b;"><a href="{{ site_url }}" style="color:#18181b;text-decoration:none;"><img src="{{ site_url }}/images/email/logo.png" width="32" height="32" alt="" style="display:inline-block;vertical-align:middle;border:0;margin-right:10px;">Redaction Tools</a></div>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:624px;" width="624" bgcolor="#f4f4f5" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#f4f4f5;background-color:#f4f4f5;margin:0px auto;max-width:624px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#f4f4f5;background-color:#f4f4f5;width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:0 32px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:560px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" width="100%">
<tbody>
<tr>
<td style="background-color:#ffffff;vertical-align:top;padding:32px 32px 16px;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="" width="100%">
<tbody>
<tr>
<td align="left" style="font-size:0px;padding:0 0 16px;word-break:break-word;">
<div style="font-family:-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;font-size:15px;line-height:1.6;text-align:left;color:#18181b;">Our team has invited you to maintain the <strong>{{ tool_name }}</strong> listing on Redaction Tools.</div>
</td>
</tr>
<tr>
<td align="left" style="font-size:0px;padding:8px 0 16px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:separate;line-height:100%;">
<tbody>
<tr>
<td align="center" bgcolor="#18181b" role="presentation" style="border:none;border-radius:6px;cursor:auto;mso-padding-alt:10px 25px;background:#18181b;" valign="middle">
<a href="{{ url }}" style="display:inline-block;background:#18181b;color:#ffffff;font-family:-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;font-size:15px;font-weight:normal;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:10px 25px;mso-padding-alt:0px;border-radius:6px;" target="_blank"> Accept the invitation </a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
<tr>
<td align="left" style="font-size:0px;padding:0 0 16px;word-break:break-word;">
<div style="font-family:-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;font-size:15px;line-height:1.6;text-align:left;color:#18181b;">Sign in with Google, then accept. As a maintainer you can propose corrections to the listing's pricing and details, and the listing shows that its vendor maintains it.</div>
</td>
</tr>
<tr>
<td align="left" style="font-size:0px;padding:0 0 16px;word-break:break-word;">
<div style="font-family:-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;font-size:15px;line-height:1.6;text-align:left;color:#18181b;">The link works once, for whoever accepts it first, so please do not forward it. If you were not expecting this, you can ignore it.</div>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:624px;" width="624" bgcolor="#f4f4f5" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#f4f4f5;background-color:#f4f4f5;margin:0px auto;max-width:624px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#f4f4f5;background-color:#f4f4f5;width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:0 32px 32px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:560px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" width="100%">
<tbody>
<tr>
<td style="background-color:#ffffff;border-top:1px solid #e4e4e7;vertical-align:top;padding:16px 32px;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="" width="100%">
<tbody>
<tr>
<td align="left" style="font-size:0px;padding:0;word-break:break-word;">
<div style="font-family:-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;font-size:12px;line-height:1.5;text-align:left;color:#71717a;">You received this because of an action on <a href="{{ site_url }}" style="color:#71717a;">Redaction Tools</a>.</div>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</div>
</body>

</html>
Loading
Loading