1
0
mirror of https://github.com/enpaul/kodak.git synced 2025-09-19 05:31:58 +00:00

Rename project to imagemuck

This commit is contained in:
2021-05-05 13:48:02 -04:00
parent 6a7662ae0f
commit d92b53a60c
31 changed files with 116 additions and 116 deletions

9
imagemuck/__about__.py Normal file
View File

@@ -0,0 +1,9 @@
"""Programatically accessible project metadata"""
__title__ = "imagemuck"
__version__ = "0.1.0"
__authors__ = ["Ethan Paul <24588726+enpaul@users.noreply.github.com>"]
__license__ = "MIT"
__summary__ = "HTTP server for uploading images and generating thumbnails"
__url__ = "https://github.com/mocproject/imagemuck/"

0
imagemuck/__init__.py Normal file
View File

43
imagemuck/__main__.py Normal file
View File

@@ -0,0 +1,43 @@
"""Development server stub entrypoint
Flask comes with a built-in development server. This entrypoint allows ``imagemuck``
to be run directly to run the development server and expose some simple config options for ease of
access. Run the below command to start the server:
::
python -m imagemuck
In addition to the helpful CLI flags, the Flask development server run by this module will also
load any ``.env`` files in the current working directory when running the application.
.. warning:: As the development server will tell you on startup, do not use this for production
deployments.
"""
import argparse
import sys
from imagemuck.application import APPLICATION
# pylint: disable=invalid-name
def main():
parser = argparse.ArgumentParser()
parser.add_argument(
"-b",
"--bind",
help="Address or socket to bind the server to",
default="127.0.0.1",
)
parser.add_argument(
"-p", "--port", help="Port bind the server to", default=5000, type=int
)
parser.add_argument(
"-D", "--debug", help="Run Flask in debug mode", action="store_true"
)
args = parser.parse_args()
APPLICATION.run(host=args.bind, port=args.port, debug=args.debug, load_dotenv=True)
if __name__ == "__main__":
sys.exit(main())

53
imagemuck/_server.py Normal file
View File

@@ -0,0 +1,53 @@
import flask
from imagemuck import __about__
from imagemuck import configuration
from imagemuck import constants
from imagemuck import database
from imagemuck import exceptions
from imagemuck.resources import ResponseHeaders
def make_the_tea() -> None:
"""Just for fun
https://en.wikipedia.org/wiki/Hyper_Text_Coffee_Pot_Control_Protocol
"""
if flask.request.content_type == "message/coffeepot":
raise exceptions.IAmATeapotError(
f"Coffee brewing request for '{flask.request.path}' cannot be completed by teapot application"
)
def initialize_database() -> None:
"""Initialize the database connection"""
database.initialize(flask.current_app.appconfig)
class ImageMuckRequest(flask.Request):
"""Extend the default Flask request object to add custom application state settings"""
def make_response_headers(self) -> ResponseHeaders:
"""Create the headers dictionary of the standard response headers
This function should be used when determining response headers so that the header names,
their contents, and formatting are universal.
:returns: Dictionary of headers
"""
return {
constants.HTTP_HEADER_RESPONSE_VERSION: __about__.__version__,
}
class ImageMuckFlask(flask.Flask):
"""Extend the default Flask object to add the custom application config
There's probably an easier/more kosher way to do this, but ¯\\_(ツ)_/¯
"""
request_class = ImageMuckRequest
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.appconfig: configuration.ImageMuckConfig = configuration.load()

22
imagemuck/application.py Normal file
View File

@@ -0,0 +1,22 @@
import flask_restful
from imagemuck import resources
from imagemuck._server import ImageMuckFlask
from imagemuck._server import initialize_database
from imagemuck._server import make_the_tea
APPLICATION = ImageMuckFlask(__name__)
API = flask_restful.Api(APPLICATION, catch_all_404s=True)
def _set_upload_limit() -> None:
APPLICATION.config["MAX_CONTENT_LENGTH"] = APPLICATION.appconfig.upload.size_limit
APPLICATION.before_request(make_the_tea)
APPLICATION.before_first_request(initialize_database)
APPLICATION.before_first_request(_set_upload_limit)
for resource in resources.RESOURCES:
API.add_resource(resource, *resource.routes)

125
imagemuck/configuration.py Normal file
View File

