diff --git a/nightwatch/rics/__init__.py b/nightwatch/rics/__init__.py index caee7bc..97e579d 100644 --- a/nightwatch/rics/__init__.py +++ b/nightwatch/rics/__init__.py @@ -215,3 +215,8 @@ async def forward_image(public_url: str) -> Response | JSONResponse: except RequestException: return JSONResponse({"code": 400, "message": "Failed to contact the specified URI."}, status_code = 400) + +# Load additional routes +from nightwatch.rics.routing import ( # noqa: E402 + files # noqa: F401 +) diff --git a/nightwatch/rics/routing/files.py b/nightwatch/rics/routing/files.py new file mode 100644 index 0000000..0faa120 --- /dev/null +++ b/nightwatch/rics/routing/files.py @@ -0,0 +1,142 @@ +# Copyright (c) 2024 iiPython + +# Modules +import typing +import shutil +import mimetypes +from pathlib import Path + +from nanoid import generate +from fastapi import UploadFile +from fastapi.responses import JSONResponse, FileResponse +from pydantic import BaseModel, Field, field_validator + +from nightwatch.rics import app, config + +# Initialization +SIZE_LIMIT = 400 * (1024 ** 2) # 100MB +CHUNK_LIMIT = SIZE_LIMIT / 4 + +UPLOAD_LOCATION = Path(config["file_upload_location"] or (config.config_path.parent / "file_uploads")) +UPLOAD_LOCATION.mkdir(parents = True, exist_ok = True) + +app.state.uploads = {} + +# Models +class FileCreationModel(BaseModel): + size: typing.Annotated[int, Field(ge = 1, le = SIZE_LIMIT)] # 1B - SIZE_LIMIT + name: str + + # Ensure that our filename conforms to ext4 storage requirements + @field_validator("name") + def validate_filename(cls, value: str) -> str: + if "/" in value or "\0" in value: + raise ValueError("Filename contains / or a null character!") + + if len(value.encode("utf-8")) > 255: + raise ValueError("Filename must be <= 255 bytes in length!") + + return value + +# Handle routing +@app.post("/api/file") +async def route_file_create(file: FileCreationModel) -> JSONResponse: + file_id = generate() + + # Save this upload + app.state.uploads[file_id] = file.model_dump() + return JSONResponse({ + "code": 200, + "data": { + "file_id": file_id + } + }) + +@app.post("/api/file/{file_id:str}") +async def route_file_upload(upload: UploadFile, file_id: str) -> JSONResponse: + if file_id not in app.state.uploads: + return JSONResponse({"code": 403}, status_code = 403) + + target = app.state.uploads[file_id] + destination = UPLOAD_LOCATION / file_id / target["name"] + + if not destination.parent.is_dir(): + destination.parent.mkdir() + + existing_size = target.get("written_bytes", 0) + if existing_size > target["size"]: + shutil.rmtree(destination.parent) + del app.state.uploads[file_id] + return JSONResponse({"code": 400, "message": "File exceeds size limit."}, status_code = 400) + + # Check filesize of this chunk + upload.file.seek(0, 2) # Go to end of file + chunk_size = upload.file.tell() + + if chunk_size > CHUNK_LIMIT: + if destination.is_file(): + shutil.rmtree(destination.parent) + + del app.state.uploads[file_id] + return JSONResponse({"code": 400, "message": "Chunk exceeds size limit."}, status_code = 400) + + if existing_size + chunk_size > target["size"]: + if destination.is_file(): + shutil.rmtree(destination.parent) + + del app.state.uploads[file_id] + return JSONResponse({"code": 400, "message": "File exceeds size limit."}, status_code = 400) + + # Save to disk + app.state.uploads[file_id]["written_bytes"] = existing_size + chunk_size + with destination.open("ab") as handle: + upload.file.seek(0) + handle.write(await upload.read()) + + return JSONResponse({"code": 200, "data": {"current_size": target["written_bytes"]}}) + +@app.post("/api/file/{file_id:str}/finalize") +async def route_file_finalize(file_id: str) -> JSONResponse: + if file_id not in app.state.uploads: + return JSONResponse({"code": 403}, status_code = 403) + + target = app.state.uploads[file_id] + if target.get("written_bytes", 0) < 1: + if (UPLOAD_LOCATION / file_id).is_dir(): + shutil.rmtree(UPLOAD_LOCATION / file_id) + + del app.state.uploads[file_id] + return JSONResponse({"code": 400, "message": "No data has been written to file."}, status_code = 400) + + del app.state.uploads[file_id] + return JSONResponse({"code": 200, "data": {"path": f"{file_id}/{target['name']}"}}) + +@app.get("/api/file/{file_id:str}/info") +async def route_file_info(file_id: str) -> JSONResponse: + file_path = UPLOAD_LOCATION / file_id + if not (file_path.is_dir() and file_path.relative_to(UPLOAD_LOCATION)): + return JSONResponse({"code": 404}, status_code = 404) + + actual_file = next(file_path.iterdir()) + return JSONResponse({ + "code": 200, + "data": { + "size": actual_file.stat().st_size, + "name": actual_file.name + } + }) + +@app.get("/file/{file_id:str}/{file_name:str}", response_model = None) +async def route_file_download(file_id: str, file_name: str) -> FileResponse | JSONResponse: + file_path = UPLOAD_LOCATION / file_id / file_name + if not (file_path.is_file() and file_path.relative_to(UPLOAD_LOCATION)): + return JSONResponse({"code": 404}, status_code = 404) + + content_type = mimetypes.guess_type(file_path.name)[0] + return FileResponse( + file_path, + headers = { + "Content-Type": content_type or "application/octet-stream", + "Content-Disposition": "inline" if content_type else "attachment" + } + ) diff --git a/nightwatch/web/css/main.css b/nightwatch/web/css/main.css index 264ac2b..1b9cd99 100644 --- a/nightwatch/web/css/main.css +++ b/nightwatch/web/css/main.css @@ -99,11 +99,11 @@ input:hover { overflow-x: hidden; max-width: calc(100% - 300px); } -.message-content.has-image { +.message-content.padded { padding-top: 5px; padding-bottom: 5px; } -.message-content.has-image > span > a { +.message-content.padded > span > a { display: flex; align-items: center; } @@ -175,3 +175,44 @@ div.server-data { div.user-data, #server-name { text-align: center; } + +/* File uploads */ +div.pending-uploads { + display: flex; + flex-direction: column; + gap: 5px; +} +div.pending-uploads > div { + display: flex; + flex-direction: column; +} +div.pending-uploads > div > div { + display: flex; +} +div.pending-uploads span { + flex: 1; + overflow-x: hidden; + text-overflow: ellipsis; +} +div.pending-uploads button { + padding: 0px; + width: 22px; + height: 22px; +} +div.file { + padding: 10px; + width: 300px; + display: flex; + flex-direction: column; + gap: 10px; + border: 1px solid white; +} +div.file > div { + display: flex; + align-items: center; +} +div.file span:first-child { + flex: 1; + overflow-x: hidden; + text-overflow: ellipsis; +} diff --git a/nightwatch/web/js/flows/files.js b/nightwatch/web/js/flows/files.js new file mode 100644 index 0000000..c3707d5 --- /dev/null +++ b/nightwatch/web/js/flows/files.js @@ -0,0 +1,105 @@ +// Copyright (c) 2024 iiPython + +export default class FileHandler { + constructor() { + this.mimetypes = { + "js": "Javascript", + "jpg": "JPEG", + "py": "Python", + "rs": "Rust", + "zip": "Archive", + "gz": "Gzip", + "tar.gz": "Gzip Archive", + "tar.xz": "XZ Archive", + "7z": "7-zip Archive" + }; + this.pending_uploads = []; + } + mimetype(filename) { + const extension = filename.match(/\.([a-z0-9]+(?:\.[a-z0-9]+)*)$/i); + if (!extension) return "binary"; + return this.mimetypes[extension[0].slice(1).toLowerCase()] || extension[0].slice(1).toUpperCase(); + } + setup(address, connection) { + this.address = address, this.connection = connection; + + // Fetch elements + const pending_element = document.querySelector(".pending-uploads"); + + // Handle pasting + document.addEventListener("paste", (e) => { + const files = e.clipboardData?.items; + if (!files) return; + + // Select first file + const item = files[0]; + if (item.kind === "string") return; + // if (!item.type.startsWith("image/")) + // return; // Yes, I support non-image uploads but for now pasting is image only. + + const file = item.getAsFile(); + + // Add to pending list + const pending_div = document.createElement("div"); + const upload_data = { file, div: pending_div }; + + pending_div.innerHTML = `
Prepared to send`; + pending_div.querySelector("span").innerText = file.name; + pending_div.querySelector("button").addEventListener("click", () => { + this.pending_uploads.splice(this.pending_uploads.indexOf(upload_data), 1); + pending_div.remove(); + }); + this.pending_uploads.push(upload_data); + pending_element.appendChild(pending_div); + }); + } + async upload_pending() { + const CHUNK_SIZE = 5 * (1024 ** 2); // 5MB for the time being... + for (const { file, div } of this.pending_uploads) { + const response = await (await fetch( + `${this.address}/api/file`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: file.name, size: file.size }) + } + )).json(); + if (response.code !== 200) { console.error(response); continue; } + + // Start sending file + let total_sent = 0; + for (let start = 0; start < file.size; start += CHUNK_SIZE) { + await new Promise((resolve, reject) => { + const loader = new FileReader(); + loader.addEventListener("load", async (e) => { + const form = new FormData(); + form.append("upload", new Blob([e.target.result])); + + // Setup XHR request + const xhr = new XMLHttpRequest(); + xhr.upload.addEventListener("progress", (e) => { + div.querySelector("em").innerText = `${Math.min(Math.round(((total_sent + e.loaded) / file.size) * 100), 100)}%`; + if (e.loaded / e.total === 1) total_sent += e.total; + }); + xhr.addEventListener("load", resolve); + xhr.addEventListener("error", reject); + xhr.addEventListener("error", reject); + xhr.addEventListener("loadend", (e) => { if (e.target.status !== 200) reject(); }); + + // Send to RICS + xhr.open("POST", `${this.address}/api/file/${response.data.file_id}`, true); + xhr.send(form); + }); + loader.readAsArrayBuffer(file.slice(start, start + CHUNK_SIZE)); + }); + } + + // Handle results + this.pending_uploads.splice(this.pending_uploads.indexOf({ file, div }, 1)); + const result = await (await fetch(`${this.address}/api/file/${response.data.file_id}/finalize`, { method: "POST" })).json(); + setTimeout(() => div.remove(), 1000); + if (result.code !== 200) return console.error(result); + await this.connection.send({ type: "message", data: { message: `${this.address}/file/${result.data.path}` } }); + } + } +} diff --git a/nightwatch/web/js/nightwatch.js b/nightwatch/web/js/nightwatch.js index c93fab2..9aae281 100644 --- a/nightwatch/web/js/nightwatch.js +++ b/nightwatch/web/js/nightwatch.js @@ -1,5 +1,6 @@ // Copyright (c) 2024 iiPython +import FileHandler from "./flows/files.js"; import ConnectionManager from "./flows/connection.js"; import { main, grab_data } from "./flows/welcome.js"; @@ -24,6 +25,7 @@ const TIME_FORMATTER = new Intl.DateTimeFormat("en-US", { hour12: true }); const NOTIFICATION_SFX = new Audio("/audio/notification.mp3"); +const FILE_HANDLER = new FileHandler(); (async () => { const { username, hex, address } = await grab_data(); @@ -52,9 +54,8 @@ const NOTIFICATION_SFX = new Audio("/audio/notification.mp3");
-
-

Current member list:

-
+

+

Connected as ${username}.

@@ -62,9 +63,15 @@ const NOTIFICATION_SFX = new Audio("/audio/notification.mp3");
`; + // Handle file uploads + FILE_HANDLER.setup(`http${connection.protocol}://${address}`, connection); + // Handle sending const input = document.getElementById("actual-input"); function send_message() { + FILE_HANDLER.upload_pending(); + + // Process text if (!input.value.trim()) return; connection.send({ type: "message", data: { message: input.value } }); input.value = ""; @@ -77,7 +84,7 @@ const NOTIFICATION_SFX = new Audio("/audio/notification.mp3"); window.location.reload(); // Fight me. }); }, - on_message: (message) => { + on_message: async (message) => { const current_time = TIME_FORMATTER.format(new Date(message.time * 1000)); // Check for anything hidden @@ -105,12 +112,34 @@ const NOTIFICATION_SFX = new Audio("/audio/notification.mp3"); // Handle image adjusting const image = dom.querySelector("img"); if (image) { - classlist += " has-image"; + classlist += " padded"; image.src = `http${connection.protocol}://${address}/api/fwd/${btoa(image.src.slice(8))}`; attachment = dom.body.innerHTML; }; }; + // Check for files + const file_match = attachment.match(new RegExp(`^https?:\/\/${address}\/file\/([a-zA-Z0-9_-]{21})\/.*$`)); + if (file_match) { + function bytes_to_human(size) { + const i = size == 0 ? 0 : Math.floor(Math.log(size) / Math.log(1024)); + return +((size / Math.pow(1024, i)).toFixed(2)) * 1 + " " + ["B", "kB", "MB", "GB"][i]; + } + const response = await (await fetch(`http${connection.protocol}://${address}/api/file/${file_match[1]}/info`)).json(); + if (response.code === 200) { + const mimetype = FILE_HANDLER.mimetype(response.data.name); + if (["avif", "avifs", "png", "apng", "jpg", "jpeg", "jfif", "webp", "ico", "gif", "svg"].includes(mimetype.toLowerCase())) { + attachment = `${response.data.name}]`; + } else { + attachment = `
+
${response.data.name} ${mimetype}
+
${bytes_to_human(response.data.size)}
+
`; + } + classlist += " padded"; + } + } + // Construct message const element = document.createElement("div"); element.classList.add("message"); @@ -126,6 +155,10 @@ const NOTIFICATION_SFX = new Audio("/audio/notification.mp3"); chat.scrollTop = chat.scrollHeight; last_author = message.user.name, last_time = current_time; + // Handle downloading + const button = element.querySelector("[data-uri]"); + if (button) button.addEventListener("click", () => { window.open(button.getAttribute("data-uri"), "_blank"); }); + // Handle notification sound if (!document.hasFocus()) NOTIFICATION_SFX.play(); }, @@ -143,6 +176,9 @@ const NOTIFICATION_SFX = new Audio("/audio/notification.mp3"); element.innerHTML = `→ ${member.name}`; element.setAttribute("data-member", member.name); member_list.appendChild(element); + + // Update member count + member_list.querySelector("p").innerText = `Members ─ ${member_list.querySelectorAll("& > span").length}`; } } ); diff --git a/pyproject.toml b/pyproject.toml index b9e23c3..60f6a63 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,7 @@ dependencies = [ "fastapi>=0.115.5", "nanoid>=2.0.0", "orjson>=3.10.11", + "python-multipart>=0.0.17", "readchar>=4.2.1", "requests>=2.32.3", "urwid>=2.6.16", diff --git a/uv.lock b/uv.lock index dd00e76..b3c36ce 100644 --- a/uv.lock +++ b/uv.lock @@ -225,13 +225,14 @@ wheels = [ [[package]] name = "nightwatch-chat" -version = "0.9.3" +version = "0.10.7" source = { editable = "." } dependencies = [ { name = "emoji" }, { name = "fastapi" }, { name = "nanoid" }, { name = "orjson" }, + { name = "python-multipart" }, { name = "readchar" }, { name = "requests" }, { name = "urwid" }, @@ -251,6 +252,7 @@ requires-dist = [ { name = "nanoid", specifier = ">=2.0.0" }, { name = "orjson", specifier = ">=3.10.11" }, { name = "pydantic", marker = "extra == 'serve'", specifier = ">=2.9.2" }, + { name = "python-multipart", specifier = ">=0.0.17" }, { name = "readchar", specifier = ">=4.2.1" }, { name = "requests", specifier = ">=2.32.3" }, { name = "urwid", specifier = ">=2.6.16" }, @@ -393,6 +395,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6a/3e/b68c118422ec867fa7ab88444e1274aa40681c606d59ac27de5a5588f082/python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a", size = 19863 }, ] +[[package]] +name = "python-multipart" +version = "0.0.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/40/22/edea41c2d4a22e666c0c7db7acdcbf7bc8c1c1f7d3b3ca246ec982fec612/python_multipart-0.0.17.tar.gz", hash = "sha256:41330d831cae6e2f22902704ead2826ea038d0419530eadff3ea80175aec5538", size = 36452 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/fb/275137a799169392f1fa88fff2be92f16eee38e982720a8aaadefc4a36b2/python_multipart-0.0.17-py3-none-any.whl", hash = "sha256:15dc4f487e0a9476cc1201261188ee0940165cffc94429b6fc565c4d3045cb5d", size = 24453 }, +] + [[package]] name = "pyyaml" version = "6.0.2"