dave
0482dcc3ac
we don't use sessions for anything critial. the custom database session library locks the session for commit, and this happens every request, which is not optimal. for now, disable session locking.
525 lines
21 KiB
Python
525 lines
21 KiB
Python
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
|
|
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),
|
|
func.strftime('%Y', PhotoSet.date).label('year'),
|
|
func.strftime('%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(PhotoSet, func.strftime('%Y-%m-%d',
|
|
PhotoSet.date).label('gdate'),
|
|
func.count('photos.id'),
|
|
func.strftime('%Y', PhotoSet.date).label('year'),
|
|
func.strftime('%m', PhotoSet.date).label('month'),
|
|
func.strftime('%d', PhotoSet.date).label('day'))). \
|
|
group_by('gdate').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
|
|
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()
|