diff --git a/README.md b/README.md
index 2892421..a945b98 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,7 @@
# Clippy
-
+
A web-based application that allows users to share text and files in real-time through secure, encrypted sessions.
@@ -39,7 +39,7 @@ Tired of sharing text / files across different computers? Try this clippy!
- **Real-time collaboration**: See blocks appear instantly when other users create them
- **File uploads**: Support for small file uploads. Drop one or several files anywhere on the session page and they upload straight away — no need to open the composer first
- **Image previews**: Uploaded images are decrypted in the browser and shown as thumbnails
-- **Curl upload**: Upload text or files from the terminal — host can enable/disable per session
+- **Curl upload**: Upload text or files from the terminal — host can enable/disable per session. It works only while everyone can edit, so turning it on with the pencil off offers to open editing first, and turning the pencil off turns curl off too, after a warning
```bash
curl -d 'hello' https://your-host/u/SESSION_ID
curl -F f=@file.txt https://your-host/u/SESSION_ID
diff --git a/README.zh-TW.md b/README.zh-TW.md
index 4fd35d4..1992188 100644
--- a/README.zh-TW.md
+++ b/README.zh-TW.md
@@ -4,7 +4,7 @@
# Clippy
-
+
一個讓使用者能透過安全、加密的的方式,即時分享文字與檔案的網頁應用程式。
@@ -39,7 +39,7 @@
- **即時協作**:當其他使用者建立區塊時,您能立即看到它們出現。
- **檔案上傳**:支援小檔案上傳。也可以直接把一個或多個檔案拖曳到連線頁面的任一處,不必先開啟新增區塊就會自動上傳。
- **圖片預覽**:上傳的圖片會在瀏覽器中解密並顯示縮圖。
-- **Curl 上傳**:透過終端機上傳文字或檔案,主持人可針對每個連線啟用/停用此功能。
+- **Curl 上傳**:透過終端機上傳文字或檔案,主持人可針對每個連線啟用/停用此功能。只有開放所有人編輯時才能使用:鉛筆未開時開啟它,會先詢問是否開放編輯;關閉鉛筆時會先提醒,curl 也會一併關閉。
```bash
curl -d 'hello' https://your-host/u/SESSION_ID
curl -F f=@file.txt https://your-host/u/SESSION_ID
diff --git a/backend/app.py b/backend/app.py
index 4f300b9..87b615a 100644
--- a/backend/app.py
+++ b/backend/app.py
@@ -173,7 +173,7 @@ async def lifespan(_app: FastAPI):
app = FastAPI(
title="Clippy API",
description="Secure collaborative clipboard with real-time file and text sharing",
- version="2.2.0",
+ version="2.2.1",
openapi_url="/api/v2/openapi.json" if ENABLE_DOCS else None,
docs_url="/api/v2/docs" if ENABLE_DOCS else None,
redoc_url=None,
@@ -655,6 +655,8 @@ def from_dict(cls, data: dict) -> "Session":
legacy = data.get("visibility")
instance.is_public = data.get("is_public", legacy in ("public_read", "public_edit"))
instance.allow_edit = data.get("allow_edit", legacy != "public_read")
+ # 2.2.0 let curl stay switched on in a room kept to its host.
+ instance.allow_curl_upload = instance.allow_curl_upload and instance.allow_edit
instance.last_activity = datetime.fromisoformat(data["last_activity"])
# Snapshots written before created_at existed fall back to the last
# activity rather than "now", which would reshuffle the lobby on restart.
@@ -1617,6 +1619,11 @@ async def toggle_curl(request: ToggleCurlRequest):
if not session.is_host_token(request.user_id):
return api_response(HTTPStatus.FORBIDDEN, "Only host can toggle curl upload")
+ # A curl upload carries nobody's token, so curl is on only while everyone
+ # may edit; toggle_edit switches it off with editing.
+ if request.allow_curl_upload and not session.allow_edit:
+ return api_response(HTTPStatus.CONFLICT, "Curl upload needs everyone to be able to edit")
+
session.allow_curl_upload = request.allow_curl_upload
session.update_activity()
@@ -1687,12 +1694,17 @@ async def toggle_edit(request: ToggleEditRequest):
return api_response(HTTPStatus.FORBIDDEN, "Only host can change who may edit")
session.allow_edit = request.allow_edit
+ curl_goes = not request.allow_edit and session.allow_curl_upload
+ if curl_goes:
+ session.allow_curl_upload = False # see toggle_curl
session.update_activity()
await session.broadcast({
"type": "edit_permission_changed",
"allow_edit": request.allow_edit,
})
+ if curl_goes:
+ await session.broadcast({"type": "curl_permission_changed", "allow_curl_upload": False})
return api_response(HTTPStatus.OK, "Edit permission updated", {"success": True})
diff --git a/backend/pyproject.toml b/backend/pyproject.toml
index 7deb9c8..7cc7f02 100644
--- a/backend/pyproject.toml
+++ b/backend/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "clippy-backend"
-version = "2.2.0"
+version = "2.2.1"
description = "Clippy - Collaborative clipboard backend"
requires-python = ">=3.12"
dependencies = [
diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py
index 9ccfbbd..fae687a 100644
--- a/backend/tests/test_api.py
+++ b/backend/tests/test_api.py
@@ -630,10 +630,10 @@ def test_deleting_block_revokes_its_raw_link(client):
def test_curl_upload_rejects_oversized_body(client):
host = _create_session(client)
cid, uid = host["connection_id"], host["user_id"]
+ _allow_edit(client, host)
assert client.post("/api/v2/session/toggle_curl", json={
"connection_id": cid, "user_id": uid, "allow_curl_upload": True,
}).status_code == 200
- _allow_edit(client, host)
import app as app_module
oversized = b"x" * (app_module.MAX_CURL_UPLOAD_BYTES + 1)
@@ -641,24 +641,54 @@ def test_curl_upload_rejects_oversized_body(client):
assert r.status_code == 413
-def test_curl_uploads_stay_out_of_read_only_rooms(client):
- """curl carries no member token, so it cannot be the host writing."""
+def test_curl_is_on_only_while_everyone_may_edit(client, monkeypatch):
+ """curl carries no member token, so it cannot be the host writing: it may
+ be switched on only while everyone may edit, and goes off with editing."""
+ import app as app_module
+
+ monkeypatch.setattr(app_module, "MAX_BLOCKS_PER_SESSION", 50)
host = _create_session(client)
cid = host["connection_id"]
- assert client.post("/api/v2/session/toggle_curl", json={
- "connection_id": cid, "user_id": host["user_id"], "allow_curl_upload": True,
- }).status_code == 200
+ session = app_module.sessions[cid]
+
+ def curl_switch(on):
+ return client.post("/api/v2/session/toggle_curl", json={
+ "connection_id": cid, "user_id": host["user_id"], "allow_curl_upload": on})
+
+ uploads = ({"content": b"hello"}, {"files": {"f": ("a.txt", b"hello")}})
+
+ # A new room keeps editing to its host, so curl will not go on.
+ r = curl_switch(True)
+ assert r.status_code == 409
+ assert r.json()["message"] == "Curl upload needs everyone to be able to edit"
+ assert session.allow_curl_upload is False
+ assert client.post(f"/u/{cid}", content=b"hello").status_code == 403
- # Kept to the host, which a new room is, listed or not.
+ # With editing open it does, listed or not.
+ _allow_edit(client, host)
+ assert curl_switch(True).status_code == 200
for listed in (False, True):
_publish(client, host, listed)
- for upload in ({"content": b"hello"}, {"files": {"f": ("a.txt", b"hello")}}):
- r = client.post(f"/u/{cid}", **upload)
- assert r.status_code == 403
- assert r.json()["error"] == "This connection is read-only"
+ for upload in uploads:
+ assert client.post(f"/u/{cid}", **upload).status_code == 200, listed
+ # Closing editing closes curl, and everyone hears both.
+ with client.websocket_connect(f"/ws/{cid}", subprotocols=[host["user_id"]]) as ws:
+ _allow_edit(client, host, False)
+ assert ws.receive_json() == {"type": "edit_permission_changed", "allow_edit": False}
+ assert ws.receive_json() == {"type": "curl_permission_changed", "allow_curl_upload": False}
+ assert session.allow_curl_upload is False
+ for upload in uploads:
+ r = client.post(f"/u/{cid}", **upload)
+ assert (r.status_code, r.json()["error"]) == (403, "Curl upload is not enabled")
+ # Opening editing again leaves curl off until it is asked for.
_allow_edit(client, host)
- assert client.post(f"/u/{cid}", content=b"hello").status_code == 200
+ assert session.allow_curl_upload is False
+
+ # A room saved with curl on and editing kept to the host loads with curl off.
+ snapshot = {"connection_id": "curlsnap", "users": {}, "last_activity": "2026-10-12T10:00:00",
+ "allow_curl_upload": True, "allow_edit": False}
+ assert app_module.Session.from_dict(snapshot).allow_curl_upload is False
def _writes(client, member, block_id, file_block_id):
@@ -736,6 +766,7 @@ def test_writes_that_lose_permission_mid_write_leave_nothing_behind(client, monk
}).json()["data"]["block_id"]
file_block_id = client.post("/api/v2/block/upload", data={"connection_id": cid, "user_id": host["user_id"]},
files={"file": ("f.bin", b"f")}).json()["data"]["block_id"]
+ _allow_edit(client, host) # curl goes on only with editing open
assert client.post("/api/v2/session/toggle_curl", json={
"connection_id": cid, "user_id": host["user_id"], "allow_curl_upload": True,
}).status_code == 200
diff --git a/backend/uv.lock b/backend/uv.lock
index 3ef5c99..96b8b9b 100644
--- a/backend/uv.lock
+++ b/backend/uv.lock
@@ -150,7 +150,7 @@ wheels = [
[[package]]
name = "clippy-backend"
-version = "2.2.0"
+version = "2.2.1"
source = { virtual = "." }
dependencies = [
{ name = "aiofiles" },
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 8df68fd..75b3fad 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "frontend",
- "version": "2.2.0",
+ "version": "2.2.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frontend",
- "version": "2.2.0",
+ "version": "2.2.1",
"dependencies": {
"highlight.js": "^11.11.1",
"motion": "^13.4.6",
diff --git a/frontend/package.json b/frontend/package.json
index 08789b9..f6f904f 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
- "version": "2.2.0",
+ "version": "2.2.1",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/frontend/src/components/ClipboardInterface.jsx b/frontend/src/components/ClipboardInterface.jsx
index 7b8d6fa..968d68d 100644
--- a/frontend/src/components/ClipboardInterface.jsx
+++ b/frontend/src/components/ClipboardInterface.jsx
@@ -813,8 +813,20 @@ export function ClipboardInterface() {
}
};
+ // Closing editing closes curl with it (see toggle_edit), so with curl on
+ // the host is told first; declining changes nothing.
const handleToggleEdit = async () => {
const next = !session?.allow_edit;
+ if (!next && session?.allow_curl_upload) {
+ const closeBoth = await confirm({
+ title: 'Keep editing to the host',
+ message: 'Curl upload will be turned off too.',
+ confirmText: 'Turn off',
+ cancelText: 'Cancel',
+ confirmStyle: 'primary',
+ });
+ if (!closeBoth) return;
+ }
try {
await toggleSessionEdit(sessionData.connection_id, sessionData.user_id, next);
toast.success(next ? 'Everyone can edit' : 'Only the host can edit');
diff --git a/frontend/src/components/Menu.jsx b/frontend/src/components/Menu.jsx
index a533966..013b11b 100644
--- a/frontend/src/components/Menu.jsx
+++ b/frontend/src/components/Menu.jsx
@@ -4,7 +4,7 @@ import {useSession} from '../context/SessionContext';
import {useConfirm} from '../context/ConfirmContext';
import {useToast} from '../context/ToastContext';
import {useTheme} from '../hooks/useTheme';
-import {destroySession, toggleCurl, toggleJoin, transferHost} from '../utils/api';
+import {destroySession, toggleCurl, toggleJoin, toggleSessionEdit, transferHost} from '../utils/api';
import {IconButton} from './IconButton';
import {Copy} from './animate-ui/icons/copy';
import {X} from './animate-ui/icons/x';
@@ -93,10 +93,25 @@ export function Menu({session, users, currentUser, onClose}) {
}
};
+ // curl goes on only while everyone may edit — a curl upload carries
+ // nobody's token — so turning it on in a room kept to its host asks to
+ // open editing first. Declining leaves curl off.
const handleToggleCurl = async () => {
+ const turningOn = !session?.allow_curl_upload;
try {
- await toggleCurl(sessionData.connection_id, sessionData.user_id, !session?.allow_curl_upload);
- toast.success(session?.allow_curl_upload ? 'Curl upload disabled' : 'Curl upload enabled');
+ if (turningOn && !session?.allow_edit) {
+ const openEditing = await confirm({
+ title: 'Let everyone edit',
+ message: 'Curl upload only works while everyone can edit.',
+ confirmText: 'Let everyone edit',
+ cancelText: 'Cancel',
+ confirmStyle: 'primary',
+ });
+ if (!openEditing) return;
+ await toggleSessionEdit(sessionData.connection_id, sessionData.user_id, true);
+ }
+ await toggleCurl(sessionData.connection_id, sessionData.user_id, turningOn);
+ toast.success(turningOn ? 'Curl upload enabled' : 'Curl upload disabled');
} catch (err) {
toast.error('Failed to toggle curl upload: ' + err.message);
}