mirror of
https://github.com/enpaul/kodak.git
synced 2025-09-18 21:21:59 +00:00
Rename project to fresnel-lens
This commit is contained in:
9
fresnel_lens/__about__.py
Normal file
9
fresnel_lens/__about__.py
Normal file
@@ -0,0 +1,9 @@
|
||||
"""Programatically accessible project metadata"""
|
||||
|
||||
|
||||
__title__ = "fresnel-lens"
|
||||
__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/enpaul/fresnel-lens/"
|
0
fresnel_lens/__init__.py
Normal file
0
fresnel_lens/__init__.py
Normal file
43
fresnel_lens/__main__.py
Normal file
43
fresnel_lens/__main__.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""Development server stub entrypoint
|
||||
|
||||
Flask comes with a built-in development server. This entrypoint allows ``fresnel_lens``
|
||||
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 fresnel_lens
|
||||
|
||||
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 fresnel_lens.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())
|
54
fresnel_lens/_server.py
Normal file
54
fresnel_lens/_server.py
Normal file
@@ -0,0 +1,54 @@
|
||||
import flask
|
||||
|
||||
from fresnel_lens import __about__
|
||||
from fresnel_lens import configuration
|
||||
from fresnel_lens import constants
|
||||
from fresnel_lens import database
|
||||
from fresnel_lens import exceptions
|
||||
from fresnel_lens.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 FresnelRequest(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 = FresnelRequest
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.appconfig: configuration.FresnelConfig = configuration.load()
|
22
fresnel_lens/application.py
Normal file
22
fresnel_lens/application.py
Normal file
@@ -0,0 +1,22 @@
|
||||
import flask_restful
|
||||
|
||||
from fresnel_lens import resources
|
||||
from fresnel_lens._server import ImageMuckFlask
|
||||
from fresnel_lens._server import initialize_database
|
||||
from fresnel_lens._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)
|
124
fresnel_lens/configuration.py
Normal file
124
fresnel_lens/configuration.py
Normal file
@@ -0,0 +1,124 @@
|
||||
import datetime
|
||||
import enum
|
||||
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 NamedTuple
|
||||
from typing import Optional
|
||||
from typing import Sequence
|
||||
from typing import Tuple
|
||||
|
||||
import peewee
|
||||
|
||||
from fresnel_lens import constants
|
||||
|
||||
|
||||
class DimensionConfig(NamedTuple):
|
||||
strategy: constants.DimensionStrategy
|
||||
anchor: constants.Anchor
|
||||
value: int
|
||||
|
||||
|
||||
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() / "fresnel_lens.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 = "fresnel_lens"
|
||||
|
||||
@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.DatabaseBackend = constants.DatabaseBackend.SQLITE
|
||||
sqlite: _DBSqliteConfig = field(default_factory=_DBSqliteConfig.build)
|
||||
mariadb: _DBMariaConfig = field(default_factory=_DBMariaConfig.build)
|
||||
|
||||
@classmethod
|
||||
def build(cls):
|
||||
return cls(
|
||||
backend=constants.DatabaseBackend[os.environ[constants.ENV_CONF_DB_BACKEND]]
|
||||
if constants.ENV_CONF_DB_BACKEND in os.environ
|
||||
else cls.backend
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ManipConfig:
|
||||
alias: str
|
||||
formats: Sequence[constants.ImageFormat] = (
|
||||
constants.ImageFormat.JPEG,
|
||||
constants.ImageFormat.PNG,
|
||||
)
|
||||
horizontal: None
|
||||
vertical: None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ImageMuckConfig:
|
||||
database: _DBConfig = field(default_factory=_DBConfig.build)
|
||||
images: Path = Path.cwd() / "images"
|
||||
cache_dir: Path = Path.cwd() / "cache"
|
||||
expose_source: bool = False
|
||||
manips: Sequence[ManipConfig] = ()
|
||||
|
||||
@classmethod
|
||||
def from_env(cls):
|
||||
return cls(
|
||||
storage_path=Path(
|
||||
os.getenv(constants.ENV_CONF_FS_STORAGE_PATH, cls.storage_path)
|
||||
).resolve()
|
||||
)
|
||||
|
||||
|
||||
def load() -> ImageMuckConfig:
|
||||
return ImageMuckConfig.from_env()
|
29
fresnel_lens/constants.py
Normal file
29
fresnel_lens/constants.py
Normal file
@@ -0,0 +1,29 @@
|
||||
import enum
|
||||
|
||||
import peewee
|
||||
|
||||
|
||||
class DatabaseBackend(enum.Enum):
|
||||
MARIADB = peewee.MySQLDatabase
|
||||
SQLITE = peewee.SqliteDatabase
|
||||
|
||||
|
||||
class DimensionStrategy(enum.Enum):
|
||||
CROP = enum.auto()
|
||||
SCALE = enum.auto()
|
||||
RELATIVE = enum.auto()
|
||||
|
||||
|
||||
class ImageFormat(enum.Enum):
|
||||
JPEG = ("jpg", "jpeg")
|
||||
PNG = ("png",)
|
||||
GIF = ("gif",)
|
||||
|
||||
|
||||
class Anchor(enum.Enum):
|
||||
C = "center"
|
||||
|
||||
|
||||
HTTP_HEADER_RESPONSE_VERSION = "x-fresnel_lens-version"
|
||||
|
||||
HTTP_HEADER_RESPONSE_DIGEST = "Digest"
|
58
fresnel_lens/database/__init__.py
Normal file
58
fresnel_lens/database/__init__.py
Normal file
@@ -0,0 +1,58 @@
|
||||
import logging
|
||||
from typing import Tuple
|
||||
|
||||
import peewee
|
||||
|
||||
from fresnel_lens import constants
|
||||
from fresnel_lens.configuration import FresnelConfig
|
||||
from fresnel_lens.database._shared import FresnelModel
|
||||
from fresnel_lens.database._shared import INTERFACE as interface
|
||||
from fresnel_lens.database.image import ImageRecord
|
||||
from fresnel_lens.database.thumbnail import ThumbnailRecord
|
||||
|
||||
|
||||
MODELS: Tuple[FresnelModel, ...] = (ImageRecord, ThumbnailRecord)
|
||||
|
||||
|
||||
def initialize(config: FresnelConfig):
|
||||
"""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)
|
15
fresnel_lens/database/_shared.py
Normal file
15
fresnel_lens/database/_shared.py
Normal file
@@ -0,0 +1,15 @@
|
||||
import datetime
|
||||
import uuid
|
||||
|
||||
import peewee
|
||||
|
||||
|
||||
INTERFACE = peewee.DatabaseProxy()
|
||||
|
||||
|
||||
class FresnelModel(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)
|
30
fresnel_lens/database/image.py
Normal file
30
fresnel_lens/database/image.py
Normal file
@@ -0,0 +1,30 @@
|
||||
import json
|
||||
import uuid
|
||||
from typing import List
|
||||
|
||||
import peewee
|
||||
|
||||
from fresnel_lens.database._shared import FresnelModel
|
||||
|
||||
|
||||
class ImageRecord(FresnelModel):
|
||||
"""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])
|
11
fresnel_lens/database/thumbnail.py
Normal file
11
fresnel_lens/database/thumbnail.py
Normal file
@@ -0,0 +1,11 @@
|
||||
import peewee
|
||||
|
||||
from fresnel_lens.database._shared import FresnelModel
|
||||
from fresnel_lens.database.image import ImageRecord
|
||||
|
||||
|
||||
class ThumbnailRecord(FresnelModel):
|
||||
|
||||
parent = peewee.ForeignKeyField(ImageRecord)
|
||||
width = peewee.IntegerField(null=False)
|
||||
height = peewee.IntegerField(null=False)
|
39
fresnel_lens/exceptions.py
Normal file
39
fresnel_lens/exceptions.py
Normal file
@@ -0,0 +1,39 @@
|
||||
"""Application exceptions
|
||||
|
||||
::
|
||||
|
||||
FresnelException
|
||||
+-- ClientError
|
||||
+-- ServerError
|
||||
"""
|
||||
|
||||
|
||||
class FresnelException(Exception):
|
||||
"""Whomp whomp, something went wrong
|
||||
|
||||
But seriously, don't ever raise this exception
|
||||
"""
|
||||
|
||||
status: int
|
||||
|
||||
|
||||
class ClientError(FresnelException):
|
||||
"""Error while processing client side input"""
|
||||
|
||||
status = 400
|
||||
|
||||
|
||||
class ImageResourceDeletedError(ClientError):
|
||||
"""Requested image resource has been deleted"""
|
||||
|
||||
status = 410
|
||||
|
||||
|
||||
class ServerError(FresnelException):
|
||||
"""Error while processing server side data"""
|
||||
|
||||
status = 500
|
||||
|
||||
|
||||
class ImageFileRemovedError(ServerError):
|
||||
"""Image file removed from server"""
|
0
fresnel_lens/py.typed
Normal file
0
fresnel_lens/py.typed
Normal file
19
fresnel_lens/resources/__init__.py
Normal file
19
fresnel_lens/resources/__init__.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from typing import Tuple
|
||||
|
||||
from fresnel_lens.resources._shared import FresnelResource
|
||||
from fresnel_lens.resources._shared import ResponseBody
|
||||
from fresnel_lens.resources._shared import ResponseHeaders
|
||||
from fresnel_lens.resources.image import Image
|
||||
from fresnel_lens.resources.image import ImageUpload
|
||||
from fresnel_lens.resources.openapi import OpenAPI
|
||||
from fresnel_lens.resources.thumbnail import ThumbnailResize
|
||||
from fresnel_lens.resources.thumbnail import ThumbnailScale
|
||||
|
||||
|
||||
RESOURCES: Tuple[FresnelResource, ...] = (
|
||||
ImageUpload,
|
||||
Image,
|
||||
OpenAPI,
|
||||
ThumbnailScale,
|
||||
ThumbnailResize,
|
||||
)
|
110
fresnel_lens/resources/_shared.py
Normal file
110
fresnel_lens/resources/_shared.py
Normal 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 FresnelResource(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
|
||||
)
|
113
fresnel_lens/resources/image.py
Normal file
113
fresnel_lens/resources/image.py
Normal file
@@ -0,0 +1,113 @@
|
||||
import hashlib
|
||||
import shutil
|
||||
import uuid
|
||||
|
||||
import flask
|
||||
|
||||
from fresnel_lens import constants
|
||||
from fresnel_lens import database
|
||||
from fresnel_lens import exceptions
|
||||
from fresnel_lens.resources._shared import FresnelResource
|
||||
|
||||
|
||||
class ImageUpload(FresnelResource):
|
||||
|
||||
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(FresnelResource):
|
||||
|
||||
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
|
19
fresnel_lens/resources/openapi.py
Normal file
19
fresnel_lens/resources/openapi.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from pathlib import Path
|
||||
|
||||
from ruamel.yaml import YAML
|
||||
|
||||
from fresnel_lens.resources._shared import FresnelResource
|
||||
|
||||
yaml = YAML(typ="safe")
|
||||
|
||||
|
||||
class OpenAPI(FresnelResource):
|
||||
|
||||
routes = ("/openapi.json",)
|
||||
|
||||
def get(self):
|
||||
|
||||
with (Path(__file__).parent, "openapi.yaml").open() as infile:
|
||||
data = yaml.load(infile)
|
||||
|
||||
return data, 200
|
17
fresnel_lens/resources/thumbnail.py
Normal file
17
fresnel_lens/resources/thumbnail.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from fresnel_lens.resources._shared import FresnelResource
|
||||
|
||||
|
||||
class ThumbnailScale(FresnelResource):
|
||||
|
||||
routes = ("/thumb/<string:image_id>/scale/<int:scale_width>.jpg",)
|
||||
|
||||
def get(self, image_id: str, scale_width: int):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class ThumbnailResize(FresnelResource):
|
||||
|
||||
routes = ("/thumb/<string:image_id>/size/<int:width>x<int:height>.jpg",)
|
||||
|
||||
def get(self, image_id: str, width: int, height: int):
|
||||
raise NotImplementedError
|
Reference in New Issue
Block a user