~jae/dn0magik-mc

417d1a47c9fe52cfe453e51f734659806e74dffa — Jae Lo Presti (DN0) 1 year, 6 months ago 39ce21b
Merge "media-api" into "beep"

Squashed commit of the following:

commit d0e3e13babd856ddd2ea9ee0fd39b7e6cc6fa703
Author: Jae Lo Presti (DN0) <me@jae.fi>
Date:   Fri Aug 19 08:07:57 2022 +0300

    README: add new endpoints to the working

commit 0f3deb34d1d338128b3b0456b1fce296597b70b5
Author: Jae Lo Presti (DN0) <me@jae.fi>
Date:   Fri Aug 19 08:05:36 2022 +0300

    Media: add handling of skin from URL upload

    Additions:
     - /minecraft/profile/skins skin from URL upload

    Fixes:
     - /minecraft/profile/skins use lowercase for "skin" in temp folder

commit b9c82efb3bfb241dd1ee8cb7e86a1180b784ac5d
Author: Jae Lo Presti (DN0) <me@jae.fi>
Date:   Fri Aug 19 07:58:01 2022 +0300

    Requirements: add new dependency (requests)

commit c8d3fbedd308fafd31384ddbd979838d41063d93
Author: Jae Lo Presti (DN0) <me@jae.fi>
Date:   Fri Aug 19 07:55:28 2022 +0300

    Media: upload skin endpoint

    Fixes:
     - upload_file() get UUID from player username

    Additions:
     - is_file_allowed() returns if an uploaded file is allowed on the
       server
     - check_temp_folder() creates all the temp dirs
     - /minecraft/profile/skins skin upload endpoint

commit f184b9d61c9ca5b9870f847aa29a0d9e2540de8b
Author: Jae Lo Presti (DN0) <me@jae.fi>
Date:   Fri Aug 19 07:38:08 2022 +0300

    Media: add delete skin method & endpoint

    Additions:
     - delete_file() method to delete files from storage pool
     - remove_skin_from_player() method to delete reset player skin
     - /minecraft/profile/skins/active endpoint to reset player skin

commit d446632c81c9dfa0f39eb2fcd7e4359a8ebb46ec
Author: Jae Lo Presti (DN0) <me@jae.fi>
Date:   Fri Aug 19 07:28:01 2022 +0300

    MediaAPI: add cape profile endpoint

    Additions:
     - /minecraft/profile/capes/active endpoint

commit 6149513f5055ecde59e85a368bafa26750f5f9b5
Author: Jae Lo Presti (DN0) <me@jae.fi>
Date:   Fri Aug 19 01:43:20 2022 +0300

    Media: cache values for hashes

commit b680adb58b5a04d078731d8fcc02f00d4119b6d4
Author: Jae Lo Presti (DN0) <me@jae.fi>
Date:   Fri Aug 19 01:40:04 2022 +0300

    Media: add cape handling

    Additions:
     - get_player_cape() method to retreive cape hash
     - generate_user_profile() now uses the selected user cape

    Fixes:
     - upload_file() now uses the cape or skin path depending on type

commit 978a477c146bca12c5b1bc7a23aaa5434a7e6e2e
Author: Jae Lo Presti (DN0) <me@jae.fi>
Date:   Fri Aug 19 01:32:08 2022 +0300

    API: add mediaapi & related methods

    Additions:
     - Base media API file
     - get_player_skin() method that returns the file hash
     - generate_user_profile() now uses the real player skin

commit 8367b86be3194ffdcb7e815baf5a2f9dfae4722b
Author: Jae Lo Presti (DN0) <me@jae.fi>
Date:   Fri Aug 19 01:17:31 2022 +0300

    Mediautil: add method & remove temp file

    Additions:
     - download_file() method that downloads to tmp location

    Fixes:
     - Remove temp file on upload_file() call

commit 0bcbe7cc9497b5ac794807a259e5b5f70f939883
Author: Jae Lo Presti (DN0) <me@jae.fi>
Date:   Fri Aug 19 01:11:29 2022 +0300

    Utils: add basic mediautil file

    Additions:
     - get_hash() -> Generates sha-1 hash from file
     - upload_file() -> Uploads the file to the s3 bucket with {hash}.png

