diff --git a/.gitignore b/.gitignore index aad3bbc..bc22080 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ /__pycache__ /dataBase.db /.env -/tg \ No newline at end of file +/tg +/web_nextjs diff --git a/README.md b/README.md index a221a6b..18ea244 100644 --- a/README.md +++ b/README.md @@ -39,13 +39,13 @@ This section will be referred to as `1.1` in the documentation. **List of errors:** -| errorId | message | Reasons | -| ------- | ----------------------------------------------------- | --------------------------------------------- | -| -1 | No Authorization header provided | The request is missing the `Authorization` header | +| errorId | message | Reasons | +| ------- | ----------------------------------------------------- | --------------------------------------------------- | +| -1 | No Authorization header provided | The request is missing the `Authorization` header | | -2 | Authorization header must have `Bearer ` format | The `Authorization` header has an incorrect format | -| -3 | Access token expired | The token has expired | -| -4 | Invalid access token | The token cannot be decrypted | -| -5 | Token not found | The token is not found | +| -3 | Access token expired | The token has expired | +| -4 | Invalid access token | The token cannot be decrypted | +| -5 | Token not found | The token is not found | ### 1.2 Basic API @@ -56,8 +56,8 @@ If the file type cannot be determined, the API returns the file in download mode #### Possible Errors -| Error Code | Description | Possible Reasons | -| ---------- | ----------------------------- | ------------------------------------------ | +| Error Code | Description | Possible Reasons | +| ---------- | ----------------------------- | -----------------------------------------------| | 404 | File not found | The file referenced by the code does not exist | ### Upload a file to the server @@ -122,7 +122,7 @@ Both requests accept the same request body but have different errors. ```json { - "username": "Andcool", + "username": "My cool username", "password": "My cool password" } ``` @@ -168,7 +168,7 @@ Successful execution returns a `200` HTTP code along with the `accessToken` fiel #### Possible Errors | errorId | HTTP code | message | Reasons | -| ------- | ----------|-----------------------------| ------------------------------------------------ | +| ------- | ----------|-----------------------------| ------------------------------------------------- | | 5 | 400 | No access token provided | The `accessToken` field is missing in the request | Errors described in section `1.1` may also occur. diff --git a/imports.py b/imports.py new file mode 100644 index 0000000..a8e4f6a --- /dev/null +++ b/imports.py @@ -0,0 +1,23 @@ +from fastapi import FastAPI, UploadFile, Request, Header +from fastapi.responses import JSONResponse, FileResponse, Response +from typing import Annotated, Union +import uvicorn +from config import * +import aiohttp +import utils +from slowapi.errors import RateLimitExceeded +from slowapi import Limiter, _rate_limit_exceeded_handler +from slowapi.util import get_remote_address +from fastapi.middleware.cors import CORSMiddleware +import time +import aiofiles +from prisma import Prisma +import uuid +import os +from datetime import datetime +from dotenv import load_dotenv +import jwt +import bcrypt +import random + +rate_limit_exceeded_handler = _rate_limit_exceeded_handler \ No newline at end of file diff --git a/main.py b/main.py index 3420c3f..39aff04 100644 --- a/main.py +++ b/main.py @@ -2,26 +2,7 @@ created by AndcoolSystems, 2023-2024 """ -from fastapi import FastAPI, UploadFile, Request, Header -from fastapi.responses import JSONResponse, FileResponse, Response -from typing import Annotated, Union -import uvicorn -from config import * -import aiohttp -import utils -from slowapi.errors import RateLimitExceeded -from slowapi import Limiter, _rate_limit_exceeded_handler -from slowapi.util import get_remote_address -from fastapi.middleware.cors import CORSMiddleware -import time -import aiofiles -from prisma import Prisma -import uuid -import os -from datetime import datetime -from dotenv import load_dotenv -import jwt -import bcrypt +from imports import * def custom_key_func(request: Request): if get_remote_address(request) == os.getenv('SERVER_IP'): @@ -46,7 +27,7 @@ def dynamic_limit_provider_upload(key: str): db = Prisma() load_dotenv() app.state.limiter = limiter -app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) +app.add_exception_handler(RateLimitExceeded, rate_limit_exceeded_handler) app.add_middleware( # Disable CORS CORSMiddleware, @@ -62,7 +43,7 @@ async def startup_event(): print("Connected to Data Base") -@app.get("/api") # Get file handler +@app.get("/api") # root api endpoint @limiter.limit(dynamic_limit_provider) async def api(request: Request): return JSONResponse(content={"status": "success", "message": "File uploader RESTful API", @@ -82,7 +63,7 @@ async def check_token(Authorization): except jwt.exceptions.DecodeError: return None, {"message": "Invalid access token", "errorId": -4} - token_db = await db.token.find_first(where={"accessToken": token_header[1]}) # Find token in db + token_db = await db.token.find_first(where={"accessToken": token_header[1]}, include={"user": True}) # Find token in db if not token_db: # If not found return None, {"message": "Token not found", "errorId": -5} @@ -110,8 +91,8 @@ async def upload_file(file: UploadFile, request: Request, include_ext: bool = Fa if max_uses > 10000: return JSONResponse(content={"status": "error", "message": "Invalid max_uses parameter"}, status_code=400) - user_id = -1 saved_to_account = False + user_id = -1 token_db, auth_error = await check_token(Authorization) # Check token if token_db: # If token is okay @@ -212,18 +193,19 @@ async def getFiles(request: Request, Authorization: Annotated[Union[str, None], Header(convert_underscores=False)] = None): token_db, auth_error = await check_token(Authorization) # Check token validity if not token_db: # If token is not valid - return JSONResponse(content={"status": "error", "auth_error": auth_error}, status_code=401) + return JSONResponse(content={"status": "error", "message": "Auth error", "auth_error": auth_error}, status_code=401) files = await db.file.find_many(where={"user_id": token_db.user_id}) # Get all user files from db user = await db.user.find_first(where={"id": token_db.user_id}) # Get all user files from db files_response = [] for file in files: + user_filename = file.user_filename[:50] + ("..." if len(file.user_filename) > 50 else "") files_response.append({ "file_url": file.url, "file_url_full": "https://fu.andcool.ru/file/" + file.url, "key": file.key, "ext": file.ext, - "user_filename": file.user_filename, + "user_filename": user_filename, "creation_date": file.created_date, "craeted_at": file.craeted_at, "size": utils.calculate_size(file.size), @@ -310,7 +292,7 @@ async def login(request: Request): token_db, auth_error = await check_token(body["accessToken"]) # Check token validity if not token_db: # If token is not valid - return JSONResponse(content={"status": "error", "auth_error": auth_error}, status_code=401) + return JSONResponse(content={"status": "error", "message": "Auth error", "auth_error": auth_error}, status_code=401) access = jwt.encode({"user_id": int(token_db.user_id), "ExpiresAt": time.time() + accesLifeTime}, "accessTokenSecret", algorithm="HS256") # Generate new token @@ -329,7 +311,7 @@ async def login(request: Request, token_db, auth_error = await check_token(Authorization) # Check token validity if not token_db: # If token is not valid - return JSONResponse(content={"status": "error", "auth_error": auth_error}, status_code=401) + return JSONResponse(content={"status": "error", "message": "Auth error", "auth_error": auth_error}, status_code=401) await db.token.delete(where={"id": token_db.id}) # Delete token record from db @@ -337,5 +319,237 @@ async def login(request: Request, "message": "logged out"} +@app.post("/api/transfer") # logout handler +@limiter.limit(dynamic_limit_provider) +async def transfer(request: Request, + Authorization: Annotated[Union[str, None], Header(convert_underscores=False)] = None): + + token_db, auth_error = await check_token(Authorization) # Check token validity + if not token_db: # If token is not valid + return JSONResponse(content={"status": "error", "message": "Auth error", "auth_error": auth_error}, status_code=401) + + try: + body = await request.json() + except: + return JSONResponse(content={"status": "error", "message": "Couldn't parse request body"}, status_code=400) + + if "data" not in body: + return JSONResponse(content={"status": "error", "message": "No `data` field in request body"}, status_code=400) + + non_success = [] + for requested_file in body["data"]: + try: + file = await db.file.find_first(where={"url": requested_file["file_url"]}) + if not file or file.key != requested_file["key"]: + non_success.append(requested_file) + continue + + await db.file.update(where={"id": file.id}, data={"user_id": token_db.user_id}) + except: + non_success.append(requested_file) + + return {"status": "success", + "message": "transfered", + "unsuccess": non_success} + + +# --------------------------------------Groups------------------------------------------ + +@app.post("/api/create_group") # create_group handler +@limiter.limit(dynamic_limit_provider) +async def create_group(request: Request, + Authorization: Annotated[Union[str, None], Header(convert_underscores=False)] = None): + + body = await request.json() + if "group_name" not in body: # If token doesn't provided + return JSONResponse({"status": "error", "message": "No `group_name` provided"}, status_code=400) + + token_db, auth_error = await check_token(Authorization) # Check token validity + if not token_db: # If token is not valid + return JSONResponse(content={"status": "error", "message": "Auth error", "auth_error": auth_error}, status_code=401) + + group = await db.group.create(data={ + "name": body["group_name"], + "group_id": random.randint(10000000, 99999999), + "admin_id": token_db.user_id, + "invite_string": utils.generate_token(15), + 'members': { + 'connect': { + 'id': token_db.user_id + }, + } + }) + return {"status": "success", + "message": "created", + "name": group.name, + "invite_string": group.invite_string, + "group_id": group.group_id} + + +@app.delete("/api/delete_group/{group_id}") # delete_group handler +@limiter.limit(dynamic_limit_provider) +async def delete_group(group_id: int, request: Request, + Authorization: Annotated[Union[str, None], Header(convert_underscores=False)] = None): + + token_db, auth_error = await check_token(Authorization) # Check token validity + if not token_db: # If token is not valid + return JSONResponse(content={"status": "error", "message": "Auth error", "auth_error": auth_error}, status_code=401) + + group = await db.group.find_first(where={"group_id": group_id}) + if not group: + return JSONResponse({"status": "error", "message": "Group not found"}, status_code=404) + + if group.admin_id != token_db.user_id: + return JSONResponse({"status": "error", "message": "You dont have any permissions to delete this group"}, status_code=403) + + await db.group.delete(where={"id": group.id}) + + return {"status": "success", + "message": "deleted"} + + +@app.post("/api/join/{invite_link}") # join handler +@limiter.limit(dynamic_limit_provider) +async def delete_group(invite_link: str, request: Request, + Authorization: Annotated[Union[str, None], Header(convert_underscores=False)] = None): + + token_db, auth_error = await check_token(Authorization) # Check token validity + if not token_db: # If token is not valid + return JSONResponse(content={"status": "error", "message": "Auth error", "auth_error": auth_error}, status_code=401) + + group = await db.group.find_first(where={"invite_string": invite_link}, include={"members": True}) + if not group: + return JSONResponse({"status": "error", "message": "Invite link not found"}, status_code=404) + + if token_db.user in group.members: + return JSONResponse({"status": "error", "message": "You are already in the group"}, status_code=400) + + await db.group.update(data={'members': { + 'connect': { + 'id': token_db.user_id + }, + } + }, + + where={"id": group.id} + ) + + return {"status": "success", + "message": "Joined", + "name": group.name, + "invite_string": group.invite_string, + "group_id": group.group_id} + + +@app.post("/api/leave/{group_id}") # leave handler +@limiter.limit(dynamic_limit_provider) +async def delete_group(group_id: int, request: Request, + Authorization: Annotated[Union[str, None], Header(convert_underscores=False)] = None): + + token_db, auth_error = await check_token(Authorization) # Check token validity + if not token_db: # If token is not valid + return JSONResponse(content={"status": "error", "message": "Auth error", "auth_error": auth_error}, status_code=401) + + group = await db.group.find_first(where={"group_id": group_id}, include={"members": True}) + if not group: + return JSONResponse({"status": "error", "message": "Group not found"}, status_code=404) + + if token_db.user not in group.members: + return JSONResponse({"status": "error", "message": "You are not in the group"}, status_code=400) + + await db.group.update(data={'members': {'disconnect': {'id': token_db.user_id}}}, where={"id": group.id}) + + return {"status": "success", + "message": "leaved"} + + +@app.post("/api/group/{group_id}/upload") # leave handler +@limiter.limit(dynamic_limit_provider) +async def upload_group(group_id: int, file: UploadFile, request: Request, include_ext: bool = False, max_uses: int = 0, + Authorization: Annotated[Union[str, None], Header(convert_underscores=False)] = None): + + token_db, auth_error = await check_token(Authorization) # Check token validity + if not token_db: # If token is not valid + return JSONResponse(content={"status": "error", "message": "Auth error", "auth_error": auth_error}, status_code=401) + + group = await db.group.find_first(where={"group_id": group_id}, include={"members": True}) + if not group: + return JSONResponse({"status": "error", "message": "Group not found"}, status_code=404) + + if token_db.user not in group.members: + return JSONResponse({"status": "error", "message": "You are not in the group"}, status_code=400) + + if not file: # Check, if the file is uploaded + return JSONResponse(content={"status": "error", "message": "No file uploaded"}, status_code=400) + + if file.filename.find(".") == -1: # Check, if the file has a extension + return JSONResponse(content={"status": "error", "message": "Bad file extension"}, status_code=400) + + if file.size > 100 * 1024 * 1024: # 100MB limit + return JSONResponse(content={"status": "error", "message": "File size exceeds the limit (100MB)"}, status_code=413) + + if max_uses > 10000: + return JSONResponse(content={"status": "error", "message": "Invalid max_uses parameter"}, status_code=400) + + key = str(uuid.uuid4()) # Generate unique delete key + ext = "." + file.filename.split(".")[-1].lower() # Get file extension + fid = utils.generate_token(10) + (ext if include_ext else "") # Generate file url + fn = str(uuid.uuid4()) + ext # Generate file name + + async with aiofiles.open(f"uploads/{fn}", "wb") as f: # Save file locally + await f.write(file.file.read()) + + now = datetime.now() + created = await db.file.create({ # Creating a file record + "user_id": group.group_id * -1, + "created_date": f"{now.day}.{now.month}.{now.year} {now.hour}:{now.minute}:{now.second}", + "url": fid, + "filename": f"uploads/{fn}", + "craeted_at": time.time(), + "last_watched": time.time(), + "key": key, + "type": filetypes.get(ext[1:], default) if ext.lower()[1:] in filetypes else "download", + "ext": ext, + "size": file.size, + "user_filename": file.filename, + "max_uses": max_uses + }) + + user_filename = created.user_filename[:50] + ("..." if len(created.user_filename) > 50 else "") + return JSONResponse(content={"status": "success", + "message": "File uploaded successfully", + "file_url": created.url, + "file_url_full": "https://fu.andcool.ru/file/" + created.url, + "key": created.key, + "ext": created.ext, + "size": utils.calculate_size(file.size), + "user_filename": user_filename, + "craeted_at": created.craeted_at, + "synced": False, + "auth_error": auth_error}, status_code=200) + + +@app.get("/api/get_groups") # leave handler +@limiter.limit(dynamic_limit_provider) +async def get_groups(request: Request, Authorization: Annotated[Union[str, None], Header(convert_underscores=False)] = None): + + token_db, auth_error = await check_token(Authorization) # Check token validity + if not token_db: # If token is not valid + return JSONResponse(content={"status": "error", "message": "Auth error", "auth_error": auth_error}, status_code=401) + + user = await db.user.find_first(where={"id": token_db.user_id}, include={"groups": True}) + groups = [] + for group in user.groups: + groups.append({ + "name": group.name, + "group_id": group.group_id, + "invite_string": group.invite_string + }) + + return {"status": "success", + "message": "groups got successfully", + "groups": groups} + + if __name__ == "__main__": # Start program uvicorn.run("main:app", reload=True, port=8080) diff --git a/schema.prisma b/schema.prisma index c943748..61c52ad 100644 --- a/schema.prisma +++ b/schema.prisma @@ -32,11 +32,22 @@ model User { username String @default("") password String @default("") tokens Token[] + groups Group[] } model Token{ - id Int @id @default(autoincrement()) - accessToken String - user User @relation(fields: [user_id], references: [id]) - user_id Int + id Int @id @default(autoincrement()) + accessToken String @default("") + user User @relation(fields: [user_id], references: [id]) + user_id Int @default(0) } + +model Group{ + id Int @id @default(autoincrement()) + name String @default("") + group_id BigInt @default(0) + admin_id Int @default(0) + members User[] + invite_string String @default("") +} + diff --git a/web/script.js b/web/api.js similarity index 73% rename from web/script.js rename to web/api.js index f9126f3..680b9cf 100644 --- a/web/script.js +++ b/web/api.js @@ -1,7 +1,8 @@ let api_upload_url = "/api/upload"; let api_file_url = "/file/"; -let api_url = "https://fu.andcool.ru"; -//let api_url = "http://127.0.0.1:8080"; +//let api_url = "https://fu.andcool.ru"; +let api_url = "http://127.0.0.1:8080"; +let groups = []; async function delete_file(data, id){ let confirmed = confirm("Delete it? It will be impossible to restore the file!"); @@ -25,6 +26,7 @@ function append_to_files_arr(data, id){ // Insert a row at the start of table let newRow = table.insertRow(0); newRow.id = "row_" + id; + newRow.className = "tr"; // Insert a cell at the end of the row let newCell = newRow.insertCell(); @@ -50,6 +52,7 @@ function append_to_files_arr(data, id){ const button = document.createElement('button'); button.innerHTML = 'Delete'; + button.className = "button" button.onclick = function(){delete_file(data, id);} let online = document.createElement("img"); @@ -90,6 +93,7 @@ function append_to_files_arr(data, id){ newCell2.appendChild(button); } + async function get_new_tokens(accessToken){ try{ let response = await axios.post(api_url + "/api/refresh_token", {'accessToken': "Bearer " + accessToken}, {}) @@ -101,7 +105,9 @@ async function get_new_tokens(accessToken){ } } -async function fetch_files(accessToken, len){ + +async function transfer_func(){ + accessToken = localStorage.getItem("accessToken"); if (!accessToken) return []; if (!checkAccess(accessToken)){ let new_access = await get_new_tokens(accessToken); @@ -114,16 +120,95 @@ async function fetch_files(accessToken, len){ localStorage.setItem("accessToken", new_access); } try{ - let response = await axios.get(api_url + "/api/get_files", { + let response = await axios.post(api_url + "/api/transfer", {'data': JSON.parse(localStorage.getItem("file_history") || "[]")}, { headers: { 'Authorization': 'Bearer ' + accessToken } }) if (!response) return; - if (response.status == 401){ + if (response.status == 200){ + localStorage.setItem("file_history", JSON.stringify(response.data.unsuccess)); + location.reload(); + } + + + }catch (e){ + console.log(e); + if (e.response.status == 401){ localStorage.removeItem("accessToken"); return []; } + return []; + } +} + +async function fetch_groups(){ + let accessToken = localStorage.getItem("accessToken"); + if (!accessToken) return []; + if (!checkAccess(accessToken)){ + let new_access = await get_new_tokens(accessToken); + if (!new_access){ + localStorage.removeItem("accessToken"); + return []; + } + console.log(new_access); + accessToken = new_access; + localStorage.setItem("accessToken", new_access); + } + try{ + let response = await axios.get(api_url + "/api/get_groups", { + headers: { + 'Authorization': 'Bearer ' + accessToken + } + }) + if (!response) return; + + /*let logim_page_btn = document.getElementById('login_page_a'); + logim_page_btn.textContent = "Logout"; + logim_page_btn.href = "/"; + logim_page_btn.onclick = function() {if (confirm("Log out?")) {logout()}}; + document.title = "File uploader · " + response.data.username; + + document.getElementById('login_mess').textContent = "Logged as " + response.data.username;*/ + + let groups = document.getElementById('groups'); + + for (const group of response.data.groups){ + let groupel = document.createElement("option"); + groupel.innerHTML = group.name; + groupel.value = group.group_id; + groups.appendChild(groupel); + } + + }catch (e){ + console.log(e); + if (e.response.status == 401){ + localStorage.removeItem("accessToken"); + return []; + } + return []; + } +} + +async function fetch_files(accessToken, len){ + if (!accessToken) return []; + if (!checkAccess(accessToken)){ + let new_access = await get_new_tokens(accessToken); + if (!new_access){ + localStorage.removeItem("accessToken"); + return []; + } + console.log(new_access); + accessToken = new_access; + localStorage.setItem("accessToken", new_access); + } + try{ + let response = await axios.get(api_url + "/api/get_files", { + headers: { + 'Authorization': 'Bearer ' + accessToken + } + }) + if (!response) return; let logim_page_btn = document.getElementById('login_page_a'); logim_page_btn.textContent = "Logout"; @@ -132,14 +217,33 @@ async function fetch_files(accessToken, len){ document.title = "File uploader · " + response.data.username; document.getElementById('login_mess').textContent = "Logged as " + response.data.username; + + let table = document.getElementById('files_table'); + + // Insert a row at the start of table + let newRow = table.insertRow(0); + newRow.id = "transfer_row"; + // Insert a cell at the end of the row + let newCell = newRow.insertCell(); + // Append a text node to the cell + let transfer = document.createElement("button"); + transfer.id = "trensfer"; + transfer.innerHTML = "Transfer local files to an account" + transfer.onclick = function(){if (confirm("Transfer local files to an active account?")) transfer_func()} + if (len > 0) newCell.appendChild(transfer); + let it = 0; for (const file of response.data.data){ append_to_files_arr(file, len + it); it++; } - }catch{ - localStorage.removeItem("accessToken"); + }catch (e){ + console.log(e); + if (e.response.status == 401){ + localStorage.removeItem("accessToken"); + return []; + } return []; } } @@ -216,6 +320,7 @@ addEventListener("DOMContentLoaded", (event) => { it++; } } + fetch_groups(); fetch_files(localStorage.getItem("accessToken"), file_history.length); diff --git a/web/index.html b/web/index.html index f4ebb4b..05dc8b1 100644 --- a/web/index.html +++ b/web/index.html @@ -9,7 +9,7 @@ - +
@@ -72,6 +75,12 @@

