PageRenderTime 68ms CodeModel.GetById 31ms RepoModel.GetById 0ms app.codeStats 0ms

/root/mobwrite/mobwrite_appengine.py

https://github.com/masyl/wrook
Python | 417 lines | 410 code | 1 blank | 6 comment | 0 complexity | cff86fca828f46a4d0ad008452a2e626 MD5 | raw file
  1. #!/usr/bin/python2.4
  2. """MobWrite - Real-time Synchronization and Collaboration Service
  3. Copyright 2008 Neil Fraser
  4. http://code.google.com/p/google-mobwrite/
  5. Licensed under the Apache License, Version 2.0 (the "License");
  6. you may not use this file except in compliance with the License.
  7. You may obtain a copy of the License at
  8. http://www.apache.org/licenses/LICENSE-2.0
  9. Unless required by applicable law or agreed to in writing, software
  10. distributed under the License is distributed on an "AS IS" BASIS,
  11. WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  12. See the License for the specific language governing permissions and
  13. limitations under the License.
  14. """
  15. """This file is the server, running under Google App Engine.
  16. Accepting synchronization sessions from clients.
  17. """
  18. __author__ = "fraser@google.com (Neil Fraser)"
  19. import logging
  20. import cgi
  21. import urllib
  22. import datetime
  23. import re
  24. import diff_match_patch as dmp_module
  25. from google.appengine.ext import db
  26. from google.appengine import runtime
  27. # Demo usage should limit the maximum size of any text.
  28. # Set to 0 to disable limit.
  29. MAXCHARS = 50000
  30. # Global Diff/Match/Patch object.
  31. dmp = dmp_module.diff_match_patch()
  32. def main():
  33. # Choose from: CRITICAL, ERROR, WARNING, INFO, DEBUG
  34. logging.getLogger().setLevel(logging.DEBUG)
  35. form = cgi.FieldStorage()
  36. if form.has_key('q'):
  37. # Client sending a sync. Requesting text return.
  38. print 'Content-Type: text/plain'
  39. print ''
  40. print parseRequest(form['q'].value)
  41. elif form.has_key('p'):
  42. # Client sending a sync. Requesting JS return.
  43. print 'Content-Type: text/javascript'
  44. print ''
  45. value = parseRequest(form['p'].value)
  46. value = value.replace("\\", "\\\\").replace("\"", "\\\"")
  47. value = value.replace("\n", "\\n").replace("\r", "\\r")
  48. print "mobwrite.syncRun2_(\"%s\");" % value
  49. elif form.has_key('clean'):
  50. # External cron job to clean the database.
  51. print 'Content-Type: text/plain'
  52. print ''
  53. cleanup()
  54. else:
  55. # Unknown request.
  56. print 'Content-Type: text/plain'
  57. print ''
  58. logging.debug("Disconnecting.")
  59. class TextObj(db.Model):
  60. # An object which stores a text.
  61. # Object properties:
  62. # .text - The text itself.
  63. # .lasttime - The last time that this text was modified.
  64. text = db.TextProperty()
  65. lasttime = db.DateTimeProperty(auto_now=True)
  66. def setText(self, text):
  67. # Scrub the text before setting it.
  68. # Keep the text within the length limit.
  69. if MAXCHARS != 0 and len(text) > MAXCHARS:
  70. text = text[-MAXCHARS:]
  71. logging.warning("Truncated text to %d characters." % MAXCHARS)
  72. # Normalize linebreaks to CRLF.
  73. text = re.sub(r"(\r\n|\r|\n)", "\r\n", text)
  74. if (self.text != text):
  75. self.text = text
  76. self.put()
  77. logging.debug("Saved %db TextObj: '%s'" %
  78. (len(text), self.key().id_or_name()))
  79. def fetchText(filename):
  80. # DataStore doesn't like names starting with numbers.
  81. filename = "_" + filename
  82. key = db.Key.from_path(TextObj.kind(), filename)
  83. text = db.get(key)
  84. # Should be zero or one result.
  85. if text != None:
  86. logging.debug("Loaded %db TextObj: '%s'" % (len(text.text), filename))
  87. return text
  88. logging.debug("Created new TextObj: '%s'" % filename)
  89. return TextObj(key_name=filename)
  90. class ViewObj(db.Model):
  91. # An object which contains one user's view of one text.
  92. # Object properties:
  93. # .username - The name for the user, e.g 'fraser'
  94. # .filename - The name for the file, e.g 'proposal'
  95. # .mytext - The last version of the text sent to client.
  96. # .lasttime - The last time (in seconds since 1970) that a web connection
  97. # serviced this object.
  98. # .textobj - The shared text object being worked on.
  99. # .changed - Does this object need to be saved.
  100. username = db.StringProperty(required=True)
  101. filename = db.StringProperty(required=True)
  102. mytext = db.TextProperty()
  103. lasttime = db.DateTimeProperty(auto_now=True)
  104. changed = False
  105. textobj = None
  106. def fetchUserViews(username):
  107. query = db.GqlQuery("SELECT * FROM ViewObj WHERE username = :1", username)
  108. # Convert list to a hash, and also load the associated text objects.
  109. views = {}
  110. for view in query:
  111. logging.debug("Loaded %db ViewObj: '%s %s'" %
  112. (len(view.mytext), view.username, view.filename))
  113. view.textobj = fetchText(view.filename)
  114. views[view.filename] = view
  115. if len(views) == 0:
  116. logging.debug("Unable to find a ViewObj for: '%s'" % username)
  117. return views
  118. class BufferObj(db.Model):
  119. # An object which assembles large commands from fragments.
  120. # Object properties:
  121. # .name - The name (and size) of the buffer, e.g. '_alpha_12'
  122. # .index - Which slot in the buffer this data belongs to.
  123. # .data - The contents of the buffer.
  124. # .lasttime - The last time that this buffer was modified.
  125. name = db.StringProperty(required=True)
  126. index = db.IntegerProperty(required=True)
  127. data = db.TextProperty(required=True)
  128. lasttime = db.DateTimeProperty(auto_now=True)
  129. def setToBuffer(name, size, index, data):
  130. # DataStore doesn't like names starting with numbers.
  131. name = "_%s_%d" % (name, size)
  132. id = "%s_%d" % (name, index)
  133. key = db.Key.from_path(BufferObj.kind(), id)
  134. buffer = db.get(key)
  135. # Should be zero or one result.
  136. if buffer == None:
  137. buffer = BufferObj(key_name=id, name=name, index=index, data=data)
  138. logging.debug("Created new BufferObj: '%s'" % id)
  139. else:
  140. logging.warning("Reloaded existing BufferObj: '%s'" % id)
  141. buffer.data = data
  142. buffer.put()
  143. def getFromBuffer(name, size):
  144. # DataStore doesn't like names starting with numbers.
  145. name = "_%s_%d" % (name, size)
  146. query = db.GqlQuery("SELECT * FROM BufferObj WHERE name = :1 ORDER BY index", name)
  147. if query.count() != size:
  148. # Buffer not yet fully defined.
  149. return None
  150. # Assemble the buffer's contents and delete it.
  151. data = []
  152. for buffer in query:
  153. # Cast each part as a string (Unicode causes problems).
  154. data.append(str(buffer.data))
  155. buffer.delete()
  156. text = ''.join(data)
  157. return urllib.unquote(text)
  158. def cleanup():
  159. logging.info("Cleaning database")
  160. try:
  161. # Delete any view which hasn't been written to in half an hour.
  162. limit = datetime.datetime.now() - datetime.timedelta(0, 0, 0, 0, 30, 0)
  163. query = db.GqlQuery("SELECT * FROM ViewObj WHERE lasttime < :1", limit)
  164. for datum in query:
  165. print "Deleting '%s %s' ViewObj" % (datum.username, datum.filename)
  166. datum.delete()
  167. # Delete any text which hasn't been written to in an hour.
  168. limit = datetime.datetime.now() - datetime.timedelta(0, 0, 0, 0, 0, 1)
  169. query = db.GqlQuery("SELECT * FROM TextObj WHERE lasttime < :1", limit)
  170. for datum in query:
  171. print "Deleting '%s' TextObj" % datum.key().id_or_name()
  172. datum.delete()
  173. # Delete any buffer which hasn't been written to in a quarter of an hour.
  174. limit = datetime.datetime.now() - datetime.timedelta(0, 0, 0, 0, 15, 0)
  175. query = db.GqlQuery("SELECT * FROM BufferObj WHERE lasttime < :1", limit)
  176. for datum in query:
  177. print "Deleting '%s' BufferObj" % datum.key().id_or_name()
  178. datum.delete()
  179. print "Database clean."
  180. logging.info("Database clean")
  181. except runtime.DeadlineExceededError:
  182. print "Cleanup only partially complete. Deadline exceeded."
  183. logging.warning("Database only partially cleaned")
  184. def parseRequest(data):
  185. # Passing a Unicode string is an easy way to cause numerous subtle bugs.
  186. if type(data) != str:
  187. logging.critical("parseRequest data type is %s" % type(data))
  188. return ""
  189. username = None
  190. filename = None
  191. lastUsername = None
  192. lastFilename = None
  193. echoUsername = False
  194. echoFilename = False
  195. viewobj = None
  196. textobj = None
  197. deltaOk = False
  198. userViews = ()
  199. userViewsName = None
  200. output = []
  201. for line in data.splitlines(True):
  202. if not (line.endswith("\n") or line.endswith("\r")):
  203. # Truncated line. Abort.
  204. logging.warning("Truncated line: '%s'" % line)
  205. break
  206. line = line.rstrip("\r\n")
  207. if not line:
  208. # Terminate on blank line.
  209. break
  210. if line.find(":") != 1:
  211. # Invalid line.
  212. logging.warning("Invalid line: '%s'" % line)
  213. continue
  214. (name, value) = (line[:1], line[2:])
  215. if name == "u" or name == "U":
  216. if name == "U":
  217. # Client requests explicit usernames in response.
  218. echoUsername = True
  219. username = value
  220. if userViewsName != username:
  221. userViews = fetchUserViews(username)
  222. userViewsName = username
  223. elif name == "f" or name == "F":
  224. if name == "F":
  225. # Client requests explicit filenames in response.
  226. echoFilename = True
  227. filename = value
  228. elif name == "b" or name == "B":
  229. try:
  230. (name, size, index, data) = value.split(" ", 3)
  231. size = int(size)
  232. index = int(index)
  233. except ValueError:
  234. logging.warning("Invalid buffer format: %s" % value)
  235. continue
  236. # Store this buffer fragment.
  237. setToBuffer(name, size, index, data)
  238. # Check to see if the buffer is complete. If so, execute it.
  239. data = getFromBuffer(name, size)
  240. if data:
  241. logging.info("Executing buffer: %s_%d" % (name, size))
  242. output.append(parseRequest(data))
  243. elif name == "r" or name == "R" or name == "d" or name == "D":
  244. # Edit a file.
  245. if not username or not filename:
  246. # Both a username and a filename must be specified.
  247. continue
  248. if userViews.has_key(filename):
  249. viewobj = userViews[filename]
  250. else:
  251. viewobj = ViewObj(username=username, filename=filename)
  252. logging.debug("Created new ViewObj: '%s %s'" % (username, filename))
  253. viewobj.mytext = u""
  254. viewobj.textobj = fetchText(filename)
  255. userViews[filename] = viewobj
  256. textobj = viewobj.textobj
  257. if name == "r" or name == "R":
  258. # It's a raw text dump.
  259. value = urllib.unquote(value).decode("utf-8")
  260. logging.info("Got %db raw text: '%s %s'" %
  261. (len(value), viewobj.username, viewobj.filename))
  262. deltaOk = True
  263. # First, update the client's shadow.
  264. if viewobj.mytext != value:
  265. viewobj.changed = True
  266. viewobj.mytext = value
  267. if name == "R":
  268. # Clobber the server's text.
  269. if textobj.text != value:
  270. textobj.setText(value)
  271. logging.debug("Overwrote content: '%s %s'" %
  272. (viewobj.username, viewobj.filename))
  273. elif name == "d" or name == "D":
  274. # It's a delta.
  275. # Expand the delta into a diff using the client shadow.
  276. logging.info("Got '%s' delta: '%s %s'" %
  277. (value, viewobj.username, viewobj.filename))
  278. try:
  279. diffs = dmp.diff_fromDelta(viewobj.mytext, value)
  280. except ValueError:
  281. diffs = None
  282. deltaOk = False
  283. logging.warning("Delta failure, expected %d length: '%s %s'" %
  284. (len(viewobj.mytext), viewobj.username, viewobj.filename))
  285. if diffs != None:
  286. deltaOk = True
  287. # First, update the client's shadow.
  288. patches = dmp.patch_make(viewobj.mytext, '', diffs)
  289. newtext = dmp.diff_text2(diffs)
  290. if viewobj.mytext != newtext:
  291. viewobj.mytext = newtext
  292. viewobj.changed = True
  293. # Second, deal with the server's text.
  294. if textobj.text == None:
  295. # A view is sending a valid delta on a file we've never heard of.
  296. textobj.setText("")
  297. if name == "D":
  298. # Clobber the server's text if a change was received.
  299. if len(diffs) > 1 or diffs[0][0] != dmp.DIFF_EQUAL:
  300. mastertext = viewobj.mytext
  301. logging.debug("Overwrote content: '%s %s'" %
  302. (viewobj.username, viewobj.filename))
  303. else:
  304. mastertext = textobj.text
  305. else:
  306. (mastertext, results) = dmp.patch_apply(patches, textobj.text)
  307. logging.debug("Patched (%s): '%s %s'" %
  308. (",".join(["%s" % (x) for x in results]), viewobj.username, viewobj.filename))
  309. if textobj.text != mastertext:
  310. textobj.setText(mastertext)
  311. # Process the output.
  312. if (echoUsername and lastUsername != username):
  313. output.append("u:" + username + "\n")
  314. lastUsername = username
  315. if (echoFilename and lastFilename != filename):
  316. output.append("f:" + filename + "\n")
  317. lastFilename = filename
  318. # Accept this view's version of the text if we've never heard of this
  319. # text before.
  320. if textobj.text == None:
  321. if deltaOk:
  322. textobj.setText(viewobj.mytext)
  323. else:
  324. textobj.setText("")
  325. mastertext = textobj.text
  326. if deltaOk:
  327. # Create the diff between the view's text and the master text.
  328. diffs = dmp.diff_main(viewobj.mytext, mastertext)
  329. dmp.diff_cleanupEfficiency(diffs)
  330. text = dmp.diff_toDelta(diffs)
  331. if name == "D" or name == "R":
  332. # Client sending 'D' means number, no error.
  333. # Client sebding 'R' means number, client error.
  334. # Both cases involve numbers, so send back an overwrite delta.
  335. output.append("D:" + text + "\n")
  336. else:
  337. # Client sending 'd' means text, no error.
  338. # Client sending 'r' means text, client error.
  339. # Both cases involve text, so send back a merge delta.
  340. output.append("d:" + text + "\n")
  341. logging.info("Sent '%s' delta: '%s %s'" %
  342. (text, viewobj.username, viewobj.filename))
  343. else:
  344. # Error; server could not parse client's delta.
  345. # Send a raw dump of the text. Force overwrite of client.
  346. value = mastertext
  347. value = value.encode("utf-8")
  348. value = urllib.quote(value, "!~*'();/?:@&=+$,# ")
  349. output.append("R:" + value + "\n")
  350. logging.info("Sent %db raw text: '%s %s'" %
  351. (len(value), viewobj.username, viewobj.filename))
  352. if viewobj.mytext != mastertext:
  353. viewobj.mytext = mastertext
  354. viewobj.changed = True
  355. if viewobj.changed:
  356. logging.debug("Saving %db ViewObj: '%s %s'" %
  357. (len(viewobj.mytext), viewobj.username, viewobj.filename))
  358. viewobj.put()
  359. viewobj.changed = False
  360. return ''.join(output)
  361. if __name__ == '__main__':
  362. main()