Compare commits
9 Commits
688c1f7ace
...
342da62d5f
Author | SHA1 | Date | |
---|---|---|---|
342da62d5f | |||
22f74b75b3 | |||
8185b606f9 | |||
25d53a6dd6 | |||
30fea07fe4 | |||
f8066df838 | |||
9561e02ff7 | |||
e320a1bdbe | |||
5713fa2448 |
6
.gitignore
vendored
6
.gitignore
vendored
@@ -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
16
config_example.json
Normal 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
1
data/api_keys.json
Normal file
@@ -0,0 +1 @@
|
||||
[]
|
1
data/expired_keys.json
Normal file
1
data/expired_keys.json
Normal file
@@ -0,0 +1 @@
|
||||
[]
|
30
extensions/apikey.py
Normal file
30
extensions/apikey.py
Normal 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
21
extensions/devices.py
Normal 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
141
extensions/saves.py
Normal 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
BIN
favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 29 KiB |
4
models/apikey.py
Normal file
4
models/apikey.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
class APIKeyUpdated(BaseModel):
|
||||
apikey: str
|
1
models/devices.py
Normal file
1
models/devices.py
Normal file
@@ -0,0 +1 @@
|
||||
# from pydantic import BaseModel
|
13
models/saves.py
Normal file
13
models/saves.py
Normal 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
75
modules/app.py
Normal 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"
|
||||
)
|
47
modules/extensions_loader.py
Normal file
47
modules/extensions_loader.py
Normal 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
61
modules/utils.py
Normal 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
5
requirements.txt
Normal 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
25
sync_api.py
Normal 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)
|
||||
#=================================================================================
|
Reference in New Issue
Block a user