274 lines
9.4 KiB
Python
274 lines
9.4 KiB
Python
import os
|
|
import cherrypy
|
|
import logging
|
|
import json
|
|
from datetime import datetime, timedelta
|
|
from photoapp.library import PhotoLibrary
|
|
from photoapp.types import Photo, PhotoSet, Tag, TagItem, PhotoStatus, User, known_extensions, known_mimes
|
|
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
|
from sqlalchemy import desc
|
|
from sqlalchemy import func, and_, or_
|
|
from sqlalchemy.exc import IntegrityError
|
|
from photoapp.common import pwhash
|
|
import math
|
|
from urllib.parse import urlparse
|
|
from photoapp.utils import mime2ext, auth, require_auth, photo_auth_filter, slugify, copysha, get_extension
|
|
from photoapp.image import special_magic_fobj
|
|
from photoapp.dbutils import db
|
|
import tempfile
|
|
from contextlib import closing
|
|
import traceback
|
|
|
|
|
|
class StorageAdapter(object):
|
|
"""
|
|
Abstract interface for working with photo file storage. All paths are relative to the storage adapter's root param.
|
|
"""
|
|
|
|
def exists(self, path):
|
|
# TODO return true/false if the file path exists
|
|
raise NotImplementedError()
|
|
|
|
def open(self, path, mode):
|
|
# TODO return a handle to the path
|
|
# TODO this should work as a context manager
|
|
raise NotImplementedError()
|
|
|
|
def delete(self, path):
|
|
# TODO erase the path
|
|
raise NotImplementedError()
|
|
|
|
def getsize(self, path):
|
|
raise NotImplementedError()
|
|
|
|
|
|
class FilesystemAdapter(StorageAdapter):
|
|
def __init__(self, root):
|
|
super().__init__()
|
|
self.root = root # root path
|
|
|
|
def exists(self, path):
|
|
# TODO return true/false if the file path exists
|
|
return os.path.exists(self._abspath(path))
|
|
|
|
def open(self, path, mode):
|
|
# TODO return a handle to the path. this should work as a context manager
|
|
os.makedirs(os.path.dirname(self._abspath(path)), exist_ok=True)
|
|
return open(self._abspath(path), mode)
|
|
|
|
def delete(self, path):
|
|
# TODO delete the file
|
|
# TODO prune empty directories that were components of $path
|
|
os.unlink(self._abspath(path))
|
|
|
|
def getsize(self, path):
|
|
return os.path.getsize(self._abspath(path))
|
|
|
|
def _abspath(self, path):
|
|
return os.path.join(self.root, path)
|
|
|
|
|
|
class S3Adapter(StorageAdapter):
|
|
def exists(self, path):
|
|
# TODO return true/false if the file path exists
|
|
raise NotImplementedError()
|
|
|
|
def open(self, path, mode):
|
|
# TODO return a handle to the path. this should work as a context manager
|
|
raise NotImplementedError()
|
|
|
|
def delete(self, path):
|
|
# TODO erase the path
|
|
raise NotImplementedError()
|
|
|
|
def getsize(self, path):
|
|
raise NotImplementedError()
|
|
|
|
|
|
class GfapiAdapter(StorageAdapter):
|
|
pass # TODO gluster storage backend
|
|
|
|
|
|
# This is largely duplicated from library.py, but written with intent for later refactoring to support abstract storage.
|
|
class LibraryManager(object):
|
|
def __init__(self, storage):
|
|
assert isinstance(storage, StorageAdapter)
|
|
self.storage = storage
|
|
|
|
# def add_photoset(self, photoset):
|
|
# """
|
|
# Commit a populated photoset object to the library. The paths in the photoset's file list entries will be updated
|
|
# as the file is moved to the library path.
|
|
# """
|
|
# # Create target directory
|
|
# path = os.path.join(self.path, self.get_datedir_path(photoset.date))
|
|
# os.makedirs(path, exist_ok=True)
|
|
|
|
# moves = [] # Track files moved. If the sql transaction files, we'll undo these
|
|
|
|
# for file in photoset.files:
|
|
# dest = os.path.join(path, os.path.basename(file.path))
|
|
|
|
# # Check if the name is already in use, rename new file if needed
|
|
# dupe_rename = 1
|
|
# while os.path.exists(dest):
|
|
# fname = os.path.basename(file.path).split(".")
|
|
# fname[-2] += "_{}".format(dupe_rename)
|
|
# dest = os.path.join(path, '.'.join(fname))
|
|
# dupe_rename += 1
|
|
# os.rename(file.path, dest)
|
|
# moves.append((file.path, dest))
|
|
# file.path = dest.lstrip(self.path)
|
|
|
|
# s = self.session()
|
|
# s.add(photoset)
|
|
# try:
|
|
# s.commit()
|
|
# except IntegrityError:
|
|
# # Commit failed, undo the moves
|
|
# for move in moves:
|
|
# os.rename(move[1], move[0])
|
|
# raise
|
|
|
|
|
|
class PhotosApi(object):
|
|
def __init__(self, library):
|
|
self.library = library
|
|
self.v1 = PhotosApiV1(self.library)
|
|
|
|
|
|
class PhotosApiV1(object):
|
|
def __init__(self, library):
|
|
self.library = library
|
|
|
|
@cherrypy.expose
|
|
def index(self):
|
|
yield f"<plaintext>hello, this is the api. my database is: {db}\n"
|
|
|
|
@cherrypy.expose
|
|
@cherrypy.tools.json_out()
|
|
def upload(self, files, meta):
|
|
"""
|
|
upload accepts one photoset (multiple images)
|
|
"""
|
|
# load and verify metadata
|
|
meta = json.loads(meta)
|
|
if type(files) != list:
|
|
files = [files]
|
|
if set([file.filename for file in files]) != set(meta["files"].keys()):
|
|
raise cherrypy.HTTPError(400, f"file metadata missing")
|
|
|
|
# use the photo's date to build a base path
|
|
# each file's sha and file extension will be appended to this
|
|
photo_date = datetime.fromisoformat(meta["date"])
|
|
basepath = photo_date.strftime("%Y/%m/%d/%Y-%m-%d_%H.%M.%S")
|
|
|
|
stored_files = []
|
|
photo_objs = []
|
|
|
|
def abort_upload(reason):
|
|
for file in stored_files:
|
|
self.library.storage.delete(photo_path)
|
|
db.rollback()
|
|
cherrypy.response.status = 400
|
|
return {"error": reason}
|
|
|
|
for file in files:
|
|
# build path using the sha and extension. note that we trust the sha the client provided now & verify later
|
|
# something like 2019/06/25/2019-06-25_19.28.05_cea1a138.png
|
|
photo_meta = meta["files"][file.filename]
|
|
ext = get_extension(file.filename)
|
|
assert ext in known_extensions
|
|
photo_path = f"{basepath}_{photo_meta['hash'][0:8]}.{ext}"
|
|
|
|
if self.library.storage.exists(photo_path):
|
|
return abort_upload("file already in library: {photo_path}")
|
|
|
|
# write file to the path (and copy sha while in flight)
|
|
with closing(self.library.storage.open(photo_path, 'wb')) as f:
|
|
shasum = copysha(file.file, f)
|
|
|
|
stored_files.append(photo_path)
|
|
|
|
# misc input validation
|
|
# also if sha doesn't match uploaded metadata, abort
|
|
# todo don't use asserts
|
|
try:
|
|
assert shasum == photo_meta["hash"], "uploaded file didn't match provided sha"
|
|
|
|
with closing(self.library.storage.open(photo_path, 'rb')) as f:
|
|
mime = special_magic_fobj(f, file.filename)
|
|
assert mime == photo_meta.get("format") and mime in known_mimes, "unknown or invalid mime"
|
|
|
|
assert self.library.storage.getsize(photo_path) == photo_meta.get("size"), \
|
|
"invalid size, file truncated?"
|
|
|
|
except AssertionError as ae:
|
|
return abort_upload(str(ae))
|
|
|
|
# create photo object for this entry
|
|
p = Photo(hash=shasum,
|
|
path=photo_path,
|
|
format=photo_meta.get("format"),
|
|
size=photo_meta.get("size"),
|
|
width=photo_meta.get("width"), # not verified
|
|
height=photo_meta.get("height"), # not verified
|
|
orientation=photo_meta.get("orientation")) # not verified
|
|
|
|
photo_objs.append(p)
|
|
|
|
ps = PhotoSet(date=photo_date,
|
|
date_real=photo_date, # TODO support time offsets
|
|
files=photo_objs) # TODO support title field etc
|
|
|
|
db.add(ps)
|
|
|
|
try:
|
|
db.commit()
|
|
except IntegrityError:
|
|
return abort_upload()
|
|
|
|
return ps.to_json()
|
|
|
|
@cherrypy.expose
|
|
@cherrypy.tools.json_out()
|
|
def byhash(self, sha):
|
|
f = db.query(Photo).filter(Photo.hash == sha).first()
|
|
if not f:
|
|
raise cherrypy.HTTPError(404)
|
|
return f.to_json()
|
|
|
|
@cherrypy.expose
|
|
@cherrypy.tools.json_out()
|
|
def set(self, uuid):
|
|
s = db.query(PhotoSet).filter(PhotoSet.uuid == uuid).first()
|
|
if not s:
|
|
raise cherrypy.HTTPError(404)
|
|
return s.to_json()
|
|
|
|
@cherrypy.expose
|
|
def download(self, uuid):
|
|
f = db.query(Photo).filter(Photo.uuid == uuid).first()
|
|
if not f:
|
|
raise cherrypy.HTTPError(404)
|
|
return cherrypy.lib.static.serve_file(os.path.abspath(os.path.join("./library", f.path)),
|
|
f.format)#TODO no hardcode path
|
|
|
|
@cherrypy.expose
|
|
@cherrypy.tools.json_out()
|
|
def user(self, username=None, password_hash=None):
|
|
if username is None: # list all users
|
|
return [u.to_json() for u in db.query(User).all()]
|
|
elif username and cherrypy.request.method == "DELETE": # delete user
|
|
u = db.query(User).filter(User.name == username).first()
|
|
if not u:
|
|
raise cherrypy.HTTPError(404)
|
|
db.delete(u)
|
|
elif username and password_hash: # create/update user
|
|
u = db.query(User).filter(User.name == username).first()
|
|
if u:
|
|
u.password = password_hash
|
|
else:
|
|
db.add(User(name=username, password=password_hash))
|
|
return "ok"
|