commit 49e82db8dc4b6eec7865d560eb8efec9afcdcfd3
Author: Jae Lo Presti (DN0) <me@jae.fi>
Date:   Fri Aug 19 00:59:48 2022 +0300

    Requirements: add storage API requirement (boto3)

commit 35411a63c581f58f0b4ad6f40e53058de8ea8041
Author: Jae Lo Presti (DN0) <me@jae.fi>
Date:   Fri Aug 19 00:54:34 2022 +0300

    DB: add media class to ORM file

commit efa69b8154741bcf3f141f5c1ca61a0d25c30450
Author: Jae Lo Presti (DN0) <me@jae.fi>
Date:   Fri Aug 19 00:53:00 2022 +0300

    Migrations: add new migations for the media table
M README.md => README.md +3 -0
@@ 18,6 18,9 @@ The goals of the projects are:
 - Token refresh (`/refresh`)
 - Status check (`/check`)
 - Sales stats (`/orders/statistics`)
 - Get active capes (`/minecraft/profile/capes/active`)
 - Get active skins (`/minecraft/profile/skins/active`)
 - Upload skin (`/minecraft/profile/skins`)

### TODO


A src/api/mediaapi.py => src/api/mediaapi.py +140 -0
@@ 0,0 1,140 @@
from time import time

from flask import Blueprint, jsonify
from requests import get

from utils.playerutil import generate_user_profile
from utils.mediautil import (
    remove_skin_from_player,
    is_file_allowed,
    check_temp_folder,
    upload_file,
)

media_api = Blueprint("media_api", __name__)

TEMP_UPLOAD_FOLDER = "/tmp/mc/"


@media_api.put("/minecraft/profile/capes/active")
def media_api_minecraft_profile_capes_active():
    data = request.header.get("Authorization")

    if not data:
        res = {
            "error": "ForbiddenOperationException",
            "errorMessage": "Incorrect login or password",
        }
        return jsonify(res), 400

    token = data.replace("Bearer ", "")
    jwt_check = check_auth_jwt(token, None)

    if not jwt_check:
        res = {
            "error": "ForbiddenOperationException",
            "errorMessage": "Incorrect login or password",
        }
        return jsonify(res), 400

    username = jwt_check["sub"]
    res = generate_user_profile(username)

    return jsonify(res), 200


@media_api.delete("/minecraft/profile/skins/active")
def media_api_minecraft_profile_skins_delete():
    data = request.header.get("Authorization")

    if not data:
        res = {
            "error": "ForbiddenOperationException",
            "errorMessage": "Incorrect login or password",
        }
        return jsonify(res), 400

    token = data.replace("Bearer ", "")
    jwt_check = check_auth_jwt(token, None)

    if not jwt_check:
        res = {
            "error": "ForbiddenOperationException",
            "errorMessage": "Incorrect login or password",
        }
        return jsonify(res), 400

    username = jwt_check["sub"]
    remove_skin_from_player(username)

    # ADD RES OTHERWISE FLASK WILL SCREAM
    res = {"status": "ok"}
    return jsonify(res), 200


@media_api.post("/minecraft/profile/skins")
def media_api_minecraft_profile_skins_upload():
    data = request.header.get("Authorization")

    if not data:
        res = {
            "error": "ForbiddenOperationException",
            "errorMessage": "Incorrect login or password",
        }
        return jsonify(res), 400

    token = data.replace("Bearer ", "")
    jwt_check = check_auth_jwt(token, None)

    if not jwt_check:
        res = {
            "error": "ForbiddenOperationException",
            "errorMessage": "Incorrect login or password",
        }
        return jsonify(res), 400

    username = jwt_check["sub"]

    if "file" not in request.files:
        if request.json and request.json.get("url"):
            new_skin_url = request.json.get("url")
            if is_file_allowed(new_skin_url):
                current_time = int(time())
                temp_filename = f"{TEMP_UPLOAD_FOLDER}/skins/{current_time}.png"
                with get(new_skin_url, stream=True) as r:
                    r.raise_for_status()
                    with open(temp_filename, "wb") as file:
                        for chunk in r.iter_content(chunk_size=8192):
                            file.write(chunk)
                upload_file(temp_filename, "SKIN", jwt_check)

                res = {"status": "ok"}
                return jsonify(res), 200

        res = {
            "error": "ForbiddenOperationException",
            "errorMessage": "No file",
        }
        return jsonify(res), 400

    file = request.files["file"]

    if not is_file_allowed:
        res = {
            "error": "ForbiddenOperationException",
            "errorMessage": "Invalid file",
        }
        return jsonify(res), 400

    filename = secure_filename(file.filename)
    current_time = int(time())
    check_temp_folder()
    final_temp_file = f"{TEMP_UPLOAD_FOLDER}/skins/{current_time}_{filename}"
    file.save(final_temp_file)

    # UPLOAD TO S3
    upload_file(final_temp_file, "SKIN", jwt_check)

    # RESPONSE OR FLASK WILL SCREAM
    res = {"status": "ok"}
    return jsonify(res), 200

