Compare commits

...

37 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
b13f128cca Fixed a few mistakes 2023-01-16 15:39:53 +01:00
71f994c8b2 Fixed order 2023-01-16 15:33:42 +01:00
c09873e92c Typo fixed 2023-01-16 15:32:52 +01:00
7c1db0ce35 config.json replaced config_example.json 2023-01-16 15:26:17 +01:00
342da62d5f Devices are still commented out 2023-01-16 15:23:38 +01:00
22f74b75b3 Added saves logic and models 2023-01-16 15:23:19 +01:00
8185b606f9 Updated requirements 2023-01-16 15:23:06 +01:00
25d53a6dd6 Updated ignore 2023-01-16 15:22:59 +01:00
30fea07fe4 Keys management improved 2023-01-16 15:22:51 +01:00
f8066df838 Updated ignore 2023-01-16 13:31:34 +01:00
9561e02ff7 Updated ignore 2023-01-16 13:29:26 +01:00
e320a1bdbe Initial 2023-01-16 13:23:18 +01:00
5713fa2448 Updated ignore 2023-01-16 13:22:48 +01:00
22 changed files with 878 additions and 0 deletions

5
.gitignore vendored
View File

@@ -152,3 +152,8 @@ cython_debug/
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
.vscode
data/users
cleanup.bat
data/api_keys.json
data/expired_keys.json

48
config.json Normal file
View File

@@ -0,0 +1,48 @@
{
"database": {
"name": "stardew_sync",
"host": "127.0.0.1",
"port": 27017,
"user": null,
"password": null
},
"locations": {
"data": "data"
},
"compression": 5,
"limits": {
"saves": -1,
"devices": -1
},
"messages": {
"key_expired": "API key expired",
"key_invalid": "Invalid API key",
"key_valid": "Valid API key",
"bad_request": "Bad request. Read the docs at photos.end-play.xyz/docs",
"ip_blacklisted": "Your IP is blacklisted. Make sure you are using correct API address.",
"credentials_invalid": "Incorrect user or password",
"user_already_exists": "User with this username already exists.",
"email_confirmed": "Email confirmed. You can now log in.",
"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}"
}
}
}
}

21
extensions/apikey.py Normal file
View File

@@ -0,0 +1,21 @@
from uuid import uuid4
from models.apikey import APIKeyUpdated
from modules.app import app, get_api_key
from modules.security import passEncode
from modules.database import col_apikeys, col_expired
from fastapi import Depends
from fastapi.responses import UJSONResponse
from fastapi.openapi.models import APIKey
@app.put("/apikey", response_class=UJSONResponse, response_model=APIKeyUpdated, description="Update API key")
async def apikey_put(apikey: APIKey = Depends(get_api_key)):
new_key = str(uuid4())
col_apikeys.find_one_and_replace({"hash": passEncode(apikey)}, {"hash": passEncode(new_key)})
col_expired.insert_one({"hash": passEncode(apikey)})
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."})

84
extensions/devices.py Normal file
View File

@@ -0,0 +1,84 @@
from modules.app import app, get_api_key, user_by_key
from modules.database import col_devices, col_saves
from fastapi import HTTPException, Depends
from fastapi.responses import UJSONResponse, Response
from fastapi.openapi.models import APIKey
from starlette.status import HTTP_204_NO_CONTENT, HTTP_403_FORBIDDEN, HTTP_404_NOT_FOUND, HTTP_409_CONFLICT
from modules.utils import configGet
@app.get("/devices", response_class=UJSONResponse, description="Get all devices")
async def devices_get(apikey: APIKey = Depends(get_api_key)):
devices = list(col_devices.find({"user": user_by_key(apikey)}))
if len(devices) == 0:
raise HTTPException(HTTP_404_NOT_FOUND, detail="Could not find any devices.")
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)

219
extensions/saves.py Normal file
View File

