-
Notifications
You must be signed in to change notification settings - Fork 0
/
myturn.py
executable file
·343 lines (320 loc) · 13.4 KB
/
myturn.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
#!/usr/bin/python3 -OO
'''
implementing David Stodolsky's meeting facilitation application
Python backend and JavaScript frontend
Copyright 2017 John Otis Comeau <jc@unternet.net>
distributed under the terms of the GNU General Public License Version 3
(see COPYING)
for testing with local host http://myturn/, must first mate a local IP
address with the name `myturn` in /etc/hosts, e.g.:
127.0.1.125 myturn
'''
from __future__ import print_function
import sys, os, urllib.request, urllib.error, urllib.parse, logging, pwd
import subprocess, site, cgi, datetime, urllib.parse, threading, copy, json
import uuid
from collections import defaultdict, OrderedDict
from lxml import html
from lxml.html import builder
logging.basicConfig(level = logging.DEBUG if __debug__ else logging.INFO)
LOCK = threading.Lock()
try: # command-line testing won't have module available
import uwsgi
logging.debug('uwsgi: %s', dir(uwsgi))
except ImportError:
uwsgi = type('uwsgi', (), {'opt': {}}) # object with empty opt attribute
uwsgi.lock = LOCK.acquire
uwsgi.unlock = LOCK.release
logging.debug('uwsgi.opt: %s', repr(uwsgi.opt))
#logging.debug('sys.argv: %s', sys.argv) # only shows [uwsgi]
#logging.debug('current working directory: %s', os.path.abspath('.')) # '/'
# so we can see that sys.argv and PWD are useless for uwsgi operation
THISDIR = os.path.dirname(uwsgi.opt.get('wsgi-file', b'').decode())
APPDIR = (uwsgi.opt.get('check_static', b'').decode() or
os.path.join(THISDIR, 'html'))
MIMETYPES = {'png': 'image/png', 'ico': 'image/x-icon', 'jpg': 'image/jpeg',
'jpeg': 'image/jpeg',}
DATA = {
'groups': {},
}
HTTPSESSIONS = {} # threads linked with session keys go here
EXPECTED_ERRORS = (NotImplementedError, ValueError, KeyError, IndexError)
def findpath(env):
'''
locate directory where files are stored, and requested file
'''
#logging.debug('env: %s' % repr(env))
start = APPDIR
logging.debug('findpath: start: %s' % start)
path = env.get('HTTP_PATH')
#logging.debug('path, attempt 1: %s', path)
path = path or env.get('REQUEST_URI')
#logging.debug('path, attempt 2: %s', path)
path = (path or '/').lstrip('/')
logging.debug('findpath: should not be None at this point: "%s"', path)
return start, path
def loadpage(webpage, path, data=DATA):
'''
input template and populate the HTML with data array
eventually client-side JavaScript will perform many of these functions.
'''
parsed = html.fromstring(webpage)
postdict = data.get('postdict', {})
set_values(parsed, postdict, ['username', 'groupname', 'httpsession_key'])
if 'groups' in data:
groups = populate_grouplist(parsed, data)
else:
groups = None
# only show load indicator if no path specified;
# get rid of meta refresh if path has already been chosen
if path == '':
hide_except('loading', parsed)
return html.tostring(parsed).decode()
else:
for tag in parsed.xpath('//meta[@http-equiv="refresh"]'):
tag.getparent().remove(tag)
if 'text' in postdict:
span = builder.SPAN(cgi.escape(postdict['text']))
parsed.xpath('//div[@id="error-text"]')[0].append(span)
hide_except('error', parsed)
elif 'joined' in postdict:
logging.debug('found "joined": %s', data['postdict'])
hide_except('talksession', parsed)
elif groups:
hide_except('joinform', parsed)
else:
hide_except('groupform', parsed)
return html.tostring(parsed).decode()
def set_values(parsed, postdict, fieldlist):
'''
pre-set form input values from postdict
'''
logging.debug('setting values of %s from %s', fieldlist, postdict)
for fieldname in fieldlist:
value = postdict.get(fieldname, '')
if not value:
logging.debug('skipping %s, no value found', fieldname)
continue
elements = parsed.xpath('//input[@name="%s"]' % fieldname)
for element in elements:
logging.debug('before: %s', html.tostring(element))
element.set('value', value)
logging.debug('after: %s', html.tostring(element))
def populate_grouplist(parsed=None, data=DATA):
'''
fill in 'select' element with options for each available group
if called with parsed=None, just return list of groups, oldest first
'''
# sorting a dict gives you a list of keys
groups = sorted(data['groups'],
key=lambda g: data['groups'][g]['timestamp'])
if parsed:
grouplist = parsed.xpath('//select[@name="group"]')
logging.debug('populate_grouplist: %s', grouplist)
grouplist = grouplist[0]
for group in groups:
newgroup = builder.OPTION(group, value=group)
grouplist.append(newgroup)
# make newest group the "selected" one
# FIXME: for someone who just created a group, mark *that* one selected
for group in grouplist.getchildren():
try:
del group.attrib['selected']
except KeyError:
pass
grouplist[-1].set('selected', 'selected')
return groups
def hide_except(keep, tree):
'''
set "display: none" for all sections of the page we don't want to see
'''
for page in tree.xpath('//div[@class="body"]'):
if not page.get('id').startswith(keep):
page.set('style', 'display: none')
elif 'style' in page.attrib:
del page.attrib['style']
def server(env = None, start_response = None):
'''
primary server process, sends page with current groups list
'''
status_code, mimetype, page = '500 Server error', 'text/html', '(Unknown)'
start, path = findpath(env)
data = handle_post(env)
logging.debug('server: data: %s', data)
if path.startswith('groups'):
page = json.dumps({'groups': populate_grouplist()})
status_code = '200 OK'
elif path in ('', 'noscript', 'app'):
page = loadpage(read(os.path.join(start, 'index.html')), path, data)
status_code = '200 OK'
elif path == 'status':
page = cgi.escape(json.dumps(data))
status_code = '200 OK'
else:
try:
page, mimetype = render(os.path.join(start, path))
status_code = '200 OK'
except (IOError, OSError) as filenotfound:
status_code = '404 File not found'
page = '<h1>No such page: %s</h1>' % str(filenotfound)
start_response(status_code, [('Content-type', mimetype)])
logging.debug('page: %s', page[:128])
return [page.encode('utf8')]
def handle_post(env):
'''
process the form submission and return data structures
note what dict(parse_qsl(formdata)) does:
>>> from urllib.parse import parse_qsl
>>> parse_qsl('a=b&b=c&a=d&a=e')
[('a', 'b'), ('b', 'c'), ('a', 'd'), ('a', 'e')]
>>> OrderedDict(_)
{'a': 'e', 'b': 'c'}
>>>
so only use it where you know that no key will have more than
one value.
parse_qs will instead return a dict of lists.
'''
uwsgi.lock() # lock access to DATA global
worker = getattr(uwsgi, 'worker_id', lambda *args: None)()
DATA['handler'] = (worker, env.get('uwsgi.core'))
timestamp = datetime.datetime.utcnow().isoformat()
try:
if env.get('REQUEST_METHOD') != 'POST':
return copy.deepcopy(DATA)
posted = urllib.parse.parse_qsl(env['wsgi.input'].read().decode())
DATA['postdict'] = postdict = dict(posted)
logging.debug('handle_post: %s, postdict: %s', posted, postdict)
# [groupname, total, turn] and submit=Submit if group creation
# [username, group] and submit=Join if joining a group
postdict['timestamp'] = timestamp
if not postdict.get('httpsession_key'):
postdict['httpsession_key'] = uuid.uuid4().hex
logging.debug('set httpsession_key = %s',
postdict['httpsession_key'])
try:
buttonvalue = postdict['submit']
except KeyError:
raise ValueError('No "submit" button found')
update_httpsession(postdict)
if buttonvalue == 'Join':
# username being added to group
# don't allow if name already in group
groups = DATA['groups']
logging.debug('processing Join: %s', postdict)
username = postdict.get('username', '')
group = postdict.get('group', '')
if not username:
raise ValueError('Name field cannot be empty')
elif group in groups:
postdict['groupname'] = group
if username in groups[group]['participants']:
raise ValueError('"%s" is already a member of %s' % (
username, group))
groups[group]['participants'][username] = {
'timestamp': timestamp}
if 'talksession' not in groups[group]:
groups[group]['talksession'] = {'start': timestamp}
postdict['joined'] = True
# else group not in groups, no problem, return to add group form
return copy.deepcopy(DATA)
elif buttonvalue == 'Submit':
# groupname, total (time), turn (time) being added to groups
# don't allow if groupname already being used
groups = DATA['groups']
group = postdict['groupname']
if not group in groups:
groups[group] = postdict
groups[group]['participants'] = {}
return copy.deepcopy(DATA)
else:
raise ValueError((
'Group {group[groupname]} already exists with total time '
'{group[total]} minutes and turn time '
'{group[turn]} seconds')
.format(group=groups[group]))
elif buttonvalue == 'OK':
# affirming receipt of error message or Help screen
return copy.deepcopy(DATA)
elif buttonvalue == 'Help':
raise UserWarning('Help requested')
elif buttonvalue == 'My Turn':
# attempting to speak in ongoing session
# this could only be reached by browser in which JavaScript did
# not work properly in taking over default actions
logging.debug('env: %s', env)
raise NotImplementedError(
'Browser \'%s\' incompatible with script' %
env.get('HTTP_USER_AGENT', '(unknown)'))
else:
raise ValueError('Unknown form submitted')
except UserWarning as request:
if str(request) == 'Help requested':
logging.debug('displaying help screen')
DATA['postdict']['text'] = read(os.path.join(THISDIR, 'README.md'))
return copy.deepcopy(DATA)
except EXPECTED_ERRORS as failed:
logging.debug('displaying error: "%r"', failed)
DATA['postdict']['text'] = repr(failed)
return copy.deepcopy(DATA)
finally:
uwsgi.unlock()
def update_httpsession(postdict):
'''
simple implementation of user (http) sessions
this is for keeping state between client and server, this is *not*
the same as discussion (talk) sessions!
another thread should go through and remove expired httpsessions
'''
# FIXME: this session mechanism can only be somewhat secure with https
timestamp = postdict['timestamp']
if 'httpsession_key' in postdict and postdict['httpsession_key']:
session_key = postdict['httpsession_key']
if 'username' in postdict and postdict['username']:
username = postdict['username']
if postdict.get('groupname'):
# both username and groupname filled out means "in session"
postdict['joined'] = True
if session_key in HTTPSESSIONS:
if HTTPSESSIONS[session_key]['username'] != username:
raise ValueError('Session belongs to "%s"' % username)
else:
HTTPSESSIONS[session_key]['updated'] = postdict['timestamp']
else:
HTTPSESSIONS[session_key] = {
'timestamp': timestamp,
'updated': timestamp,
'username': username}
else:
logging.debug('no username associated with session %s', session_key)
else:
logging.warn('no httpsession_key in POST')
def render(pagename, standalone=True):
'''
Return content with Content-type header
'''
logging.debug('render(%s, %s) called', pagename, standalone)
if pagename.endswith('.html'):
logging.debug('rendering static HTML content')
return (read(pagename), 'text/html')
elif not pagename.endswith(('.png', '.ico', '.jpg', '.jpeg')):
# assume plain text
logging.warn('app is serving %s instead of nginx', pagename)
return (read(pagename), 'text/plain')
elif standalone:
logging.warn('app is serving %s instead of nginx', pagename)
return (read(pagename),
MIMETYPES.get(os.path.splitext(pagename)[1], 'text/plain'))
else:
logging.error('not standalone, and no match for filetype')
raise OSError('File not found: %s' % pagename)
def read(filename):
'''
Return contents of a file
'''
logging.debug('read: returning contents of %s', filename)
with open(filename) as infile:
data = infile.read()
logging.debug('data: %s', data[:128])
return data
if __name__ == '__main__':
print(server(os.environ, lambda *args: None))