@@ -0,0 +1,125 @@
import json
import os
from dataclasses import dataclass
from dataclasses import field
from pathlib import Path
from typing import Any
from typing import Dict
from typing import Optional
from typing import Tuple
from imagemuck import constants
def _default_sqlite_pragmas() -> Dict[str, Any]:
"""Default SQLite pragmas
.. note:: This needs to be a separate callout function to avoid setting a mutable type in the
dataclass signature.
.. note:: These settings are taken directly from
`the Peewee docs <http://docs.peewee-orm.com/en/latest/peewee/database.html#recommended-settings>`_.
"""
return {
"journal_mode": "wal",
"cache_size": -1 * 64000,
"foreign_keys": 1,
"ignore_check_constraints": 0,
"synchronous": 0,
}
@dataclass
class _DBSqliteConfig:
path: Path = Path.cwd() / "imagemuck.db"
pragmas: Dict[str, Any] = field(default_factory=_default_sqlite_pragmas)
@classmethod
def build(cls):
return cls(
path=Path(os.environ.get(constants.ENV_CONF_DB_SQLITE_PATH, cls.path)),
pragmas=json.loads(os.environ[constants.ENV_CONF_DB_SQLITE_PRAGMAS])
if constants.ENV_CONF_DB_SQLITE_PRAGMAS in os.environ
else _default_sqlite_pragmas(),
)
@dataclass
class _DBMariaConfig:
hostname: str = "localhost"
username: str = "root"
password: Optional[str] = None
port: int = 3306
schema: str = "imagemuck"
@classmethod
def build(cls):
return cls(
hostname=os.getenv(constants.ENV_CONF_DB_MARIA_HOSTNAME, cls.hostname),
username=os.getenv(constants.ENV_CONF_DB_MARIA_USERNAME, cls.username),
password=os.environ.get(constants.ENV_CONF_DB_MARIA_PASSWORD, cls.password),
port=int(os.environ.get(constants.ENV_CONF_DB_MARIA_PORT, cls.port)),
schema=os.getenv(constants.ENV_CONF_DB_MARIA_SCHEMA, cls.schema),
)
@dataclass
class _DBConfig:
backend: constants.SupportedDatabaseBackend = (
constants.SupportedDatabaseBackend.SQLITE
)
sqlite: _DBSqliteConfig = field(default_factory=_DBSqliteConfig.build)
mariadb: _DBMariaConfig = field(default_factory=_DBMariaConfig.build)
@classmethod
def build(cls):
return cls(
backend=constants.SupportedDatabaseBackend[
os.environ[constants.ENV_CONF_DB_BACKEND]
]
if constants.ENV_CONF_DB_BACKEND in os.environ
else cls.backend
)
@dataclass
class _UploadConfig:
size_limit: int = 1 * 1024 * 1024
formats: Tuple[str] = ("jpg", "jpeg")
@classmethod
def build(cls):
return cls(
size_limit=(int(os.environ[constants.ENV_CONF_FS_UPLOAD_MAX_SIZE]) * 1024)
if constants.ENV_CONF_FS_UPLOAD_MAX_SIZE in os.environ
else cls.size_limit,
formats=(
item.strip().lower()
for item in os.environ[constants.ENV_CONF_FS_UPLOAD_FORMATS].split(",")
)
if constants.ENV_CONF_FS_UPLOAD_MAX_SIZE in os.environ
else cls.formats,
)
@dataclass
class ImageMuckConfig:
database: _DBConfig = field(default_factory=_DBConfig.build)
upload: _UploadConfig = field(default_factory=_UploadConfig.build)
storage_path: Path = Path.cwd()
@classmethod
def build(cls):
return cls(
storage_path=Path(
os.getenv(constants.ENV_CONF_FS_STORAGE_PATH, cls.storage_path)
).resolve()
)
def load() -> ImageMuckConfig:
return ImageMuckConfig.build()

34
imagemuck/constants.py Normal file
View File

