Curious Cat to Mastodon crossposter
Vous ne pouvez pas sélectionner plus de 25 sujets Les noms de sujets doivent commencer par une lettre ou un nombre, peuvent contenir des tirets ('-') et peuvent comporter jusqu'à 35 caractères.

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285
  1. #!/usr/bin/env python3
  2. #Curious Greg - Curious Cat to Mastodon crossposter
  3. # This Source Code Form is subject to the terms of the Mozilla Public
  4. # License, v. 2.0. If a copy of the MPL was not distributed with this
  5. # file, You can obtain one at http://mozilla.org/MPL/2.0/.
  6. #TODO: ADD RETROSPRING SUPPORT!
  7. import json, hashlib, urllib, time, re, random
  8. import requests
  9. from mastodon import Mastodon
  10. from flask import Flask, render_template, request, session, redirect, url_for
  11. import mysql.connector
  12. import bcrypt
  13. cfg = json.load(open("meta.json"))
  14. scopes = ["read:accounts", "write:statuses"]
  15. settings = {
  16. "cw": True,
  17. # "disabled": False,
  18. }
  19. # +---------------------+---------------+------+-----+------------------------------------------------+-------+
  20. # | Field | Type | Null | Key | Default | Extra |
  21. # +---------------------+---------------+------+-----+------------------------------------------------+-------+
  22. # | username | varchar(64) | NO | PRI | NULL | |
  23. # | instance | varchar(128) | NO | PRI | NULL | |
  24. # | password | tinytext | NO | | NULL | |
  25. # | avi | text | NO | | NULL | |
  26. # | secret | tinytext | NO | | NULL | |
  27. # | client_id | varchar(128) | NO | | NULL | |
  28. # | client_secret | tinytext | NO | | NULL | |
  29. # | cc | tinytext | YES | | NULL | |
  30. # | ccavi | varchar(128) | YES | | https://lynnesbian.space/res/ceres/cc-smol.png | |
  31. # | latest_post | tinytext | YES | | NULL | |
  32. # | last_check | int(11) | NO | | 0 | |
  33. # | time_between_checks | int(11) | NO | | 1 | |
  34. # | settings | varchar(4096) | YES | | {"cw": true} | |
  35. # +---------------------+---------------+------+-----+------------------------------------------------+-------+
  36. def db_reconnect():
  37. db = mysql.connector.connect(user=cfg['dbuser'], password=cfg['dbpass'], database=cfg['dbname'])
  38. c = db.cursor()
  39. dc = db.cursor(dictionary=True)
  40. return (db, c, dc)
  41. gdb, gc, gdc = db_reconnect()
  42. gc.execute("CREATE TABLE IF NOT EXISTS `data` (username VARCHAR(64) NOT NULL, instance VARCHAR(128) NOT NULL, password TINYTEXT NOT NULL, avi TEXT NOT NULL, secret TINYTEXT NOT NULL, client_id VARCHAR(128) NOT NULL, client_secret TINYTEXT NOT NULL, cc TINYTEXT, ccavi VARCHAR(128) DEFAULT 'https://lynnesbian.space/res/ceres/cc-smol.png', latest_post TINYTEXT, last_check INT DEFAULT 0 NOT NULL, time_between_checks INT DEFAULT %s NOT NULL, settings VARCHAR(4096) DEFAULT %s, PRIMARY KEY(username, instance))", (cfg['min_time_between_checks'], json.dumps(settings),))
  43. gdb.close()
  44. app = Flask(cfg['name'])
  45. app.secret_key = cfg['flask_key']
  46. if cfg['debug']:
  47. app.config['DEBUG'] = True
  48. @app.route('/')
  49. def main():
  50. if 'acct' not in session:
  51. return render_template("landing_page.html")
  52. else:
  53. return redirect(url_for('home'))
  54. @app.route('/home')
  55. def home():
  56. if 'acct' in session:
  57. db, c, dc = db_reconnect()
  58. dc.execute("SELECT * FROM data WHERE username = %s AND instance = %s", (session['username'], session['instance']))
  59. data = dc.fetchone()
  60. try:
  61. for item in ['username', 'instance', 'avi', 'secret', 'client_id', 'client_secret', 'cc', 'ccavi']:
  62. session[item] = data[item]
  63. except:
  64. db.close()
  65. return redirect('/logout') #TODO: not good UX
  66. if 'cc' not in session:
  67. session['cc'] = "None"
  68. if session['cc'] == "None" or 'ccavi' not in session:
  69. #every time home is rendered without cc being set
  70. c.execute("SELECT cc, ccavi FROM `data` WHERE client_id = %s AND instance = %s", (session['client_id'], session['instance']))
  71. cc = c.fetchone()
  72. if cc[0] != '':
  73. session['cc'] = cc[0]
  74. session['ccavi'] = cc[1]
  75. if 'last_avi_update' not in session or session['last_avi_update'] + (24 * 60 * 60) < time.time():
  76. #avatars haven't been updated for over 24 hours, update them now
  77. client = Mastodon(client_id=session['client_id'], client_secret=session['client_secret'], access_token=session['secret'], api_base_url=session['instance'])
  78. session['avi'] = client.account_verify_credentials()['avatar']
  79. if session['cc'] != "None" and session['cc'] != None:
  80. #update cc avi too
  81. r = requests.get("https://curiouscat.me/api/v2/profile?username={}".format(session['cc']))
  82. j = r.json()
  83. try:
  84. session['ccavi'] = j['userData']['avatar']
  85. except:
  86. return json.dumps(j)
  87. c.execute("UPDATE data SET avi = %s, ccavi = %s WHERE client_id = %s AND instance = %s", (session['avi'], session['ccavi'], session['client_id'], session['instance']))
  88. else:
  89. c.execute("UPDATE data SET avi = %s WHERE client_id = %s AND instance = %s", (session['avi'], session['client_id'], session['instance']))
  90. session['last_avi_update'] = int(time.time())
  91. db.commit()
  92. db.close()
  93. return render_template("home.html", mabg="background-image:url('{}')".format(session['avi']), ccbg="background-image:url('{}')".format(session['ccavi']))
  94. else:
  95. db.close()
  96. return redirect(url_for('main'))
  97. @app.route('/debug')
  98. def print_debug_info():
  99. if cfg['debug']:
  100. return json.dumps(session._get_current_object())
  101. else:
  102. return redirect('/home')
  103. @app.route('/logout')
  104. def reset_session():
  105. session.clear()
  106. return redirect(url_for('main'))
  107. @app.route('/login')
  108. def log_in():
  109. if 'acct' in session:
  110. #user is probably already logged in. if they aren't, home() will handle things and redirect them back here
  111. return redirect(url_for('home'))
  112. return render_template("login.html")
  113. # return(json.dumps(client_info))
  114. #internal stuff
  115. @app.route('/internal/auth_a')
  116. def internal_auth_a(): #TODO: prevent these endpoints from being spammed somehow
  117. session['instance'] = request.args.get('instance', default='mastodon.social', type=str)
  118. if not session['instance'].startswith("https://"):
  119. session['instance'] = "https://{}".format(session['instance'])
  120. session['client_id'], session['client_secret'] = Mastodon.create_app(cfg['name'],
  121. api_base_url=session['instance'],
  122. scopes=scopes,
  123. website=cfg['website'],
  124. redirect_uris=['{}/internal/auth_b'.format(cfg['base_uri'])]
  125. )
  126. client = Mastodon(client_id=session['client_id'], client_secret=session['client_secret'], api_base_url=session['instance'])
  127. url = client.auth_request_url(client_id=session['client_id'], redirect_uris='{}/internal/auth_b'.format(cfg['base_uri']), scopes=scopes)
  128. return redirect(url, code=307)
  129. @app.route('/internal/auth_b')
  130. def internal_auth_b():
  131. #write details to DB
  132. db, c, dc = db_reconnect()
  133. client = Mastodon(client_id=session['client_id'], client_secret=session['client_secret'], api_base_url=session['instance'])
  134. session['secret'] = client.log_in(code = request.args.get('code'), scopes=scopes, redirect_uri='{}/internal/auth_b'.format(cfg['base_uri']))
  135. acct_info = client.account_verify_credentials()
  136. session['username'] = acct_info['username']
  137. session['avi'] = acct_info['avatar']
  138. session['acct'] = "@{}@{}".format(session['username'], session['instance'].replace("https://", ""))
  139. c.execute("SELECT COUNT(*) FROM data WHERE username = %s AND instance = %s", (session['username'], session['instance']))
  140. if c.fetchone()[0] > 0:
  141. #user already has an account with CG
  142. #update the user's info to use the new info we just got, then redirect them to the login page
  143. c.execute("UPDATE data SET client_id = %s, client_secret = %s, secret = %s, avi = %s WHERE username = %s AND instance = %s", (session['client_id'], session['client_secret'], session['secret'], session['avi'], session['username'], session['instance']))
  144. db.commit()
  145. db.close()
  146. return redirect(url_for('log_in'))
  147. else:
  148. db.close()
  149. return redirect(url_for('create_password'))
  150. @app.route('/internal/do_login', methods = ['POST'])
  151. def do_login():
  152. db, c, dc = db_reconnect()
  153. pw_in = request.form['pw']
  154. pw_hashed = hashlib.sha256(pw_in.encode('utf-8')).digest()
  155. acct = request.form['acct']
  156. session['username'] = re.match("^@([^@]+)@", acct).group(1)
  157. session['instance'] = "https://{}".format(re.search("@([^@]+)$", acct).group(1)) #todo: this occasionally gets "https://@username@instan.ce"
  158. dc.execute("SELECT * FROM data WHERE username = %s AND instance = %s", (session['username'], session['instance']))
  159. data = dc.fetchone()
  160. db.close()
  161. if bcrypt.checkpw(pw_hashed, data['password'].encode('utf-8')):
  162. #password is correct, log the user in
  163. for item in ['username', 'instance', 'avi', 'secret', 'client_id', 'client_secret', 'cc', 'ccavi']:
  164. session[item] = data[item]
  165. session['acct'] = "@{}@{}".format(session['username'], re.match("https://(.*)", session['instance']).group(1))
  166. return redirect('/home')
  167. else:
  168. return redirect('/login?invalid')
  169. @app.route('/create_password')
  170. def create_password():
  171. db, c, dc = db_reconnect()
  172. c.execute("SELECT COUNT(*) FROM data WHERE username = %s AND instance = %s", (session['username'], session['instance']))
  173. if c.fetchone()[0] == 0:
  174. db.close()
  175. return render_template("create_password.html", bg = "background-image:url('{}')".format(session['avi']))
  176. else:
  177. #user already exists in database, so they already have a password
  178. db.close()
  179. return redirect(url_for('main'))
  180. @app.route('/internal/create_account', methods=['POST'])
  181. def create_account():
  182. db, c, dc = db_reconnect()
  183. pw_in = request.form['pw']
  184. if len(pw_in) < 6 or pw_in == 'password': #TODO: this is a pretty crappy check
  185. db.close()
  186. return redirect('/create_password?invalid')
  187. pw_hashed = hashlib.sha256(pw_in.encode('utf-8')).digest()
  188. pw = bcrypt.hashpw(pw_hashed, bcrypt.gensalt(15))
  189. c.execute("INSERT INTO data (username, instance, avi, password, secret, client_id, client_secret) VALUES (%s, %s, %s, %s, %s, %s, %s)", (session['username'], session['instance'], session['avi'], pw, session['secret'], session['client_id'], session['client_secret']))
  190. db.commit()
  191. db.close()
  192. return redirect(url_for('home'))
  193. #cc connection
  194. @app.route('/cc_connect')
  195. def cc_connect():
  196. return render_template('cc_connect.html')
  197. @app.route('/internal/ccc_a', methods=['POST'])
  198. def ccc_a(): #step one of curiouscat connection: retreive details
  199. r = requests.get("https://curiouscat.me/api/v2/profile?username={}&count=1".format(request.form['cc']))
  200. j = r.json()
  201. if 'error' in j:
  202. return redirect('/cc_connect?invalid')
  203. session['cctemp'] = {
  204. "cc":j['userData']['username'],
  205. "ccavi":j['userData']['avatar'],
  206. "ccid":j['userData']['id'],
  207. "latest_post":j['posts'][0]['timestamp'] if len(j['posts']) != 0 else 0 #only post new answers from this point onwards, rather than posting all the old ones
  208. }
  209. return redirect('/cc_connect/confirm')
  210. @app.route('/cc_connect/confirm')
  211. def cc_connect_confirm():
  212. return render_template('cc_connect_confirm.html', bg="background-image:url('{}')".format(session['cctemp']['ccavi']))
  213. @app.route('/internal/ccc_b') #TODO: don't allow people to spam this
  214. def ccc_b():
  215. session['cctemp']['challenge'] = random.randint(100000, 999999)
  216. session.modified = True
  217. form_data = {
  218. "addressees": session['cctemp']['ccid'],
  219. "anon": "true",
  220. "question": "Hi {}! Your Curious Greg authentication code is: {}. You may safely delete this question after entering the code. If you didn't request this, you can ignore this question.".format(session['acct'], session['cctemp']['challenge'])
  221. }
  222. r = requests.post("https://curiouscat.me/api/v2/post/create", data=form_data)
  223. j = r.json()
  224. if 'success' in j and j['success'] == True:
  225. return redirect('/cc_connect/code')
  226. else:
  227. #todo: handle error properly
  228. return False
  229. @app.route('/cc_connect/code')
  230. def cc_connect_code():
  231. return render_template('cc_connect_code.html')
  232. @app.route('/internal/ccc_c', methods=['POST'])
  233. def ccc_c():
  234. if int(request.form['challenge']) != session['cctemp']['challenge']:
  235. return redirect('/cc_connect/code?invalid')
  236. for item in ['cc', 'ccavi']:
  237. session[item] = session['cctemp'][item]
  238. db, c, dc = db_reconnect()
  239. c.execute("UPDATE data SET cc = %s, ccavi = %s, latest_post = %s WHERE username = %s AND instance = %s", (session['cc'], session['ccavi'], session['cctemp']['latest_post'], session['username'], session['instance']))
  240. db.commit()
  241. db.close()
  242. del session['cctemp']
  243. return redirect('/cc_connect/complete')
  244. @app.route('/cc_connect/complete')
  245. def cc_connect_complete():
  246. return render_template('cc_connect_complete.html', bg="background-image:url('{}')".format(session['ccavi']))
  247. @app.route('/settings')
  248. def settings_page():
  249. return render_template('settings.html')