basic auth for endpoints

This commit is contained in:
dave 2018-09-22 18:01:33 -07:00
parent 346f0a7944
commit 3035d117b5
4 changed files with 156 additions and 67 deletions

View File

@ -3,7 +3,7 @@ import cherrypy
import logging import logging
from datetime import datetime, timedelta from datetime import datetime, timedelta
from photoapp.library import PhotoLibrary from photoapp.library import PhotoLibrary
from photoapp.types import Photo, PhotoSet, Tag, TagItem from photoapp.types import Photo, PhotoSet, Tag, TagItem, PhotoStatus
from jinja2 import Environment, FileSystemLoader, select_autoescape from jinja2 import Environment, FileSystemLoader, select_autoescape
from sqlalchemy import desc from sqlalchemy import desc
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
@ -15,17 +15,43 @@ APPROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "../"))
def auth(): def auth():
"""
Return the currently authorized username (per request) or None
"""
return cherrypy.session.get('authed', None) return cherrypy.session.get('authed', None)
def mime2ext(mime): def mime2ext(mime):
return {"image/png": "png", """
"image/jpeg": "jpg", Given a mime type return the canonical file extension
"image/gif": "gif", """
"application/octet-stream-xmp": "xmp", return {"image/png": "png",
"image/x-canon-cr2": "cr2", "image/jpeg": "jpg",
"video/mp4": "mp4", "image/gif": "gif",
"video/quicktime": "mov"}[mime] "application/octet-stream-xmp": "xmp",
"image/x-canon-cr2": "cr2",
"video/mp4": "mp4",
"video/quicktime": "mov"}[mime]
def require_auth(func):
"""
Decorator: raise 403 unless session is authed
"""
def wrapped(*args, **kwargs):
if not auth():
raise cherrypy.HTTPError(403)
return func(*args, **kwargs)
return wrapped
def photo_auth_filter(query):
"""
Sqlalchemy helper: filter the given PhotoSet query to items that match the authorized user's PhotoStatus access
level. Currently, authed users can access ALL photos, and unauthed users can access only PhotoStatus.public
status items.
"""
return query.filter(PhotoSet.status == PhotoStatus.public) if not auth() else query
class PhotosWeb(object): class PhotosWeb(object):
@ -57,11 +83,23 @@ class PhotosWeb(object):
Return a dict containing variables expected to be on every page Return a dict containing variables expected to be on every page
""" """
s = self.session() s = self.session()
# all tags / albums with photos visible under the current auth context
tagq = s.query(Tag).join(TagItem).join(PhotoSet)
if not auth():
tagq = tagq.filter(PhotoSet.status == PhotoStatus.public)
tagq = tagq.order_by(Tag.title).all() # pragma: manual auth
albumq = s.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.title).all() # pragma: manual auth
ret = { ret = {
"all_tags": s.query(Tag).order_by(Tag.title).all(), "all_tags": tagq,
"all_albums": s.query(Tag).filter(Tag.is_album == True).order_by(Tag.title).all(), "all_albums": albumq,
"path": cherrypy.request.path_info, "path": cherrypy.request.path_info,
"auth": auth() "auth": auth(),
"PhotoStatus": PhotoStatus
} }
s.close() s.close()
return ret return ret
@ -86,8 +124,9 @@ class PhotosWeb(object):
""" """
s = self.session() s = self.session()
page, pgsize = int(page), int(pgsize) page, pgsize = int(page), int(pgsize)
total_sets = s.query(func.count(PhotoSet.id)).first()[0] total_sets = photo_auth_filter(s.query(func.count(PhotoSet.id))).first()[0]
images = s.query(PhotoSet).order_by(PhotoSet.date.desc()).offset(pgsize * page).limit(pgsize).all() images = photo_auth_filter(s.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) yield self.render("feed.html", images=[i for i in images], page=page, pgsize=int(pgsize), total_sets=total_sets)
@cherrypy.expose @cherrypy.expose
@ -96,11 +135,11 @@ class PhotosWeb(object):
/stats - show server statistics /stats - show server statistics
""" """
s = self.session() s = self.session()
images = s.query(func.count(PhotoSet.uuid), images = photo_auth_filter(s.query(func.count(PhotoSet.uuid),
func.strftime('%Y', PhotoSet.date).label('year'), func.strftime('%Y', PhotoSet.date).label('year'),
func.strftime('%m', PhotoSet.date).label('month')). \ func.strftime('%m', PhotoSet.date).label('month'))). \
group_by('year', 'month').order_by(desc('year'), desc('month')).all() group_by('year', 'month').order_by(desc('year'), desc('month')).all()
tsize = s.query(func.sum(Photo.size)).scalar() tsize = photo_auth_filter(s.query(func.sum(Photo.size)).join(PhotoSet)).scalar() # pragma: manual auth
yield self.render("monthly.html", images=images, tsize=tsize) yield self.render("monthly.html", images=images, tsize=tsize)
@cherrypy.expose @cherrypy.expose
@ -110,12 +149,13 @@ class PhotosWeb(object):
TODO using so many coordinates is slow in the browser. dedupe them somehow. TODO using so many coordinates is slow in the browser. dedupe them somehow.
""" """
s = self.session() s = self.session()
query = s.query(PhotoSet).filter(PhotoSet.lat != 0, PhotoSet.lon != 0) query = photo_auth_filter(s.query(PhotoSet)).filter(PhotoSet.lat != 0, PhotoSet.lon != 0)
if i: if i:
query = query.filter(PhotoSet.uuid == i) query = query.filter(PhotoSet.uuid == i)
yield self.render("map.html", images=query.all(), zoom=int(zoom)) yield self.render("map.html", images=query.all(), zoom=int(zoom))
@cherrypy.expose @cherrypy.expose
@require_auth
def create_tags(self, fromdate=None, uuid=None, tag=None, newtag=None, remove=None): def create_tags(self, fromdate=None, uuid=None, tag=None, newtag=None, remove=None):
""" """
/create_tags - tag multiple items selected by day of photo /create_tags - tag multiple items selected by day of photo
@ -178,11 +218,12 @@ class PhotosWeb(object):
raise cherrypy.HTTPRedirect('/feed', 302) raise cherrypy.HTTPRedirect('/feed', 302)
@cherrypy.expose @cherrypy.expose
def sess(self): def logout(self):
""" """
/sess - TODO DELETE ME dump session contents for debugging purposes /login - enable super features by logging into the app
""" """
yield cherrypy.session['authed'] cherrypy.session.clear()
raise cherrypy.HTTPRedirect('/feed', 302)
@cherrypy.popargs('date') @cherrypy.popargs('date')
@ -201,20 +242,20 @@ class DateView(object):
pgsize = 100 pgsize = 100
dt = datetime.strptime(date, "%Y-%m-%d") dt = datetime.strptime(date, "%Y-%m-%d")
dt_end = dt + timedelta(days=1) dt_end = dt + timedelta(days=1)
total_sets = s.query(func.count(PhotoSet.id)). \ total_sets = photo_auth_filter(s.query(func.count(PhotoSet.id))). \
filter(and_(PhotoSet.date >= dt, PhotoSet.date < dt_end)).first()[0] filter(and_(PhotoSet.date >= dt, PhotoSet.date < dt_end)).first()[0]
images = s.query(PhotoSet).filter(and_(PhotoSet.date >= dt, images = photo_auth_filter(s.query(PhotoSet)).filter(and_(PhotoSet.date >= dt,
PhotoSet.date < dt_end)).order_by(PhotoSet.date). \ PhotoSet.date < dt_end)).order_by(PhotoSet.date). \
offset(page * pgsize).limit(pgsize).all() offset(page * pgsize).limit(pgsize).all()
yield self.master.render("date.html", page=page, pgsize=pgsize, total_sets=total_sets, yield self.master.render("date.html", page=page, pgsize=pgsize, total_sets=total_sets,
images=[i for i in images], date=dt) images=[i for i in images], date=dt)
return return
images = s.query(PhotoSet, func.strftime('%Y-%m-%d', images = photo_auth_filter(s.query(PhotoSet, func.strftime('%Y-%m-%d',
PhotoSet.date).label('gdate'), PhotoSet.date).label('gdate'),
func.count('photos.id'), func.count('photos.id'),
func.strftime('%Y', PhotoSet.date).label('year'), func.strftime('%Y', PhotoSet.date).label('year'),
func.strftime('%m', PhotoSet.date).label('month'), func.strftime('%m', PhotoSet.date).label('month'),
func.strftime('%d', PhotoSet.date).label('day')). \ func.strftime('%d', PhotoSet.date).label('day'))). \
group_by('gdate').order_by(desc('year'), 'month', 'day').all() group_by('gdate').order_by(desc('year'), 'month', 'day').all()
yield self.master.render("dates.html", images=images) yield self.master.render("dates.html", images=images)
@ -233,10 +274,12 @@ class ThumbnailView(object):
uuid = uuid.split(".")[0] uuid = uuid.split(".")[0]
s = self.master.session() s = self.master.session()
query = s.query(Photo).filter(Photo.set.has(uuid=uuid)) if item_type == "set" \ query = photo_auth_filter(s.query(Photo)).filter(Photo.set.has(uuid=uuid)) if item_type == "set" \
else s.query(Photo).filter(Photo.uuid == uuid) if item_type == "one" \ else photo_auth_filter(s.query(Photo)).filter(Photo.uuid == uuid) if item_type == "one" \
else None else None
assert query
# prefer making thumbs from jpeg to avoid loading large raws # prefer making thumbs from jpeg to avoid loading large raws
first = None first = None
best = None best = None
@ -248,7 +291,7 @@ class ThumbnailView(object):
break break
thumb_from = best or first thumb_from = best or first
if not thumb_from: if not thumb_from:
raise Exception("404") # TODO it right raise Exception("404")
# TODO some lock around calls to this based on uuid # TODO some lock around calls to this based on uuid
thumb_path = self.master.library.make_thumb(thumb_from, thumb_size) thumb_path = self.master.library.make_thumb(thumb_from, thumb_size)
if thumb_path: if thumb_path:
@ -272,7 +315,7 @@ class DownloadView(object):
s = self.master.session() s = self.master.session()
query = None if item_type == "set" \ query = None if item_type == "set" \
else s.query(Photo).filter(Photo.uuid == uuid) if item_type == "one" \ else photo_auth_filter(s.query(Photo)).filter(Photo.uuid == uuid) if item_type == "one" \
else None # TODO set download query else None # TODO set download query
item = query.first() item = query.first()
@ -296,9 +339,21 @@ class PhotoView(object):
def index(self, uuid): def index(self, uuid):
uuid = uuid.split(".")[0] uuid = uuid.split(".")[0]
s = self.master.session() s = self.master.session()
photo = s.query(PhotoSet).filter(PhotoSet.uuid == uuid).first() photo = photo_auth_filter(s.query(PhotoSet)).filter(PhotoSet.uuid == uuid).first()
yield self.master.render("photo.html", image=photo) yield self.master.render("photo.html", image=photo)
@cherrypy.expose
@require_auth
def op(self, uuid, op):
s = self.master.session()
photo = s.query(PhotoSet).filter(PhotoSet.uuid == uuid).first()
if op == "Make public":
photo.status = PhotoStatus.public
elif op == "Make private":
photo.status = PhotoStatus.private
s.commit()
raise cherrypy.HTTPRedirect('/photo/{}'.format(photo.uuid), 302)
@cherrypy.popargs('uuid') @cherrypy.popargs('uuid')
class TagView(object): class TagView(object):
@ -316,19 +371,22 @@ class TagView(object):
s = self.master.session() s = self.master.session()
if uuid == "untagged": if uuid == "untagged":
numphotos = s.query(func.count(PhotoSet.id)).filter(~PhotoSet.id.in_(s.query(TagItem.set_id))).scalar() numphotos = photo_auth_filter(s.query(func.count(PhotoSet.id))). \
photos = s.query(PhotoSet).filter(~PhotoSet.id.in_(s.query(TagItem.set_id))).\ filter(~PhotoSet.id.in_(s.query(TagItem.set_id))).scalar()
photos = photo_auth_filter(s.query(PhotoSet)).filter(~PhotoSet.id.in_(s.query(TagItem.set_id))).\
offset(page * pgsize).limit(pgsize).all() offset(page * pgsize).limit(pgsize).all()
yield self.master.render("untagged.html", images=photos, total_items=numphotos, pgsize=pgsize, page=page) yield self.master.render("untagged.html", images=photos, total_items=numphotos, pgsize=pgsize, page=page)
else: else:
tag = s.query(Tag).filter(Tag.uuid == uuid).first() tag = s.query(Tag).filter(Tag.uuid == uuid).first()
numphotos = s.query(func.count(Tag.id)).join(TagItem).join(PhotoSet).filter(Tag.uuid == uuid).scalar() numphotos = photo_auth_filter(s.query(func.count(Tag.id)).join(TagItem).join(PhotoSet)). \
photos = s.query(PhotoSet).join(TagItem).join(Tag).filter(Tag.uuid == uuid). \ filter(Tag.uuid == uuid).scalar()
photos = photo_auth_filter(s.query(PhotoSet)).join(TagItem).join(Tag).filter(Tag.uuid == uuid). \
order_by(PhotoSet.date.desc()).offset(page * pgsize).limit(pgsize).all() order_by(PhotoSet.date.desc()).offset(page * pgsize).limit(pgsize).all()
yield self.master.render("album.html", tag=tag, images=photos, yield self.master.render("album.html", tag=tag, images=photos,
total_items=numphotos, pgsize=pgsize, page=page) total_items=numphotos, pgsize=pgsize, page=page)
@cherrypy.expose @cherrypy.expose
@require_auth
def op(self, uuid, op): def op(self, uuid, op):
s = self.master.session() s = self.master.session()
tag = s.query(Tag).filter(Tag.uuid == uuid).first() tag = s.query(Tag).filter(Tag.uuid == uuid).first()

