diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2910a27 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,9 @@ +FROM tiangolo/uwsgi-nginx-flask:python2.7 + +COPY ./ ./ + +EXPOSE 5000 + +RUN pip install -r requirements.txt + +CMD gunicorn --timeout 0 -c config.py wsgi diff --git a/README.md b/README.md index 3aa0e30..7718cc8 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,16 @@ Commands: clean Cleans up any temporary files (including... ``` +## Deployment + +If for any reason you need to use this tool on a server (maybe for your HR manager to easily use) you can build and deploy a docker image. + +Build +`docker build -t slack-export-viewer .` + +Run +`docker run -p 5000:5000 -d slack-export-viewer` + ### Examples ``` diff --git a/config.py b/config.py new file mode 100644 index 0000000..3fa628d --- /dev/null +++ b/config.py @@ -0,0 +1,3 @@ +# config for gunicorn server + +bind = '0.0.0.0:5000' diff --git a/requirements.txt b/requirements.txt index 7e2e5f2..50cf09a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,3 +2,4 @@ click flask markdown2 emoji +gunicorn diff --git a/slackviewer/app.py b/slackviewer/app.py index fa17a9a..4a818f9 100644 --- a/slackviewer/app.py +++ b/slackviewer/app.py @@ -1,4 +1,8 @@ +import os import flask +from flask import request, redirect, url_for, flash +from slackviewer.archive import extract_archive +from slackviewer.reader import Reader app = flask.Flask( @@ -7,14 +11,22 @@ static_folder="static" ) +app.config["UPLOAD_FOLDER"] = "archives" +app.config['MAX_CONTENT_LENGTH'] = 2 * 1024 * 1024 * 1024 # 2 GB limit + +reader = Reader() + +# these functions only fire when the route is navigated to @app.route("/channel//") def channel_name(name): - messages = flask._app_ctx_stack.channels[name] - channels = list(flask._app_ctx_stack.channels.keys()) - groups = list(flask._app_ctx_stack.groups.keys()) - dm_users = list(flask._app_ctx_stack.dm_users) - mpim_users = list(flask._app_ctx_stack.mpim_users) + if not hasattr(reader, 'channels'): + return flask.render_template("404.html") + messages = reader.channels[name] + channels = list(reader.channels.keys()) + groups = list(reader.groups.keys()) + dm_users = list(reader.dm_users) + mpim_users = list(reader.mpim_users) return flask.render_template("viewer.html", messages=messages, name=name.format(name=name), @@ -26,11 +38,13 @@ def channel_name(name): @app.route("/group//") def group_name(name): - messages = flask._app_ctx_stack.groups[name] - channels = list(flask._app_ctx_stack.channels.keys()) - groups = list(flask._app_ctx_stack.groups.keys()) - dm_users = list(flask._app_ctx_stack.dm_users) - mpim_users = list(flask._app_ctx_stack.mpim_users) + if not hasattr(reader, 'channels'): + return flask.render_template("404.html") + messages = reader.groups[name] + channels = list(reader.channels.keys()) + groups = list(reader.groups.keys()) + dm_users = list(reader.dm_users) + mpim_users = list(reader.mpim_users) return flask.render_template("viewer.html", messages=messages, name=name.format(name=name), @@ -42,11 +56,13 @@ def group_name(name): @app.route("/dm//") def dm_id(id): - messages = flask._app_ctx_stack.dms[id] - channels = list(flask._app_ctx_stack.channels.keys()) - groups = list(flask._app_ctx_stack.groups.keys()) - dm_users = list(flask._app_ctx_stack.dm_users) - mpim_users = list(flask._app_ctx_stack.mpim_users) + if not hasattr(reader, 'channels'): + return flask.render_template("404.html") + messages = reader.dms[id] + channels = list(reader.channels.keys()) + groups = list(reader.groups.keys()) + dm_users = list(reader.dm_users) + mpim_users = list(reader.mpim_users) return flask.render_template("viewer.html", messages=messages, id=id.format(id=id), @@ -58,11 +74,13 @@ def dm_id(id): @app.route("/mpim//") def mpim_name(name): - messages = flask._app_ctx_stack.mpims[name] - channels = list(flask._app_ctx_stack.channels.keys()) - groups = list(flask._app_ctx_stack.groups.keys()) - dm_users = list(flask._app_ctx_stack.dm_users) - mpim_users = list(flask._app_ctx_stack.mpim_users) + if reader.channels is None: + return flask.render_template("404.html") + messages = reader.mpims[name] + channels = list(reader.channels.keys()) + groups = list(reader.groups.keys()) + dm_users = list(reader.dm_users) + mpim_users = list(reader.mpim_users) return flask.render_template("viewer.html", messages=messages, name=name.format(name=name), @@ -72,9 +90,46 @@ def mpim_name(name): mpim_users=mpim_users) -@app.route("/") +ALLOWED_EXTENSIONS = set(["zip"]) + +def allowed_file(filename): + return "." in filename and \ + filename.rsplit(".", 1)[1].lower() in ALLOWED_EXTENSIONS + +@app.route("/", methods=["GET", "POST"]) +def upload(): + print('upload') + if request.method == "POST": + # check if the post request has the file part + print(request.files) + if "archive_file" not in request.files: + print('archive_file not in request.file') + flash("No file part") + return redirect(request.url) + file = request.files["archive_file"] + # if user does not select file, browser also + # submit a empty part without filename + if file.filename == "": + print("file name is empty") + flash("No selected file") + return redirect(request.url) + if file and allowed_file(file.filename): + print(os.path.abspath(file.filename)) + filename = os.path.abspath(file.filename) # change this to full file path for where it gets uploaded to + archive_path = extract_archive(filename) + reader.set_path(archive_path) + reader.get_all_messages() + return redirect(url_for("index")) + + reader.reset() + return flask.render_template("upload.html", reader=reader) + + +@app.route("/channel/") def index(): - channels = list(flask._app_ctx_stack.channels.keys()) + if not hasattr(reader, 'channels'): + return flask.render_template("404.html") + channels = list(reader.channels.keys()) if "general" in channels: return channel_name("general") else: diff --git a/slackviewer/archive.py b/slackviewer/archive.py index 3419ab0..4c93bc0 100644 --- a/slackviewer/archive.py +++ b/slackviewer/archive.py @@ -61,6 +61,7 @@ def extract_archive(filepath): print("{} already exists".format(extracted_path)) else: # Extract zip + print("extracting archive...") with zipfile.ZipFile(filepath) as zip: print("{} extracting to {}...".format(filepath, extracted_path)) zip.extractall(path=extracted_path) @@ -68,6 +69,7 @@ def extract_archive(filepath): print("{} extracted to {}".format(filepath, extracted_path)) # Add additional file with archive info + print("creating meta data for next time...") create_archive_info(filepath, extracted_path, archive_sha) return extracted_path @@ -100,4 +102,5 @@ def create_archive_info(filepath, extracted_path, archive_sha=None): ) as f: s = json.dumps(archive_info, ensure_ascii=False) s = to_unicode(s) + print("writing meta data to file...") f.write(s) diff --git a/slackviewer/main.py b/slackviewer/main.py index 976d92f..846fa1b 100644 --- a/slackviewer/main.py +++ b/slackviewer/main.py @@ -1,11 +1,10 @@ import webbrowser +import time,sys import click -import flask -from slackviewer.app import app +from slackviewer.app import app, reader from slackviewer.archive import extract_archive -from slackviewer.reader import Reader from slackviewer.utils.click import envvar, flag_ennvar @@ -14,17 +13,10 @@ def configure_app(app, archive, debug): if app.debug: print("WARNING: DEBUG MODE IS ENABLED!") app.config["PROPAGATE_EXCEPTIONS"] = True - + path = extract_archive(archive) - reader = Reader(path) - - top = flask._app_ctx_stack - top.channels = reader.compile_channels() - top.groups = reader.compile_groups() - top.dms = reader.compile_dm_messages() - top.dm_users = reader.compile_dm_users() - top.mpims = reader.compile_mpim_messages() - top.mpim_users = reader.compile_mpim_users() + reader.set_path(path) + reader.get_all_messages() @click.command() @@ -35,6 +27,7 @@ def configure_app(app, archive, debug): help="Path to your Slack export archive (.zip file or directory)") @click.option('-I', '--ip', default=envvar('SEV_IP', 'localhost'), type=click.STRING, help="Host IP to serve your content on") +@click.option('-U', '--upload', is_flag=True, help="Start server to upload your zip") @click.option('--no-browser', is_flag=True, default=flag_ennvar("SEV_NO_BROWSER"), help="If you do not want a browser to open " @@ -43,14 +36,22 @@ def configure_app(app, archive, debug): help="Runs in 'test' mode, i.e., this will do an archive extract, but will not start the server," " and immediately quit.") @click.option('--debug', is_flag=True, default=flag_ennvar("FLASK_DEBUG")) -def main(port, archive, ip, no_browser, test, debug): - if not archive: +def main(port, archive, ip, upload, no_browser, test, debug): + if not archive and not upload: raise ValueError("Empty path provided for archive") + if upload: + webbrowser.open("http://{}:{}".format(ip, port)) + app.run( + host=ip, + port=port + ) + return + configure_app(app, archive, debug) if not no_browser and not test: - webbrowser.open("http://{}:{}".format(ip, port)) + webbrowser.open("http://{}:{}/channel".format(ip, port)) if not test: app.run( diff --git a/slackviewer/reader.py b/slackviewer/reader.py index 85c9214..bb65c87 100644 --- a/slackviewer/reader.py +++ b/slackviewer/reader.py @@ -10,39 +10,72 @@ class Reader(object): Reader object will read all of the archives' data from the json files """ - def __init__(self, PATH): - self._PATH = PATH - # TODO: Make sure this works - with io.open(os.path.join(self._PATH, "users.json"), encoding="utf8") as f: - self.__USER_DATA = {u["id"]: u for u in json.load(f)} + def __init__(self, PATH=''): + if not PATH == '': + self._PATH = PATH + with io.open(os.path.join(self._PATH, "users.json"), encoding="utf8") as f: + self.__USER_DATA = {u["id"]: u for u in json.load(f)} ################## # Public Methods # ################## + def reset(self): + self.channels = None + self.groups = None + self.dms = None + self.dm_users = None + self.mpims = None + self.mpim_users = None + self._PATH = '' + self.__USER_DATA = None + + def get_all_messages(self): + """ + This method is used to call all of the compile methods at once. + """ + self.compile_channels() + self.compile_groups() + self.compile_dm_messages() + self.compile_dm_users() + self.compile_mpim_messages() + self.compile_mpim_users() + + def set_path(self, path): + """ + Sets the _PATH and readers the users json file to get the user data + """ + self._PATH = path + with io.open(os.path.join(self._PATH, "users.json"), encoding="utf8") as f: + self.__USER_DATA = {u["id"]: u for u in json.load(f)} + def compile_channels(self): + print("getting channels...") channel_data = self._read_from_json("channels.json") channel_names = [c["name"] for c in channel_data.values()] - return self._create_messages(channel_names, channel_data) + self.channels = self._create_messages(channel_names, channel_data) def compile_groups(self): + print("getting groups...") group_data = self._read_from_json("groups.json") group_names = [c["name"] for c in group_data.values()] - return self._create_messages(group_names, group_data) + self.groups = self._create_messages(group_names, group_data) def compile_dm_messages(self): + + print("getting dm messages...") # Gets list of dm objects with dm ID and array of members ids dm_data = self._read_from_json("dms.json") dm_ids = [c["id"] for c in dm_data.values()] # True is passed here to let the create messages function know that # it is dm data being passed to it - return self._create_messages(dm_ids, dm_data, True) + self.dms = self._create_messages(dm_ids, dm_data, True) def compile_dm_users(self): """ @@ -58,6 +91,8 @@ def compile_dm_users(self): """ + print("getting dm users...") + dm_data = self._read_from_json("dms.json") dms = dm_data.values() all_dms_users = [] @@ -68,15 +103,17 @@ def compile_dm_users(self): dm_members = {"id": dm["id"], "users": [self.__USER_DATA[m] for m in dm["members"]]} all_dms_users.append(dm_members) - return all_dms_users + self.dm_users = all_dms_users def compile_mpim_messages(self): + print("getting mpim messages...") + mpim_data = self._read_from_json("mpims.json") mpim_names = [c["name"] for c in mpim_data.values()] - return self._create_messages(mpim_names, mpim_data) + self.mpims = self._create_messages(mpim_names, mpim_data) def compile_mpim_users(self): """ @@ -92,6 +129,8 @@ def compile_mpim_users(self): """ + print("getting mpim users...") + mpim_data = self._read_from_json("mpims.json") mpims = [c for c in mpim_data.values()] all_mpim_users = [] @@ -100,7 +139,7 @@ def compile_mpim_users(self): mpim_members = {"name": mpim["name"], "users": [self.__USER_DATA[m] for m in mpim["members"]]} all_mpim_users.append(mpim_members) - return all_mpim_users + self.mpim_users = all_mpim_users ################### diff --git a/slackviewer/static/404.css b/slackviewer/static/404.css new file mode 100644 index 0000000..a24327b --- /dev/null +++ b/slackviewer/static/404.css @@ -0,0 +1,61 @@ +@import url('https://fonts.googleapis.com/css?family=Lato:400,900'); + +* { + font-family: 'Lato', sans-serif; +} + +body { + padding: 0; + margin: 0; + height: 100vh; + text-align: center; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + background-color: #f5f5f5 +} + +.container { + padding: 20px; + width: 350px; + background-color: white; + box-shadow: 0 0 15px 0 rgba(0,0,0,0.05); +} + +.container h1 { + margin: 4px; +} + +.container h5 { + font-weight: 400; + color: #c8c8c8; + margin-top: 0; +} + +.container form { + margin: 60px 30px 10px 0; + font-size: 14px; +} + +.container form .submit { + background-color: #3f46ad; + color: #fff; + border-radius: 3px; + text-transform: uppercase; + font-size: 14px; + display: block; + margin-top: 16px; + padding: 11px 30px 12px; + border: none; + +} + +.container form .submit:hover { + cursor: pointer; + background-color: #393f9e; +} + +.container form .submit:active, :focus { + outline: none +} diff --git a/slackviewer/static/assets/images/Slack_Mark_Web.png b/slackviewer/static/assets/images/Slack_Mark_Web.png new file mode 100644 index 0000000..ef3b211 Binary files /dev/null and b/slackviewer/static/assets/images/Slack_Mark_Web.png differ diff --git a/slackviewer/static/upload.css b/slackviewer/static/upload.css new file mode 100644 index 0000000..639d3cd --- /dev/null +++ b/slackviewer/static/upload.css @@ -0,0 +1,70 @@ +@import url('https://fonts.googleapis.com/css?family=Lato:400,900'); + +* { + font-family: 'Lato', sans-serif; +} + +body { + padding: 0; + margin: 0; + height: 100vh; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + background-color: #f5f5f5 +} + +.image { + margin-bottom: 20px; +} + +.image img { + width: 100px; +} + +.container { + padding: 20px; + width: 350px; + background-color: white; + box-shadow: 0 0 15px 0 rgba(0,0,0,0.05); +} + + + +.container h1 { + margin: 4px; +} + +.container h5 { + font-weight: 400; + color: #c8c8c8; + margin-top: 0; +} + +.container form { + margin: 60px 30px 10px 0; + font-size: 14px; +} + +.container form .submit { + background-color: #3f46ad; + color: #fff; + border-radius: 3px; + text-transform: uppercase; + font-size: 14px; + display: block; + margin-top: 16px; + padding: 11px 30px 12px; + border: none; + +} + +.container form .submit:hover { + cursor: pointer; + background-color: #393f9e; +} + +.container form .submit:active, :focus { + outline: none +} diff --git a/slackviewer/templates/404.html b/slackviewer/templates/404.html new file mode 100644 index 0000000..c32c466 --- /dev/null +++ b/slackviewer/templates/404.html @@ -0,0 +1,19 @@ + + + + + Slack Export - #{{ name }} + + + +
+
+ Slack +
+

404

+
Page not found.
+ +
+ + diff --git a/slackviewer/templates/upload.html b/slackviewer/templates/upload.html new file mode 100644 index 0000000..dc9da22 --- /dev/null +++ b/slackviewer/templates/upload.html @@ -0,0 +1,23 @@ + + + + + Slack Export - #{{ name }} + + + +
+ Slack +
+
+

Upload an archive

+
It has to be a .zip file
+
+ + +
+ +
+ + diff --git a/wsgi.py b/wsgi.py new file mode 100755 index 0000000..a664b21 --- /dev/null +++ b/wsgi.py @@ -0,0 +1,10 @@ +""" +It exposes the WSGI callable as a module-level variable named ``application``. + +Just makes the app availible as application for gunicorn + +For more information on this file, see +https://docs.djangoproject.com/en/1.6/howto/deployment/wsgi/ +""" + +from slackviewer.app import app as application