mirror of https://github.com/percyliang/sempre
574 lines
18 KiB
Python
Executable File
574 lines
18 KiB
Python
Executable File
#!/usr/bin/env python
|
|
|
|
"""
|
|
SHRDLURN - Community & Logging Server
|
|
|
|
# Instructions
|
|
|
|
You can run the server by running ```./server.py --port <PORT_NUMBER>```
|
|
"""
|
|
|
|
import json
|
|
import sys
|
|
import time
|
|
import os
|
|
import random
|
|
import eventlet
|
|
import glob
|
|
from optparse import OptionParser
|
|
from flask import Flask, request, session
|
|
from flask_cors import CORS
|
|
from flask_socketio import SocketIO, emit, join_room, leave_room
|
|
import jwt
|
|
import requests
|
|
|
|
# Setup flask
|
|
app = Flask(__name__)
|
|
app.config['SECRET_KEY'] = os.urandom(24)
|
|
|
|
# We need to enable CORS support to handle CORS flights from the frontend
|
|
CORS(app)
|
|
|
|
# The community server runs through websockets to enable real-time updates
|
|
socketio = SocketIO(app)
|
|
|
|
# Hardcoded folders for the data (mirrored in data_rotate.py)
|
|
DATA_FOLDER = "community-server/data/"
|
|
LOG_FOLDER = os.path.join(DATA_FOLDER, "log/")
|
|
STRUCTS_FOLDER = os.path.join(DATA_FOLDER, "structs/")
|
|
|
|
CITATION_FOLDER = "../int-output/citation"
|
|
|
|
# Scoring function parameters
|
|
GRAVITY = 1.1 # higher the gravity, the faster old structs lose score
|
|
TIME_INTERVAL = 7200.0 # break off by every 30 minutes
|
|
|
|
# Default port for the server
|
|
DEFAULT_PORT = 8406
|
|
|
|
JWT_SECRET = os.environ['SEMPRE_JWT_SECRET']
|
|
SLACK_SECRET = os.environ['SLACK_OAUTH_SECRET']
|
|
|
|
|
|
@app.route("/")
|
|
def index():
|
|
return "Hello World! ~ SHRDLURN Community Server"
|
|
|
|
|
|
def is_safe_path(basedir, path, follow_symlinks=True):
|
|
"""https://security.openstack.org/guidelines/dg_using-file-paths.html"""
|
|
# resolves symbolic links
|
|
if follow_symlinks:
|
|
return os.path.realpath(path).startswith(os.path.realpath(basedir))
|
|
|
|
return os.path.abspath(path).startswith(os.path.abspath(basedir))
|
|
|
|
|
|
def score_struct(timestamp, upvotesN):
|
|
"""We use the HN formula to score structures for ranking.
|
|
|
|
Formula is: (P + 1) / ((T + 2)^GRAVITY)
|
|
where: - P: the number of unique upvotes for the structure
|
|
- T: the amount of TIME_INTERVALs that have elapsed since the
|
|
structure was submitted
|
|
- GRAVITY: a constant to determine the weight of T v. P
|
|
"""
|
|
time_ago = (current_unix_time() / TIME_INTERVAL) - \
|
|
(int(timestamp) / TIME_INTERVAL)
|
|
return (upvotesN + 1) / ((time_ago + 2) ** GRAVITY)
|
|
|
|
|
|
def current_unix_time():
|
|
"""Returns the number of seconds since the epoch."""
|
|
return int(time.time())
|
|
|
|
|
|
def emit_structs():
|
|
"""Walk through the STRUCTS_FOLDER directory and read each struct and emit
|
|
it to the user one by one."""
|
|
|
|
structs = []
|
|
for uid in [name for name in os.listdir(STRUCTS_FOLDER) if os.path.isdir(os.path.join(STRUCTS_FOLDER, name))]:
|
|
uid_folder = os.path.join(STRUCTS_FOLDER, uid)
|
|
|
|
count = 0
|
|
for name in os.listdir(uid_folder):
|
|
if count > 100:
|
|
break
|
|
|
|
path = os.path.join(uid_folder, name)
|
|
if not os.path.isfile(path):
|
|
continue
|
|
|
|
fname = name[:-5]
|
|
|
|
try:
|
|
with open(path, 'r') as f:
|
|
lines = f.readlines()
|
|
|
|
upvotes = json.loads(lines[0].strip())
|
|
timestamp = json.loads(lines[1].strip())
|
|
struct = json.loads(lines[2].strip())
|
|
image = lines[3].strip()
|
|
|
|
score = score_struct(timestamp, len(upvotes))
|
|
|
|
message = {"uid": uid, "id": fname, "score": score, "upvotes": [
|
|
up for up in upvotes], "struct": struct, "image": image}
|
|
|
|
structs.append(message)
|
|
count += 1
|
|
except:
|
|
pass
|
|
|
|
emit("structs", structs)
|
|
|
|
|
|
def emit_user_structs_count(uid):
|
|
""""Emits a count of the total number of user structs in the folder."""
|
|
path = os.path.join(STRUCTS_FOLDER, uid)
|
|
if not is_safe_path(STRUCTS_FOLDER, path):
|
|
return
|
|
structs = [name[:-5] for name in os.listdir(path) if os.path.isfile(
|
|
os.path.join(path, name)) and os.path.join(path, name).endswith(".json")]
|
|
if os.path.isdir(path):
|
|
structs = [name[:-5] for name in os.listdir(path) if os.path.isfile(
|
|
os.path.join(path, name)) and os.path.join(path, name).endswith(".json")]
|
|
emit("user_structs", {"structs": structs})
|
|
|
|
|
|
def emit_utterances():
|
|
"""Emit a list of the last 11 utterances for the 5 most recent turkers."""
|
|
latest_5 = []
|
|
for dirname, subdirs, files in os.walk(LOG_FOLDER):
|
|
for fname in files:
|
|
path = os.path.join(dirname, fname)
|
|
|
|
mtime = os.stat(path).st_mtime
|
|
file_info = (mtime, fname[:-5], path)
|
|
|
|
if len(latest_5) < 3:
|
|
latest_5.append(file_info)
|
|
else:
|
|
earliest_time = latest_5[0][0]
|
|
earliest_idx = 0
|
|
for idx, l in enumerate(latest_5):
|
|
if l[0] < earliest_time:
|
|
earliest_time = l[0]
|
|
earliest_idx = idx
|
|
|
|
if mtime > earliest_time:
|
|
latest_5[earliest_idx] = file_info
|
|
|
|
for (time, uid, path) in sorted(latest_5, key=lambda s: int(s[0]), reverse=True):
|
|
uid = uid
|
|
utts = []
|
|
count = 0
|
|
for line in reverse_readline(path):
|
|
data = json.loads(line)
|
|
if (data["type"] == "accept" or data["type"] == "define"):
|
|
utts.append(line)
|
|
count += 1
|
|
|
|
if count > 10:
|
|
break
|
|
|
|
message = {"uid": uid, "utterances": utts}
|
|
emit("utterances", message)
|
|
|
|
|
|
def h_index(citations):
|
|
"""https://github.com/kamyu104/LeetCode/blob/master/Python/h-index.py"""
|
|
n = len(citations)
|
|
count = [0] * (n + 1)
|
|
for x in citations:
|
|
# Put all x >= n in the same bucket.
|
|
if x >= n:
|
|
count[n] += 1
|
|
else:
|
|
count[x] += 1
|
|
|
|
h = 0
|
|
for i in reversed(xrange(0, n + 1)):
|
|
h += count[i]
|
|
if h >= i:
|
|
return i
|
|
return h
|
|
|
|
|
|
def compute_citations(dir):
|
|
citations = []
|
|
for fname in os.listdir(dir):
|
|
if not fname.endswith(".json"):
|
|
continue
|
|
|
|
path = os.path.join(dir, fname)
|
|
|
|
with open(path, 'r') as f:
|
|
data = json.load(f)
|
|
citations.append(data)
|
|
|
|
citation_numbers = [citation["cite"] + citation["self"]
|
|
for citation in citations]
|
|
citation_score = h_index(citation_numbers)
|
|
|
|
return (citations, citation_score)
|
|
|
|
|
|
def emit_top_builders():
|
|
top_5_builders = []
|
|
for uid in os.listdir(CITATION_FOLDER):
|
|
subdir = os.path.join(CITATION_FOLDER, uid)
|
|
if not os.path.isdir(subdir):
|
|
continue
|
|
|
|
top_5_builders = sorted(
|
|
top_5_builders, key=lambda b: b[1], reverse=True)
|
|
|
|
(citations, citation_score) = compute_citations(subdir)
|
|
|
|
top_5_builders = sorted(
|
|
top_5_builders, key=lambda b: b[1], reverse=True)
|
|
if len(top_5_builders) < 10 or citation_score > top_5_builders[9][1]:
|
|
# If there are more than 5 citations with cites, only return those
|
|
citations = sorted(
|
|
citations, key=lambda c: c["cite"] + c["self"], reverse=True)[:10]
|
|
# if len(citations_with_cites) >= 6:
|
|
# citations = citations_with_cites
|
|
|
|
# Sort them by score and return the top 7.
|
|
citations = sorted(
|
|
citations, key=lambda c: c["cite"] + c["self"], reverse=True)[:10]
|
|
|
|
struct = (uid, citation_score, citations)
|
|
if len(top_5_builders) < 10:
|
|
top_5_builders.append(struct)
|
|
else:
|
|
top_5_builders[9] = struct
|
|
|
|
emit("top_builders", {"top_builders": top_5_builders},
|
|
broadcast=True, room="community")
|
|
|
|
|
|
def log(message):
|
|
"""Logs the given message by writing it in the uid's JSON log file."""
|
|
uid = message["uid"] if 'uid' in message else "NULL_session"
|
|
|
|
user = current_user(message['token'])
|
|
if user:
|
|
uid = user['id']
|
|
|
|
path = os.path.join(LOG_FOLDER, uid + ".json")
|
|
|
|
if not is_safe_path(LOG_FOLDER, path):
|
|
print("NOT SAFE!", path)
|
|
return
|
|
|
|
# Add a timestamp to the log
|
|
message["timestamp"] = current_unix_time()
|
|
|
|
# Append the log to the end of the file
|
|
with open(path, 'a') as f:
|
|
json.dump(message, f)
|
|
f.write('\n')
|
|
|
|
|
|
@socketio.on('getscore')
|
|
def get_score(data):
|
|
user = current_user(data['token'])
|
|
if not user:
|
|
return
|
|
|
|
uid = user['id']
|
|
subdir = os.path.join(CITATION_FOLDER, uid)
|
|
if (os.path.isdir(subdir) and is_safe_path(CITATION_FOLDER, subdir)):
|
|
(citations, score) = compute_citations(subdir)
|
|
emit("score", {"score": score})
|
|
|
|
|
|
@socketio.on('delete_struct')
|
|
def delete_struct(data):
|
|
user = current_user(data['token'])
|
|
|
|
if not user:
|
|
return
|
|
|
|
uid = user['id']
|
|
|
|
struct_id = data["id"]
|
|
struct_path = struct_id + ".json"
|
|
subdir = os.path.join(STRUCTS_FOLDER, uid)
|
|
if not (is_safe_path(STRUCTS_FOLDER, subdir)):
|
|
return
|
|
path = os.path.join(subdir, struct_path)
|
|
if (is_safe_path(subdir, path) and os.path.isfile(path)):
|
|
delete_dir = os.path.join(STRUCTS_FOLDER, uid, "deleted")
|
|
make_dir_if_necessary(delete_dir)
|
|
os.rename(path, os.path.join(delete_dir, struct_path))
|
|
|
|
|
|
@socketio.on('join')
|
|
def on_join(data):
|
|
"""When a user joins the "community" room, emit to them the list of
|
|
the top 5 most recent users' most recent 11 utterances and all of the
|
|
submitted structs."""
|
|
|
|
room = data['room']
|
|
join_room(room)
|
|
|
|
if (room == "community"):
|
|
# And then we emit the most recent 5 users' utterances per file
|
|
emit_utterances()
|
|
|
|
# and also emit the top builders when first joining
|
|
emit_top_builders()
|
|
|
|
# We iterate through all the shared structs and emit them one by one
|
|
emit_structs()
|
|
|
|
|
|
@socketio.on('leave')
|
|
def on_leave(data):
|
|
"""A user can leave a room"""
|
|
# username = data['sessionId']
|
|
room = data['room']
|
|
leave_room(room)
|
|
|
|
|
|
@socketio.on('share')
|
|
def handle_share(data):
|
|
"""Users can share structs. We save this struct in STRUCTS_FOLDER/UID/SCORE_ID.json
|
|
|
|
where UID is the uid of the user who submitted the struct, SCORE is the
|
|
current score of the struct and ID is the unique index (auto-incremented) of
|
|
this particular struct.."""
|
|
|
|
user = current_user(data['token'])
|
|
if not user:
|
|
return
|
|
|
|
uid = user['id']
|
|
|
|
user_structs_folder = os.path.join(STRUCTS_FOLDER, uid)
|
|
if not is_safe_path(STRUCTS_FOLDER, user_structs_folder):
|
|
return
|
|
make_dir_if_necessary(user_structs_folder)
|
|
new_struct_path = os.path.join(user_structs_folder, data["id"] + ".json")
|
|
if not is_safe_path(user_structs_folder, new_struct_path):
|
|
return
|
|
|
|
submission_time = current_unix_time()
|
|
score = score_struct(submission_time, 0)
|
|
|
|
with open(new_struct_path, 'w') as f:
|
|
f.write("[]\n") # it starts with no upvoters
|
|
f.write(str(submission_time) + "\n") # timestamp of submission
|
|
f.write(json.dumps(data["struct"]) + "\n") # the actual struct
|
|
f.write(data["image"]) # the png of the struct
|
|
|
|
# Broadcast addition to the "community" room
|
|
message = {"uid": uid, "id": data["id"], "score": score, "upvotes": [
|
|
], "struct": data["struct"], "image": data["image"]}
|
|
emit("struct", message, broadcast=True, room="community")
|
|
|
|
|
|
@socketio.on('upvote')
|
|
def upvote(data):
|
|
"""Users can upvote other users' structures."""
|
|
|
|
user = current_user(data['token'])
|
|
|
|
# if no authenticated user, do nothing
|
|
if not user:
|
|
return
|
|
|
|
subdir = os.path.join(STRUCTS_FOLDER, data["struct_uid"])
|
|
if not is_safe_path(STRUCTS_FOLDER, subdir):
|
|
return
|
|
struct_path = os.path.join(subdir, str(data["id"]) + ".json")
|
|
|
|
# if the struct does not exist, do nothing
|
|
if not (is_safe_path(subdir, struct_path) or os.path.isfile(struct_path)):
|
|
print("not", struct_path)
|
|
return
|
|
|
|
# Read the first line of the file to get the number of upvotes
|
|
upvotes = []
|
|
score = 0
|
|
with open(struct_path, 'r+') as f:
|
|
upvotes = json.loads(f.readline().strip())
|
|
|
|
# If the user has not already upvoted this, add them
|
|
if user['id'] not in upvotes:
|
|
upvotes.append(user['id'])
|
|
|
|
timestamp = f.readline()
|
|
struct = f.readline()
|
|
image = f.readline()
|
|
|
|
# reset file to top
|
|
f.seek(0)
|
|
|
|
# write file back with updated upvotes
|
|
f.write(json.dumps(upvotes) + "\n")
|
|
f.write(timestamp) # rewrite the timestamp
|
|
f.write(struct) # rewrite the actual struct
|
|
f.write(image) # rewrite the image
|
|
|
|
message = {"uid": data["struct_uid"],
|
|
"id": data["id"], "up": user['id'], "score": score}
|
|
|
|
f.truncate() # truncate to ensure flush appropriate
|
|
|
|
# calculate score
|
|
score = score_struct(timestamp, len(upvotes))
|
|
|
|
# and then broadcast the new upvote to the room:
|
|
message = {"uid": data["struct_uid"],
|
|
"id": data["id"], "up": user['id'], "score": score}
|
|
emit("upvote", message, broadcast=True, room="community")
|
|
|
|
|
|
@socketio.on('log')
|
|
def handle_log(data):
|
|
"""Receive a log message in the form of {"type": LOG_TYPE, "msg": LOG_OBJECT}
|
|
|
|
If the log type is an accept of utterance, then broadcast that to all
|
|
community-connected clients."""
|
|
|
|
if "type" not in data:
|
|
# If the log object is improper, don't do anything.
|
|
return
|
|
|
|
log(data)
|
|
|
|
# If the message is an accept or define type, broadcast it to all
|
|
# community-connected clients so they can update their display.
|
|
if data["type"] == "accept":
|
|
emit("new_accept", {"uid": data['uid'], "query": data["msg"]["query"], "timestamp": current_unix_time()},
|
|
broadcast=True, room="community")
|
|
elif data["type"] == "define":
|
|
emit("new_define", {"uid": data['uid'], "defined": data["msg"]["defineAs"], "timestamp": current_unix_time()},
|
|
broadcast=True, room="community")
|
|
|
|
|
|
@socketio.on('session')
|
|
def get_session(data):
|
|
"""On every new connection, the client should transmit the sessionId to tell
|
|
the server that a new session has started. This sessionId is then used for
|
|
all future authentication by storing it as uid in the session global
|
|
context variable."""
|
|
session.uid = data['uid']
|
|
log({"type": "connect", "token": ""})
|
|
|
|
|
|
@socketio.on('getstructcount')
|
|
def getstructcount(data):
|
|
emit_user_structs_count(data['uid'])
|
|
|
|
|
|
@socketio.on('connect')
|
|
def connect():
|
|
"""Return an ok if connection worked"""
|
|
emit('ok', {'data': 'Connected'})
|
|
|
|
|
|
@socketio.on('disconnect')
|
|
def disconnect():
|
|
"""Log the fact that a user disconnected."""
|
|
if 'uid' in session:
|
|
log({"uid": session.uid, "type": "disconnect"})
|
|
|
|
|
|
@socketio.on('sign_in')
|
|
def sign_in(data):
|
|
r = requests.get("https://slack.com/api/oauth.access", params={
|
|
'code': data['code'], 'client_id': '130265636855.151294060356', 'client_secret': SLACK_SECRET})
|
|
data = r.json()
|
|
session['access_token'] = data['access_token']
|
|
user = data['user']
|
|
session['user'] = user
|
|
|
|
encoded = jwt.encode(
|
|
{'name': user['name'], 'email': user['email'], 'id': user['id']}, JWT_SECRET, algorithm='HS256').decode('utf-8')
|
|
|
|
emit('sign_in', {
|
|
"name": user['name'], "email": user['email'], 'id': user['id'], 'token': encoded})
|
|
|
|
|
|
@socketio.on('get_user')
|
|
def get_user(data):
|
|
user = current_user(data['token'])
|
|
if user:
|
|
emit('sign_in', {
|
|
"name": user['name'], "email": user['email'], 'id': user['id'], 'token': data['token']
|
|
})
|
|
else:
|
|
emit('sign_in_failed')
|
|
|
|
|
|
def current_user(token):
|
|
try:
|
|
return jwt.decode(token, JWT_SECRET, algorithms=['HS256'])
|
|
except:
|
|
return False
|
|
|
|
|
|
# http://stackoverflow.com/questions/2301789/read-a-file-in-reverse-order-using-python
|
|
def reverse_readline(filename, buf_size=8192):
|
|
"""a generator that returns the lines of a file in reverse order"""
|
|
with open(filename) as fh:
|
|
segment = None
|
|
offset = 0
|
|
fh.seek(0, os.SEEK_END)
|
|
file_size = remaining_size = fh.tell()
|
|
while remaining_size > 0:
|
|
offset = min(file_size, offset + buf_size)
|
|
fh.seek(file_size - offset)
|
|
buffer = fh.read(min(remaining_size, buf_size))
|
|
remaining_size -= buf_size
|
|
lines = buffer.split('\n')
|
|
# the first line of the buffer is probably not a complete line so
|
|
# we'll save it and append it to the last line of the next buffer
|
|
# we read
|
|
if segment is not None:
|
|
# if the previous chunk starts right from the beginning of line
|
|
# do not concact the segment to the last line of new chunk
|
|
# instead, yield the segment first
|
|
if buffer[-1] is not '\n':
|
|
lines[-1] += segment
|
|
else:
|
|
yield segment
|
|
segment = lines[0]
|
|
for index in range(len(lines) - 1, 0, -1):
|
|
if len(lines[index]):
|
|
yield lines[index]
|
|
# Don't yield None if the file was empty
|
|
if segment is not None:
|
|
yield segment
|
|
|
|
|
|
def make_dir_if_necessary(dir_name):
|
|
"""Creates the directory if not already created"""
|
|
if not os.path.exists(dir_name):
|
|
os.makedirs(dir_name)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
# Create any missing directories
|
|
make_dir_if_necessary(DATA_FOLDER)
|
|
make_dir_if_necessary(LOG_FOLDER)
|
|
make_dir_if_necessary(STRUCTS_FOLDER)
|
|
|
|
# Parse arguments
|
|
parser = OptionParser()
|
|
parser.add_option("-p", "--port", dest="port",
|
|
help="port number to run the server", default=DEFAULT_PORT)
|
|
(options, args) = parser.parse_args()
|
|
|
|
# Run the server
|
|
# NB: socketio.run uses eventlet to run a production webserver
|
|
# so, make sure that "eventlet" is installed, or else it will default to
|
|
# the werkzeug development server which is unsafe and slow.
|
|
socketio.run(app, host='0.0.0.0', port=int(options.port))
|