View File

@ -22,19 +22,29 @@ a {
position: relative; position: relative;
background: rgb(37, 42, 58); background: rgb(37, 42, 58);
text-align: center; text-align: center;
}
/* show the "Menu" button on phones */ .user-status {
#nav .nav-menu-button { padding: 25px 0px;
display: block; color: #fff;
top: 0.5em;
right: 0.5em; span {
position: absolute; font-family: monospace;
}
}
/* show the "Menu" button on phones */
.nav-menu-button {
display: block;
top: 0.5em;
right: 0.5em;
position: absolute;
}
&.active {
height: 80%;
}
} }
/* when "Menu" is clicked, the navbar should be 80% height */ /* when "Menu" is clicked, the navbar should be 80% height */
#nav.active {
height: 80%;
}
/* don't show the navigation items until the "Menu" button is clicked */ /* don't show the navigation items until the "Menu" button is clicked */
.nav-inner { .nav-inner {
display: none; display: none;
@ -45,24 +55,28 @@ a {
} }
#nav .pure-menu { #nav {
background: transparent; .pure-menu {
border: none; background: transparent;
text-align: left; border: none;
} text-align: left;
#nav .pure-menu-link:hover, }
#nav .pure-menu-link:focus {
.pure-menu-link:hover,
.pure-menu-link:focus {
background: rgb(55, 60, 90); background: rgb(55, 60, 90);
} }
#nav .pure-menu-link { .pure-menu-link {
color: #fff; color: #fff;
margin-left: 0.5em; margin-left: 0.5em;
} }
#nav .pure-menu-heading { .pure-menu-heading {
border-bottom: none; border-bottom: none;
font-size:110%; font-size:110%;
color: rgb(75, 113, 151); color: rgb(75, 113, 151);
} }
}
.tag-icon { .tag-icon {
width: 15px; width: 15px;

View File

@ -16,7 +16,7 @@
<li class="pure-menu-item"><a href="/" class="pure-menu-link">All photos</a></li> <li class="pure-menu-item"><a href="/" class="pure-menu-link">All photos</a></li>
<li class="pure-menu-item"><a href="/albums" class="pure-menu-link">Albums</a></li> <li class="pure-menu-item"><a href="/albums" class="pure-menu-link">Albums</a></li>
<li class="pure-menu-item"><a href="/date" class="pure-menu-link">Dates</a></li> <li class="pure-menu-item"><a href="/date" class="pure-menu-link">Dates</a></li>
<li class="pure-menu-item"><a href="/monthly" class="pure-menu-link">Stats</a></li> <li class="pure-menu-item"><a href="/stats" class="pure-menu-link">Stats</a></li>
<li class="pure-menu-item"><a href="/map" class="pure-menu-link">Map</a></li> <li class="pure-menu-item"><a href="/map" class="pure-menu-link">Map</a></li>
<li class="pure-menu-item"><a href="/tag/untagged" class="pure-menu-link">Untagged</a></li> <li class="pure-menu-item"><a href="/tag/untagged" class="pure-menu-link">Untagged</a></li>
<li class="pure-menu-item"><a href="/admin/trash" class="pure-menu-link">Trash</a></li> <li class="pure-menu-item"><a href="/admin/trash" class="pure-menu-link">Trash</a></li>
@ -30,6 +30,15 @@
{% endfor %} {% endfor %}
</ul> </ul>
</div> </div>
<div class="user-status">
{% if auth %}
<p>Authed as <span>{{ auth }}</span></p>
<p><a href="/logout">Log out</a></p>
{% else %}
<p>Browsing as a guest</p>
<p><a href="/login">Log in</a></p>
{% endif %}
</div>
</div> </div>
</div> </div>
<div id="main" class="pure-u-1"> <div id="main" class="pure-u-1">
@ -41,13 +50,15 @@
{{ subtitle }} {{ subtitle }}
</p> </p>
</div> </div>
<div class="email-content-controls pure-u-1-2"> {% if auth %}
{% block buttons %} <div class="email-content-controls pure-u-1-2">
<button class="secondary-button pure-button">420</button> {% block buttons %}
<button class="secondary-button pure-button">Blaze</button> <button class="secondary-button pure-button">420</button>
<button class="secondary-button pure-button">It</button> <button class="secondary-button pure-button">Blaze</button>
{% endblock %} <button class="secondary-button pure-button">It</button>
</div> {% endblock %}
</div>
{% endif %}
</div> </div>
<div class="email-content-body"> <div class="email-content-body">
{% block body %}default body{% endblock %} {% block body %}default body{% endblock %}

View File

@ -3,7 +3,13 @@
{% set subtitle = image.uuid %} {% set subtitle = image.uuid %}
{% block buttons %} {% block buttons %}
xxx <form action="/photo/{{ image.uuid }}/op" method="post">
{% if image.status == PhotoStatus.private %}
<input type="submit" class="secondary-button pure-button" name="op" value="Make public" />
{% else %}
<input type="submit" class="secondary-button pure-button" name="op" value="Make private" />
{% endif %}
</form>
{% endblock %} {% endblock %}
{% block body %} {% block body %}