@@ -0,0 +1,34 @@
import enum
class SupportedDatabaseBackend(enum.Enum):
MARIADB = enum.auto()
SQLITE = enum.auto()
HTTP_HEADER_RESPONSE_VERSION = "x-imagemuck-version"
HTTP_HEADER_RESPONSE_DIGEST = "Digest"
ENV_CONF_DB_BACKEND = "IMGMONK_DATABASE_BACKEND"
ENV_CONF_DB_SQLITE_PATH = "IMGMONK_SQLITE_PATH"
ENV_CONF_DB_SQLITE_PRAGMAS = "IMGMONK_SQLITE_PRAGMAS"
ENV_CONF_DB_MARIA_HOSTNAME = "IMGMONK_MARIA_HOSTNAME"
ENV_CONF_DB_MARIA_USERNAME = "IMGMONK_MARIA_USERNAME"
ENV_CONF_DB_MARIA_PASSWORD = "IMGMONK_MARIA_PASSWORD"
ENV_CONF_DB_MARIA_PORT = "IMGMONK_MARIA_PORT"
ENV_CONF_DB_MARIA_SCHEMA = "IMGMONK_MARIA_SCHEMA"
ENV_CONF_FS_STORAGE_PATH = "IMGMONK_STORAGE_PATH"
ENV_CONF_FS_UPLOAD_MAX_SIZE = "IMGMONK_UPLOAD_LIMIT"
ENV_CONF_FS_UPLOAD_FORMATS = "IMGMONK_UPLOAD_FORMATS"

View File

@@ -0,0 +1,58 @@
import logging
from typing import Tuple
import peewee
from imagemuck import constants
from imagemuck.configuration import ImageMuckConfig
from imagemuck.database._shared import ImageMuckModel
from imagemuck.database._shared import INTERFACE as interface
from imagemuck.database.image import ImageRecord
from imagemuck.database.thumbnail import ThumbnailRecord
MODELS: Tuple[ImageMuckModel, ...] = (ImageRecord, ThumbnailRecord)
def initialize(config: ImageMuckConfig):
"""Initialize the database interface
Defining the database as an
`unconfigured proxy object <http://docs.peewee-orm.com/en/latest/peewee/database.html#setting-the-database-at-run-time>`_
allows it to be configured at runtime based on the config values.
:param config: Populated configuration container object
"""
logger = logging.getLogger(__name__)
if config.database.backend == constants.SupportedDatabaseBackend.SQLITE:
logger.debug("Using SQLite database backend")
logger.debug(f"Applying SQLite pragmas: {config.database.sqlite.pragmas}")
database = peewee.SqliteDatabase(
config.database.sqlite.path, pragmas=config.database.sqlite.pragmas
)
elif config.database.backend == constants.SupportedDatabaseBackend.MARIADB:
logger.debug("Using MariaDB database backend")
logger.debug(
"Configuring MariaDB:"
f" {config.database.mariadb.username}@{config.database.mariadb.hostname}:{config.database.mariadb.port},"
f" with database '{config.database.mariadb.schema}'"
)
database = peewee.MySQLDatabase(
config.database.mariadb.schema,
host=config.database.mariadb.hostname,
port=config.database.mariadb.port,
user=config.database.mariadb.username,
password=config.database.mariadb.password,
charset="utf8mb4",
)
else:
raise ValueError(
f"Invalid storage backend in configuration: {config.database.backend}"
)
interface.initialize(database)
with interface.atomic():
interface.create_tables(MODELS)

View File

@@ -0,0 +1,15 @@
import datetime
import uuid
import peewee
INTERFACE = peewee.DatabaseProxy()
class ImageMuckModel(peewee.Model):
class Meta: # pylint: disable=too-few-public-methods,missing-class-docstring
database = INTERFACE
uuid = peewee.UUIDField(null=False, unique=True, default=uuid.uuid4)
created = peewee.DateTimeField(null=False, default=datetime.datetime.utcnow)

View File

@@ -0,0 +1,30 @@
import json
import uuid
from typing import List
import peewee
from imagemuck.database._shared import ImageMuckModel
class ImageRecord(ImageMuckModel):
"""Database record for"""
width = peewee.IntegerField(null=False)
height = peewee.IntegerField(null=False)
format = peewee.CharField(null=False)
deleted = peewee.BooleanField(null=False, default=False)
public = peewee.BooleanField(null=False, default=False)
owner = peewee.UUIDField(null=False)
sha256 = peewee.CharField(null=False)
_readable = peewee.CharField(null=False, default="[]")
@property
def readable(self) -> List[uuid.UUID]:
"""List of UUIDs corresponding to accounts that can read the file"""
return [uuid.UUID(item) for item in json.loads(self._readable)]
@readable.setter
def readable(self, value: List[uuid.UUID]):
"""Update the list of UUIDs for accounts that can read the file"""
self._readable = json.dumps([str(item) for item in value])

