model.py 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849
  1. # encoding=utf-8
  2. import base64
  3. import calendar
  4. import contextlib
  5. import datetime
  6. import hmac
  7. import json
  8. import os
  9. import random
  10. import subprocess
  11. from sqlalchemy.ext.declarative import declarative_base
  12. from sqlalchemy.orm import sessionmaker, relationship
  13. from sqlalchemy.orm.session import make_transient
  14. from sqlalchemy.orm.util import object_state
  15. from sqlalchemy.sql.expression import insert, select, delete, exists
  16. from sqlalchemy.sql.functions import func
  17. from sqlalchemy.sql.schema import Column, ForeignKey
  18. from sqlalchemy.sql.sqltypes import String, LargeBinary, Float, Boolean, Integer, \
  19. DateTime
  20. from sqlalchemy.sql.type_api import TypeDecorator
  21. from terroroftinytown.client import VERSION
  22. from terroroftinytown.client.alphabet import str_to_int, int_to_str
  23. from terroroftinytown.tracker.errors import NoItemAvailable, FullClaim, UpdateClient, \
  24. InvalidClaim, NoResourcesAvailable
  25. from terroroftinytown.tracker.stats import Stats
  26. # These overrides for major api changes
  27. MIN_VERSION_OVERRIDE = 55 # for terroroftinytown.client
  28. MIN_CLIENT_VERSION_OVERRIDE = 7 # for terrofoftinytown-client-grab/pipeline.py
  29. DEADMAN_MAX_ERROR_REPORTS = 4000
  30. DEADMAN_MAX_RESULTS = 40000000
  31. Base = declarative_base()
  32. Session = sessionmaker()
  33. @contextlib.contextmanager
  34. def new_session():
  35. session = Session()
  36. try:
  37. yield session
  38. session.commit()
  39. except:
  40. session.rollback()
  41. raise
  42. finally:
  43. session.close()
  44. class JsonType(TypeDecorator):
  45. impl = String
  46. def process_bind_param(self, value, engine):
  47. return json.dumps(value)
  48. def process_result_value(self, value, engine):
  49. if value:
  50. return json.loads(value)
  51. else:
  52. return None
  53. class GlobalSetting(Base):
  54. __tablename__ = 'global_settings'
  55. key = Column(String, primary_key=True)
  56. value = Column(JsonType)
  57. AUTO_DELETE_ERROR_REPORTS = 'auto_delete_error_reports'
  58. @classmethod
  59. def set_value(cls, key, value):
  60. with new_session() as session:
  61. setting = session.query(GlobalSetting).filter_by(key=key).first()
  62. if setting:
  63. setting.value = value
  64. else:
  65. setting = GlobalSetting(key=key, value=value)
  66. session.add(setting)
  67. @classmethod
  68. def get_value(cls, key):
  69. with new_session() as session:
  70. setting = session.query(GlobalSetting).filter_by(key=key).first()
  71. if setting:
  72. return setting.value
  73. class User(Base):
  74. '''User accounts that manager the tracker.'''
  75. __tablename__ = 'users'
  76. username = Column(String, primary_key=True)
  77. salt = Column(LargeBinary, nullable=False)
  78. hash = Column(LargeBinary, nullable=False)
  79. def set_password(self, password):
  80. self.salt = new_salt()
  81. self.hash = make_hash(password, self.salt)
  82. def check_password(self, password):
  83. test_hash = make_hash(password, self.salt)
  84. return compare_digest(self.hash, test_hash)
  85. def get_token(self):
  86. return make_hash(self.username, self.salt)
  87. def check_token(self, test_token):
  88. token = self.get_token()
  89. return compare_digest(token, test_token)
  90. @classmethod
  91. def no_users_exist(cls):
  92. with new_session() as session:
  93. user = session.query(User).first()
  94. return user is None
  95. @classmethod
  96. def is_user_exists(cls, username):
  97. with new_session() as session:
  98. user = session.query(User).filter_by(username=username).first()
  99. return user is not None
  100. @classmethod
  101. def all_usernames(cls):
  102. with new_session() as session:
  103. users = session.query(User.username)
  104. return list([user.username for user in users])
  105. @classmethod
  106. def save_new_user(cls, username, password):
  107. with new_session() as session:
  108. user = User(username=username)
  109. user.set_password(password)
  110. session.add(user)
  111. @classmethod
  112. def check_account(cls, username, password):
  113. with new_session() as session:
  114. user = session.query(User).filter_by(username=username).first()
  115. if user:
  116. return user.check_password(password)
  117. @classmethod
  118. def update_password(cls, username, password):
  119. with new_session() as session:
  120. user = session.query(User).filter_by(username=username).first()
  121. user.set_password(password)
  122. @classmethod
  123. def delete_user(cls, username):
  124. with new_session() as session:
  125. session.query(User).filter_by(username=username).delete()
  126. @classmethod
  127. def get_user_token(cls, username):
  128. with new_session() as session:
  129. return session.query(User).filter_by(username=username)\
  130. .first().get_token()
  131. @classmethod
  132. def check_account_session(cls, username, token):
  133. with new_session() as session:
  134. user = session.query(User).filter_by(username=username).first()
  135. if not user:
  136. return
  137. return user.check_token(token)
  138. class Project(Base):
  139. '''Project settings.'''
  140. __tablename__ = 'projects'
  141. name = Column(String, primary_key=True)
  142. min_version = Column(Integer, default=VERSION, nullable=False)
  143. min_client_version = Column(Integer, default=MIN_CLIENT_VERSION_OVERRIDE, nullable=False)
  144. alphabet = Column(String, default='0123456789abcdefghijklmnopqrstuvwxyz'
  145. 'ABCDEFGHIJKLMNOPQRSTUVWXYZ',
  146. nullable=False)
  147. url_template = Column(String, default='http://example.com/{shortcode}',
  148. nullable=False)
  149. request_delay = Column(Float, default=0.5, nullable=False)
  150. redirect_codes = Column(JsonType, default=[301, 302, 303, 307],
  151. nullable=False)
  152. no_redirect_codes = Column(JsonType, default=[404], nullable=False)
  153. unavailable_codes = Column(JsonType, default=[200])
  154. banned_codes = Column(JsonType, default=[403, 420, 429])
  155. body_regex = Column(String)
  156. location_anti_regex = Column(String)
  157. method = Column(String, default='head', nullable=False)
  158. enabled = Column(Boolean, default=False)
  159. autoqueue = Column(Boolean, default=False)
  160. num_count_per_item = Column(Integer, default=50, nullable=False)
  161. max_num_items = Column(Integer, default=100, nullable=False)
  162. lower_sequence_num = Column(Integer, default=0, nullable=False)
  163. autorelease_time = Column(Integer, default=60 * 30)
  164. def to_dict(self, with_shortcode=False):
  165. ans = {x.key:x.value for x in object_state(self).attrs}
  166. if with_shortcode:
  167. ans['lower_shortcode'] = self.lower_shortcode()
  168. return ans
  169. def lower_shortcode(self):
  170. return int_to_str(self.lower_sequence_num, self.alphabet)
  171. @classmethod
  172. def all_project_names(cls):
  173. with new_session() as session:
  174. projects = session.query(Project.name)
  175. return list([project.name for project in projects])
  176. @classmethod
  177. def all_project_infos(cls):
  178. with new_session() as session:
  179. projects = session.query(Project)
  180. return list([project.to_dict(with_shortcode=True) for project in projects])
  181. @classmethod
  182. def new_project(cls, name):
  183. with new_session() as session:
  184. project = Project(name=name)
  185. session.add(project)
  186. @classmethod
  187. def get_plain(cls, name):
  188. with new_session() as session:
  189. project = session.query(Project).filter_by(name=name).first()
  190. make_transient(project)
  191. return project
  192. @classmethod
  193. @contextlib.contextmanager
  194. def get_session_object(cls, name):
  195. with new_session() as session:
  196. project = session.query(Project).filter_by(name=name).first()
  197. yield project
  198. @classmethod
  199. def delete_project(cls, name):
  200. # FIXME: need to cascade the deletes
  201. with new_session() as session:
  202. session.query(Project).filter_by(name=name).delete()
  203. class Item(Base):
  204. __tablename__ = 'items'
  205. id = Column(Integer, primary_key=True)
  206. project_id = Column(Integer, ForeignKey('projects.name'), nullable=False)
  207. project = relationship('Project')
  208. lower_sequence_num = Column(Integer, nullable=False)
  209. upper_sequence_num = Column(Integer, nullable=False)
  210. datetime_claimed = Column(DateTime)
  211. tamper_key = Column(String)
  212. username = Column(String)
  213. ip_address = Column(String)
  214. def to_dict(self, with_shortcode=False):
  215. ans = {x.key:x.value for x in object_state(self).attrs}
  216. ans.update({
  217. 'project': self.project.to_dict(),
  218. 'datetime_claimed': calendar.timegm(self.datetime_claimed.utctimetuple()) if self.datetime_claimed else None,
  219. })
  220. if with_shortcode:
  221. ans['lower_shortcode'] = int_to_str(self.lower_sequence_num, self.project.alphabet)
  222. ans['upper_shortcode'] = int_to_str(self.upper_sequence_num, self.project.alphabet)
  223. return ans
  224. @classmethod
  225. def get_items(cls, project_id):
  226. with new_session() as session:
  227. rows = session.query(Item).filter_by(project_id=project_id).order_by(Item.datetime_claimed)
  228. return list([item.to_dict(with_shortcode=True) for item in rows])
  229. @classmethod
  230. def add_items(cls, project_id, sequence_list):
  231. with new_session() as session:
  232. query = insert(Item)
  233. query_args = []
  234. for lower_num, upper_num in sequence_list:
  235. query_args.append({
  236. 'project_id': project_id,
  237. 'lower_sequence_num': lower_num,
  238. 'upper_sequence_num': upper_num,
  239. })
  240. session.execute(query, query_args)
  241. @classmethod
  242. def delete(cls, item_id):
  243. with new_session() as session:
  244. session.query(Item).filter_by(id=item_id).delete()
  245. @classmethod
  246. def release(cls, item_id):
  247. with new_session() as session:
  248. item = session.query(Item).filter_by(id=item_id).first()
  249. item.datetime_claimed = None
  250. item.ip_address = None
  251. item.username = None
  252. @classmethod
  253. def release_all(cls, project_id=None, old_date=None):
  254. with new_session() as session:
  255. query = session.query(Item)
  256. if project_id:
  257. query = query.filter_by(project_id=project_id)
  258. if old_date:
  259. query = query.filter(Item.datetime_claimed <= old_date)
  260. query.update({
  261. 'datetime_claimed': None,
  262. 'ip_address': None,
  263. 'username': None,
  264. })
  265. @classmethod
  266. def release_old(cls, project_id=None, autoqueue_only=False):
  267. with new_session() as session:
  268. # we could probably write this in one query
  269. # but it would be non-portable across SQL dialects
  270. projects = session.query(Project) \
  271. .filter(Project.autorelease_time > 0)
  272. if project_id:
  273. projects = projects.filter_by(name=project_id)
  274. if autoqueue_only:
  275. projects = projects.filter_by(autoqueue=True)
  276. for project in projects:
  277. min_time = datetime.datetime.utcnow() - datetime.timedelta(seconds=project.autorelease_time)
  278. query = session.query(Item) \
  279. .filter(Item.datetime_claimed <= min_time, Item.project == project)
  280. query.update({
  281. 'datetime_claimed': None,
  282. 'ip_address': None,
  283. 'username': None,
  284. })
  285. @classmethod
  286. def delete_all(cls, project_id):
  287. with new_session() as session:
  288. session.query(Item).filter_by(project_id=project_id).delete()
  289. class BlockedUser(Base):
  290. '''Blocked IP addresses or usernames.'''
  291. __tablename__ = 'blocked_users'
  292. username = Column(String, primary_key=True)
  293. note = Column(String)
  294. @classmethod
  295. def block_username(cls, username, note=None):
  296. with new_session() as session:
  297. session.add(BlockedUser(username=username, note=note))
  298. @classmethod
  299. def unblock_username(cls, username):
  300. with new_session() as session:
  301. session.query(BlockedUser).filter_by(username=username).delete()
  302. @classmethod
  303. def is_username_blocked(cls, *username):
  304. with new_session() as session:
  305. query = select([BlockedUser.username])\
  306. .where(BlockedUser.username.in_(username))
  307. result = session.execute(query).first()
  308. if result:
  309. return True
  310. @classmethod
  311. def all_blocked_usernames(cls):
  312. with new_session() as session:
  313. names = session.query(BlockedUser.username)
  314. return list([row[0] for row in names])
  315. class Result(Base):
  316. '''Unshortend URL.'''
  317. __tablename__ = 'results'
  318. id = Column(Integer, primary_key=True)
  319. project_id = Column(Integer, ForeignKey('projects.name'), nullable=False, index=True)
  320. project = relationship('Project')
  321. shortcode = Column(String, nullable=False)
  322. url = Column(String, nullable=False)
  323. encoding = Column(String, nullable=False)
  324. datetime = Column(DateTime)
  325. @classmethod
  326. def has_results(cls):
  327. with new_session() as session:
  328. result = session.query(Result.id).first()
  329. return bool(result)
  330. @classmethod
  331. def get_count(cls):
  332. with new_session() as session:
  333. return (session.query(func.max(Result.id)).scalar() or 0) \
  334. - (session.query(func.min(Result.id)).scalar() or 0)
  335. @classmethod
  336. def get_results(cls, offset_id=0, limit=1000, project_id=None):
  337. with new_session() as session:
  338. if int(offset_id) == 0:
  339. offset_id = session.query(func.max(Result.id)).scalar() or 0
  340. rows = session.query(
  341. Result.id, Result.project_id, Result.shortcode,
  342. Result.url, Result.encoding, Result.datetime
  343. ) \
  344. .filter(Result.id <= int(offset_id))
  345. if project_id is not None and project_id != 'None':
  346. rows = rows.filter(Result.project_id == project_id)
  347. alphabet = Project.get_plain(project_id).alphabet
  348. else:
  349. alphabet = None
  350. rows = rows.order_by(Result.id.desc()).limit(int(limit))
  351. for row in rows:
  352. ans = {
  353. 'id': row[0],
  354. 'project_id': row[1],
  355. 'shortcode': row[2],
  356. 'url': row[3],
  357. 'encoding': row[4],
  358. 'datetime': row[5]
  359. }
  360. if alphabet:
  361. ans['seq_num'] = str_to_int(row[2], alphabet)
  362. yield ans
  363. class ErrorReport(Base):
  364. '''Error report.'''
  365. __tablename__ = 'error_reports'
  366. id = Column(Integer, primary_key=True)
  367. item_id = Column(Integer, ForeignKey('items.id'), nullable=False)
  368. item = relationship('Item')
  369. message = Column(String, nullable=False)
  370. datetime = Column(DateTime, nullable=False,
  371. default=datetime.datetime.utcnow)
  372. def to_dict(self):
  373. ans = {x.key:x.value for x in object_state(self).attrs}
  374. ans.update({
  375. 'project': self.item.project_id if self.item else None,
  376. })
  377. return ans
  378. @classmethod
  379. def get_count(cls):
  380. with new_session() as session:
  381. min_id = session.query(func.min(ErrorReport.id)).scalar() or 0
  382. max_id = session.query(func.max(ErrorReport.id)).scalar() or 0
  383. return max_id - min_id
  384. @classmethod
  385. def all_reports(cls, limit=100, offset_id=None, project_id=None):
  386. with new_session() as session:
  387. reports = session.query(ErrorReport)
  388. if offset_id:
  389. reports = reports.filter(ErrorReport.id > offset_id)
  390. if project_id is not None and project_id != 'None':
  391. reports = reports.join(Item).filter(Item.project_id == project_id)
  392. reports = reports.limit(limit)
  393. return list(report.to_dict() for report in reports)
  394. @classmethod
  395. def delete_all(cls):
  396. with new_session() as session:
  397. session.query(ErrorReport.id).delete()
  398. @classmethod
  399. def delete_one(cls, report_id):
  400. with new_session() as session:
  401. query = delete(ErrorReport).where(ErrorReport.id == report_id)
  402. session.execute(query)
  403. @classmethod
  404. def delete_orphaned(cls):
  405. with new_session() as session:
  406. subquery = select([ErrorReport.id])\
  407. .where(ErrorReport.item_id == Item.id)\
  408. .limit(1)
  409. query = delete(ErrorReport).where(~exists(subquery))
  410. session.execute(query)
  411. class Budget(object):
  412. '''Budget calculator to help manage available items.
  413. Warning: This class assumes the application is single instance.
  414. '''
  415. projects = {}
  416. @classmethod
  417. def calculate_budgets(cls):
  418. cls.projects = {}
  419. with new_session() as session:
  420. query = session.query(
  421. Project.name, Project.max_num_items,
  422. Project.min_client_version, Project.min_version,
  423. Project.max_num_items
  424. ).filter_by(enabled=True)
  425. for row in query:
  426. (name, max_num_items, min_client_version, min_version,
  427. max_num_items) = row
  428. cls.projects[name] = {
  429. 'max_num_items': max_num_items,
  430. 'min_client_version': min_client_version,
  431. 'min_version': min_version,
  432. 'items': 0,
  433. 'claims': 0,
  434. 'ip_addresses': set(),
  435. }
  436. query = session.query(Item.project_id, Item.ip_address)
  437. for row in query:
  438. project_id, ip_address = row
  439. if project_id not in cls.projects:
  440. continue
  441. project_info = cls.projects[project_id]
  442. project_info['items'] += 1
  443. if ip_address:
  444. project_info['ip_addresses'].add(ip_address)
  445. project_info['claims'] += 1
  446. @classmethod
  447. def get_available_project(cls, ip_address, version, client_version):
  448. project_names = list(cls.projects.keys())
  449. random.shuffle(project_names)
  450. for project_id in project_names:
  451. project_info = cls.projects[project_id]
  452. if ip_address not in project_info['ip_addresses'] and \
  453. version >= project_info['min_version'] and \
  454. client_version >= project_info['min_client_version'] and \
  455. project_info['claims'] <= project_info['items'] and \
  456. project_info['claims'] < project_info['max_num_items']:
  457. return (project_id, project_info['claims'],
  458. project_info['items'], project_info['max_num_items'])
  459. @classmethod
  460. def is_client_outdated(cls, version, client_version):
  461. if not cls.projects:
  462. return
  463. max_version = max(project['min_version']
  464. for project in cls.projects.values())
  465. max_client_version = max(project['min_client_version']
  466. for project in cls.projects.values())
  467. if version < max_version or client_version < max_client_version:
  468. return max_version, max_client_version
  469. @classmethod
  470. def is_claims_full(cls, ip_address):
  471. return cls.projects and all(ip_address in project['ip_addresses']
  472. for project in cls.projects.values())
  473. @classmethod
  474. def check_out(cls, project_id, ip_address, new_item=False):
  475. assert project_id
  476. assert ip_address
  477. project_info = cls.projects[project_id]
  478. project_info['claims'] += 1
  479. if new_item:
  480. project_info['items'] += 1
  481. project_info['ip_addresses'].add(ip_address)
  482. @classmethod
  483. def check_in(cls, project_id, ip_address):
  484. assert project_id
  485. assert ip_address
  486. if project_id not in cls.projects:
  487. # Project was recently disabled but the job hasn't come back
  488. # yet. Should be safe to ignore.
  489. return
  490. project_info = cls.projects[project_id]
  491. project_info['claims'] -= 1
  492. project_info['items'] -= 1
  493. project_info['ip_addresses'].remove(ip_address)
  494. def make_hash(plaintext, salt):
  495. key = salt
  496. msg = plaintext.encode('ascii')
  497. # Yes, I know MD5 is bad but it was the silent default at the time
  498. return hmac.new(key, msg, digestmod='MD5').digest()
  499. def new_salt():
  500. return os.urandom(16)
  501. def new_tamper_key():
  502. return base64.b16encode(os.urandom(16)).decode('ascii')
  503. def deadman_checks():
  504. if ErrorReport.get_count() > DEADMAN_MAX_ERROR_REPORTS:
  505. return '<div class="alert btn-danger">Too many error reports! Figure out what went wrong.</div>'
  506. if Result.get_count() > DEADMAN_MAX_RESULTS:
  507. return '<div class="alert btn-danger">Too many results! Run the export script.</div>'
  508. return ''
  509. def checkout_item(username, ip_address, version=-1, client_version=-1):
  510. assert version is not None
  511. assert client_version is not None
  512. check_min_version_overrides(version, client_version)
  513. if deadman_checks():
  514. raise NoResourcesAvailable()
  515. available = Budget.get_available_project(
  516. ip_address, version, client_version
  517. )
  518. if available:
  519. project_id, num_claims, num_items, max_num_items = available
  520. with new_session() as session:
  521. if num_claims >= num_items and num_items < max_num_items:
  522. project = session.query(Project).get(project_id)
  523. if project.autoqueue:
  524. item_count = project.num_count_per_item
  525. upper_sequence_num = project.lower_sequence_num + item_count - 1
  526. item = Item(
  527. project=project,
  528. lower_sequence_num=project.lower_sequence_num,
  529. upper_sequence_num=upper_sequence_num,
  530. )
  531. new_item = True
  532. project.lower_sequence_num = upper_sequence_num + 1
  533. session.add(item)
  534. else:
  535. item = None
  536. new_item = None
  537. else:
  538. item = session.query(Item) \
  539. .filter_by(username=None) \
  540. .filter_by(project_id=project_id) \
  541. .first()
  542. new_item = False
  543. if item:
  544. item.datetime_claimed = datetime.datetime.utcnow()
  545. item.tamper_key = new_tamper_key()
  546. item.username = username
  547. item.ip_address = ip_address
  548. # Item should be committed now to generate ID for
  549. # newly generated items
  550. session.commit()
  551. Budget.check_out(project_id, ip_address, new_item=new_item)
  552. return item.to_dict()
  553. else:
  554. raise NoItemAvailable()
  555. else:
  556. if Budget.is_claims_full(ip_address):
  557. raise FullClaim()
  558. else:
  559. outdated = Budget.is_client_outdated(version, client_version)
  560. if outdated:
  561. current_version, current_client_version = outdated
  562. raise UpdateClient(
  563. version=version,
  564. client_version=client_version,
  565. current_version=current_version,
  566. current_client_version=current_client_version
  567. )
  568. else:
  569. raise NoItemAvailable()
  570. def checkin_item(item_id, tamper_key, results):
  571. item_stat = {
  572. 'project': '',
  573. 'username': '',
  574. 'scanned': 0,
  575. 'found': len(results)
  576. }
  577. with new_session() as session:
  578. row = session.query(
  579. Item.project_id, Item.username, Item.upper_sequence_num,
  580. Item.lower_sequence_num, Item.ip_address, Item.datetime_claimed
  581. ) \
  582. .filter_by(id=item_id, tamper_key=tamper_key).first()
  583. if not row:
  584. raise InvalidClaim()
  585. (project_id, username, upper_sequence_num, lower_sequence_num,
  586. ip_address, datetime_claimed) = row
  587. item_stat['project'] = project_id
  588. item_stat['username'] = username
  589. item_stat['scanned'] = upper_sequence_num - lower_sequence_num + 1
  590. item_stat['started'] = datetime_claimed.replace(
  591. tzinfo=datetime.timezone.utc).timestamp()
  592. query_args = []
  593. # tz instead of utcnow() for Unix timestamp in UTC instead of local
  594. time = datetime.datetime.now(datetime.timezone.utc)
  595. item_stat['finished'] = time.timestamp()
  596. for shortcode in results.keys():
  597. url = results[shortcode]['url']
  598. encoding = results[shortcode]['encoding']
  599. query_args.append({
  600. 'project_id': project_id,
  601. 'shortcode': shortcode,
  602. 'url': url,
  603. 'encoding': encoding,
  604. 'datetime': time
  605. })
  606. if len(query_args) > 0:
  607. query = insert(Result)
  608. session.execute(query, query_args)
  609. session.execute(delete(Item).where(Item.id == item_id))
  610. Budget.check_in(project_id, ip_address)
  611. if Stats.instance:
  612. Stats.instance.update(item_stat)
  613. return item_stat
  614. def report_error(item_id, tamper_key, message):
  615. with new_session() as session:
  616. item = session.query(Item).filter_by(id=item_id, tamper_key=tamper_key).first()
  617. if not item:
  618. raise InvalidClaim()
  619. error_report = ErrorReport(item_id=item_id, message=message)
  620. session.add(error_report)
  621. def check_min_version_overrides(version, client_version):
  622. if version < MIN_VERSION_OVERRIDE or client_version < MIN_CLIENT_VERSION_OVERRIDE:
  623. raise UpdateClient(
  624. version=version,
  625. client_version=client_version,
  626. current_version=MIN_VERSION_OVERRIDE,
  627. current_client_version=MIN_CLIENT_VERSION_OVERRIDE
  628. )
  629. def get_git_hash():
  630. try:
  631. return subprocess.check_output(
  632. ['git', 'rev-parse', 'HEAD'],
  633. cwd=os.path.dirname(__file__)).strip()
  634. except (subprocess.CalledProcessError, OSError) as error:
  635. return str(error)
  636. def compare_digest(value_1, value_2):
  637. if len(value_1) != len(value_2):
  638. return False
  639. iterable = [a == b for a, b in zip(value_1, value_2)]
  640. ok = True
  641. for result in iterable:
  642. ok &= result
  643. return ok