Compare commits

...

24 Commits

Author SHA1 Message Date
c0d608b63f Added save sorting by upload and progress 2023-01-26 11:25:42 +01:00
dd7031ff19 Changed DT method to fix UTC time 2023-01-25 15:59:54 +01:00
1294d1a037 Changed only_ids behavior + sorting added 2023-01-23 16:39:11 +01:00
546600a29e Changed only_ids response (still not final) 2023-01-23 10:36:28 +01:00
Profitroll
30f11a7c83 Added client arg to PATCH /devices/{name} 2023-01-22 22:35:33 +01:00
Profitroll
14d1ba9fa7 Added only_ids to GET /saves 2023-01-22 11:31:00 +01:00
Profitroll
ce768d895d Added getting the last save 2023-01-21 18:02:36 +01:00
daa3b0ca73 Added device client option 2023-01-20 16:02:46 +01:00
673c986ff9 Typo fixed 2023-01-19 22:15:07 +02:00
453c3e95dd Argument for /check fixed 2023-01-19 22:10:04 +02:00
aff3f76fc1 Local account support added 2023-01-19 15:31:40 +01:00
1ce8c0d712 Mailer added 2023-01-19 15:31:27 +01:00
5f8dff42c3 Saves unlim by -1 added 2023-01-19 14:13:14 +01:00
02552dce5a Limits implemented 2023-01-19 14:11:49 +01:00
84be0c7154 Versions added 2023-01-19 13:58:18 +01:00
08d060e160 Refactored some stuff 2023-01-19 13:47:18 +01:00
aa8be6006c Files zipping update 2023-01-19 13:35:27 +01:00
3c245d8671 Removed trash and optimized imports 2023-01-18 14:31:22 +01:00
335a497991 Added device arg into search by both ID 2023-01-18 14:30:14 +01:00
1beca94cc0 Added search by device 2023-01-18 14:27:58 +01:00
e9e9e3784a MongoDB migration 2023-01-18 14:25:22 +01:00
94f8839e53 Saved deletion implemented 2023-01-17 10:54:31 +01:00
0476167823 API key check implemented 2023-01-17 10:54:23 +01:00
94095288b7 API status check implemented 2023-01-17 10:54:12 +01:00
20 changed files with 605 additions and 174 deletions

View File

@@ -1,7 +1,19 @@
{ {
"database": {
"name": "stardew_sync",
"host": "127.0.0.1",
"port": 27017,
"user": null,
"password": null
},
"locations": { "locations": {
"data": "data" "data": "data"
}, },
"compression": 5,
"limits": {
"saves": -1,
"devices": -1
},
"messages": { "messages": {
"key_expired": "API key expired", "key_expired": "API key expired",
"key_invalid": "Invalid API key", "key_invalid": "Invalid API key",
@@ -12,5 +24,25 @@
"user_already_exists": "User with this username already exists.", "user_already_exists": "User with this username already exists.",
"email_confirmed": "Email confirmed. You can now log in.", "email_confirmed": "Email confirmed. You can now log in.",
"email_code_invalid": "Confirmation code is invalid." "email_code_invalid": "Confirmation code is invalid."
},
"external_address": "localhost",
"registration_enabled": false,
"registration_requires_confirmation": false,
"mailer": {
"smtp": {
"host": "",
"port": 0,
"sender": "",
"login": "",
"password": "",
"use_ssl": true,
"use_tls": false
},
"messages": {
"registration_confirmation": {
"subject": "Email confirmation",
"message": "To confirm your email please follow this link: {0}"
}
}
} }
} }

View File

@@ -1 +0,0 @@
[]

View File

@@ -1 +0,0 @@
[]

View File

@@ -1,9 +1,8 @@
from os import path
from uuid import uuid4 from uuid import uuid4
from shutil import move
from models.apikey import APIKeyUpdated from models.apikey import APIKeyUpdated
from modules.app import app, get_api_key from modules.app import app, get_api_key
from modules.utils import configGet, jsonLoad, jsonSave from modules.security import passEncode
from modules.database import col_apikeys, col_expired
from fastapi import Depends from fastapi import Depends
from fastapi.responses import UJSONResponse from fastapi.responses import UJSONResponse
from fastapi.openapi.models import APIKey from fastapi.openapi.models import APIKey
@@ -11,20 +10,12 @@ from fastapi.openapi.models import APIKey
@app.put("/apikey", response_class=UJSONResponse, response_model=APIKeyUpdated, description="Update API key") @app.put("/apikey", response_class=UJSONResponse, response_model=APIKeyUpdated, description="Update API key")
async def apikey_put(apikey: APIKey = Depends(get_api_key)): async def apikey_put(apikey: APIKey = Depends(get_api_key)):
keys_valid = jsonLoad(path.join(configGet("data", "locations"), "api_keys.json"))
keys_expired = jsonLoad(path.join(configGet("data", "locations"), "expired_keys.json"))
new_key = str(uuid4()) new_key = str(uuid4())
col_apikeys.find_one_and_replace({"hash": passEncode(apikey)}, {"hash": passEncode(new_key)})
keys_valid.remove(apikey) col_expired.insert_one({"hash": passEncode(apikey)})
keys_valid.append(new_key)
keys_expired.append(apikey)
jsonSave(keys_valid, path.join(configGet("data", "locations"), "api_keys.json"))
jsonSave(keys_expired, path.join(configGet("data", "locations"), "expired_keys.json"))
if path.exists(path.join(configGet("data", "locations"), apikey)): # type: ignore
move(path.join(configGet("data", "locations"), apikey), path.join(configGet("data", "locations"), new_key)) # type: ignore
return UJSONResponse({"apikey": new_key}) return UJSONResponse({"apikey": new_key})
@app.get("/apikey", response_class=UJSONResponse, description="Check API key")
async def apikey_check(apikey: APIKey = Depends(get_api_key)):
return UJSONResponse({"detail": "This key is valid."})

