photolib/photoapp/api.py

257 lines
8.8 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
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.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()
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 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()
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():
for file in stored_files:
self.library.storage.delete(photo_path)
db.rollback()
print(traceback.format_exc())
# raise cherrypy.HTTPError(400, traceback.format_exc())
raise
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}"
try:
assert not self.library.storage.exists(photo_path), f"file already in library: {photo_path}"
except AssertionError:
abort_upload()
# 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)
# is sha doesn't match uploaded metadata, abort
try:
assert shasum == photo_meta["hash"], "uploaded file didn't match provided sha"
except AssertionError:
abort_upload()
# create photo object for this entry
p = Photo(hash=shasum,
path=photo_path,
format=photo_meta.get("format"), # TODO verify
size=photo_meta.get("size"), # TODO verify
width=photo_meta.get("width"),
height=photo_meta.get("height"),
orientation=photo_meta.get("orientation"))
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:
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"