import os import cherrypy import json from datetime import datetime from photoapp.types import Photo, PhotoSet, Tag, TagItem, PhotoStatus, User, known_extensions, known_mimes, \ genuuid, generate_storage_path from photoapp.utils import copysha, get_extension, slugify from photoapp.image import special_magic_fobj from photoapp.storage import StorageAdapter from photoapp.dbutils import db from contextlib import closing from decimal import Decimal import traceback class LibraryManager(object): def __init__(self, storage): assert isinstance(storage, StorageAdapter) self.storage = storage 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): cherrypy.response.headers["Content-type"] = "text/plain" return "Hello! This is the Photolib V1 API.\n" @cherrypy.expose @cherrypy.tools.json_out() def upload(self, files, meta): """ upload accepts one photoset (multiple images) """ # TODO stage files in tmp storage since this can DEFINITELY lead to data loss # TODO refactor function stored_files = [] def abort_upload(reason): for file in stored_files: self.library.storage.delete(photo_path) db.rollback() cherrypy.response.status = 400 return {"error": reason} 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") dupes = db.query(Photo).filter(Photo.hash.in_([f["hash"] for f in meta["files"].values()])).first() if dupes: return abort_upload(f"file already in database: {dupes.path}") # 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"]) photo_objs = [] 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 = generate_storage_path(photo_date, photo_meta['hash'][0:8], ext) if self.library.storage.exists(photo_path): pass # we trust that the database has enforced uniqueness of this image # return abort_upload(f"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(uuid=genuuid(), 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 fname=photo_meta.get("fname")) photo_objs.append(p) for pob in photo_objs: db.add(pob) if meta["uuid"] is not None: ps = db.query(PhotoSet).filter(PhotoSet.uuid == meta["uuid"]).first() if not ps: return abort_upload("parent uuid not found") ps.files.extend(photo_objs) if not ps.lat and meta["lat"] and meta["lon"]: ps.lat = Decimal(meta["lat"]) ps.lon = Decimal(meta["lon"]) else: ps = PhotoSet(uuid=genuuid(), date=photo_date, date_real=photo_date, # TODO support time offsets lat=Decimal(meta["lat"]) if meta["lat"] else None, lon=Decimal(meta["lon"]) if meta["lon"] else None, files=photo_objs) # TODO support title field et db.add(ps) for tag_name in meta["tags"]: tag = db.query(Tag).filter(Tag.name == tag_name).first() assert tag, "unknown tag name" db.add(TagItem(tag_id=tag.id, set=ps)) ps_json = ps.to_json() # we do this now to avoid a sqlalchemy bug where the object disappears after the commit try: db.commit() except Exception as e: traceback.print_exc() return abort_upload(str(e)) return ps_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" @cherrypy.expose @cherrypy.tools.json_out() def stats(self): return {"photos": db.query(PhotoSet).count(), "files": db.query(Photo).count(), "tags": db.query(Tag).count(), "users": db.query(User).count(), "public_photos": db.query(PhotoSet).filter(PhotoSet.status == PhotoStatus.public).count()} @cherrypy.expose @cherrypy.tools.json_out() @cherrypy.popargs("uuid") def photos(self, uuid=None, page=0, pagesize=50): if uuid: p = db.query(PhotoSet).filter(PhotoSet.uuid == uuid).first() if not p: cherrypy.response.status = 404 return {"error": "not found"} return p.to_json() else: page, pagesize = int(page), int(pagesize) return [p.to_json() for p in db.query(PhotoSet).order_by(PhotoSet.id).offset(pagesize * page).limit(pagesize).all()] @cherrypy.expose @cherrypy.tools.json_out() @cherrypy.popargs("uuid") def tags(self, uuid=None, name=None, page=0, pagesize=50): if cherrypy.request.method == "POST": # creating tag tagdata = json.loads(cherrypy.request.body.read()) tagname = tagdata.get("name") db.add(Tag(name=tagname, title=tagdata.get("title", None) or tagname.capitalize(), description=tagdata.get("description", None), slug=slugify(tagname))) db.commit() return {} elif uuid and cherrypy.request.method == "DELETE": # deleting tag tag = db.query(Tag).filter(Tag.uuid == uuid).first() db.query(TagItem).fitler(TagItem.tag_id == tag.id).delete() db.delete(tag) db.commit() return {} elif uuid or name: # getting tag q = db.query(Tag) if uuid: q = q.filter(Tag.uuid == uuid) if name: q = q.filter(Tag.name == name) t = q.first() if not t: cherrypy.response.status = 404 return {"error": "not found"} return [t.to_json()] else: # getting all tags page, pagesize = int(page), int(pagesize) return [t.to_json() for t in db.query(Tag).order_by(Tag.id).offset(pagesize * page).limit(pagesize).all()]