From 8ebabfe1028716561053e927eb96a1b1f4f8cce0 Mon Sep 17 00:00:00 2001 From: Username Date: Tue, 24 Feb 2026 12:55:44 +0100 Subject: [PATCH] pastes: add display_name field Authenticated users can tag pastes with a human-readable label via X-Display-Name header. Supports create, update, remove, and listing. Max 128 chars, control characters rejected. --- app/api/routes.py | 51 ++++++++++++++++++-- app/database.py | 3 +- documentation/api.md | 73 +++++++++++++++++++++++++++- tests/test_paste_listing.py | 94 +++++++++++++++++++++++++++++++++++++ tests/test_paste_update.py | 80 +++++++++++++++++++++++++++++++ 5 files changed, 295 insertions(+), 6 deletions(-) diff --git a/app/api/routes.py b/app/api/routes.py index 2e62bb3..bc12fe3 100644 --- a/app/api/routes.py +++ b/app/api/routes.py @@ -42,6 +42,7 @@ PASTE_ID_PATTERN = re.compile(r"^[a-f0-9]+$") CLIENT_ID_PATTERN = re.compile(r"^[a-f0-9]{40}$") MIME_PATTERN = re.compile(r"^[a-z0-9][a-z0-9!#$&\-^_.+]*/[a-z0-9][a-z0-9!#$&\-^_.+]*$") SHORT_ID_PATTERN = re.compile(r"^[a-zA-Z0-9]+$") +CONTROL_CHAR_PATTERN = re.compile(r"[\x00-\x1f\x7f]") SHORT_ID_ALPHABET = string.ascii_letters + string.digits ALLOWED_URL_SCHEMES = frozenset({"http", "https"}) @@ -450,6 +451,7 @@ def build_paste_metadata( password_protected: bool = False, include_owner: bool = False, last_accessed: int | None = None, + display_name: str | None = None, ) -> dict[str, Any]: """Build standardized paste metadata response dict. @@ -464,6 +466,7 @@ def build_paste_metadata( password_protected: Whether paste has password include_owner: Whether to include owner in response last_accessed: Last access timestamp (optional) + display_name: Human-readable label (optional) """ data: dict[str, Any] = { "id": paste_id, @@ -483,6 +486,8 @@ def build_paste_metadata( data["expires_at"] = expires_at if password_protected: data["password_protected"] = True + if display_name: + data["display_name"] = display_name return data @@ -523,7 +528,8 @@ def fetch_paste(paste_id: str, check_password: bool = True) -> Response | None: row = db.execute( """SELECT id, content, mime_type, owner, created_at, - length(content) as size, burn_after_read, expires_at, password_hash + length(content) as size, burn_after_read, expires_at, + password_hash, display_name FROM pastes WHERE id = ?""", (paste_id,), ).fetchone() @@ -1242,6 +1248,16 @@ class IndexView(MethodView): return error_response("Password too long (max 1024 chars)", 400) password_hash = hash_password(password_header) + # Display name (authenticated users only, silently ignored for anonymous) + display_name: str | None = None + display_name_header = request.headers.get("X-Display-Name", "").strip() + if display_name_header and owner: + if len(display_name_header) > 128: + return error_response("Display name too long (max 128 chars)", 400) + if CONTROL_CHAR_PATTERN.search(display_name_header): + return error_response("Display name contains invalid characters", 400) + display_name = display_name_header + # Insert paste paste_id = generate_paste_id(content) now = int(time.time()) @@ -1250,8 +1266,8 @@ class IndexView(MethodView): db.execute( """INSERT INTO pastes (id, content, mime_type, owner, created_at, last_accessed, - burn_after_read, expires_at, password_hash) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""", + burn_after_read, expires_at, password_hash, display_name) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", ( paste_id, content, @@ -1262,6 +1278,7 @@ class IndexView(MethodView): 1 if burn_after_read else 0, expires_at, password_hash, + display_name, ), ) db.commit() @@ -1282,6 +1299,8 @@ class IndexView(MethodView): response_data["expires_at"] = expires_at if password_hash: response_data["password_protected"] = True + if display_name: + response_data["display_name"] = display_name # Record successful paste for anti-flood tracking record_antiflood_request() @@ -1562,6 +1581,7 @@ class PasteView(MethodView): burn_after_read=bool(row["burn_after_read"]), expires_at=row["expires_at"], password_protected=bool(row["password_hash"]), + display_name=row["display_name"], ) ) @@ -1581,6 +1601,8 @@ class PasteView(MethodView): - X-Paste-Password: Set/change password - X-Remove-Password: true to remove password - X-Extend-Expiry: Seconds to add to current expiry + - X-Display-Name: Set/change display name + - X-Remove-Display-Name: true to remove display name """ # Validate paste ID format if err := validate_paste_id(paste_id): @@ -1659,6 +1681,23 @@ class PasteView(MethodView): except ValueError: return error_response("Invalid X-Extend-Expiry value", 400) + # Display name update + remove_display_name = request.headers.get("X-Remove-Display-Name", "").lower() in ( + "true", + "1", + "yes", + ) + new_display_name = request.headers.get("X-Display-Name", "").strip() + if remove_display_name: + update_fields.append("display_name = NULL") + elif new_display_name: + if len(new_display_name) > 128: + return error_response("Display name too long (max 128 chars)", 400) + if CONTROL_CHAR_PATTERN.search(new_display_name): + return error_response("Display name contains invalid characters", 400) + update_fields.append("display_name = ?") + update_params.append(new_display_name) + if not update_fields: return error_response("No updates provided", 400) @@ -1684,6 +1723,7 @@ class PasteView(MethodView): # Fetch updated paste for response updated = db.execute( """SELECT id, mime_type, length(content) as size, expires_at, + display_name, CASE WHEN password_hash IS NOT NULL THEN 1 ELSE 0 END as password_protected FROM pastes WHERE id = ?""", (paste_id,), @@ -1698,6 +1738,8 @@ class PasteView(MethodView): response_data["expires_at"] = updated["expires_at"] if updated["password_protected"]: response_data["password_protected"] = True + if updated["display_name"]: + response_data["display_name"] = updated["display_name"] return json_response(response_data) @@ -1915,7 +1957,7 @@ class PastesListView(MethodView): # Include owner for admin view rows = db.execute( f"""SELECT id, owner, mime_type, length(content) as size, created_at, - last_accessed, burn_after_read, expires_at, + last_accessed, burn_after_read, expires_at, display_name, CASE WHEN password_hash IS NOT NULL THEN 1 ELSE 0 END as password_protected FROM pastes WHERE {where_sql} @@ -1940,6 +1982,7 @@ class PastesListView(MethodView): password_protected=bool(row["password_protected"]), include_owner=show_all, last_accessed=row["last_accessed"], + display_name=row["display_name"], ) for row in rows ] diff --git a/app/database.py b/app/database.py index 560429b..849c656 100644 --- a/app/database.py +++ b/app/database.py @@ -28,7 +28,8 @@ CREATE TABLE IF NOT EXISTS pastes ( last_accessed INTEGER NOT NULL, burn_after_read INTEGER NOT NULL DEFAULT 0, expires_at INTEGER, - password_hash TEXT + password_hash TEXT, + display_name TEXT ); CREATE INDEX IF NOT EXISTS idx_pastes_created_at ON pastes(created_at); diff --git a/documentation/api.md b/documentation/api.md index 4ef096d..ec2a39f 100644 --- a/documentation/api.md +++ b/documentation/api.md @@ -175,6 +175,7 @@ X-SSL-Client-SHA1: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2 "expires_at": "2024-12-25T10:30:00Z", "burn_after_read": false, "password_protected": false, + "display_name": "my notes", "owner": "a1b2c3d4..." } ], @@ -280,6 +281,20 @@ X-Paste-Password: secretpassword Password protected content ``` +**Request (Display Name):** + +Tag a paste with a human-readable label (authenticated users only): + +```http +POST / HTTP/1.1 +Host: localhost:5000 +Content-Type: text/plain +X-SSL-Client-SHA1: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2 +X-Display-Name: project notes + +Content here +``` + **Response (201 Created):** ```json { @@ -291,7 +306,8 @@ Password protected content "owner": "a1b2c3...", // Only present if authenticated "burn_after_read": true, // Only present if enabled "expires_at": 1700003600, // Only present if custom expiry set - "password_protected": true // Only present if password set + "password_protected": true, // Only present if password set + "display_name": "my notes" // Only present if set (authenticated only) } ``` @@ -300,6 +316,8 @@ Password protected content |------|-------------| | 400 | No content provided | | 400 | Password too long (max 1024 chars) | +| 400 | Display name too long (max 128 chars) | +| 400 | Display name contains invalid characters | | 400 | Proof-of-work required (when PoW enabled) | | 400 | Proof-of-work failed (invalid/expired challenge) | | 400 | Paste too small (below minimum size) | @@ -427,6 +445,59 @@ Content-Disposition: inline --- +### PUT /{id} + +Update paste content and/or metadata. Requires authentication and ownership. + +**Request:** +```http +PUT /abc12345 HTTP/1.1 +Host: localhost:5000 +X-SSL-Client-SHA1: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2 +Content-Type: text/plain + +Updated content +``` + +**Headers:** +| Header | Description | +|--------|-------------| +| `X-Paste-Password` | Set or change paste password | +| `X-Remove-Password` | `true` to remove password protection | +| `X-Extend-Expiry` | Seconds to add to current expiry | +| `X-Display-Name` | Set or change display name (max 128 chars) | +| `X-Remove-Display-Name` | `true` to remove display name | + +**Response (200 OK):** +```json +{ + "id": "abc12345", + "size": 15, + "mime_type": "text/plain", + "expires_at": 1700086400, + "display_name": "my notes" +} +``` + +**Errors:** +| Code | Description | +|------|-------------| +| 400 | Invalid paste ID format | +| 400 | No updates provided | +| 400 | Cannot update burn-after-read paste | +| 400 | Display name too long (max 128 chars) | +| 400 | Display name contains invalid characters | +| 401 | Authentication required | +| 403 | Permission denied (not owner) | +| 404 | Paste not found | + +**Notes:** +- Send content in the body to update paste content +- Use headers with empty body for metadata-only updates +- Display name only accepts printable characters (no control chars) + +--- + ### DELETE /{id} Delete a paste. Requires authentication and ownership (or admin rights). diff --git a/tests/test_paste_listing.py b/tests/test_paste_listing.py index 3492f30..ef6e008 100644 --- a/tests/test_paste_listing.py +++ b/tests/test_paste_listing.py @@ -333,3 +333,97 @@ class TestPastesSearch: assert response.status_code == 200 data = json.loads(response.data) assert data["count"] == 1 + + +class TestPastesDisplayName: + """Tests for display_name field in paste listing.""" + + def test_create_with_display_name(self, client, auth_header): + """Create paste with display_name returns it in response.""" + response = client.post( + "/", + data="test content", + content_type="text/plain", + headers={**auth_header, "X-Display-Name": "my notes"}, + ) + assert response.status_code == 201 + data = json.loads(response.data) + assert data["display_name"] == "my notes" + + def test_display_name_in_listing(self, client, auth_header): + """Display name appears in paste listing.""" + client.post( + "/", + data="test content", + content_type="text/plain", + headers={**auth_header, "X-Display-Name": "project docs"}, + ) + + response = client.get("/pastes", headers=auth_header) + data = json.loads(response.data) + assert data["count"] == 1 + assert data["pastes"][0]["display_name"] == "project docs" + + def test_display_name_in_metadata(self, client, auth_header): + """Display name appears in single-paste GET.""" + create = client.post( + "/", + data="test content", + content_type="text/plain", + headers={**auth_header, "X-Display-Name": "readme"}, + ) + paste_id = json.loads(create.data)["id"] + + response = client.get(f"/{paste_id}") + data = json.loads(response.data) + assert data["display_name"] == "readme" + + def test_display_name_absent_when_unset(self, client, auth_header): + """Display name is omitted from response when not set.""" + create = client.post( + "/", + data="test content", + content_type="text/plain", + headers=auth_header, + ) + paste_id = json.loads(create.data)["id"] + + response = client.get(f"/{paste_id}") + data = json.loads(response.data) + assert "display_name" not in data + + def test_anonymous_display_name_ignored(self, client): + """Anonymous user's display_name header is silently ignored.""" + create = client.post( + "/", + data="test content", + content_type="text/plain", + headers={"X-Display-Name": "sneaky"}, + ) + assert create.status_code == 201 + data = json.loads(create.data) + assert "display_name" not in data + + def test_display_name_too_long(self, client, auth_header): + """Display name exceeding 128 chars is rejected.""" + response = client.post( + "/", + data="test content", + content_type="text/plain", + headers={**auth_header, "X-Display-Name": "a" * 129}, + ) + assert response.status_code == 400 + data = json.loads(response.data) + assert "too long" in data["error"].lower() + + def test_display_name_control_chars_rejected(self, client, auth_header): + """Display name with control characters is rejected.""" + response = client.post( + "/", + data="test content", + content_type="text/plain", + headers={**auth_header, "X-Display-Name": "bad\x00name"}, + ) + assert response.status_code == 400 + data = json.loads(response.data) + assert "invalid" in data["error"].lower() diff --git a/tests/test_paste_update.py b/tests/test_paste_update.py index 0ad9645..a4a4bf7 100644 --- a/tests/test_paste_update.py +++ b/tests/test_paste_update.py @@ -202,6 +202,86 @@ class TestPasteUpdateEndpoint: assert data.get("password_protected") is True +class TestPasteUpdateDisplayName: + """Tests for display_name update via PUT.""" + + def test_update_set_display_name(self, client, auth_header): + """Set display_name on existing paste.""" + create = client.post("/", data="content", content_type="text/plain", headers=auth_header) + paste_id = json.loads(create.data)["id"] + + response = client.put( + f"/{paste_id}", + data="", + headers={**auth_header, "X-Display-Name": "my label"}, + ) + assert response.status_code == 200 + data = json.loads(response.data) + assert data["display_name"] == "my label" + + def test_update_change_display_name(self, client, auth_header): + """Change existing display_name.""" + create = client.post( + "/", + data="content", + content_type="text/plain", + headers={**auth_header, "X-Display-Name": "old name"}, + ) + paste_id = json.loads(create.data)["id"] + + response = client.put( + f"/{paste_id}", + data="", + headers={**auth_header, "X-Display-Name": "new name"}, + ) + assert response.status_code == 200 + data = json.loads(response.data) + assert data["display_name"] == "new name" + + def test_update_remove_display_name(self, client, auth_header): + """Remove display_name via X-Remove-Display-Name header.""" + create = client.post( + "/", + data="content", + content_type="text/plain", + headers={**auth_header, "X-Display-Name": "to remove"}, + ) + paste_id = json.loads(create.data)["id"] + + response = client.put( + f"/{paste_id}", + data="", + headers={**auth_header, "X-Remove-Display-Name": "true"}, + ) + assert response.status_code == 200 + data = json.loads(response.data) + assert "display_name" not in data + + def test_update_display_name_too_long(self, client, auth_header): + """Reject display_name exceeding 128 chars in update.""" + create = client.post("/", data="content", content_type="text/plain", headers=auth_header) + paste_id = json.loads(create.data)["id"] + + response = client.put( + f"/{paste_id}", + data="", + headers={**auth_header, "X-Display-Name": "x" * 129}, + ) + assert response.status_code == 400 + + def test_update_display_name_control_chars(self, client, auth_header): + """Reject display_name with control characters in update.""" + create = client.post("/", data="content", content_type="text/plain", headers=auth_header) + paste_id = json.loads(create.data)["id"] + + response = client.put( + f"/{paste_id}", + data="", + headers={**auth_header, "X-Display-Name": "bad\x01name"}, + ) + assert response.status_code == 400 + + class TestPasteUpdatePrivacy: """Privacy-focused tests for paste update."""