Compare commits

...

9 Commits

Author SHA1 Message Date
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
16 changed files with 447 additions and 0 deletions

6
.gitignore vendored
View File

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

16
config_example.json Normal file
View File

@@ -0,0 +1,16 @@
{
"locations": {
"data": "data"
},
"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."
}
}

1
data/api_keys.json Normal file
View File

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

1
data/expired_keys.json Normal file
View File

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

30
extensions/apikey.py Normal file
View File

@@ -0,0 +1,30 @@
from os import path
from uuid import uuid4
from shutil import move
from models.apikey import APIKeyUpdated
from modules.app import app, get_api_key
from modules.utils import configGet, jsonLoad, jsonSave
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)):
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())
keys_valid.remove(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})

21
extensions/devices.py Normal file
View File

@@ -0,0 +1,21 @@
# from modules.app import app, get_api_key
# from modules.utils import configGet, jsonLoad
# from fastapi import HTTPException, Depends
# from fastapi.responses import UJSONResponse, FileResponse
# from fastapi.openapi.models import APIKey
# @app.get("/devices", response_class=UJSONResponse, description="Get all devices")
# 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")
# async def devices_get_by_name(name: str, apikey: APIKey = Depends(get_api_key)):
# pass
# @app.post("/devices", response_class=UJSONResponse, description="Create new device")
# 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")
# async def devices_put(name: str, apikey: APIKey = Depends(get_api_key)):
# pass

141
extensions/saves.py Normal file
View File

@@ -0,0 +1,141 @@
from datetime import datetime
from io import BytesIO
from os import listdir, makedirs, path, sep
from typing import Dict, List
from zipfile import ZipFile
from xmltodict import parse
from models.saves import StardewSave
from modules.app import app, get_api_key
from modules.utils import configGet, jsonLoad, jsonSave
from fastapi import HTTPException, Depends, UploadFile
from fastapi.responses import UJSONResponse, FileResponse, Response
from fastapi.openapi.models import APIKey
from starlette.status import HTTP_404_NOT_FOUND, HTTP_406_NOT_ACCEPTABLE
def zipfiles(filenames, save_name: str) -> Response:
zip_filename = save_name+".svsave"
s = BytesIO()
zf = ZipFile(s, "w")
for fpath in filenames:
# Calculate path for file in zip
fdir, fname = path.split(fpath)
# Add file, at correct path
zf.write(fpath, fname)
# Must close zip for all contents to be written
zf.close()
# Grab ZIP file from in-memory, make response with correct MIME-type
return Response(
s.getvalue(),
media_type="application/x-zip-compressed",
headers={
'Content-Disposition': f'attachment;filename={zip_filename}'
}
)
@app.get("/saves", response_class=UJSONResponse, response_model=Dict[str, StardewSave], description="Get all available game saves")
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")
async def saves_get_by_id(id: str, apikey: APIKey = Depends(get_api_key)):
save_path = path.join(configGet("data", "locations"), "users", apikey, id) # type: ignore
if path.exists(save_path):
output = []
for dir in listdir(save_path):
d = path.join(save_path, dir)
if path.isdir(d):
output.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 save with such id.")
@app.get("/saves/{id}/download", response_class=FileResponse, description="Get game save as .svsave file by its id and save date")
async def saves_download(id: str, save_date: str, 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
return zipfiles([f"{save_path}{sep}{id}.txt", f"{save_path}{sep}SaveGameInfo.txt", f"{save_path}{sep}index.json"], save_name=f"{id}_{save_date}")
else:
return 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)):
error_return = HTTPException(HTTP_406_NOT_ACCEPTABLE, detail="You must provide two files: save file and SaveGameInfo.txt for that save")
if len(files) != 2:
return error_return
if "SaveGameInfo.txt" not in [file.filename for file in files]:
return error_return
save_info = save_data = save_info_file = save_data_file = None
for file in files:
if file.filename == "SaveGameInfo.txt":
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_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_file is None:
return error_return
now = datetime.now()
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
with open(path.join(configGet("data", "locations"), "users", apikey, save_data["SaveGame"]["uniqueIDForThisGame"], save_date, save_data["SaveGame"]["uniqueIDForThisGame"]+".txt"), "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, "SaveGameInfo.txt"), "wb") as f: # type: ignore
f.write(save_data_file)
index = {
"id": int(save_data["SaveGame"]["uniqueIDForThisGame"]),
"device": device,
"farmer": save_info["Farmer"]["name"],
"year": int(save_info["Farmer"]["yearForSaveGame"]),
"season": int(save_info["Farmer"]["seasonForSaveGame"]),
"day": int(save_info["Farmer"]["dayOfMonthForSaveGame"]),
"money": int(save_info["Farmer"]["money"]),
"played": int(save_info["Farmer"]["millisecondsPlayed"]),
"save_time": int(save_info["Farmer"]["saveTime"]),
"save_date": save_date,
"uploaded": now.isoformat()
}
jsonSave(index, path.join(configGet("data", "locations"), "users", apikey, save_data["SaveGame"]["uniqueIDForThisGame"], save_date, "index.json")) # type: ignore
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

1
models/devices.py Normal file
View File

@@ -0,0 +1 @@
# from pydantic import BaseModel

13
models/saves.py Normal file
View File

@@ -0,0 +1,13 @@
from pydantic import BaseModel
class StardewSave(BaseModel):
id: int
device: str
farmer: str
year: int
season: int
day: int
money: int
played: int
save_time: int
uploaded: str

75
modules/app.py Normal file
View File

@@ -0,0 +1,75 @@
from os import path
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.docs import get_swagger_ui_html, get_redoc_html
from starlette.status import HTTP_401_UNAUTHORIZED
from modules.utils import configGet, jsonLoad
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)
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(
api_key_query: str = Security(api_key_query),
api_key_header: str = Security(api_key_header),
api_key_cookie: str = Security(api_key_cookie),
) -> str:
keys = get_all_api_keys()
expired = get_all_expired_keys()
def is_valid(key):
return True if key in keys else False
if is_valid(api_key_query):
return api_key_query
elif is_valid(api_key_header):
return api_key_header
elif is_valid(api_key_cookie):
return api_key_cookie
else:
if (api_key_query in expired) or (api_key_header in expired) or (api_key_cookie in expired):
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"))
@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"
)

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
#=================================================================================

61
modules/utils.py Normal file
View File

@@ -0,0 +1,61 @@
from typing import Any, Union
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]

5
requirements.txt Normal file
View File

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

25
sync_api.py Normal file
View File

@@ -0,0 +1,25 @@
from os import makedirs, path
from modules.app import app
from modules.utils import configGet
from modules.extensions_loader import dynamic_import_from_src
from fastapi.responses import FileResponse
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")]:
mode = 'r' if path.exists(entry) else 'w'
with open(entry, mode) as f:
try:
f.write("[]")
except:
pass
@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)
#=================================================================================