View File

@@ -0,0 +1,11 @@
import peewee
from imagemuck.database._shared import ImageMuckModel
from imagemuck.database.image import ImageRecord
class ThumbnailRecord(ImageMuckModel):
parent = peewee.ForeignKeyField(ImageRecord)
width = peewee.IntegerField(null=False)
height = peewee.IntegerField(null=False)

39
imagemuck/exceptions.py Normal file
View File

@@ -0,0 +1,39 @@
"""Application exceptions
::
ImageMuckException
+-- ClientError
+-- ServerError
"""
class ImageMuckException(Exception):
"""Whomp whomp, something went wrong
But seriously, don't ever raise this exception
"""
status: int
class ClientError(ImageMuckException):
"""Error while processing client side input"""
status = 400
class ImageResourceDeletedError(ClientError):
"""Requested image resource has been deleted"""
status = 410
class ServerError(ImageMuckException):
"""Error while processing server side data"""
status = 500
class ImageFileRemovedError(ServerError):
"""Image file removed from server"""

0
imagemuck/py.typed Normal file
View File

View File

@@ -0,0 +1,19 @@
from typing import Tuple
from imagemuck.resources._shared import ImageMuckResource
from imagemuck.resources._shared import ResponseBody
from imagemuck.resources._shared import ResponseHeaders
from imagemuck.resources.image import Image
from imagemuck.resources.image import ImageUpload
from imagemuck.resources.openapi import OpenAPI
from imagemuck.resources.thumbnail import ThumbnailResize
from imagemuck.resources.thumbnail import ThumbnailScale
RESOURCES: Tuple[ImageMuckResource, ...] = (
ImageUpload,
Image,
OpenAPI,
ThumbnailScale,
ThumbnailResize,
)

View File

@@ -0,0 +1,110 @@
"""Shared resource base with common functionality"""
import logging
from typing import Any
from typing import Dict
from typing import List
from typing import NamedTuple
from typing import Optional
from typing import Tuple
from typing import Union
import flask
import flask_restful
ResponseBody = Optional[Union[Dict[str, Any], List[Dict[str, Any]], List[str]]]
ResponseHeaders = Dict[str, str]
class ResponseTuple(NamedTuple):
"""Namedtuple representing the format of a flask-restful response tuple
:param body: Response body; must be comprised only of JSON-friendly primative types
:param code: HTTP response code
:param headers: Dictionary of headers
"""
body: ResponseBody
code: int
headers: ResponseHeaders
class ImageMuckResource(flask_restful.Resource):
"""Extension of the default :class:`flask_restful.Resource` class
Add a couple of useful things to the default resource class:
* Adds the :meth:`options` method to respond to HTTP OPTION requests
* Adds the :meth:`_head` method as a stub helper for responding to HTTP HEAD requests
* Adds the :meth:`make_response` method which handles response formatting boilerplate
* Type hints the :attr:`routes` attribute for usage in subclasses
* Adds an instance logger
.. warning:: This class is a stub and should not be directly attached to an application
:attribute routes: Tuple of route paths that this resource should handle; can be unpacked into
``flask_restful.Api().add_route()``
"""
routes: Tuple[str, ...]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.logger = logging.getLogger()
def options(
self, *args, **kwargs
) -> ResponseTuple: # pylint: disable=unused-argument
"""Implement HTTP ``OPTIONS`` support
`Reference documentation <https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/OPTIONS>`_
"""
verbs = ",".join([verb.upper() for verb in flask.request.url_rule.methods])
return self.make_response(None, 204, {"Allowed": verbs})
def _head(self, response: ResponseTuple) -> ResponseTuple:
"""Wrapper to implement HTTP ``HEAD`` support
`Reference documentation <https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/HEAD>`_
.. note:: The ``head`` method cannot be implemented directly as an alias of ``get`` because
that would require a uniform signature for ``get`` across all resources; or some
hacky nonsense that wouldn't be worth it. This stub instead lets child resources
implement ``head`` as a oneliner.
"""
return self.make_response(None, response.code, response.headers)
def make_response(
self,
data: ResponseBody,
code: int = 200,
headers: Optional[ResponseHeaders] = None,
):
"""Create a response tuple from the current context
Helper function for generating defaults, parsing common data, and formatting the response.
:param data: Response data to return from the request
:param code: Response code to return; defaults to `200: Ok <https://httpstatuses.com/200>`_
:param headers: Additional headers to return with the request; the default headers will
be added automatically and do not need to be passed.
:returns: Response tuple ready to be returned out of a resource method
.. note:: This function will handle pagination and header assembly internally. The response
data passed to the ``data`` parameter should be unpaginated.
"""
headers = headers or {}
headers = {**headers, **flask.request.make_response_headers()}
# 204 code specifies that it must never include a response body. Most clients will ignore
# any response body when a 204 is given, but that's no reason to abandon best practices here
# on the server side
# https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/204
return ResponseTuple(
body=data if code != 204 else None, code=code, headers=headers
)

