Retro Bulletin Board Systems on atproto. Web app and TUI. lazy mirror of alyraffauf/atbbs atbbs.xyz
forums python tui atproto bbs
3
fork

Configure Feed

Select the types of activity you want to include in your feed.

clean image metadata

+41 -6
+1
pyproject.toml
··· 11 11 "platformdirs>=4.0.0", 12 12 "rich-click>=1.9.7", 13 13 "textual>=8.2.2", 14 + "piexif>=1.1.3", 14 15 ] 15 16 16 17 [build-system]
+16 -3
tui/screens/compose/upload.py
··· 1 1 import mimetypes 2 2 from pathlib import Path 3 3 4 + import piexif 5 + 4 6 from core.records import upload_blob 5 7 6 8 9 + def strip_image_metadata(data: bytes, mime_type: str) -> bytes: 10 + """Remove EXIF metadata from JPEG images to protect user privacy.""" 11 + if mime_type not in ("image/jpeg", "image/jpg"): 12 + return data 13 + try: 14 + return piexif.remove(data) 15 + except Exception: 16 + return data 17 + 18 + 7 19 async def upload_file(screen, file_path: str, session: dict) -> list[dict] | None: 8 20 """Upload a file and return attachments list, or None on error.""" 9 21 path = Path(file_path).expanduser().resolve() ··· 14 26 screen.notify(f"Not a file: {path}", severity="error") 15 27 return None 16 28 17 - data = path.read_bytes() 18 - mime = mimetypes.guess_type(str(path))[0] or "application/octet-stream" 29 + file_bytes = path.read_bytes() 30 + mime_type = mimetypes.guess_type(str(path))[0] or "application/octet-stream" 31 + cleaned_bytes = strip_image_metadata(file_bytes, mime_type) 19 32 20 33 async def nonce_updater(did, field, value): 21 34 if hasattr(screen.app, "user_session") and screen.app.user_session: ··· 23 36 24 37 try: 25 38 blob_ref = await upload_blob( 26 - screen.app.http_client, session, data, mime, session_updater=nonce_updater 39 + screen.app.http_client, session, cleaned_bytes, mime_type, session_updater=nonce_updater 27 40 ) 28 41 return [{"file": blob_ref, "name": path.name}] 29 42 except Exception as error:
+11
uv.lock
··· 94 94 { name = "aiohttp" }, 95 95 { name = "authlib" }, 96 96 { name = "httpx" }, 97 + { name = "piexif" }, 97 98 { name = "platformdirs" }, 98 99 { name = "rich-click" }, 99 100 { name = "textual" }, ··· 104 105 { name = "aiohttp", specifier = ">=3.13.5" }, 105 106 { name = "authlib", specifier = ">=1.6.9" }, 106 107 { name = "httpx", specifier = ">=0.28.1" }, 108 + { name = "piexif", specifier = ">=1.1.3" }, 107 109 { name = "platformdirs", specifier = ">=4.0.0" }, 108 110 { name = "rich-click", specifier = ">=1.9.7" }, 109 111 { name = "textual", specifier = ">=8.2.2" }, ··· 429 431 { url = "https://files.pythonhosted.org/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542, upload-time = "2026-01-26T02:46:08.809Z" }, 430 432 { url = "https://files.pythonhosted.org/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719, upload-time = "2026-01-26T02:46:11.146Z" }, 431 433 { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, 434 + ] 435 + 436 + [[package]] 437 + name = "piexif" 438 + version = "1.1.3" 439 + source = { registry = "https://pypi.org/simple" } 440 + sdist = { url = "https://files.pythonhosted.org/packages/fa/84/a3f25cec7d0922bf60be8000c9739d28d24b6896717f44cc4cfb843b1487/piexif-1.1.3.zip", hash = "sha256:83cb35c606bf3a1ea1a8f0a25cb42cf17e24353fd82e87ae3884e74a302a5f1b", size = 1011134, upload-time = "2019-07-01T15:29:23.045Z" } 441 + wheels = [ 442 + { url = "https://files.pythonhosted.org/packages/2c/d8/6f63147dd73373d051c5eb049ecd841207f898f50a5a1d4378594178f6cf/piexif-1.1.3-py2.py3-none-any.whl", hash = "sha256:3bc435d171720150b81b15d27e05e54b8abbde7b4242cddd81ef160d283108b6", size = 20691, upload-time = "2019-07-01T15:43:20.907Z" }, 432 443 ] 433 444 434 445 [[package]]
+13 -3
web/src/lib/writes.ts
··· 115 115 116 116 // --- Blob upload --- 117 117 118 + async function stripImageMetadata(file: File): Promise<File> { 119 + if (!file.type.startsWith("image/")) return file; 120 + const bitmap = await createImageBitmap(file); 121 + const canvas = new OffscreenCanvas(bitmap.width, bitmap.height); 122 + canvas.getContext("2d")!.drawImage(bitmap, 0, 0); 123 + const blob = await canvas.convertToBlob({ type: file.type }); 124 + return new File([blob], file.name, { type: file.type }); 125 + } 126 + 118 127 async function uploadBlob(rpc: Client, file: File): Promise<BlobRef> { 119 - const buf = new Uint8Array(await file.arrayBuffer()); 128 + const cleanedFile = await stripImageMetadata(file); 129 + const fileBytes = new Uint8Array(await cleanedFile.arrayBuffer()); 120 130 // atcute's typed upload signature is awkward for raw binary; cast at boundary. 121 131 // eslint-disable-next-line @typescript-eslint/no-explicit-any 122 132 const resp = await rpc.post("com.atproto.repo.uploadBlob", { 123 - input: buf, 133 + input: fileBytes, 124 134 headers: { 125 - "content-type": file.type || "application/octet-stream", 135 + "content-type": cleanedFile.type || "application/octet-stream", 126 136 }, 127 137 } as any); 128 138 if (!resp.ok) {