WIP
This commit is contained in:
parent
637b00261a
commit
3c4a25e5ad
17 changed files with 197 additions and 272 deletions
|
@ -38,6 +38,12 @@ Stacosys can be hosted on the same server or on a different server than the blog
|
||||||
- [Peewee ORM](http://docs.peewee-orm.com)
|
- [Peewee ORM](http://docs.peewee-orm.com)
|
||||||
- [Markdown](http://daringfireball.net/projects/markdown)
|
- [Markdown](http://daringfireball.net/projects/markdown)
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
|
||||||
|
Python 3.7
|
||||||
|
|
||||||
|
pip libs: flask peewee pyrss2gen markdown clize flask-apscheduler profig
|
||||||
|
|
||||||
### Ways of improvement
|
### Ways of improvement
|
||||||
|
|
||||||
Current version of Stacosys fits my needs and it serves comments on [my blog](https://blogduyax.madyanne.fr). However Stacosys has been designed to serve several blogs and e-mail can be a constraint for some people. So an area of improvement would be to add an administration UI to configure sites, approve or reject comments, keep track of usage statistics and get rid of e-mails. I encourage you to fork the project and create such improvements if you need them.
|
Current version of Stacosys fits my needs and it serves comments on [my blog](https://blogduyax.madyanne.fr). However Stacosys has been designed to serve several blogs and e-mail can be a constraint for some people. So an area of improvement would be to add an administration UI to configure sites, approve or reject comments, keep track of usage statistics and get rid of e-mails. I encourage you to fork the project and create such improvements if you need them.
|
||||||
|
|
|
@ -1,3 +0,0 @@
|
||||||
from flask import Flask
|
|
||||||
|
|
||||||
app = Flask(__name__)
|
|
|
@ -1,3 +1,48 @@
|
||||||
#!/usr/bin/env python
|
#!/usr/bin/env python
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
import profig
|
||||||
|
|
||||||
|
# constants
|
||||||
|
FLASK_APP = "flask.app"
|
||||||
|
|
||||||
|
DB_URL = "main.db_url"
|
||||||
|
|
||||||
|
HTTP_HOST = "http.host"
|
||||||
|
HTTP_PORT = "http.port"
|
||||||
|
|
||||||
|
SECURITY_SALT = "security.salt"
|
||||||
|
SECURITY_SECRET = "security.secret"
|
||||||
|
|
||||||
|
MAIL_POLLING = "polling.newmail"
|
||||||
|
COMMENT_POLLING = "polling.newcomment"
|
||||||
|
|
||||||
|
# variable
|
||||||
|
params = dict()
|
||||||
|
|
||||||
|
|
||||||
|
def initialize(config_pathname, flask_app):
|
||||||
|
cfg = profig.Config(config_pathname)
|
||||||
|
cfg.sync()
|
||||||
|
params.update(cfg)
|
||||||
|
params.update({FLASK_APP: flask_app})
|
||||||
|
|
||||||
|
|
||||||
|
def get(key):
|
||||||
|
return params[key]
|
||||||
|
|
||||||
|
|
||||||
|
def getInt(key):
|
||||||
|
return int(params[key])
|
||||||
|
|
||||||
|
|
||||||
|
def _str2bool(v):
|
||||||
|
return v.lower() in ("yes", "true", "t", "1")
|
||||||
|
|
||||||
|
|
||||||
|
def getBool(key):
|
||||||
|
return _str2bool(params[key])
|
||||||
|
|
||||||
|
|
||||||
|
def flaskapp():
|
||||||
|
return params[FLASK_APP]
|
||||||
|
|
|
@ -1,115 +0,0 @@
|
||||||
#!/usr/bin/env python
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
|
|
||||||
# Created with https://app.quicktype.io
|
|
||||||
# name: stacosys
|
|
||||||
|
|
||||||
json_schema = """
|
|
||||||
{
|
|
||||||
"$schema": "http://json-schema.org/draft-06/schema#",
|
|
||||||
"$ref": "#/definitions/Welcome",
|
|
||||||
"definitions": {
|
|
||||||
"Welcome": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"properties": {
|
|
||||||
"general": {
|
|
||||||
"$ref": "#/definitions/General"
|
|
||||||
},
|
|
||||||
"http": {
|
|
||||||
"$ref": "#/definitions/HTTP"
|
|
||||||
},
|
|
||||||
"security": {
|
|
||||||
"$ref": "#/definitions/Security"
|
|
||||||
},
|
|
||||||
"rss": {
|
|
||||||
"$ref": "#/definitions/RSS"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": [
|
|
||||||
"general",
|
|
||||||
"http",
|
|
||||||
"rss",
|
|
||||||
"security"
|
|
||||||
],
|
|
||||||
"title": "Welcome"
|
|
||||||
},
|
|
||||||
"General": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"properties": {
|
|
||||||
"debug": {
|
|
||||||
"type": "boolean"
|
|
||||||
},
|
|
||||||
"lang": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"db_url": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": [
|
|
||||||
"db_url",
|
|
||||||
"debug",
|
|
||||||
"lang"
|
|
||||||
],
|
|
||||||
"title": "General"
|
|
||||||
},
|
|
||||||
"HTTP": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"properties": {
|
|
||||||
"root_url": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"host": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"port": {
|
|
||||||
"type": "integer"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": [
|
|
||||||
"host",
|
|
||||||
"port",
|
|
||||||
"root_url"
|
|
||||||
],
|
|
||||||
"title": "HTTP"
|
|
||||||
},
|
|
||||||
"RSS": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"properties": {
|
|
||||||
"proto": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"file": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": [
|
|
||||||
"file",
|
|
||||||
"proto"
|
|
||||||
],
|
|
||||||
"title": "RSS"
|
|
||||||
},
|
|
||||||
"Security": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"properties": {
|
|
||||||
"salt": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"secret": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": [
|
|
||||||
"salt",
|
|
||||||
"secret"
|
|
||||||
],
|
|
||||||
"title": "Security"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"""
|
|
|
@ -1,84 +0,0 @@
|
||||||
#!/usr/bin/python
|
|
||||||
# -*- coding: UTF-8 -*-
|
|
||||||
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import logging
|
|
||||||
from flask import Flask
|
|
||||||
from conf import config
|
|
||||||
from jsonschema import validate
|
|
||||||
from flask_apscheduler import APScheduler
|
|
||||||
|
|
||||||
app = Flask(__name__)
|
|
||||||
|
|
||||||
# add current path and parent path to syspath
|
|
||||||
current_path = os.path.dirname(__file__)
|
|
||||||
parent_path = os.path.abspath(os.path.join(current_path, os.path.pardir))
|
|
||||||
paths = [current_path, parent_path]
|
|
||||||
for path in paths:
|
|
||||||
if path not in sys.path:
|
|
||||||
sys.path.insert(0, path)
|
|
||||||
|
|
||||||
# more imports
|
|
||||||
import database
|
|
||||||
import processor
|
|
||||||
from interface import api
|
|
||||||
from interface import form
|
|
||||||
|
|
||||||
# configure logging
|
|
||||||
def configure_logging(level):
|
|
||||||
root_logger = logging.getLogger()
|
|
||||||
root_logger.setLevel(level)
|
|
||||||
ch = logging.StreamHandler()
|
|
||||||
ch.setLevel(level)
|
|
||||||
# create formatter
|
|
||||||
formatter = logging.Formatter(
|
|
||||||
'[%(asctime)s] %(name)s %(levelname)s %(message)s')
|
|
||||||
# add formatter to ch
|
|
||||||
ch.setFormatter(formatter)
|
|
||||||
# add ch to logger
|
|
||||||
root_logger.addHandler(ch)
|
|
||||||
|
|
||||||
logging_level = (20, 10)[config.general['debug']]
|
|
||||||
configure_logging(logging_level)
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
class Config(object):
|
|
||||||
JOBS = [
|
|
||||||
{
|
|
||||||
'id': 'fetch_mail',
|
|
||||||
'func': 'core.cron:fetch_mail_answers',
|
|
||||||
'trigger': 'interval',
|
|
||||||
'seconds': 120
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'id': 'submit_new_comment',
|
|
||||||
'func': 'core.cron:submit_new_comment',
|
|
||||||
'trigger': 'interval',
|
|
||||||
'seconds': 60
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
# initialize database
|
|
||||||
database.setup()
|
|
||||||
|
|
||||||
# start processor
|
|
||||||
template_path = os.path.abspath(os.path.join(current_path, '../templates'))
|
|
||||||
processor.start(template_path)
|
|
||||||
|
|
||||||
# cron
|
|
||||||
app.config.from_object(Config())
|
|
||||||
scheduler = APScheduler()
|
|
||||||
scheduler.init_app(app)
|
|
||||||
scheduler.start()
|
|
||||||
|
|
||||||
# tune logging level
|
|
||||||
if not config.general['debug']:
|
|
||||||
logging.getLogger('werkzeug').level = logging.WARNING
|
|
||||||
|
|
||||||
logger.info("Start Stacosys application")
|
|
||||||
|
|
||||||
app.run(host=config.http['host'],
|
|
||||||
port=config.http['port'],
|
|
||||||
debug=config.general['debug'], use_reloader=False)
|
|
|
@ -3,26 +3,29 @@
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
from core import app
|
from core import mailer
|
||||||
from core import processor
|
from core import templater
|
||||||
from models.comment import Comment
|
from model.comment import Comment
|
||||||
|
from model.comment import Site
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def fetch_mail_answers():
|
def fetch_mail_answers():
|
||||||
|
|
||||||
logger.info('DEBUT POP MAIL')
|
logger.info("DEBUT POP MAIL")
|
||||||
time.sleep(80)
|
time.sleep(80)
|
||||||
logger.info('FIN POP MAIL')
|
logger.info("FIN POP MAIL")
|
||||||
# data = request.get_json()
|
# data = request.get_json()
|
||||||
# logger.debug(data)
|
# logger.debug(data)
|
||||||
|
|
||||||
# processor.enqueue({'request': 'new_mail', 'data': data})
|
# processor.enqueue({'request': 'new_mail', 'data': data})
|
||||||
|
|
||||||
|
|
||||||
def submit_new_comment():
|
def submit_new_comment():
|
||||||
|
|
||||||
for comment in Comment.select().where(Comment.notified.is_null()):
|
for comment in Comment.select().where(Comment.notified.is_null()):
|
||||||
# render email body template
|
|
||||||
comment_list = (
|
comment_list = (
|
||||||
"author: %s" % comment.author_name,
|
"author: %s" % comment.author_name,
|
||||||
"site: %s" % comment.author_site,
|
"site: %s" % comment.author_site,
|
||||||
|
@ -33,15 +36,10 @@ def submit_new_comment():
|
||||||
"",
|
"",
|
||||||
)
|
)
|
||||||
comment_text = "\n".join(comment_list)
|
comment_text = "\n".join(comment_list)
|
||||||
email_body = get_template("new_comment").render(url=url, comment=comment_text)
|
email_body = templater.get_template("new_comment").render(url=comment.url, comment=comment_text)
|
||||||
|
|
||||||
if clientip:
|
|
||||||
client_ips[comment.id] = clientip
|
|
||||||
|
|
||||||
|
site = Site.select().where(Site.id == Comment.site).get()
|
||||||
# send email
|
# send email
|
||||||
subject = "STACOSYS %s: [%d:%s]" % (site.name, comment.id, token)
|
subject = "STACOSYS %s: [%d:%s]" % (site.name, comment.id, site.token)
|
||||||
mailer.send_mail(site.admin_email, subject, email_body)
|
mailer.send_mail(site.admin_email, subject, email_body)
|
||||||
logger.debug("new comment processed ")
|
logger.debug("new comment processed ")
|
||||||
|
|
||||||
def get_template(name):
|
|
||||||
return env.get_template(config.general["lang"] + "/" + name + ".tpl")
|
|
||||||
|
|
|
@ -2,26 +2,15 @@
|
||||||
# -*- coding: UTF-8 -*-
|
# -*- coding: UTF-8 -*-
|
||||||
|
|
||||||
from conf import config
|
from conf import config
|
||||||
import functools
|
|
||||||
from playhouse.db_url import connect
|
from playhouse.db_url import connect
|
||||||
|
|
||||||
|
|
||||||
def get_db():
|
def get_db():
|
||||||
return connect(config.general['db_url'])
|
return connect(config.get(config.DB_URL))
|
||||||
|
|
||||||
|
|
||||||
def provide_db(func):
|
def setup():
|
||||||
|
from model.site import Site
|
||||||
|
from model.comment import Comment
|
||||||
|
|
||||||
@functools.wraps(func)
|
get_db().create_tables([Site, Comment], safe=True)
|
||||||
def new_function(*args, **kwargs):
|
|
||||||
return func(get_db(), *args, **kwargs)
|
|
||||||
|
|
||||||
return new_function
|
|
||||||
|
|
||||||
|
|
||||||
@provide_db
|
|
||||||
def setup(db):
|
|
||||||
from models.site import Site
|
|
||||||
from models.comment import Comment
|
|
||||||
|
|
||||||
db.create_tables([Site, Comment], safe=True)
|
|
||||||
|
|
|
@ -10,10 +10,8 @@ import json
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from threading import Thread
|
from threading import Thread
|
||||||
from queue import Queue
|
from queue import Queue
|
||||||
from jinja2 import Environment
|
from model.site import Site
|
||||||
from jinja2 import FileSystemLoader
|
from model.comment import Comment
|
||||||
from models.site import Site
|
|
||||||
from models.comment import Comment
|
|
||||||
from helpers.hashing import md5
|
from helpers.hashing import md5
|
||||||
from conf import config
|
from conf import config
|
||||||
from core import mailer
|
from core import mailer
|
||||||
|
|
16
app/core/templater.py
Normal file
16
app/core/templater.py
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
|
||||||
|
#!/usr/bin/env python
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
import os
|
||||||
|
from jinja2 import Environment
|
||||||
|
from jinja2 import FileSystemLoader
|
||||||
|
from conf import config
|
||||||
|
|
||||||
|
current_path = os.path.dirname(__file__)
|
||||||
|
template_path = os.path.abspath(os.path.join(current_path, "../templates"))
|
||||||
|
env = Environment(loader=FileSystemLoader(template_path))
|
||||||
|
|
||||||
|
|
||||||
|
def get_template(name):
|
||||||
|
return env.get_template(config.general["lang"] + "/" + name + ".tpl")
|
|
@ -6,7 +6,7 @@ from conf import config
|
||||||
|
|
||||||
|
|
||||||
def salt(value):
|
def salt(value):
|
||||||
string = '%s%s' % (value, config.security['salt'])
|
string = "%s%s" % (value, config.get(config.SECURITY_SALT))
|
||||||
dk = hashlib.sha256(string.encode())
|
dk = hashlib.sha256(string.encode())
|
||||||
return dk.hexdigest()
|
return dk.hexdigest()
|
||||||
|
|
||||||
|
|
0
app/interface/__init__.py
Normal file
0
app/interface/__init__.py
Normal file
|
@ -3,13 +3,13 @@
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from flask import request, jsonify, abort
|
from flask import request, jsonify, abort
|
||||||
from core import app
|
from model.site import Site
|
||||||
from models.site import Site
|
from model.comment import Comment
|
||||||
from models.comment import Comment
|
from conf import config
|
||||||
from core import processor
|
from core import processor
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
app = config.flaskapp()
|
||||||
|
|
||||||
@app.route("/ping", methods=['GET'])
|
@app.route("/ping", methods=['GET'])
|
||||||
def ping():
|
def ping():
|
||||||
|
|
|
@ -4,13 +4,13 @@
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from flask import request, abort, redirect
|
from flask import request, abort, redirect
|
||||||
from core import app
|
from model.site import Site
|
||||||
from models.site import Site
|
from model.comment import Comment
|
||||||
from models.comment import Comment
|
from conf import config
|
||||||
from helpers.hashing import md5
|
from helpers.hashing import md5
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
app = config.flaskapp()
|
||||||
|
|
||||||
@app.route("/newcomment", methods=["POST"])
|
@app.route("/newcomment", methods=["POST"])
|
||||||
def new_form_comment():
|
def new_form_comment():
|
||||||
|
|
|
@ -6,7 +6,7 @@ from peewee import CharField
|
||||||
from peewee import TextField
|
from peewee import TextField
|
||||||
from peewee import DateTimeField
|
from peewee import DateTimeField
|
||||||
from peewee import ForeignKeyField
|
from peewee import ForeignKeyField
|
||||||
from models.site import Site
|
from model.site import Site
|
||||||
from core.database import get_db
|
from core.database import get_db
|
||||||
|
|
||||||
|
|
94
app/run.py
94
app/run.py
|
@ -1,36 +1,90 @@
|
||||||
#!/usr/bin/python
|
#!/usr/bin/python
|
||||||
# -*- coding: UTF-8 -*-
|
# -*- coding: UTF-8 -*-
|
||||||
|
|
||||||
|
import os
|
||||||
import logging
|
import logging
|
||||||
import json
|
|
||||||
from clize import Clize, run
|
from clize import Clize, run
|
||||||
from jsonschema import validate
|
from flask import Flask
|
||||||
from conf import config, schema
|
from flask_apscheduler import APScheduler
|
||||||
|
from conf import config
|
||||||
|
|
||||||
|
# configure logging
|
||||||
|
def configure_logging(level):
|
||||||
|
root_logger = logging.getLogger()
|
||||||
|
root_logger.setLevel(level)
|
||||||
|
ch = logging.StreamHandler()
|
||||||
|
ch.setLevel(level)
|
||||||
|
# create formatter
|
||||||
|
formatter = logging.Formatter("[%(asctime)s] %(name)s %(levelname)s %(message)s")
|
||||||
|
# add formatter to ch
|
||||||
|
ch.setFormatter(formatter)
|
||||||
|
# add ch to logger
|
||||||
|
root_logger.addHandler(ch)
|
||||||
|
|
||||||
|
|
||||||
def load_json(filename):
|
class JobConfig(object):
|
||||||
jsondoc = None
|
|
||||||
with open(filename, 'rt') as json_file:
|
JOBS = []
|
||||||
jsondoc = json.loads(json_file.read())
|
|
||||||
return jsondoc
|
def __init__(self, mail_polling_seconds, new_comment_polling_seconds):
|
||||||
|
self.JOBS = [
|
||||||
|
{
|
||||||
|
"id": "fetch_mail",
|
||||||
|
"func": "core.cron:fetch_mail_answers",
|
||||||
|
"trigger": "interval",
|
||||||
|
"seconds": mail_polling_seconds,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "submit_new_comment",
|
||||||
|
"func": "core.cron:submit_new_comment",
|
||||||
|
"trigger": "interval",
|
||||||
|
"seconds": new_comment_polling_seconds,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@Clize
|
@Clize
|
||||||
def stacosys_server(config_pathname):
|
def stacosys_server(config_pathname):
|
||||||
|
|
||||||
# load and validate startup config
|
app = Flask(__name__)
|
||||||
conf = load_json(config_pathname)
|
config.initialize(config_pathname, app)
|
||||||
json_schema = json.loads(schema.json_schema)
|
|
||||||
validate(conf, json_schema)
|
|
||||||
|
|
||||||
# set configuration
|
# configure logging
|
||||||
config.general = conf['general']
|
logger = logging.getLogger(__name__)
|
||||||
config.http = conf['http']
|
configure_logging(logging.INFO)
|
||||||
config.security = conf['security']
|
logging.getLogger("werkzeug").level = logging.WARNING
|
||||||
config.rss = conf['rss']
|
|
||||||
|
|
||||||
# start application
|
# initialize database
|
||||||
from core import app
|
from core import database
|
||||||
|
|
||||||
if __name__ == '__main__':
|
database.setup()
|
||||||
|
|
||||||
|
# start processor
|
||||||
|
from core import processor
|
||||||
|
|
||||||
|
# cron email fetcher
|
||||||
|
app.config.from_object(
|
||||||
|
JobConfig(
|
||||||
|
config.getInt(config.MAIL_POLLING), config.getInt(config.COMMENT_POLLING)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
scheduler = APScheduler()
|
||||||
|
scheduler.init_app(app)
|
||||||
|
scheduler.start()
|
||||||
|
|
||||||
|
logger.info("Start Stacosys application")
|
||||||
|
|
||||||
|
# start Flask
|
||||||
|
from interface import api
|
||||||
|
from interface import form
|
||||||
|
|
||||||
|
app.run(
|
||||||
|
host=config.get(config.HTTP_HOST),
|
||||||
|
port=config.get(config.HTTP_PORT),
|
||||||
|
debug=False,
|
||||||
|
use_reloader=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
run(stacosys_server)
|
run(stacosys_server)
|
||||||
|
|
21
config.ini
Executable file
21
config.ini
Executable file
|
@ -0,0 +1,21 @@
|
||||||
|
; Default configuration
|
||||||
|
[main]
|
||||||
|
lang = fr
|
||||||
|
db_url = sqlite:///db.sqlite
|
||||||
|
|
||||||
|
[http]
|
||||||
|
root_url = http://localhost:8100
|
||||||
|
host = 0.0.0.0
|
||||||
|
port = 8100
|
||||||
|
|
||||||
|
[security]
|
||||||
|
salt = BRRJRqXgGpXWrgTidBPcixIThHpDuKc0
|
||||||
|
secret = Uqca5Kc8xuU6THz9
|
||||||
|
|
||||||
|
[rss]
|
||||||
|
proto = http
|
||||||
|
file = comments.xml
|
||||||
|
|
||||||
|
[polling]
|
||||||
|
newmail = 15
|
||||||
|
newcomment = 60
|
Loading…
Add table
Reference in a new issue