2024-04-12 19:22:00 +00:00
# builtins
2024-04-12 19:22:00 +00:00
import time
2024-04-21 15:37:52 +00:00
import datetime
2024-04-12 19:22:00 +00:00
import functools
2024-04-12 19:22:00 +00:00
import hashlib
import secrets
2024-05-20 02:10:17 +00:00
import uuid
2024-04-12 19:22:00 +00:00
# third-party
2024-04-12 19:22:00 +00:00
import werkzeug , flask
2024-04-12 19:22:00 +00:00
import peewee , playhouse . postgres_ext
2024-05-20 02:10:17 +00:00
import OpenSSL as openssl
2024-04-12 19:22:00 +00:00
# internals
from application import app
import util
import rendering
import form
import database
2024-05-02 18:57:38 +00:00
import markdown
2024-04-12 19:22:00 +00:00
2024-04-12 19:22:00 +00:00
# static salt for fake hash comparison; this is part of timing sidechannel
# mitigation. generated once at boot so it doesn't take extra time to generate
# when faking a login check for a non-existing user.
__fake_salt__ = secrets . token_hex ( 64 )
@app.before_request
def request_setup ( ) :
2024-05-20 02:10:17 +00:00
client_cert_verified = flask . request . environ . get ( ' SSL_CLIENT_VERIFY ' )
client_cert_raw = flask . request . environ . get ( ' SSL_CLIENT_CERT ' )
client_cert = None
client_cert_info = None
flask . g . user = None
flask . g . tls_cipher = flask . request . environ . get ( ' SSL_CIPHER ' )
flask . g . client_cert_verified = False
2024-05-21 03:58:28 +00:00
flask . g . client_cert_fingerprint = None # SHA2-512 (without ':' separators)
2024-05-20 02:10:17 +00:00
flask . g . client_cert_fingerprint_matched = False
2024-05-21 03:58:28 +00:00
flask . g . client_cert_info = None # ClientCert object
2024-05-20 02:10:17 +00:00
if client_cert_verified == ' SUCCESS ' :
flask . g . client_cert_verified = True
if not client_cert_raw :
app . logger . error ( " Successful upstream client cert authentication, but not certificate given (fix nginx.conf!) " )
else :
try :
client_cert = openssl . crypto . load_certificate ( openssl . crypto . FILETYPE_PEM , client_cert_raw )
except Exception as e :
if app . debug :
raise
app . logger . error ( f " Error while loading client certificate: { str ( e ) } " )
else :
fingerprint = client_cert . digest ( ' sha512 ' ) . replace ( b ' : ' , b ' ' )
flask . g . client_cert_fingerprint = fingerprint . decode ( ' ascii ' )
try :
client_cert_info = ClientCert . get ( ClientCert . fingerprint == fingerprint )
2024-05-21 03:58:28 +00:00
flask . g . client_cert_info = client_cert_info
2024-05-20 02:10:17 +00:00
flask . g . client_cert_fingerprint_matched = True
except ClientCert . DoesNotExist as e :
app . logger . error ( f " Can ' t find certificate info for fingerprint ' { fingerprint } ' in database. Dropped cert or user? Error: { str ( e ) } " )
except Exception as e :
if app . debug :
raise
app . logger . error ( f " Generic exception when trying to load client certificate info from database: { str ( e ) } " )
else :
if ' uid ' in flask . session :
try :
if client_cert_info . user . id == flask . session [ ' uid ' ] :
flask . g . user = client_cert_info . user
except User . DoesNotExist :
flask . flash ( " You did done got deleted. " , ' error ' )
del flask . session [ ' uid ' ]
except Exception :
# silently fail all other exception types. this means that only
# consequent database failures will trigger an error page, but
# routes that don't use the database at all, will still work.
# this is needed so things like theme asset delivery work when
# there's a database problem.
del flask . session [ ' uid ' ]
2024-04-12 19:22:00 +00:00
def auth ( f ) :
@functools.wraps ( f )
def wrapper ( * args , * * kwargs ) :
if isinstance ( flask . g . user , User ) :
return f ( * args , * * kwargs )
flask . abort ( 403 ) # raises HTTPException
return wrapper
class User ( database . RenderableModel ) :
name = peewee . CharField ( null = False , unique = True , verbose_name = ' Name ' )
password = peewee . CharField ( null = False , verbose_name = ' Password ' )
salt = peewee . CharField ( null = False )
@classmethod
2024-05-20 02:10:17 +00:00
def load ( cls , name ) :
return cls . select ( ) . where ( cls . name == name ) . get ( )
@classmethod
def load_by_id ( cls , id ) :
2024-04-12 19:22:00 +00:00
return cls . select ( ) . where ( cls . id == id ) . get ( )
@classmethod
def __gen_salt__ ( cls ) :
return secrets . token_hex ( 64 ) # 64 *byte* (512 bit), so string length of 128 as hex
@classmethod
def __gen_hash__ ( cls , salt , password ) :
iterations = 100000 # ~50ms at ~5GHz
hash = hashlib . sha3_512 ( bytes ( app . config [ ' PEPPER ' ] + salt + password , ' UTF-8 ' ) )
for i in range ( 1 , iterations ) :
hash = hashlib . sha3_512 ( hash . digest ( ) )
return hash . hexdigest ( )
@classmethod
def login ( cls , name , password ) :
r = False
time_rand = secrets . randbelow ( 100 ) # random delay to mitigate timing sidechannel
try :
user = cls . select ( ) . where ( cls . name == name ) . get ( )
if user . password == cls . __gen_hash__ ( user . salt , password ) :
r = user
except cls . DoesNotExist :
# TODO: benchmark this to see whether we need an extra delay
cls . __gen_hash__ ( __fake_salt__ , password )
time . sleep ( time_rand / 1000 )
return r
@classmethod
def register ( cls , name , password ) :
user = cls ( )
user . name = name
user . salt = cls . __gen_salt__ ( )
user . password = cls . __gen_hash__ ( user . salt , password )
user . save ( force_insert = True )
return user
2024-05-20 02:10:17 +00:00
@property
def notifications_unread ( self ) :
return self . notifications . where ( Notification . read == False )
2024-05-02 18:57:38 +00:00
def notify ( self , message ) :
n = Notification ( to = self , message = message )
return n . save ( force_insert = True )
2024-05-20 21:33:18 +00:00
def update_password ( self , password ) :
self . salt = self . __gen_salt__ ( ) # new random salt
self . password = self . __gen_hash__ ( self . salt , password )
return self . save ( )
2024-05-20 02:10:17 +00:00
def gen_keypair_and_clientcert ( self , certname , not_after ) :
invalid_after = datetime . datetime . utcnow ( ) + app . config [ ' CLIENTCERT_MAX_LIFETIME ' ]
if not_after > invalid_after :
raise exceptions . ExposedException ( f " not_after too far into the future maximum allowed is { str ( invalid_after ) } but got { str ( not_after ) } . " )
common_name = f ' { self . name } : { certname } @ { app . config [ " SITE_NAME " ] } '
fd = open ( ' ca/key.pem ' , ' rb ' )
ca_key = openssl . crypto . load_privatekey ( openssl . crypto . FILETYPE_PEM , fd . read ( ) )
fd . close ( )
del fd
fd = open ( ' ca/cert.pem ' , ' rb ' )
ca_cert = openssl . crypto . load_certificate ( openssl . crypto . FILETYPE_PEM , fd . read ( ) )
fd . close ( )
del fd
keypair = openssl . crypto . PKey ( )
keypair . generate_key ( openssl . crypto . TYPE_RSA , app . config [ ' CLIENTCERT_KEYLENGTH ' ] )
extensions = [
openssl . crypto . X509Extension ( b ' keyUsage ' , True , b ' digitalSignature, keyEncipherment, keyAgreement ' ) ,
openssl . crypto . X509Extension ( b ' extendedKeyUsage ' , True , b ' clientAuth ' ) ,
]
cert = openssl . crypto . X509 ( )
cert . set_version ( 2 ) # actually means 3, openssl bad
cert . add_extensions ( extensions )
cert . set_issuer ( ca_cert . get_subject ( ) )
cert . set_pubkey ( keypair )
cert . gmtime_adj_notBefore ( 0 ) # now
cert . set_notAfter ( not_after . strftime ( ' % Y % m %d % H % M % SZ ' ) . encode ( ' utf-8 ' ) )
cert . set_serial_number ( uuid . uuid4 ( ) . int ) # random uuid as int
cert . get_subject ( ) . CN = common_name . encode ( ' utf-8 ' )
cert . sign ( ca_key , ' sha512 ' )
pkcs12 = openssl . crypto . PKCS12 ( )
pkcs12 . set_ca_certificates ( [ ca_cert ] )
pkcs12 . set_privatekey ( keypair )
pkcs12 . set_certificate ( cert )
pkcs12 . set_friendlyname ( certname . encode ( ' utf-8 ' ) )
return pkcs12
class ClientCert ( database . Model ) :
class Meta :
indexes = (
( ( ' user_id ' , ' name ' ) , True ) , # unique constraint for (user, name)
)
2024-05-20 21:33:18 +00:00
user = peewee . ForeignKeyField ( User , backref = ' clientcerts ' , on_delete = ' CASCADE ' )
2024-05-20 02:10:17 +00:00
name = peewee . CharField ( null = False )
fingerprint = peewee . CharField ( null = False )
2024-05-02 18:57:38 +00:00
2024-04-12 19:22:00 +00:00
class AutoForm ( form . Form ) :
2024-04-12 19:22:00 +00:00
2024-04-12 19:22:00 +00:00
def __init__ ( self , administerable , mode , * args , * * kwargs ) :
super ( ) . __init__ ( * args , * * kwargs )
self . administerable = administerable
2024-06-11 18:31:28 +00:00
self . menu = administerable . menu
2024-04-12 19:22:00 +00:00
self . mode = mode
2024-04-12 19:22:00 +00:00
if mode == ' delete ' :
2024-04-12 19:22:00 +00:00
2024-04-12 19:22:00 +00:00
self . intro = f " You are about to delete this { self . administerable . __class__ . __name__ } , this cannot be undone. Proceed? "
2024-05-31 14:41:36 +00:00
self . buttons [ mode ] = form . Button ( label = ' Yes, delete ' )
2024-04-12 19:22:00 +00:00
2024-04-12 19:22:00 +00:00
else :
for field_name in administerable . __class__ . _meta . columns . keys ( ) :
if field_name not in administerable . __class__ . autoform_blacklist :
2024-05-31 14:41:36 +00:00
self [ field_name ] = administerable . form_field ( field_name )
2024-04-12 19:22:00 +00:00
2024-04-21 14:02:58 +00:00
button_label = f ' Create { administerable . _lowerclass } ' if mode == ' create ' else f ' Save { administerable . _lowerclass } '
2024-05-31 14:41:36 +00:00
self . buttons [ mode ] = form . Button ( label = button_label )
2024-04-12 19:22:00 +00:00
2024-06-01 13:43:31 +00:00
if isinstance ( administerable , Named ) :
self . fields . position ( ' name ' , 0 )
2024-04-17 17:30:52 +00:00
def process ( self , submit ) :
2024-04-12 19:22:00 +00:00
2024-05-23 23:48:49 +00:00
if self . valid :
2024-04-12 19:22:00 +00:00
2024-05-23 23:48:49 +00:00
processable_fields = [ field for field in self . fields . values ( ) if not field . name in self . administerable . __class__ . autoform_blacklist ]
for field in processable_fields :
2024-04-12 19:22:00 +00:00
2024-04-17 17:30:52 +00:00
if not isinstance ( field , form . Fieldset ) and not isinstance ( field . value , werkzeug . datastructures . FileStorage ) : # FIXME: hacky way to preserve old filename value for Upload
2024-04-12 19:22:00 +00:00
setattr ( self . administerable , field . name , field . value )
2024-05-23 23:48:49 +00:00
for field in processable_fields :
2024-04-12 19:22:00 +00:00
2024-04-12 19:22:00 +00:00
# call extra field handling (usually a no-op)
2024-04-12 19:22:00 +00:00
# doing this in an extra loop so form_field_handle
# can depend on properties in administerable being filled
self . administerable . form_field_handle ( field , self . mode )
2024-04-12 19:22:00 +00:00
2024-04-12 19:22:00 +00:00
try :
if self . mode == ' create ' :
self . administerable . save ( force_insert = True )
2024-04-12 19:22:00 +00:00
elif self . mode == ' edit ' :
2024-04-12 19:22:00 +00:00
self . administerable . save ( )
2024-04-12 19:22:00 +00:00
elif self . mode == ' delete ' :
self . administerable . delete_instance ( )
2024-04-12 19:22:00 +00:00
except peewee . IntegrityError as e :
2024-04-12 19:22:00 +00:00
2024-04-12 19:22:00 +00:00
diag = e . orig . diag # psycopg2 diagnostic object
2024-04-12 19:22:00 +00:00
2024-04-12 19:22:00 +00:00
if diag . source_function == ' _bt_check_unique ' : # we're dealing with a unique constraint
2024-04-12 19:22:00 +00:00
2024-04-12 19:22:00 +00:00
# assuming <tablename>_<fieldname> for constraint_name
parts = diag . constraint_name . split ( ' _ ' , 1 )
if len ( parts ) == 2 : # ignore constraint names without underscores
tablename , fieldname = parts
if tablename == self . administerable . _lowerclass and fieldname in self . fields :
2024-05-31 14:41:36 +00:00
field = self [ fieldname ]
2024-04-12 19:22:00 +00:00
error = form . ValidationError ( f " { field . label } must be unique. " )
field . errors . append ( error )
self . errors . append ( error )
flask . flash ( " A problem occured when saving. " , ' error ' )
app . logger . warning ( f " IntegrityError when saving object from AutoForm: { str ( e ) } " )
else :
if self . mode == ' create ' :
flask . flash ( f " Successfully created new { self . administerable . _lowerclass } . " , ' success ' )
return flask . redirect ( self . administerable . url ( ' edit ' ) )
2024-04-12 19:22:00 +00:00
elif self . mode == ' edit ' :
2024-04-12 19:22:00 +00:00
flask . flash ( f " Successfully saved { self . administerable . _lowerclass } . " , ' success ' )
2024-04-12 19:22:00 +00:00
return flask . redirect ( self . administerable . url ( ' edit ' ) ) # makes sure we get to the right url even if id/name changed
elif self . mode == ' delete ' :
flask . flash ( f " Successfully deleted { self . administerable . _lowerclass } . " , ' success ' )
return flask . redirect ( flask . url_for ( f ' admin. { self . administerable . _lowerclass } _listing ' ) ) # TODO: make this cleaner, implement cls.url_listing?
2024-04-12 19:22:00 +00:00
@app.abstract
2024-04-12 19:22:00 +00:00
class Administerable ( database . RenderableModel ) :
2024-04-12 19:22:00 +00:00
id = playhouse . postgres_ext . IdentityField ( generate_always = True , verbose_name = ' ID ' )
2024-04-12 19:22:00 +00:00
autoform_blacklist = ( ' id ' , )
2024-04-12 19:22:00 +00:00
autoform_class = AutoForm
2024-04-12 19:22:00 +00:00
@classmethod
def load ( cls , id ) :
return cls . select ( ) . where ( cls . id == id ) . get ( )
@classmethod
def list ( cls ) :
2024-05-21 05:31:13 +00:00
return cls . select ( ) . order_by ( cls . id . desc ( ) )
2024-04-12 19:22:00 +00:00
@classmethod
def view ( cls , mode = ' full ' , * * kwargs ) :
2024-04-12 19:22:00 +00:00
if mode in ( ' create ' , ' edit ' , ' delete ' ) :
2024-04-12 19:22:00 +00:00
@rendering.page ( mode = mode )
def wrapped ( * * kw ) :
2024-04-17 17:30:52 +00:00
if mode == ' create ' :
instance = cls ( )
else :
instance = cls . load ( kw [ ' id ' ] )
2024-07-13 21:09:48 +00:00
if not instance . access ( mode ) :
flask . abort ( 404 )
2024-04-12 19:22:00 +00:00
form = instance . form ( mode )
2024-04-12 19:22:00 +00:00
if flask . request . method == ' POST ' :
2024-04-12 19:22:00 +00:00
redirect = form . handle ( )
if isinstance ( redirect , werkzeug . Response ) :
return redirect
2024-04-12 19:22:00 +00:00
return form
else :
2024-06-03 15:50:44 +00:00
@rendering.page ( mode = mode , title_show = False ) # show title in own template, gives better flexibility
2024-04-12 19:22:00 +00:00
def wrapped ( * * kw ) :
2024-07-13 21:09:48 +00:00
instance = cls . load ( kw [ ' id ' ] )
if not instance . access ( mode ) :
flask . abort ( 404 )
return instance
2024-04-12 19:22:00 +00:00
return wrapped ( * * kwargs )
2024-04-12 19:22:00 +00:00
@property
2024-06-11 18:31:28 +00:00
def menu ( self ) :
2024-04-12 19:22:00 +00:00
2024-05-08 17:56:54 +00:00
if self . is_in_db and flask . g . user :
2024-04-12 19:22:00 +00:00
# TODO: make this work with .url() in a way that doesn't break trail detection in Menu
items = [ ]
if self . __class__ in app . models_exposed . values ( ) :
# full view added by @expose
items . append ( {
' endpoint ' : f ' { self . _lowerclass } _full ' ,
' params ' : { ' id ' : self . id } ,
' label ' : ' View ' ,
} )
# admin routes automatically added by register_admin
items . extend ( [
{
' endpoint ' : f ' admin. { self . _lowerclass } _edit ' ,
' params ' : { ' id ' : self . id } ,
' label ' : ' Edit ' ,
} ,
{
' endpoint ' : f ' admin. { self . _lowerclass } _delete ' ,
' params ' : { ' id ' : self . id } ,
' label ' : ' Delete ' ,
}
] )
return rendering . Menu (
2024-10-14 14:54:17 +00:00
f ' { self . _lowerclass } - { self . id } -tabs ' ,
2024-04-12 19:22:00 +00:00
items ,
2024-06-29 03:36:26 +00:00
extra_classes = [ f ' menu- { self . _lowerclass } ' , ' tabs ' ]
2024-04-12 19:22:00 +00:00
)
@property
def is_in_db ( self ) :
return bool ( self . id )
2024-04-12 19:22:00 +00:00
2024-07-13 21:09:48 +00:00
def access ( self , mode = ' full ' ) :
if isinstance ( flask . g . user , User ) :
return True
# access allowed for anything but a list of "privileged" modes
return mode not in ( ' create ' , ' edit ' , ' delete ' , ' admin-teaser ' )
2024-04-12 19:22:00 +00:00
def url ( self , mode = ' full ' ) :
2024-04-12 19:22:00 +00:00
if mode == ' full ' :
2024-04-12 19:22:00 +00:00
if self . __class__ not in app . models_exposed . values ( ) :
raise TypeError ( " Use of .url(mode= ' full ' ) is only possible on @exposed classes. " )
2024-04-12 19:22:00 +00:00
return flask . url_for ( f " { self . _lowerclass } _full " , id = self . id )
2024-04-12 19:22:00 +00:00
2024-04-12 19:22:00 +00:00
elif mode == ' edit ' :
2024-04-12 19:22:00 +00:00
2024-04-12 19:22:00 +00:00
return flask . url_for ( f " admin. { self . _lowerclass } _edit " , id = self . id )
2024-04-12 19:22:00 +00:00
2024-04-12 19:22:00 +00:00
elif mode == ' create ' :
2024-04-12 19:22:00 +00:00
2024-04-12 19:22:00 +00:00
return flask . url_for ( f " admin. { self . _lowerclass } _create " )
2024-04-12 19:22:00 +00:00
2024-04-12 19:22:00 +00:00
elif mode == ' teaser ' :
2024-04-12 19:22:00 +00:00
2024-04-12 19:22:00 +00:00
return flask . url_for ( f " { cls . _lowerclass } _listing " ) # TODO: pagination
2024-04-29 16:16:00 +00:00
def form ( self , mode , * * kwargs ) :
2024-05-02 18:57:38 +00:00
if ' id ' not in kwargs :
kwargs [ ' id ' ] = f ' { self . _lowerclass } - { mode } '
if ' title ' not in kwargs :
kwargs [ ' title ' ] = f " { mode . capitalize ( ) } { self . __class__ . __name__ } "
return self . autoform_class ( self , mode , * * kwargs )
2024-04-12 19:22:00 +00:00
2024-04-12 19:22:00 +00:00
def form_field ( self , field_name ) :
db_field = getattr ( self . __class__ , field_name )
field_label = db_field . verbose_name or field_name
field_help = db_field . help_text
field_value = getattr ( self , field_name )
field_required = not db_field . null
if isinstance ( db_field , peewee . ForeignKeyField ) :
2024-05-31 14:41:36 +00:00
return form . ForeignKeySelect ( db_field , label = field_label , value = field_value , help = field_help , required = field_required )
2024-04-12 19:22:00 +00:00
if isinstance ( db_field , peewee . IntegerField ) :
2024-05-31 14:41:36 +00:00
return form . Integer ( label = field_label , value = field_value , help = field_help , required = field_required )
2024-04-12 19:22:00 +00:00
2024-06-04 11:06:58 +00:00
if isinstance ( db_field , peewee . FloatField ) :
return form . Float ( label = field_label , value = field_value , help = field_help , required = field_required )
2024-04-12 19:22:00 +00:00
if isinstance ( db_field , peewee . TextField ) :
2024-05-31 14:41:36 +00:00
return form . TextArea ( label = field_label , value = field_value , help = field_help , required = field_required )
2024-04-12 19:22:00 +00:00
2024-06-04 11:06:58 +00:00
if isinstance ( db_field , peewee . CharField ) :
return form . Text ( label = field_label , value = field_value , help = field_help , required = field_required )
2024-04-12 19:22:00 +00:00
if isinstance ( db_field , peewee . BooleanField ) :
# required for checkbox means it MUST be checked (forbidding unchecked, i.e. False)
2024-05-31 14:41:36 +00:00
return form . Checkbox ( label = field_label , value = field_value , help = field_help , required = False )
2024-04-12 19:22:00 +00:00
2024-06-04 11:06:58 +00:00
if isinstance ( db_field , peewee . DateField ) :
return form . DateField ( label = field_label , value = field_value , help = field_help , required = field_required )
if isinstance ( db_field , peewee . DateTimeField ) :
return form . DateTime ( label = field_label , value = field_value , help = field_help , required = field_required )
2024-04-12 19:22:00 +00:00
# fallback
2024-04-12 19:22:00 +00:00
app . logger . warning ( f " AutoForm using fallback field construction for { self . __class__ . __name__ } . { field_name } " )
2024-05-31 14:41:36 +00:00
return form . Text ( label = field_label , value = field_value , help = field_help , required = field_required )
2024-04-12 19:22:00 +00:00
2024-04-12 19:22:00 +00:00
def form_field_handle ( self , field , mode ) :
2024-04-12 19:22:00 +00:00
pass
2024-05-02 18:57:38 +00:00
class Notification ( database . RenderableModel ) :
to = peewee . ForeignKeyField ( User , backref = ' notifications ' )
created = peewee . DateTimeField ( default = datetime . datetime . utcnow , null = False )
read = peewee . BooleanField ( default = False , null = False )
2024-06-15 14:19:08 +00:00
message = markdown . MarkdownTextField ( )
2024-05-02 18:57:38 +00:00
2024-04-12 19:22:00 +00:00
@app.abstract
class Named ( Administerable ) :
2024-04-23 09:01:18 +00:00
name = peewee . CharField ( unique = True , null = False , verbose_name = ' Name ' , constraints = [ peewee . Check ( ' " name " ~ \' ^[a-z0-9_ \ -]+$ \' ' ) ] )
2024-04-12 19:22:00 +00:00
@classmethod
def load ( cls , name ) :
return cls . select ( ) . where ( cls . name == name ) . get ( )
@classmethod
def view ( cls , mode = ' full ' , * * kwargs ) :
2024-04-12 19:22:00 +00:00
if mode in ( ' create ' , ' edit ' , ' delete ' ) :
2024-04-12 19:22:00 +00:00
@rendering.page ( mode = mode )
def wrapped ( * * kw ) :
if mode == ' create ' :
instance = cls ( )
else :
instance = cls . load ( kw [ ' name ' ] )
2024-07-13 21:09:48 +00:00
if not instance . access ( mode ) :
flask . abort ( 404 )
2024-04-12 19:22:00 +00:00
form = instance . form ( mode )
2024-04-12 19:22:00 +00:00
if flask . request . method == ' POST ' :
redirect = form . handle ( )
for error in form . errors :
flask . flash ( str ( error ) , ' error ' )
if isinstance ( redirect , werkzeug . Response ) :
return redirect
return form
else :
2024-06-03 15:50:44 +00:00
@rendering.page ( mode = mode , title_show = False ) # show title in own template, gives better flexibility
2024-04-12 19:22:00 +00:00
def wrapped ( * * kw ) :
2024-07-13 21:09:48 +00:00
instance = cls . load ( kw [ ' name ' ] )
if not instance . access ( mode ) :
flask . abort ( 404 )
return instance
2024-04-12 19:22:00 +00:00
return wrapped ( * * kwargs )
2024-04-12 19:22:00 +00:00
@property
2024-06-11 18:31:28 +00:00
def menu ( self ) :
2024-04-12 19:22:00 +00:00
2024-05-08 17:56:54 +00:00
if self . is_in_db and flask . g . user :
2024-04-12 19:22:00 +00:00
# TODO: make this work with .url() in a way that doesn't break trail detection in Menu
items = [ ]
if self . __class__ in app . models_exposed . values ( ) :
# full view added by @expose
items . append ( {
' endpoint ' : f ' { self . _lowerclass } _full ' ,
' params ' : { ' name ' : self . name } ,
' label ' : ' View ' ,
} )
# admin routes automatically added by register_admin
items . extend ( [
{
' endpoint ' : f ' admin. { self . _lowerclass } _edit ' ,
' params ' : { ' name ' : self . name } ,
' label ' : ' Edit ' ,
} ,
{
' endpoint ' : f ' admin. { self . _lowerclass } _delete ' ,
' params ' : { ' name ' : self . name } ,
' label ' : ' Delete ' ,
}
] )
return rendering . Menu (
2024-10-14 14:54:17 +00:00
f ' { self . _lowerclass } - { self . id } -tabs ' ,
2024-04-12 19:22:00 +00:00
items ,
2024-06-29 03:36:26 +00:00
extra_classes = [ f ' menu- { self . _lowerclass } ' , ' tabs ' ]
2024-04-12 19:22:00 +00:00
)
2024-04-12 19:22:00 +00:00
def url ( self , mode = ' full ' ) :
if mode in ( ' teaser ' , ' full ' ) and self . __class__ not in app . models_exposed . values ( ) :
raise TypeError ( " Trying to get public URL for non-exposed model {self.__class__.__name__} . " )
if mode == ' full ' :
return flask . url_for ( f " { self . _lowerclass } _full " , name = self . name )
elif mode == ' edit ' :
return flask . url_for ( f " admin. { self . _lowerclass } _edit " , name = self . name )
elif mode == ' create ' :
return flask . url_for ( f " admin. { self . _lowerclass } _create " )
elif mode == ' teaser ' :
return flask . url_for ( f " { self . _lowerclass } _listing " ) # TODO: pagination
2024-04-23 09:01:18 +00:00
def form_field ( self , field_name ) :
f = super ( ) . form_field ( field_name )
if field_name == ' name ' :
f . validators . append (
functools . partial ( form . validate_regexp , r ' ^[a-z0-9_ \ -]+$ ' )
)
return f
2024-04-12 19:22:00 +00:00
def register_administerable ( cls ) :
2024-04-12 19:22:00 +00:00
# not in register_admin below so view functions are in a new scope
# for each model (otherwise the last view function definitions would
2024-04-12 19:22:00 +00:00
# get registered to all routes).
2024-04-12 19:22:00 +00:00
@auth
2024-04-12 19:22:00 +00:00
@rendering.page ( title_show = False )
2024-04-12 19:22:00 +00:00
def view_listing ( ) :
2024-04-12 19:22:00 +00:00
2024-10-14 14:54:17 +00:00
menu = rendering . Menu (
f ' { cls . _lowerclass } -listing-actions ' ,
[ {
2024-04-12 19:22:00 +00:00
' endpoint ' : f ' admin. { cls . _lowerclass } _create ' ,
' label ' : ' Create new ' ,
2024-10-14 14:54:17 +00:00
} ]
)
2024-04-12 19:22:00 +00:00
return rendering . Listing ( cls . select ( ) , title = cls . __name__ , mode = ' admin-teaser ' , menu = menu )
2024-04-12 19:22:00 +00:00
@auth
def view_create ( * args , * * kwargs ) :
kwargs [ ' mode ' ] = ' create '
return cls . view ( * args , * * kwargs )
2024-04-12 19:22:00 +00:00
2024-04-12 19:22:00 +00:00
@auth
def view_edit ( * args , * * kwargs ) :
kwargs [ ' mode ' ] = ' edit '
return cls . view ( * args , * * kwargs )
2024-04-12 19:22:00 +00:00
@auth
def view_delete ( * args , * * kwargs ) :
kwargs [ ' mode ' ] = ' delete '
return cls . view ( * args , * * kwargs )
2024-04-12 19:22:00 +00:00
app . admin . add_url_rule ( f ' / { cls . _lowerclass } / ' , f ' { cls . _lowerclass } _listing ' , view_listing )
app . admin . add_url_rule ( f ' / { cls . _lowerclass } /create/ ' , f ' { cls . _lowerclass } _create ' , view_create , methods = [ ' GET ' , ' POST ' ] )
2024-04-12 19:22:00 +00:00
if issubclass ( cls , Named ) :
2024-04-12 19:22:00 +00:00
app . admin . add_url_rule ( f ' / { cls . _lowerclass } /<string:name>/ ' , f ' { cls . _lowerclass } _edit ' , view_edit , methods = [ ' GET ' , ' POST ' ] )
2024-04-12 19:22:00 +00:00
app . admin . add_url_rule ( f ' / { cls . _lowerclass } /<string:name>/delete/ ' , f ' { cls . _lowerclass } _delete ' , view_delete , methods = [ ' GET ' , ' POST ' ] )
2024-04-12 19:22:00 +00:00
else :
2024-04-12 19:22:00 +00:00
app . admin . add_url_rule ( f ' / { cls . _lowerclass } /<int:id>/ ' , f ' { cls . _lowerclass } _edit ' , view_edit , methods = [ ' GET ' , ' POST ' ] )
2024-04-12 19:22:00 +00:00
app . admin . add_url_rule ( f ' / { cls . _lowerclass } /<int:id>/delete ' , f ' { cls . _lowerclass } _delete ' , view_delete , methods = [ ' GET ' , ' POST ' ] )
2024-04-12 19:22:00 +00:00
2024-05-21 05:31:13 +00:00
class Dashboard ( rendering . Renderable ) :
2024-06-29 03:36:26 +00:00
def __init__ ( self , * * kwargs ) :
super ( ) . __init__ ( * * kwargs )
2024-05-21 05:31:13 +00:00
self . commentables = { }
Commentable = Administerable . __class_descendants__ [ ' Commentable ' ] # avoids circular dependency
2024-06-15 14:19:08 +00:00
Upload = Administerable . __class_descendants__ [ ' Upload ' ]
2024-05-21 05:31:13 +00:00
2024-06-15 14:19:08 +00:00
preview_classes = list ( Commentable . __class_descendants__ . values ( ) )
preview_classes . extend ( Upload . __class_descendants__ . values ( ) )
preview_classes . append ( Administerable . __class_descendants__ [ ' Gallery ' ] )
for cls in preview_classes :
2024-04-12 19:22:00 +00:00
2024-05-21 05:31:13 +00:00
if cls not in app . models_abstract :
2024-05-02 18:57:38 +00:00
2024-05-21 05:31:13 +00:00
info = { }
2024-10-14 14:54:17 +00:00
info [ ' menu ' ] = rendering . Menu (
f ' dashboard-preview-actions- { cls . _lowerclass } ' ,
[
{
' endpoint ' : f ' admin. { cls . _lowerclass } _listing ' ,
' label ' : cls . __name__ ,
} ,
{
' endpoint ' : f ' admin. { cls . _lowerclass } _create ' ,
' label ' : ' Create new ' ,
} ,
]
)
2024-05-02 18:57:38 +00:00
2024-06-15 14:19:08 +00:00
if hasattr ( cls , ' created ' ) :
info [ ' listing ' ] = rendering . Listing ( cls . select ( ) . order_by ( cls . created . desc ( ) ) . limit ( 3 ) )
else :
info [ ' listing ' ] = rendering . Listing ( cls . select ( ) . order_by ( cls . id . desc ( ) ) . limit ( 3 ) )
2024-05-02 18:57:38 +00:00
2024-05-21 05:31:13 +00:00
self . commentables [ cls . _lowerclass ] = info
2024-10-14 14:54:17 +00:00
self . menu_others = rendering . Menu ( ' dashboard-others ' )
2024-05-21 05:31:13 +00:00
for cls in Administerable . __class_descendants__ . values ( ) :
2024-06-15 14:19:08 +00:00
if cls not in app . models_abstract and cls not in preview_classes :
2024-05-21 05:31:13 +00:00
self . menu_others . append (
endpoint = f ' admin. { cls . _lowerclass } _listing ' ,
label = cls . __name__
)
@app.admin.route ( ' / ' )
@auth
@rendering.page ( )
def admin_home ( ) :
2024-05-02 18:57:38 +00:00
2024-05-21 05:31:13 +00:00
return Dashboard ( )
2024-04-12 19:22:00 +00:00
2024-04-12 19:22:00 +00:00
@app.admin.route ( ' /login/ ' , methods = [ ' GET ' , ' POST ' ] )
@rendering.page ( )
def admin_login ( ) :
2024-05-21 03:58:28 +00:00
if not flask . g . client_cert_verified :
flask . abort ( 403 )
2024-04-12 19:22:00 +00:00
2024-05-21 03:58:28 +00:00
elif not flask . g . client_cert_fingerprint_matched :
2024-04-12 19:22:00 +00:00
2024-05-21 03:58:28 +00:00
flask . abort ( 400 )
2024-04-12 19:22:00 +00:00
2024-05-21 03:58:28 +00:00
else :
2024-04-12 19:22:00 +00:00
2024-05-21 03:58:28 +00:00
f = form . Form ( title = ' Login ' )
2024-04-12 19:22:00 +00:00
2024-05-31 14:41:36 +00:00
#f['name'] = form.Text(label='Name', required=True)
f [ ' password ' ] = form . Password ( label = ' Password ' , required = True )
f . buttons [ ' login ' ] = form . Button ( name = ' login ' , label = ' Log in ' )
2024-04-12 19:22:00 +00:00
2024-05-21 03:58:28 +00:00
if flask . request . method == ' POST ' :
2024-04-12 19:22:00 +00:00
2024-05-21 03:58:28 +00:00
f . handle ( ) # binds and validates field values
2024-04-12 19:22:00 +00:00
2024-05-31 14:41:36 +00:00
#name = f['name'].value
2024-05-21 03:58:28 +00:00
name = flask . g . client_cert_info . user . name
2024-05-31 14:41:36 +00:00
password = f [ ' password ' ] . value
2024-05-21 03:58:28 +00:00
user = User . login ( name , password )
2024-04-12 19:22:00 +00:00
2024-05-21 03:58:28 +00:00
if user :
2024-04-12 19:22:00 +00:00
2024-05-21 03:58:28 +00:00
flask . session [ ' uid ' ] = user . id
flask . flash ( f " Welcome, { user . name } . " , ' success ' )
return flask . redirect ( flask . url_for ( ' admin.admin_home ' ) )
else :
flask . flash ( " Invalid login. " , ' error ' )
return f
2024-04-12 19:22:00 +00:00
@app.admin.route ( ' /logout/ ' )
@auth
def admin_logout ( ) :
del flask . session [ ' uid ' ]
2024-05-23 21:27:21 +00:00
return flask . redirect ( flask . url_for ( ' frontpage ' ) )
2024-04-12 19:22:00 +00:00
2024-05-05 21:46:36 +00:00
class NotificationForm ( form . Form ) :
def __init__ ( self , filter , * * kwargs ) :
super ( ) . __init__ ( * * kwargs )
if filter == ' unread ' :
notifications = flask . g . user . notifications_unread
elif filter == ' read ' :
notifications = flask . g . user . notifications . where ( Notification . read == True )
2024-05-07 20:54:45 +00:00
elif filter == ' all ' :
notifications = flask . g . user . notifications
2024-05-05 21:46:36 +00:00
else :
raise ValueError ( " Invalid filter " )
2024-10-14 14:54:17 +00:00
self . filters = rendering . Menu (
' notification-actions ' ,
items = (
{
' endpoint ' : ' admin.admin_notifications ' ,
' params ' : { ' filter ' : ' unread ' } ,
' label ' : ' Unread ' ,
} ,
{
' endpoint ' : ' admin.admin_notifications ' ,
' params ' : { ' filter ' : ' read ' } ,
' label ' : ' Read ' ,
} ,
{
' endpoint ' : ' admin.admin_notifications ' ,
' params ' : { ' filter ' : ' all ' } ,
' label ' : ' All ' ,
} ,
)
)
2024-05-07 20:54:45 +00:00
2024-05-05 21:46:36 +00:00
self . notifications = form . MultiGroup ( form . types . INT , ' notifications ' , required = True )
for notification in notifications :
2024-05-31 14:41:36 +00:00
#fs = form.Fieldset(name=f'notification-{notification.id}')
fs = form . Fieldset ( )
fs [ f ' notification-preview- { notification . id } ' ] = form . RenderableField ( notification , mode = ' inline ' )
fs [ f ' notification- { notification . id } ' ] = form . Checkbox ( group = self . notifications , value = notification . id )
2024-05-05 21:46:36 +00:00
2024-05-31 14:41:36 +00:00
self [ f ' notification- { notification . id } ' ] = fs
2024-05-05 21:46:36 +00:00
2024-05-31 14:41:36 +00:00
self . buttons [ ' mark ' ] = form . Button ( label = ' Mark as read ' )
self . buttons [ ' delete ' ] = form . Button ( label = ' Delete ' )
2024-05-05 21:46:36 +00:00
def process ( self , submit ) :
if submit == ' mark ' :
2024-05-07 20:54:45 +00:00
count = Notification . update (
read = True
) . where ( Notification . id << self . notifications . values ) . execute ( )
2024-05-05 21:46:36 +00:00
flask . flash ( f " Marked { count } notifications as read. " , ' success ' )
elif submit == ' delete ' :
2024-05-07 20:54:45 +00:00
count = Notification . delete ( ) . where (
Notification . id << self . notifications . values
) . execute ( )
2024-05-05 21:46:36 +00:00
flask . flash ( f " Deleted { count } notifications. " , ' success ' )
return flask . redirect ( ' ' )
@app.admin.route ( ' /notifications/ ' , methods = [ ' GET ' , ' POST ' ] )
@app.admin.route ( ' /notifications/<string:filter>/ ' , methods = [ ' GET ' , ' POST ' ] )
2024-05-02 18:57:38 +00:00
@auth
@rendering.page ( )
def admin_notifications ( filter = ' unread ' ) :
2024-05-05 21:46:36 +00:00
f = NotificationForm ( filter , title = ' Notifications ' )
2024-05-02 18:57:38 +00:00
2024-05-05 21:46:36 +00:00
if flask . request . method == ' POST ' :
2024-05-02 18:57:38 +00:00
2024-05-05 21:46:36 +00:00
redirect = f . handle ( )
2024-05-02 18:57:38 +00:00
2024-05-05 21:46:36 +00:00
if redirect :
return redirect
2024-05-02 18:57:38 +00:00
return f
2024-04-12 19:22:00 +00:00
@app.admin.menu ( ' main ' )
def admin_menu_main ( ) :
2024-05-02 18:57:38 +00:00
items = [
{
2024-05-23 21:27:21 +00:00
' endpoint ' : ' frontpage ' ,
2024-05-02 18:57:38 +00:00
' label ' : ' Home ' ,
} ,
]
2024-04-12 19:22:00 +00:00
2024-04-12 19:22:00 +00:00
if isinstance ( flask . g . user , User ) : # empty menu without login
2024-04-12 19:22:00 +00:00
2024-05-02 18:57:38 +00:00
items . append ( {
' endpoint ' : ' admin.admin_home ' ,
' label ' : ' Admin ' ,
} )
2024-04-12 19:22:00 +00:00
2024-05-02 18:57:38 +00:00
notification_count = len ( flask . g . user . notifications_unread )
if notification_count == 0 :
notification_label = ' No unread notifications '
elif notification_count == 1 :
notification_label = ' One unread notification '
else :
notification_label = f " { notification_count } unread notifications "
items . append ( {
' endpoint ' : ' admin.admin_notifications ' ,
' label ' : notification_label ,
} )
2024-04-12 19:22:00 +00:00
2024-06-18 01:37:49 +00:00
items . append ( {
' endpoint ' : ' admin.comment_moderation ' ,
' label ' : ' Comment moderation ' ,
} )
2024-10-14 14:54:17 +00:00
return rendering . Menu ( ' main ' , items , burger = True )
2024-04-12 19:22:00 +00:00
2024-04-12 19:22:00 +00:00
@app.admin.menu ( ' secondary ' )
def admin_menu_secondary ( ) :
items = [ ]
if isinstance ( flask . g . user , User ) : # logged in
items . append ( {
' endpoint ' : ' admin.admin_logout ' ,
' label ' : ' Log out ' ,
} )
else :
items . append ( {
' endpoint ' : ' admin.admin_login ' ,
' label ' : ' Log in ' ,
} )
2024-10-14 14:54:17 +00:00
return rendering . Menu ( ' secondary ' , items )
2024-04-12 19:22:00 +00:00
2024-04-12 19:22:00 +00:00
@app.boot
def register_admin ( ) :
2024-04-12 19:22:00 +00:00
for cls in Administerable . __class_descendants__ . values ( ) :
if not cls in app . models_abstract :
register_administerable ( cls )
2024-04-12 19:22:00 +00:00
app . register_blueprint ( app . admin )