@@ -0,0 +1,219 @@
from datetime import datetime, timezone
from urllib.parse import quote_plus
from os import remove
from typing import Dict, List, Literal, Union
from xmltodict import parse
from pymongo import DESCENDING
from models.saves import StardewSave, StardewSaveBrief
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.responses import UJSONResponse, FileResponse, Response
from fastapi.openapi.models import APIKey
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
@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)):
user = user_by_key(apikey)
query = {"user": user}
if device is not None:
query["device"] = device
if version is not None:
query["data.game_version"] = version
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 any saves.")
output = []
added = []
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)
return UJSONResponse(output)
@app.get("/saves/{id}", response_class=UJSONResponse, response_model=List[StardewSave], description="Get game saves by name")
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)):
query = {"id": id}
if device is not None:
query["device"] = device
if version is not None:
query["data.game_version"] = version
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}/{save_date}", response_class=UJSONResponse, response_model=List[StardewSave], description="Get game saves by id and upload date")
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)):
query = {"user": user_by_key(apikey), "id": id, "date": 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:
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")
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")
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:
return error_return
if "SaveGameInfo" not in [file.filename for file in files]:
return error_return
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:
if file.filename == "SaveGameInfo":
save_info_file = await file.read()
save_info = parse(save_info_file.decode("utf-8"))
if "Farmer" not in save_info:
return error_return
else:
save_data_filename = file.filename
save_data_file = await file.read()
save_data = parse(save_data_file.decode("utf-8"))
if "SaveGame" not in save_data:
return error_return
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
zipped = zip_saves(save_data_filename, save_data_file, save_info_file)
save_date = int(datetime.now(tz=timezone.utc).timestamp())
index = {
"id": int(save_data["SaveGame"]["uniqueIDForThisGame"]),
"user": user,
"device": device,
"date": save_date,
"data": {
"farmer": save_info["Farmer"]["name"],
"money": int(save_info["Farmer"]["money"]),
"played": int(save_info["Farmer"]["millisecondsPlayed"]),
"save_time": int(save_info["Farmer"]["saveTime"]),
"year": int(save_info["Farmer"]["yearForSaveGame"]),
"season": int(save_info["Farmer"]["seasonForSaveGame"]),
"day": int(save_info["Farmer"]["dayOfMonthForSaveGame"]),
"game_version": save_info["Farmer"]["gameVersion"]
},
"file": {
"name": save_data_filename,
"uuid": zipped[0],
"path": zipped[1]
}
}
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)

BIN
favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

4
models/apikey.py Normal file
View File

@@ -0,0 +1,4 @@
from pydantic import BaseModel
class APIKeyUpdated(BaseModel):
apikey: str

8
models/devices.py Normal file
View File

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

21
models/saves.py Normal file
View File

@@ -0,0 +1,21 @@
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):
id: int
device: str
date: int
data: StardewSaveData
class StardewSaveBrief(BaseModel):
id: int
farmer: str

55
modules/app.py Normal file
View File

@@ -0,0 +1,55 @@
from typing import Union
from fastapi import FastAPI, Security, HTTPException
from starlette.status import HTTP_401_UNAUTHORIZED, HTTP_403_FORBIDDEN
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 starlette.status import HTTP_401_UNAUTHORIZED
from modules.utils import configGet
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")
api_key_query = APIKeyQuery(name="apikey", auto_error=False)
api_key_header = APIKeyHeader(name="apikey", auto_error=False)
async def get_api_key(
api_key_query: str = Security(api_key_query),
api_key_header: str = Security(api_key_header),
) -> str:
def is_valid(key):
return True if col_apikeys.find_one({"hash": passEncode(key)}) is not None else False
if is_valid(api_key_query):
return api_key_query
elif is_valid(api_key_header):
return api_key_header
else:
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"))
else:
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)
async def custom_swagger_ui_html():
return get_swagger_ui_html(
openapi_url=app.openapi_url, # type: ignore
title=app.title + " - Documentation",
swagger_favicon_url="/favicon.ico"
)
@app.get("/redoc", include_in_schema=False)
async def custom_redoc_html():
return get_redoc_html(
openapi_url=app.openapi_url, # type: ignore
title=app.title + " - Documentation",
redoc_favicon_url="/favicon.ico"
)

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")

