|
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525 |
- import os
- import math
- import logging
- import cherrypy
- from urllib.parse import urlparse
- from datetime import datetime, timedelta
- from photoapp.thumb import ThumbGenerator
- from photoapp.types import Photo, PhotoSet, Tag, TagItem, PhotoStatus, User
- from photoapp.dbsession import DatabaseSession
- from photoapp.common import pwhash
- from photoapp.api import PhotosApi, LibraryManager
- from photoapp.dbutils import SAEnginePlugin, SATool, db, get_db_engine, date_format
- from photoapp.utils import mime2ext, auth, require_auth, photoset_auth_filter, slugify
- from photoapp.storage import uri_to_storage
- from jinja2 import Environment, FileSystemLoader, select_autoescape
- from sqlalchemy import desc, func, and_, or_
-
-
- APPROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "../"))
-
-
- def validate_password(realm, username, password):
- if db.query(User).filter(User.name == username, User.password == pwhash(password)).first():
- return True
- return False
-
-
- class PhotosWeb(object):
- def __init__(self, library, thumbtool, template_dir):
- self.library = library
- self.thumbtool = thumbtool
- self.tpl = Environment(loader=FileSystemLoader(template_dir),
- autoescape=select_autoescape(['html', 'xml']))
- self.tpl.filters.update(mime2ext=mime2ext,
- basename=os.path.basename,
- ceil=math.ceil,
- statusstr=lambda x: str(x).split(".")[-1])
-
- self.thumb = ThumbnailView(self)
- self.photo = PhotoView(self)
- self.download = DownloadView(self)
- self.date = DateView(self)
- self.tag = TagView(self)
- self.album = self.tag
-
- def render(self, template, **kwargs):
- """
- Render a template
- """
- return self.tpl.get_template(template).render(**kwargs, **self.get_default_vars())
-
- def get_default_vars(self):
- """
- Return a dict containing variables expected to be on every page
- """
- # all tags / albums with photos visible under the current auth context
- tagq = db.query(Tag).join(TagItem).join(PhotoSet)
- if not auth():
- tagq = tagq.filter(PhotoSet.status == PhotoStatus.public)
- tagq = tagq.filter(Tag.is_album == False).order_by(Tag.name).all() # pragma: manual auth
-
- albumq = db.query(Tag).join(TagItem).join(PhotoSet)
- if not auth():
- albumq = albumq.filter(PhotoSet.status == PhotoStatus.public)
- albumq = albumq.filter(Tag.is_album == True).order_by(Tag.name).all() # pragma: manual auth
-
- ret = {
- "all_tags": tagq,
- "all_albums": albumq,
- "path": cherrypy.request.path_info,
- "auth": auth(),
- "PhotoStatus": PhotoStatus
- }
- return ret
-
- @cherrypy.expose
- def index(self):
- """
- Home page - redirect to the photo feed
- """
- raise cherrypy.HTTPRedirect('feed', 302)
-
- @cherrypy.expose
- def feed(self, page=0, pgsize=25):
- """
- /feed - main photo feed - show photos sorted by date, newest first
- """
- page, pgsize = int(page), int(pgsize)
- total_sets = photoset_auth_filter(db.query(func.count(PhotoSet.id))).first()[0]
- images = photoset_auth_filter(db.query(PhotoSet)).order_by(PhotoSet.date.desc()). \
- offset(pgsize * page).limit(pgsize).all()
- yield self.render("feed.html", images=[i for i in images], page=page, pgsize=int(pgsize), total_sets=total_sets)
-
- @cherrypy.expose
- def stats(self):
- """
- /stats - show server statistics
- """
- images = photoset_auth_filter(db.query(func.count(PhotoSet.uuid),
- date_format('%Y', PhotoSet.date).label('year'),
- date_format('%m', PhotoSet.date).label('month'))). \
- group_by('year', 'month').order_by(desc('year'), desc('month')).all()
- tsize = photoset_auth_filter(db.query(func.sum(Photo.size)).join(PhotoSet)).scalar() # pragma: manual auth
- yield self.render("monthly.html", images=images, tsize=tsize)
-
- @cherrypy.expose
- def map(self, i=None, a=None, zoom=3):
- """
- /map - show all photos on the a map. Passing $i will show a single photo, or passing $a will show photos under
- the given tag.
- TODO using so many coordinates is slow in the browser. dedupe them somehow.
- """
- query = photoset_auth_filter(db.query(PhotoSet)).filter(PhotoSet.lat != 0, PhotoSet.lon != 0)
- if a:
- query = query.join(TagItem).join(Tag).filter(Tag.uuid == a)
- if i:
- query = query.filter(PhotoSet.uuid == i)
- yield self.render("map.html", images=query.all(), zoom=int(zoom))
-
- @cherrypy.expose
- @require_auth
- def create_tags(self, fromdate=None, uuid=None, tag=None, newtag=None, remove=None):
- """
- /create_tags - tag multiple items selected by day of photo
- :param fromdate: act upon photos taken on this day
- :param uuid: act upon a single photo with this uuid
- :param tag: target photos will have a tag specified by this uuid added
- :param remove: target photos will have the tag specified by this uuid removed
- :param newtag: new tag name to create
- """
-
- def get_photos():
- if fromdate:
- dt = datetime.strptime(fromdate, "%Y-%m-%d")
- dt_end = dt + timedelta(days=1)
- photos = db.query(PhotoSet).filter(and_(PhotoSet.date >= dt,
- PhotoSet.date < dt_end)).order_by(PhotoSet.date.desc())
- num_photos = db.query(func.count(PhotoSet.id)). \
- filter(and_(PhotoSet.date >= dt, PhotoSet.date < dt_end)).order_by(PhotoSet.date.desc()).scalar()
-
- if uuid:
- photos = db.query(PhotoSet).filter(PhotoSet.uuid == uuid)
- num_photos = db.query(func.count(PhotoSet.id)).filter(PhotoSet.uuid == uuid).scalar()
- return photos, num_photos
-
- if remove:
- rmtag = db.query(Tag).filter(Tag.uuid == remove).first()
- photoq, _ = get_photos()
- for photo in photoq:
- db.query(TagItem).filter(TagItem.tag_id == rmtag.id, TagItem.set_id == photo.id).delete()
- db.commit()
-
- if newtag:
- db.add(Tag(title=newtag.capitalize(), name=newtag, slug=slugify(newtag)))
- db.commit()
-
- photos, num_photos = get_photos()
-
- if tag: # Create the tag on all the photos
- tag = db.query(Tag).filter(Tag.uuid == tag).first()
- for photo in photos.all():
- if 0 == db.query(func.count(TagItem.id)).filter(TagItem.tag_id == tag.id,
- TagItem.set_id == photo.id).scalar():
- db.add(TagItem(tag_id=tag.id, set_id=photo.id))
- db.commit()
-
- alltags = db.query(Tag).order_by(Tag.name).all()
- yield self.render("create_tags.html", images=photos, alltags=alltags,
- num_photos=num_photos, fromdate=fromdate, uuid=uuid)
-
- @cherrypy.expose
- def login(self):
- """
- /login - enable super features by logging into the app
- """
- cherrypy.session['authed'] = cherrypy.request.login
- print("Authed as", cherrypy.session['authed'])
- dest = "/feed" if "Referer" not in cherrypy.request.headers \
- else urlparse(cherrypy.request.headers["Referer"]).path
- raise cherrypy.HTTPRedirect(dest, 302)
-
- @cherrypy.expose
- def logout(self):
- """
- /logout
- """
- cherrypy.session.clear()
- dest = "/feed" if "Referer" not in cherrypy.request.headers \
- else urlparse(cherrypy.request.headers["Referer"]).path
- raise cherrypy.HTTPRedirect(dest, 302)
-
- @cherrypy.expose
- def error(self, status, message, traceback, version):
- yield self.render("error.html", status=status, message=message, traceback=traceback)
-
-
- @cherrypy.popargs('date')
- class DateView(object):
- """
- View all the photos shot on a given date
- """
- def __init__(self, master):
- self.master = master
-
- @cherrypy.expose
- def index(self, date=None, page=0):
- if date:
- page = int(page)
- pgsize = 100
- dt = datetime.strptime(date, "%Y-%m-%d")
- dt_end = dt + timedelta(days=1)
- total_sets = photoset_auth_filter(db.query(func.count(PhotoSet.id))). \
- filter(and_(PhotoSet.date >= dt, PhotoSet.date < dt_end)).first()[0]
- images = photoset_auth_filter(db.query(PhotoSet)). \
- filter(and_(PhotoSet.date >= dt, PhotoSet.date < dt_end)). \
- order_by(PhotoSet.date.desc()). \
- offset(page * pgsize).limit(pgsize).all()
- yield self.master.render("date.html", page=page, pgsize=pgsize, total_sets=total_sets,
- images=[i for i in images], date=dt)
- return
-
- images = photoset_auth_filter(db.query(
- func.count(PhotoSet.id),
- date_format('%Y', PhotoSet.date).label('year'),
- date_format('%m', PhotoSet.date).label('month'),
- date_format('%d', PhotoSet.date).label('day'))). \
- group_by('year', 'month', 'day').order_by(desc('year'), 'month', 'day').all()
- yield self.master.render("dates.html", images=images)
-
-
- @cherrypy.popargs('item_type', 'thumb_size', 'uuid')
- class ThumbnailView(object):
- """
- Generate and serve thumbnails on-demand
- """
- def __init__(self, master):
- self.master = master
-
- @cherrypy.expose
- def index(self, item_type, thumb_size, uuid):
- uuid = uuid.split(".")[0]
- query = photoset_auth_filter(db.query(Photo).join(PhotoSet))
-
- query = query.filter(Photo.set.has(uuid=uuid)) if item_type == "set" \
- else query.filter(Photo.uuid == uuid) if item_type == "one" \
- else None
-
- assert query
-
- # prefer making thumbs from jpeg to avoid loading large raws
- # jk we can't load raws anyway
- first = None
- best = None
- for photo in query.all():
- if first is None:
- first = photo
- if photo.format == "image/jpeg":
- best = photo
- break
- thumb_from = best or first
- if not thumb_from:
- raise cherrypy.HTTPError(404)
- # TODO some lock around calls to this based on uuid
- thumb_fobj = self.master.thumbtool.make_thumb(thumb_from, thumb_size)
-
- if thumb_fobj:
- return cherrypy.lib.static.serve_fileobj(thumb_fobj, "image/jpeg")
- else:
- return cherrypy.lib.static.serve_file(os.path.join(APPROOT, "assets/img/unknown.svg"), "image/svg+xml")
-
-
- @cherrypy.popargs('item_type', 'uuid')
- class DownloadView(object):
- """
- View original files or force-download them
- """
- def __init__(self, master):
- self.master = master
-
- @cherrypy.expose
- def index(self, item_type, uuid, preview=False):
- uuid = uuid.split(".")[0]
- query = None if item_type == "set" \
- else photoset_auth_filter(db.query(Photo).join(PhotoSet)).filter(Photo.uuid == uuid) if item_type == "one" \
- else None # TODO set download query
-
- item = query.first()
- if not item:
- raise cherrypy.HTTPError(404)
- extra = {}
- if not preview:
- extra.update(disposition="attachement", name=item.fname)
- return cherrypy.lib.static.serve_fileobj(self.master.library.storage.open(item.path, 'rb'),
- content_type=item.format, **extra)
-
-
- @cherrypy.popargs('uuid')
- class PhotoView(object):
- """
- View a single photo
- """
- def __init__(self, master):
- self.master = master
-
- @cherrypy.expose
- def index(self, uuid):
- # uuid = uuid.split(".")[0]
- photo = photoset_auth_filter(db.query(PhotoSet)).filter(or_(PhotoSet.uuid == uuid,
- PhotoSet.slug == uuid)).first()
- if not photo:
- raise cherrypy.HTTPError(404)
- yield self.master.render("photo.html", image=photo)
-
- @cherrypy.expose
- @require_auth
- def op(self, uuid, op, title=None, description=None, offset=None):
- """
- Modify a photo
- :param op: operation to perform:
- * "Make public":
- * "Make private":
- * "Save": update the photo's title, description, and date_offset fields
- """
- photo = db.query(PhotoSet).filter(PhotoSet.uuid == uuid).first()
- if op == "Make public":
- photo.status = PhotoStatus.public
- elif op == "Make private":
- photo.status = PhotoStatus.private
- elif op == "Save":
- photo.title = title
- photo.description = description
- photo.slug = slugify(title) or None
- photo.date_offset = int(offset) if offset else 0
- db.commit()
- raise cherrypy.HTTPRedirect('/photo/{}'.format(photo.slug or photo.uuid), 302)
-
- @cherrypy.expose
- @require_auth
- def edit(self, uuid):
- photo = photoset_auth_filter(db.query(PhotoSet)).filter(PhotoSet.uuid == uuid).first()
- yield self.master.render("photo_edit.html", image=photo)
-
-
- @cherrypy.popargs('uuid')
- class TagView(object):
- """
- View the photos associated with a single tag
- """
- def __init__(self, master):
- self.master = master
-
- @cherrypy.expose
- def index(self, uuid, page=0):
- page = int(page)
- pgsize = 100
- if uuid == "untagged":
- numphotos = photoset_auth_filter(db.query(func.count(PhotoSet.id))). \
- filter(~PhotoSet.id.in_(db.query(TagItem.set_id))).scalar()
- photos = photoset_auth_filter(db.query(PhotoSet)).filter(~PhotoSet.id.in_(db.query(TagItem.set_id))). \
- order_by(PhotoSet.date.desc()). \
- offset(page * pgsize). \
- limit(pgsize).all()
- yield self.master.render("untagged.html", images=photos, total_items=numphotos, pgsize=pgsize, page=page)
- else:
- tag = db.query(Tag).filter(or_(Tag.uuid == uuid, Tag.slug == uuid)).first()
- numphotos = photoset_auth_filter(db.query(func.count(Tag.id)).join(TagItem).join(PhotoSet)). \
- filter(Tag.id == tag.id).scalar()
- photos = photoset_auth_filter(db.query(PhotoSet)).join(TagItem).join(Tag). \
- filter(Tag.id == tag.id). \
- order_by(PhotoSet.date.desc()). \
- offset(page * pgsize). \
- limit(pgsize).all()
- yield self.master.render("album.html", tag=tag, images=photos,
- total_items=numphotos, pgsize=pgsize, page=page)
-
- @cherrypy.expose
- @require_auth
- def op(self, uuid, op, title=None, description=None):
- """
- Perform some action on this tag
- - Promote: label this tag an album
- - Demote: label this tag as only a tag and not an album
- - Delete: remove this tag
- - Make all public: mark all photos under this tag as public
- - Make all private: mark all photos under this tag as private
- """
- tag = db.query(Tag).filter(or_(Tag.uuid == uuid, Tag.slug == uuid)).first()
- if op == "Demote to tag":
- tag.is_album = 0
- elif op == "Promote to album":
- tag.is_album = 1
- elif op == "Delete tag":
- db.query(TagItem).filter(TagItem.tag_id == tag.id).delete()
- db.delete(tag)
- db.commit()
- raise cherrypy.HTTPRedirect('/', 302)
- elif op == "Make all public":
- # TODO smarter query
- for photo in db.query(PhotoSet).join(TagItem).join(Tag).filter(Tag.id == tag.id).all():
- photo.status = PhotoStatus.public
- elif op == "Make all private":
- # TODO smarter query
- for photo in db.query(PhotoSet).join(TagItem).join(Tag).filter(Tag.id == tag.id).all():
- photo.status = PhotoStatus.private
- elif op == "Save":
- tag.title = title.capitalize()
- tag.name = title
- tag.description = description
- tag.slug = slugify(title)
- else:
- raise Exception("Invalid op: '{}'".format(op))
- db.commit()
- raise cherrypy.HTTPRedirect('/tag/{}'.format(tag.slug or tag.uuid), 302)
-
- @cherrypy.expose
- @require_auth
- def edit(self, uuid):
- tag = db.query(Tag).filter(Tag.uuid == uuid).first()
- yield self.master.render("tag_edit.html", tag=tag)
-
-
- def main():
- import argparse
- import signal
-
- parser = argparse.ArgumentParser(description="Photod photo server")
-
- parser.add_argument('-p', '--port', help="tcp port to listen on",
- default=int(os.environ.get("PHOTOLIB_PORT", 8080)), type=int)
- parser.add_argument('-l', '--library', default=os.environ.get("STORAGE_URL"), help="library path")
- parser.add_argument('-c', '--cache', default=os.environ.get("CACHE_URL"), help="cache url")
- # https://docs.sqlalchemy.org/en/13/core/engines.html
- parser.add_argument('-s', '--database', help="sqlalchemy database connection uri",
- default=os.environ.get("DATABASE_URL")),
- parser.add_argument('--debug', action="store_true", help="enable development options")
-
- tunables = parser.add_argument_group(title="tunables")
- tunables.add_argument('--max-upload', help="maximum file upload size accepted in bytes",
- default=1024**3, type=int)
-
- args = parser.parse_args()
-
- if not args.database:
- parser.error("--database or DATABASE_URL is required")
-
- if not args.library:
- parser.error("--library or STORAGE_URL is required")
-
- if not args.cache:
- parser.error("--cache or CACHE_URL is required")
-
- logging.basicConfig(level=logging.INFO if args.debug else logging.WARNING,
- format="%(asctime)-15s %(levelname)-8s %(filename)s:%(lineno)d %(message)s")
-
- # Get database connection
- engine = get_db_engine(args.database)
-
- # Setup database in web framework
- cherrypy.tools.db = SATool()
- SAEnginePlugin(cherrypy.engine, engine).subscribe()
-
- # Create various internal tools
- library_storage = uri_to_storage(args.library)
- library_manager = LibraryManager(library_storage)
- thumbnail_tool = ThumbGenerator(library_manager, uri_to_storage(args.cache))
-
- # Setup and mount web ui
- tpl_dir = os.path.join(APPROOT, "templates") if not args.debug else "templates"
- web = PhotosWeb(library_manager, thumbnail_tool, tpl_dir)
- cherrypy.tree.mount(web, '/', {'/': {'tools.trailing_slash.on': False,
- 'tools.db.on': True,
- 'error_page.403': web.error,
- 'error_page.404': web.error},
- '/static': {"tools.staticdir.on": True,
- "tools.staticdir.dir": os.path.join(APPROOT, "styles/dist")
- if not args.debug else os.path.abspath("styles/dist")},
- '/thumb': {'tools.expires.on': True,
- 'tools.expires.secs': 7 * 86400},
- '/login': {'tools.auth_basic.on': True,
- 'tools.auth_basic.realm': 'photolib',
- 'tools.auth_basic.checkpassword': validate_password}})
-
- # Setup and mount API
- api = PhotosApi(library_manager)
- cherrypy.tree.mount(api, '/api', {'/': {'tools.sessions.on': False,
- 'tools.trailing_slash.on': False,
- 'tools.auth_basic.on': True,
- 'tools.auth_basic.realm': 'photolib',
- 'tools.auth_basic.checkpassword': validate_password,
- 'tools.db.on': True}})
-
- # General config options
- cherrypy.config.update({
- 'tools.sessions.storage_class': DatabaseSession,
- 'tools.sessions.on': True,
- 'tools.sessions.locking': 'explicit',
- 'tools.sessions.timeout': 525600,
- 'request.show_tracebacks': True,
- 'server.socket_port': args.port,
- 'server.thread_pool': 25,
- 'server.socket_host': '0.0.0.0',
- 'server.show_tracebacks': True,
- 'log.screen': False,
- 'engine.autoreload.on': args.debug,
- 'server.max_request_body_size': args.max_upload
- })
-
- # Setup signal handling and run it.
- def signal_handler(signum, stack):
- logging.critical('Got sig {}, exiting...'.format(signum))
- cherrypy.engine.exit()
-
- signal.signal(signal.SIGINT, signal_handler)
- signal.signal(signal.SIGTERM, signal_handler)
-
- try:
- cherrypy.engine.start()
- cherrypy.engine.block()
- finally:
- logging.info("API has shut down")
- cherrypy.engine.exit()
-
-
- if __name__ == '__main__':
- main()
|