2024-04-29 16:16:00 +00:00
# builtins
import io
import random
import secrets
import datetime
# third-party
import PIL . Image , PIL . ImageDraw , PIL . ImageFont , PIL . ImageFilter # pillow, not built-in PIL
import markupsafe
import werkzeug
import flask
import peewee
# internals
from application import app
import util
import rendering
import form
2024-05-07 20:54:45 +00:00
import markdown
2024-04-29 16:16:00 +00:00
import admin
import tagging
class CommentCollection ( admin . Administerable ) :
@werkzeug.utils.cached_property
def parent ( self ) :
# find (and return) the Commentable instance using this CommentCollection, if any
# TODO: It should be possible to do this with one big JOINed statement
# NOTE: This is essentially a copy of TagCollection.parent
for cls in Commentable . __class_descendants__ . values ( ) :
2024-09-01 16:49:26 +00:00
if cls not in app . models_abstract :
try :
return cls . select ( ) . where ( cls . comment_collection == self ) . get ( )
except cls . DoesNotExist :
pass # continue to next loop iteration
2024-04-29 16:16:00 +00:00
return None
2024-10-22 14:53:43 +00:00
comment_markdown_help = """
Allows limited markdown , but no HTML .
* * Emphasis : * *
* ` * italic * ` → * italic *
* ` * * bold * * ` → * * bold * *
* ` * * * bold italic * * * ` → * * * bold italic * * *
* ` ~ ~ strikethrough ~ ~ ` → ~ ~ strikethrough ~ ~
* ` ` ` simple code ` ` ` → ` simple code `
* * Blockquotes : * *
* Input : *
` ` ` md
> This is a blockquote .
> It can span multiple lines and will appear indented .
` ` `
* Output : *
> This is a blockquote .
> It can span multiple lines and will appear indented .
* * Fenced code blocks : * *
* Input : *
` ` ` ` md
` ` `
def example ( x ) :
if x > = 2 :
do_stuff ( x )
else :
do_thing ( x )
` ` `
` ` ` `
* Output : *
` ` `
def example ( x ) :
if x > = 2 :
do_stuff ( x )
else :
do_thing ( x )
` ` `
"""
2024-04-29 16:16:00 +00:00
class Comment ( admin . Administerable ) :
2024-10-14 14:54:17 +00:00
autoform_blacklist = ( ' id ' , )
2024-04-29 16:16:00 +00:00
collection = peewee . ForeignKeyField ( CommentCollection , backref = ' items ' , null = False , on_delete = ' CASCADE ' )
2024-05-02 18:57:38 +00:00
reply_to = peewee . ForeignKeyField ( ' self ' , null = True , on_delete = ' SET NULL ' , verbose_name = ' Reply to ' )
2024-04-29 16:16:00 +00:00
created = peewee . DateTimeField ( default = datetime . datetime . utcnow , null = False )
captcha = peewee . CharField ( null = False , default = util . random_string_light , verbose_name = " Captcha " , help_text = " Case-sensitive " )
captcha_passed = peewee . BooleanField ( null = False , default = False , verbose_name = " Passed captcha " , help_text = " Whether the comment passed the captcha challenge. " )
moderation_passed = peewee . BooleanField ( null = False , default = False , verbose_name = " Passed moderation " , help_text = " Whether the comment passed moderation. " )
nick = peewee . CharField ( null = False , verbose_name = ' Your name ' )
2024-10-22 14:53:43 +00:00
text = markdown . SafeMarkdownTextField ( verbose_name = " Your message " , help_text = comment_markdown_help )
2024-04-29 16:16:00 +00:00
@werkzeug.utils.cached_property
def replies ( self ) :
# return replies that should be visible to visitors
2024-05-02 18:57:38 +00:00
return Comment . select ( ) . where (
( Comment . reply_to == self ) &
( Comment . captcha_passed == True ) &
( Comment . moderation_passed == True )
)
2024-04-29 16:16:00 +00:00
@werkzeug.utils.cached_property
def replies_all ( self ) :
# ditto, but include replies that didn't pass captcha/moderation yet
return Comment . select ( ) . where ( Comment . reply_to == self )
@werkzeug.utils.cached_property
def thread ( self ) :
thread = { }
for reply in self . replies :
thread [ reply ] = reply . thread
return thread
@werkzeug.utils.cached_property
def thread_all ( self ) :
thread = { }
for reply in self . replies_all :
thread [ reply ] = reply . thread_all
return thread
2024-05-02 18:57:38 +00:00
def reply_form ( self ) :
commentable = self . collection . parent
comment = Comment ( reply_to = self )
f = comment . form (
' create ' ,
id = f ' comment-reply- { self . id } ' ,
action = flask . url_for ( ' comment_post ' , type = commentable . __class__ . __name__ , name = commentable . name , reply_to_id = self . id )
)
2024-05-31 14:41:36 +00:00
f [ ' collection_id ' ] = form . ValueField ( value = self . collection_id )
f [ ' reply_to_id ' ] = form . ValueField ( value = self . id )
2024-10-14 14:54:17 +00:00
del f [ ' created ' ]
2024-05-31 14:41:36 +00:00
del f [ ' captcha ' ]
del f [ ' captcha_passed ' ]
del f [ ' moderation_passed ' ]
2024-05-02 18:57:38 +00:00
2024-11-04 12:12:45 +00:00
f [ ' text ' ] . id = f ' comment-reply- { self . id } -text ' # avoid id collision for help toggle
2024-05-02 18:57:38 +00:00
return f
2024-04-29 16:16:00 +00:00
@app.abstract
class Commentable ( tagging . Taggable ) :
2024-05-21 03:58:28 +00:00
created = peewee . DateTimeField ( default = datetime . datetime . utcnow , null = False )
published = peewee . BooleanField ( null = False , default = False , verbose_name = ' Published ' )
2024-04-29 16:16:00 +00:00
comment_collection = peewee . ForeignKeyField ( CommentCollection , null = True , on_delete = ' CASCADE ' )
comment_enabled = peewee . BooleanField ( null = False , default = True , verbose_name = " Activate commenting " )
comment_moderated = peewee . BooleanField ( null = False , default = False , verbose_name = " Activate comment moderation " )
2024-07-27 01:05:56 +00:00
autoform_blacklist = ( ' id ' , ' comment_collection_id ' )
2024-05-21 03:58:28 +00:00
2024-04-29 16:16:00 +00:00
@werkzeug.utils.cached_property
def comments ( self ) :
# return all root-level comments that visitors should see.
# i.e. passed captcha and moderation (if any)
return Comment . select ( ) . where (
2024-05-02 18:57:38 +00:00
( Comment . collection == self . comment_collection ) &
( Comment . reply_to == None ) &
( Comment . captcha_passed == True ) &
( Comment . moderation_passed == True )
2024-04-29 16:16:00 +00:00
)
@werkzeug.utils.cached_property
def comments_all ( self ) :
# ditto, but includes comments that didn't pass captcha and/or moderation.
return Comment . select ( ) . where (
Comment . collection == self . comment_collection
)
@werkzeug.utils.cached_property
def comments_threaded ( self ) :
# threaded representation of all comments that visitors should see.
comments_threaded = { }
for comment in self . comments :
comments_threaded [ comment ] = comment . thread
return comments_threaded
@werkzeug.utils.cached_property
def comments_threaded_all ( self ) :
# ditto, but with comments that haven't passed captcha/moderation
comments_threaded = { }
for comment in self . comments_all :
comments_threaded [ comment ] = comment . thread_all
return comments_threaded
2024-05-21 03:58:28 +00:00
@classmethod
def list ( cls ) :
2024-05-21 05:31:13 +00:00
return cls . select ( ) . where ( cls . published == True ) . order_by ( cls . created . desc ( ) )
2024-05-21 03:58:28 +00:00
2024-07-13 21:09:48 +00:00
def access ( self , mode = ' full ' ) :
if isinstance ( flask . g . user , admin . User ) :
return True
# access allowed for anything but a list of "privileged" modes
return ( mode not in ( ' create ' , ' edit ' , ' delete ' , ' admin-teaser ' ) ) and self . published
2024-05-21 03:58:28 +00:00
def render ( self , mode = ' full ' , format = ' html ' ) :
if not ( self . published or flask . g . user ) :
return markupsafe . Markup ( ' <div class= " embed-error " >Unpublished embedded content</div> ' )
return super ( ) . render ( mode = mode , format = format )
2024-05-02 18:57:38 +00:00
def comment_form ( self ) :
2024-04-29 16:16:00 +00:00
2024-05-02 18:57:38 +00:00
comment = Comment ( )
2024-04-29 16:16:00 +00:00
f = comment . form ( ' create ' , action = flask . url_for ( ' comment_post ' , type = self . __class__ . __name__ , name = self . name ) )
2024-05-31 14:41:36 +00:00
f [ ' collection_id ' ] = form . ValueField ( value = self . comment_collection_id )
f [ ' reply_to_id ' ] = form . ValueField ( value = None ) # TODO: value
2024-10-14 14:54:17 +00:00
del f [ ' created ' ]
2024-05-31 14:41:36 +00:00
del f [ ' captcha ' ]
del f [ ' captcha_passed ' ]
del f [ ' moderation_passed ' ]
2024-04-29 16:16:00 +00:00
return f
@app.route ( ' /comment/<string:type>/<string:name>/ ' , methods = [ ' GET ' , ' POST ' ] )
2024-05-02 18:57:38 +00:00
@app.route ( ' /comment/<string:type>/<string:name>/<int:reply_to_id>/ ' , methods = [ ' GET ' , ' POST ' ] )
2024-04-29 16:16:00 +00:00
@rendering.page ( )
2024-05-02 18:57:38 +00:00
def comment_post ( type , name , reply_to_id = None ) :
2024-04-29 16:16:00 +00:00
if not type in Commentable . __class_descendants__ :
flask . abort ( 400 )
cls = Commentable . __class_descendants__ [ type ]
instance = cls . load ( name )
# TODO: if not instance.published
if not instance . comment_enabled :
flask . abort ( 403 )
collection = instance . comment_collection
if collection is None :
# if instance has no comment collection, create and assign it
collection = CommentCollection ( )
collection . save ( force_insert = True )
instance . comment_collection = collection
instance . save ( )
2024-05-02 18:57:38 +00:00
if reply_to_id is not None :
comment = Comment . load ( reply_to_id )
f = comment . reply_form ( )
else :
f = instance . comment_form ( )
2024-04-29 16:16:00 +00:00
if flask . request . method == ' POST ' :
f . handle ( )
2024-05-02 18:57:38 +00:00
2024-04-29 16:16:00 +00:00
if f . administerable . is_in_db : # successfully saved
return flask . redirect ( flask . url_for ( ' comment_captcha ' , type = type , name = name , id = f . administerable . id ) )
return f
@app.route ( ' /comment/<string:type>/<string:name>/captcha/<int:id>/ ' , methods = [ ' GET ' , ' POST ' ] )
@rendering.page ( )
def comment_captcha ( type , name , id ) :
if not type in Commentable . __class_descendants__ :
flask . abort ( 400 )
cls = Commentable . __class_descendants__ [ type ]
instance = cls . load ( name )
2024-05-02 18:57:38 +00:00
2024-04-29 16:16:00 +00:00
if not instance . comment_enabled :
flask . abort ( 403 )
comment = Comment . load ( id )
if comment . captcha_passed :
flask . abort ( 400 )
captcha_url = flask . url_for ( ' serve_captcha ' , id = id )
f = form . Form ( )
f . intro = markupsafe . Markup ( f ' <img src= " { captcha_url } " alt= " Solve this captcha to continue " /> ' )
2024-05-31 14:41:36 +00:00
f [ ' captcha ' ] = form . Text ( label = ' Captcha ' , help = ' Case-sensitive ' , required = True )
f . buttons [ ' submit ' ] = form . Button ( label = ' Continue ' )
2024-04-29 16:16:00 +00:00
if flask . request . method == ' POST ' :
f . handle ( ) # binds and validates form
if f . valid :
2024-05-31 14:41:36 +00:00
if f [ ' captcha ' ] . value == comment . captcha :
2024-04-29 16:16:00 +00:00
comment . captcha_passed = True
if not instance . comment_moderated :
comment . moderation_passed = True
comment . save ( )
2024-05-02 18:57:38 +00:00
2024-04-29 16:16:00 +00:00
if instance . comment_moderated :
flask . flash ( " Your comment has been received and is awaiting moderation. " , ' success ' )
else :
flask . flash ( " Your comment has been posted. " , ' success ' )
return flask . redirect ( instance . url ( ' full ' ) )
else :
flask . flash ( " You entered the captcha incorrectly. Make sure you enter the correct upper- and lowercase letters. " , ' error ' )
return f
def font_getsize ( font , text ) :
bbox = font . getbbox ( text )
#return (bbox[2] - bbox[0], bbox[3] - bbox[1]) # old and naive, broken with most fonts
#return (bbox[2] - bbox[1], bbox[3] + 1) # https://stackoverflow.com/a/77999858 (+1 for subpixel stuff [i.e. clean AA]), works for most fonts
return ( bbox [ 2 ] , bbox [ 3 ] + 1 ) # most conservative solution, should work for every font
@app.route ( ' /comment/captcha/<int:id>/ ' )
def serve_captcha ( id ) :
comment = Comment . load ( id )
colors = [
( 0 , 128 , 255 ) ,
( 0 , 255 , 128 ) ,
( 128 , 0 , 255 ) ,
( 128 , 255 , 0 ) ,
( 255 , 0 , 128 ) ,
( 255 , 128 , 0 )
]
2024-07-13 21:09:48 +00:00
font_path = ' themes/default/assets/fonts/Michroma/Michroma-Regular.ttf '
2024-04-29 16:16:00 +00:00
image = PIL . Image . new ( ' RGBA ' , ( 320 , 80 ) , ( 255 , 255 , 255 , 0 ) )
font = PIL . ImageFont . truetype ( font_path , 42 )
#x_jitter = ((image.width/10) * -1, 0)
#y_jitter = ((image.height/10) * -1, image.height/10)
x_jitter = ( - 5 , 15 )
y_jitter = ( - 15 , 5 )
textsize = font_getsize ( font , ' ' . join ( comment . captcha ) )
centered = ( image . width / 2 - textsize [ 0 ] / 2 , image . height / 2 - textsize [ 1 ] / 2 )
x = centered [ 0 ] + random . randint ( x_jitter [ 0 ] , x_jitter [ 1 ] )
y = centered [ 1 ] + random . randint ( y_jitter [ 0 ] , y_jitter [ 1 ] )
baseline = centered [ 1 ]
for char in comment . captcha :
c = colors [ random . randint ( 0 , len ( colors ) - 1 ) ]
c = tuple ( list ( c ) + [ random . randint ( 255 , 255 ) ] )
char_size = font_getsize ( font , char )
char_wrapped = f ' { char } '
char_wrapped_size = font_getsize ( font , char_wrapped )
char_layer = PIL . Image . new ( ' RGBA ' , char_wrapped_size , ( 255 , 255 , 255 , 0 ) )
char_draw = PIL . ImageDraw . Draw ( char_layer )
char_draw . text ( ( 0 , 0 ) , char_wrapped , c , font = font )
char_layer = char_layer . rotate ( random . randint ( - 15 , 15 ) , expand = True , resample = PIL . Image . BICUBIC )
image . paste (
char_layer ,
( int ( x ) , int ( y ) ) ,
mask = char_layer
)
x + = char_size [ 0 ] + random . randint ( x_jitter [ 0 ] , x_jitter [ 1 ] )
y = baseline + random . randint ( y_jitter [ 0 ] , y_jitter [ 1 ] )
shine = image . filter ( PIL . ImageFilter . GaussianBlur ( radius = 8 ) )
image = PIL . Image . alpha_composite ( image , shine )
out = io . BytesIO ( )
image . save ( out , format = ' PNG ' )
return flask . Response (
out . getvalue ( ) ,
mimetype = ' image/png '
)
2024-05-07 20:54:45 +00:00
class ModerationForm ( form . Form ) :
def __init__ ( self , filter , * * kwargs ) :
super ( ) . __init__ ( * * kwargs )
comments = Comment . select ( ) . order_by ( Comment . collection )
if filter == ' awaiting_moderation ' :
comments = comments . where ( ( Comment . captcha_passed == True ) & ( Comment . moderation_passed == False ) )
elif filter == ' awaiting_captcha ' :
comments = comments . where ( Comment . captcha_passed == False )
elif filter == ' public ' :
comments = comments . where ( ( Comment . captcha_passed == True ) & ( Comment . moderation_passed == True ) )
elif filter == ' all ' :
pass
else :
raise ValueError ( " Invalid filter " )
2024-10-14 14:54:17 +00:00
self . filters = rendering . Menu (
' comment-moderation-actions ' ,
items = (
{
' endpoint ' : ' admin.comment_moderation ' ,
' params ' : { ' filter ' : ' awaiting_moderation ' } ,
' label ' : ' Awaiting moderation ' ,
} ,
{
' endpoint ' : ' admin.comment_moderation ' ,
' params ' : { ' filter ' : ' awaiting_captcha ' } ,
' label ' : ' Awaiting Captcha ' ,
} ,
{
' endpoint ' : ' admin.comment_moderation ' ,
' params ' : { ' filter ' : ' public ' } ,
' label ' : ' Public ' ,
} ,
{
' endpoint ' : ' admin.comment_moderation ' ,
' params ' : { ' filter ' : ' all ' } ,
' label ' : ' All ' ,
} ,
)
)
2024-05-07 20:54:45 +00:00
self . comments = form . MultiGroup ( form . types . INT , ' comments ' , required = True )
for comment in comments :
fs = form . Fieldset ( name = f ' comment- { comment . id } ' )
2024-05-31 14:41:36 +00:00
fs [ f ' comment-preview- { comment . id } ' ] = form . RenderableField ( comment , mode = ' inline ' )
fs [ f ' comment- { comment . id } ' ] = form . Checkbox ( group = self . comments , value = comment . id )
2024-05-07 20:54:45 +00:00
2024-05-31 14:41:36 +00:00
self [ fs . name ] = fs
2024-05-07 20:54:45 +00:00
2024-05-31 14:41:36 +00:00
self . buttons [ ' approve ' ] = form . Button ( label = ' Approve ' )
self . buttons [ ' delete ' ] = form . Button ( label = ' Delete ' )
2024-05-07 20:54:45 +00:00
def process ( self , submit ) :
if submit == ' approve ' :
count = Comment . update (
moderation_passed = True ,
captcha_passed = True
) . where ( Comment . id << self . comments . values ) . execute ( )
flask . flash ( f " Approved { count } comments. " , ' success ' )
elif submit == ' delete ' :
count = Comment . delete ( ) . where (
Comment . id << self . comments . values
) . execute ( )
flask . flash ( f " Deleted { count } comments. " , ' success ' )
return flask . redirect ( ' ' )
@app.admin.route ( ' /comment/moderation/ ' , methods = [ ' GET ' , ' POST ' ] )
@app.admin.route ( ' /comment/moderation/<string:filter>/ ' , methods = [ ' GET ' , ' POST ' ] )
@rendering.page ( )
def comment_moderation ( filter = ' awaiting_moderation ' ) :
f = ModerationForm ( filter )
if flask . request . method == ' POST ' :
redirect = f . handle ( )
if redirect :
return redirect
return f