Skip to content

Commit

Permalink
file uploading support
Browse files Browse the repository at this point in the history
  • Loading branch information
iiPythonx committed Nov 26, 2024
1 parent 75a32a1 commit 7f37cab
Show file tree
Hide file tree
Showing 7 changed files with 349 additions and 8 deletions.
5 changes: 5 additions & 0 deletions nightwatch/rics/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
142 changes: 142 additions & 0 deletions nightwatch/rics/routing/files.py
Original file line number Diff line number Diff line change
@@ -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"
}
)
45 changes: 43 additions & 2 deletions nightwatch/web/css/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
}
105 changes: 105 additions & 0 deletions nightwatch/web/js/flows/files.js
Original file line number Diff line number Diff line change
@@ -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 = `<div><span></span><button>x</button></div><em>Prepared to send</em>`;
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}` } });
}
}
}
Loading

0 comments on commit 7f37cab

Please sign in to comment.