diff --git a/README.md b/README.md index b8dffd5..72017aa 100644 --- a/README.md +++ b/README.md @@ -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) - [Markdown](http://daringfireball.net/projects/markdown) +### Installation + +Python 3.7 + +pip libs: flask peewee pyrss2gen markdown clize flask-apscheduler profig + ### 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. diff --git a/app/__init__.py b/app/__init__.py index d7562aa..e69de29 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,3 +0,0 @@ -from flask import Flask - -app = Flask(__name__) diff --git a/app/conf/config.py b/app/conf/config.py index 3a1213c..9994bc2 100644 --- a/app/conf/config.py +++ b/app/conf/config.py @@ -1,3 +1,48 @@ #!/usr/bin/env python # -*- 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] diff --git a/app/conf/schema.py b/app/conf/schema.py deleted file mode 100644 index f35e594..0000000 --- a/app/conf/schema.py +++ /dev/null @@ -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" - } - } -} -""" \ No newline at end of file diff --git a/app/core/__init__.py b/app/core/__init__.py index 48cee40..e69de29 100644 --- a/app/core/__init__.py +++ b/app/core/__init__.py @@ -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) \ No newline at end of file diff --git a/app/core/cron.py b/app/core/cron.py index 77f4077..9788efc 100644 --- a/app/core/cron.py +++ b/app/core/cron.py @@ -3,26 +3,29 @@ import logging import time -from core import app -from core import processor -from models.comment import Comment +from core import mailer +from core import templater +from model.comment import Comment +from model.comment import Site logger = logging.getLogger(__name__) + def fetch_mail_answers(): - logger.info('DEBUT POP MAIL') + logger.info("DEBUT POP MAIL") time.sleep(80) - logger.info('FIN POP MAIL') - #data = request.get_json() - #logger.debug(data) + logger.info("FIN POP MAIL") + # data = request.get_json() + # logger.debug(data) + + # processor.enqueue({'request': 'new_mail', 'data': data}) - #processor.enqueue({'request': 'new_mail', 'data': data}) def submit_new_comment(): for comment in Comment.select().where(Comment.notified.is_null()): - # render email body template + comment_list = ( "author: %s" % comment.author_name, "site: %s" % comment.author_site, @@ -33,15 +36,10 @@ def submit_new_comment(): "", ) comment_text = "\n".join(comment_list) - email_body = get_template("new_comment").render(url=url, comment=comment_text) - - if clientip: - client_ips[comment.id] = clientip - - # send email - subject = "STACOSYS %s: [%d:%s]" % (site.name, comment.id, token) - mailer.send_mail(site.admin_email, subject, email_body) - logger.debug("new comment processed ") + email_body = templater.get_template("new_comment").render(url=comment.url, comment=comment_text) -def get_template(name): - return env.get_template(config.general["lang"] + "/" + name + ".tpl") + site = Site.select().where(Site.id == Comment.site).get() + # send email + subject = "STACOSYS %s: [%d:%s]" % (site.name, comment.id, site.token) + mailer.send_mail(site.admin_email, subject, email_body) + logger.debug("new comment processed ") diff --git a/app/core/database.py b/app/core/database.py index f6797d8..8404ea2 100644 --- a/app/core/database.py +++ b/app/core/database.py @@ -2,26 +2,15 @@ # -*- coding: UTF-8 -*- from conf import config -import functools from playhouse.db_url import connect 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) - 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) + get_db().create_tables([Site, Comment], safe=True) diff --git a/app/core/processor.py b/app/core/processor.py index b94636c..aa63c27 100644 --- a/app/core/processor.py +++ b/app/core/processor.py @@ -10,10 +10,8 @@ import json from datetime import datetime from threading import Thread from queue import Queue -from jinja2 import Environment -from jinja2 import FileSystemLoader -from models.site import Site -from models.comment import Comment +from model.site import Site +from model.comment import Comment from helpers.hashing import md5 from conf import config from core import mailer diff --git a/app/core/templater.py b/app/core/templater.py new file mode 100644 index 0000000..55d59fe --- /dev/null +++ b/app/core/templater.py @@ -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") diff --git a/app/helpers/hashing.py b/app/helpers/hashing.py index 207da43..fd48851 100644 --- a/app/helpers/hashing.py +++ b/app/helpers/hashing.py @@ -6,7 +6,7 @@ from conf import config def salt(value): - string = '%s%s' % (value, config.security['salt']) + string = "%s%s" % (value, config.get(config.SECURITY_SALT)) dk = hashlib.sha256(string.encode()) return dk.hexdigest() diff --git a/app/interface/__init__.py b/app/interface/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/interface/api.py b/app/interface/api.py index 025601c..8aba222 100644 --- a/app/interface/api.py +++ b/app/interface/api.py @@ -3,13 +3,13 @@ import logging from flask import request, jsonify, abort -from core import app -from models.site import Site -from models.comment import Comment +from model.site import Site +from model.comment import Comment +from conf import config from core import processor logger = logging.getLogger(__name__) - +app = config.flaskapp() @app.route("/ping", methods=['GET']) def ping(): diff --git a/app/interface/form.py b/app/interface/form.py index e1e67ec..36620e3 100644 --- a/app/interface/form.py +++ b/app/interface/form.py @@ -4,13 +4,13 @@ import logging from datetime import datetime from flask import request, abort, redirect -from core import app -from models.site import Site -from models.comment import Comment +from model.site import Site +from model.comment import Comment +from conf import config from helpers.hashing import md5 logger = logging.getLogger(__name__) - +app = config.flaskapp() @app.route("/newcomment", methods=["POST"]) def new_form_comment(): diff --git a/app/models/comment.py b/app/model/comment.py similarity index 95% rename from app/models/comment.py rename to app/model/comment.py index 10f7301..8cea605 100644 --- a/app/models/comment.py +++ b/app/model/comment.py @@ -6,7 +6,7 @@ from peewee import CharField from peewee import TextField from peewee import DateTimeField from peewee import ForeignKeyField -from models.site import Site +from model.site import Site from core.database import get_db diff --git a/app/models/site.py b/app/model/site.py similarity index 100% rename from app/models/site.py rename to app/model/site.py diff --git a/app/run.py b/app/run.py index a46b640..4dfb781 100644 --- a/app/run.py +++ b/app/run.py @@ -1,36 +1,90 @@ #!/usr/bin/python # -*- coding: UTF-8 -*- +import os import logging -import json from clize import Clize, run -from jsonschema import validate -from conf import config, schema +from flask import Flask +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): - jsondoc = None - with open(filename, 'rt') as json_file: - jsondoc = json.loads(json_file.read()) - return jsondoc + +class JobConfig(object): + + JOBS = [] + + 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 def stacosys_server(config_pathname): - # load and validate startup config - conf = load_json(config_pathname) - json_schema = json.loads(schema.json_schema) - validate(conf, json_schema) + app = Flask(__name__) + config.initialize(config_pathname, app) + + # configure logging + logger = logging.getLogger(__name__) + configure_logging(logging.INFO) + logging.getLogger("werkzeug").level = logging.WARNING + + # initialize database + from core import database + + 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 - # set configuration - config.general = conf['general'] - config.http = conf['http'] - config.security = conf['security'] - config.rss = conf['rss'] + app.run( + host=config.get(config.HTTP_HOST), + port=config.get(config.HTTP_PORT), + debug=False, + use_reloader=False, + ) - # start application - from core import app -if __name__ == '__main__': +if __name__ == "__main__": run(stacosys_server) diff --git a/config.ini b/config.ini new file mode 100755 index 0000000..c91f89d --- /dev/null +++ b/config.ini @@ -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