M src/main.py => src/main.py +2 -0
@@ 2,11 2,13 @@ from flask import Flask, jsonify

from api.statusapi import status_api
from api.userapi import user_api
from api.mediaapi import media_api

app = Flask(__name__)

app.register_blueprint(status_api)
app.register_blueprint(user_api)
app.register_blueprint(media_api)


@app.route("/")

A src/migrations/0002-media.py => src/migrations/0002-media.py +8 -0
@@ 0,0 1,8 @@
from yoyo import step

steps = [
    step(
        "CREATE TABLE Media (id INTEGER PRIMARY KEY AUTOINCREMENT, uuid VARCHAR(255) NOT NULL, hash VARCHAR(255) NOT NULL, type VARCHAR(255) NOT NULL, CONSTRAINT fk_uui_media FOREIGN KEY (uuid) REFERENCES Users(uuid))",
        "DROP TABLE Media",
    )
]

M src/requirements.txt => src/requirements.txt +11 -0
@@ 1,21 1,32 @@
async-timeout==4.0.2
bcrypt==3.2.2
boto3==1.24.55
botocore==1.27.55
certifi==2022.6.15
cffi==1.15.1
charset-normalizer==2.1.0
click==8.1.3
Deprecated==1.2.13
Flask==2.2.2
greenlet==1.1.2
idna==3.3
itsdangerous==2.1.2
Jinja2==3.1.2
jmespath==1.0.1
MarkupSafe==2.1.1
packaging==21.3
peewee==3.15.1
pycparser==2.21
PyJWT==2.4.0
pyparsing==3.0.9
python-dateutil==2.8.2
redis==4.3.4
requests==2.28.1
s3transfer==0.6.0
six==1.16.0
sqlparse==0.4.2
tabulate==0.8.10
urllib3==1.26.11
Werkzeug==2.2.2
wrapt==1.14.1
yoyo-migrations==7.3.2

M src/utils/db.py => src/utils/db.py +6 -0
@@ 33,6 33,12 @@ class UsernameHistory(BaseModel):
    uuid = ForeignKeyField(model=Users, column_name="uuid")


class Media(BaseModel):
    hash = CharField(null=False)
    type = CharField(null=False)  # MAY BE "SKIN" OR "CAPE" DEPENDING ON WHAT USER CHOSE
    uuid = ForeignKeyField(model=Users, column_name="uuid")


def get_object(model, **kwargs):
    try:
        return model.get(**kwargs)

A src/utils/mediautil.py => src/utils/mediautil.py +128 -0
@@ 0,0 1,128 @@
from os import environ, remove, mkdir
from hashlib import sha1

from boto3 import client
from botocore.exceptions import ClientError

from utils.db import Media
from utils.redisutil import cache_val, get_val

# ENV
S3_ENDPOINT = environ.get("S3_ENDPOINT")
S3_PRIVATE_KEY = environ.get("S3_PRIVATE_KEY")
S3_LOGIN_KEY = environ.get("S3_LOGIN_KEY")
S3_BUCKET = environ.get("S3_BUCKET")

client_args = {
    "aws_access_key_id": S3_LOGIN_KEY,
    "aws_secret_access_key": S3_PRIVATE_KEY,
    "endpoint_url": S3_ENDPOINT,
}

s3_client = client("s3", **client_args)
ALLOWED_EXTENSIONS = {"png"}
TEMP_UPLOAD_FOLDER = "/tmp/mc/"


