From c35389d77608fa78affe4eee331970897707bc97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabriel=20P=C3=A9rez-Cerezo?= Date: Fri, 6 Jul 2018 17:34:38 +0200 Subject: first commit --- export/static/excl.png | Bin 0 -> 449 bytes export/styles.css | 132 ++++++++++++++++++ hemiptera | 364 ++++++++++++++++++++++++++++++++++++++++++++++++ hemiptera_avatar_gen.py | 52 +++++++ hemiptera_common.py | 25 ++++ hemiptera_html.py | 117 ++++++++++++++++ 6 files changed, 690 insertions(+) create mode 100644 export/static/excl.png create mode 100644 export/styles.css create mode 100755 hemiptera create mode 100644 hemiptera_avatar_gen.py create mode 100644 hemiptera_common.py create mode 100644 hemiptera_html.py diff --git a/export/static/excl.png b/export/static/excl.png new file mode 100644 index 0000000..7be223f Binary files /dev/null and b/export/static/excl.png differ diff --git a/export/styles.css b/export/styles.css new file mode 100644 index 0000000..51f797e --- /dev/null +++ b/export/styles.css @@ -0,0 +1,132 @@ +.container { + border: 1px solid #aaa; + margin: 1em; + margin-top:0; + margin-bottom:-1px; + padding: 1em; + /* border-radius: 0.5em;*/ + background-color: #eee; + max-width: 35em; + padding-bottom: 0; +} + +header { + border: 1px solid #aaa; + padding: 1em; + padding-top: 0.1em; + background-color: #eee; + margin: 0; + margin-bottom: 1em; + max-width: 40em; +} + +.avatar { + height: 3em; + float: left; + margin-right: 0.7em; +} + + +.subject { + color: #0a0; +} + +body { + font-family: sans-serif; + margin: 0; +} + +.container:first-of-type { + border-radius: 0.5em 0.5em 0 0 ; +} + +.container:last-of-type { + border-radius: 0 0 0.5em 0.5em; +} + +table tr:nth-child(odd) { + background: #eed; +} + +.date, thead td { + border-bottom: 1px solid #aaa; + background-color: #dde; + padding: 0.5em; + margin: -1em; + margin-bottom: 0em; + height: 3em; +} + +thead td { + padding: 0.5em; +} +td { + padding: 0.3em; + overflow: ellipsis; +} + +tr.closed { + background: #dee; +} +table tr.closed:nth-child(odd) { + background: #cdd; +} +table { + border: 1px solid #aaa; + padding: 0; +} + +img { + image-rendering: -moz-crisp-edges; + image-rendering: -moz-crisp-edges; + image-rendering: -o-crisp-edges; + image-rendering: -webkit-optimize-contrast; + -ms-interpolation-mode: nearest-neighbor; +} + +.status-header { + background-color: #ddd; +} + +.op { + background-color: #ded; +} + +.dev { + background-color: #edd; +} + +.opdev { + background-color: #ede; +} + +.container:first-of-type .date { + border-radius: 0.5em 0.5em 0 0; +} + +.reply { + font-family: monospace; + white-space: pre-wrap; + counter-reset: line; +} +.reply p { + counter-increment: line; + margin: 0.2em; + margin-left: 3em; + background-color: #ddd; +} +.reply p::before { + content: "" counter(line) ""; +/* font-size: 0.7em;*/ + display: inline-block; + width: 2em; + margin-right: 1em; + text-align: right; + margin-left: -3em; + color: #a00; +} + +table { + margin: 1em +} + diff --git a/hemiptera b/hemiptera new file mode 100755 index 0000000..7dc0bcf --- /dev/null +++ b/hemiptera @@ -0,0 +1,364 @@ +#!/usr/bin/python3 + +## Hemiptera: +## Simple E-Mail-based Bugtracker + + + + + +# +# DIRECTORY STRUCTURE + +# basedir +# ├── bugcount <- File keeping track of the count of bugs +# ├── bugs ← For direct access to bugs for fast reply indexing +# │ ├── 388 ⇒ basedir/projects/foo-bar/388 +# │ └── 498 ⇒ basedir/projects/bar-baz/498 +# ├── inbox ← Emails arrive here +# ├── lock ⇒ hemiptera:$PID ← Filesystem lock +# ├── outbox +# │ ├── 1529069051.7963576.bugreply +# │ └── 1529069051.7971687.bugreply +# ├── projects ← Directory containing bugs, sorted by directory +# │ ├── foo-bar +# │ │ ├── 388 ← Directory containing messages for bug 388 +# │ │ │ ├── 1528380502.4440382 +# │ │ │ ├── 1529069051.7901955 +# │ │ │ └── subscribers ← File containing subscribers of bug 388 +# │ │ └── devs.txt ← File containing addresses of developers of foo-bar +# │ └── bar-baz +# │ ├── [...] +# └── unconfirmed +# ├── 0f61caf5ff24b5871835801c008341e7347928ba +# ├── 8751c708556bb583c19b6ed87a891c3f25b99e2b +# +import email +from email import policy +from email.parser import BytesParser, Parser +from email.utils import parseaddr +import email.utils +import sys +import os +import time +from hemiptera_common import * +import glob +LOCKPATH =opj(basedir, "lock") + +class Bug : + def __init__(self, path) : + self.path = path + replies = glob.glob(os.path.join(self.path, "[0-9]*")) + def get_subs(self) : + if os.path.exists(opj(self.path, "subscribers")) : + with open(os.path.join(self.path, "subscribers"), "r+" ) as f : + return [line.strip() for line in f] + else : + return [] + def set_subs(self, subs) : + with open(opj(self.path, "subscribers", "w" )) as f : + f.write("\n".join(subs)) + f.close() + def get_message(self, i) : + # get i-th reply to the bug + f = open(replies[i], "rb") + m = BytesParser(policy=policy.default).parse(f) + f.close() + return m + def get_op(self) : + m = self.get_message(0) + return m["From"] + def get_subject(self) : + m = self.get_message(0) + return m["Subject"] + + +def check_lock() : + if os.path.islink(LOCKPATH) : + sys.stderr.write("ERROR: Directory locked by "+os.readlink(LOCKPATH)+". Aborting\n") + sys.exit(255) +def get_lock() : + """ Get a filesystem lock, as emacs does it (symlink to PID)""" + check_lock() + os.symlink("hemiptera:"+str(os.getpid()), LOCKPATH) + +def remove_lock() : + os.remove(LOCKPATH) + +def get_subs(bug) : + """ Get e-mail addresses of people who subscribed to bug """ + if os.path.exists(os.path.join(bug, "subscribers")) : + with open(os.path.join(bug, "subscribers"), "r+" ) as f : + return [line.strip() for line in f] + else : + return None + +def write_subs(bug, subs) : + with open(os.path.join(bug, "subscribers"), "w") as f : + f.write("\n".join(subs)) + f.close() + +def get_op(bug) : + r = glob.glob(os.path.join(bug, "[0-9]*")) + f = open(r[0], "rb") + m = BytesParser(policy=policy.default).parse(f) + f.close() + return m["From"] + +def write_message(path, m) : + """ Write message m to path """ + f = open(path, "wb") + f.write(m.as_bytes()) + f.close() + +def get_message_data(m) : + """ returns subject and content of message """ + content = m.get_body("plain").get_content() + subject = m["Subject"] + return subject,content + +def get_from_addr(m) : + """ returns the from address of a message """ + return parseaddr(m["From"])[1] +def get_to_addr(m) : + """ returns the to address of a message """ + return parseaddr(m["To"])[1] + + +def generate_confirmation_id() : + """ Generates a confirmation ID for text """ + return os.urandom(8).hex() + + +def create_censored_message(msg, TrustedDate=False) : + """This transforms a mail message received by the script into a +message which was stripped of any names and other personal information. +Saved stuff: + - Email address (No name) + - Date + - Subject + - Body + - references and In-Reply-To (if they exist) +In a further step, when generating HTML, e-mail addresses will also get stripped. + +The TrustedDate Parameter indicates whether the date from the message + is to be trusted. Otherwise, it is replaced with the date at which + this function is run. + + """ + m = email.message.EmailMessage() + m.set_payload(msg.get_body("plain").get_content(), charset="utf-8") + m["From"] = get_from_addr(msg) + m["To"] = get_to_addr(msg) + m["Subject"] = msg["Subject"] + if not "Date" in msg or not TrustedDate : + del m["Date"] + m["Date"] = email.utils.formatdate() + else : + m["Date"] = msg["Date"] + if "In-Reply-To" in msg : + m["In-Reply-To"] = msg["In-Reply-To"] + if "References" in msg: + m["References"] = msg["References"] + m["Message-ID"] = email.utils.make_msgid(domain=DOMAIN) + return m + + +def gen_message(body,subject, to, fr, headers=None) : + r = email.message.EmailMessage() + r["To"] = to + r["From"] = fr + r["Subject"] = subject + r.set_payload(body, charset="utf-8") + os.makedirs(outbox, exist_ok=True) + fname = opj(outbox, str(time.time())+".bugreply") + write_message(fname, r) + + +def reply(body, subject, to, bugid) : + """ This function generates a reply message""" + fr = str(bugid) + "@" + DOMAIN + gen_message(body,subject,to,fr) + + +def get_project(bug) : + """Get project name for bug path pointing into global bug directory""" + dest = os.readlink(bug) + return dest.split(os.sep)[-2] + +def create_unconfirmed_bug(msg) : + ## send back confirmation message + get_lock() + fromaddr = get_from_addr(msg) + confid = generate_confirmation_id() + print("ID: "+confid) + bugpath = os.path.join(basedir, "unconfirmed" , confid) + fr = "confirm-bug@" + DOMAIN + subject, _ = get_message_data(msg) + gen_message(confid,subject,fromaddr,fr) + os.makedirs(bugpath) + m = create_censored_message(msg) + write_message(os.path.join(bugpath, "message"), m) + remove_lock() + return + +def create_bug(prname, msg) : + get_lock() + bak = open(opj(basedir, "bugcount.bak"), "w") + try : + bg = open("bugcount", "r+") + bugcount = int(bg.read()) + bak.write(str(bugcount)) + bak.close() + bg.close() + bg = open("bugcount", "w") + except FileNotFoundError : + bg = open("bugcount", "w") + bg.write("0") + bugcount = 0 + bg.close() + bg = open("bugcount", "w") + bugcount += 1 + bg.write(str(bugcount)) + bugpath = os.path.join(basedir, "projects" , prname,str(bugcount)) + os.makedirs(bugpath) + subs = get_devs(prname) + fromaddr = get_from_addr(msg) + if fromaddr not in subs : + subs.append(fromaddr) + + os.symlink( bugpath, os.path.join(bugdir, str(bugcount)), target_is_directory=True) + + m = create_censored_message(msg) + subject,content = get_message_data(m) + write_message(os.path.join(bugpath, str(time.time())), m) + write_subs(bugpath, subs) + + print( + + """ +You have sent a bug report to %s. + +It has been processed under the id %d + + + """ % (prname, bugcount)) + for sub in subs : + ### Send out an e-mail to each subscriber + reply(content, subject, sub, str(bugcount)) + remove_lock() + + +def reply_to_bug(bugname, msg) : + get_lock() + bug = os.path.join(bugdir, bugname) + fromaddr = get_from_addr(msg) + cm = create_censored_message(msg) + subject,content = get_message_data(cm) + write_message(os.path.join(bug, str(time.time())), cm) + print(""" +Your reply to bug ID %s has been processed + """%bugname) + subs = get_subs(bug) + if not fromaddr in subs : + subs.append(fromaddr) + write_subs(bug, subs) + for sub in subs : + reply(content, subject, sub, bugname) + remove_lock() + +def process_command(bugname, msg) : + bug = os.path.join(bugdir, bugname) + fromaddr = get_from_addr(msg) + cm = create_censored_message(msg) + subject,content = get_message_data(cm) + if "%close" in content : + f = open(opj(bug, "closed"), "w") + f.write(cm["Date"]) + f.close() + subs = get_subs(bug) + for sub in subs : + reply("This bug was closed", "Bug #%s closed" % bugname, sub, bugname) + + +def import_bugs() : + """ Imports bugs from Inbox """ + os.makedirs(inbox, exist_ok=True) + for i in os.listdir(inbox) : + g = open(opj(inbox, i), "rb") + m = BytesParser(policy=policy.default).parse(g) + process_message(m) + os.remove(opj(inbox, i)) + +def process_message(m) : + toaddr = get_to_addr(m) + os.makedirs(bugdir, exist_ok=True) + dest = toaddr.split("@")[0] + if dest in prlist : + create_unconfirmed_bug(m) + elif dest in os.listdir(bugdir) : + reply_to_bug(dest, m) + elif dest.split("-")[0] in os.listdir(bugdir) and dest.split("-")[1] == "command" : + ### Process command + print("Command spotted") + process_command(dest.split("-")[0],m) + elif dest.startswith("confirm-bug") : + unconf_dir = opj(basedir, "unconfirmed") + subject, content = get_message_data(m) + for i in os.listdir(unconf_dir) : + if i in content : + f = open(opj(unconf_dir, i, "message"), "rb") + m = BytesParser(policy=policy.default).parse(f) + f.close() + p = parseaddr(m["To"])[1].split("@")[0] + create_bug(p,m) + import shutil + shutil.rmtree(opj(unconf_dir, i)) + exit(0) + print("Invalid activation id") + else : + print("Invalid message") + + + + +import_bugs() + +# m = BytesParser(policy=policy.default).parse(g) + +# fromaddr = get_from_addr(m) +# toaddr = get_to_addr(m) +# subject,content = get_message_data(m) +# bugdir = os.path.join(basedir, "bugs") +# os.makedirs(bugdir, exist_ok=True) + +# prname = toaddr.split("@")[0] +# print(prname) +# if prname in prlist : +# create_unconfirmed_bug(m) +# elif prname in os.listdir(bugdir) : +# reply_to_bug(prname, m) +# elif prname.split("-")[0] in os.listdir(bugdir) and prname.split("-")[1] == "command" : +# ### Process command +# print("Command spotted") +# process_command(prname.split("-")[0],m) +# elif prname.startswith("confirm-bug") : +# unconf_dir = opj(basedir, "unconfirmed") +# for i in os.listdir(unconf_dir) : +# if i in content : +# f = open(opj(unconf_dir, i, "message"), "rb") +# m = BytesParser(policy=policy.default).parse(f) +# f.close() +# p = parseaddr(m["To"])[1].split("@")[0] +# create_bug(p,m) +# import shutil +# shutil.rmtree(opj(unconf_dir, i)) +# exit(0) +# print("Invalid activation id") +# else : +# print("Invalid message") + + + + + diff --git a/hemiptera_avatar_gen.py b/hemiptera_avatar_gen.py new file mode 100644 index 0000000..db380a9 --- /dev/null +++ b/hemiptera_avatar_gen.py @@ -0,0 +1,52 @@ +import hashlib +import random +from PIL import Image, ImageDraw +from hemiptera_common import * +import os + +SERVER_SALT="foobar" + +D = 7 +pixel_width=1 +def hash_generator(h) : + index = 0 + while True : + yield int(h[index],16) + index +=1 + if index == len(h) : ## Keep images interesting if size is significantly larger than hash input + index = 0 + h = hashlib.sha1(h.encode()).hexdigest() + + +def gen_color(h) : + return tuple(tuple(next(h)*16 for i in range(3)) for i in range(2)) + +def gen_image(h) : + h = hash_generator(h) + c1, c2 = gen_color(h) + im = Image.new("RGBA", (D,D)) + draw = ImageDraw.Draw(im) + index = 0 + for i in range(D//2+1) : + for j in range(D) : + k = next(h) + if k % 2 or k//2 % 2 : + if k % 2 : + color = c1 + else : + color = c2 + draw.point([(i,j), (D-1-i,j)], fill=color) + return im + + +def generate_avatar(email) : + if email is None : + email = "" + h = hashlib.sha1(str(email+SERVER_SALT).encode()).hexdigest() + imgpath = opj(basedir, "export", "static") + img = gen_image(h) + os.makedirs(imgpath, exist_ok=True) + img.save(opj(imgpath, h+".png")) + return "/static/" + h+".png" + + diff --git a/hemiptera_common.py b/hemiptera_common.py new file mode 100644 index 0000000..6204f62 --- /dev/null +++ b/hemiptera_common.py @@ -0,0 +1,25 @@ +import email +from email import policy +from email.parser import BytesParser +from email.utils import parseaddr +import sys +import os +import time +opj = os.path.join + +DOMAIN = "bugs.bananach.space" +prlist = ["advtrains", "moreblocks", "foo"] +basedir = "" +basedir = os.path.abspath("") +outbox = opj(basedir, "outbox") +inbox = os.path.join(basedir, "inbox") +bugdir = os.path.join(basedir, "bugs") + +def get_devs(project) : + """ Get list of developer addresses for project """ + devfile = os.path.join(basedir,"projects",project,"devs.txt") + devs = [] + if os.path.exists(devfile): + f = open(devfile, "r") + devs = [ line.strip() for line in f ] + return devs diff --git a/hemiptera_html.py b/hemiptera_html.py new file mode 100644 index 0000000..6596c13 --- /dev/null +++ b/hemiptera_html.py @@ -0,0 +1,117 @@ +#!/usr/bin/python + +## HTML exporter for hemiptera +from hemiptera_common import * +import glob + +from email.utils import parsedate_tz, mktime_tz, formatdate, parsedate_to_datetime +from jinja2 import Environment, FileSystemLoader +templatedir = os.path.join(os.path.dirname(os.path.realpath(__file__)), "templates") +file_loader = FileSystemLoader(templatedir) + +env = Environment(loader=file_loader) +template = env.get_template("main_page.html") +buglist = env.get_template("buglist.html") +bugt = env.get_template("bug.html") +from hemiptera_avatar_gen import generate_avatar +export = opj(basedir, "export") + +## TODO: Choose template system, create nice template +class Project() : + pass +class Bug() : + pass +### https://stackoverflow.com/questions/410221/natural-relative-days-in-python +import datetime +def prettydate(d): + if not d.tzname() : + d = d.replace(tzinfo=datetime.timezone.utc) + diff = datetime.datetime.utcnow().replace(tzinfo=datetime.timezone.utc) - d + s = diff.seconds + if diff.days > 7 or diff.days < 0: + return d.strftime('%Y-%m-%d') + elif diff.days == 1: + return '1 day ago' + elif diff.days > 1: + return '{} days ago'.format(diff.days) + elif s <= 1: + return 'just now' + elif s < 60: + return '{} seconds ago'.format(s) + elif s < 120: + return '1 minute ago' + elif s < 3600: + return '{} minutes ago'.format(s//60) + elif s < 7200: + return '1 hour ago' + else: + return '{} hours ago'.format(s//3600) + + + +def create_project_list() : + projects = [] + for i in os.listdir(opj(basedir, "projects")) : + k = Project() + k.name = i + k.count = len(os.listdir(opj(basedir, "projects", i))) -1 + projects.append(k) + bugs =[] + os.makedirs(opj(export, i), exist_ok=True) + devs = get_devs(i) + for j in glob.glob(opj(basedir, "projects", i, "[0-9]*")) : + b = Bug() + b.closed = os.path.exists(opj(j, "closed")) + if b.closed : + b.closeddate = open(opj(j,"closed"), "r").read() + + b.id = os.path.basename(j) + r = glob.glob(opj(j, "[0-9]*")) + r.sort() + b.replies = len(r) + f = open(r[0], "rb") + m = BytesParser(policy=policy.default).parse(f) + b.subject = m["Subject"] + if not b.subject : + b.subject = "No subject" + bugs.append(b) + replies = [] + for k in r : + f = open(k, "rb") + m = BytesParser(policy=policy.default).parse(f) + m["Avatar"] = generate_avatar(m["From"]) + replies.append(m) + b.created = replies[0]["Date"] + if b.created : + b.nicecreated = prettydate(parsedate_to_datetime(b.created)) + else : + b.nicecreated = "unknown" + b.last_reply = replies[-1]["Date"] + if b.last_reply : + b.nicereply = prettydate(parsedate_to_datetime(b.last_reply)) + else : + b.nicereply = "unknown" + f = open(opj(export, i, str(b.id)+".html"), "w") + f.write(bugt.render(DOMAIN=DOMAIN, replies=replies, prname=i, bug=b, devs=devs)) + bugs.sort(key=lambda b: int(b.id), reverse=True) + bugs.sort(key=lambda b: int(b.closed)) + f = open(opj(export, i, "index.html"), "w") + f.write(buglist.render(DOMAIN=DOMAIN, bugs=bugs, prname=i)) + f.close() + f = open(opj(export, "index.html"), "w") + f.write(template.render(DOMAIN=DOMAIN, projects=projects)) + ## Create the project list + + +def create_buglist(project) : + ## Create bug list for project + pass + +def show_bug(bug_nr): + ## Display all the replies to bug with number bug_nr + pass + + + +if __name__ == "__main__" : + create_project_list() -- cgit v1.2.3