269 lines
9.4 KiB
Python
269 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
|
|
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
|
|
|
|
|
|
class StorageAdapter(object):
|
|
"""
|
|
Abstract interface for working with photo file storage. All paths are relative to the storage adapter's root param.
|
|
"""
|
|
|
|
def file_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 dedupe_name(self, path):
|
|
# TODO modify and return the passed path such that writing to it does not overwrite an existing file
|
|
# TODO it would probably be smart to hold some kind of lock on this file
|
|
raise NotImplementedError()
|
|
|
|
|
|
class FilesystemAdapter(StorageAdapter):
|
|
def __init__(self, root):
|
|
super().__init__()
|
|
self.root = root # root path
|
|
|
|
def file_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 dedupe_name(self, path):
|
|
# TODO modify and return the passed path such that writing to it does not overwrite an existing file
|
|
raise NotImplementedError()
|
|
|
|
|
|
class S3Adapter(StorageAdapter):
|
|
def file_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 dedupe_name(self, path):
|
|
# TODO modify and return the passed path such that writing to it does not overwrite an existing file
|
|
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
|
|
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")
|
|
|
|
for file in files:
|
|
# build path using the sha and extension. note that we trust the sha the client provided now & verify later
|
|
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}"
|
|
|
|
print(photo_path)
|
|
|
|
# generate a path in the storage
|
|
# yyyy/mm/dd/yyyy-mm_hh.MM.ss_x.jpg
|
|
# dest = self.library.storage.dedupe_name()
|
|
|
|
# write file to the path (and copy sha while in flight)
|
|
|
|
# is sha doesn't match uploaded metadata, abort
|
|
|
|
# create photo object for this entry
|
|
pass
|
|
|
|
# create photoset with the above photos
|
|
|
|
# commit it
|
|
|
|
# if commit fails, delete the files
|
|
|
|
# with tempfile.TemporaryDirectory() as tmpdir:
|
|
# finfo = []
|
|
# for file in files:
|
|
# # copy to local storage
|
|
# # TODO validate for funny paths like ../../ etc
|
|
# tmpphoto = os.path.join(tmpdir, file.filename)
|
|
# with open(tmpphoto, 'wb') as fout:
|
|
# shasum = copysha(file.file, fout)
|
|
|
|
# finfo.append((tmpphoto, shasum, os.path.getsize(tmpphoto), ))
|
|
|
|
# # print("File name:", file.filename)
|
|
# # import hashlib
|
|
# # sha = hashlib.sha256()
|
|
# # total = 0
|
|
# # while True:
|
|
# # b = file.file.read(1024)
|
|
# # if not b:
|
|
# # break
|
|
# # sha.update(b)
|
|
# # total += len(b)
|
|
# # print("Read length:", total)
|
|
# # print("Read sha256:", sha.hexdigest())
|
|
|
|
# if str(file.filename) not in meta["files"].keys():
|
|
# raise cherrypy.HTTPError(400, f"no mdatadata provided for filename '{file.filename}'")
|
|
# print("we have metadata for this file:", meta["files"][file.filename])
|
|
|
|
# # create database objects based on the request
|
|
# # self.lib.add_photoset(set_, photos)
|
|
|
|
# # build file path (yyyy/mm/dd/yyyy-mm_hh.MM.ss_x.jpg) (incrmenting X if the key already exists etc)
|
|
# # copy to storage
|
|
# # check if sha256 exists already
|
|
# # delete if dupe, raise error
|
|
# # (see file rewind code in ingest.py)
|
|
# # create records
|
|
# # commit
|
|
# # respond with list of uuids of the sets
|
|
# print("____")
|
|
|
|
@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"
|