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": {
"data": "data"
},
"compression": 5,
"limits": {
"saves": -1,
"devices": -1
},
"messages": {
"key_expired": "API key expired",
"key_invalid": "Invalid API key",
@@ -12,5 +24,25 @@
"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}"
}
}
}
}

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 shutil import move
from models.apikey import APIKeyUpdated
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.responses import UJSONResponse
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")
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())
col_apikeys.find_one_and_replace({"hash": passEncode(apikey)}, {"hash": passEncode(new_key)})
col_expired.insert_one({"hash": passEncode(apikey)})
keys_valid.remove(apikey)
keys_valid.append(new_key)
keys_expired.append(apikey)
return UJSONResponse({"apikey": new_key})
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})
@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.utils import configGet, jsonLoad
# from fastapi import HTTPException, Depends
# from fastapi.responses import UJSONResponse, FileResponse
# from fastapi.openapi.models import APIKey
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
# @app.get("/devices", response_class=UJSONResponse, description="Get all devices")
# async def devices_get(apikey: APIKey = Depends(get_api_key)):
# pass
from modules.utils import configGet
# @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.get("/devices", response_class=UJSONResponse, description="Get all devices")
async def devices_get(apikey: APIKey = Depends(get_api_key)):
# @app.post("/devices", response_class=UJSONResponse, description="Create new device")
# async def devices_post(name: str, apikey: APIKey = Depends(get_api_key)):
# pass
devices = list(col_devices.find({"user": user_by_key(apikey)}))
# @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
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)

View File

@@ -1,100 +1,168 @@
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 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 models.saves import StardewSave
from modules.app import app, get_api_key
from modules.utils import configGet, jsonLoad, jsonSave
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_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()
zf = ZipFile(s, "w")
query = {"user": user}
for fpath in filenames:
if device is not None:
query["device"] = device
# Calculate path for file in zip
fdir, fname = path.split(fpath)
if version is not None:
query["data.game_version"] = version
# Add file, at correct path
zf.write(fpath, fname)
saves_entries = list(col_saves.find(query).sort("date", DESCENDING)) if sort == "upload" else list(col_saves.find(query).sort("data.save_time", DESCENDING))
# Must close zip for all contents to be written
zf.close()
if len(saves_entries) == 0:
raise HTTPException(HTTP_404_NOT_FOUND, detail="Could not find any saves.")
# 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}'
}
)
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)
@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.")
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: 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.")
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}/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}", f"{save_path}{sep}SaveGameInfo", f"{save_path}{sep}index.json"], save_name=f"{id}_{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_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:
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")
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
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:
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:
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_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
now = datetime.now()
save_date = now.strftime("%Y%m%d_%H%M%S")
zipped = zip_saves(save_data_filename, save_data_file, save_info_file)
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, "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)
save_date = int(datetime.now(tz=timezone.utc).timestamp())
index = {
"id": int(save_data["SaveGame"]["uniqueIDForThisGame"]),
"user": user,
"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()
"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)
jsonSave(index, path.join(configGet("data", "locations"), "users", apikey, save_data["SaveGame"]["uniqueIDForThisGame"], save_date, "index.json")) # type: ignore
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)

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
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
farmer: str
year: int
season: int
day: int
money: int
played: int
save_time: int
save_date: str
uploaded: str
date: int
data: StardewSaveData
class StardewSaveBrief(BaseModel):
id: int
farmer: str

View File

@@ -1,63 +1,43 @@
from os import path
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, jsonLoad
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)
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
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
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):
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(

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 traceback import print_exc
@@ -58,4 +61,26 @@ def configGet(key: str, *args: str) -> Any:
this_key = this_dict
for dict_key in args:
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]
ujson~=5.7.0
pymongo~=4.3.3
pydantic~=1.10.4
xmltodict~=0.13.0
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.utils import configGet
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)
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("/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"
}
}
}
}