web-based photo library management software
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

526 lines
21 KiB

  1. import os
  2. import math
  3. import logging
  4. import cherrypy
  5. from urllib.parse import urlparse
  6. from datetime import datetime, timedelta
  7. from photoapp.thumb import ThumbGenerator
  8. from photoapp.types import Photo, PhotoSet, Tag, TagItem, PhotoStatus, User
  9. from photoapp.dbsession import DatabaseSession
  10. from photoapp.common import pwhash
  11. from photoapp.api import PhotosApi, LibraryManager
  12. from photoapp.dbutils import SAEnginePlugin, SATool, db, get_db_engine, date_format
  13. from photoapp.utils import mime2ext, auth, require_auth, photoset_auth_filter, slugify
  14. from photoapp.storage import uri_to_storage
  15. from jinja2 import Environment, FileSystemLoader, select_autoescape
  16. from sqlalchemy import desc, func, and_, or_
  17. APPROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "../"))
  18. def validate_password(realm, username, password):
  19. if db.query(User).filter(User.name == username, User.password == pwhash(password)).first():
  20. return True
  21. return False
  22. class PhotosWeb(object):
  23. def __init__(self, library, thumbtool, template_dir):
  24. self.library = library
  25. self.thumbtool = thumbtool
  26. self.tpl = Environment(loader=FileSystemLoader(template_dir),
  27. autoescape=select_autoescape(['html', 'xml']))
  28. self.tpl.filters.update(mime2ext=mime2ext,
  29. basename=os.path.basename,
  30. ceil=math.ceil,
  31. statusstr=lambda x: str(x).split(".")[-1])
  32. self.thumb = ThumbnailView(self)
  33. self.photo = PhotoView(self)
  34. self.download = DownloadView(self)
  35. self.date = DateView(self)
  36. self.tag = TagView(self)
  37. self.album = self.tag
  38. def render(self, template, **kwargs):
  39. """
  40. Render a template
  41. """
  42. return self.tpl.get_template(template).render(**kwargs, **self.get_default_vars())
  43. def get_default_vars(self):
  44. """
  45. Return a dict containing variables expected to be on every page
  46. """
  47. # all tags / albums with photos visible under the current auth context
  48. tagq = db.query(Tag).join(TagItem).join(PhotoSet)
  49. if not auth():
  50. tagq = tagq.filter(PhotoSet.status == PhotoStatus.public)
  51. tagq = tagq.filter(Tag.is_album == False).order_by(Tag.name).all() # pragma: manual auth
  52. albumq = db.query(Tag).join(TagItem).join(PhotoSet)
  53. if not auth():
  54. albumq = albumq.filter(PhotoSet.status == PhotoStatus.public)
  55. albumq = albumq.filter(Tag.is_album == True).order_by(Tag.name).all() # pragma: manual auth
  56. ret = {
  57. "all_tags": tagq,
  58. "all_albums": albumq,
  59. "path": cherrypy.request.path_info,
  60. "auth": auth(),
  61. "PhotoStatus": PhotoStatus
  62. }
  63. return ret
  64. @cherrypy.expose
  65. def index(self):
  66. """
  67. Home page - redirect to the photo feed
  68. """
  69. raise cherrypy.HTTPRedirect('feed', 302)
  70. @cherrypy.expose
  71. def feed(self, page=0, pgsize=25):
  72. """
  73. /feed - main photo feed - show photos sorted by date, newest first
  74. """
  75. page, pgsize = int(page), int(pgsize)
  76. total_sets = photoset_auth_filter(db.query(func.count(PhotoSet.id))).first()[0]
  77. images = photoset_auth_filter(db.query(PhotoSet)).order_by(PhotoSet.date.desc()). \
  78. offset(pgsize * page).limit(pgsize).all()
  79. yield self.render("feed.html", images=[i for i in images], page=page, pgsize=int(pgsize), total_sets=total_sets)
  80. @cherrypy.expose
  81. def stats(self):
  82. """
  83. /stats - show server statistics
  84. """
  85. images = photoset_auth_filter(db.query(func.count(PhotoSet.uuid),
  86. date_format('%Y', PhotoSet.date).label('year'),
  87. date_format('%m', PhotoSet.date).label('month'))). \
  88. group_by('year', 'month').order_by(desc('year'), desc('month')).all()
  89. tsize = photoset_auth_filter(db.query(func.sum(Photo.size)).join(PhotoSet)).scalar() # pragma: manual auth
  90. yield self.render("monthly.html", images=images, tsize=tsize)
  91. @cherrypy.expose
  92. def map(self, i=None, a=None, zoom=3):
  93. """
  94. /map - show all photos on the a map. Passing $i will show a single photo, or passing $a will show photos under
  95. the given tag.
  96. TODO using so many coordinates is slow in the browser. dedupe them somehow.
  97. """
  98. query = photoset_auth_filter(db.query(PhotoSet)).filter(PhotoSet.lat != 0, PhotoSet.lon != 0)
  99. if a:
  100. query = query.join(TagItem).join(Tag).filter(Tag.uuid == a)
  101. if i:
  102. query = query.filter(PhotoSet.uuid == i)
  103. yield self.render("map.html", images=query.all(), zoom=int(zoom))
  104. @cherrypy.expose
  105. @require_auth
  106. def create_tags(self, fromdate=None, uuid=None, tag=None, newtag=None, remove=None):
  107. """
  108. /create_tags - tag multiple items selected by day of photo
  109. :param fromdate: act upon photos taken on this day
  110. :param uuid: act upon a single photo with this uuid
  111. :param tag: target photos will have a tag specified by this uuid added
  112. :param remove: target photos will have the tag specified by this uuid removed
  113. :param newtag: new tag name to create
  114. """
  115. def get_photos():
  116. if fromdate:
  117. dt = datetime.strptime(fromdate, "%Y-%m-%d")
  118. dt_end = dt + timedelta(days=1)
  119. photos = db.query(PhotoSet).filter(and_(PhotoSet.date >= dt,
  120. PhotoSet.date < dt_end)).order_by(PhotoSet.date.desc())
  121. num_photos = db.query(func.count(PhotoSet.id)). \
  122. filter(and_(PhotoSet.date >= dt, PhotoSet.date < dt_end)).order_by(PhotoSet.date.desc()).scalar()
  123. if uuid:
  124. photos = db.query(PhotoSet).filter(PhotoSet.uuid == uuid)
  125. num_photos = db.query(func.count(PhotoSet.id)).filter(PhotoSet.uuid == uuid).scalar()
  126. return photos, num_photos
  127. if remove:
  128. rmtag = db.query(Tag).filter(Tag.uuid == remove).first()
  129. photoq, _ = get_photos()
  130. for photo in photoq:
  131. db.query(TagItem).filter(TagItem.tag_id == rmtag.id, TagItem.set_id == photo.id).delete()
  132. db.commit()
  133. if newtag:
  134. db.add(Tag(title=newtag.capitalize(), name=newtag, slug=slugify(newtag)))
  135. db.commit()
  136. photos, num_photos = get_photos()
  137. if tag: # Create the tag on all the photos
  138. tag = db.query(Tag).filter(Tag.uuid == tag).first()
  139. for photo in photos.all():
  140. if 0 == db.query(func.count(TagItem.id)).filter(TagItem.tag_id == tag.id,
  141. TagItem.set_id == photo.id).scalar():
  142. db.add(TagItem(tag_id=tag.id, set_id=photo.id))
  143. db.commit()
  144. alltags = db.query(Tag).order_by(Tag.name).all()
  145. yield self.render("create_tags.html", images=photos, alltags=alltags,
  146. num_photos=num_photos, fromdate=fromdate, uuid=uuid)
  147. @cherrypy.expose
  148. def login(self):
  149. """
  150. /login - enable super features by logging into the app
  151. """
  152. cherrypy.session['authed'] = cherrypy.request.login
  153. print("Authed as", cherrypy.session['authed'])
  154. dest = "/feed" if "Referer" not in cherrypy.request.headers \
  155. else urlparse(cherrypy.request.headers["Referer"]).path
  156. raise cherrypy.HTTPRedirect(dest, 302)
  157. @cherrypy.expose
  158. def logout(self):
  159. """
  160. /logout
  161. """
  162. cherrypy.session.clear()
  163. dest = "/feed" if "Referer" not in cherrypy.request.headers \
  164. else urlparse(cherrypy.request.headers["Referer"]).path
  165. raise cherrypy.HTTPRedirect(dest, 302)
  166. @cherrypy.expose
  167. def error(self, status, message, traceback, version):
  168. yield self.render("error.html", status=status, message=message, traceback=traceback)
  169. @cherrypy.popargs('date')
  170. class DateView(object):
  171. """
  172. View all the photos shot on a given date
  173. """
  174. def __init__(self, master):
  175. self.master = master
  176. @cherrypy.expose
  177. def index(self, date=None, page=0):
  178. if date:
  179. page = int(page)
  180. pgsize = 100
  181. dt = datetime.strptime(date, "%Y-%m-%d")
  182. dt_end = dt + timedelta(days=1)
  183. total_sets = photoset_auth_filter(db.query(func.count(PhotoSet.id))). \
  184. filter(and_(PhotoSet.date >= dt, PhotoSet.date < dt_end)).first()[0]
  185. images = photoset_auth_filter(db.query(PhotoSet)). \
  186. filter(and_(PhotoSet.date >= dt, PhotoSet.date < dt_end)). \
  187. order_by(PhotoSet.date.desc()). \
  188. offset(page * pgsize).limit(pgsize).all()
  189. yield self.master.render("date.html", page=page, pgsize=pgsize, total_sets=total_sets,
  190. images=[i for i in images], date=dt)
  191. return
  192. images = photoset_auth_filter(db.query(
  193. func.count(PhotoSet.id),
  194. date_format('%Y', PhotoSet.date).label('year'),
  195. date_format('%m', PhotoSet.date).label('month'),
  196. date_format('%d', PhotoSet.date).label('day'))). \
  197. group_by('year', 'month', 'day').order_by(desc('year'), 'month', 'day').all()
  198. yield self.master.render("dates.html", images=images)
  199. @cherrypy.popargs('item_type', 'thumb_size', 'uuid')
  200. class ThumbnailView(object):
  201. """
  202. Generate and serve thumbnails on-demand
  203. """
  204. def __init__(self, master):
  205. self.master = master
  206. @cherrypy.expose
  207. def index(self, item_type, thumb_size, uuid):
  208. uuid = uuid.split(".")[0]
  209. query = photoset_auth_filter(db.query(Photo).join(PhotoSet))
  210. query = query.filter(Photo.set.has(uuid=uuid)) if item_type == "set" \
  211. else query.filter(Photo.uuid == uuid) if item_type == "one" \
  212. else None
  213. assert query
  214. # prefer making thumbs from jpeg to avoid loading large raws
  215. # jk we can't load raws anyway
  216. first = None
  217. best = None
  218. for photo in query.all():
  219. if first is None:
  220. first = photo
  221. if photo.format == "image/jpeg":
  222. best = photo
  223. break
  224. thumb_from = best or first
  225. if not thumb_from:
  226. raise cherrypy.HTTPError(404)
  227. # TODO some lock around calls to this based on uuid
  228. thumb_fobj = self.master.thumbtool.make_thumb(thumb_from, thumb_size)
  229. if thumb_fobj:
  230. return cherrypy.lib.static.serve_fileobj(thumb_fobj, "image/jpeg")
  231. else:
  232. return cherrypy.lib.static.serve_file(os.path.join(APPROOT, "assets/img/unknown.svg"), "image/svg+xml")
  233. @cherrypy.popargs('item_type', 'uuid')
  234. class DownloadView(object):
  235. """
  236. View original files or force-download them
  237. """
  238. def __init__(self, master):
  239. self.master = master
  240. @cherrypy.expose
  241. def index(self, item_type, uuid, preview=False):
  242. uuid = uuid.split(".")[0]
  243. query = None if item_type == "set" \
  244. else photoset_auth_filter(db.query(Photo).join(PhotoSet)).filter(Photo.uuid == uuid) if item_type == "one" \
  245. else None # TODO set download query
  246. item = query.first()
  247. if not item:
  248. raise cherrypy.HTTPError(404)
  249. extra = {}
  250. if not preview:
  251. extra.update(disposition="attachement", name=item.fname)
  252. return cherrypy.lib.static.serve_fileobj(self.master.library.storage.open(item.path, 'rb'),
  253. content_type=item.format, **extra)
  254. @cherrypy.popargs('uuid')
  255. class PhotoView(object):
  256. """
  257. View a single photo
  258. """
  259. def __init__(self, master):
  260. self.master = master
  261. @cherrypy.expose
  262. def index(self, uuid):
  263. # uuid = uuid.split(".")[0]
  264. photo = photoset_auth_filter(db.query(PhotoSet)).filter(or_(PhotoSet.uuid == uuid,
  265. PhotoSet.slug == uuid)).first()
  266. if not photo:
  267. raise cherrypy.HTTPError(404)
  268. yield self.master.render("photo.html", image=photo)
  269. @cherrypy.expose
  270. @require_auth
  271. def op(self, uuid, op, title=None, description=None, offset=None):
  272. """
  273. Modify a photo
  274. :param op: operation to perform:
  275. * "Make public":
  276. * "Make private":
  277. * "Save": update the photo's title, description, and date_offset fields
  278. """
  279. photo = db.query(PhotoSet).filter(PhotoSet.uuid == uuid).first()
  280. if op == "Make public":
  281. photo.status = PhotoStatus.public
  282. elif op == "Make private":
  283. photo.status = PhotoStatus.private
  284. elif op == "Save":
  285. photo.title = title
  286. photo.description = description
  287. photo.slug = slugify(title) or None
  288. photo.date_offset = int(offset) if offset else 0
  289. db.commit()
  290. raise cherrypy.HTTPRedirect('/photo/{}'.format(photo.slug or photo.uuid), 302)
  291. @cherrypy.expose
  292. @require_auth
  293. def edit(self, uuid):
  294. photo = photoset_auth_filter(db.query(PhotoSet)).filter(PhotoSet.uuid == uuid).first()
  295. yield self.master.render("photo_edit.html", image=photo)
  296. @cherrypy.popargs('uuid')
  297. class TagView(object):
  298. """
  299. View the photos associated with a single tag
  300. """
  301. def __init__(self, master):
  302. self.master = master
  303. @cherrypy.expose
  304. def index(self, uuid, page=0):
  305. page = int(page)
  306. pgsize = 100
  307. if uuid == "untagged":
  308. numphotos = photoset_auth_filter(db.query(func.count(PhotoSet.id))). \
  309. filter(~PhotoSet.id.in_(db.query(TagItem.set_id))).scalar()
  310. photos = photoset_auth_filter(db.query(PhotoSet)).filter(~PhotoSet.id.in_(db.query(TagItem.set_id))). \
  311. order_by(PhotoSet.date.desc()). \
  312. offset(page * pgsize). \
  313. limit(pgsize).all()
  314. yield self.master.render("untagged.html", images=photos, total_items=numphotos, pgsize=pgsize, page=page)
  315. else:
  316. tag = db.query(Tag).filter(or_(Tag.uuid == uuid, Tag.slug == uuid)).first()
  317. numphotos = photoset_auth_filter(db.query(func.count(Tag.id)).join(TagItem).join(PhotoSet)). \
  318. filter(Tag.id == tag.id).scalar()
  319. photos = photoset_auth_filter(db.query(PhotoSet)).join(TagItem).join(Tag). \
  320. filter(Tag.id == tag.id). \
  321. order_by(PhotoSet.date.desc()). \
  322. offset(page * pgsize). \
  323. limit(pgsize).all()
  324. yield self.master.render("album.html", tag=tag, images=photos,
  325. total_items=numphotos, pgsize=pgsize, page=page)
  326. @cherrypy.expose
  327. @require_auth
  328. def op(self, uuid, op, title=None, description=None):
  329. """
  330. Perform some action on this tag
  331. - Promote: label this tag an album
  332. - Demote: label this tag as only a tag and not an album
  333. - Delete: remove this tag
  334. - Make all public: mark all photos under this tag as public
  335. - Make all private: mark all photos under this tag as private
  336. """
  337. tag = db.query(Tag).filter(or_(Tag.uuid == uuid, Tag.slug == uuid)).first()
  338. if op == "Demote to tag":
  339. tag.is_album = 0
  340. elif op == "Promote to album":
  341. tag.is_album = 1
  342. elif op == "Delete tag":
  343. db.query(TagItem).filter(TagItem.tag_id == tag.id).delete()
  344. db.delete(tag)
  345. db.commit()
  346. raise cherrypy.HTTPRedirect('/', 302)
  347. elif op == "Make all public":
  348. # TODO smarter query
  349. for photo in db.query(PhotoSet).join(TagItem).join(Tag).filter(Tag.id == tag.id).all():
  350. photo.status = PhotoStatus.public
  351. elif op == "Make all private":
  352. # TODO smarter query
  353. for photo in db.query(PhotoSet).join(TagItem).join(Tag).filter(Tag.id == tag.id).all():
  354. photo.status = PhotoStatus.private
  355. elif op == "Save":
  356. tag.title = title.capitalize()
  357. tag.name = title
  358. tag.description = description
  359. tag.slug = slugify(title)
  360. else:
  361. raise Exception("Invalid op: '{}'".format(op))
  362. db.commit()
  363. raise cherrypy.HTTPRedirect('/tag/{}'.format(tag.slug or tag.uuid), 302)
  364. @cherrypy.expose
  365. @require_auth
  366. def edit(self, uuid):
  367. tag = db.query(Tag).filter(Tag.uuid == uuid).first()
  368. yield self.master.render("tag_edit.html", tag=tag)
  369. def main():
  370. import argparse
  371. import signal
  372. parser = argparse.ArgumentParser(description="Photod photo server")
  373. parser.add_argument('-p', '--port', help="tcp port to listen on",
  374. default=int(os.environ.get("PHOTOLIB_PORT", 8080)), type=int)
  375. parser.add_argument('-l', '--library', default=os.environ.get("STORAGE_URL"), help="library path")
  376. parser.add_argument('-c', '--cache', default=os.environ.get("CACHE_URL"), help="cache url")
  377. # https://docs.sqlalchemy.org/en/13/core/engines.html
  378. parser.add_argument('-s', '--database', help="sqlalchemy database connection uri",
  379. default=os.environ.get("DATABASE_URL")),
  380. parser.add_argument('--debug', action="store_true", help="enable development options")
  381. tunables = parser.add_argument_group(title="tunables")
  382. tunables.add_argument('--max-upload', help="maximum file upload size accepted in bytes",
  383. default=1024**3, type=int)
  384. args = parser.parse_args()
  385. if not args.database:
  386. parser.error("--database or DATABASE_URL is required")
  387. if not args.library:
  388. parser.error("--library or STORAGE_URL is required")
  389. if not args.cache:
  390. parser.error("--cache or CACHE_URL is required")
  391. logging.basicConfig(level=logging.INFO if args.debug else logging.WARNING,
  392. format="%(asctime)-15s %(levelname)-8s %(filename)s:%(lineno)d %(message)s")
  393. # Get database connection
  394. engine = get_db_engine(args.database)
  395. # Setup database in web framework
  396. cherrypy.tools.db = SATool()
  397. SAEnginePlugin(cherrypy.engine, engine).subscribe()
  398. # Create various internal tools
  399. library_storage = uri_to_storage(args.library)
  400. library_manager = LibraryManager(library_storage)
  401. thumbnail_tool = ThumbGenerator(library_manager, uri_to_storage(args.cache))
  402. # Setup and mount web ui
  403. tpl_dir = os.path.join(APPROOT, "templates") if not args.debug else "templates"
  404. web = PhotosWeb(library_manager, thumbnail_tool, tpl_dir)
  405. cherrypy.tree.mount(web, '/', {'/': {'tools.trailing_slash.on': False,
  406. 'tools.db.on': True,
  407. 'error_page.403': web.error,
  408. 'error_page.404': web.error},
  409. '/static': {"tools.staticdir.on": True,
  410. "tools.staticdir.dir": os.path.join(APPROOT, "styles/dist")
  411. if not args.debug else os.path.abspath("styles/dist")},
  412. '/thumb': {'tools.expires.on': True,
  413. 'tools.expires.secs': 7 * 86400},
  414. '/login': {'tools.auth_basic.on': True,
  415. 'tools.auth_basic.realm': 'photolib',
  416. 'tools.auth_basic.checkpassword': validate_password}})
  417. # Setup and mount API
  418. api = PhotosApi(library_manager)
  419. cherrypy.tree.mount(api, '/api', {'/': {'tools.sessions.on': False,
  420. 'tools.trailing_slash.on': False,
  421. 'tools.auth_basic.on': True,
  422. 'tools.auth_basic.realm': 'photolib',
  423. 'tools.auth_basic.checkpassword': validate_password,
  424. 'tools.db.on': True}})
  425. # General config options
  426. cherrypy.config.update({
  427. 'tools.sessions.storage_class': DatabaseSession,
  428. 'tools.sessions.on': True,
  429. 'tools.sessions.locking': 'explicit',
  430. 'tools.sessions.timeout': 525600,
  431. 'request.show_tracebacks': True,
  432. 'server.socket_port': args.port,
  433. 'server.thread_pool': 25,
  434. 'server.socket_host': '0.0.0.0',
  435. 'server.show_tracebacks': True,
  436. 'log.screen': False,
  437. 'engine.autoreload.on': args.debug,
  438. 'server.max_request_body_size': args.max_upload
  439. })
  440. # Setup signal handling and run it.
  441. def signal_handler(signum, stack):
  442. logging.critical('Got sig {}, exiting...'.format(signum))
  443. cherrypy.engine.exit()
  444. signal.signal(signal.SIGINT, signal_handler)
  445. signal.signal(signal.SIGTERM, signal_handler)
  446. try:
  447. cherrypy.engine.start()
  448. cherrypy.engine.block()
  449. finally:
  450. logging.info("API has shut down")
  451. cherrypy.engine.exit()
  452. if __name__ == '__main__':
  453. main()