View File

@@ -1,21 +1,84 @@
# from modules.app import app, get_api_key from modules.app import app, get_api_key, user_by_key
# from modules.utils import configGet, jsonLoad from modules.database import col_devices, col_saves
# from fastapi import HTTPException, Depends from fastapi import HTTPException, Depends
# from fastapi.responses import UJSONResponse, FileResponse from fastapi.responses import UJSONResponse, Response
# from fastapi.openapi.models import APIKey from fastapi.openapi.models import APIKey
from starlette.status import HTTP_204_NO_CONTENT, HTTP_403_FORBIDDEN, HTTP_404_NOT_FOUND, HTTP_409_CONFLICT
# @app.get("/devices", response_class=UJSONResponse, description="Get all devices") from modules.utils import configGet
# async def devices_get(apikey: APIKey = Depends(get_api_key)):
# pass
# @app.get("/devices/{name}", response_class=UJSONResponse, description="Get game saves from device by name") @app.get("/devices", response_class=UJSONResponse, description="Get all devices")
# async def devices_get_by_name(name: str, apikey: APIKey = Depends(get_api_key)): async def devices_get(apikey: APIKey = Depends(get_api_key)):
# pass
# @app.post("/devices", response_class=UJSONResponse, description="Create new device") devices = list(col_devices.find({"user": user_by_key(apikey)}))
# async def devices_post(name: str, apikey: APIKey = Depends(get_api_key)):
# pass
# @app.put("/devices/{name}", response_class=UJSONResponse, description="Update name of the existing device") if len(devices) == 0:
# async def devices_put(name: str, apikey: APIKey = Depends(get_api_key)): raise HTTPException(HTTP_404_NOT_FOUND, detail="Could not find any devices.")
# pass
output = []
for device in devices:
out_device = device
del out_device["_id"]
del out_device["user"]
output.append(out_device)
return UJSONResponse(output)
@app.get("/devices/{name}", response_class=UJSONResponse, description="Get information about device by name")
async def devices_get_by_name(name: str, apikey: APIKey = Depends(get_api_key)):
device = col_devices.find_one({"user": user_by_key(apikey), "name": name})
if device is not None:
del device["_id"]
del device["user"]
return UJSONResponse(device)
else:
raise HTTPException(HTTP_404_NOT_FOUND, detail="Could not find device with that name.")
@app.delete("/devices/{name}", description="Get information about device by name")
async def devices_delete_by_name(name: str, apikey: APIKey = Depends(get_api_key)):
user = user_by_key(apikey)
device = col_devices.find_one({"user": user, "name": name})
if device is not None:
col_devices.find_one_and_delete({"user": user, "name": name})
col_saves.delete_many({"user": user, "device": name})
return Response(status_code=HTTP_204_NO_CONTENT)
else:
raise HTTPException(HTTP_404_NOT_FOUND, detail="Could not find device with that name.")
@app.post("/devices", response_class=UJSONResponse, description="Create new device")
async def devices_post(name: str, os: str, client: str, apikey: APIKey = Depends(get_api_key)):
user = user_by_key(apikey)
if (configGet("devices", "limits") != -1) and (col_devices.count_documents({"user": user}) >= configGet("devices", "limits")):
return UJSONResponse(
{
"detail": f'Too many devices. This instance allows to register only {configGet("devices", "limits")} devices per user',
"limit": configGet("devices", "limits")
},
status_code=HTTP_403_FORBIDDEN
)
if col_devices.find_one({"user": user, "name": name}) is not None:
raise HTTPException(HTTP_409_CONFLICT, detail="Device with this name already exists.")
col_devices.insert_one({"user": user, "name": name, "os": os, "client": client, "last_save": 0})
return Response(status_code=HTTP_204_NO_CONTENT)
@app.patch("/devices/{name}", description="Update name of the existing device")
async def devices_patch(name: str, new_name: str, os: str, client: str, apikey: APIKey = Depends(get_api_key)):
user = user_by_key(apikey)
if col_devices.find_one({"user": user, "name": name}) is None:
raise HTTPException(HTTP_404_NOT_FOUND, detail="Could not find device with that name.")
if col_devices.find_one({"user": user, "name": new_name}) is not None:
raise HTTPException(HTTP_409_CONFLICT, detail="Device with this name already exists.")
col_devices.find_one_and_update({"user": user, "name": name}, {"$set": {"name": new_name, "os": os, "client": client}})
col_saves.update_many({"user": user, "device": name}, {"$set": {"device": new_name}})
return Response(status_code=HTTP_204_NO_CONTENT)