def upload_file(path: str, type: str, player: str):
    file_hash = get_hash(path)
    final_file_name = f"{type}/{file_hash}.png"
    uuid = get_uuid_from_username(player)

    m = Media.create(hash=file_hash, type=type, uuid=uuid)

    try:
        res = s3_client.upload_file(path, S3_BUCKET, final_file_name)
    except ClientError as e:
        return False

    remove(path)

    m.save()
    return True


def download_file(hash: str, path: str):
    try:
        s3_client.download_file(S3_BUCKET, hash, path)
    except:
        return False

    return True


def delete_file(path: str):
    try:
        s3_client.delete_object(S3_BUCKET, path)
    except:
        return False

    return True


def get_hash(file: str):
    h = sha1()

    with open(file, "rb") as file:
        chunk = 0
        while chunk != b"":
            chunk = file.read(1024)
            h.update(chunk)

    return h.hexdigest()


def get_player_skin(uuid: str):
    cachekey = f"player_skin_hash_{uuid}"
    cached_val = get_val(cachekey)
    if cached_val:
        return cached_val

    try:
        m = Media.select().where(Media.uuid == uuid and Media.type == "SKIN").get()

        cache_val(cachekey, m.hash)

        return m.hash
    except:
        return None


def get_player_cape(uuid: str):
    cachekey = f"player_cape_hash_{uuid}"
    cached_val = get_val(cachekey)
    if cached_val:
        return cached_val

    try:
        m = Media.select().where(Media.uuid == uuid and Media.type == "CAPE").get()

        cache_val(cachekey, m.hash)

        return m.hash
    except:
        return None


def remove_skin_from_player(username: str):
    uuid = get_uuid_from_username(username)

    m = Media.select().where(Media.uuid == uuid and Media.type == "SKIN").get()

    full_path = f"SKIN/{m.hash}.png"

    m.delete_instance()

    delete_file(full_path)

    return None


def is_file_allowed(filename: str):
    return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_EXTENSIONS


def check_temp_folder():
    os.mkdir(f"{TEMP_UPLOAD_FOLDER}")
    os.mkdir(f"{TEMP_UPLOAD_FOLDER}/skins")
    os.mkdir(f"{TEMP_UPLOAD_FOLDER}/capes")

M src/utils/playerutil.py => src/utils/playerutil.py +29 -11
@@ 1,4 1,10 @@
from os import environ

from utils.dbutils import get_uuid_from_username, get_user_remoteid
from utils.mediautil import get_player_skin, get_player_cape

# ENV
STORAGE_BASEURL = environ.get("STORAGE_BASEURL")


def generate_login_dict(username: str, token: str, client_identifer: str):


@@ 26,27 32,39 @@ def generate_login_dict(username: str, token: str, client_identifer: str):
def generate_user_profile(username: str):
    user_uuid = get_uuid_from_username(username)

    # TODO: SKINHANDLER
    # SKIN HANDLER
    skin_hash = get_player_skin(user_uuid)
    if not skin_hash:
        skin_hash = "default"

    final_skin_uri = f"{STORAGE_BASEURL}/skin/{skin_hash}.png"

    # CAPE HANDLER
    cape_hash = get_player_cape(user_uuid)
    cape_res = {
        "id": "default",
        "state": "INACTIVE",
        "url": f"{STORAGE_BASEURL}/cape/default.png",
    }
    if cape_hash:
        cape_res = {
            "id": cape_hash,
            state: "ACTIVE",
            "url": f"{STORAGE_BASEURL}/cape/{cape_hash}.png",
        }

    res = {
        "id": user_uuid,
        "name": username,
        "skins": [
            {
                "id": "8c94945e-d0b4-4df8-97d1-d8d397624f93",
                "id": skin_hash,
                "state": "ACTIVE",
                # TEMPORARY
                "url": "https://bm.jae.fi/default.png",
                "url": final_skin_uri,
                "variant": "SLIM",
            }
        ],
        "CAPES": [
            {
                "id": "8c94945e-d0b4-4df8-97d1-d8d397624f93",
                "state": "ACTIVE",
                "url": "https://bm.jae.fi/defaultcape.png",
            }
        ],
        "CAPES": [cape_res],
    }

    return res