forked from username/flaskpaste
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.
This commit is contained in:
@@ -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
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user