View File

@@ -1,100 +1,168 @@
from datetime import datetime from datetime import datetime, timezone
from io import BytesIO from urllib.parse import quote_plus
from os import listdir, makedirs, path, sep from os import remove
from typing import Dict, List from typing import Dict, List, Literal, Union
from zipfile import ZipFile
from xmltodict import parse from xmltodict import parse
from models.saves import StardewSave from pymongo import DESCENDING
from modules.app import app, get_api_key from models.saves import StardewSave, StardewSaveBrief
from modules.utils import configGet, jsonLoad, jsonSave from modules.app import app, get_api_key, user_by_key
from modules.database import col_devices, col_saves
from fastapi import HTTPException, Depends, UploadFile from fastapi import HTTPException, Depends, UploadFile
from fastapi.responses import UJSONResponse, FileResponse, Response from fastapi.responses import UJSONResponse, FileResponse, Response
from fastapi.openapi.models import APIKey from fastapi.openapi.models import APIKey
from starlette.status import HTTP_404_NOT_FOUND, HTTP_406_NOT_ACCEPTABLE from starlette.status import HTTP_204_NO_CONTENT, HTTP_403_FORBIDDEN, HTTP_404_NOT_FOUND, HTTP_406_NOT_ACCEPTABLE
from modules.utils import configGet, zip_saves
def zipfiles(filenames, save_name: str) -> Response: @app.get("/saves", response_class=UJSONResponse, response_model=Union[List[Dict[str, StardewSave]], List[StardewSaveBrief]], description="Get all available game saves")
async def saves_get(device: Union[str, None] = None, version: Union[str, None] = None, only_ids: bool = False, sort: Union[Literal["upload", "progress"], None] = "upload", apikey: APIKey = Depends(get_api_key)):
zip_filename = save_name+".svsave" user = user_by_key(apikey)
s = BytesIO() query = {"user": user}
zf = ZipFile(s, "w")
for fpath in filenames: if device is not None:
query["device"] = device
# Calculate path for file in zip if version is not None:
fdir, fname = path.split(fpath) query["data.game_version"] = version
# Add file, at correct path saves_entries = list(col_saves.find(query).sort("date", DESCENDING)) if sort == "upload" else list(col_saves.find(query).sort("data.save_time", DESCENDING))
zf.write(fpath, fname)
# Must close zip for all contents to be written if len(saves_entries) == 0:
zf.close() raise HTTPException(HTTP_404_NOT_FOUND, detail="Could not find any saves.")
# Grab ZIP file from in-memory, make response with correct MIME-type output = []
return Response( added = []
s.getvalue(),
media_type="application/x-zip-compressed",
headers={
'Content-Disposition': f'attachment;filename={zip_filename}'
}
)
for entry in saves_entries:
out_entry = entry
del out_entry["_id"]
del out_entry["user"]
del out_entry["file"]
if only_ids is True:
if entry["id"] in added:
continue
else:
added.append(entry["id"])
output.append(out_entry)
@app.get("/saves", response_class=UJSONResponse, response_model=Dict[str, StardewSave], description="Get all available game saves") return UJSONResponse(output)
async def saves_get(apikey: APIKey = Depends(get_api_key)):
save_path = path.join(configGet("data", "locations"), "users", apikey) # type: ignore
if path.exists(save_path):
output = {}
for id in listdir(save_path):
print("Iterating through", save_path, "on", id)
for dir in listdir(path.join(save_path, id)):
print("Iterating through", path.join(save_path, id, dir), "on", dir)
d = path.join(save_path, id, dir)
if path.isdir(d):
if str(id) not in output:
output[str(id)] = []
output[str(id)].append(jsonLoad(path.join(configGet("data", "locations"), "users", apikey, id, dir, "index.json"))) # type: ignore
return UJSONResponse(output)
else:
return HTTPException(HTTP_404_NOT_FOUND, detail="Could not find any saves.")
@app.get("/saves/{id}", response_class=UJSONResponse, response_model=List[StardewSave], description="Get game saves by name") @app.get("/saves/{id}", response_class=UJSONResponse, response_model=List[StardewSave], description="Get game saves by name")
async def saves_get_by_id(id: str, apikey: APIKey = Depends(get_api_key)): async def saves_get_by_id(id: int, device: Union[str, None] = None, version: Union[str, None] = None, sort: Union[Literal["upload", "progress"], None] = "upload", apikey: APIKey = Depends(get_api_key)):
save_path = path.join(configGet("data", "locations"), "users", apikey, id) # type: ignore
if path.exists(save_path): query = {"id": id}
output = []
for dir in listdir(save_path): if device is not None:
d = path.join(save_path, dir) query["device"] = device
if path.isdir(d):
output.append(jsonLoad(path.join(configGet("data", "locations"), "users", apikey, id, dir, "index.json"))) # type: ignore if version is not None:
return UJSONResponse(output) query["data.game_version"] = version
else:
return HTTPException(HTTP_404_NOT_FOUND, detail="Could not find save with such id.") saves_entries = list(col_saves.find(query).sort("date", DESCENDING)) if sort == "upload" else list(col_saves.find(query).sort("data.save_time", DESCENDING))
if len(saves_entries) == 0:
raise HTTPException(HTTP_404_NOT_FOUND, detail="Could not find save with such id.")
output = []
for entry in saves_entries:
out_entry = entry
del out_entry["_id"]
del out_entry["file"]
del out_entry["user"]
output.append(out_entry)
return UJSONResponse(output)
@app.get("/saves/{id}/download", response_class=FileResponse, description="Get game save as .svsave file by its id and save date") @app.get("/saves/{id}/{save_date}", response_class=UJSONResponse, response_model=List[StardewSave], description="Get game saves by id and upload date")
async def saves_download(id: str, save_date: str, apikey: APIKey = Depends(get_api_key)): async def saves_get_by_both_ids(id: int, save_date: Union[int, Literal["latest"]], device: Union[str, None] = None, apikey: APIKey = Depends(get_api_key)):
if path.exists(path.join(configGet("data", "locations"), "users", apikey, id, save_date)): # type: ignore
save_path = path.join(configGet("data", "locations"), "users", apikey, id, save_date) # type: ignore query = {"user": user_by_key(apikey), "id": id, "date": save_date}
return zipfiles([f"{save_path}{sep}{id}", f"{save_path}{sep}SaveGameInfo", f"{save_path}{sep}index.json"], save_name=f"{id}_{save_date}")
if device is not None:
query["device"] = device
saves_entry = col_saves.find_one(query)
if saves_entry is not None:
del saves_entry["_id"]
del saves_entry["file"]
return UJSONResponse(saves_entry)
else: else:
return HTTPException(HTTP_404_NOT_FOUND, detail="Could not find save with such id.") raise HTTPException(HTTP_404_NOT_FOUND, detail="Could not find save with such id.")
@app.delete("/saves/{id}", description="Delete game saves by id")
async def saves_delete_by_id(id: int, apikey: APIKey = Depends(get_api_key)):
user = user_by_key(apikey)
if col_saves.count_documents({"user": user, "id": id}) > 0:
saves_entries = list(col_saves.find({"user": user, "id": id}))
for entry in saves_entries:
remove(entry["file"]["path"])
col_saves.delete_many({"user": user, "id": id})
return Response(status_code=HTTP_204_NO_CONTENT)
else:
raise HTTPException(HTTP_404_NOT_FOUND, detail="Could not find save with such id.")
@app.delete("/saves/{id}/{save_date}", response_class=UJSONResponse, description="Delete game saves by id and upload date")
async def saves_delete_by_both_ids(id: int, save_date: int, apikey: APIKey = Depends(get_api_key)):
saves_entry = col_saves.find_one_and_delete({"id": id, "date": save_date})
if saves_entry is not None:
remove(saves_entry["file"]["path"])
return Response(status_code=HTTP_204_NO_CONTENT)
else:
raise HTTPException(HTTP_404_NOT_FOUND, detail="Could not find save with such id.")
@app.get("/saves/{id}/{save_date}/download", response_class=FileResponse, description="Get game save as .svsave file by its id and upload date")
async def saves_download(id: int, save_date: int, device: Union[str, None] = None, apikey: APIKey = Depends(get_api_key)):
saves_entry = col_saves.find_one({"user": user_by_key(apikey), "id": id, "date": save_date}) if device is None else col_saves.find_one({"user": user_by_key(apikey), "id": id, "device": device, "date": save_date})
if saves_entry is not None: # type: ignore
with open(saves_entry["file"]["path"], "rb") as file:
response = Response(
file.read(),
media_type="application/x-zip-compressed",
headers={
'Content-Disposition': f'attachment;filename={quote_plus(saves_entry["file"]["name"])}.svsave'
}
)
return response
else:
raise HTTPException(HTTP_404_NOT_FOUND, detail="Could not find save with such id.")
@app.post("/saves", response_class=UJSONResponse, response_model=StardewSave, description="Upload new save") @app.post("/saves", response_class=UJSONResponse, response_model=StardewSave, description="Upload new save")
async def saves_post(device: str, files: List[UploadFile], apikey: APIKey = Depends(get_api_key)): async def saves_post(device: str, files: List[UploadFile], apikey: APIKey = Depends(get_api_key)):
user = user_by_key(apikey)
error_return = HTTPException(HTTP_406_NOT_ACCEPTABLE, detail="You must provide two files: save file and SaveGameInfo for that save") error_return = HTTPException(HTTP_406_NOT_ACCEPTABLE, detail="You must provide two files: save file and SaveGameInfo for that save")
if (configGet("saves", "limits") != -1) and (col_saves.count_documents({"user": user}) >= configGet("saves", "limits")):
return UJSONResponse(
{
"detail": f'Too many save files. This instance allows to store only {configGet("saves", "limits")} saves per user',
"limit": configGet("saves", "limits")
},
status_code=HTTP_403_FORBIDDEN
)
if len(files) != 2: if len(files) != 2:
return error_return return error_return
if "SaveGameInfo" not in [file.filename for file in files]: if "SaveGameInfo" not in [file.filename for file in files]:
return error_return return error_return
save_info = save_data = save_info_file = save_data_file = None if col_devices.find_one({"user": user, "name": device}) is None:
raise HTTPException(HTTP_404_NOT_FOUND, detail="Could not find device with that name.")
save_info = save_data = save_info_file = save_data_filename = save_data_file = None
for file in files: for file in files:
if file.filename == "SaveGameInfo": if file.filename == "SaveGameInfo":
@@ -103,39 +171,49 @@ async def saves_post(device: str, files: List[UploadFile], apikey: APIKey = Depe
if "Farmer" not in save_info: if "Farmer" not in save_info:
return error_return return error_return
else: else:
save_data_filename = file.filename
save_data_file = await file.read() save_data_file = await file.read()
save_data = parse(save_data_file.decode("utf-8")) save_data = parse(save_data_file.decode("utf-8"))
if "SaveGame" not in save_data: if "SaveGame" not in save_data:
return error_return return error_return
if save_info is None or save_data is None or save_info_file is None or save_data_file is None: if save_info is None or save_data is None or save_info_file is None or save_data_filename is None or save_data_file is None:
return error_return return error_return
now = datetime.now() zipped = zip_saves(save_data_filename, save_data_file, save_info_file)
save_date = now.strftime("%Y%m%d_%H%M%S")
makedirs(path.join(configGet("data", "locations"), "users", apikey, save_data["SaveGame"]["uniqueIDForThisGame"], save_date), exist_ok=True) # type: ignore save_date = int(datetime.now(tz=timezone.utc).timestamp())
with open(path.join(configGet("data", "locations"), "users", apikey, save_data["SaveGame"]["uniqueIDForThisGame"], save_date, "SaveGameInfo"), "wb") as f: # type: ignore
f.write(save_info_file)
with open(path.join(configGet("data", "locations"), "users", apikey, save_data["SaveGame"]["uniqueIDForThisGame"], save_date, save_data["SaveGame"]["uniqueIDForThisGame"]), "wb") as f: # type: ignore
f.write(save_data_file)
index = { index = {
"id": int(save_data["SaveGame"]["uniqueIDForThisGame"]), "id": int(save_data["SaveGame"]["uniqueIDForThisGame"]),
"user": user,
"device": device, "device": device,
"farmer": save_info["Farmer"]["name"], "date": save_date,
"year": int(save_info["Farmer"]["yearForSaveGame"]), "data": {
"season": int(save_info["Farmer"]["seasonForSaveGame"]), "farmer": save_info["Farmer"]["name"],
"day": int(save_info["Farmer"]["dayOfMonthForSaveGame"]), "money": int(save_info["Farmer"]["money"]),
"money": int(save_info["Farmer"]["money"]), "played": int(save_info["Farmer"]["millisecondsPlayed"]),
"played": int(save_info["Farmer"]["millisecondsPlayed"]), "save_time": int(save_info["Farmer"]["saveTime"]),
"save_time": int(save_info["Farmer"]["saveTime"]), "year": int(save_info["Farmer"]["yearForSaveGame"]),
"save_date": save_date, "season": int(save_info["Farmer"]["seasonForSaveGame"]),
"uploaded": now.isoformat() "day": int(save_info["Farmer"]["dayOfMonthForSaveGame"]),
"game_version": save_info["Farmer"]["gameVersion"]
},
"file": {
"name": save_data_filename,
"uuid": zipped[0],
"path": zipped[1]
}
} }
jsonSave(index, path.join(configGet("data", "locations"), "users", apikey, save_data["SaveGame"]["uniqueIDForThisGame"], save_date, "index.json")) # type: ignore col_saves.insert_one(index)
del save_info, save_data
del index["user"]
del index["file"]
del index["_id"]
col_devices.find_one_and_update({"user": user, "name": device}, {"$set": {"last_save": save_date}})
return UJSONResponse(index) return UJSONResponse(index)

View File

@@ -1 +1,8 @@
# from pydantic import BaseModel from pydantic import BaseModel
class Device(BaseModel):
user: str
name: str
os: str
client: str
last_save: int

View File

@@ -1,14 +1,21 @@
from pydantic import BaseModel from pydantic import BaseModel
class StardewSaveData(BaseModel):
farmer: str
money: int
played: int
save_time: int
year: int
season: int
day: int
game_version: str
class StardewSave(BaseModel): class StardewSave(BaseModel):
id: int id: int
device: str device: str
date: int
data: StardewSaveData
class StardewSaveBrief(BaseModel):
id: int
farmer: str farmer: str
year: int
season: int
day: int
money: int
played: int
save_time: int
save_date: str
uploaded: str

View File

@@ -1,63 +1,43 @@
from os import path from typing import Union
from fastapi import FastAPI, Security, HTTPException from fastapi import FastAPI, Security, HTTPException
from starlette.status import HTTP_401_UNAUTHORIZED, HTTP_403_FORBIDDEN from starlette.status import HTTP_401_UNAUTHORIZED, HTTP_403_FORBIDDEN
from fastapi.security import APIKeyQuery, APIKeyHeader, APIKeyCookie from fastapi.security import APIKeyQuery, APIKeyHeader, APIKeyCookie
from fastapi.openapi.models import APIKey
from fastapi.openapi.docs import get_swagger_ui_html, get_redoc_html from fastapi.openapi.docs import get_swagger_ui_html, get_redoc_html
from starlette.status import HTTP_401_UNAUTHORIZED from starlette.status import HTTP_401_UNAUTHORIZED
from modules.utils import configGet
from modules.utils import configGet, jsonLoad from modules.security import passEncode
from modules.database import col_apikeys, col_expired
app = FastAPI(title="Stardew Sync", docs_url=None, redoc_url=None, version="0.1") app = FastAPI(title="Stardew Sync", docs_url=None, redoc_url=None, version="0.1")
api_key_query = APIKeyQuery(name="apikey", auto_error=False) api_key_query = APIKeyQuery(name="apikey", auto_error=False)
api_key_header = APIKeyHeader(name="apikey", auto_error=False) api_key_header = APIKeyHeader(name="apikey", auto_error=False)
api_key_cookie = APIKeyCookie(name="apikey", auto_error=False)
def get_all_api_keys() -> list:
return jsonLoad(path.join(configGet("data", "locations"), "api_keys.json"))
def get_all_expired_keys() -> list:
return jsonLoad(path.join(configGet("data", "locations"), "expired_keys.json"))
# def check_project_key(project: str, apikey: APIKey) -> bool:
# keys = jsonLoad(path.join(configGet("data", "locations"), "api_keys.json"))
# if apikey in keys:
# if keys[apikey] != []:
# if project in keys[apikey]:
# return True
# else:
# return False
# else:
# return False
# else:
# return False
async def get_api_key( async def get_api_key(
api_key_query: str = Security(api_key_query), api_key_query: str = Security(api_key_query),
api_key_header: str = Security(api_key_header), api_key_header: str = Security(api_key_header),
api_key_cookie: str = Security(api_key_cookie),
) -> str: ) -> str:
keys = get_all_api_keys()
expired = get_all_expired_keys()
def is_valid(key): def is_valid(key):
return True if key in keys else False return True if col_apikeys.find_one({"hash": passEncode(key)}) is not None else False
if is_valid(api_key_query): if is_valid(api_key_query):
return api_key_query return api_key_query
elif is_valid(api_key_header): elif is_valid(api_key_header):
return api_key_header return api_key_header
elif is_valid(api_key_cookie):
return api_key_cookie
else: else:
if (api_key_query in expired) or (api_key_header in expired) or (api_key_cookie in expired): if (col_expired.find_one({"hash": passEncode(api_key_query)}) is not None) or (col_expired.find_one({"hash": passEncode(api_key_header)}) is not None):
raise HTTPException(status_code=HTTP_403_FORBIDDEN, detail=configGet("key_expired", "messages")) raise HTTPException(status_code=HTTP_403_FORBIDDEN, detail=configGet("key_expired", "messages"))
else: else:
raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail=configGet("key_invalid", "messages")) raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail=configGet("key_invalid", "messages"))
def user_by_key(apikey: Union[str, APIKey]) -> Union[str, None]:
db_key = col_apikeys.find_one({"hash": passEncode(apikey)})
return db_key["user"] if db_key is not None else None
@app.get("/docs", include_in_schema=False) @app.get("/docs", include_in_schema=False)
async def custom_swagger_ui_html(): async def custom_swagger_ui_html():
return get_swagger_ui_html( return get_swagger_ui_html(

34
modules/database.py Normal file
View File

@@ -0,0 +1,34 @@
from modules.utils import configGet
from pymongo import MongoClient
db_config = configGet("database")
if db_config["user"] is not None and db_config["password"] is not None:
con_string = 'mongodb://{0}:{1}@{2}:{3}/{4}'.format(
db_config["user"],
db_config["password"],
db_config["host"],
db_config["port"],
db_config["name"]
)
else:
con_string = 'mongodb://{0}:{1}/{2}'.format(
db_config["host"],
db_config["port"],
db_config["name"]
)
db_client = MongoClient(con_string)
db = db_client.get_database(name=db_config["name"])
collections = db.list_collection_names()
for collection in ["saves", "devices", "apikeys", "expired"]:
if not collection in collections:
db.create_collection(collection)
col_saves = db.get_collection("saves")
col_devices = db.get_collection("devices")
col_apikeys = db.get_collection("apikeys")
col_expired = db.get_collection("expired")

39
modules/mailer.py Normal file
View File

@@ -0,0 +1,39 @@
from smtplib import SMTP, SMTP_SSL
from traceback import print_exc
from ssl import create_default_context
from modules.utils import configGet, logWrite
try:
if configGet("use_ssl", "mailer", "smtp") is True:
mail_sender = SMTP_SSL(
configGet("host", "mailer", "smtp"),
configGet("port", "mailer", "smtp"),
)
logWrite(f"Initialized SMTP SSL connection")
elif configGet("use_tls", "mailer", "smtp") is True:
mail_sender = SMTP(
configGet("host", "mailer", "smtp"),
configGet("port", "mailer", "smtp"),
)
mail_sender.starttls(context=create_default_context())
mail_sender.ehlo()
logWrite(f"Initialized SMTP TLS connection")
else:
mail_sender = SMTP(
configGet("host", "mailer", "smtp"),
configGet("port", "mailer", "smtp")
)
mail_sender.ehlo()
logWrite(f"Initialized SMTP connection")
except Exception as exp:
logWrite(f"Could not initialize SMTP connection to: {exp}")
print_exc()
try:
mail_sender.login(
configGet("login", "mailer", "smtp"),
configGet("password", "mailer", "smtp")
)
logWrite(f"Successfully initialized mailer")
except Exception as exp:
logWrite(f"Could not login into provided SMTP account due to: {exp}")

17
modules/security.py Normal file
View File

@@ -0,0 +1,17 @@
from hashlib import pbkdf2_hmac
from os import chmod, path, urandom
from typing import Union
from modules.utils import configGet
from fastapi.openapi.models import APIKey
def saltRead():
if not path.exists(path.join(configGet("data", "locations"), "salt")):
with open(path.join(configGet("data", "locations"), "salt"), "wb") as file:
file.write(urandom(32))
chmod(path.join(configGet("data", "locations"), "salt"), mode=0o600)
with open(path.join(configGet("data", "locations"), "salt"), "rb") as file:
contents = file.read()
return contents
def passEncode(password: Union[str, APIKey, None]) -> Union[bytes, None]:
return None if password is None else pbkdf2_hmac("sha256", str(password).encode("utf-8"), saltRead(), 96800, dklen=128)

View File

@@ -1,4 +1,7 @@
from typing import Any, Union from os import makedirs, path
from typing import Any, Tuple, Union
from uuid import uuid4
from zipfile import ZIP_DEFLATED, ZipFile
from ujson import loads, dumps, JSONDecodeError from ujson import loads, dumps, JSONDecodeError
from traceback import print_exc from traceback import print_exc
@@ -59,3 +62,25 @@ def configGet(key: str, *args: str) -> Any:
for dict_key in args: for dict_key in args:
this_key = this_key[dict_key] this_key = this_key[dict_key]
return this_key[key] return this_key[key]
def zip_saves(save_filename: str, save_bytes: bytes, saveinfo_bytes: bytes) -> Tuple[str, str]:
"""Save files of the SV save into archive and return uuid and path
### Args:
* save_filename (`str`): Filename of the save file
vsave_bytes (`bytes`): Bytes of the save file
* saveinfo_bytes (`bytes`): Bytes of the save info file
### Returns:
* `Tuple[str, str]`: First element is an UUID and the second is a filepath
"""
save_uuid = str(uuid4())
makedirs(path.join(configGet("data", "locations"), "files", save_uuid))
with ZipFile(path.join(configGet("data", "locations"), "files", save_uuid, save_filename+".svsave"), 'w', ZIP_DEFLATED, compresslevel=configGet("compression")) as ziph:
ziph.writestr("SaveGameInfo", saveinfo_bytes)
ziph.writestr(save_filename, save_bytes)
return save_uuid, path.join(configGet("data", "locations"), "files", save_uuid, save_filename+".svsave")

View File

@@ -1,5 +1,6 @@
fastapi[all] fastapi[all]
ujson~=5.7.0 ujson~=5.7.0
pymongo~=4.3.3
pydantic~=1.10.4 pydantic~=1.10.4
xmltodict~=0.13.0 xmltodict~=0.13.0
apscheduler~=3.9.1.post1 apscheduler~=3.9.1.post1

View File

@@ -1,25 +1,20 @@
from os import makedirs, path from os import makedirs
from modules.app import app from modules.app import app
from modules.utils import configGet from modules.utils import configGet
from modules.extensions_loader import dynamic_import_from_src from modules.extensions_loader import dynamic_import_from_src
from fastapi.responses import FileResponse from fastapi.responses import FileResponse, Response
from starlette.status import HTTP_200_OK
makedirs(configGet("data", "locations"), exist_ok=True) makedirs(configGet("data", "locations"), exist_ok=True)
for entry in [path.join(configGet("data", "locations"), "api_keys.json"), path.join(configGet("data", "locations"), "expired_keys.json")]: @app.get("/check", response_class=Response, include_in_schema=False)
mode = 'r' if path.exists(entry) else 'w' async def check():
with open(entry, mode) as f: return Response(status_code=HTTP_200_OK)
try:
f.write("[]")
except:
pass
@app.get("/favicon.ico", response_class=FileResponse, include_in_schema=False) @app.get("/favicon.ico", response_class=FileResponse, include_in_schema=False)
async def favicon(): async def favicon():
return FileResponse("favicon.ico") return FileResponse("favicon.ico")
#================================================================================= #=================================================================================
dynamic_import_from_src("extensions", star_import = True) dynamic_import_from_src("extensions", star_import = True)
#================================================================================= #=================================================================================

36
sync_gen.py Normal file
View File

@@ -0,0 +1,36 @@
from argparse import ArgumentParser
from json import dumps
from uuid import uuid4
from modules.database import col_apikeys
from modules.security import passEncode
# Args =====================================================================================================================================
parser = ArgumentParser(
prog = "Stardew Valley Sync API",
description = "Small subprogram made for API keys generation."
)
parser.add_argument("-u", "--username", help="Enter username without input prompt", action="store")
parser.add_argument("-e", "--email", help="Enter email without input prompt", action="store")
parser.add_argument("-l", "--local", help="Do not save user's email to make it completely local and unrecoverable", action="store_true")
parser.add_argument("-j", "--json", help="Return output as a json. Username must be provided as an argument", action="store_true")
args = parser.parse_args()
#===========================================================================================================================================
username = input("Enter username: ") if args.username is None else args.username
if args.local is False:
email = input("Enter email: ") if args.email is None else args.email
if email.strip() == "":
email = None
else:
email = None
new_key = str(uuid4())
col_apikeys.insert_one({"user": username, "email": email, "hash": passEncode(new_key)})
if args.json is True and args.username is not None:
print(dumps({"apikey": new_key}))
else:
print(f"Generated API key for {username}: {new_key}", flush=True)

16
validation/apikeys.json Normal file
View File

@@ -0,0 +1,16 @@
{
"$jsonSchema": {
"required": [
"user",
"hash"
],
"properties": {
"user": {
"bsonType": "string"
},
"hash": {
"bsonType": "binData"
}
}
}
}

28
validation/devices.json Normal file
View File

@@ -0,0 +1,28 @@
{
"$jsonSchema": {
"required": [
"user",
"name",
"os",
"client",
"last_save"
],
"properties": {
"user": {
"bsonType": "string"
},
"name": {
"bsonType": "string"
},
"os": {
"bsonType": "string"
},
"client": {
"bsonType": "string"
},
"last_save": {
"bsonType": ["int", "double"]
}
}
}
}

12
validation/expired.json Normal file
View File

@@ -0,0 +1,12 @@
{
"$jsonSchema": {
"required": [
"hash"
],
"properties": {
"hash": {
"bsonType": "binData"
}
}
}
}

72
validation/saves.json Normal file
View File

@@ -0,0 +1,72 @@
{
"$jsonSchema": {
"required": [
"id",
"user",
"device",
"date",
"data",
"data.farmer",
"data.money",
"data.played",
"data.save_time",
"data.year",
"data.season",
"data.day",
"file",
"file.name",
"file.uuid",
"file.path"
],
"properties": {
"id": {
"bsonType": "int"
},
"user": {
"bsonType": "string"
},
"device": {
"bsonType": "string"
},
"date": {
"bsonType": ["int", "double"]
},
"data": {
"bsonType": "object"
},
"data.farmer": {
"bsonType": "string"
},
"data.money": {
"bsonType": "int"
},
"data.played": {
"bsonType": "int"
},
"data.save_time": {
"bsonType": "int"
},
"data.year": {
"bsonType": "int"
},
"data.season": {
"bsonType": "int"
},
"data.day": {
"bsonType": "int"
},
"file": {
"bsonType": "object"
},
"file.name": {
"bsonType": "string"
},
"file.uuid": {
"bsonType": "string"
},
"file.path": {
"bsonType": "string"
}
}
}
}