database.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813
  1. import hashlib
  2. import re
  3. import sqlite3
  4. from freepost import random, settings
  5. db = sqlite3.connect(settings['sqlite']['database'])
  6. # Returns SQLite rows as dictionaries instead of tuples.
  7. # https://docs.python.org/3/library/sqlite3.html#sqlite3.Connection.row_factory
  8. db.row_factory = sqlite3.Row
  9. # A custom function to compute SHA-512 because it's not built into SQLite
  10. db.create_function('SHA512', 1, lambda text:
  11. None if text is None else hashlib.sha512(text.encode('UTF-8')).hexdigest())
  12. # The REGEXP operator is a special syntax for the regexp() user function. No
  13. # regexp() user function is defined by default and so use of the REGEXP operator
  14. # will normally result in an error message. If an application-defined SQL
  15. # function named "regexp" is added at run-time, then the "X REGEXP Y" operator
  16. # will be implemented as a call to "regexp(Y,X)".
  17. db.create_function('REGEXP', 2, lambda pattern, string:
  18. re.search(pattern, string, flags=re.IGNORECASE) is not None)
  19. # Store a new session_id for a user that has logged in
  20. # The session token is stored in the user cookies during login, here
  21. # we store the hash value of that token.
  22. def new_session(user_id, session_token):
  23. with db:
  24. db.execute(
  25. """
  26. UPDATE user
  27. SET session = SHA512(:session)
  28. WHERE id = :user
  29. """,
  30. {
  31. 'user': user_id,
  32. 'session': session_token
  33. }
  34. )
  35. # Delete user session token on logout
  36. def delete_session (user_id):
  37. with db:
  38. db.execute (
  39. """
  40. UPDATE user
  41. SET session = NULL
  42. WHERE id = :user
  43. """,
  44. {
  45. 'user': user_id
  46. }
  47. )
  48. # Check user login credentials
  49. #
  50. # @return None if bad credentials, otherwise return the user
  51. def check_user_credentials (username, password):
  52. with db:
  53. cursor = db.execute (
  54. """
  55. SELECT *
  56. FROM user
  57. WHERE username = :username
  58. AND password = SHA512(:password || salt)
  59. AND isActive = 1
  60. """,
  61. {
  62. 'username': username,
  63. 'password': password
  64. }
  65. )
  66. return cursor.fetchone ()
  67. # Check if username exists
  68. def username_exists (username, case_sensitive = True):
  69. if not username:
  70. return None
  71. if case_sensitive:
  72. where = 'WHERE username = :username'
  73. else:
  74. where = 'WHERE LOWER(username) = LOWER(:username)'
  75. with db:
  76. cursor = db.execute(
  77. """
  78. SELECT *
  79. FROM user
  80. """ +
  81. where,
  82. {
  83. 'username': username
  84. }
  85. )
  86. return cursor.fetchone() is not None
  87. # Check if post with same link exists
  88. def post_exists (link):
  89. if not link:
  90. return None
  91. with db:
  92. cursor = db.execute(
  93. """
  94. SELECT *
  95. FROM post
  96. WHERE LOWER(link) = LOWER(:link)
  97. """,
  98. {
  99. 'link': link
  100. }
  101. )
  102. return cursor.fetchone() is not None
  103. # Create new user account
  104. def new_user (username, password):
  105. # Create a hash_id for the new post
  106. hash_id = random.alphanumeric_string (10)
  107. # Create a salt for user's password
  108. salt = random.ascii_string (16)
  109. # Add user to database
  110. with db:
  111. db.execute (
  112. """
  113. INSERT INTO user (hashId, isActive, password, registered, salt, username)
  114. VALUES (:hash_id, 1, SHA512(:password || :salt), DATE(), :salt, :username)
  115. """,
  116. {
  117. 'hash_id': hash_id,
  118. 'password': password,
  119. 'salt': salt,
  120. 'username': username
  121. }
  122. )
  123. # Check if session token exists
  124. def is_valid_session (token):
  125. return get_user_by_session_token (token) is not None
  126. # Return the number of unread replies
  127. def count_unread_messages (user_id):
  128. with db:
  129. cursor = db.execute (
  130. """
  131. SELECT COUNT(1) AS new_messages
  132. FROM comment
  133. WHERE parentUserId = :user AND userId != :user AND `read` = 0
  134. """,
  135. {
  136. 'user': user_id
  137. }
  138. )
  139. return cursor.fetchone ()['new_messages']
  140. # Retrieve a user
  141. def get_user_by_username (username):
  142. if not username:
  143. return None
  144. with db:
  145. cursor = db.execute(
  146. """
  147. SELECT *
  148. FROM user
  149. WHERE username = :username
  150. """,
  151. {
  152. 'username': username
  153. }
  154. )
  155. return cursor.fetchone()
  156. # Retrieve a user from a session cookie
  157. def get_user_by_session_token(session_token):
  158. with db:
  159. cursor = db.execute(
  160. """
  161. SELECT *
  162. FROM user
  163. WHERE session = SHA512(:session)
  164. """,
  165. {
  166. 'session': session_token
  167. }
  168. )
  169. return cursor.fetchone()
  170. # Get posts by date (for homepage)
  171. def get_posts (page = 0, session_user_id = None, sort = 'hot', topic = None):
  172. if sort == 'new':
  173. sort = 'ORDER BY P.created DESC'
  174. else:
  175. sort = 'ORDER BY P.dateCreated DESC, P.vote DESC, P.commentsCount DESC'
  176. if topic:
  177. topic_name = 'WHERE T.name = :topic'
  178. else:
  179. topic_name = ''
  180. with db:
  181. cursor = db.execute (
  182. """
  183. SELECT P.*,
  184. U.username,
  185. V.vote AS user_vote,
  186. GROUP_CONCAT(T.name, " ") AS topics
  187. FROM post AS P
  188. JOIN user AS U ON P.userId = U.id
  189. LEFT JOIN vote_post as V ON V.postId = P.id AND V.userId = :user
  190. LEFT JOIN topic as T ON T.post_id = P.id
  191. {topic}
  192. GROUP BY P.id
  193. {order}
  194. LIMIT :limit
  195. OFFSET :offset
  196. """.format (topic=topic_name, order=sort),
  197. {
  198. 'user': session_user_id,
  199. 'limit': settings['defaults']['items_per_page'],
  200. 'offset': page * settings['defaults']['items_per_page'],
  201. 'topic': topic
  202. }
  203. )
  204. return cursor.fetchall ()
  205. # Retrieve user's own posts
  206. def get_user_posts (user_id):
  207. with db:
  208. cursor = db.execute (
  209. """
  210. SELECT *
  211. FROM post
  212. WHERE userId = :user
  213. ORDER BY created DESC
  214. LIMIT 50
  215. """,
  216. {
  217. 'user': user_id
  218. }
  219. )
  220. return cursor.fetchall()
  221. # Retrieve user's own comments
  222. def get_user_comments (user_id):
  223. with db:
  224. cursor = db.execute (
  225. """
  226. SELECT C.*,
  227. P.title AS postTitle,
  228. P.hashId AS postHashId
  229. FROM comment AS C
  230. JOIN post AS P ON P.id = C.postId
  231. WHERE C.userId = :user
  232. ORDER BY C.created DESC
  233. LIMIT 50
  234. """,
  235. {
  236. 'user': user_id
  237. }
  238. )
  239. return cursor.fetchall()
  240. # Retrieve user's own replies to other people
  241. def get_user_replies (user_id):
  242. with db:
  243. cursor = db.execute(
  244. """
  245. SELECT C.*,
  246. P.title AS postTitle,
  247. P.hashId AS postHashId,
  248. U.username AS username
  249. FROM comment AS C
  250. JOIN post AS P ON P.id = C.postId
  251. JOIN user AS U ON U.id = C.userId
  252. WHERE C.parentUserId = :user AND C.userId != :user
  253. ORDER BY C.created DESC
  254. LIMIT 50
  255. """,
  256. {
  257. 'user': user_id
  258. }
  259. )
  260. return cursor.fetchall()
  261. # Update user information
  262. def update_user (user_id, about, email, email_notifications, preferred_feed):
  263. with db:
  264. # Update user info, but not email address
  265. db.execute(
  266. """
  267. UPDATE user
  268. SET about = :about,
  269. email_notifications = :notifications,
  270. preferred_feed = :preferred_feed
  271. WHERE id = :user
  272. """,
  273. {
  274. 'about': about,
  275. 'notifications': email_notifications,
  276. 'user': user_id,
  277. 'preferred_feed': preferred_feed
  278. }
  279. )
  280. # Update email address. Convert all addresses to LOWER() case. This
  281. # prevents two users from using the same address with different case.
  282. # IGNORE update if the email address is already specified. This is
  283. # necessary to avoid an "duplicate key" exception when updating value.
  284. db.execute (
  285. """
  286. UPDATE OR IGNORE user
  287. SET email = LOWER(:email)
  288. WHERE id = :user
  289. """,
  290. {
  291. 'email': email,
  292. 'user': user_id
  293. }
  294. )
  295. # Set user replies as read
  296. def set_replies_as_read (user_id):
  297. with db:
  298. db.execute(
  299. """
  300. UPDATE comment
  301. SET `read` = 1
  302. WHERE parentUserId = :user AND `read` = 0
  303. """,
  304. {
  305. 'user': user_id
  306. }
  307. )
  308. # Submit a new post/link
  309. def new_post (title, link, text, user_id):
  310. # Create a hash_id for the new post
  311. hash_id = random.alphanumeric_string (10)
  312. with db:
  313. db.execute(
  314. """
  315. INSERT INTO post (hashId, created, dateCreated, title,
  316. link, text, vote, commentsCount, userId)
  317. VALUES (:hash_id, DATETIME(), DATE(), :title, :link,
  318. :text, 0, 0, :user)
  319. """,
  320. {
  321. 'hash_id': hash_id,
  322. 'title': title,
  323. 'link': link,
  324. 'text': text,
  325. 'user': user_id
  326. }
  327. )
  328. return hash_id
  329. # Set topics post. Deletes existing ones.
  330. def replace_post_topics (post_id, topics = ''):
  331. if not topics:
  332. return
  333. # Normalize topics
  334. # 1. Split topics by space
  335. # 2. Remove empty strings
  336. # 3. Lower case topic name
  337. topics = [ topic.lower () for topic in topics.split (' ') if topic ]
  338. if len (topics) == 0:
  339. return
  340. # Remove extra topics if the list is too long
  341. topics = topics[:settings['defaults']['topics_per_post']]
  342. with db:
  343. # First we delete the existing topics
  344. db.execute (
  345. """
  346. DELETE
  347. FROM topic
  348. WHERE post_id = :post
  349. """,
  350. {
  351. 'post': post_id
  352. }
  353. )
  354. # Now insert the new topics.
  355. # IGNORE duplicates that trigger UNIQUE constraint.
  356. db.executemany (
  357. """
  358. INSERT OR IGNORE INTO topic (post_id, name)
  359. VALUES (?, ?)
  360. """,
  361. [ (post_id, topic) for topic in topics ]
  362. )
  363. # Retrieve a post
  364. def get_post (hash, session_user_id = None):
  365. with db:
  366. cursor = db.execute (
  367. """
  368. SELECT P.*,
  369. U.username,
  370. V.vote AS user_vote
  371. FROM post AS P
  372. JOIN user AS U ON P.userId = U.id
  373. LEFT JOIN vote_post as V ON V.postId = P.id AND V.userId = :user
  374. WHERE P.hashId = :post
  375. """,
  376. {
  377. 'user': session_user_id,
  378. 'post': hash
  379. }
  380. )
  381. return cursor.fetchone ()
  382. # Update a post
  383. def update_post (title, link, text, post_hash_id, user_id):
  384. with db:
  385. db.execute (
  386. """
  387. UPDATE post
  388. SET title = :title,
  389. link = :link,
  390. text = :text
  391. WHERE hashId = :hash_id
  392. AND userId = :user
  393. """,
  394. {
  395. 'title': title,
  396. 'link': link,
  397. 'text': text,
  398. 'hash_id': post_hash_id,
  399. 'user': user_id
  400. }
  401. )
  402. # Retrieve all comments for a specific post
  403. def get_post_comments (post_id, session_user_id = None):
  404. with db:
  405. cursor = db.execute (
  406. """
  407. SELECT C.*,
  408. U.username,
  409. V.vote AS user_vote
  410. FROM comment AS C
  411. JOIN user AS U ON C.userId = U.id
  412. LEFT JOIN vote_comment as V ON V.commentId = C.id AND V.userId = :user
  413. WHERE C.postId = :post
  414. ORDER BY C.vote DESC,
  415. C.created ASC
  416. """,
  417. {
  418. 'user': session_user_id,
  419. 'post': post_id
  420. }
  421. )
  422. return cursor.fetchall ()
  423. # Retrieve all topics for a specific post
  424. def get_post_topics (post_id):
  425. with db:
  426. cursor = db.execute (
  427. """
  428. SELECT T.name
  429. FROM topic AS T
  430. WHERE T.post_id = :post
  431. ORDER BY T.name ASC
  432. """,
  433. {
  434. 'post': post_id
  435. }
  436. )
  437. return cursor.fetchall ()
  438. # Submit a new comment to a post
  439. def new_comment (comment_text, post_hash_id, user_id, parent_user_id = None, parent_comment_id = None):
  440. # Create a hash_id for the new comment
  441. hash_id = random.alphanumeric_string (10)
  442. # Retrieve post
  443. post = get_post (post_hash_id)
  444. with db:
  445. db.execute (
  446. """
  447. INSERT INTO comment (hashId, created, dateCreated, `read`, text, vote,
  448. parentId, parentUserId, postId, userId)
  449. VALUES (:hash_id, DATETIME(), DATE(), 0, :text, 0, :parent_id,
  450. :parent_user_id, :post_id, :user)
  451. """,
  452. {
  453. 'hash_id': hash_id,
  454. 'text': comment_text,
  455. 'parent_id': parent_comment_id,
  456. 'parent_user_id': parent_user_id,
  457. 'post_id': post['id'],
  458. 'user': user_id
  459. }
  460. )
  461. # Increase comments count for post
  462. db.execute (
  463. """
  464. UPDATE post
  465. SET commentsCount = commentsCount + 1
  466. WHERE id = :post
  467. """,
  468. {
  469. 'post': post['id']
  470. }
  471. )
  472. return hash_id
  473. # Retrieve a single comment
  474. def get_comment (hash_id, session_user_id = None):
  475. with db:
  476. cursor = db.execute(
  477. """
  478. SELECT C.*,
  479. P.hashId AS postHashId,
  480. P.title AS postTitle,
  481. U.username,
  482. V.vote AS user_vote
  483. FROM comment AS C
  484. JOIN user AS U ON C.userId = U.id
  485. JOIN post AS P ON P.id = C.postId
  486. LEFT JOIN vote_comment as V ON V.commentId = C.id AND V.userId = :user
  487. WHERE C.hashId = :comment
  488. """,
  489. {
  490. 'user': session_user_id,
  491. 'comment': hash_id
  492. }
  493. )
  494. return cursor.fetchone()
  495. # Retrieve last N newest comments
  496. def get_latest_comments ():
  497. with db:
  498. cursor = db.execute (
  499. """
  500. SELECT C.*,
  501. P.hashId AS postHashId,
  502. P.title AS postTitle,
  503. U.username
  504. FROM comment AS C
  505. JOIN user AS U ON C.userId = U.id
  506. JOIN post AS P ON P.id = C.postId
  507. ORDER BY C.id DESC
  508. LIMIT 50
  509. """,
  510. {
  511. }
  512. )
  513. return cursor.fetchall ()
  514. # Update a comment
  515. def update_comment (text, comment_hash_id, user_id):
  516. with db:
  517. db.execute (
  518. """
  519. UPDATE comment
  520. SET text = :text
  521. WHERE hashId = :comment AND userId = :user
  522. """,
  523. {
  524. 'text': text,
  525. 'comment': comment_hash_id,
  526. 'user': user_id
  527. }
  528. )
  529. # Add or update vote to a post
  530. def vote_post (post_id, user_id, vote):
  531. with db:
  532. # Create a new vote for this post, if one doesn't already exist
  533. db.execute(
  534. """
  535. INSERT OR IGNORE INTO vote_post (vote, datetime, postId, userId)
  536. VALUES (0, DATETIME(), :post, :user)
  537. """,
  538. {
  539. 'post': post_id,
  540. 'user': user_id
  541. }
  542. )
  543. # Update user vote (+1 or -1)
  544. db.execute(
  545. """
  546. UPDATE vote_post
  547. SET vote = vote + :vote
  548. WHERE postId = :post AND userId = :user
  549. """,
  550. {
  551. 'vote': vote,
  552. 'post': post_id,
  553. 'user': user_id
  554. }
  555. )
  556. # Update post's total
  557. db.execute (
  558. """
  559. UPDATE post
  560. SET vote = vote + :vote
  561. WHERE id = :post
  562. """,
  563. {
  564. 'vote': vote,
  565. 'post': post_id
  566. }
  567. )
  568. # Add or update vote to a comment
  569. def vote_comment (comment_id, user_id, vote):
  570. with db:
  571. # Create a new vote for this post, if one doesn't already exist
  572. db.execute (
  573. """
  574. INSERT INTO vote_comment (vote, datetime, commentId, userId)
  575. VALUES (0, DATETIME(), :comment, :user)
  576. """,
  577. {
  578. 'comment': comment_id,
  579. 'user': user_id
  580. }
  581. )
  582. # Update user vote (+1 or -1)
  583. db.execute (
  584. """
  585. UPDATE vote_comment
  586. SET vote = vote + :vote
  587. WHERE commentId = :comment AND userId = :user
  588. """,
  589. {
  590. 'vote': vote,
  591. 'comment': comment_id,
  592. 'user': user_id
  593. }
  594. )
  595. # Update comment's total
  596. db.execute (
  597. """
  598. UPDATE comment
  599. SET vote = vote + :vote
  600. WHERE id = :comment
  601. """,
  602. {
  603. 'vote': vote,
  604. 'comment': comment_id
  605. }
  606. )
  607. # Search posts
  608. def search (query, sort='newest', page=0):
  609. if not query:
  610. return []
  611. # Remove multiple white spaces and replace with '|' (for query REGEXP)
  612. query = re.sub (' +', '|', query.strip ())
  613. if len (query) == 0:
  614. return []
  615. if sort == 'newest':
  616. sort = 'P.created DESC'
  617. if sort == 'points':
  618. sort = 'P.vote DESC'
  619. with db:
  620. cursor = db.execute (
  621. """
  622. SELECT P.*,
  623. U.username
  624. FROM post AS P
  625. JOIN user AS U ON P.userId = U.id
  626. WHERE P.title REGEXP :query
  627. ORDER BY {sort}
  628. LIMIT :limit
  629. OFFSET :offset
  630. """.format (sort=sort),
  631. {
  632. 'query': query,
  633. 'sort': sort,
  634. 'limit': settings['defaults']['search_results_per_page'],
  635. 'offset': page * settings['defaults']['search_results_per_page']
  636. }
  637. )
  638. return cursor.fetchall ()
  639. # Set reset token for user email
  640. def set_password_reset_token (user_id = None, token = None):
  641. if not user_id or not token:
  642. return
  643. with db:
  644. db.execute (
  645. """
  646. UPDATE user
  647. SET passwordResetToken = SHA512(:token),
  648. passwordResetTokenExpire = DATETIME('now', '+1 HOUR')
  649. WHERE id = :user
  650. """,
  651. {
  652. 'user': user_id,
  653. 'token': token
  654. }
  655. )
  656. # Delete the password reset token for a user
  657. def delete_password_reset_token (user_id = None):
  658. with db:
  659. db.execute (
  660. """
  661. UPDATE user
  662. SET passwordResetToken = NULL,
  663. passwordResetTokenExpire = NULL
  664. WHERE id = :user
  665. """,
  666. {
  667. 'user': user_id
  668. }
  669. )
  670. # Check if a reset token has expired.
  671. def is_password_reset_token_valid (user_id = None):
  672. with db:
  673. cursor = db.execute(
  674. """
  675. SELECT COUNT(1) AS valid
  676. FROM user
  677. WHERE id = :user
  678. AND passwordResetToken IS NOT NULL
  679. AND passwordResetTokenExpire IS NOT NULL
  680. AND passwordResetTokenExpire > DATETIME('now')
  681. """,
  682. {
  683. 'user': user_id
  684. }
  685. )
  686. return cursor.fetchone()['valid'] == 1
  687. # Reset user password
  688. def reset_password (username = None, email = None, new_password = None, secret_token = None):
  689. if not new_password:
  690. return
  691. with db:
  692. db.execute (
  693. """
  694. UPDATE user
  695. SET password = SHA512(:password || salt),
  696. passwordResetToken = NULL,
  697. passwordResetTokenExpire = NULL
  698. WHERE username = :user
  699. AND email = :email
  700. AND passwordResetToken = SHA512(:token)
  701. AND passwordResetTokenExpire > DATE()
  702. """,
  703. {
  704. 'password': new_password,
  705. 'user': username,
  706. 'email': email,
  707. 'token': secret_token
  708. }
  709. )