View File

@@ -0,0 +1,47 @@
from importlib.util import module_from_spec, spec_from_file_location
from os import getcwd, path, walk
#=================================================================================
# Import functions
# Took from https://stackoverflow.com/a/57892961
def get_py_files(src):
cwd = getcwd()
py_files = []
for root, dirs, files in walk(src):
for file in files:
if file.endswith(".py"):
py_files.append(path.join(cwd, root, file))
return py_files
def dynamic_import(module_name, py_path):
try:
module_spec = spec_from_file_location(module_name, py_path)
module = module_from_spec(module_spec) # type: ignore
module_spec.loader.exec_module(module) # type: ignore
return module
except SyntaxError:
print(f"Could not load extension {module_name} due to invalid syntax. Check logs/errors.log for details.", flush=True)
return
except Exception as exp:
print(f"Could not load extension {module_name} due to {exp}", flush=True)
return
def dynamic_import_from_src(src, star_import = False):
my_py_files = get_py_files(src)
for py_file in my_py_files:
module_name = path.split(py_file)[-1][:-3]
print(f"Importing {module_name} extension...", flush=True)
imported_module = dynamic_import(module_name, py_file)
if imported_module != None:
if star_import:
for obj in dir(imported_module):
globals()[obj] = imported_module.__dict__[obj]
else:
globals()[module_name] = imported_module
print(f"Successfully loaded {module_name} extension", flush=True)
return
#=================================================================================

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)

86
modules/utils.py Normal file
View File

@@ -0,0 +1,86 @@
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 traceback import print_exc
# Print to stdout and then to log
def logWrite(message: str, debug: bool = False) -> None:
# save to log file and rotation is to be done
# logAppend(f'{message}', debug=debug)
print(f"{message}", flush=True)
def jsonLoad(filepath: str) -> Any:
"""Load json file
### Args:
* filepath (`str`): Path to input file
### Returns:
* `Any`: Some json deserializable
"""
with open(filepath, "r", encoding='utf8') as file:
try:
output = loads(file.read())
except JSONDecodeError:
logWrite(f"Could not load json file {filepath}: file seems to be incorrect!\n{print_exc()}")
raise
except FileNotFoundError:
logWrite(f"Could not load json file {filepath}: file does not seem to exist!\n{print_exc()}")
raise
file.close()
return output
def jsonSave(contents: Union[list, dict], filepath: str) -> None:
"""Save contents into json file
### Args:
* contents (`Union[list, dict]`): Some json serializable
* filepath (`str`): Path to output file
"""
try:
with open(filepath, "w", encoding='utf8') as file:
file.write(dumps(contents, ensure_ascii=False, indent=4))
file.close()
except Exception as exp:
logWrite(f"Could not save json file {filepath}: {exp}\n{print_exc()}")
return
def configGet(key: str, *args: str) -> Any:
"""Get value of the config key
### Args:
* key (`str`): The last key of the keys path.
* *args (`str`): Path to key like: dict[args][key].
### Returns:
* `Any`: Value of provided key
"""
this_dict = jsonLoad("config.json")
this_key = this_dict
for dict_key in args:
this_key = this_key[dict_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")

6
requirements.txt Normal file
View File

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

20
sync_api.py Normal file
View File

@@ -0,0 +1,20 @@
from os import makedirs
from modules.app import app
from modules.utils import configGet
from modules.extensions_loader import dynamic_import_from_src
from fastapi.responses import FileResponse, Response
from starlette.status import HTTP_200_OK
makedirs(configGet("data", "locations"), exist_ok=True)
@app.get("/check", response_class=Response, include_in_schema=False)
async def check():
return Response(status_code=HTTP_200_OK)
@app.get("/favicon.ico", response_class=FileResponse, include_in_schema=False)
async def favicon():
return FileResponse("favicon.ico")
#=================================================================================
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"
}
}
}
}