View File

@@ -0,0 +1,113 @@
import hashlib
import shutil
import uuid
import flask
from imagemuck import constants
from imagemuck import database
from imagemuck import exceptions
from imagemuck.resources._shared import ImageMuckResource
class ImageUpload(ImageMuckResource):
routes = ("/image/",)
def post(self):
if "image" not in flask.request.files:
raise
uploaded = flask.request.files["image"]
if not uploaded.filename:
raise
format = uploaded.filename.rpartition(".")[-1].lower()
if format not in flask.current_app.appconfig.upload.formats:
raise
image = database.ImageRecord(format=format, width=0, height=0, owner="foobar")
imagedir = flask.current_app.appconfig.storage_path / str(image.uuid)
imagedir.mkdir()
uploaded.save(imagedir / f"base.{format}")
with (imagedir / f"base.{format}").open() as infile:
image.sha256 = hashlib.sha256(infile.read()).hexdigest()
with database.interface.atomic():
image.save()
return None, 201
class Image(ImageMuckResource):
routes = ("/image/<string:image_id>.jpeg",)
def get(self, image_id: str):
image = database.ImageRecord.get(
database.ImageRecord.uuid == uuid.UUID(image_id)
)
if image.deleted:
raise exceptions.ImageResourceDeletedError(
f"Image with ID '{image_id}' was deleted"
)
filepath = (
flask.current_app.appconfig.storage_path
/ str(image.uuid)
/ f"base.{image.format}"
)
if not filepath.exists():
with database.interface.atomic():
image.deleted = True
image.save()
raise exceptions.ImageFileRemovedError(
f"Image file with ID '{image_id}' removed from the server"
)
flask.send_file(
filepath,
mimetype=f"image/{'jpeg' if image.format == 'jpg' else image.format}",
# images are indexed by UUID with no ability to update, y'all should cache
# this thing 'till the sun explodes
cache_timeout=(60 * 60 * 24 * 365),
)
return (
None,
200,
{constants.HTTP_HEADER_RESPONSE_DIGEST: f"sha-256={image.sha256}"},
)
def delete(self, image_id: str, format: str):
image = database.ImageRecord.get(
database.ImageRecord.uuid
== uuid.UUID(image_id) & database.ImageRecord.format
== format
)
if image.deleted:
raise exceptions.ImageResourceDeletedError(
f"Image with ID '{image_id}' was deleted"
)
filepath = flask.current_app.appconfig.storage_path / str(image.uuid)
with database.interface.atomic():
image.deleted = True
image.save()
if filepath.exists():
shutil.rmtree(filepath)
return None, 204

View File

@@ -0,0 +1,19 @@
from pathlib import Path
from ruamel.yaml import YAML
from imagemuck.resources._shared import ImageMuckResource
yaml = YAML(typ="safe")
class OpenAPI(ImageMuckResource):
routes = ("/openapi.json",)
def get(self):
with (Path(__file__).parent, "openapi.yaml").open() as infile:
data = yaml.load(infile)
return data, 200

View File

@@ -0,0 +1 @@
../../openapi.yaml

View File

@@ -0,0 +1,17 @@
from imagemuck.resources._shared import ImageMuckResource
class ThumbnailScale(ImageMuckResource):
routes = ("/thumb/<string:image_id>/scale/<int:scale_width>.jpg",)
def get(self, image_id: str, scale_width: int):
raise NotImplementedError
class ThumbnailResize(ImageMuckResource):
routes = ("/thumb/<string:image_id>/size/<int:width>x<int:height>.jpg",)
def get(self, image_id: str, width: int, height: int):
raise NotImplementedError