File uploader

Choose file + +

Select file group:

+

diff --git a/web/style.css b/web/style.css index ebd0a24..f7d9804 100644 --- a/web/style.css +++ b/web/style.css @@ -67,7 +67,7 @@ p{margin-left: 2px;} #files_table tbody{width: 100%;} -#files_table tr{ +#files_table .tr{ display: flex; position: relative; outline: none; @@ -83,7 +83,7 @@ p{margin-left: 2px;} margin-bottom: 2%; } -#files_table button{ +#files_table .button{ cursor: pointer; background-color: #222222; color:#eeeeee; @@ -255,6 +255,26 @@ nav a:hover{ justify-content: center; margin-right: 1%; } + +#transfer_row{ + border: none; +} + +#trensfer{ + margin-bottom: 3%; + margin-left: 0; + cursor: pointer; + background-color: #222222; + color:#eeeeee; + font-family: 'Roboto Mono', monospace; + font-weight: 400; + font-size: 80%; + border: 2px rgb(105, 105, 105) solid; + border-radius: 10px; + padding: 5px; + font-weight: 400; + transition: background-color 0.2s; +} @media(max-width: 425px){ main{width: 90%;} h1{font-size: 130%;} @@ -264,12 +284,12 @@ nav a:hover{ padding: 7px; border-radius: 10px; } - #files_table tr{padding: 2%; flex-wrap: wrap;} + #files_table .tr{padding: 2%; flex-wrap: wrap;} #files_table p{font-size: 70%; margin: 0; word-break: break-all;} - #files_table button{font-size: 70%; padding: 7px; margin: 0} + #files_table .button{font-size: 70%; padding: 7px; margin: 0} #files_table #url{font-size: 70%;} #addit_sett{padding-left: 2%; margin-bottom: 3%;} #addit_sett_btn{margin-bottom: 3%;} .add_label{font-size: 80%;} - #files_table tr {flex-direction: column; align-items: flex-start;} + #files_table .tr {flex-direction: column; align-items: flex-start;} } \ No newline at end of file