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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

# Clippy

<img src="https://img.shields.io/badge/Version-v2.2.0-green">
<img src="https://img.shields.io/badge/Version-v2.2.1-green">

A web-based application that allows users to share text and files in real-time through secure, encrypted sessions.

Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions README.zh-TW.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

# Clippy

<img src="https://img.shields.io/badge/Version-v2.2.0-green">
<img src="https://img.shields.io/badge/Version-v2.2.1-green">

一個讓使用者能透過安全、加密的的方式,即時分享文字與檔案的網頁應用程式。

Expand Down Expand Up @@ -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
Expand Down
14 changes: 13 additions & 1 deletion backend/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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})

Expand Down
2 changes: 1 addition & 1 deletion backend/pyproject.toml
Original file line number Diff line number Diff line change
@@ -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 = [
Expand Down
55 changes: 43 additions & 12 deletions backend/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -630,35 +630,65 @@ 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)
r = client.post(f"/u/{cid}", content=oversized)
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):
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion backend/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "2.2.0",
"version": "2.2.1",
"type": "module",
"scripts": {
"dev": "vite",
Expand Down
12 changes: 12 additions & 0 deletions frontend/src/components/ClipboardInterface.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
21 changes: 18 additions & 3 deletions frontend/src/components/Menu.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
}
Expand Down
Loading