-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
7 changed files
with
349 additions
and
8 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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" | ||
} | ||
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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}` } }); | ||
} | ||
} | ||
} |
Oops, something went wrong.