From b0ccb2e3b089771607d7a32343e043b9ff77adbb Mon Sep 17 00:00:00 2001 From: Mattia Succurro Date: Wed, 16 Oct 2019 18:36:30 +0200 Subject: [PATCH 01/42] Preparing for release --- .gitignore | 1 + CHANGELOG.md | 2 +- app.py | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index dd7c762..0bd8872 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ coriplus.sqlite +coriplus-*.sqlite __pycache__/ uploads/ *.pyc diff --git a/CHANGELOG.md b/CHANGELOG.md index 58ee3f1..ccfd15f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 0.5-dev +## 0.5.0 * Removed `type` and `info` fields from `Message` table and merged `privacy` field, previously into a separate table, into that table. In order to make the app work, when upgrading you should run the `migrate_0_4_to_0_5.py` script. * Added flask-login dependency. Now, user logins can be persistent up to 365 days. diff --git a/app.py b/app.py index b7c92da..a753040 100644 --- a/app.py +++ b/app.py @@ -8,7 +8,7 @@ from functools import wraps import argparse from flask_login import LoginManager, login_user, logout_user, login_required -__version__ = '0.5-dev' +__version__ = '0.5.0' # we want to support Python 3 only. # Python 2 has too many caveats. From 156d58e5499802e0deb6eb56628b9b7dff46616f Mon Sep 17 00:00:00 2001 From: Mattia Succurro Date: Wed, 16 Oct 2019 19:06:09 +0200 Subject: [PATCH 02/42] Changing version number --- .gitignore | 1 + CHANGELOG.md | 4 +++- app.py | 2 +- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index dd7c762..0bd8872 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ coriplus.sqlite +coriplus-*.sqlite __pycache__/ uploads/ *.pyc diff --git a/CHANGELOG.md b/CHANGELOG.md index 58ee3f1..b7823b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,8 @@ # Changelog -## 0.5-dev +## 0.6-dev + +## 0.5.0 * Removed `type` and `info` fields from `Message` table and merged `privacy` field, previously into a separate table, into that table. In order to make the app work, when upgrading you should run the `migrate_0_4_to_0_5.py` script. * Added flask-login dependency. Now, user logins can be persistent up to 365 days. diff --git a/app.py b/app.py index b7c92da..cd5cb29 100644 --- a/app.py +++ b/app.py @@ -8,7 +8,7 @@ from functools import wraps import argparse from flask_login import LoginManager, login_user, logout_user, login_required -__version__ = '0.5-dev' +__version__ = '0.6-dev' # we want to support Python 3 only. # Python 2 has too many caveats. From 32e7c37158fbdf5e125eb80ebe695a26afef4e62 Mon Sep 17 00:00:00 2001 From: Mattia Succurro Date: Thu, 17 Oct 2019 14:34:55 +0200 Subject: [PATCH 03/42] Adding profiles and adminship --- CHANGELOG.md | 7 +++ app.py | 57 +++++++++++++++++++++++-- static/style.css | 5 +++ templates/edit_profile.html | 13 ++++++ templates/includes/infobox_profile.html | 30 +++++++++++++ templates/join.html | 26 +++++++++-- templates/user_detail.html | 11 +---- 7 files changed, 133 insertions(+), 16 deletions(-) create mode 100644 templates/edit_profile.html create mode 100644 templates/includes/infobox_profile.html diff --git a/CHANGELOG.md b/CHANGELOG.md index b7823b4..a63f553 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ ## 0.6-dev +* Added user adminship. Admins are users with very high privileges. Adminship can be assigned only at script level (not from the web). +* Now one's messages won't show up in public timeline. +* Added user profile info. Now you can specify your full name, biography, location, birth year, website, Facebook and Instagram. Of course this is totally optional. +* Added reference to terms of service and privacy policy on signup page. +* When visiting signup page as logged in, user should confirm he wants to create another account in order to do it. +* Moved user stats inside profile info. + ## 0.5.0 * Removed `type` and `info` fields from `Message` table and merged `privacy` field, previously into a separate table, into that table. In order to make the app work, when upgrading you should run the `migrate_0_4_to_0_5.py` script. diff --git a/app.py b/app.py index cd5cb29..9c04f8d 100644 --- a/app.py +++ b/app.py @@ -93,6 +93,38 @@ class User(BaseModel): .where( (Notification.target == self) & (Notification.seen == 0) )) + # user adminship is stored into a separate table; new in 0.6 + @property + def is_admin(self): + return UserAdminship.select().where(UserAdminship.user == self).exists() + # user profile info; new in 0.6 + @property + def profile(self): + # lazy initialization; I don't want (and don't know how) + # to do schema changes. + try: + return UserProfile.get(UserProfile.user == self) + except UserProfile.DoesNotExist: + return UserProfile.create(user=self, full_name=self.username) + +# User adminship. +# A very high privilege where users can review posts. +# For very few users only; new in 0.6 +class UserAdminship(BaseModel): + user = ForeignKeyField(User, primary_key=True) + +# User profile. +# Additional info for identifying users. +# New in 0.6 +class UserProfile(BaseModel): + user = ForeignKeyField(User, primary_key=True) + full_name = TextField() + biography = TextField(default='') + location = IntegerField(null=True) + year = IntegerField(null=True) + website = TextField(null=True) + instagram = TextField(null=True) + facebook = TextField(null=True) # The message privacy values. MSGPRV_PUBLIC = 0 # everyone @@ -118,11 +150,11 @@ class Message(BaseModel): privacy = self.privacy if user == cur_user: # short path - return True + # also: don't show user's messages in public timeline + return not is_public_timeline elif privacy == MSGPRV_PUBLIC: return True elif privacy == MSGPRV_UNLISTED: - # TODO user's posts may appear the same in public timeline, # even if unlisted return not is_public_timeline elif privacy == MSGPRV_FRIENDS: @@ -172,7 +204,8 @@ class Notification(BaseModel): def create_tables(): with database: database.create_tables([ - User, Message, Relationship, Upload, Notification]) + User, UserAdminship, UserProfile, Message, Relationship, + Upload, Notification]) if not os.path.isdir(UPLOAD_DIRECTORY): os.makedirs(UPLOAD_DIRECTORY) @@ -384,6 +417,11 @@ def register(): username = request.form['username'].lower() if not is_username(username): flash('This username is invalid') + return render_template('join.html') + if username == getattr(get_current_user(), 'username', None) and not request.form.get('confirm_another'): + flash('You are already logged in. Please confirm you want to ' + 'create another account by checking the option.') + return render_template('join.html') try: with database.atomic(): # Attempt to create the user. If the username is taken, due to the @@ -394,6 +432,10 @@ def register(): email=request.form['email'], birthday=birthday, join_date=datetime.datetime.now()) + UserProfile.create( + user=user, + full_name=request.form.get('full_name') or username + ) # mark the user as being 'authenticated' by setting the session vars login_user(user) @@ -562,6 +604,15 @@ def edit(id): #def confirm_delete(id): # return render_template('confirm_delete.html') +@app.route('/edit_profile/', methods=['GET', 'POST']) +def edit_profile(): + if request.method == 'POST': + user = get_current_user() + username = request.form['username'] + if username != user.username: + User.update(username=username).where(User.id == user.id).execute() + return render_template('edit_profile.html') + @app.route('/notifications/') @login_required def notifications(): diff --git a/static/style.css b/static/style.css index baa794c..650f108 100644 --- a/static/style.css +++ b/static/style.css @@ -8,7 +8,12 @@ body{margin:0} .metanav{float:right} .header h1{margin:0;display:inline-block} .flash{background-color:#ff9;border:yellow 1px solid} +.infobox{padding:12px;border:#ccc 1px solid} +@media (min-width:640px) { + .infobox{float:right;width:320px} +} .weak{opacity:.5} +.field_desc{display:block} .message-visual img{max-width:100%;max-height:8em} .message-options-showhide::before{content:'\2026'} .message-options{display:none} diff --git a/templates/edit_profile.html b/templates/edit_profile.html new file mode 100644 index 0000000..7475d4f --- /dev/null +++ b/templates/edit_profile.html @@ -0,0 +1,13 @@ +{% extends "base.html" %} + +{% block body %} +

Edit Profile

+ +
+
+
Username:
+
+
+
+
+{% endblock %} diff --git a/templates/includes/infobox_profile.html b/templates/includes/infobox_profile.html new file mode 100644 index 0000000..730db0c --- /dev/null +++ b/templates/includes/infobox_profile.html @@ -0,0 +1,30 @@ +{% set profile = user.profile %} +
+

{{ profile.full_name }}

+

{{ profile.biography|enrich }}

+ {% if profile.location %} +

Location: {{ profile.location }}

+ {% endif %} + {% if profile.year %} +

Year: {{ profile.year }}

+ {% endif %} + {% if profile.website %} +

Website: {{ profile.website|urlize }}

+ {% endif %} + {% if profile.instagram %} +

Instagram: {{ profile.instagram }}

+ {% endif %} + {% if profile.facebook %} +

Facebook: {{ profile.facebook }}

+ {% endif %} +

+ {{ user.messages|count }} messages + - + {{ user.followers()|count }} followers + - + {{ user.following()|count }} following +

+ {% if user == current_user %} +

Edit profile

+ {% endif %} +
diff --git a/templates/join.html b/templates/join.html index 29411a6..ad980de 100644 --- a/templates/join.html +++ b/templates/join.html @@ -1,16 +1,34 @@ {% extends "base.html" %} {% block body %}

Join {{ site_name }}

-
+
Username:
-
+
+
Full name:
+
+ If not given, defaults to your username. + +
Password:
Email:
-
Birthday: -
+
Birthday:
+
+ Your birthday won't be shown to anyone. + +
+ {% if not current_user.is_anonymous %} +
+ + +
+ {% endif %} +
+ + +
diff --git a/templates/user_detail.html b/templates/user_detail.html index 7f93604..9316432 100644 --- a/templates/user_detail.html +++ b/templates/user_detail.html @@ -1,13 +1,7 @@ {% extends "base.html" %} {% block body %} + {% include "includes/infobox_profile.html" %}

Messages from {{ user.username }}

-

- {{ user.messages|count }} messages - - - {{ user.followers()|count }} followers - - - {{ user.following()|count }} following -

{% if not current_user.is_anonymous %} {% if user.username != current_user.username %} {% if current_user|is_following(user) %} @@ -21,8 +15,7 @@ {% endif %}

Mention this user in a message

{% else %} - - Create a status + Create a message {% endif %} {% endif %}
    From bfc44c9362b9c7714b0c78158aeb40139720b9ae Mon Sep 17 00:00:00 2001 From: Mattia Succurro Date: Thu, 17 Oct 2019 15:21:33 +0200 Subject: [PATCH 04/42] Now you can edit username, full name, biography and website --- app.py | 9 +++++++++ static/style.css | 1 + templates/edit_profile.html | 9 ++++++++- 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/app.py b/app.py index 9c04f8d..1045cd1 100644 --- a/app.py +++ b/app.py @@ -609,8 +609,17 @@ def edit_profile(): if request.method == 'POST': user = get_current_user() username = request.form['username'] + if not username: + # prevent username to be set to empty + username = user.username if username != user.username: User.update(username=username).where(User.id == user.id).execute() + UserProfile.update( + full_name=request.form['full_name'] or username, + biography=request.form['biography'], + website=request.form['website'] + ).where(UserProfile.user == user).execute() + return redirect(url_for('user_detail', username=username)) return render_template('edit_profile.html') @app.route('/notifications/') diff --git a/static/style.css b/static/style.css index 650f108..6b084e5 100644 --- a/static/style.css +++ b/static/style.css @@ -18,6 +18,7 @@ body{margin:0} .message-options-showhide::before{content:'\2026'} .message-options{display:none} .create_text{width:100%;height:8em} +.biography_text{height:4em} .follow_button,input[type="submit"]{background-color:#ff3018;color:white;border-radius:3px;border:1px solid #ff3018} .follow_button.following{background-color:transparent;color:#ff3018;border-color:#ff3018} .copyright{font-size:smaller;text-align:center;color:#808080} diff --git a/templates/edit_profile.html b/templates/edit_profile.html index 7475d4f..c173260 100644 --- a/templates/edit_profile.html +++ b/templates/edit_profile.html @@ -6,7 +6,14 @@
    Username:
    -
    +
    + {% set profile = current_user.profile %} +
    Full name:
    +
    +
    Biography:
    +
    +
    Website:
    +
    From b9467583b76c60fcbf2cd2bc574ed033e0ef597f Mon Sep 17 00:00:00 2001 From: Mattia Succurro Date: Sun, 20 Oct 2019 13:14:16 +0200 Subject: [PATCH 05/42] Adding location and privacy policy --- CHANGELOG.md | 1 + app.py | 48 ++- locations.txt | 356 ++++++++++++++++++++++ templates/edit_profile.html | 2 + templates/includes/infobox_profile.html | 4 +- templates/includes/location_selector.html | 6 + templates/privacy.html | 40 +++ 7 files changed, 454 insertions(+), 3 deletions(-) create mode 100644 locations.txt create mode 100644 templates/includes/location_selector.html diff --git a/CHANGELOG.md b/CHANGELOG.md index a63f553..3d5269a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ * Added reference to terms of service and privacy policy on signup page. * When visiting signup page as logged in, user should confirm he wants to create another account in order to do it. * Moved user stats inside profile info. +* Adding Privacy Policy. ## 0.5.0 diff --git a/app.py b/app.py index 1045cd1..95529a1 100644 --- a/app.py +++ b/app.py @@ -236,6 +236,11 @@ def validate_birthday(date): return True return False +def validate_website(website): + return re.match(r'(?:https?://)?(?:[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*' + r'|\[[A-Fa-f0-9:]+\])(?::\d+)?(?:/[^\s]*)?(?:\?[^\s]*)?(?:#[^\s]*)?$', + website) + def human_short_date(timestamp): return '' @@ -306,6 +311,25 @@ class Visibility(object): yield i counter += 1 +def get_locations(): + data = {} + with open('locations.txt') as f: + for line in f: + line = line.rstrip() + if line.startswith('#'): + continue + try: + key, value = line.split(None, 1) + except ValueError: + continue + data[key] = value + return data + +try: + locations = get_locations() +except OSError: + locations = {} + # get the user from the session # changed in 0.5 to comply with flask_login def get_current_user(): @@ -369,7 +393,7 @@ def after_request(response): @app.context_processor def _inject_variables(): - return {'site_name': app.config['SITE_NAME']} + return {'site_name': app.config['SITE_NAME'], 'locations': locations} @login_manager.user_loader def _inject_user(userid): @@ -614,10 +638,18 @@ def edit_profile(): username = user.username if username != user.username: User.update(username=username).where(User.id == user.id).execute() + website = request.form['website'].strip().replace(' ', '%20') + if website and not validate_website(website): + flash('You should enter a valid URL.') + return render_template('edit_profile.html') + location = int(request.form.get('location')) + if location == 0: + location = None UserProfile.update( full_name=request.form['full_name'] or username, biography=request.form['biography'], - website=request.form['website'] + website=website, + location=location ).where(UserProfile.user == user).execute() return redirect(url_for('user_detail', username=username)) return render_template('edit_profile.html') @@ -677,6 +709,14 @@ def username_availability(username): is_available = False return jsonify({'is_valid':is_valid, 'is_available':is_available, 'status':'ok'}) +@app.route('/ajax/location_search/') +def location_search(name): + results = [] + for key, value in locations.items(): + if value.startswith(name): + results.append({'value': key, 'display': value}) + return jsonify({'results': results}) + _enrich_symbols = [ (r'\n', 'NEWLINE'), (r'https?://(?:[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*|\[[A-Fa-f0-9:]+\])' @@ -720,6 +760,10 @@ def enrich(s): def is_following(from_user, to_user): return from_user.is_following(to_user) +@app.template_filter('locationdata') +def locationdata(key): + if key > 0: + return locations[str(key)] # allow running from the command line if __name__ == '__main__': diff --git a/locations.txt b/locations.txt new file mode 100644 index 0000000..6725e9c --- /dev/null +++ b/locations.txt @@ -0,0 +1,356 @@ +004 Afghanistan +008 Albania +010 Antarctica +012 Algeria +016 American Samoa +020 Andorra +024 Angola +028 Antigua and Barbuda +031 Azerbaijan +032 Argentina +036 Australia +040 Austria +044 Bahamas +048 Bahrain +050 Bangladesh +051 Armenia +052 Barbados +056 Belgium +060 Bermuda +064 Bhutan +068 Bolivia (Plurinational State of) +070 Bosnia and Herzegovina +072 Botswana +074 Bouvet Island +076 Brazil +084 Belize +086 British Indian Ocean Territory +090 Solomon Islands +092 Virgin Islands (British) +096 Brunei Darussalam +100 Bulgaria +104 Myanmar +108 Burundi +112 Belarus +116 Cambodia +120 Cameroon +124 Canada +132 Cabo Verde +136 Cayman Islands +140 Central African Republic +144 Sri Lanka +148 Chad +152 Chile +156 China +158 Taiwan, Province of China +162 Christmas Island +166 Cocos (Keeling) Islands +170 Colombia +174 Comoros +175 Mayotte +178 Congo +180 Congo, Democratic Republic of the +184 Cook Islands +188 Costa Rica +191 Croatia +192 Cuba +196 Cyprus +203 Czechia +204 Benin +208 Denmark +212 Dominica +214 Dominican Republic +218 Ecuador +222 El Salvador +226 Equatorial Guinea +231 Ethiopia +232 Eritrea +233 Estonia +234 Faroe Islands +238 Falkland Islands (Malvinas) +239 South Georgia and the South Sandwich Islands +242 Fiji +246 Finland +248 Åland Islands +250 France +254 French Guiana +258 French Polynesia +260 French Southern Territories +262 Djibouti +266 Gabon +268 Georgia +270 Gambia +275 Palestine, State of +276 Germany +288 Ghana +292 Gibraltar +296 Kiribati +300 Greece +304 Greenland +308 Grenada +312 Guadeloupe +316 Guam +320 Guatemala +324 Guinea +328 Guyana +332 Haiti +334 Heard Island and McDonald Islands +336 Holy See +340 Honduras +344 Hong Kong +348 Hungary +352 Iceland +356 India +360 Indonesia +364 Iran (Islamic Republic of) +368 Iraq +372 Ireland +376 Israel +380 Italy +384 Côte d'Ivoire +388 Jamaica +392 Japan +398 Kazakhstan +400 Jordan +404 Kenya +408 Korea (Democratic People's Republic of) +410 Korea, Republic of +414 Kuwait +417 Kyrgyzstan +418 Lao People's Democratic Republic +422 Lebanon +426 Lesotho +428 Latvia +430 Liberia +434 Libya +438 Liechtenstein +440 Lithuania +442 Luxembourg +446 Macao +450 Madagascar +454 Malawi +458 Malaysia +462 Maldives +466 Mali +470 Malta +474 Martinique +478 Mauritania +480 Mauritius +484 Mexico +492 Monaco +496 Mongolia +498 Moldova, Republic of +499 Montenegro +500 Montserrat +504 Morocco +508 Mozambique +512 Oman +516 Namibia +520 Nauru +524 Nepal +528 Netherlands +531 Curaçao +533 Aruba +534 Sint Maarten (Dutch part) +535 Bonaire, Sint Eustatius and Saba +540 New Caledonia +548 Vanuatu +554 New Zealand +558 Nicaragua +562 Niger +566 Nigeria +570 Niue +574 Norfolk Island +578 Norway +580 Northern Mariana Islands +581 United States Minor Outlying Islands +583 Micronesia (Federated States of) +584 Marshall Islands +585 Palau +586 Pakistan +591 Panama +598 Papua New Guinea +600 Paraguay +604 Peru +608 Philippines +612 Pitcairn +616 Poland +620 Portugal +624 Guinea-Bissau +626 Timor-Leste +630 Puerto Rico +634 Qatar +638 Réunion +642 Romania +643 Russian Federation +646 Rwanda +652 Saint Barthélemy +654 Saint Helena, Ascension and Tristan da Cunha +659 Saint Kitts and Nevis +660 Anguilla +662 Saint Lucia +663 Saint Martin (French part) +666 Saint Pierre and Miquelon +670 Saint Vincent and the Grenadines +674 San Marino +678 Sao Tome and Principe +682 Saudi Arabia +686 Senegal +688 Serbia +690 Seychelles +694 Sierra Leone +702 Singapore +703 Slovakia +704 Viet Nam +705 Slovenia +706 Somalia +710 South Africa +716 Zimbabwe +724 Spain +728 South Sudan +729 Sudan +732 Western Sahara +740 Suriname +744 Svalbard and Jan Mayen +748 Eswatini +752 Sweden +756 Switzerland +760 Syrian Arab Republic +762 Tajikistan +764 Thailand +768 Togo +772 Tokelau +776 Tonga +780 Trinidad and Tobago +784 United Arab Emirates +788 Tunisia +792 Turkey +795 Turkmenistan +796 Turks and Caicos Islands +798 Tuvalu +800 Uganda +804 Ukraine +807 North Macedonia +818 Egypt +826 United Kingdom of Great Britain and Northern Ireland +831 Guernsey +832 Jersey +833 Isle of Man +834 Tanzania, United Republic of +840 United States of America +850 Virgin Islands (U.S.) +854 Burkina Faso +858 Uruguay +860 Uzbekistan +862 Venezuela (Bolivarian Republic of) +876 Wallis and Futuna +882 Samoa +887 Yemen +894 Zambia +1001 Torino +1002 Vercelli +1003 Novara +1004 Cuneo +1005 Asti +1006 Alessandria +1007 Aosta +1008 Imperia +1009 Savona +1010 Genova +1011 La Spezia +1012 Varese +1013 Como +1014 Sondrio +1015 Milano +1016 Bergamo +1017 Brescia +1018 Pavia +1019 Cremona +1020 Mantova +1021 Bolzano +1022 Trento +1023 Verona +1024 Vicenza +1025 Belluno +1026 Treviso +1027 Venezia +1028 Padova +1029 Rovigo +1030 Udine +1031 Gorizia +1032 Trieste +1033 Piacenza +1034 Parma +1035 Reggio nell'Emilia +1036 Modena +1037 Bologna +1038 Ferrara +1039 Ravenna +1040 Forlì-Cesena +1041 Pesaro e Urbino +1042 Ancona +1043 Macerata +1044 Ascoli Piceno +1045 Massa-Carrara +1046 Lucca +1047 Pistoia +1048 Firenze +1049 Livorno +1050 Pisa +1051 Arezzo +1052 Siena +1053 Grosseto +1054 Perugia +1055 Terni +1056 Viterbo +1057 Rieti +1058 Roma +1059 Latina +1060 Frosinone +1061 Caserta +1062 Benevento +1063 Napoli +1064 Avellino +1065 Salerno +1066 L'Aquila +1067 Teramo +1068 Pescara +1069 Chieti +1070 Campobasso +1071 Foggia +1072 Bari +1073 Taranto +1074 Brindisi +1075 Lecce +1076 Potenza +1077 Matera +1078 Cosenza +1079 Catanzaro +1080 Reggio Calabria +1081 Trapani +1082 Palermo +1083 Messina +1084 Agrigento +1085 Caltanissetta +1086 Enna +1087 Catania +1088 Ragusa +1089 Siracusa +1090 Sassari +1091 Nuoro +1092 Cagliari +1093 Pordenone +1094 Isernia +1095 Oristano +1096 Biella +1097 Lecco +1098 Lodi +1099 Rimini +1100 Prato +1101 Crotone +1102 Vibo Valentia +1103 Verbano-Cusio-Ossola +1108 Monza e della Brianza +1109 Fermo +1110 Barletta-Andria-Trani +1111 Sud Sardegna diff --git a/templates/edit_profile.html b/templates/edit_profile.html index c173260..dad75e7 100644 --- a/templates/edit_profile.html +++ b/templates/edit_profile.html @@ -12,6 +12,8 @@
    Biography:
    +
    Location:
    +
    {% include "includes/location_selector.html" %}
    Website:
    diff --git a/templates/includes/infobox_profile.html b/templates/includes/infobox_profile.html index 730db0c..d268eae 100644 --- a/templates/includes/infobox_profile.html +++ b/templates/includes/infobox_profile.html @@ -3,12 +3,14 @@

    {{ profile.full_name }}

    {{ profile.biography|enrich }}

    {% if profile.location %} -

    Location: {{ profile.location }}

    +

    Location: {{ profile.location|locationdata }}

    {% endif %} {% if profile.year %}

    Year: {{ profile.year }}

    {% endif %} {% if profile.website %} + {% set website = profile.website %} + {% set website = website if website.startswith(('http://', 'https://')) else 'http://' + website %}

    Website: {{ profile.website|urlize }}

    {% endif %} {% if profile.instagram %} diff --git a/templates/includes/location_selector.html b/templates/includes/location_selector.html new file mode 100644 index 0000000..86861d4 --- /dev/null +++ b/templates/includes/location_selector.html @@ -0,0 +1,6 @@ + diff --git a/templates/privacy.html b/templates/privacy.html index 8bdfb55..a7b8570 100644 --- a/templates/privacy.html +++ b/templates/privacy.html @@ -3,5 +3,45 @@ {% block body %}

    Privacy Policy

    +

    At {{ site_name }}, accessible from {{ request.host }}, one of our main priorities is the privacy of our visitors. This Privacy Policy document contains types of information that is collected and recorded by {{ site_name }} and how we use it.

    +

    If you have additional questions or require more information about our Privacy Policy, do not hesitate to contact us through email at sakuragasaki46@gmail.com

    + +

    Log Files

    + +

    {{ site_name }} follows a standard procedure of using log files. These files log visitors when they visit websites. All hosting companies do this and a part of hosting services' analytics. The information collected by log files include internet protocol (IP) addresses, browser type, Internet Service Provider (ISP), date and time stamp, referring/exit pages, and possibly the number of clicks. These are not linked to any information that is personally identifiable. The purpose of the information is for analyzing trends, administering the site, tracking users' movement on the website, and gathering demographic information.

    + +

    Cookies and Web Beacons

    + +

    Like any other website, {{ site_name }} uses 'cookies'. These cookies are used to store information including visitors' preferences, and the pages on the website that the visitor accessed or visited. The information is used to optimize the users' experience by customizing our web page content based on visitors' browser type and/or other information.

    + + + +

    Privacy Policies

    + +

    You may consult this list to find the Privacy Policy for each of the advertising partners of {{ site_name }}. Our Privacy Policy was created with the help of the Privacy Policy Generator and the Generate Privacy Policy Generator.

    + +

    Third-party ad servers or ad networks uses technologies like cookies, JavaScript, or Web Beacons that are used in their respective advertisements and links that appear on {{ site_name }}, which are sent directly to users' browser. They automatically receive your IP address when this occurs. These technologies are used to measure the effectiveness of their advertising campaigns and/or to personalize the advertising content that you see on websites that you visit.

    + +

    Note that {{ site_name }} has no access to or control over these cookies that are used by third-party advertisers.

    + +

    Third Party Privacy Policies

    + +

    {{ site_name }}'s Privacy Policy does not apply to other advertisers or websites. Thus, we are advising you to consult the respective Privacy Policies of these third-party ad servers for more detailed information. It may include their practices and instructions about how to opt-out of certain options. You may find a complete list of these Privacy Policies and their links here: Privacy Policy Links.

    + +

    You can choose to disable cookies through your individual browser options. To know more detailed information about cookie management with specific web browsers, it can be found at the browsers' respective websites. What Are Cookies?

    + +

    Children's Information

    + +

    Another part of our priority is adding protection for children while using the internet. We encourage parents and guardians to observe, participate in, and/or monitor and guide their online activity.

    + +

    {{ site_name }} does not knowingly collect any Personal Identifiable Information from children under the age of 13. If you think that your child provided this kind of information on our website, we strongly encourage you to contact us immediately and we will do our best efforts to promptly remove such information from our records.

    + +

    Online Privacy Policy Only

    + +

    This Privacy Policy applies only to our online activities and is valid for visitors to our website with regards to the information that they shared and/or collect in {{ site_name }}. This policy is not applicable to any information collected offline or via channels other than this website.

    + +

    Consent

    + +

    By using our website, you hereby consent to our Privacy Policy and agree to its Terms and Conditions.

    {% endblock %} From d8f7d609aa651131a5b1ebef854950bd88844134 Mon Sep 17 00:00:00 2001 From: Mattia Succurro Date: Sun, 20 Oct 2019 20:04:58 +0200 Subject: [PATCH 06/42] Fixed problem when entering invalid data while editing profile --- app.py | 33 ++++++++++++++++++++++++++++----- static/.style.css.swp | Bin 0 -> 1024 bytes static/style.css | 1 + templates/base.html | 3 ++- templates/edit_profile.html | 15 +++++++++++++-- 5 files changed, 44 insertions(+), 8 deletions(-) create mode 100644 static/.style.css.swp diff --git a/app.py b/app.py index 95529a1..c7fad25 100644 --- a/app.py +++ b/app.py @@ -18,6 +18,8 @@ if sys.version_info[0] < 3: arg_parser = argparse.ArgumentParser() arg_parser.add_argument('--norun', action='store_true', help='Don\'t run the app. Useful for debugging.') +arg_parser.add_argument('--debug', action='store_true', + help='Run the app in debug mode.') arg_parser.add_argument('-p', '--port', type=int, default=5000, help='The port where to run the app. Defaults to 5000') @@ -628,6 +630,20 @@ def edit(id): #def confirm_delete(id): # return render_template('confirm_delete.html') +# Workaround for problems related to invalid data. +# Without that, changes will be lost across requests. +def profile_checkpoint(): + return UserProfile( + user=get_current_user(), + full_name=request.form['full_name'], + biography=request.form['biography'], + location=int(request.form['location']), + year=int(request.form['year'] if request.form.get('has_year') else '0'), + website=request.form['website'] or None, + instagram=request.form['instagram'] or None, + facebook=request.form['facebook'] or None + ) + @app.route('/edit_profile/', methods=['GET', 'POST']) def edit_profile(): if request.method == 'POST': @@ -637,19 +653,26 @@ def edit_profile(): # prevent username to be set to empty username = user.username if username != user.username: - User.update(username=username).where(User.id == user.id).execute() + try: + User.update(username=username).where(User.id == user.id).execute() + except IntegrityError: + flash('That username is already taken') + return render_template('edit_profile.html', profile=profile_checkpoint()) website = request.form['website'].strip().replace(' ', '%20') if website and not validate_website(website): flash('You should enter a valid URL.') - return render_template('edit_profile.html') + return render_template('edit_profile.html', profile=profile_checkpoint()) location = int(request.form.get('location')) if location == 0: location = None UserProfile.update( full_name=request.form['full_name'] or username, biography=request.form['biography'], + year=request.form['year'] if request.form.get('has_year') else None, + location=location, website=website, - location=location + instagram=request.form['instagram'], + facebook=request.form['facebook'] ).where(UserProfile.user == user).execute() return redirect(url_for('user_detail', username=username)) return render_template('edit_profile.html') @@ -713,7 +736,7 @@ def username_availability(username): def location_search(name): results = [] for key, value in locations.items(): - if value.startswith(name): + if value.lower().startswith(name.lower()): results.append({'value': key, 'display': value}) return jsonify({'results': results}) @@ -770,4 +793,4 @@ if __name__ == '__main__': args = arg_parser.parse_args() create_tables() if not args.norun: - app.run(port=args.port) + app.run(port=args.port, debug=args.debug) diff --git a/static/.style.css.swp b/static/.style.css.swp new file mode 100644 index 0000000000000000000000000000000000000000..e371f7802f15303029fdc9f60ce99dc8b238a71e GIT binary patch literal 1024 zcmYc?$V<%2S1{HyVn6|2Q49>Zi6teOi73KYIS_TaAsLx@*#U_ux~`^{rq~qfXXNLm z>O)lPo0Md@7A5K@=NDxb diff --git a/templates/edit_profile.html b/templates/edit_profile.html index dad75e7..2feaf29 100644 --- a/templates/edit_profile.html +++ b/templates/edit_profile.html @@ -7,15 +7,26 @@
    Username:
    - {% set profile = current_user.profile %} + {% if not profile %} + {% set profile = current_user.profile %} + {% endif %}
    Full name:
    Biography:
    Location:
    {% include "includes/location_selector.html" %}
    +
    Generation:
    +
    + + +
    Website:
    -
    +
    +
    Instagram:
    +
    +
    Facebook:
    +
    From 635e3eaa2da43ae3b110105c718a94a535cfad4e Mon Sep 17 00:00:00 2001 From: Mattia Succurro Date: Sun, 20 Oct 2019 20:19:20 +0200 Subject: [PATCH 07/42] Update readme and changelog --- .gitignore | 1 + CHANGELOG.md | 1 + README.md | 1 + static/.style.css.swp | Bin 1024 -> 0 bytes 4 files changed, 3 insertions(+) delete mode 100644 static/.style.css.swp diff --git a/.gitignore b/.gitignore index 0bd8872..cbec1df 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ __pycache__/ uploads/ *.pyc **~ +**/.*.swp diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d5269a..e9bc814 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ * When visiting signup page as logged in, user should confirm he wants to create another account in order to do it. * Moved user stats inside profile info. * Adding Privacy Policy. +* Adding links to Terms and Privacy at the bottom of any page. ## 0.5.0 diff --git a/README.md b/README.md index 5602072..8139d41 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ Based on Tweepee example of [peewee](https://github.com/coleifer/peewee/). * Create text statuses, optionally with image * Follow users * Timeline feed +* Add info to your profile * In-site notifications * SQLite-based app diff --git a/static/.style.css.swp b/static/.style.css.swp deleted file mode 100644 index e371f7802f15303029fdc9f60ce99dc8b238a71e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1024 zcmYc?$V<%2S1{HyVn6|2Q49>Zi6teOi73KYIS_TaAsLx@*#U_ux~`^{rq~qfXXNLm z>O)lPo0Md@7A5K@=NDxb Date: Sun, 20 Oct 2019 20:48:18 +0200 Subject: [PATCH 08/42] Preparing for release --- CHANGELOG.md | 2 +- app.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e9bc814..db5a99a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 0.6-dev +## 0.6.0 * Added user adminship. Admins are users with very high privileges. Adminship can be assigned only at script level (not from the web). * Now one's messages won't show up in public timeline. diff --git a/app.py b/app.py index c7fad25..cce32b8 100644 --- a/app.py +++ b/app.py @@ -8,7 +8,7 @@ from functools import wraps import argparse from flask_login import LoginManager, login_user, logout_user, login_required -__version__ = '0.6-dev' +__version__ = '0.6.0' # we want to support Python 3 only. # Python 2 has too many caveats. From a9006bf1bcd1ff316725a4c17943eb62a6bd4d9e Mon Sep 17 00:00:00 2001 From: Mattia Succurro Date: Wed, 23 Oct 2019 21:09:51 +0200 Subject: [PATCH 09/42] Unpacking modules --- .gitignore | 1 + CHANGELOG.md | 8 + app.py | 796 ------------------ app/__init__.py | 87 ++ app/__main__.py | 29 + app/ajax.py | 37 + app/filters.py | 66 ++ app/models.py | 198 +++++ {static => app/static}/lib.js | 0 {static => app/static}/style.css | 0 {templates => app/templates}/404.html | 0 {templates => app/templates}/about.html | 3 +- {templates => app/templates}/base.html | 16 +- {templates => app/templates}/create.html | 2 +- {templates => app/templates}/edit.html | 2 +- .../templates}/edit_profile.html | 2 + {templates => app/templates}/explore.html | 0 app/templates/homepage.html | 7 + .../templates}/includes/infobox_profile.html | 7 +- .../includes/location_selector.html | 0 .../templates}/includes/message.html | 4 +- .../templates}/includes/notification.html | 0 .../templates}/includes/pagination.html | 0 {templates => app/templates}/join.html | 2 +- {templates => app/templates}/login.html | 0 .../templates}/notifications.html | 0 {templates => app/templates}/privacy.html | 0 .../templates}/private_messages.html | 0 {templates => app/templates}/terms.html | 0 {templates => app/templates}/user_detail.html | 4 +- app/templates/user_list.html | 10 + app/utils.py | 162 ++++ app/website.py | 332 ++++++++ config.py | 1 - migrate_0_6_to_0_7.py | 10 + templates/homepage.html | 7 - 36 files changed, 971 insertions(+), 822 deletions(-) delete mode 100644 app.py create mode 100644 app/__init__.py create mode 100644 app/__main__.py create mode 100644 app/ajax.py create mode 100644 app/filters.py create mode 100644 app/models.py rename {static => app/static}/lib.js (100%) rename {static => app/static}/style.css (100%) rename {templates => app/templates}/404.html (100%) rename {templates => app/templates}/about.html (92%) rename {templates => app/templates}/base.html (63%) rename {templates => app/templates}/create.html (89%) rename {templates => app/templates}/edit.html (88%) rename {templates => app/templates}/edit_profile.html (92%) rename {templates => app/templates}/explore.html (100%) create mode 100644 app/templates/homepage.html rename {templates => app/templates}/includes/infobox_profile.html (72%) rename {templates => app/templates}/includes/location_selector.html (100%) rename {templates => app/templates}/includes/message.html (84%) rename {templates => app/templates}/includes/notification.html (100%) rename {templates => app/templates}/includes/pagination.html (100%) rename {templates => app/templates}/join.html (94%) rename {templates => app/templates}/login.html (100%) rename {templates => app/templates}/notifications.html (100%) rename {templates => app/templates}/privacy.html (100%) rename {templates => app/templates}/private_messages.html (100%) rename {templates => app/templates}/terms.html (100%) rename {templates => app/templates}/user_detail.html (81%) create mode 100755 app/templates/user_list.html create mode 100644 app/utils.py create mode 100644 app/website.py create mode 100644 migrate_0_6_to_0_7.py delete mode 100644 templates/homepage.html diff --git a/.gitignore b/.gitignore index cbec1df..c9522f1 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ uploads/ *.pyc **~ **/.*.swp +**/__pycache__/ diff --git a/CHANGELOG.md b/CHANGELOG.md index db5a99a..0398a37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 0.7-dev + +* Biggest change: unpacking modules. The single `app.py` file has become an `app` package, with submodules `models.py`, `utils.py`, `filters.py`, `website.py` and `ajax.py`. +* Now `/about/` shows Python and Flask versions. +* Now the error 404 handler returns HTTP 404. +* Added user followers and following lists, accessible via `/+/followers` and `/+/following` and from the profile info box, linked to the followers/following number. +* Schema changes: added column `telegram` to `UserProfile` table. To update schema, execute the script `migrate_0_6_to_0_7.py` + ## 0.6.0 * Added user adminship. Admins are users with very high privileges. Adminship can be assigned only at script level (not from the web). diff --git a/app.py b/app.py deleted file mode 100644 index cce32b8..0000000 --- a/app.py +++ /dev/null @@ -1,796 +0,0 @@ -from flask import ( - Flask, Markup, abort, flash, g, jsonify, redirect, render_template, request, - send_from_directory, session, url_for) -import hashlib -from peewee import * -import datetime, time, re, os, sys, string, json, html -from functools import wraps -import argparse -from flask_login import LoginManager, login_user, logout_user, login_required - -__version__ = '0.6.0' - -# we want to support Python 3 only. -# Python 2 has too many caveats. -if sys.version_info[0] < 3: - raise RuntimeError('Python 3 required') - -arg_parser = argparse.ArgumentParser() -arg_parser.add_argument('--norun', action='store_true', - help='Don\'t run the app. Useful for debugging.') -arg_parser.add_argument('--debug', action='store_true', - help='Run the app in debug mode.') -arg_parser.add_argument('-p', '--port', type=int, default=5000, - help='The port where to run the app. Defaults to 5000') - -app = Flask(__name__) -app.config.from_pyfile('config.py') - -login_manager = LoginManager(app) - -### DATABASE ### - -database = SqliteDatabase(app.config['DATABASE']) - -class BaseModel(Model): - class Meta: - database = database - -# A user. The user is separated from its page. -class User(BaseModel): - # The unique username. - username = CharField(unique=True) - # The password hash. - password = CharField() - # An email address. - email = CharField() - # The date of birth (required because of Terms of Service) - birthday = DateField() - # The date joined - join_date = DateTimeField() - # A disabled flag. 0 = active, 1 = disabled by user, 2 = banned - is_disabled = IntegerField(default=0) - - # Helpers for flask_login - def get_id(self): - return str(self.id) - @property - def is_active(self): - return not self.is_disabled - @property - def is_anonymous(self): - return False - @property - def is_authenticated(self): - return self == get_current_user() - - # it often makes sense to put convenience methods on model instances, for - # example, "give me all the users this user is following": - def following(self): - # query other users through the "relationship" table - return (User - .select() - .join(Relationship, on=Relationship.to_user) - .where(Relationship.from_user == self) - .order_by(User.username)) - - def followers(self): - return (User - .select() - .join(Relationship, on=Relationship.from_user) - .where(Relationship.to_user == self) - .order_by(User.username)) - - def is_following(self, user): - return (Relationship - .select() - .where( - (Relationship.from_user == self) & - (Relationship.to_user == user)) - .exists()) - - def unseen_notification_count(self): - return len(Notification - .select() - .where( - (Notification.target == self) & (Notification.seen == 0) - )) - # user adminship is stored into a separate table; new in 0.6 - @property - def is_admin(self): - return UserAdminship.select().where(UserAdminship.user == self).exists() - # user profile info; new in 0.6 - @property - def profile(self): - # lazy initialization; I don't want (and don't know how) - # to do schema changes. - try: - return UserProfile.get(UserProfile.user == self) - except UserProfile.DoesNotExist: - return UserProfile.create(user=self, full_name=self.username) - -# User adminship. -# A very high privilege where users can review posts. -# For very few users only; new in 0.6 -class UserAdminship(BaseModel): - user = ForeignKeyField(User, primary_key=True) - -# User profile. -# Additional info for identifying users. -# New in 0.6 -class UserProfile(BaseModel): - user = ForeignKeyField(User, primary_key=True) - full_name = TextField() - biography = TextField(default='') - location = IntegerField(null=True) - year = IntegerField(null=True) - website = TextField(null=True) - instagram = TextField(null=True) - facebook = TextField(null=True) - -# The message privacy values. -MSGPRV_PUBLIC = 0 # everyone -MSGPRV_UNLISTED = 1 # everyone, doesn't show up in public timeline -MSGPRV_FRIENDS = 2 # only accounts which follow each other -MSGPRV_ONLYME = 3 # only the poster - -# A single public message. -# New in v0.5: removed type and info fields; added privacy field. -class Message(BaseModel): - # The user who posted the message. - user = ForeignKeyField(User, backref='messages') - # The text of the message. - text = TextField() - # The posted date. - pub_date = DateTimeField() - # Info about privacy of the message. - privacy = IntegerField(default=MSGPRV_PUBLIC) - - def is_visible(self, is_public_timeline=False): - user = self.user - cur_user = get_current_user() - privacy = self.privacy - if user == cur_user: - # short path - # also: don't show user's messages in public timeline - return not is_public_timeline - elif privacy == MSGPRV_PUBLIC: - return True - elif privacy == MSGPRV_UNLISTED: - # even if unlisted - return not is_public_timeline - elif privacy == MSGPRV_FRIENDS: - if cur_user is None: - return False - return user.is_following(cur_user) and cur_user.is_following(user) - else: - return False - -# this model contains two foreign keys to user -- it essentially allows us to -# model a "many-to-many" relationship between users. by querying and joining -# on different columns we can expose who a user is "related to" and who is -# "related to" a given user -class Relationship(BaseModel): - from_user = ForeignKeyField(User, backref='relationships') - to_user = ForeignKeyField(User, backref='related_to') - created_date = DateTimeField() - - class Meta: - indexes = ( - # Specify a unique multi-column index on from/to-user. - (('from_user', 'to_user'), True), - ) - - -UPLOAD_DIRECTORY = 'uploads/' - -# fixing directory name because of imports from other directory -if __name__ != '__main__': - UPLOAD_DIRECTORY = os.path.join(os.path.dirname(__file__), UPLOAD_DIRECTORY) -class Upload(BaseModel): - # the extension of the media - type = TextField() - # the message bound to this media - message = ForeignKeyField(Message, backref='uploads') - # helper to retrieve contents - def filename(self): - return str(self.id) + '.' + self.type - -class Notification(BaseModel): - type = TextField() - target = ForeignKeyField(User, backref='notifications') - detail = TextField() - pub_date = DateTimeField() - seen = IntegerField(default=0) - -def create_tables(): - with database: - database.create_tables([ - User, UserAdminship, UserProfile, Message, Relationship, - Upload, Notification]) - if not os.path.isdir(UPLOAD_DIRECTORY): - os.makedirs(UPLOAD_DIRECTORY) - -### UTILS ### - -_forbidden_extensions = 'com net org txt'.split() -_username_characters = frozenset(string.ascii_letters + string.digits + '_') - -def is_username(username): - username_splitted = username.split('.') - if username_splitted and username_splitted[-1] in _forbidden_extensions: - return False - return all(x and set(x) < _username_characters for x in username_splitted) - -_mention_re = r'\+([A-Za-z0-9_]+(?:\.[A-Za-z0-9_]+)*)' - -def validate_birthday(date): - today = datetime.date.today() - if today.year - date.year > 13: - return True - if today.year - date.year < 13: - return False - if today.month > date.month: - return True - if today.month < date.month: - return False - if today.day >= date.day: - return True - return False - -def validate_website(website): - return re.match(r'(?:https?://)?(?:[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*' - r'|\[[A-Fa-f0-9:]+\])(?::\d+)?(?:/[^\s]*)?(?:\?[^\s]*)?(?:#[^\s]*)?$', - website) - -def human_short_date(timestamp): - return '' - -@app.template_filter() -def human_date(date): - timestamp = date.timestamp() - today = int(time.time()) - offset = today - timestamp - if offset <= 1: - return '1 second ago' - elif offset < 60: - return '%d seconds ago' % offset - elif offset < 120: - return '1 minute ago' - elif offset < 3600: - return '%d minutes ago' % (offset // 60) - elif offset < 7200: - return '1 hour ago' - elif offset < 86400: - return '%d hours ago' % (offset // 3600) - elif offset < 172800: - return '1 day ago' - elif offset < 604800: - return '%d days ago' % (offset // 86400) - else: - d = datetime.datetime.fromtimestamp(timestamp) - return d.strftime('%B %e, %Y') - -def int_to_b64(n): - b = int(n).to_bytes(48, 'big') - return base64.b64encode(b).lstrip(b'A').decode() - -def pwdhash(s): - return hashlib.md5((request.form['password']).encode('utf-8')).hexdigest() - -def get_object_or_404(model, *expressions): - try: - return model.get(*expressions) - except model.DoesNotExist: - abort(404) - -class Visibility(object): - ''' - Workaround for the visibility problem for posts. - Cannot be directly resolved with filter(). - - TODO find a better solution, this seems to be too slow. - ''' - def __init__(self, query, is_public_timeline=False): - self.query = query - self.is_public_timeline = is_public_timeline - def __iter__(self): - for i in self.query: - if i.is_visible(self.is_public_timeline): - yield i - def count(self): - counter = 0 - for i in self.query: - if i.is_visible(self.is_public_timeline): - counter += 1 - return counter - def paginate(self, page): - counter = 0 - pages_no = range((page - 1) * 20, page * 20) - for i in self.query: - if i.is_visible(self.is_public_timeline): - if counter in pages_no: - yield i - counter += 1 - -def get_locations(): - data = {} - with open('locations.txt') as f: - for line in f: - line = line.rstrip() - if line.startswith('#'): - continue - try: - key, value = line.split(None, 1) - except ValueError: - continue - data[key] = value - return data - -try: - locations = get_locations() -except OSError: - locations = {} - -# get the user from the session -# changed in 0.5 to comply with flask_login -def get_current_user(): - user_id = session.get('user_id') - if user_id: - return User[user_id] - -login_manager.login_view = 'login' - -def push_notification(type, target, **kwargs): - try: - if isinstance(target, str): - target = User.get(User.username == target) - Notification.create( - type=type, - target=target, - detail=json.dumps(kwargs), - pub_date=datetime.datetime.now() - ) - except Exception: - sys.excepthook(*sys.exc_info()) - -def unpush_notification(type, target, **kwargs): - try: - if isinstance(target, str): - target = User.get(User.username == target) - (Notification - .delete() - .where( - (Notification.type == type) & - (Notification.target == target) & - (Notification.detail == json.dumps(kwargs)) - ) - .execute()) - except Exception: - sys.excepthook(*sys.exc_info()) - -# given a template and a SelectQuery instance, render a paginated list of -# objects from the query inside the template -def object_list(template_name, qr, var_name='object_list', **kwargs): - kwargs.update( - page=int(request.args.get('page', 1)), - pages=qr.count() // 20 + 1) - kwargs[var_name] = qr.paginate(kwargs['page']) - return render_template(template_name, **kwargs) - -### WEB ### - -@app.before_request -def before_request(): - g.db = database - try: - g.db.connect() - except OperationalError: - sys.stderr.write('database connected twice.\n') - -@app.after_request -def after_request(response): - g.db.close() - return response - -@app.context_processor -def _inject_variables(): - return {'site_name': app.config['SITE_NAME'], 'locations': locations} - -@login_manager.user_loader -def _inject_user(userid): - return User[userid] - -@app.errorhandler(404) -def error_404(body): - return render_template('404.html') - -@app.route('/') -def homepage(): - if get_current_user(): - return private_timeline() - else: - return render_template('homepage.html') - -def private_timeline(): - # the private timeline (aka feed) exemplifies the use of a subquery -- we are asking for - # messages where the person who created the message is someone the current - # user is following. these messages are then ordered newest-first. - user = get_current_user() - messages = Visibility(Message - .select() - .where((Message.user << user.following()) - | (Message.user == user)) - .order_by(Message.pub_date.desc())) - # TODO change to "feed.html" - return object_list('private_messages.html', messages, 'message_list') - -@app.route('/explore/') -def public_timeline(): - messages = Visibility(Message - .select() - .order_by(Message.pub_date.desc()), True) - return object_list('explore.html', messages, 'message_list') - -@app.route('/signup/', methods=['GET', 'POST']) -def register(): - if request.method == 'POST' and request.form['username']: - try: - birthday = datetime.datetime.fromisoformat(request.form['birthday']) - except ValueError: - flash('Invalid date format') - return render_template('join.html') - username = request.form['username'].lower() - if not is_username(username): - flash('This username is invalid') - return render_template('join.html') - if username == getattr(get_current_user(), 'username', None) and not request.form.get('confirm_another'): - flash('You are already logged in. Please confirm you want to ' - 'create another account by checking the option.') - return render_template('join.html') - try: - with database.atomic(): - # Attempt to create the user. If the username is taken, due to the - # unique constraint, the database will raise an IntegrityError. - user = User.create( - username=username, - password=pwdhash(request.form['password']), - email=request.form['email'], - birthday=birthday, - join_date=datetime.datetime.now()) - UserProfile.create( - user=user, - full_name=request.form.get('full_name') or username - ) - - # mark the user as being 'authenticated' by setting the session vars - login_user(user) - return redirect(request.args.get('next','/')) - - except IntegrityError: - flash('That username is already taken') - - return render_template('join.html') - -@app.route('/login/', methods=['GET', 'POST']) -def login(): - if request.method == 'POST' and request.form['username']: - try: - username = request.form['username'] - pw_hash = pwdhash(request.form['password']) - if '@' in username: - user = User.get(User.email == username) - else: - user = User.get(User.username == username) - if user.password != pw_hash: - flash('The password entered is incorrect.') - return render_template('login.html') - except User.DoesNotExist: - flash('A user with this username or email does not exist.') - else: - remember_for = int(request.form['remember']) - if remember_for > 0: - login_user(user, remember=True, - duration=datetime.timedelta(days=remember_for)) - else: - login_user(user) - return redirect(request.args.get('next', '/')) - return render_template('login.html') - -@app.route('/logout/') -def logout(): - logout_user() - flash('You were logged out') - return redirect(request.args.get('next','/')) - -@app.route('/+/') -def user_detail(username): - user = get_object_or_404(User, User.username == username) - - # get all the users messages ordered newest-first -- note how we're accessing - # the messages -- user.message_set. could also have written it as: - # Message.select().where(Message.user == user) - messages = Visibility(user.messages.order_by(Message.pub_date.desc())) - # TODO change to "profile.html" - return object_list('user_detail.html', messages, 'message_list', user=user) - -@app.route('/+/follow/', methods=['POST']) -@login_required -def user_follow(username): - cur_user = get_current_user() - user = get_object_or_404(User, User.username == username) - try: - with database.atomic(): - Relationship.create( - from_user=cur_user, - to_user=user, - created_date=datetime.datetime.now()) - except IntegrityError: - pass - - flash('You are following %s' % user.username) - push_notification('follow', user, user=cur_user.id) - return redirect(url_for('user_detail', username=user.username)) - -@app.route('/+/unfollow/', methods=['POST']) -@login_required -def user_unfollow(username): - cur_user = get_current_user() - user = get_object_or_404(User, User.username == username) - (Relationship - .delete() - .where( - (Relationship.from_user == cur_user) & - (Relationship.to_user == user)) - .execute()) - flash('You are no longer following %s' % user.username) - unpush_notification('follow', user, user=cur_user.id) - return redirect(url_for('user_detail', username=user.username)) - - -@app.route('/create/', methods=['GET', 'POST']) -@login_required -def create(): - user = get_current_user() - if request.method == 'POST' and request.form['text']: - text = request.form['text'] - privacy = int(request.form.get('privacy', '0')) - message = Message.create( - user=user, - text=text, - pub_date=datetime.datetime.now(), - privacy=privacy) - file = request.files.get('file') - if file: - print('Uploading', file.filename) - ext = file.filename.split('.')[-1] - upload = Upload.create( - type=ext, - message=message - ) - file.save(UPLOAD_DIRECTORY + str(upload.id) + '.' + ext) - # create mentions - mention_usernames = set() - for mo in re.finditer(_mention_re, text): - mention_usernames.add(mo.group(1)) - # to avoid self mention - mention_usernames.difference_update({user.username}) - for u in mention_usernames: - try: - mention_user = User.get(User.username == u) - if privacy in (MSGPRV_PUBLIC, MSGPRV_UNLISTED) or \ - (privacy == MSGPRV_FRIENDS and - mention_user.is_following(user) and - user.is_following(mention_user)): - push_notification('mention', mention_user, user=user.id) - except User.DoesNotExist: - pass - flash('Your message has been posted successfully') - return redirect(url_for('user_detail', username=user.username)) - return render_template('create.html') - -@app.route('/edit/', methods=['GET', 'POST']) -@login_required -def edit(id): - user = get_current_user() - message = get_object_or_404(Message, Message.id == id) - if message.user != user: - abort(404) - if request.method == 'POST' and (request.form['text'] != message.text or - request.form['privacy'] != message.privacy): - text = request.form['text'] - privacy = int(request.form.get('privacy', '0')) - Message.update( - text=text, - privacy=privacy, - pub_date=datetime.datetime.now() - ).where(Message.id == id).execute() - # edit uploads (skipped for now) - # create mentions - mention_usernames = set() - for mo in re.finditer(_mention_re, text): - mention_usernames.add(mo.group(1)) - # to avoid self mention - mention_usernames.difference_update({user.username}) - for u in mention_usernames: - try: - mention_user = User.get(User.username == u) - if privacy in (MSGPRV_PUBLIC, MSGPRV_UNLISTED) or \ - (privacy == MSGPRV_FRIENDS and - mention_user.is_following(user) and - user.is_following(mention_user)): - push_notification('mention', mention_user, user=user.id) - except User.DoesNotExist: - pass - flash('Your message has been edited successfully') - return redirect(url_for('user_detail', username=user.username)) - return render_template('edit.html', message=message) - -#@app.route('/delete/', methods=['GET', 'POST']) -#def confirm_delete(id): -# return render_template('confirm_delete.html') - -# Workaround for problems related to invalid data. -# Without that, changes will be lost across requests. -def profile_checkpoint(): - return UserProfile( - user=get_current_user(), - full_name=request.form['full_name'], - biography=request.form['biography'], - location=int(request.form['location']), - year=int(request.form['year'] if request.form.get('has_year') else '0'), - website=request.form['website'] or None, - instagram=request.form['instagram'] or None, - facebook=request.form['facebook'] or None - ) - -@app.route('/edit_profile/', methods=['GET', 'POST']) -def edit_profile(): - if request.method == 'POST': - user = get_current_user() - username = request.form['username'] - if not username: - # prevent username to be set to empty - username = user.username - if username != user.username: - try: - User.update(username=username).where(User.id == user.id).execute() - except IntegrityError: - flash('That username is already taken') - return render_template('edit_profile.html', profile=profile_checkpoint()) - website = request.form['website'].strip().replace(' ', '%20') - if website and not validate_website(website): - flash('You should enter a valid URL.') - return render_template('edit_profile.html', profile=profile_checkpoint()) - location = int(request.form.get('location')) - if location == 0: - location = None - UserProfile.update( - full_name=request.form['full_name'] or username, - biography=request.form['biography'], - year=request.form['year'] if request.form.get('has_year') else None, - location=location, - website=website, - instagram=request.form['instagram'], - facebook=request.form['facebook'] - ).where(UserProfile.user == user).execute() - return redirect(url_for('user_detail', username=username)) - return render_template('edit_profile.html') - -@app.route('/notifications/') -@login_required -def notifications(): - user = get_current_user() - notifications = (Notification - .select() - .where(Notification.target == user) - .order_by(Notification.pub_date.desc())) - - with database.atomic(): - (Notification - .update(seen=1) - .where((Notification.target == user) & (Notification.seen == 0)) - .execute()) - return object_list('notifications.html', notifications, 'notification_list', json=json, User=User) - -@app.route('/about/') -def about(): - return render_template('about.html', version=__version__) - -# The two following routes are mandatory by law. -@app.route('/terms/') -def terms(): - return render_template('terms.html') - -@app.route('/privacy/') -def privacy(): - return render_template('privacy.html') - -@app.route('/robots.txt') -def robots_txt(): - return send_from_directory(os.getcwd(), 'robots.txt') - -@app.route('/uploads/.') -def uploads(id, type='jpg'): - return send_from_directory(UPLOAD_DIRECTORY, id + '.' + type) - -@app.route('/ajax/username_availability/') -def username_availability(username): - current = get_current_user() - if current: - current = current.username - else: - current = None - is_valid = is_username(username) - if is_valid: - try: - user = User.get(User.username == username) - is_available = current == user.username - except User.DoesNotExist: - is_available = True - else: - is_available = False - return jsonify({'is_valid':is_valid, 'is_available':is_available, 'status':'ok'}) - -@app.route('/ajax/location_search/') -def location_search(name): - results = [] - for key, value in locations.items(): - if value.lower().startswith(name.lower()): - results.append({'value': key, 'display': value}) - return jsonify({'results': results}) - -_enrich_symbols = [ - (r'\n', 'NEWLINE'), - (r'https?://(?:[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*|\[[A-Fa-f0-9:]+\])' - r'(?::\d+)?(?:/[^\s]*)?(?:\?[^\s]*)?(?:#[^\s]*)?', 'URL'), - (_mention_re, 'MENTION'), - (r'[^h\n+]+', 'TEXT'), - (r'.', 'TEXT') -] - -def _tokenize(characters, table): - pos = 0 - tokens = [] - while pos < len(characters): - mo = None - for pattern, tag in table: - mo = re.compile(pattern).match(characters, pos) - if mo: - if tag: - text = mo.group(0) - tokens.append((text, tag)) - break - pos = mo.end(0) - return tokens - -@app.template_filter() -def enrich(s): - tokens = _tokenize(s, _enrich_symbols) - r = [] - for text, tag in tokens: - if tag == 'TEXT': - r.append(html.escape(text)) - elif tag == 'URL': - r.append('{0}'.format(html.escape(text))) - elif tag == 'MENTION': - r.append('+{1}'.format(text, text.lstrip('+'))) - elif tag == 'NEWLINE': - r.append('
    ') - return Markup(''.join(r)) - -@app.template_filter('is_following') -def is_following(from_user, to_user): - return from_user.is_following(to_user) - -@app.template_filter('locationdata') -def locationdata(key): - if key > 0: - return locations[str(key)] - -# allow running from the command line -if __name__ == '__main__': - args = arg_parser.parse_args() - create_tables() - if not args.norun: - app.run(port=args.port, debug=args.debug) diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..d7cb66d --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,87 @@ +''' +Cori+ +===== + +The root module of the package. +This module also contains very basic web hooks, such as robots.txt. + +For the website hooks, see `app.website`. +For the AJAX hook, see `app.ajax`. +For template filters, see `app.filters`. +For the database models, see `app.models`. +For other, see `app.utils`. +''' + +from flask import ( + Flask, abort, flash, g, jsonify, redirect, render_template, request, + send_from_directory, session, url_for, __version__ as flask_version) +import hashlib +from peewee import * +import datetime, time, re, os, sys, string, json, html +from functools import wraps +from flask_login import LoginManager + +__version__ = '0.7-dev' + +# we want to support Python 3 only. +# Python 2 has too many caveats. +if sys.version_info[0] < 3: + raise RuntimeError('Python 3 required') + +app = Flask(__name__) +app.config.from_pyfile('../config.py') + +login_manager = LoginManager(app) + +from .models import * + +from .utils import * + +from .filters import * + +### WEB ### + +login_manager.login_view = 'website.login' + +@app.before_request +def before_request(): + g.db = database + try: + g.db.connect() + except OperationalError: + sys.stderr.write('database connected twice.\n') + +@app.after_request +def after_request(response): + g.db.close() + return response + +@app.context_processor +def _inject_variables(): + return {'site_name': app.config['SITE_NAME'], 'locations': locations} + +@login_manager.user_loader +def _inject_user(userid): + return User[userid] + +@app.errorhandler(404) +def error_404(body): + return render_template('404.html'), 404 + +@app.route('/robots.txt') +def robots_txt(): + return send_from_directory(os.getcwd(), 'robots.txt') + +@app.route('/uploads/.') +def uploads(id, type='jpg'): + return send_from_directory(UPLOAD_DIRECTORY, id + '.' + type) + +from .website import bp +app.register_blueprint(bp) + +from .ajax import bp +app.register_blueprint(bp) + + + + diff --git a/app/__main__.py b/app/__main__.py new file mode 100644 index 0000000..f09c3fc --- /dev/null +++ b/app/__main__.py @@ -0,0 +1,29 @@ +''' +Run the app as module. + +You can also use `flask run` on the parent directory of the package. + +XXX Using "--debug" argument currently causes an ImportError. +''' + +import argparse +from . import app +from .models import create_tables + +arg_parser = argparse.ArgumentParser() +arg_parser.add_argument('--norun', action='store_true', + help='Don\'t run the app. Useful for debugging.') +arg_parser.add_argument('--no-create-tables', action='store_true', + help='Don\'t create tables.') +arg_parser.add_argument('--debug', action='store_true', + help='Run the app in debug mode.') +arg_parser.add_argument('-p', '--port', type=int, default=5000, + help='The port where to run the app. Defaults to 5000') + +args = arg_parser.parse_args() + +if not args.no_create_tables: + create_tables() + +if not args.norun: + app.run(port=args.port, debug=args.debug) diff --git a/app/ajax.py b/app/ajax.py new file mode 100644 index 0000000..63dc532 --- /dev/null +++ b/app/ajax.py @@ -0,0 +1,37 @@ +''' +AJAX hooks for the website. + +Warning: this is not the public API. +''' + +from flask import Blueprint, jsonify +from .models import User +from .utils import locations, get_current_user, is_username + +bp = Blueprint('ajax', __name__, url_prefix='/ajax') + +@bp.route('/username_availability/') +def username_availability(username): + current = get_current_user() + if current: + current = current.username + else: + current = None + is_valid = is_username(username) + if is_valid: + try: + user = User.get(User.username == username) + is_available = current == user.username + except User.DoesNotExist: + is_available = True + else: + is_available = False + return jsonify({'is_valid':is_valid, 'is_available':is_available, 'status':'ok'}) + +@bp.route('/location_search/') +def location_search(name): + results = [] + for key, value in locations.items(): + if value.lower().startswith(name.lower()): + results.append({'value': key, 'display': value}) + return jsonify({'results': results}) diff --git a/app/filters.py b/app/filters.py new file mode 100644 index 0000000..2735d9d --- /dev/null +++ b/app/filters.py @@ -0,0 +1,66 @@ +''' +Filter functions used in the website templates. +''' + +from flask import Markup +import html, datetime, re, time +from .utils import tokenize +from . import app + +@app.template_filter() +def human_date(date): + timestamp = date.timestamp() + today = int(time.time()) + offset = today - timestamp + if offset <= 1: + return '1 second ago' + elif offset < 60: + return '%d seconds ago' % offset + elif offset < 120: + return '1 minute ago' + elif offset < 3600: + return '%d minutes ago' % (offset // 60) + elif offset < 7200: + return '1 hour ago' + elif offset < 86400: + return '%d hours ago' % (offset // 3600) + elif offset < 172800: + return '1 day ago' + elif offset < 604800: + return '%d days ago' % (offset // 86400) + else: + d = datetime.datetime.fromtimestamp(timestamp) + return d.strftime('%B %e, %Y') + +_enrich_symbols = [ + (r'\n', 'NEWLINE'), + (r'https?://(?:[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*|\[[A-Fa-f0-9:]+\])' + r'(?::\d+)?(?:/[^\s]*)?(?:\?[^\s]*)?(?:#[^\s]*)?', 'URL'), + (r'\+([A-Za-z0-9_]+(?:\.[A-Za-z0-9_]+)*)', 'MENTION'), + (r'[^h\n+]+', 'TEXT'), + (r'.', 'TEXT') +] + +@app.template_filter() +def enrich(s): + tokens = tokenize(s, _enrich_symbols) + r = [] + for text, tag in tokens: + if tag == 'TEXT': + r.append(html.escape(text)) + elif tag == 'URL': + r.append('{0}'.format(html.escape(text))) + elif tag == 'MENTION': + r.append('+{1}'.format(text, text.lstrip('+'))) + elif tag == 'NEWLINE': + r.append('
    ') + return Markup(''.join(r)) + +@app.template_filter('is_following') +def is_following(from_user, to_user): + return from_user.is_following(to_user) + +@app.template_filter('locationdata') +def locationdata(key): + if key > 0: + return locations[str(key)] diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..a03ad7b --- /dev/null +++ b/app/models.py @@ -0,0 +1,198 @@ +''' +Database models for the application. + +The tables are: +* user - the basic account info, such as username and password +* useradminship - relationship which existence determines whether a user is admin or not; new in 0.6 +* userprofile - additional account info for self describing; new in 0.6 +* message - a status update, appearing in profile and feeds +* relationship - a follow relationship between users +* upload - a file upload attached to a message; new in 0.2 +* notification - a in-site notification to a user; new in 0.3 +''' + +from peewee import * +import os +# here should go `from .utils import get_current_user`, but it will cause +# import errors. It's instead imported at function level. + +database = SqliteDatabase(os.path.join(os.getcwd(), 'coriplus.sqlite')) + +class BaseModel(Model): + class Meta: + database = database + +# A user. The user is separated from its page. +class User(BaseModel): + # The unique username. + username = CharField(unique=True) + # The password hash. + password = CharField() + # An email address. + email = CharField() + # The date of birth (required because of Terms of Service) + birthday = DateField() + # The date joined + join_date = DateTimeField() + # A disabled flag. 0 = active, 1 = disabled by user, 2 = banned + is_disabled = IntegerField(default=0) + + # Helpers for flask_login + def get_id(self): + return str(self.id) + @property + def is_active(self): + return not self.is_disabled + @property + def is_anonymous(self): + return False + @property + def is_authenticated(self): + from .utils import get_current_user + return self == get_current_user() + + # it often makes sense to put convenience methods on model instances, for + # example, "give me all the users this user is following": + def following(self): + # query other users through the "relationship" table + return (User + .select() + .join(Relationship, on=Relationship.to_user) + .where(Relationship.from_user == self) + .order_by(User.username)) + + def followers(self): + return (User + .select() + .join(Relationship, on=Relationship.from_user) + .where(Relationship.to_user == self) + .order_by(User.username)) + + def is_following(self, user): + return (Relationship + .select() + .where( + (Relationship.from_user == self) & + (Relationship.to_user == user)) + .exists()) + + def unseen_notification_count(self): + return len(Notification + .select() + .where( + (Notification.target == self) & (Notification.seen == 0) + )) + # user adminship is stored into a separate table; new in 0.6 + @property + def is_admin(self): + return UserAdminship.select().where(UserAdminship.user == self).exists() + # user profile info; new in 0.6 + @property + def profile(self): + # lazy initialization; I don't want (and don't know how) + # to do schema changes. + try: + return UserProfile.get(UserProfile.user == self) + except UserProfile.DoesNotExist: + return UserProfile.create(user=self, full_name=self.username) + +# User adminship. +# A very high privilege where users can review posts. +# For very few users only; new in 0.6 +class UserAdminship(BaseModel): + user = ForeignKeyField(User, primary_key=True) + +# User profile. +# Additional info for identifying users. +# New in 0.6 +class UserProfile(BaseModel): + user = ForeignKeyField(User, primary_key=True) + full_name = TextField() + biography = TextField(default='') + location = IntegerField(null=True) + year = IntegerField(null=True) + website = TextField(null=True) + instagram = TextField(null=True) + facebook = TextField(null=True) + telegram = TextField(null=True) + +# The message privacy values. +MSGPRV_PUBLIC = 0 # everyone +MSGPRV_UNLISTED = 1 # everyone, doesn't show up in public timeline +MSGPRV_FRIENDS = 2 # only accounts which follow each other +MSGPRV_ONLYME = 3 # only the poster + +# A single public message. +# New in v0.5: removed type and info fields; added privacy field. +class Message(BaseModel): + # The user who posted the message. + user = ForeignKeyField(User, backref='messages') + # The text of the message. + text = TextField() + # The posted date. + pub_date = DateTimeField() + # Info about privacy of the message. + privacy = IntegerField(default=MSGPRV_PUBLIC) + + def is_visible(self, is_public_timeline=False): + from .utils import get_current_user + user = self.user + cur_user = get_current_user() + privacy = self.privacy + if user == cur_user: + # short path + # also: don't show user's messages in public timeline + return not is_public_timeline + elif privacy == MSGPRV_PUBLIC: + return True + elif privacy == MSGPRV_UNLISTED: + # even if unlisted + return not is_public_timeline + elif privacy == MSGPRV_FRIENDS: + if cur_user is None: + return False + return user.is_following(cur_user) and cur_user.is_following(user) + else: + return False + +# this model contains two foreign keys to user -- it essentially allows us to +# model a "many-to-many" relationship between users. by querying and joining +# on different columns we can expose who a user is "related to" and who is +# "related to" a given user +class Relationship(BaseModel): + from_user = ForeignKeyField(User, backref='relationships') + to_user = ForeignKeyField(User, backref='related_to') + created_date = DateTimeField() + + class Meta: + indexes = ( + # Specify a unique multi-column index on from/to-user. + (('from_user', 'to_user'), True), + ) + + +UPLOAD_DIRECTORY = os.path.join(os.path.split(os.path.dirname(__file__))[0], 'uploads') + +class Upload(BaseModel): + # the extension of the media + type = TextField() + # the message bound to this media + message = ForeignKeyField(Message, backref='uploads') + # helper to retrieve contents + def filename(self): + return str(self.id) + '.' + self.type + +class Notification(BaseModel): + type = TextField() + target = ForeignKeyField(User, backref='notifications') + detail = TextField() + pub_date = DateTimeField() + seen = IntegerField(default=0) + +def create_tables(): + with database: + database.create_tables([ + User, UserAdminship, UserProfile, Message, Relationship, + Upload, Notification]) + if not os.path.isdir(UPLOAD_DIRECTORY): + os.makedirs(UPLOAD_DIRECTORY) diff --git a/static/lib.js b/app/static/lib.js similarity index 100% rename from static/lib.js rename to app/static/lib.js diff --git a/static/style.css b/app/static/style.css similarity index 100% rename from static/style.css rename to app/static/style.css diff --git a/templates/404.html b/app/templates/404.html similarity index 100% rename from templates/404.html rename to app/templates/404.html diff --git a/templates/about.html b/app/templates/about.html similarity index 92% rename from templates/about.html rename to app/templates/about.html index 29c7a98..e5691a8 100644 --- a/templates/about.html +++ b/app/templates/about.html @@ -3,7 +3,8 @@ {% block body %}

    About {{ site_name }}

    -

    Version: {{ version }}

    +

    {{ site_name }} {{ version }} – Python {{ python_version }} – + Flask {{ flask_version }}

    Copyright © 2019 Sakuragasaki46.

    License

    diff --git a/templates/base.html b/app/templates/base.html similarity index 63% rename from templates/base.html rename to app/templates/base.html index 783853a..74ee636 100644 --- a/templates/base.html +++ b/app/templates/base.html @@ -9,21 +9,21 @@
    -

    {{ site_name }}

    +

    {{ site_name }}

    {% if current_user.is_anonymous %} - log in - register + log in + register {% else %} - {{ current_user.username }} + {{ current_user.username }} {% set notification_count = current_user.unseen_notification_count() %} {% if notification_count > 0 %} - ({{ notification_count }}) + ({{ notification_count }}) {% endif %} - - explore - create - log out + explore + create + log out {% endif %}
    diff --git a/templates/create.html b/app/templates/create.html similarity index 89% rename from templates/create.html rename to app/templates/create.html index 4d89d6e..8f31263 100644 --- a/templates/create.html +++ b/app/templates/create.html @@ -1,7 +1,7 @@ {% extends "base.html" %} {% block body %}

    Create

    -
    +
    Message:
    diff --git a/templates/edit.html b/app/templates/edit.html similarity index 88% rename from templates/edit.html rename to app/templates/edit.html index 7111f33..2de4877 100644 --- a/templates/edit.html +++ b/app/templates/edit.html @@ -1,7 +1,7 @@ {% extends "base.html" %} {% block body %}

    Edit

    - +
    Message:
    diff --git a/templates/edit_profile.html b/app/templates/edit_profile.html similarity index 92% rename from templates/edit_profile.html rename to app/templates/edit_profile.html index 2feaf29..349d6bd 100644 --- a/templates/edit_profile.html +++ b/app/templates/edit_profile.html @@ -27,6 +27,8 @@
    Facebook:
    +
    Telegram:
    +
    diff --git a/templates/explore.html b/app/templates/explore.html similarity index 100% rename from templates/explore.html rename to app/templates/explore.html diff --git a/app/templates/homepage.html b/app/templates/homepage.html new file mode 100644 index 0000000..106bf9a --- /dev/null +++ b/app/templates/homepage.html @@ -0,0 +1,7 @@ +{% extends "base.html" %} +{% block body %} +

    Hello

    + +

    {{ site_name }} is made by people like you.
    +Log in or register to see more.

    +{% endblock %} diff --git a/templates/includes/infobox_profile.html b/app/templates/includes/infobox_profile.html similarity index 72% rename from templates/includes/infobox_profile.html rename to app/templates/includes/infobox_profile.html index d268eae..f20df41 100644 --- a/templates/includes/infobox_profile.html +++ b/app/templates/includes/infobox_profile.html @@ -19,12 +19,15 @@ {% if profile.facebook %}

    Facebook: {{ profile.facebook }}

    {% endif %} + {% if profile.telegram %} +

    Telegram: {{ profile.telegram }}

    + {% endif %}

    {{ user.messages|count }} messages - - {{ user.followers()|count }} followers + {{ user.followers()|count }} followers - - {{ user.following()|count }} following + {{ user.following()|count }} following

    {% if user == current_user %}

    Edit profile

    diff --git a/templates/includes/location_selector.html b/app/templates/includes/location_selector.html similarity index 100% rename from templates/includes/location_selector.html rename to app/templates/includes/location_selector.html diff --git a/templates/includes/message.html b/app/templates/includes/message.html similarity index 84% rename from templates/includes/message.html rename to app/templates/includes/message.html index 7f63d9b..fc8a91f 100644 --- a/templates/includes/message.html +++ b/app/templates/includes/message.html @@ -1,11 +1,11 @@

    {{ message.text|enrich }}

    {% if message.uploads %}
    - +
    {% endif %}

    Join {{ site_name }}

    -
    +
    Username:
    diff --git a/templates/login.html b/app/templates/login.html similarity index 100% rename from templates/login.html rename to app/templates/login.html diff --git a/templates/notifications.html b/app/templates/notifications.html similarity index 100% rename from templates/notifications.html rename to app/templates/notifications.html diff --git a/templates/privacy.html b/app/templates/privacy.html similarity index 100% rename from templates/privacy.html rename to app/templates/privacy.html diff --git a/templates/private_messages.html b/app/templates/private_messages.html similarity index 100% rename from templates/private_messages.html rename to app/templates/private_messages.html diff --git a/templates/terms.html b/app/templates/terms.html similarity index 100% rename from templates/terms.html rename to app/templates/terms.html diff --git a/templates/user_detail.html b/app/templates/user_detail.html similarity index 81% rename from templates/user_detail.html rename to app/templates/user_detail.html index 9316432..5a5c42f 100644 --- a/templates/user_detail.html +++ b/app/templates/user_detail.html @@ -5,11 +5,11 @@ {% if not current_user.is_anonymous %} {% if user.username != current_user.username %} {% if current_user|is_following(user) %} - + {% else %} -
    +
    {% endif %} diff --git a/app/templates/user_list.html b/app/templates/user_list.html new file mode 100755 index 0000000..b694e28 --- /dev/null +++ b/app/templates/user_list.html @@ -0,0 +1,10 @@ +{% extends "base.html" %} + +{% block body %} +

    {{ title }}

    + +{% endblock %} diff --git a/app/utils.py b/app/utils.py new file mode 100644 index 0000000..f6370a2 --- /dev/null +++ b/app/utils.py @@ -0,0 +1,162 @@ +''' +A list of utilities used across modules. +''' + +import datetime, re, base64, hashlib, string +from .models import User, Notification +from flask import abort, render_template, request, session +import sys, json + +_forbidden_extensions = 'com net org txt'.split() +_username_characters = frozenset(string.ascii_letters + string.digits + '_') + +def is_username(username): + username_splitted = username.split('.') + if username_splitted and username_splitted[-1] in _forbidden_extensions: + return False + return all(x and set(x) < _username_characters for x in username_splitted) + +def validate_birthday(date): + today = datetime.date.today() + if today.year - date.year > 13: + return True + if today.year - date.year < 13: + return False + if today.month > date.month: + return True + if today.month < date.month: + return False + if today.day >= date.day: + return True + return False + +def validate_website(website): + return re.match(r'(?:https?://)?(?:[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*' + r'|\[[A-Fa-f0-9:]+\])(?::\d+)?(?:/[^\s]*)?(?:\?[^\s]*)?(?:#[^\s]*)?$', + website) + +def human_short_date(timestamp): + return '' + +def int_to_b64(n): + b = int(n).to_bytes(48, 'big') + return base64.b64encode(b).lstrip(b'A').decode() + +def pwdhash(s): + return hashlib.md5((request.form['password']).encode('utf-8')).hexdigest() + +def get_object_or_404(model, *expressions): + try: + return model.get(*expressions) + except model.DoesNotExist: + abort(404) + +class Visibility(object): + ''' + Workaround for the visibility problem for posts. + Cannot be directly resolved with filter(). + + TODO find a better solution, this seems to be too slow. + ''' + def __init__(self, query, is_public_timeline=False): + self.query = query + self.is_public_timeline = is_public_timeline + def __iter__(self): + for i in self.query: + if i.is_visible(self.is_public_timeline): + yield i + def count(self): + counter = 0 + for i in self.query: + if i.is_visible(self.is_public_timeline): + counter += 1 + return counter + def paginate(self, page): + counter = 0 + pages_no = range((page - 1) * 20, page * 20) + for i in self.query: + if i.is_visible(self.is_public_timeline): + if counter in pages_no: + yield i + counter += 1 + +def get_locations(): + data = {} + with open('locations.txt') as f: + for line in f: + line = line.rstrip() + if line.startswith('#'): + continue + try: + key, value = line.split(None, 1) + except ValueError: + continue + data[key] = value + return data + +try: + locations = get_locations() +except OSError: + locations = {} + +# get the user from the session +# changed in 0.5 to comply with flask_login +def get_current_user(): + user_id = session.get('user_id') + if user_id: + return User[user_id] + +def push_notification(type, target, **kwargs): + try: + if isinstance(target, str): + target = User.get(User.username == target) + Notification.create( + type=type, + target=target, + detail=json.dumps(kwargs), + pub_date=datetime.datetime.now() + ) + except Exception: + sys.excepthook(*sys.exc_info()) + +def unpush_notification(type, target, **kwargs): + try: + if isinstance(target, str): + target = User.get(User.username == target) + (Notification + .delete() + .where( + (Notification.type == type) & + (Notification.target == target) & + (Notification.detail == json.dumps(kwargs)) + ) + .execute()) + except Exception: + sys.excepthook(*sys.exc_info()) + +# given a template and a SelectQuery instance, render a paginated list of +# objects from the query inside the template +def object_list(template_name, qr, var_name='object_list', **kwargs): + kwargs.update( + page=int(request.args.get('page', 1)), + pages=qr.count() // 20 + 1) + kwargs[var_name] = qr.paginate(kwargs['page']) + return render_template(template_name, **kwargs) + +def tokenize(characters, table): + ''' + A useful tokenizer. + ''' + pos = 0 + tokens = [] + while pos < len(characters): + mo = None + for pattern, tag in table: + mo = re.compile(pattern).match(characters, pos) + if mo: + if tag: + text = mo.group(0) + tokens.append((text, tag)) + break + pos = mo.end(0) + return tokens diff --git a/app/website.py b/app/website.py new file mode 100644 index 0000000..e463d37 --- /dev/null +++ b/app/website.py @@ -0,0 +1,332 @@ +''' +All website hooks, excluding AJAX. +''' + +from .utils import * +from .models import * +from . import __version__ as app_version +from sys import version as python_version +from flask import Blueprint, abort, flash, redirect, render_template, request, url_for, __version__ as flask_version +from flask_login import login_required, login_user, logout_user +import json + +bp = Blueprint('website', __name__) + +@bp.route('/') +def homepage(): + if get_current_user(): + return private_timeline() + else: + return render_template('homepage.html') + +def private_timeline(): + # the private timeline (aka feed) exemplifies the use of a subquery -- we are asking for + # messages where the person who created the message is someone the current + # user is following. these messages are then ordered newest-first. + user = get_current_user() + messages = Visibility(Message + .select() + .where((Message.user << user.following()) + | (Message.user == user)) + .order_by(Message.pub_date.desc())) + # TODO change to "feed.html" + return object_list('private_messages.html', messages, 'message_list') + +@bp.route('/explore/') +def public_timeline(): + messages = Visibility(Message + .select() + .order_by(Message.pub_date.desc()), True) + return object_list('explore.html', messages, 'message_list') + +@bp.route('/signup/', methods=['GET', 'POST']) +def register(): + if request.method == 'POST' and request.form['username']: + try: + birthday = datetime.datetime.fromisoformat(request.form['birthday']) + except ValueError: + flash('Invalid date format') + return render_template('join.html') + username = request.form['username'].lower() + if not is_username(username): + flash('This username is invalid') + return render_template('join.html') + if username == getattr(get_current_user(), 'username', None) and not request.form.get('confirm_another'): + flash('You are already logged in. Please confirm you want to ' + 'create another account by checking the option.') + return render_template('join.html') + try: + with database.atomic(): + # Attempt to create the user. If the username is taken, due to the + # unique constraint, the database will raise an IntegrityError. + user = User.create( + username=username, + password=pwdhash(request.form['password']), + email=request.form['email'], + birthday=birthday, + join_date=datetime.datetime.now()) + UserProfile.create( + user=user, + full_name=request.form.get('full_name') or username + ) + + # mark the user as being 'authenticated' by setting the session vars + login_user(user) + return redirect(request.args.get('next','/')) + + except IntegrityError: + flash('That username is already taken') + + return render_template('join.html') + +@bp.route('/login/', methods=['GET', 'POST']) +def login(): + if request.method == 'POST' and request.form['username']: + try: + username = request.form['username'] + pw_hash = pwdhash(request.form['password']) + if '@' in username: + user = User.get(User.email == username) + else: + user = User.get(User.username == username) + if user.password != pw_hash: + flash('The password entered is incorrect.') + return render_template('login.html') + except User.DoesNotExist: + flash('A user with this username or email does not exist.') + else: + remember_for = int(request.form['remember']) + if remember_for > 0: + login_user(user, remember=True, + duration=datetime.timedelta(days=remember_for)) + else: + login_user(user) + return redirect(request.args.get('next', '/')) + return render_template('login.html') + +@bp.route('/logout/') +def logout(): + logout_user() + flash('You were logged out') + return redirect(request.args.get('next','/')) + +@bp.route('/+/') +def user_detail(username): + user = get_object_or_404(User, User.username == username) + + # get all the users messages ordered newest-first -- note how we're accessing + # the messages -- user.message_set. could also have written it as: + # Message.select().where(Message.user == user) + messages = Visibility(user.messages.order_by(Message.pub_date.desc())) + # TODO change to "profile.html" + return object_list('user_detail.html', messages, 'message_list', user=user) + +@bp.route('/+/follow/', methods=['POST']) +@login_required +def user_follow(username): + cur_user = get_current_user() + user = get_object_or_404(User, User.username == username) + try: + with database.atomic(): + Relationship.create( + from_user=cur_user, + to_user=user, + created_date=datetime.datetime.now()) + except IntegrityError: + pass + + flash('You are following %s' % user.username) + push_notification('follow', user, user=cur_user.id) + return redirect(url_for('website.user_detail', username=user.username)) + +@bp.route('/+/unfollow/', methods=['POST']) +@login_required +def user_unfollow(username): + cur_user = get_current_user() + user = get_object_or_404(User, User.username == username) + (Relationship + .delete() + .where( + (Relationship.from_user == cur_user) & + (Relationship.to_user == user)) + .execute()) + flash('You are no longer following %s' % user.username) + unpush_notification('follow', user, user=cur_user.id) + return redirect(url_for('website.user_detail', username=user.username)) + +@bp.route('/+/followers/') +@login_required +def user_followers(username): + user = get_object_or_404(User, User.username == username) + return object_list('user_list.html', user.followers(), 'user_list', + title='%s\'s followers' % username) + +@bp.route('/+/following/') +@login_required +def user_following(username): + user = get_object_or_404(User, User.username == username) + return object_list('user_list.html', user.following(), 'user_list', + title='Accounts followed by %s' % username) + +@bp.route('/create/', methods=['GET', 'POST']) +@login_required +def create(): + user = get_current_user() + if request.method == 'POST' and request.form['text']: + text = request.form['text'] + privacy = int(request.form.get('privacy', '0')) + message = Message.create( + user=user, + text=text, + pub_date=datetime.datetime.now(), + privacy=privacy) + file = request.files.get('file') + if file: + print('Uploading', file.filename) + ext = file.filename.split('.')[-1] + upload = Upload.create( + type=ext, + message=message + ) + file.save(UPLOAD_DIRECTORY + str(upload.id) + '.' + ext) + # create mentions + mention_usernames = set() + for mo in re.finditer(r'\+([A-Za-z0-9_]+(?:\.[A-Za-z0-9_]+)*)', text): + mention_usernames.add(mo.group(1)) + # to avoid self mention + mention_usernames.difference_update({user.username}) + for u in mention_usernames: + try: + mention_user = User.get(User.username == u) + if privacy in (MSGPRV_PUBLIC, MSGPRV_UNLISTED) or \ + (privacy == MSGPRV_FRIENDS and + mention_user.is_following(user) and + user.is_following(mention_user)): + push_notification('mention', mention_user, user=user.id) + except User.DoesNotExist: + pass + flash('Your message has been posted successfully') + return redirect(url_for('website.user_detail', username=user.username)) + return render_template('create.html') + +@bp.route('/edit/', methods=['GET', 'POST']) +@login_required +def edit(id): + user = get_current_user() + message = get_object_or_404(Message, Message.id == id) + if message.user != user: + abort(404) + if request.method == 'POST' and (request.form['text'] != message.text or + request.form['privacy'] != message.privacy): + text = request.form['text'] + privacy = int(request.form.get('privacy', '0')) + Message.update( + text=text, + privacy=privacy, + pub_date=datetime.datetime.now() + ).where(Message.id == id).execute() + # edit uploads (skipped for now) + # create mentions + mention_usernames = set() + for mo in re.finditer(r'\+([A-Za-z0-9_]+(?:\.[A-Za-z0-9_]+)*)', text): + mention_usernames.add(mo.group(1)) + # to avoid self mention + mention_usernames.difference_update({user.username}) + for u in mention_usernames: + try: + mention_user = User.get(User.username == u) + if privacy in (MSGPRV_PUBLIC, MSGPRV_UNLISTED) or \ + (privacy == MSGPRV_FRIENDS and + mention_user.is_following(user) and + user.is_following(mention_user)): + push_notification('mention', mention_user, user=user.id) + except User.DoesNotExist: + pass + flash('Your message has been edited successfully') + return redirect(url_for('website.user_detail', username=user.username)) + return render_template('edit.html', message=message) + +#@bp.route('/delete/', methods=['GET', 'POST']) +#def confirm_delete(id): +# return render_template('confirm_delete.html') + +# Workaround for problems related to invalid data. +# Without that, changes will be lost across requests. +def profile_checkpoint(): + return UserProfile( + user=get_current_user(), + full_name=request.form['full_name'], + biography=request.form['biography'], + location=int(request.form['location']), + year=int(request.form['year'] if request.form.get('has_year') else '0'), + website=request.form['website'] or None, + instagram=request.form['instagram'] or None, + facebook=request.form['facebook'] or None + ) + +@bp.route('/edit_profile/', methods=['GET', 'POST']) +@login_required +def edit_profile(): + if request.method == 'POST': + user = get_current_user() + username = request.form['username'] + if not username: + # prevent username to be set to empty + username = user.username + if username != user.username: + try: + User.update(username=username).where(User.id == user.id).execute() + except IntegrityError: + flash('That username is already taken') + return render_template('edit_profile.html', profile=profile_checkpoint()) + website = request.form['website'].strip().replace(' ', '%20') + if website and not validate_website(website): + flash('You should enter a valid URL.') + return render_template('edit_profile.html', profile=profile_checkpoint()) + location = int(request.form.get('location')) + if location == 0: + location = None + UserProfile.update( + full_name=request.form['full_name'] or username, + biography=request.form['biography'], + year=request.form['year'] if request.form.get('has_year') else None, + location=location, + website=website, + instagram=request.form['instagram'], + facebook=request.form['facebook'], + telegram=request.form['telegram'] + ).where(UserProfile.user == user).execute() + return redirect(url_for('website.user_detail', username=username)) + return render_template('edit_profile.html') + +@bp.route('/notifications/') +@login_required +def notifications(): + user = get_current_user() + notifications = (Notification + .select() + .where(Notification.target == user) + .order_by(Notification.pub_date.desc())) + + with database.atomic(): + (Notification + .update(seen=1) + .where((Notification.target == user) & (Notification.seen == 0)) + .execute()) + return object_list('notifications.html', notifications, 'notification_list', json=json, User=User) + +@bp.route('/about/') +def about(): + return render_template('about.html', version=app_version, + python_version=python_version, flask_version=flask_version) + +# The two following routes are mandatory by law. +@bp.route('/terms/') +def terms(): + return render_template('terms.html') + +@bp.route('/privacy/') +def privacy(): + return render_template('privacy.html') + + diff --git a/config.py b/config.py index 4a13153..a6e2b64 100644 --- a/config.py +++ b/config.py @@ -1,4 +1,3 @@ -DATABASE = 'coriplus.sqlite' DEBUG = True SECRET_KEY = 'hin6bab8ge25*r=x&+5$0kn=-#log$pt^#@vrqjld!^2ci@g*b' SITE_NAME = 'Cori+' diff --git a/migrate_0_6_to_0_7.py b/migrate_0_6_to_0_7.py new file mode 100644 index 0000000..c65e73c --- /dev/null +++ b/migrate_0_6_to_0_7.py @@ -0,0 +1,10 @@ +import sqlite3 + +conn = sqlite3.connect('coriplus.sqlite') + +if __name__ == '__main__': + conn.executescript(''' +BEGIN TRANSACTION; + ALTER TABLE userprofile ADD COLUMN telegram TEXT; +COMMIT; +''') diff --git a/templates/homepage.html b/templates/homepage.html deleted file mode 100644 index da0aa3d..0000000 --- a/templates/homepage.html +++ /dev/null @@ -1,7 +0,0 @@ -{% extends "base.html" %} -{% block body %} -

    Hello

    - -

    {{ site_name }} is made by people like you.
    -Log in or register to see more.

    -{% endblock %} From 5536e764e7be92a690b6235adc80234344fb0e93 Mon Sep 17 00:00:00 2001 From: Mattia Succurro Date: Thu, 24 Oct 2019 18:27:53 +0200 Subject: [PATCH 10/42] Added password change form --- CHANGELOG.md | 4 ++ app/templates/change_password.html | 17 ++++++++ app/templates/confirm_delete.html | 22 ++++++++++ .../{private_messages.html => feed.html} | 0 app/templates/includes/message.html | 2 +- app/templates/user_list.html | 0 app/utils.py | 34 +++++++++++++-- app/website.py | 42 ++++++++++++++++--- 8 files changed, 111 insertions(+), 10 deletions(-) create mode 100644 app/templates/change_password.html create mode 100644 app/templates/confirm_delete.html rename app/templates/{private_messages.html => feed.html} (100%) mode change 100755 => 100644 app/templates/user_list.html diff --git a/CHANGELOG.md b/CHANGELOG.md index 0398a37..82fcb82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ * Now `/about/` shows Python and Flask versions. * Now the error 404 handler returns HTTP 404. * Added user followers and following lists, accessible via `/+/followers` and `/+/following` and from the profile info box, linked to the followers/following number. +* Added the page for permanent deletion of messages. Well, you cannot delete them yet. It's missing a function that checks the CSRF-Token. +* Renamed template `private_messages.html` to `feed.html`. +* Added the capability to change password. +* Corrected a bug into `pwdhash`: it accepted an argument, but pulled data from the form instead of processing it. Now it uses the argument. * Schema changes: added column `telegram` to `UserProfile` table. To update schema, execute the script `migrate_0_6_to_0_7.py` ## 0.6.0 diff --git a/app/templates/change_password.html b/app/templates/change_password.html new file mode 100644 index 0000000..afd4e28 --- /dev/null +++ b/app/templates/change_password.html @@ -0,0 +1,17 @@ +{% extends "base.html" %} + +{% block body %} +

    Change Password

    + +
    +
    +
    Old password:
    +
    +
    New password:
    +
    +
    New password, again:
    +
    +
    +
    +
    +{% endblock %} diff --git a/app/templates/confirm_delete.html b/app/templates/confirm_delete.html new file mode 100644 index 0000000..b48919c --- /dev/null +++ b/app/templates/confirm_delete.html @@ -0,0 +1,22 @@ +{% extends "base.html" %} + +{% block body %} +

    Confirm Deletion

    + +

    Are you sure you want to permanently delete this post? + Neither you nor others will be able to see it; + you cannot recover a post after it's deleted.

    + +

    If you only want to hide it from the public, + you can set its privacy to "Only me".

    + +

    Here's the content of the message for reference:

    + +
      +
    • {% include "includes/message.html" %}
    • +
    + +
    + +
    +{% endblock %} diff --git a/app/templates/private_messages.html b/app/templates/feed.html similarity index 100% rename from app/templates/private_messages.html rename to app/templates/feed.html diff --git a/app/templates/includes/message.html b/app/templates/includes/message.html index fc8a91f..59369c9 100644 --- a/app/templates/includes/message.html +++ b/app/templates/includes/message.html @@ -20,7 +20,7 @@
      {% if message.user == current_user %}
    • Edit or change privacy
    • - +
    • Delete permanently
    • {% else %} {% endif %} diff --git a/app/templates/user_list.html b/app/templates/user_list.html old mode 100755 new mode 100644 diff --git a/app/utils.py b/app/utils.py index f6370a2..8f88ac3 100644 --- a/app/utils.py +++ b/app/utils.py @@ -2,10 +2,9 @@ A list of utilities used across modules. ''' -import datetime, re, base64, hashlib, string +import datetime, re, base64, hashlib, string, sys, json from .models import User, Notification from flask import abort, render_template, request, session -import sys, json _forbidden_extensions = 'com net org txt'.split() _username_characters = frozenset(string.ascii_letters + string.digits + '_') @@ -43,7 +42,7 @@ def int_to_b64(n): return base64.b64encode(b).lstrip(b'A').decode() def pwdhash(s): - return hashlib.md5((request.form['password']).encode('utf-8')).hexdigest() + return hashlib.md5(s.encode('utf-8')).hexdigest() def get_object_or_404(model, *expressions): try: @@ -160,3 +159,32 @@ def tokenize(characters, table): break pos = mo.end(0) return tokens + +def get_secret_key(): + from . import app + secret_key = app.config['SECRET_KEY'] + if isinstance(secret_key, str): + secret_key = secret_key.encode('utf-8') + return secret_key + +def generate_access_token(user): + ''' + Generate access token for public API. + ''' + h = hashlib.sha256(get_secret_key()) + h.update(b':') + h.update(str(user.id).encode('utf-8')) + h.update(b':') + h.update(str(user.password).encode('utf-8')) + return str(user.id) + ':' + h.hexdigest()[:32] + +def check_access_token(user, token): + uid, hh = token.split(':') + if uid != user.get_id(): + return False + h = hashlib.sha256(get_secret_key()) + h.update(b':') + h.update(str(user.id).encode('utf-8')) + h.update(b':') + h.update(str(user.password).encode('utf-8')) + return h.hexdigest()[:32] == hh diff --git a/app/website.py b/app/website.py index e463d37..b5f8ee7 100644 --- a/app/website.py +++ b/app/website.py @@ -29,8 +29,7 @@ def private_timeline(): .where((Message.user << user.following()) | (Message.user == user)) .order_by(Message.pub_date.desc())) - # TODO change to "feed.html" - return object_list('private_messages.html', messages, 'message_list') + return object_list('feed.html', messages, 'message_list') @bp.route('/explore/') def public_timeline(): @@ -246,9 +245,15 @@ def edit(id): return redirect(url_for('website.user_detail', username=user.username)) return render_template('edit.html', message=message) -#@bp.route('/delete/', methods=['GET', 'POST']) -#def confirm_delete(id): -# return render_template('confirm_delete.html') +@bp.route('/delete/', methods=['GET', 'POST']) +def confirm_delete(id): + user = get_current_user() + message = get_object_or_404(Message, Message.id == id) + if message.user != user: + abort(404) + if request.method == 'POST': + abort(501, 'CSRF-Token missing.') + return render_template('confirm_delete.html', message=message) # Workaround for problems related to invalid data. # Without that, changes will be lost across requests. @@ -261,7 +266,8 @@ def profile_checkpoint(): year=int(request.form['year'] if request.form.get('has_year') else '0'), website=request.form['website'] or None, instagram=request.form['instagram'] or None, - facebook=request.form['facebook'] or None + facebook=request.form['facebook'] or None, + telegram=request.form['telegram'] or None ) @bp.route('/edit_profile/', methods=['GET', 'POST']) @@ -299,6 +305,30 @@ def edit_profile(): return redirect(url_for('website.user_detail', username=username)) return render_template('edit_profile.html') +@bp.route('/change_password/', methods=['GET', 'POST']) +def change_password(): + user = get_current_user() + if request.method == 'POST': + old_password = request.form['old_password'] + new_password = request.form['new_password'] + confirm_password = request.form['confirm_password'] + errors = False + if not new_password: + flash('Password cannot be empty') + errors = True + if new_password != confirm_password: + flash('Password mismatch') + errors = True + if pwdhash(old_password) != user.password: + flash('The old password is incorrect') + errors = True + if not errors: + user.update( + password=pwdhash(new_password) + ) + return redirect(url_for('website.edit_profile')) + return render_template('change_password.html') + @bp.route('/notifications/') @login_required def notifications(): From dc33b5567aeaf25982d20ed3d6b47e3ff4c0875c Mon Sep 17 00:00:00 2001 From: Mattia Succurro Date: Sun, 27 Oct 2019 11:30:14 +0100 Subject: [PATCH 11/42] Adding feed to public API --- CHANGELOG.md | 3 ++- README.md | 3 ++- app/__init__.py | 37 +++++++++++++++++++++++++-- app/api.py | 67 +++++++++++++++++++++++++++++++++++++++++++++++++ app/utils.py | 11 +++++--- 5 files changed, 113 insertions(+), 8 deletions(-) create mode 100644 app/api.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 82fcb82..e3652b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## 0.7-dev -* Biggest change: unpacking modules. The single `app.py` file has become an `app` package, with submodules `models.py`, `utils.py`, `filters.py`, `website.py` and `ajax.py`. +* Biggest change: unpacking modules. The single `app.py` file has become an `app` package, with submodules `models.py`, `utils.py`, `filters.py`, `website.py` and `ajax.py`. There is also a new module `api.py`. * Now `/about/` shows Python and Flask versions. * Now the error 404 handler returns HTTP 404. * Added user followers and following lists, accessible via `/+/followers` and `/+/following` and from the profile info box, linked to the followers/following number. @@ -11,6 +11,7 @@ * Added the capability to change password. * Corrected a bug into `pwdhash`: it accepted an argument, but pulled data from the form instead of processing it. Now it uses the argument. * Schema changes: added column `telegram` to `UserProfile` table. To update schema, execute the script `migrate_0_6_to_0_7.py` +* Adding public API. Each of the API endpoints take a mandatory query string argument: the access token, generated by a separate endpoint at `/get_access_token` and stored into the client. All API routes start with `/api/V1`. Added endpoints `feed`. ## 0.6.0 diff --git a/README.md b/README.md index 8139d41..06fc58b 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ A simple social network, inspired by the now dead Google-Plus. -To run the app, run the file "run_example.py" +To run the app, do "flask run" in the package's parent directory. Based on Tweepee example of [peewee](https://github.com/coleifer/peewee/). @@ -13,6 +13,7 @@ Based on Tweepee example of [peewee](https://github.com/coleifer/peewee/). * Timeline feed * Add info to your profile * In-site notifications +* Public API * SQLite-based app ## Requirements diff --git a/app/__init__.py b/app/__init__.py index d7cb66d..bfde0f2 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -16,7 +16,6 @@ from flask import ( Flask, abort, flash, g, jsonify, redirect, render_template, request, send_from_directory, session, url_for, __version__ as flask_version) import hashlib -from peewee import * import datetime, time, re, os, sys, string, json, html from functools import wraps from flask_login import LoginManager @@ -76,12 +75,46 @@ def robots_txt(): def uploads(id, type='jpg'): return send_from_directory(UPLOAD_DIRECTORY, id + '.' + type) +@app.route('/get_access_token', methods=['POST']) +def send_access_token(): + try: + try: + user = User.get( + (User.username == request.form['username']) & + (User.password == pwdhash(request.form['password']))) + except User.DoesNotExist: + return jsonify({ + 'message': 'Invalid username or password', + 'login_correct': False, + 'status': 'ok' + }) + if user.is_disabled == 1: + user.is_disabled = 0 + elif user.is_disabled == 2: + return jsonify({ + 'message': 'Your account has been disabled by violating our Terms.', + 'login_correct': False, + 'status': 'ok' + }) + return jsonify({ + 'token': generate_access_token(user), + 'login_correct': True, + 'status': 'ok' + }) + except Exception: + sys.excepthook(*sys.exc_info()) + return jsonify({ + 'message': 'An unknown error has occurred.', + 'status': 'fail' + }) + from .website import bp app.register_blueprint(bp) from .ajax import bp app.register_blueprint(bp) - +from .api import bp +app.register_blueprint(bp) diff --git a/app/api.py b/app/api.py new file mode 100644 index 0000000..f689a88 --- /dev/null +++ b/app/api.py @@ -0,0 +1,67 @@ +from flask import Blueprint, jsonify, request +import sys, datetime +from functools import wraps +from .models import User, Message +from .utils import check_access_token, Visibility + +bp = Blueprint('api', __name__, url_prefix='/api/V1') + +def get_message_info(message): + return { + 'id': message.id, + 'user': { + 'id': message.user.id, + 'username': message.user.username, + }, + 'text': message.text, + 'privacy': message.privacy, + 'pub_date': message.pub_date.timestamp() + } + +def validate_access(func): + @wraps(func) + def wrapper(*args, **kwargs): + access_token = request.args.get('access_token') + if access_token is None: + return jsonify({ + 'message': 'missing access_token', + 'status': 'fail' + }) + user = check_access_token(access_token) + if user is None: + return jsonify({ + 'message': 'invalid access_token', + 'status': 'fail' + }) + try: + result = func(user, *args, **kwargs) + assert isinstance(result, dict) + except Exception: + sys.excepthook(*sys.exc_info()) + return jsonify({ + 'message': str(sys.exc_info()[1]), + 'status': 'fail' + }) + result['status'] = 'ok' + return jsonify(result) + return wrapper + +@bp.route('/feed') +@validate_access +def feed(self): + timeline_media = [] + date = request.args.get('offset') + if date is None: + date = datetime.datetime.now() + else: + date = datetime.datetime.fromtimestamp(date) + query = Visibility(Message + .select() + .where(((Message.user << self.following()) + | (Message.user == self)) + & (Message.pub_date < date)) + .order_by(Message.pub_date.desc()) + .limit(20)) + for message in query: + timeline_media.append(get_message_info(message)) + return {'timeline_media': timeline_media} diff --git a/app/utils.py b/app/utils.py index 8f88ac3..f014d69 100644 --- a/app/utils.py +++ b/app/utils.py @@ -178,13 +178,16 @@ def generate_access_token(user): h.update(str(user.password).encode('utf-8')) return str(user.id) + ':' + h.hexdigest()[:32] -def check_access_token(user, token): +def check_access_token(token): uid, hh = token.split(':') - if uid != user.get_id(): - return False + try: + user = User[uid] + except User.DoesNotExist: + return h = hashlib.sha256(get_secret_key()) h.update(b':') h.update(str(user.id).encode('utf-8')) h.update(b':') h.update(str(user.password).encode('utf-8')) - return h.hexdigest()[:32] == hh + if h.hexdigest()[:32] == hh: + return user From 09a809192aceb70bf12f59237db928c443ddc423 Mon Sep 17 00:00:00 2001 From: Mattia Succurro Date: Mon, 28 Oct 2019 08:29:07 +0100 Subject: [PATCH 12/42] Some fixes --- app/models.py | 2 +- app/utils.py | 11 ++++++++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/app/models.py b/app/models.py index a03ad7b..d03ea39 100644 --- a/app/models.py +++ b/app/models.py @@ -16,7 +16,7 @@ import os # here should go `from .utils import get_current_user`, but it will cause # import errors. It's instead imported at function level. -database = SqliteDatabase(os.path.join(os.getcwd(), 'coriplus.sqlite')) +database = SqliteDatabase(os.path.join(os.path.dirname(os.path.dirname(__file__)), 'coriplus.sqlite')) class BaseModel(Model): class Meta: diff --git a/app/utils.py b/app/utils.py index f014d69..302cb3a 100644 --- a/app/utils.py +++ b/app/utils.py @@ -101,9 +101,14 @@ except OSError: # get the user from the session # changed in 0.5 to comply with flask_login def get_current_user(): - user_id = session.get('user_id') - if user_id: - return User[user_id] + # new in 0.7; need a different method to get current user id + if request.path.startswith('/api/'): + # assume token validation is already done + return User[request.args['access_token'].split(':')[0]] + else: + user_id = session.get('user_id') + if user_id: + return User[user_id] def push_notification(type, target, **kwargs): try: From 0b7711fe26cf109f56317938476f34b2afbe8712 Mon Sep 17 00:00:00 2001 From: Mattia Succurro Date: Mon, 28 Oct 2019 09:03:44 +0100 Subject: [PATCH 13/42] Changing login mechanism --- app/__init__.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/__init__.py b/app/__init__.py index bfde0f2..07aa02e 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -79,9 +79,10 @@ def uploads(id, type='jpg'): def send_access_token(): try: try: + data = request.json user = User.get( - (User.username == request.form['username']) & - (User.password == pwdhash(request.form['password']))) + (User.username == data['username']) & + (User.password == pwdhash(data['password']))) except User.DoesNotExist: return jsonify({ 'message': 'Invalid username or password', From 09172d9c1ef5ceb9759db0784714eb908eb43844 Mon Sep 17 00:00:00 2001 From: Mattia Succurro Date: Thu, 31 Oct 2019 16:38:43 +0100 Subject: [PATCH 14/42] Adding create API endpoint --- CHANGELOG.md | 2 +- app/__init__.py | 2 +- app/api.py | 29 +++++++++++++++++++++++++++++ 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e3652b3..d80a466 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ * Added the capability to change password. * Corrected a bug into `pwdhash`: it accepted an argument, but pulled data from the form instead of processing it. Now it uses the argument. * Schema changes: added column `telegram` to `UserProfile` table. To update schema, execute the script `migrate_0_6_to_0_7.py` -* Adding public API. Each of the API endpoints take a mandatory query string argument: the access token, generated by a separate endpoint at `/get_access_token` and stored into the client. All API routes start with `/api/V1`. Added endpoints `feed`. +* Adding public API. Each of the API endpoints take a mandatory query string argument: the access token, generated by a separate endpoint at `/get_access_token` and stored into the client. All API routes start with `/api/V1`. Added endpoints `feed` and `create`. ## 0.6.0 diff --git a/app/__init__.py b/app/__init__.py index 07aa02e..ff78d41 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -78,8 +78,8 @@ def uploads(id, type='jpg'): @app.route('/get_access_token', methods=['POST']) def send_access_token(): try: + data = request.get_json(True) try: - data = request.json user = User.get( (User.username == data['username']) & (User.password == pwdhash(data['password']))) diff --git a/app/api.py b/app/api.py index f689a88..523281d 100644 --- a/app/api.py +++ b/app/api.py @@ -65,3 +65,32 @@ def feed(self): for message in query: timeline_media.append(get_message_info(message)) return {'timeline_media': timeline_media} + +@bp.route('/create', methods=['POST']) +@validate_access +def create(self): + data = request.get_json(True) + text = data['text'] + privacy = int(data.get('privacy', 0)) + message = Message.create( + user=self, + text=text, + pub_date=datetime.datetime.now(), + privacy=privacy) + # Currently, API does not support files. + # create mentions + mention_usernames = set() + for mo in re.finditer(r'\+([A-Za-z0-9_]+(?:\.[A-Za-z0-9_]+)*)', text): + mention_usernames.add(mo.group(1)) + # to avoid self mention + mention_usernames.difference_update({self.username}) + for u in mention_usernames: + try: + mention_user = User.get(User.username == u) + if privacy in (MSGPRV_PUBLIC, MSGPRV_UNLISTED) or \ + (privacy == MSGPRV_FRIENDS and + mention_user.is_following(self) and + self.is_following(mention_user)): + push_notification('mention', mention_user, user=user.id) + except User.DoesNotExist: + pass From 8d97d1fbf7ef43328846a81fb63d400047c4a4d5 Mon Sep 17 00:00:00 2001 From: Mattia Succurro Date: Thu, 31 Oct 2019 17:03:14 +0100 Subject: [PATCH 15/42] Added profile_info endpoint --- CHANGELOG.md | 2 +- app/api.py | 25 ++++++++++++++++++++++++- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d80a466..d1989d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ * Added the capability to change password. * Corrected a bug into `pwdhash`: it accepted an argument, but pulled data from the form instead of processing it. Now it uses the argument. * Schema changes: added column `telegram` to `UserProfile` table. To update schema, execute the script `migrate_0_6_to_0_7.py` -* Adding public API. Each of the API endpoints take a mandatory query string argument: the access token, generated by a separate endpoint at `/get_access_token` and stored into the client. All API routes start with `/api/V1`. Added endpoints `feed` and `create`. +* Adding public API. Each of the API endpoints take a mandatory query string argument: the access token, generated by a separate endpoint at `/get_access_token` and stored into the client. All API routes start with `/api/V1`. Added endpoints `feed`, `create` and `profile_info`. ## 0.6.0 diff --git a/app/api.py b/app/api.py index 523281d..1393ce9 100644 --- a/app/api.py +++ b/app/api.py @@ -1,5 +1,5 @@ from flask import Blueprint, jsonify, request -import sys, datetime +import sys, datetime, re from functools import wraps from .models import User, Message from .utils import check_access_token, Visibility @@ -94,3 +94,26 @@ def create(self): push_notification('mention', mention_user, user=user.id) except User.DoesNotExist: pass + +@bp.route('/profile_info/', methods=['GET']) +@validate_access +def profile_info(self, userid): + if userid == 'self': + user = self + elif userid.isdigit(): + user = User[id] + else: + raise ValueError('userid should be an integer or "self"') + profile = user.profile + return { + "user": { + "id": user.id, + "username": user.username, + "full_name": profile.full_name, + "biography": profile.biography, + "website": profile.website, + "generation": profile.year, + "instagram": profile.instagram, + "facebook": profile.facebook, + } + } From 7ede351b11a173b5e2ad5c7825763d77ed5eb200 Mon Sep 17 00:00:00 2001 From: Mattia Succurro Date: Mon, 4 Nov 2019 22:09:04 +0100 Subject: [PATCH 16/42] Adding profile_feed and profile_search endpoints --- CHANGELOG.md | 2 +- app/api.py | 62 +++++++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 60 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d1989d5..31583dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ * Added the capability to change password. * Corrected a bug into `pwdhash`: it accepted an argument, but pulled data from the form instead of processing it. Now it uses the argument. * Schema changes: added column `telegram` to `UserProfile` table. To update schema, execute the script `migrate_0_6_to_0_7.py` -* Adding public API. Each of the API endpoints take a mandatory query string argument: the access token, generated by a separate endpoint at `/get_access_token` and stored into the client. All API routes start with `/api/V1`. Added endpoints `feed`, `create` and `profile_info`. +* Adding public API. Each of the API endpoints take a mandatory query string argument: the access token, generated by a separate endpoint at `/get_access_token` and stored into the client. All API routes start with `/api/V1`. Added endpoints `feed`, `create`, `profile_info`, `profile_feed` and `profile_search`. ## 0.6.0 diff --git a/app/api.py b/app/api.py index 1393ce9..0c9abd9 100644 --- a/app/api.py +++ b/app/api.py @@ -1,7 +1,7 @@ from flask import Blueprint, jsonify, request import sys, datetime, re from functools import wraps -from .models import User, Message +from .models import User, Message, Relationship from .utils import check_access_token, Visibility bp = Blueprint('api', __name__, url_prefix='/api/V1') @@ -37,7 +37,7 @@ def validate_access(func): result = func(user, *args, **kwargs) assert isinstance(result, dict) except Exception: - sys.excepthook(*sys.exc_info()) + import traceback; traceback.print_exc() return jsonify({ 'message': str(sys.exc_info()[1]), 'status': 'fail' @@ -95,13 +95,24 @@ def create(self): except User.DoesNotExist: pass +def get_relationship_info(self, other): + if self == other: + return + return { + "following": self.is_following(other), + "followed_by": other.is_following(self) + } + @bp.route('/profile_info/', methods=['GET']) @validate_access def profile_info(self, userid): if userid == 'self': user = self elif userid.isdigit(): - user = User[id] + try: + user = User[userid] + except User.DoesNotExist: + return {'user': None} else: raise ValueError('userid should be an integer or "self"') profile = user.profile @@ -115,5 +126,50 @@ def profile_info(self, userid): "generation": profile.year, "instagram": profile.instagram, "facebook": profile.facebook, + "relationships": get_relationship_info(self, user) } } + +@bp.route('/profile_info/feed/', methods=['GET']) +@validate_access +def profile_feed(self, userid): + if userid == 'self': + user = self + elif userid.isdigit(): + user = User[userid] + else: + raise ValueError('userid should be an integer or "self"') + timeline_media = [] + date = request.args.get('offset') + if date is None: + date = datetime.datetime.now() + else: + date = datetime.datetime.fromtimestamp(date) + query = Visibility(Message + .select() + .where((Message.user == user) + & (Message.pub_date < date)) + .order_by(Message.pub_date.desc()) + .limit(20)) + for message in query: + timeline_media.append(get_message_info(message)) + return {'timeline_media': timeline_media} + +@bp.route('/profile_search', methods=['POST']) +@validate_access +def profile_search(self): + data = request.get_json(True) + query = User.select().where(User.username ** ('%' + data['q'] + '%')).limit(20) + results = [] + for result in query: + profile = result.profile + result.append({ + "id": result.id, + "username": result.username, + "full_name": result.profile.full_name, + "followers_count": len(result.followers()) + }) + return { + "users": results + } + From c57088c6c325f0bf52d7b1a69d317ac22a3623d2 Mon Sep 17 00:00:00 2001 From: Mattia Succurro Date: Tue, 5 Nov 2019 17:03:58 +0100 Subject: [PATCH 17/42] Preparing for release --- CHANGELOG.md | 3 ++- app/__init__.py | 2 +- app/api.py | 11 ++++++++--- app/models.py | 2 +- 4 files changed, 12 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 31583dc..f2d98a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 0.7-dev +## 0.7.0 * Biggest change: unpacking modules. The single `app.py` file has become an `app` package, with submodules `models.py`, `utils.py`, `filters.py`, `website.py` and `ajax.py`. There is also a new module `api.py`. * Now `/about/` shows Python and Flask versions. @@ -12,6 +12,7 @@ * Corrected a bug into `pwdhash`: it accepted an argument, but pulled data from the form instead of processing it. Now it uses the argument. * Schema changes: added column `telegram` to `UserProfile` table. To update schema, execute the script `migrate_0_6_to_0_7.py` * Adding public API. Each of the API endpoints take a mandatory query string argument: the access token, generated by a separate endpoint at `/get_access_token` and stored into the client. All API routes start with `/api/V1`. Added endpoints `feed`, `create`, `profile_info`, `profile_feed` and `profile_search`. +* Planning to release mobile app for Android. ## 0.6.0 diff --git a/app/__init__.py b/app/__init__.py index ff78d41..fdab8d1 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -20,7 +20,7 @@ import datetime, time, re, os, sys, string, json, html from functools import wraps from flask_login import LoginManager -__version__ = '0.7-dev' +__version__ = '0.7.0' # we want to support Python 3 only. # Python 2 has too many caveats. diff --git a/app/api.py b/app/api.py index 0c9abd9..828ba65 100644 --- a/app/api.py +++ b/app/api.py @@ -1,7 +1,8 @@ from flask import Blueprint, jsonify, request import sys, datetime, re from functools import wraps -from .models import User, Message, Relationship +from .models import User, Message, Relationship, \ + MSGPRV_PUBLIC, MSGPRV_UNLISTED, MSGPRV_FRIENDS, MSGPRV_ONLYME from .utils import check_access_token, Visibility bp = Blueprint('api', __name__, url_prefix='/api/V1') @@ -108,6 +109,8 @@ def get_relationship_info(self, other): def profile_info(self, userid): if userid == 'self': user = self + elif userid.startswith('+'): + user = User.get(User.username == userid[1:]) elif userid.isdigit(): try: user = User[userid] @@ -135,6 +138,8 @@ def profile_info(self, userid): def profile_feed(self, userid): if userid == 'self': user = self + elif userid.startswith('+'): + user = User.get(User.username == userid[1:]) elif userid.isdigit(): user = User[userid] else: @@ -163,10 +168,10 @@ def profile_search(self): results = [] for result in query: profile = result.profile - result.append({ + results.append({ "id": result.id, "username": result.username, - "full_name": result.profile.full_name, + "full_name": profile.full_name, "followers_count": len(result.followers()) }) return { diff --git a/app/models.py b/app/models.py index d03ea39..9626890 100644 --- a/app/models.py +++ b/app/models.py @@ -149,7 +149,7 @@ class Message(BaseModel): # even if unlisted return not is_public_timeline elif privacy == MSGPRV_FRIENDS: - if cur_user is None: + if cur_user.is_anonymous: return False return user.is_following(cur_user) and cur_user.is_following(user) else: From ef8d5343e9807e2806e66fcee54e929e739d7335 Mon Sep 17 00:00:00 2001 From: Mattia Succurro Date: Tue, 5 Nov 2019 22:15:16 +0100 Subject: [PATCH 18/42] Added profile stats to API --- CHANGELOG.md | 4 ++++ app/api.py | 5 ++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f2d98a1..c022145 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 0.7.1-dev + +* Adding `messages_count`, `followers_count` and `following_count` to `profile_info` API endpoint (forgot to release). + ## 0.7.0 * Biggest change: unpacking modules. The single `app.py` file has become an `app` package, with submodules `models.py`, `utils.py`, `filters.py`, `website.py` and `ajax.py`. There is also a new module `api.py`. diff --git a/app/api.py b/app/api.py index 828ba65..05bbd94 100644 --- a/app/api.py +++ b/app/api.py @@ -129,7 +129,10 @@ def profile_info(self, userid): "generation": profile.year, "instagram": profile.instagram, "facebook": profile.facebook, - "relationships": get_relationship_info(self, user) + "relationships": get_relationship_info(self, user), + "messages_count": len(user.messages), + "followers_count": len(user.followers()), + "following_count": len(user.following()) } } From a70b4f2eaecfb7c02ccf9657a602b2f49d015cb7 Mon Sep 17 00:00:00 2001 From: Mattia Succurro Date: Wed, 6 Nov 2019 11:12:11 +0100 Subject: [PATCH 19/42] Schema and version number changes --- CHANGELOG.md | 5 +++++ app/__init__.py | 2 +- app/models.py | 9 ++++++++- migrate_0_7_to_0_8.py | 20 ++++++++++++++++++++ 4 files changed, 34 insertions(+), 2 deletions(-) create mode 100644 migrate_0_7_to_0_8.py diff --git a/CHANGELOG.md b/CHANGELOG.md index c022145..6759900 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 0.8-dev + +* Schema changes: moved `full_name` field from table `userprofile` to table `user` for search improvement reasons. +* Adding `messages_count`, `followers_count` and `following_count` to `profile_info` API endpoint (what I've done to 0.7.1 too). + ## 0.7.1-dev * Adding `messages_count`, `followers_count` and `following_count` to `profile_info` API endpoint (forgot to release). diff --git a/app/__init__.py b/app/__init__.py index fdab8d1..cbcdd40 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -20,7 +20,7 @@ import datetime, time, re, os, sys, string, json, html from functools import wraps from flask_login import LoginManager -__version__ = '0.7.0' +__version__ = '0.8-dev' # we want to support Python 3 only. # Python 2 has too many caveats. diff --git a/app/models.py b/app/models.py index 9626890..5c35108 100644 --- a/app/models.py +++ b/app/models.py @@ -26,6 +26,8 @@ class BaseModel(Model): class User(BaseModel): # The unique username. username = CharField(unique=True) + # The user's full name (here for better search since 0.8) + full_name = TextField() # The password hash. password = CharField() # An email address. @@ -107,7 +109,6 @@ class UserAdminship(BaseModel): # New in 0.6 class UserProfile(BaseModel): user = ForeignKeyField(User, primary_key=True) - full_name = TextField() biography = TextField(default='') location = IntegerField(null=True) year = IntegerField(null=True) @@ -115,6 +116,12 @@ class UserProfile(BaseModel): instagram = TextField(null=True) facebook = TextField(null=True) telegram = TextField(null=True) + @property + def full_name(self): + ''' + Moved to User in 0.8 for search improvement reasons. + ''' + return self.user.full_name # The message privacy values. MSGPRV_PUBLIC = 0 # everyone diff --git a/migrate_0_7_to_0_8.py b/migrate_0_7_to_0_8.py new file mode 100644 index 0000000..ab1390b --- /dev/null +++ b/migrate_0_7_to_0_8.py @@ -0,0 +1,20 @@ +import sqlite3 + +import sqlite3 + +conn = sqlite3.connect('coriplus.sqlite') + +if __name__ == '__main__': + conn.executescript(''' +BEGIN TRANSACTION; + CREATE TABLE "new_userprofile" ("user_id" INTEGER NOT NULL PRIMARY KEY, "biography" TEXT NOT NULL, "location" INTEGER, "year" INTEGER, "website" TEXT, "instagram" TEXT, "facebook" TEXT, telegram TEXT, FOREIGN KEY ("user_id") REFERENCES "user" ("id")); + CREATE TABLE "new_user" ("id" INTEGER NOT NULL PRIMARY KEY, "username" VARCHAR(30) NOT NULL, "full_name" VARCHAR(30), "password" VARCHAR(255) NOT NULL, "email" VARCHAR(255) NOT NULL, "birthday" DATE NOT NULL, "join_date" DATETIME NOT NULL, "is_disabled" INTEGER NOT NULL); + INSERT INTO new_user (id, username, full_name, password, email, birthday, join_date, is_disabled) SELECT t1.id, t1.username, t2.full_name, t1.password, t1.email, t1.birthday, t1.join_date, t1.is_disabled FROM user AS t1 LEFT JOIN userprofile AS t2 ON t1.id = t2.user_id; + INSERT INTO new_userprofile (user_id, biography, location, year, website, instagram, facebook, telegram) SELECT user_id, biography, location, year, website, instagram, facebook, telegram FROM userprofile; + UPDATE new_user SET full_name = username WHERE username IS NULL; + DROP TABLE user; + DROP TABLE userprofile; + ALTER TABLE new_user RENAME TO user; + ALTER TABLE new_userprofile RENAME TO userprofile; +COMMIT; +''') From 7fb5c47e4d559ea132c09bff01d7a2154d6a18c4 Mon Sep 17 00:00:00 2001 From: Mattia Succurro Date: Fri, 8 Nov 2019 16:51:32 +0100 Subject: [PATCH 20/42] Added new API endpoints --- CHANGELOG.md | 3 ++ app/api.py | 94 +++++++++++++++++++++++++++++++++++++++++++++++--- app/models.py | 3 ++ app/website.py | 9 ++--- 4 files changed, 101 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6759900..3828e7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ * Schema changes: moved `full_name` field from table `userprofile` to table `user` for search improvement reasons. * Adding `messages_count`, `followers_count` and `following_count` to `profile_info` API endpoint (what I've done to 0.7.1 too). +* Adding media URLs to messages in API. +* Added `relationships_follow`, `relationships_unfollow`, `username_availability` and `edit_profile` endpoints to API. +* Added `url` utility to model `Upload`. ## 0.7.1-dev diff --git a/app/api.py b/app/api.py index 05bbd94..95b7c42 100644 --- a/app/api.py +++ b/app/api.py @@ -1,13 +1,20 @@ from flask import Blueprint, jsonify, request import sys, datetime, re from functools import wraps -from .models import User, Message, Relationship, \ +from peewee import IntegrityError +from .models import User, Message, Relationship, database, \ MSGPRV_PUBLIC, MSGPRV_UNLISTED, MSGPRV_FRIENDS, MSGPRV_ONLYME -from .utils import check_access_token, Visibility +from .utils import check_access_token, Visibility, push_notification, unpush_notification bp = Blueprint('api', __name__, url_prefix='/api/V1') def get_message_info(message): + try: + media = message.uploads[0].url() + except IndexError: + media = None + if media: + print(media) return { 'id': message.id, 'user': { @@ -16,7 +23,8 @@ def get_message_info(message): }, 'text': message.text, 'privacy': message.privacy, - 'pub_date': message.pub_date.timestamp() + 'pub_date': message.pub_date.timestamp(), + 'media': media } def validate_access(func): @@ -163,6 +171,34 @@ def profile_feed(self, userid): timeline_media.append(get_message_info(message)) return {'timeline_media': timeline_media} +@bp.route('/relationships//follow', methods=['POST']) +@validate_access +def relationships_follow(self, userid): + user = User[userid] + try: + with database.atomic(): + Relationship.create( + from_user=self, + to_user=user, + created_date=datetime.datetime.now()) + except IntegrityError: + pass + push_notification('follow', user, user=self.id) + return get_relationship_info(self, user) + +@bp.route('/relationships//unfollow', methods=['POST']) +@validate_access +def relationships_unfollow(self, userid): + user = User[userid] + (Relationship + .delete() + .where( + (Relationship.from_user == self) & + (Relationship.to_user == user)) + .execute()) + unpush_notification('follow', user, user=self.id) + return get_relationship_info(self, user) + @bp.route('/profile_search', methods=['POST']) @validate_access def profile_search(self): @@ -180,4 +216,54 @@ def profile_search(self): return { "users": results } - + +@bp.route('/username_availability/') +@validate_access +def username_availability(self, username): + current = self.username + is_valid = is_username(username) + if is_valid: + try: + user = User.get(User.username == username) + is_available = current == user.username + except User.DoesNotExist: + is_available = True + else: + is_available = False + return { + 'is_valid': is_valid, + 'is_available': is_available + } + +@bp.route('/edit_profile', methods=['POST']) +@validate_access +def edit_profile(user): + data = request.get_json(True) + username = data['username'] + if not username: + # prevent username to be set to empty + username = user.username + if username != user.username: + try: + User.update(username=username).where(User.id == user.id).execute() + except IntegrityError: + raise ValueError('that username is already taken') + full_name = data['full_name'] or username + if full_name != user.full_name: + User.update(full_name=full_name).where(User.id == user.id).execute() + website = data['website'].strip().replace(' ', '%20') + if website and not validate_website(website): + raise ValueError('You should enter a valid URL.') + #location = int(request.form.get('location')) + #if location == 0: + # location = None + UserProfile.update( + biography=data['biography'], + #year=data['year'] if data.get('has_year') else None, + #location=location, + website=website, + instagram=data['instagram'], + facebook=data['facebook'], + telegram=data['telegram'] + ).where(UserProfile.user == user).execute() + return {} diff --git a/app/models.py b/app/models.py index 5c35108..e95a793 100644 --- a/app/models.py +++ b/app/models.py @@ -11,6 +11,7 @@ The tables are: * notification - a in-site notification to a user; new in 0.3 ''' +from flask import request from peewee import * import os # here should go `from .utils import get_current_user`, but it will cause @@ -188,6 +189,8 @@ class Upload(BaseModel): # helper to retrieve contents def filename(self): return str(self.id) + '.' + self.type + def url(self): + return request.host_url + 'uploads/' + self.filename() class Notification(BaseModel): type = TextField() diff --git a/app/website.py b/app/website.py index b5f8ee7..9d7d13d 100644 --- a/app/website.py +++ b/app/website.py @@ -60,13 +60,13 @@ def register(): # unique constraint, the database will raise an IntegrityError. user = User.create( username=username, + full_name=request.form.get('full_name') or username, password=pwdhash(request.form['password']), email=request.form['email'], birthday=birthday, join_date=datetime.datetime.now()) UserProfile.create( - user=user, - full_name=request.form.get('full_name') or username + user=user ) # mark the user as being 'authenticated' by setting the session vars @@ -260,7 +260,6 @@ def confirm_delete(id): def profile_checkpoint(): return UserProfile( user=get_current_user(), - full_name=request.form['full_name'], biography=request.form['biography'], location=int(request.form['location']), year=int(request.form['year'] if request.form.get('has_year') else '0'), @@ -285,6 +284,9 @@ def edit_profile(): except IntegrityError: flash('That username is already taken') return render_template('edit_profile.html', profile=profile_checkpoint()) + full_name = request.form['full_name'] or username + if full_name != user.full_name: + User.update(full_name=full_name).where(User.id == user.id).execute() website = request.form['website'].strip().replace(' ', '%20') if website and not validate_website(website): flash('You should enter a valid URL.') @@ -293,7 +295,6 @@ def edit_profile(): if location == 0: location = None UserProfile.update( - full_name=request.form['full_name'] or username, biography=request.form['biography'], year=request.form['year'] if request.form.get('has_year') else None, location=location, From af299a53c7aafa4ac676128d86dc5972ae05948f Mon Sep 17 00:00:00 2001 From: Mattia Succurro Date: Sat, 9 Nov 2019 15:00:06 +0100 Subject: [PATCH 21/42] Added create2 endpoint --- CHANGELOG.md | 1 + app/api.py | 44 +++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3828e7c..d8c472c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ * Schema changes: moved `full_name` field from table `userprofile` to table `user` for search improvement reasons. * Adding `messages_count`, `followers_count` and `following_count` to `profile_info` API endpoint (what I've done to 0.7.1 too). +* Adding `create2` API endpoint that accepts media, due to an issue with the `create` endpoint that would make it incompatible. * Adding media URLs to messages in API. * Added `relationships_follow`, `relationships_unfollow`, `username_availability` and `edit_profile` endpoints to API. * Added `url` utility to model `Upload`. diff --git a/app/api.py b/app/api.py index 95b7c42..a11b5eb 100644 --- a/app/api.py +++ b/app/api.py @@ -1,9 +1,9 @@ from flask import Blueprint, jsonify, request -import sys, datetime, re +import sys, os, datetime, re from functools import wraps from peewee import IntegrityError -from .models import User, Message, Relationship, database, \ - MSGPRV_PUBLIC, MSGPRV_UNLISTED, MSGPRV_FRIENDS, MSGPRV_ONLYME +from .models import User, Message, Upload, Relationship, database, \ + MSGPRV_PUBLIC, MSGPRV_UNLISTED, MSGPRV_FRIENDS, MSGPRV_ONLYME, UPLOAD_DIRECTORY from .utils import check_access_token, Visibility, push_notification, unpush_notification bp = Blueprint('api', __name__, url_prefix='/api/V1') @@ -103,6 +103,44 @@ def create(self): push_notification('mention', mention_user, user=user.id) except User.DoesNotExist: pass + return {} + +@bp.route('/create2', methods=['POST']) +@validate_access +def create2(self): + text = request.form['text'] + privacy = int(request.form.get('privacy', 0)) + message = Message.create( + user=self, + text=text, + pub_date=datetime.datetime.now(), + privacy=privacy) + file = request.files.get('file') + if file: + print('Uploading', file.filename) + ext = file.filename.split('.')[-1] + upload = Upload.create( + type=ext, + message=message + ) + file.save(os.path.join(UPLOAD_DIRECTORY, str(upload.id) + '.' + ext)) + # create mentions + mention_usernames = set() + for mo in re.finditer(r'\+([A-Za-z0-9_]+(?:\.[A-Za-z0-9_]+)*)', text): + mention_usernames.add(mo.group(1)) + # to avoid self mention + mention_usernames.difference_update({self.username}) + for u in mention_usernames: + try: + mention_user = User.get(User.username == u) + if privacy in (MSGPRV_PUBLIC, MSGPRV_UNLISTED) or \ + (privacy == MSGPRV_FRIENDS and + mention_user.is_following(self) and + self.is_following(mention_user)): + push_notification('mention', mention_user, user=user.id) + except User.DoesNotExist: + pass + return {} def get_relationship_info(self, other): if self == other: From 6c128d05679e8438badd0a06ff6b90aaf5b3f033 Mon Sep 17 00:00:00 2001 From: Mattia Succurro Date: Mon, 11 Nov 2019 19:15:55 +0100 Subject: [PATCH 22/42] Adding admin and report endpoints --- CHANGELOG.md | 6 +- README.md | 2 + app/__init__.py | 7 +++ app/admin.py | 66 ++++++++++++++++++++ app/api.py | 5 +- app/models.py | 52 ++++++++++++++- app/reports.py | 42 +++++++++++++ app/templates/admin_base.html | 28 +++++++++ app/templates/admin_home.html | 9 +++ app/templates/admin_report_detail.html | 27 ++++++++ app/templates/admin_reports.html | 16 +++++ app/templates/includes/message.html | 2 +- app/templates/includes/reported_message.html | 15 +++++ app/templates/report_base.html | 27 ++++++++ app/templates/report_done.html | 11 ++++ app/templates/report_message.html | 11 ++++ app/templates/report_user.html | 11 ++++ robots.txt | 4 +- 18 files changed, 335 insertions(+), 6 deletions(-) create mode 100644 app/admin.py create mode 100644 app/reports.py create mode 100644 app/templates/admin_base.html create mode 100644 app/templates/admin_home.html create mode 100644 app/templates/admin_report_detail.html create mode 100644 app/templates/admin_reports.html create mode 100644 app/templates/includes/reported_message.html create mode 100644 app/templates/report_base.html create mode 100644 app/templates/report_done.html create mode 100644 app/templates/report_message.html create mode 100644 app/templates/report_user.html diff --git a/CHANGELOG.md b/CHANGELOG.md index d8c472c..6c78b15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,12 +2,16 @@ ## 0.8-dev -* Schema changes: moved `full_name` field from table `userprofile` to table `user` for search improvement reasons. +* Added the admin dashboard, accessible from `/admin/` via basic auth. Only users with admin right can access it. Added endpoints `admin.reports` and `admin.reports_detail`. +* Safety is our top priority: added the ability to report someone other's post for everything violating the site's Terms of Service. The current reasons for reporting are: spam, impersonation, pornography, violence, harassment or bullying, hate speech or symbols, self injury, sale or promotion of firearms or drugs, and underage use. +* Schema changes: moved `full_name` field from table `userprofile` to table `user` for search improvement reasons. Added `Report` model. +* Now `profile_search` API endpoint searches by full name too. * Adding `messages_count`, `followers_count` and `following_count` to `profile_info` API endpoint (what I've done to 0.7.1 too). * Adding `create2` API endpoint that accepts media, due to an issue with the `create` endpoint that would make it incompatible. * Adding media URLs to messages in API. * Added `relationships_follow`, `relationships_unfollow`, `username_availability` and `edit_profile` endpoints to API. * Added `url` utility to model `Upload`. +* Changed default `robots.txt`, adding report and admin-related lines. ## 0.7.1-dev diff --git a/README.md b/README.md index 06fc58b..b96fffc 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,8 @@ To run the app, do "flask run" in the package's parent directory. Based on Tweepee example of [peewee](https://github.com/coleifer/peewee/). +This is the server. For the client, see [coriplusapp](https://github.com/sakuragasaki46/coriplusapp/). + ## Features * Create text statuses, optionally with image diff --git a/app/__init__.py b/app/__init__.py index cbcdd40..83afa06 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -7,6 +7,9 @@ This module also contains very basic web hooks, such as robots.txt. For the website hooks, see `app.website`. For the AJAX hook, see `app.ajax`. +For public API, see `app.api`. +For report pages, see `app.reports`. +For site administration, see `app.admin`. For template filters, see `app.filters`. For the database models, see `app.models`. For other, see `app.utils`. @@ -118,4 +121,8 @@ app.register_blueprint(bp) from .api import bp app.register_blueprint(bp) +from .reports import bp +app.register_blueprint(bp) +from .admin import bp +app.register_blueprint(bp) diff --git a/app/admin.py b/app/admin.py new file mode 100644 index 0000000..b8fb4b9 --- /dev/null +++ b/app/admin.py @@ -0,0 +1,66 @@ +''' +Management of reports and the entire site. + +New in 0.8. +''' + +from flask import Blueprint, redirect, render_template, request, url_for +from .models import User, Message, Report, report_reasons, REPORT_STATUS_ACCEPTED, \ + REPORT_MEDIA_USER, REPORT_MEDIA_MESSAGE +from .utils import pwdhash, object_list +from functools import wraps + +bp = Blueprint('admin', __name__, url_prefix='/admin') + +def check_auth(username, password): + try: + return User.get((User.username == username) & (User.password == pwdhash(password)) + ).is_admin + except User.DoesNotExist: + return False + +def admin_required(f): + @wraps(f) + def wrapped_view(**kwargs): + auth = request.authorization + if not (auth and check_auth(auth.username, auth.password)): + return ('Unauthorized', 401, { + 'WWW-Authenticate': 'Basic realm="Login Required"' + }) + return f(**kwargs) + return wrapped_view + +def review_reports(status, media_type, media_id): + (Report + .update(status=status) + .where((Report.media_type == media_type) & (Report.media_id == media_id)) + .execute()) + if status == REPORT_STATUS_ACCEPTED: + if media_type == REPORT_MEDIA_USER: + user = User[media_id] + user.is_disabled = 2 + user.save() + elif media_type == REPORT_MEDIA_MESSAGE: + Message.delete().where(Message.id == media_id).execute() + +@bp.route('/') +@admin_required +def homepage(): + return render_template('admin_home.html') + +@bp.route('/reports') +@admin_required +def reports(): + return object_list('admin_reports.html', Report.select().order_by(Report.created_date.desc()), 'report_list', report_reasons=dict(report_reasons)) + +@bp.route('/reports/', methods=['GET', 'POST']) +@admin_required +def reports_detail(id): + report = Report[id] + if request.method == 'POST': + if request.form.get('take_down'): + review_reports(REPORT_STATUS_ACCEPTED, report.media_type, report.media_id) + elif request.form.get('discard'): + review_reports(REPORT_STATUS_DECLINED, report.media_type, report.media_id) + return redirect(url_for('admin.reports')) + return render_template('admin_report_detail.html', report=report, report_reasons=dict(report_reasons)) diff --git a/app/api.py b/app/api.py index a11b5eb..0171f0f 100644 --- a/app/api.py +++ b/app/api.py @@ -241,14 +241,15 @@ def relationships_unfollow(self, userid): @validate_access def profile_search(self): data = request.get_json(True) - query = User.select().where(User.username ** ('%' + data['q'] + '%')).limit(20) + query = User.select().where((User.username ** ('%' + data['q'] + '%')) | + (User.full_name ** ('%' + data['q'] + '%'))).limit(20) results = [] for result in query: profile = result.profile results.append({ "id": result.id, "username": result.username, - "full_name": profile.full_name, + "full_name": result.full_name, "followers_count": len(result.followers()) }) return { diff --git a/app/models.py b/app/models.py index e95a793..34d04e3 100644 --- a/app/models.py +++ b/app/models.py @@ -198,11 +198,61 @@ class Notification(BaseModel): detail = TextField() pub_date = DateTimeField() seen = IntegerField(default=0) + +REPORT_MEDIA_USER = 1 +REPORT_MEDIA_MESSAGE = 2 + +REPORT_REASON_SPAM = 1 +REPORT_REASON_IMPERSONATION = 2 +REPORT_REASON_PORN = 3 +REPORT_REASON_VIOLENCE = 4 +REPORT_REASON_HATE = 5 +REPORT_REASON_BULLYING = 6 +REPORT_REASON_SELFINJURY = 7 +REPORT_REASON_FIREARMS = 8 +REPORT_REASON_DRUGS = 9 +REPORT_REASON_UNDERAGE = 10 + +report_reasons = [ + (REPORT_REASON_SPAM, "It's spam"), + (REPORT_REASON_IMPERSONATION, "This profile is pretending to be someone else"), + (REPORT_REASON_PORN, "Nudity or pornography"), + (REPORT_REASON_VIOLENCE, "Violence or dangerous organization"), + (REPORT_REASON_HATE, "Hate speech or symbols"), + (REPORT_REASON_BULLYING, "Harassment or bullying"), + (REPORT_REASON_SELFINJURY, "Self injury"), + (REPORT_REASON_FIREARMS, "Sale or promotion of firearms"), + (REPORT_REASON_DRUGS, "Sale or promotion of drugs"), + (REPORT_REASON_UNDERAGE, "This user is less than 13 years old"), +] + +REPORT_STATUS_DELIVERED = 0 +REPORT_STATUS_ACCEPTED = 1 +REPORT_STATUS_DECLINED = 2 + +# New in 0.8. +class Report(BaseModel): + media_type = IntegerField() + media_id = IntegerField() + sender = ForeignKeyField(User, null=True) + reason = IntegerField() + status = IntegerField(default=REPORT_STATUS_DELIVERED) + created_date = DateTimeField() + + @property + def media(self): + try: + if self.media_type == REPORT_MEDIA_USER: + return User[self.media_id] + elif self.media_type == REPORT_MEDIA_MESSAGE: + return Message[self.media_id] + except DoesNotExist: + return def create_tables(): with database: database.create_tables([ User, UserAdminship, UserProfile, Message, Relationship, - Upload, Notification]) + Upload, Notification, Report]) if not os.path.isdir(UPLOAD_DIRECTORY): os.makedirs(UPLOAD_DIRECTORY) diff --git a/app/reports.py b/app/reports.py new file mode 100644 index 0000000..4c001ba --- /dev/null +++ b/app/reports.py @@ -0,0 +1,42 @@ +''' +Module for user and message reports. + +New in 0.8. +''' + +from flask import Blueprint, redirect, request, render_template, url_for +from .models import Report, REPORT_MEDIA_USER, REPORT_MEDIA_MESSAGE, report_reasons +from .utils import get_current_user +import datetime + +bp = Blueprint('reports', __name__, url_prefix='/report') + +@bp.route('/user/', methods=['GET', 'POST']) +def report_user(userid): + if request.method == "POST": + Report.create( + media_type=REPORT_MEDIA_USER, + media_id=userid, + sender=get_current_user(), + reason=request.form['reason'], + created_date=datetime.datetime.now() + ) + return redirect(url_for('reports.report_done')) + return render_template('report_user.html', report_reasons=report_reasons) + +@bp.route('/message/', methods=['GET', 'POST']) +def report_message(userid): + if request.method == "POST": + Report.create( + media_type=REPORT_MEDIA_MESSAGE, + media_id=userid, + sender=get_current_user(), + reason=request.form['reason'], + created_date=datetime.datetime.now() + ) + return redirect(url_for('reports.report_done')) + return render_template('report_message.html', report_reasons=report_reasons) + +@bp.route('/done', methods=['GET', 'POST']) +def report_done(): + return render_template('report_done.html') diff --git a/app/templates/admin_base.html b/app/templates/admin_base.html new file mode 100644 index 0000000..e59e813 --- /dev/null +++ b/app/templates/admin_base.html @@ -0,0 +1,28 @@ + + + + {{ site_name }} + + + + + +
      + {% for message in get_flashed_messages() %} +
      {{ message }}
      + {% endfor %} + {% block body %}{% endblock %} +
      + + + + diff --git a/app/templates/admin_home.html b/app/templates/admin_home.html new file mode 100644 index 0000000..d4075d1 --- /dev/null +++ b/app/templates/admin_home.html @@ -0,0 +1,9 @@ +{% extends "admin_base.html" %} + +{% block body %} + +{% endblock %} diff --git a/app/templates/admin_report_detail.html b/app/templates/admin_report_detail.html new file mode 100644 index 0000000..d445d64 --- /dev/null +++ b/app/templates/admin_report_detail.html @@ -0,0 +1,27 @@ +{% extends "admin_base.html" %} + +{% block body %} +

      Report detail #{{ report.id }}

      +

      Type: {{ [None, 'user', 'message'][report.media_type] }}

      +

      Reason: {{ report_reasons[report.reason] }}

      +

      Status: {{ ['Unreviewed', 'Accepted', 'Declined'][report.status] }}

      + +

      Detail

      + {% if report.media is none %} +

      The media is unavailable.

      + {% elif report.media_type == 1 %} +

      Showing first 20 messages of the reported user.

      +
        + {% for message in report.media.messages %} + {% include "includes/reported_message.html" %} + {% endfor %} +
      + {% elif report.media_type == 2 %} + {% set message = report.media %} + {% include "includes/reported_message.html" %} + {% endif %} +
      + + +
      +{% endblock %} diff --git a/app/templates/admin_reports.html b/app/templates/admin_reports.html new file mode 100644 index 0000000..1d022a8 --- /dev/null +++ b/app/templates/admin_reports.html @@ -0,0 +1,16 @@ +{% extends "admin_base.html" %} + +{% block body %} +
        + {% for report in report_list %} +
      • 0 %}class="done"{% endif %}> +

        #{{ report.id }} + (detail)

        +

        Type: {{ [None, 'user', 'message'][report.media_type] }}

        +

        Reason: {{ report_reasons[report.reason] }}

        +

        Status: {{ ['Unreviewed', 'Accepted', 'Declined'][report.status] }}

        +
      • + {% endfor %} +
      + {% include "includes/pagination.html" %} +{% endblock %} diff --git a/app/templates/includes/message.html b/app/templates/includes/message.html index 59369c9..8095ab2 100644 --- a/app/templates/includes/message.html +++ b/app/templates/includes/message.html @@ -22,6 +22,6 @@
    • Edit or change privacy
    • Delete permanently
    • {% else %} - +
    • Report
    • {% endif %}
    diff --git a/app/templates/includes/reported_message.html b/app/templates/includes/reported_message.html new file mode 100644 index 0000000..aaed6cd --- /dev/null +++ b/app/templates/includes/reported_message.html @@ -0,0 +1,15 @@ +
    +

    Message #{{ message.id }} (detail)

    +

    Author: {{ message.user.username }}

    +

    Text:

    +
    + {{ message.text|enrich }} + {% if message.uploads %} +
    + +
    + {% endif %} +
    +

    Privacy: {{ ['public', 'unlisted', 'friends', 'only me'][message.privacy] }}

    +

    Date: {{ message.pub_date.strftime('%B %-d, %Y %H:%M:%S') }}

    +
    diff --git a/app/templates/report_base.html b/app/templates/report_base.html new file mode 100644 index 0000000..a069cb5 --- /dev/null +++ b/app/templates/report_base.html @@ -0,0 +1,27 @@ + + + + Report – Cori+ + + + + +
    + {% block body %}{% endblock %} +
    +
    + + +
    + + + diff --git a/app/templates/report_done.html b/app/templates/report_done.html new file mode 100644 index 0000000..d696e7c --- /dev/null +++ b/app/templates/report_done.html @@ -0,0 +1,11 @@ +{% extends "report_base.html" %} + +{% block body %} +
    +

    Done

    + +

    Your report has been sent.
    + We'll review the user or message, and, if against our Community + Guidelines, we'll remove it.

    +
    +{% endblock %} diff --git a/app/templates/report_message.html b/app/templates/report_message.html new file mode 100644 index 0000000..c17553e --- /dev/null +++ b/app/templates/report_message.html @@ -0,0 +1,11 @@ +{% extends "report_base.html" %} + +{% block body %} + {% for reason in report_reasons %} +
    +
    +

    {{ reason[1] }}

    +
    +
    + {% endfor %} +{% endblock %} diff --git a/app/templates/report_user.html b/app/templates/report_user.html new file mode 100644 index 0000000..c17553e --- /dev/null +++ b/app/templates/report_user.html @@ -0,0 +1,11 @@ +{% extends "report_base.html" %} + +{% block body %} + {% for reason in report_reasons %} +
    +
    +

    {{ reason[1] }}

    +
    +
    + {% endfor %} +{% endblock %} diff --git a/robots.txt b/robots.txt index 8b13789..2f6dd99 100644 --- a/robots.txt +++ b/robots.txt @@ -1 +1,3 @@ - +User-Agent: * +Disallow: /report/ +Noindex: /admin/ From 621d8cf2c8a912d5bffe277c99bee8a3bacb4e67 Mon Sep 17 00:00:00 2001 From: Mattia Succurro Date: Mon, 18 Nov 2019 19:19:06 +0100 Subject: [PATCH 23/42] Adding message edit support for API --- CHANGELOG.md | 2 +- app/api.py | 59 ++++++++++++++++++++++----------------------------- app/models.py | 2 ++ app/utils.py | 21 +++++++++++++++++- 4 files changed, 48 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c78b15..7ab3cde 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ * Adding `messages_count`, `followers_count` and `following_count` to `profile_info` API endpoint (what I've done to 0.7.1 too). * Adding `create2` API endpoint that accepts media, due to an issue with the `create` endpoint that would make it incompatible. * Adding media URLs to messages in API. -* Added `relationships_follow`, `relationships_unfollow`, `username_availability` and `edit_profile` endpoints to API. +* Added `relationships_follow`, `relationships_unfollow`, `username_availability`, `edit_profile`, `request_edit` and `confirm_edit` endpoints to API. * Added `url` utility to model `Upload`. * Changed default `robots.txt`, adding report and admin-related lines. diff --git a/app/api.py b/app/api.py index 0171f0f..3481ec4 100644 --- a/app/api.py +++ b/app/api.py @@ -4,7 +4,8 @@ from functools import wraps from peewee import IntegrityError from .models import User, Message, Upload, Relationship, database, \ MSGPRV_PUBLIC, MSGPRV_UNLISTED, MSGPRV_FRIENDS, MSGPRV_ONLYME, UPLOAD_DIRECTORY -from .utils import check_access_token, Visibility, push_notification, unpush_notification +from .utils import check_access_token, Visibility, push_notification, unpush_notification, \ + create_mentions, is_username bp = Blueprint('api', __name__, url_prefix='/api/V1') @@ -86,23 +87,8 @@ def create(self): text=text, pub_date=datetime.datetime.now(), privacy=privacy) - # Currently, API does not support files. - # create mentions - mention_usernames = set() - for mo in re.finditer(r'\+([A-Za-z0-9_]+(?:\.[A-Za-z0-9_]+)*)', text): - mention_usernames.add(mo.group(1)) - # to avoid self mention - mention_usernames.difference_update({self.username}) - for u in mention_usernames: - try: - mention_user = User.get(User.username == u) - if privacy in (MSGPRV_PUBLIC, MSGPRV_UNLISTED) or \ - (privacy == MSGPRV_FRIENDS and - mention_user.is_following(self) and - self.is_following(mention_user)): - push_notification('mention', mention_user, user=user.id) - except User.DoesNotExist: - pass + # This API does not support files. Use create2 instead. + create_mentions(self, text) return {} @bp.route('/create2', methods=['POST']) @@ -124,22 +110,7 @@ def create2(self): message=message ) file.save(os.path.join(UPLOAD_DIRECTORY, str(upload.id) + '.' + ext)) - # create mentions - mention_usernames = set() - for mo in re.finditer(r'\+([A-Za-z0-9_]+(?:\.[A-Za-z0-9_]+)*)', text): - mention_usernames.add(mo.group(1)) - # to avoid self mention - mention_usernames.difference_update({self.username}) - for u in mention_usernames: - try: - mention_user = User.get(User.username == u) - if privacy in (MSGPRV_PUBLIC, MSGPRV_UNLISTED) or \ - (privacy == MSGPRV_FRIENDS and - mention_user.is_following(self) and - self.is_following(mention_user)): - push_notification('mention', mention_user, user=user.id) - except User.DoesNotExist: - pass + create_mentions(self, text) return {} def get_relationship_info(self, other): @@ -306,3 +277,23 @@ def edit_profile(user): telegram=data['telegram'] ).where(UserProfile.user == user).execute() return {} + +@bp.route('/request_edit/') +@validate_access +def request_edit(self, id): + message = Message[id] + if message.user != self: + raise ValueError('Attempt to edit a message from another') + return { + 'message_info': get_message_info(message) + } + +@bp.route('/save_edit/', methods=['POST']) +@validate_access +def save_edit(self, id): + message = Message[id] + if message.user != self: + raise ValueError('Attempt to edit a message from another') + data = request.get_json(True) + Message.update(text=data['text'], privacy=data['privacy']).where(Message.id == id).execute() + return {} diff --git a/app/models.py b/app/models.py index 34d04e3..ec37798 100644 --- a/app/models.py +++ b/app/models.py @@ -212,6 +212,8 @@ REPORT_REASON_SELFINJURY = 7 REPORT_REASON_FIREARMS = 8 REPORT_REASON_DRUGS = 9 REPORT_REASON_UNDERAGE = 10 +REPORT_REASON_LEAK = 11 +REPORT_REASON_DMCA = 12 report_reasons = [ (REPORT_REASON_SPAM, "It's spam"), diff --git a/app/utils.py b/app/utils.py index 302cb3a..f459bf1 100644 --- a/app/utils.py +++ b/app/utils.py @@ -3,7 +3,8 @@ A list of utilities used across modules. ''' import datetime, re, base64, hashlib, string, sys, json -from .models import User, Notification +from .models import User, Message, Notification, MSGPRV_PUBLIC, MSGPRV_UNLISTED, \ + MSGPRV_FRIENDS, MSGPRV_ONLYME from flask import abort, render_template, request, session _forbidden_extensions = 'com net org txt'.split() @@ -196,3 +197,21 @@ def check_access_token(token): h.update(str(user.password).encode('utf-8')) if h.hexdigest()[:32] == hh: return user + +def create_mentions(cur_user, text): + # create mentions + mention_usernames = set() + for mo in re.finditer(r'\+([A-Za-z0-9_]+(?:\.[A-Za-z0-9_]+)*)', text): + mention_usernames.add(mo.group(1)) + # to avoid self mention + mention_usernames.difference_update({cur_user.username}) + for u in mention_usernames: + try: + mention_user = User.get(User.username == u) + if privacy in (MSGPRV_PUBLIC, MSGPRV_UNLISTED) or \ + (privacy == MSGPRV_FRIENDS and + mention_user.is_following(cur_user) and + cur_user.is_following(mention_user)): + push_notification('mention', mention_user, user=user.id) + except User.DoesNotExist: + pass From 42552f12be26e581d5d19b3db5f949dca637169b Mon Sep 17 00:00:00 2001 From: Mattia Succurro Date: Wed, 20 Nov 2019 12:30:40 +0100 Subject: [PATCH 24/42] Commenting out some entries on edit_profile endpoint --- app/api.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/app/api.py b/app/api.py index 3481ec4..d99b075 100644 --- a/app/api.py +++ b/app/api.py @@ -261,9 +261,9 @@ def edit_profile(user): full_name = data['full_name'] or username if full_name != user.full_name: User.update(full_name=full_name).where(User.id == user.id).execute() - website = data['website'].strip().replace(' ', '%20') - if website and not validate_website(website): - raise ValueError('You should enter a valid URL.') + #website = data['website'].strip().replace(' ', '%20') + #if website and not validate_website(website): + # raise ValueError('You should enter a valid URL.') #location = int(request.form.get('location')) #if location == 0: # location = None @@ -272,9 +272,9 @@ def edit_profile(user): #year=data['year'] if data.get('has_year') else None, #location=location, website=website, - instagram=data['instagram'], - facebook=data['facebook'], - telegram=data['telegram'] + #instagram=data['instagram'], + #facebook=data['facebook'], + #telegram=data['telegram'] ).where(UserProfile.user == user).execute() return {} From 3e1c3bfebeedd2d1028bc943028e8269a1d49205 Mon Sep 17 00:00:00 2001 From: Mattia Succurro Date: Wed, 20 Nov 2019 12:46:33 +0100 Subject: [PATCH 25/42] Fixing edit_profile endpoint --- app/api.py | 34 +++++++++++++++++++++------------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/app/api.py b/app/api.py index d99b075..234a850 100644 --- a/app/api.py +++ b/app/api.py @@ -2,7 +2,7 @@ from flask import Blueprint, jsonify, request import sys, os, datetime, re from functools import wraps from peewee import IntegrityError -from .models import User, Message, Upload, Relationship, database, \ +from .models import User, UserProfile, Message, Upload, Relationship, database, \ MSGPRV_PUBLIC, MSGPRV_UNLISTED, MSGPRV_FRIENDS, MSGPRV_ONLYME, UPLOAD_DIRECTORY from .utils import check_access_token, Visibility, push_notification, unpush_notification, \ create_mentions, is_username @@ -261,20 +261,28 @@ def edit_profile(user): full_name = data['full_name'] or username if full_name != user.full_name: User.update(full_name=full_name).where(User.id == user.id).execute() - #website = data['website'].strip().replace(' ', '%20') - #if website and not validate_website(website): - # raise ValueError('You should enter a valid URL.') - #location = int(request.form.get('location')) - #if location == 0: - # location = None + kwargs = {} + if 'website' in data: + website = data['website'].strip().replace(' ', '%20') + if website and not validate_website(website): + raise ValueError('You should enter a valid URL.') + kwargs['website'] = website + if 'location' in data: + location = int(request.form.get('location')) + if location == 0: + location = None + kwargs['location'] = location + if 'year' in data: + if data.get('has_year'): + kwargs['year'] = data['year'] + else: + kwargs['year'] = None + if 'instagram' in data: kwargs['instagram'] = data['instagram'] + if 'facebook' in data: kwargs['facebook'] = data['facebook'] + if 'telegram' in data: kwargs['telegram'] = data['telegram'] UserProfile.update( biography=data['biography'], - #year=data['year'] if data.get('has_year') else None, - #location=location, - website=website, - #instagram=data['instagram'], - #facebook=data['facebook'], - #telegram=data['telegram'] + **kwargs ).where(UserProfile.user == user).execute() return {} From d115e80e412c62d34b618537253a9b31cc6ef9f8 Mon Sep 17 00:00:00 2001 From: Mattia Succurro Date: Wed, 20 Nov 2019 12:49:24 +0100 Subject: [PATCH 26/42] Fixing taberror --- app/api.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/api.py b/app/api.py index 234a850..0cb8719 100644 --- a/app/api.py +++ b/app/api.py @@ -273,10 +273,10 @@ def edit_profile(user): location = None kwargs['location'] = location if 'year' in data: - if data.get('has_year'): - kwargs['year'] = data['year'] - else: - kwargs['year'] = None + if data.get('has_year'): + kwargs['year'] = data['year'] + else: + kwargs['year'] = None if 'instagram' in data: kwargs['instagram'] = data['instagram'] if 'facebook' in data: kwargs['facebook'] = data['facebook'] if 'telegram' in data: kwargs['telegram'] = data['telegram'] From d40a8b9b6b818f5985861a7bbaac3d03ffd05d8a Mon Sep 17 00:00:00 2001 From: Mattia Succurro Date: Thu, 21 Nov 2019 20:11:14 +0100 Subject: [PATCH 27/42] Preparing for release --- CHANGELOG.md | 3 ++- app/__init__.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ab3cde..cae4de4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 0.8-dev +## 0.8.0 * Added the admin dashboard, accessible from `/admin/` via basic auth. Only users with admin right can access it. Added endpoints `admin.reports` and `admin.reports_detail`. * Safety is our top priority: added the ability to report someone other's post for everything violating the site's Terms of Service. The current reasons for reporting are: spam, impersonation, pornography, violence, harassment or bullying, hate speech or symbols, self injury, sale or promotion of firearms or drugs, and underage use. @@ -12,6 +12,7 @@ * Added `relationships_follow`, `relationships_unfollow`, `username_availability`, `edit_profile`, `request_edit` and `confirm_edit` endpoints to API. * Added `url` utility to model `Upload`. * Changed default `robots.txt`, adding report and admin-related lines. +* Released official [Android client](https://github.com/sakuragasaki46/coriplusapp/releases/tag/v0.8.0) ## 0.7.1-dev diff --git a/app/__init__.py b/app/__init__.py index 83afa06..835fcbc 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -23,7 +23,7 @@ import datetime, time, re, os, sys, string, json, html from functools import wraps from flask_login import LoginManager -__version__ = '0.8-dev' +__version__ = '0.8.0' # we want to support Python 3 only. # Python 2 has too many caveats. From 29cf1532f750b7984b68af73583454f1f9fca0df Mon Sep 17 00:00:00 2001 From: Mattia Succurro Date: Fri, 22 Nov 2019 18:20:32 +0100 Subject: [PATCH 28/42] Adding explore endpoint and fixing bugs --- CHANGELOG.md | 10 ++++++- app/__init__.py | 6 +++- app/api.py | 71 ++++++++++++++++++++++++++++++++++++++---------- app/utils.py | 2 +- app/website.py | 17 +----------- favicon.ico | Bin 0 -> 1150 bytes 6 files changed, 73 insertions(+), 33 deletions(-) create mode 100644 favicon.ico diff --git a/CHANGELOG.md b/CHANGELOG.md index cae4de4..2e8a425 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 0.9-dev + +* Added `create_account` endpoint to API. This endpoint does not require an access token. +* Added `has_more` field to feed endpoints (`feed`, `explore` and `profile_feed`). +* Added `/favicon.ico`. +* Added `explore` endpoint. +* Fixed some bugs when creating mentions and using offsets in feeds. + ## 0.8.0 * Added the admin dashboard, accessible from `/admin/` via basic auth. Only users with admin right can access it. Added endpoints `admin.reports` and `admin.reports_detail`. @@ -12,7 +20,7 @@ * Added `relationships_follow`, `relationships_unfollow`, `username_availability`, `edit_profile`, `request_edit` and `confirm_edit` endpoints to API. * Added `url` utility to model `Upload`. * Changed default `robots.txt`, adding report and admin-related lines. -* Released official [Android client](https://github.com/sakuragasaki46/coriplusapp/releases/tag/v0.8.0) +* Released official [Android client](https://github.com/sakuragasaki46/coriplusapp/releases/tag/v0.8.0). ## 0.7.1-dev diff --git a/app/__init__.py b/app/__init__.py index 835fcbc..b8b5c06 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -23,7 +23,7 @@ import datetime, time, re, os, sys, string, json, html from functools import wraps from flask_login import LoginManager -__version__ = '0.8.0' +__version__ = '0.9-dev' # we want to support Python 3 only. # Python 2 has too many caveats. @@ -69,6 +69,10 @@ def _inject_user(userid): @app.errorhandler(404) def error_404(body): return render_template('404.html'), 404 + +@app.route('/favicon.ico') +def favicon_ico(): + return send_from_directory(os.getcwd(), 'favicon.ico') @app.route('/robots.txt') def robots_txt(): diff --git a/app/api.py b/app/api.py index 0cb8719..c74efa3 100644 --- a/app/api.py +++ b/app/api.py @@ -1,11 +1,11 @@ from flask import Blueprint, jsonify, request -import sys, os, datetime, re +import sys, os, datetime, re, uuid from functools import wraps from peewee import IntegrityError from .models import User, UserProfile, Message, Upload, Relationship, database, \ MSGPRV_PUBLIC, MSGPRV_UNLISTED, MSGPRV_FRIENDS, MSGPRV_ONLYME, UPLOAD_DIRECTORY from .utils import check_access_token, Visibility, push_notification, unpush_notification, \ - create_mentions, is_username + create_mentions, is_username, generate_access_token, pwdhash bp = Blueprint('api', __name__, url_prefix='/api/V1') @@ -64,17 +64,33 @@ def feed(self): if date is None: date = datetime.datetime.now() else: - date = datetime.datetime.fromtimestamp(date) + date = datetime.datetime.fromtimestamp(float(date)) query = Visibility(Message .select() .where(((Message.user << self.following()) | (Message.user == self)) & (Message.pub_date < date)) - .order_by(Message.pub_date.desc()) - .limit(20)) - for message in query: + .order_by(Message.pub_date.desc())) + for message in query.paginate(1): timeline_media.append(get_message_info(message)) - return {'timeline_media': timeline_media} + return {'timeline_media': timeline_media, 'has_more': query.count() > len(timeline_media)} + +@bp.route('/explore') +@validate_access +def explore(self): + timeline_media = [] + date = request.args.get('offset') + if date is None: + date = datetime.datetime.now() + else: + date = datetime.datetime.fromtimestamp(float(date)) + query = Visibility(Message + .select() + .where(Message.pub_date < date) + .order_by(Message.pub_date.desc()), True) + for message in query.paginate(1): + timeline_media.append(get_message_info(message)) + return {'timeline_media': timeline_media, 'has_more': query.count() > len(timeline_media)} @bp.route('/create', methods=['POST']) @validate_access @@ -88,7 +104,7 @@ def create(self): pub_date=datetime.datetime.now(), privacy=privacy) # This API does not support files. Use create2 instead. - create_mentions(self, text) + create_mentions(self, text, privacy) return {} @bp.route('/create2', methods=['POST']) @@ -110,7 +126,7 @@ def create2(self): message=message ) file.save(os.path.join(UPLOAD_DIRECTORY, str(upload.id) + '.' + ext)) - create_mentions(self, text) + create_mentions(self, text, privacy) return {} def get_relationship_info(self, other): @@ -169,16 +185,15 @@ def profile_feed(self, userid): if date is None: date = datetime.datetime.now() else: - date = datetime.datetime.fromtimestamp(date) + date = datetime.datetime.fromtimestamp(float(date)) query = Visibility(Message .select() .where((Message.user == user) & (Message.pub_date < date)) - .order_by(Message.pub_date.desc()) - .limit(20)) - for message in query: + .order_by(Message.pub_date.desc())) + for message in query.paginate(1): timeline_media.append(get_message_info(message)) - return {'timeline_media': timeline_media} + return {'timeline_media': timeline_media, 'has_more': query.count() > len(timeline_media)} @bp.route('/relationships//follow', methods=['POST']) @validate_access @@ -305,3 +320,31 @@ def save_edit(self, id): data = request.get_json(True) Message.update(text=data['text'], privacy=data['privacy']).where(Message.id == id).execute() return {} + +# no validate access for this endpoint! +@bp.route('/create_account', methods=['POST']) +def create_account(): + try: + data = request.get_json(True) + try: + birthday = datetime.datetime.fromisoformat(data['birthday']) + except ValueError: + raise ValueError('invalid date format') + username = data['username'].lower() + if not is_username(username): + raise ValueError('invalid username') + with database.atomic(): + user = User.create( + username=username, + full_name=data.get('full_name') or username, + password=pwdhash(data['password']), + email=data['email'], + birthday=birthday, + join_date=datetime.datetime.now()) + UserProfile.create( + user=user + ) + + return jsonify({'access_token': generate_access_token(user), 'status': 'ok'}) + except Exception as e: + return jsonify({'message': str(e), 'status': 'fail'}) diff --git a/app/utils.py b/app/utils.py index f459bf1..e39e476 100644 --- a/app/utils.py +++ b/app/utils.py @@ -198,7 +198,7 @@ def check_access_token(token): if h.hexdigest()[:32] == hh: return user -def create_mentions(cur_user, text): +def create_mentions(cur_user, text, privacy): # create mentions mention_usernames = set() for mo in re.finditer(r'\+([A-Za-z0-9_]+(?:\.[A-Za-z0-9_]+)*)', text): diff --git a/app/website.py b/app/website.py index 9d7d13d..f5867e3 100644 --- a/app/website.py +++ b/app/website.py @@ -188,22 +188,7 @@ def create(): message=message ) file.save(UPLOAD_DIRECTORY + str(upload.id) + '.' + ext) - # create mentions - mention_usernames = set() - for mo in re.finditer(r'\+([A-Za-z0-9_]+(?:\.[A-Za-z0-9_]+)*)', text): - mention_usernames.add(mo.group(1)) - # to avoid self mention - mention_usernames.difference_update({user.username}) - for u in mention_usernames: - try: - mention_user = User.get(User.username == u) - if privacy in (MSGPRV_PUBLIC, MSGPRV_UNLISTED) or \ - (privacy == MSGPRV_FRIENDS and - mention_user.is_following(user) and - user.is_following(mention_user)): - push_notification('mention', mention_user, user=user.id) - except User.DoesNotExist: - pass + create_mentions(user, text, privacy) flash('Your message has been posted successfully') return redirect(url_for('website.user_detail', username=user.username)) return render_template('create.html') diff --git a/favicon.ico b/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..89a340743bced95ba012163e32d0c525a1eed6f9 GIT binary patch literal 1150 zcmbu7Q4WJJ3`0F>2jFYp``ppE{{^~)=&4>QLX4o|#MnuJN{_gfMR9*?eM&D$pIAW! zQ+&Wdv?kSR5h2=E<{r+Y9}&H4liSyQ=2?$3Z=bn;2fSeIrR&%mJ+2~s6TO$$o{t)T zmbuqxk2v^kaNMr?@qu^zc-fPq=Q(SpooQT4J|ex6|0kH6Ue00V^)KiTjykX1KkJcP b2D%xRqJN{~dfun{_j81Q Date: Mon, 25 Nov 2019 09:39:33 +0100 Subject: [PATCH 29/42] Adding notifications and +1's to messages --- CHANGELOG.md | 5 +- app/ajax.py | 30 +++++++++- app/api.py | 87 ++++++++++++++++++++++++++++- app/models.py | 26 ++++++++- app/static/lib.js | 18 ++++++ app/templates/includes/message.html | 10 +++- 6 files changed, 168 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e8a425..4e027d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,13 @@ ## 0.9-dev +* Added positive feedback mechanism: now you can +1 a message. So, `score_message_add` and `score_message_remove` API endpoints were added, and `MessageUpvote` table was created. +* Added notifications support for API. * Added `create_account` endpoint to API. This endpoint does not require an access token. +* Added `explore`, `notifications_count`, `notifications` and `notifications_seen` endpoints. * Added `has_more` field to feed endpoints (`feed`, `explore` and `profile_feed`). +* Added `join_date` field into `user` object of `profile_info` endpoint, for more profile transparency. * Added `/favicon.ico`. -* Added `explore` endpoint. * Fixed some bugs when creating mentions and using offsets in feeds. ## 0.8.0 diff --git a/app/ajax.py b/app/ajax.py index 63dc532..d2c0be3 100644 --- a/app/ajax.py +++ b/app/ajax.py @@ -5,8 +5,9 @@ Warning: this is not the public API. ''' from flask import Blueprint, jsonify -from .models import User +from .models import User, Message, MessageUpvote from .utils import locations, get_current_user, is_username +import datetime bp = Blueprint('ajax', __name__, url_prefix='/ajax') @@ -35,3 +36,30 @@ def location_search(name): if value.lower().startswith(name.lower()): results.append({'value': key, 'display': value}) return jsonify({'results': results}) + +@bp.route('/score//toggle', methods=['POST']) +def score_toggle(id): + user = get_current_user() + message = Message[id] + upvoted_by_self = (MessageUpvote + .select() + .where((MessageUpvote.message == message) & (MessageUpvote.user == user)) + .exists()) + if upvoted_by_self: + (MessageUpvote + .delete() + .where( + (MessageUpvote.message == message) & + (MessageUpvote.user == user)) + .execute() + ) + else: + MessageUpvote.create( + message=message, + user=user, + created_date=datetime.datetime.now() + ) + return jsonify({ + "score": message.score, + "status": "ok" + }) diff --git a/app/api.py b/app/api.py index c74efa3..6ee0fd3 100644 --- a/app/api.py +++ b/app/api.py @@ -2,7 +2,8 @@ from flask import Blueprint, jsonify, request import sys, os, datetime, re, uuid from functools import wraps from peewee import IntegrityError -from .models import User, UserProfile, Message, Upload, Relationship, database, \ +from .models import User, UserProfile, Message, Upload, Relationship, Notification, \ + MessageUpvote, database, \ MSGPRV_PUBLIC, MSGPRV_UNLISTED, MSGPRV_FRIENDS, MSGPRV_ONLYME, UPLOAD_DIRECTORY from .utils import check_access_token, Visibility, push_notification, unpush_notification, \ create_mentions, is_username, generate_access_token, pwdhash @@ -25,7 +26,9 @@ def get_message_info(message): 'text': message.text, 'privacy': message.privacy, 'pub_date': message.pub_date.timestamp(), - 'media': media + 'media': media, + 'score': len(message.upvotes), + 'upvoted_by_self': message.upvoted_by_self(), } def validate_access(func): @@ -162,6 +165,7 @@ def profile_info(self, userid): "generation": profile.year, "instagram": profile.instagram, "facebook": profile.facebook, + "join_date": user.join_date.timestamp(), "relationships": get_relationship_info(self, user), "messages_count": len(user.messages), "followers_count": len(user.followers()), @@ -348,3 +352,82 @@ def create_account(): return jsonify({'access_token': generate_access_token(user), 'status': 'ok'}) except Exception as e: return jsonify({'message': str(e), 'status': 'fail'}) + +def get_notification_info(notification): + obj = { + "id": notification.id, + "type": notification.type, + "timestamp": notification.pub_date.timestamp(), + "seen": notification.seen + } + obj.update(json.loads(notification.detail)) + return obj + +@bp.route('/notifications/count') +@validate_access +def notifications_count(self): + count = len(Notification + .select() + .where((Notification.target == self) & (Notification.seen == 0))) + return { + 'count': count + } + +@bp.route('/notifications') +@validate_access +def notifications(self): + items = [] + query = (Notification + .select() + .where(Notification.target == self) + .order_by(Notification.pub_date.desc()) + .limit(100)) + unseen_count = len(Notification + .select() + .where((Notification.target == self) & (Notification.seen == 0))) + for notification in query: + items.append(get_notification_info(query)) + return { + "notifications": { + "items": items, + "unseen_count": unseen_count + } + } + +@bp.route('/notifications/seen', methods=['POST']) +@validate_access +def notifications_seen(self): + data = request.get_json(True) + (Notification + .update(seen=1) + .where((Notification.target == self) & (Notification.pub_date < data['offset'])) + .execute()) + return {} + +@bp.route('/score/message//add', methods=['POST']) +@validate_access +def score_message_add(self, id): + message = Message[id] + MessageUpvote.create( + message=message, + user=self, + created_date=datetime.datetime.now() + ) + return { + 'score': len(message.upvotes) + } + +@bp.route('/score/message//remove', methods=['POST']) +@validate_access +def score_message_remove(self, id): + message = Message[id] + (MessageUpvote + .delete() + .where( + (MessageUpvote.message == message) & + (MessageUpvote.user == self)) + .execute() + ) + return { + 'score': len(message.upvotes) + } diff --git a/app/models.py b/app/models.py index ec37798..4d2cc8c 100644 --- a/app/models.py +++ b/app/models.py @@ -162,6 +162,17 @@ class Message(BaseModel): return user.is_following(cur_user) and cur_user.is_following(user) else: return False + @property + def score(self): + return self.upvotes.count() + def upvoted_by_self(self): + from .utils import get_current_user + user = get_current_user() + return (MessageUpvote + .select() + .where((MessageUpvote.message == self) & (MessageUpvote.user == user)) + .exists() + ) # this model contains two foreign keys to user -- it essentially allows us to # model a "many-to-many" relationship between users. by querying and joining @@ -226,6 +237,8 @@ report_reasons = [ (REPORT_REASON_FIREARMS, "Sale or promotion of firearms"), (REPORT_REASON_DRUGS, "Sale or promotion of drugs"), (REPORT_REASON_UNDERAGE, "This user is less than 13 years old"), + (REPORT_REASON_LEAK, "Leak of sensitive information"), + (REPORT_REASON_DMCA, "Copyright violation") ] REPORT_STATUS_DELIVERED = 0 @@ -251,10 +264,21 @@ class Report(BaseModel): except DoesNotExist: return +# New in 0.9. +class MessageUpvote(BaseModel): + message = ForeignKeyField(Message, backref='upvotes') + user = ForeignKeyField(User) + created_date = DateTimeField() + + class Meta: + indexes = ( + (('message', 'user'), True), + ) + def create_tables(): with database: database.create_tables([ User, UserAdminship, UserProfile, Message, Relationship, - Upload, Notification, Report]) + Upload, Notification, Report, MessageUpvote]) if not os.path.isdir(UPLOAD_DIRECTORY): os.makedirs(UPLOAD_DIRECTORY) diff --git a/app/static/lib.js b/app/static/lib.js index 538218a..cc78bea 100644 --- a/app/static/lib.js +++ b/app/static/lib.js @@ -98,3 +98,21 @@ function showHideMessageOptions(id){ options.style.display = 'block'; } } + +function toggleUpvote(id){ + var msgElem = document.getElementById(id); + var upvoteLink = msgElem.getElementsByClassName('message-upvote')[0]; + var scoreCounter = msgElem.getElementsByClassName('message-score')[0]; + var xhr = new XMLHttpRequest(); + xhr.open("POST", "/ajax/score/" + id + "/toggle", true); + xhr.onreadystatechange = function(){ + if(xhr.readyState == XMLHttpRequest.DONE){ + if(xhr.status == 200){ + console.log('liked #' + id); + var data = JSON.parse(xhr.responseText); + scoreCounter.innerHTML = data.score; + } + } + }; + xhr.send(); +} diff --git a/app/templates/includes/message.html b/app/templates/includes/message.html index 8095ab2..aa10503 100644 --- a/app/templates/includes/message.html +++ b/app/templates/includes/message.html @@ -5,12 +5,16 @@ {% endif %}

    {{ site_name }}

    @@ -36,7 +36,8 @@ diff --git a/app/templates/explore.html b/app/templates/explore.html index ed3aab2..65d1846 100644 --- a/app/templates/explore.html +++ b/app/templates/explore.html @@ -1,7 +1,7 @@ {% extends "base.html" %} {% block body %}

    Explore

    -
      +
        {% for message in message_list %}
      • {% include "includes/message.html" %}
      • {% endfor %} diff --git a/app/templates/feed.html b/app/templates/feed.html index 4a57123..88b05ac 100644 --- a/app/templates/feed.html +++ b/app/templates/feed.html @@ -1,7 +1,7 @@ {% extends "base.html" %} {% block body %}

        Your Timeline

        -
          +
            {% for message in message_list %}
          • {% include "includes/message.html" %}
          • {% endfor %} diff --git a/app/templates/includes/infobox_profile.html b/app/templates/includes/infobox_profile.html index f20df41..2cec0b7 100644 --- a/app/templates/includes/infobox_profile.html +++ b/app/templates/includes/infobox_profile.html @@ -30,6 +30,6 @@ {{ user.following()|count }} following

            {% if user == current_user %} -

            Edit profile

            +

            {{ inline_svg('edit', 18) }} Edit profile

            {% endif %} diff --git a/app/templates/user_detail.html b/app/templates/user_detail.html index 5a5c42f..9b5e7a5 100644 --- a/app/templates/user_detail.html +++ b/app/templates/user_detail.html @@ -18,7 +18,7 @@ Create a message {% endif %} {% endif %} -
              +
                {% for message in message_list %}
              • {% include "includes/message.html" %}
              • {% endfor %} diff --git a/app/utils.py b/app/utils.py index e39e476..dfd2518 100644 --- a/app/utils.py +++ b/app/utils.py @@ -5,7 +5,7 @@ A list of utilities used across modules. import datetime, re, base64, hashlib, string, sys, json from .models import User, Message, Notification, MSGPRV_PUBLIC, MSGPRV_UNLISTED, \ MSGPRV_FRIENDS, MSGPRV_ONLYME -from flask import abort, render_template, request, session +from flask import Markup, abort, render_template, request, session _forbidden_extensions = 'com net org txt'.split() _username_characters = frozenset(string.ascii_letters + string.digits + '_') @@ -82,7 +82,7 @@ class Visibility(object): def get_locations(): data = {} - with open('locations.txt') as f: + with open('locations.txt', encoding='utf-8') as f: for line in f: line = line.rstrip() if line.startswith('#'): @@ -215,3 +215,14 @@ def create_mentions(cur_user, text, privacy): push_notification('mention', mention_user, user=user.id) except User.DoesNotExist: pass + +# New in 0.9 +def inline_svg(name, width=None): + try: + with open('icons/' + name + '-24px.svg') as f: + data = f.read() + if isinstance(width, int): + data = re.sub(r'( (?:height|width)=")\d+(")', lambda x:x.group(1) + str(width) + x.group(2), data) + return Markup(data) + except OSError: + return '' diff --git a/icons/edit-24px.svg b/icons/edit-24px.svg new file mode 100644 index 0000000..a6f23ff --- /dev/null +++ b/icons/edit-24px.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/icons/exit_to_app-24px.svg b/icons/exit_to_app-24px.svg new file mode 100644 index 0000000..2f0decb --- /dev/null +++ b/icons/exit_to_app-24px.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/icons/explore-24px.svg b/icons/explore-24px.svg new file mode 100644 index 0000000..9e72b8b --- /dev/null +++ b/icons/explore-24px.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/icons/notifications-24px.svg b/icons/notifications-24px.svg new file mode 100644 index 0000000..6d5dfe6 --- /dev/null +++ b/icons/notifications-24px.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/icons/person-24px.svg b/icons/person-24px.svg new file mode 100644 index 0000000..58b25d9 --- /dev/null +++ b/icons/person-24px.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/icons/person_add-24px.svg b/icons/person_add-24px.svg new file mode 100644 index 0000000..40736bb --- /dev/null +++ b/icons/person_add-24px.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/icons/shuffle-24px.svg b/icons/shuffle-24px.svg new file mode 100644 index 0000000..a3efe19 --- /dev/null +++ b/icons/shuffle-24px.svg @@ -0,0 +1 @@ + \ No newline at end of file From 5ba9f1d7d5c382c06ddf9e0f88e7edb2df0c5204 Mon Sep 17 00:00:00 2001 From: Mattia Succurro Date: Sun, 16 Jun 2024 11:22:54 +0200 Subject: [PATCH 31/42] CSS changes --- app/models.py | 2 +- app/static/style.css | 1 + app/templates/base.html | 12 ++++++------ 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/app/models.py b/app/models.py index 865513b..4cdb2b5 100644 --- a/app/models.py +++ b/app/models.py @@ -156,7 +156,7 @@ class Message(BaseModel): # even if unlisted return not is_public_timeline elif privacy == MSGPRV_FRIENDS: - if cur_user.is_anonymous: + if not cur_user or cur_user.is_anonymous: return False return user.is_following(cur_user) and cur_user.is_following(user) else: diff --git a/app/static/style.css b/app/static/style.css index aaef997..87152a8 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -13,6 +13,7 @@ a:hover{text-decoration:underline} .content a svg{fill:#3399ff} .content a.plus{color:#ff3018} .metanav{float:right} +.metanav-divider{width:1px;background:white;display:inline-block} .header h1{margin:0;display:inline-block} .flash{background-color:#ff9;border:yellow 1px solid} .infobox{padding:12px;border:#ccc 1px solid} diff --git a/app/templates/base.html b/app/templates/base.html index dae5889..b2f79bc 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -12,18 +12,18 @@

                {{ site_name }}

                From 71619dba2b8168781b616aae18584a858e41b585 Mon Sep 17 00:00:00 2001 From: Mattia Succurro Date: Sun, 16 Jun 2024 11:31:56 +0200 Subject: [PATCH 32/42] Fix imports --- .gitignore | 9 +++++++++ app/filters.py | 2 +- app/utils.py | 3 ++- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index c9522f1..d320dae 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,12 @@ uploads/ **~ **/.*.swp **/__pycache__/ +venv +.env +.venv +env +data/ +conf/ +config/ +\#*\# +.\#* diff --git a/app/filters.py b/app/filters.py index cbb9dbb..04d929a 100644 --- a/app/filters.py +++ b/app/filters.py @@ -2,7 +2,7 @@ Filter functions used in the website templates. ''' -from flask import Markup +from markupsafe import Markup import html, datetime, re, time from .utils import tokenize, inline_svg as _inline_svg from . import app diff --git a/app/utils.py b/app/utils.py index dfd2518..5259b80 100644 --- a/app/utils.py +++ b/app/utils.py @@ -5,7 +5,8 @@ A list of utilities used across modules. import datetime, re, base64, hashlib, string, sys, json from .models import User, Message, Notification, MSGPRV_PUBLIC, MSGPRV_UNLISTED, \ MSGPRV_FRIENDS, MSGPRV_ONLYME -from flask import Markup, abort, render_template, request, session +from flask import abort, render_template, request, session +from markupsafe import Markup _forbidden_extensions = 'com net org txt'.split() _username_characters = frozenset(string.ascii_letters + string.digits + '_') From b874b989bfad7242884cf8ab3dc3262adce0132f Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Thu, 6 Nov 2025 05:48:17 +0100 Subject: [PATCH 33/42] 0.9.0 --- app/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/__init__.py b/app/__init__.py index 3b8ab12..0ef203e 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -23,7 +23,7 @@ import datetime, time, re, os, sys, string, json, html from functools import wraps from flask_login import LoginManager -__version__ = '0.9-dev' +__version__ = '0.9.0' # we want to support Python 3 only. # Python 2 has too many caveats. From 71f7bd1a3b501014eab6a08b24037a02e9d9ecdc Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Thu, 6 Nov 2025 06:33:31 +0100 Subject: [PATCH 34/42] refactor code layout, move config to .env, add pyproject.toml --- .gitignore | 7 +++- CHANGELOG.md | 9 ++++- config.py | 3 -- pyproject.toml | 21 ++++++++++ run_example.py | 16 -------- {app => src/coriplus}/__init__.py | 37 +++++++++++------- {app => src/coriplus}/__main__.py | 0 {app => src/coriplus}/admin.py | 0 {app => src/coriplus}/ajax.py | 0 {app => src/coriplus}/api.py | 7 +++- {app => src/coriplus}/filters.py | 0 {app => src/coriplus}/models.py | 0 {app => src/coriplus}/reports.py | 0 {app => src/coriplus}/static/lib.js | 0 {app => src/coriplus}/static/style.css | 0 {app => src/coriplus}/templates/404.html | 0 {app => src/coriplus}/templates/about.html | 0 .../coriplus}/templates/admin_base.html | 0 .../coriplus}/templates/admin_home.html | 0 .../templates/admin_report_detail.html | 0 .../coriplus}/templates/admin_reports.html | 0 {app => src/coriplus}/templates/base.html | 0 .../coriplus}/templates/change_password.html | 0 .../coriplus}/templates/confirm_delete.html | 0 {app => src/coriplus}/templates/create.html | 0 {app => src/coriplus}/templates/edit.html | 0 .../coriplus}/templates/edit_profile.html | 0 {app => src/coriplus}/templates/explore.html | 0 {app => src/coriplus}/templates/feed.html | 0 {app => src/coriplus}/templates/homepage.html | 0 .../templates/includes/infobox_profile.html | 0 .../templates/includes/location_selector.html | 0 .../coriplus}/templates/includes/message.html | 0 .../templates/includes/notification.html | 0 .../templates/includes/pagination.html | 0 .../templates/includes/reported_message.html | 0 {app => src/coriplus}/templates/join.html | 0 {app => src/coriplus}/templates/login.html | 0 .../coriplus}/templates/notifications.html | 0 {app => src/coriplus}/templates/privacy.html | 0 .../coriplus}/templates/report_base.html | 0 .../coriplus}/templates/report_done.html | 0 .../coriplus}/templates/report_message.html | 0 .../coriplus}/templates/report_user.html | 0 {app => src/coriplus}/templates/terms.html | 0 .../coriplus}/templates/user_detail.html | 0 .../coriplus}/templates/user_list.html | 0 {app => src/coriplus}/utils.py | 0 {app => src/coriplus}/website.py | 5 ++- favicon.ico => src/favicon.ico | Bin robots.txt => src/robots.txt | 0 51 files changed, 64 insertions(+), 41 deletions(-) delete mode 100644 config.py create mode 100644 pyproject.toml delete mode 100644 run_example.py rename {app => src/coriplus}/__init__.py (77%) rename {app => src/coriplus}/__main__.py (100%) rename {app => src/coriplus}/admin.py (100%) rename {app => src/coriplus}/ajax.py (100%) rename {app => src/coriplus}/api.py (99%) rename {app => src/coriplus}/filters.py (100%) rename {app => src/coriplus}/models.py (100%) rename {app => src/coriplus}/reports.py (100%) rename {app => src/coriplus}/static/lib.js (100%) rename {app => src/coriplus}/static/style.css (100%) rename {app => src/coriplus}/templates/404.html (100%) rename {app => src/coriplus}/templates/about.html (100%) rename {app => src/coriplus}/templates/admin_base.html (100%) rename {app => src/coriplus}/templates/admin_home.html (100%) rename {app => src/coriplus}/templates/admin_report_detail.html (100%) rename {app => src/coriplus}/templates/admin_reports.html (100%) rename {app => src/coriplus}/templates/base.html (100%) rename {app => src/coriplus}/templates/change_password.html (100%) rename {app => src/coriplus}/templates/confirm_delete.html (100%) rename {app => src/coriplus}/templates/create.html (100%) rename {app => src/coriplus}/templates/edit.html (100%) rename {app => src/coriplus}/templates/edit_profile.html (100%) rename {app => src/coriplus}/templates/explore.html (100%) rename {app => src/coriplus}/templates/feed.html (100%) rename {app => src/coriplus}/templates/homepage.html (100%) rename {app => src/coriplus}/templates/includes/infobox_profile.html (100%) rename {app => src/coriplus}/templates/includes/location_selector.html (100%) rename {app => src/coriplus}/templates/includes/message.html (100%) rename {app => src/coriplus}/templates/includes/notification.html (100%) rename {app => src/coriplus}/templates/includes/pagination.html (100%) rename {app => src/coriplus}/templates/includes/reported_message.html (100%) rename {app => src/coriplus}/templates/join.html (100%) rename {app => src/coriplus}/templates/login.html (100%) rename {app => src/coriplus}/templates/notifications.html (100%) rename {app => src/coriplus}/templates/privacy.html (100%) rename {app => src/coriplus}/templates/report_base.html (100%) rename {app => src/coriplus}/templates/report_done.html (100%) rename {app => src/coriplus}/templates/report_message.html (100%) rename {app => src/coriplus}/templates/report_user.html (100%) rename {app => src/coriplus}/templates/terms.html (100%) rename {app => src/coriplus}/templates/user_detail.html (100%) rename {app => src/coriplus}/templates/user_list.html (100%) rename {app => src/coriplus}/utils.py (100%) rename {app => src/coriplus}/website.py (99%) rename favicon.ico => src/favicon.ico (100%) rename robots.txt => src/robots.txt (100%) diff --git a/.gitignore b/.gitignore index d320dae..546be68 100644 --- a/.gitignore +++ b/.gitignore @@ -4,8 +4,8 @@ __pycache__/ uploads/ *.pyc **~ -**/.*.swp -**/__pycache__/ +.*.swp +__pycache__/ venv .env .venv @@ -15,3 +15,6 @@ conf/ config/ \#*\# .\#* +node_modules/ +alembic.ini +**.egg-info \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 105a0ad..ff728bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,11 @@ # Changelog -## 0.9-dev +## 0.10.0 ++ Codebase refactor (with breaking changes!) ++ Move ALL config to .env (config.py is NO MORE supported) ++ Config SITE_NAME replaced with APP_NAME + +## 0.9.0 * Website redesign: added some material icons, implemented via a `inline_svg` function, injected by default in templates and defined in `utils.py`. * Added positive feedback mechanism: now you can +1 a message. So, `score_message_add` and `score_message_remove` API endpoints were added, and `MessageUpvote` table was created. @@ -26,7 +31,7 @@ * Changed default `robots.txt`, adding report and admin-related lines. * Released official [Android client](https://github.com/sakuragasaki46/coriplusapp/releases/tag/v0.8.0). -## 0.7.1-dev +## 0.7.1 * Adding `messages_count`, `followers_count` and `following_count` to `profile_info` API endpoint (forgot to release). diff --git a/config.py b/config.py deleted file mode 100644 index a6e2b64..0000000 --- a/config.py +++ /dev/null @@ -1,3 +0,0 @@ -DEBUG = True -SECRET_KEY = 'hin6bab8ge25*r=x&+5$0kn=-#log$pt^#@vrqjld!^2ci@g*b' -SITE_NAME = 'Cori+' diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..f4d5b53 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,21 @@ +[project] +name = "sakuragasaki46_coriplus" +authors = [ + { name = "Sakuragasaki46" } +] +dynamic = ["version"] +dependencies = [ + "Python-Dotenv>=1.0.0", + "Flask", + "Flask-Login", + "Peewee" +] +requires-python = ">=3.10" +classifiers = [ + "Private :: X" +] + +[tool.setuptools.dynamic] +version = { attr = "coriplus.__version__" } + + diff --git a/run_example.py b/run_example.py deleted file mode 100644 index 4ab76a9..0000000 --- a/run_example.py +++ /dev/null @@ -1,16 +0,0 @@ -#!/usr/bin/env python - -import sys -sys.path.insert(0, '../..') - -import argparse -parser = argparse.ArgumentParser() -parser.add_argument('-p', '--port', type=int, default=5000, - help='An alternative port where to run the server.') - -from app import app, create_tables - -if __name__ == '__main__': - args = parser.parse_args() - create_tables() - app.run(port=args.port) diff --git a/app/__init__.py b/src/coriplus/__init__.py similarity index 77% rename from app/__init__.py rename to src/coriplus/__init__.py index 0ef203e..4f01715 100644 --- a/app/__init__.py +++ b/src/coriplus/__init__.py @@ -16,24 +16,31 @@ For other, see `app.utils`. ''' from flask import ( - Flask, abort, flash, g, jsonify, redirect, render_template, request, - send_from_directory, session, url_for, __version__ as flask_version) -import hashlib -import datetime, time, re, os, sys, string, json, html -from functools import wraps + Flask, g, jsonify, render_template, request, + send_from_directory, __version__ as flask_version) +import os, sys from flask_login import LoginManager +import dotenv +import logging -__version__ = '0.9.0' +__version__ = '0.10.0-dev44' -# we want to support Python 3 only. +# we want to support Python 3.10+ only. # Python 2 has too many caveats. -if sys.version_info[0] < 3: - raise RuntimeError('Python 3 required') +# Python <=3.9 has harder type support. +if sys.version_info[0:2] < (3, 10): + raise RuntimeError('Python 3.10+ required') -os.chdir(os.path.dirname(os.path.dirname(__file__))) +BASEDIR = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) +os.chdir(BASEDIR) + +dotenv.load_dotenv() + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) app = Flask(__name__) -app.config.from_pyfile('../config.py') +app.secret_key = os.environ['SECRET_KEY'] login_manager = LoginManager(app) @@ -53,7 +60,7 @@ def before_request(): try: g.db.connect() except OperationalError: - sys.stderr.write('database connected twice.\n') + logger.error('database connected twice.\n') @app.after_request def after_request(response): @@ -63,7 +70,7 @@ def after_request(response): @app.context_processor def _inject_variables(): return { - 'site_name': app.config['SITE_NAME'], + 'site_name': os.environ.get('APP_NAME', 'Cori+'), 'locations': locations, 'inline_svg': inline_svg } @@ -78,11 +85,11 @@ def error_404(body): @app.route('/favicon.ico') def favicon_ico(): - return send_from_directory(os.getcwd(), 'favicon.ico') + return send_from_directory(BASEDIR, 'src/favicon.ico') @app.route('/robots.txt') def robots_txt(): - return send_from_directory(os.getcwd(), 'robots.txt') + return send_from_directory(BASEDIR, 'src/robots.txt') @app.route('/uploads/.') def uploads(id, type='jpg'): diff --git a/app/__main__.py b/src/coriplus/__main__.py similarity index 100% rename from app/__main__.py rename to src/coriplus/__main__.py diff --git a/app/admin.py b/src/coriplus/admin.py similarity index 100% rename from app/admin.py rename to src/coriplus/admin.py diff --git a/app/ajax.py b/src/coriplus/ajax.py similarity index 100% rename from app/ajax.py rename to src/coriplus/ajax.py diff --git a/app/api.py b/src/coriplus/api.py similarity index 99% rename from app/api.py rename to src/coriplus/api.py index 6ee0fd3..5f58c2c 100644 --- a/app/api.py +++ b/src/coriplus/api.py @@ -7,6 +7,9 @@ from .models import User, UserProfile, Message, Upload, Relationship, Notificati MSGPRV_PUBLIC, MSGPRV_UNLISTED, MSGPRV_FRIENDS, MSGPRV_ONLYME, UPLOAD_DIRECTORY from .utils import check_access_token, Visibility, push_notification, unpush_notification, \ create_mentions, is_username, generate_access_token, pwdhash +import logging + +logger = logging.getLogger(__name__) bp = Blueprint('api', __name__, url_prefix='/api/V1') @@ -16,7 +19,7 @@ def get_message_info(message): except IndexError: media = None if media: - print(media) + logger.debug(media) return { 'id': message.id, 'user': { @@ -122,7 +125,7 @@ def create2(self): privacy=privacy) file = request.files.get('file') if file: - print('Uploading', file.filename) + logger.info('Uploading', file.filename) ext = file.filename.split('.')[-1] upload = Upload.create( type=ext, diff --git a/app/filters.py b/src/coriplus/filters.py similarity index 100% rename from app/filters.py rename to src/coriplus/filters.py diff --git a/app/models.py b/src/coriplus/models.py similarity index 100% rename from app/models.py rename to src/coriplus/models.py diff --git a/app/reports.py b/src/coriplus/reports.py similarity index 100% rename from app/reports.py rename to src/coriplus/reports.py diff --git a/app/static/lib.js b/src/coriplus/static/lib.js similarity index 100% rename from app/static/lib.js rename to src/coriplus/static/lib.js diff --git a/app/static/style.css b/src/coriplus/static/style.css similarity index 100% rename from app/static/style.css rename to src/coriplus/static/style.css diff --git a/app/templates/404.html b/src/coriplus/templates/404.html similarity index 100% rename from app/templates/404.html rename to src/coriplus/templates/404.html diff --git a/app/templates/about.html b/src/coriplus/templates/about.html similarity index 100% rename from app/templates/about.html rename to src/coriplus/templates/about.html diff --git a/app/templates/admin_base.html b/src/coriplus/templates/admin_base.html similarity index 100% rename from app/templates/admin_base.html rename to src/coriplus/templates/admin_base.html diff --git a/app/templates/admin_home.html b/src/coriplus/templates/admin_home.html similarity index 100% rename from app/templates/admin_home.html rename to src/coriplus/templates/admin_home.html diff --git a/app/templates/admin_report_detail.html b/src/coriplus/templates/admin_report_detail.html similarity index 100% rename from app/templates/admin_report_detail.html rename to src/coriplus/templates/admin_report_detail.html diff --git a/app/templates/admin_reports.html b/src/coriplus/templates/admin_reports.html similarity index 100% rename from app/templates/admin_reports.html rename to src/coriplus/templates/admin_reports.html diff --git a/app/templates/base.html b/src/coriplus/templates/base.html similarity index 100% rename from app/templates/base.html rename to src/coriplus/templates/base.html diff --git a/app/templates/change_password.html b/src/coriplus/templates/change_password.html similarity index 100% rename from app/templates/change_password.html rename to src/coriplus/templates/change_password.html diff --git a/app/templates/confirm_delete.html b/src/coriplus/templates/confirm_delete.html similarity index 100% rename from app/templates/confirm_delete.html rename to src/coriplus/templates/confirm_delete.html diff --git a/app/templates/create.html b/src/coriplus/templates/create.html similarity index 100% rename from app/templates/create.html rename to src/coriplus/templates/create.html diff --git a/app/templates/edit.html b/src/coriplus/templates/edit.html similarity index 100% rename from app/templates/edit.html rename to src/coriplus/templates/edit.html diff --git a/app/templates/edit_profile.html b/src/coriplus/templates/edit_profile.html similarity index 100% rename from app/templates/edit_profile.html rename to src/coriplus/templates/edit_profile.html diff --git a/app/templates/explore.html b/src/coriplus/templates/explore.html similarity index 100% rename from app/templates/explore.html rename to src/coriplus/templates/explore.html diff --git a/app/templates/feed.html b/src/coriplus/templates/feed.html similarity index 100% rename from app/templates/feed.html rename to src/coriplus/templates/feed.html diff --git a/app/templates/homepage.html b/src/coriplus/templates/homepage.html similarity index 100% rename from app/templates/homepage.html rename to src/coriplus/templates/homepage.html diff --git a/app/templates/includes/infobox_profile.html b/src/coriplus/templates/includes/infobox_profile.html similarity index 100% rename from app/templates/includes/infobox_profile.html rename to src/coriplus/templates/includes/infobox_profile.html diff --git a/app/templates/includes/location_selector.html b/src/coriplus/templates/includes/location_selector.html similarity index 100% rename from app/templates/includes/location_selector.html rename to src/coriplus/templates/includes/location_selector.html diff --git a/app/templates/includes/message.html b/src/coriplus/templates/includes/message.html similarity index 100% rename from app/templates/includes/message.html rename to src/coriplus/templates/includes/message.html diff --git a/app/templates/includes/notification.html b/src/coriplus/templates/includes/notification.html similarity index 100% rename from app/templates/includes/notification.html rename to src/coriplus/templates/includes/notification.html diff --git a/app/templates/includes/pagination.html b/src/coriplus/templates/includes/pagination.html similarity index 100% rename from app/templates/includes/pagination.html rename to src/coriplus/templates/includes/pagination.html diff --git a/app/templates/includes/reported_message.html b/src/coriplus/templates/includes/reported_message.html similarity index 100% rename from app/templates/includes/reported_message.html rename to src/coriplus/templates/includes/reported_message.html diff --git a/app/templates/join.html b/src/coriplus/templates/join.html similarity index 100% rename from app/templates/join.html rename to src/coriplus/templates/join.html diff --git a/app/templates/login.html b/src/coriplus/templates/login.html similarity index 100% rename from app/templates/login.html rename to src/coriplus/templates/login.html diff --git a/app/templates/notifications.html b/src/coriplus/templates/notifications.html similarity index 100% rename from app/templates/notifications.html rename to src/coriplus/templates/notifications.html diff --git a/app/templates/privacy.html b/src/coriplus/templates/privacy.html similarity index 100% rename from app/templates/privacy.html rename to src/coriplus/templates/privacy.html diff --git a/app/templates/report_base.html b/src/coriplus/templates/report_base.html similarity index 100% rename from app/templates/report_base.html rename to src/coriplus/templates/report_base.html diff --git a/app/templates/report_done.html b/src/coriplus/templates/report_done.html similarity index 100% rename from app/templates/report_done.html rename to src/coriplus/templates/report_done.html diff --git a/app/templates/report_message.html b/src/coriplus/templates/report_message.html similarity index 100% rename from app/templates/report_message.html rename to src/coriplus/templates/report_message.html diff --git a/app/templates/report_user.html b/src/coriplus/templates/report_user.html similarity index 100% rename from app/templates/report_user.html rename to src/coriplus/templates/report_user.html diff --git a/app/templates/terms.html b/src/coriplus/templates/terms.html similarity index 100% rename from app/templates/terms.html rename to src/coriplus/templates/terms.html diff --git a/app/templates/user_detail.html b/src/coriplus/templates/user_detail.html similarity index 100% rename from app/templates/user_detail.html rename to src/coriplus/templates/user_detail.html diff --git a/app/templates/user_list.html b/src/coriplus/templates/user_list.html similarity index 100% rename from app/templates/user_list.html rename to src/coriplus/templates/user_list.html diff --git a/app/utils.py b/src/coriplus/utils.py similarity index 100% rename from app/utils.py rename to src/coriplus/utils.py diff --git a/app/website.py b/src/coriplus/website.py similarity index 99% rename from app/website.py rename to src/coriplus/website.py index f5867e3..df43b86 100644 --- a/app/website.py +++ b/src/coriplus/website.py @@ -9,6 +9,9 @@ from sys import version as python_version from flask import Blueprint, abort, flash, redirect, render_template, request, url_for, __version__ as flask_version from flask_login import login_required, login_user, logout_user import json +import logging + +logger = logging.getLogger(__name__) bp = Blueprint('website', __name__) @@ -181,7 +184,7 @@ def create(): privacy=privacy) file = request.files.get('file') if file: - print('Uploading', file.filename) + logger.info('Uploading', file.filename) ext = file.filename.split('.')[-1] upload = Upload.create( type=ext, diff --git a/favicon.ico b/src/favicon.ico similarity index 100% rename from favicon.ico rename to src/favicon.ico diff --git a/robots.txt b/src/robots.txt similarity index 100% rename from robots.txt rename to src/robots.txt From be24a37f5cd911c4e10e7aa4bcf27ecbe519982f Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Thu, 6 Nov 2025 06:36:02 +0100 Subject: [PATCH 35/42] move old migrations away from project root --- migrate_0_4_to_0_5.py => src/old_migrations/migrate_0_4_to_0_5.py | 0 migrate_0_6_to_0_7.py => src/old_migrations/migrate_0_6_to_0_7.py | 0 migrate_0_7_to_0_8.py => src/old_migrations/migrate_0_7_to_0_8.py | 0 3 files changed, 0 insertions(+), 0 deletions(-) rename migrate_0_4_to_0_5.py => src/old_migrations/migrate_0_4_to_0_5.py (100%) rename migrate_0_6_to_0_7.py => src/old_migrations/migrate_0_6_to_0_7.py (100%) rename migrate_0_7_to_0_8.py => src/old_migrations/migrate_0_7_to_0_8.py (100%) diff --git a/migrate_0_4_to_0_5.py b/src/old_migrations/migrate_0_4_to_0_5.py similarity index 100% rename from migrate_0_4_to_0_5.py rename to src/old_migrations/migrate_0_4_to_0_5.py diff --git a/migrate_0_6_to_0_7.py b/src/old_migrations/migrate_0_6_to_0_7.py similarity index 100% rename from migrate_0_6_to_0_7.py rename to src/old_migrations/migrate_0_6_to_0_7.py diff --git a/migrate_0_7_to_0_8.py b/src/old_migrations/migrate_0_7_to_0_8.py similarity index 100% rename from migrate_0_7_to_0_8.py rename to src/old_migrations/migrate_0_7_to_0_8.py From c46dce5e3bef094dc726483232ee02049cf6edd9 Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Thu, 6 Nov 2025 07:25:07 +0100 Subject: [PATCH 36/42] add CSRF token --- CHANGELOG.md | 1 + genmig.sh | 6 ++++++ pyproject.toml | 4 +++- src/coriplus/__init__.py | 8 +++++++- src/coriplus/models.py | 3 ++- src/coriplus/templates/about.html | 9 ++++++--- src/coriplus/templates/admin_report_detail.html | 1 + src/coriplus/templates/base.html | 2 +- src/coriplus/templates/change_password.html | 1 + src/coriplus/templates/confirm_delete.html | 9 ++++----- src/coriplus/templates/create.html | 1 + src/coriplus/templates/edit.html | 1 + src/coriplus/templates/edit_profile.html | 1 + src/coriplus/templates/login.html | 1 + src/coriplus/templates/report_message.html | 1 + src/coriplus/templates/report_user.html | 1 + 16 files changed, 38 insertions(+), 12 deletions(-) create mode 100755 genmig.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index ff728bc..24a2232 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ + Codebase refactor (with breaking changes!) + Move ALL config to .env (config.py is NO MORE supported) + Config SITE_NAME replaced with APP_NAME ++ Add CSRF token and flask_WTF ## 0.9.0 diff --git a/genmig.sh b/genmig.sh new file mode 100755 index 0000000..bc624ce --- /dev/null +++ b/genmig.sh @@ -0,0 +1,6 @@ +#!/usr/bin/bash +# GENERATE MIGRATIONS + +source venv/bin/activate && \ +source .env && \ +pw_migrate create --auto --auto-source=coriplus.models --directory=src/migrations --database="$DATABASE_URL" "$@" \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index f4d5b53..72b1800 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,9 @@ dependencies = [ "Python-Dotenv>=1.0.0", "Flask", "Flask-Login", - "Peewee" + "Peewee", + "Flask-WTF", + "peewee-migrate" ] requires-python = ">=3.10" classifiers = [ diff --git a/src/coriplus/__init__.py b/src/coriplus/__init__.py index 4f01715..d829da9 100644 --- a/src/coriplus/__init__.py +++ b/src/coriplus/__init__.py @@ -20,6 +20,7 @@ from flask import ( send_from_directory, __version__ as flask_version) import os, sys from flask_login import LoginManager +from flask_wtf import CSRFProtect import dotenv import logging @@ -44,6 +45,8 @@ app.secret_key = os.environ['SECRET_KEY'] login_manager = LoginManager(app) +CSRFProtect(app) + from .models import * from .utils import * @@ -64,7 +67,10 @@ def before_request(): @app.after_request def after_request(response): - g.db.close() + try: + g.db.close() + except Exception: + logger.error('database closed twice') return response @app.context_processor diff --git a/src/coriplus/models.py b/src/coriplus/models.py index 4cdb2b5..0c9c68e 100644 --- a/src/coriplus/models.py +++ b/src/coriplus/models.py @@ -13,11 +13,12 @@ The tables are: from flask import request from peewee import * +from playhouse.db_url import connect import os # here should go `from .utils import get_current_user`, but it will cause # import errors. It's instead imported at function level. -database = SqliteDatabase('coriplus.sqlite') +database = connect(os.environ['DATABASE_URL']) class BaseModel(Model): class Meta: diff --git a/src/coriplus/templates/about.html b/src/coriplus/templates/about.html index e5691a8..b337caa 100644 --- a/src/coriplus/templates/about.html +++ b/src/coriplus/templates/about.html @@ -3,9 +3,12 @@ {% block body %}

                About {{ site_name }}

                -

                {{ site_name }} {{ version }} – Python {{ python_version }} – - Flask {{ flask_version }}

                -

                Copyright © 2019 Sakuragasaki46.

                +
                  +
                • {{ site_name }} {{ version }}
                • +
                • Python {{ python_version }}
                • +
                • Flask {{ flask_version }}
                • +
                +

                Copyright © 2019, 2025 Sakuragasaki46.

                License

                Permission is hereby granted, free of charge, to any person obtaining diff --git a/src/coriplus/templates/admin_report_detail.html b/src/coriplus/templates/admin_report_detail.html index d445d64..8f5d2c6 100644 --- a/src/coriplus/templates/admin_report_detail.html +++ b/src/coriplus/templates/admin_report_detail.html @@ -21,6 +21,7 @@ {% include "includes/reported_message.html" %} {% endif %}

                +
                diff --git a/src/coriplus/templates/base.html b/src/coriplus/templates/base.html index b2f79bc..adba498 100644 --- a/src/coriplus/templates/base.html +++ b/src/coriplus/templates/base.html @@ -34,7 +34,7 @@ {% block body %}{% endblock %} {% endblock %} diff --git a/src/coriplus/templates/edit.html b/src/coriplus/templates/edit.html index 5a8f907..316dc7e 100644 --- a/src/coriplus/templates/edit.html +++ b/src/coriplus/templates/edit.html @@ -1,5 +1,6 @@ {% extends "base.html" %} {% block body %} +

                Edit

                @@ -15,4 +16,5 @@
    + {% endblock %} diff --git a/src/coriplus/templates/edit_profile.html b/src/coriplus/templates/edit_profile.html index 319a9c2..66ecd28 100644 --- a/src/coriplus/templates/edit_profile.html +++ b/src/coriplus/templates/edit_profile.html @@ -1,6 +1,7 @@ {% extends "base.html" %} {% block body %} +

    Edit Profile

    @@ -33,4 +34,5 @@
    + {% endblock %} diff --git a/src/coriplus/templates/explore.html b/src/coriplus/templates/explore.html index 65d1846..e86a8f2 100644 --- a/src/coriplus/templates/explore.html +++ b/src/coriplus/templates/explore.html @@ -1,9 +1,10 @@ {% extends "base.html" %} +{% from "macros/message.html" import feed_message with context %} {% block body %}

    Explore

      {% for message in message_list %} -
    • {% include "includes/message.html" %}
    • + {{ feed_message(message) }} {% endfor %}
    {% include "includes/pagination.html" %} diff --git a/src/coriplus/templates/feed.html b/src/coriplus/templates/feed.html index 88b05ac..beb6607 100644 --- a/src/coriplus/templates/feed.html +++ b/src/coriplus/templates/feed.html @@ -1,9 +1,10 @@ {% extends "base.html" %} +{% from "macros/message.html" import feed_message with context %} {% block body %}

    Your Timeline

      {% for message in message_list %} -
    • {% include "includes/message.html" %}
    • + {{ feed_message(message) }} {% endfor %}
    {% include "includes/pagination.html" %} diff --git a/src/coriplus/templates/homepage.html b/src/coriplus/templates/homepage.html index 106bf9a..fdad92f 100644 --- a/src/coriplus/templates/homepage.html +++ b/src/coriplus/templates/homepage.html @@ -1,7 +1,9 @@ {% extends "base.html" %} {% block body %} +

    Hello

    {{ site_name }} is made by people like you.
    Log in or register to see more.

    +
    {% endblock %} diff --git a/src/coriplus/templates/join.html b/src/coriplus/templates/join.html index 607387f..a45b511 100644 --- a/src/coriplus/templates/join.html +++ b/src/coriplus/templates/join.html @@ -1,5 +1,6 @@ {% extends "base.html" %} {% block body %} +

    Join {{ site_name }}

    @@ -32,4 +33,5 @@
    +
    {% endblock %} diff --git a/src/coriplus/templates/login.html b/src/coriplus/templates/login.html index dee8219..21a5e93 100644 --- a/src/coriplus/templates/login.html +++ b/src/coriplus/templates/login.html @@ -1,9 +1,10 @@ {% extends "base.html" %} {% block body %}

    Login

    - {% if error %}

    Error: {{ error }}{% endif %} + {% if error %}

    Error: {{ error }}

    {% endif %} +
    - +
    Username or email:
    @@ -19,4 +20,5 @@
    +
    {% endblock %} diff --git a/src/coriplus/templates/macros/message.html b/src/coriplus/templates/macros/message.html new file mode 100644 index 0000000..b4ef93a --- /dev/null +++ b/src/coriplus/templates/macros/message.html @@ -0,0 +1,35 @@ +{% macro feed_message(message) %} +
  • +

    {{ message.text|enrich }}

    +{% if message.uploads %} +
    + +
    +{% endif %} + + +
  • +{% endmacro %} \ No newline at end of file diff --git a/src/coriplus/templates/notifications.html b/src/coriplus/templates/notifications.html index 04b61d1..33ee7bb 100644 --- a/src/coriplus/templates/notifications.html +++ b/src/coriplus/templates/notifications.html @@ -3,7 +3,7 @@

    Notifications

      {% for notification in notification_list %} -
    • {% include "includes/notification.html" %}
    • +
    • {% include "includes/notification.html" %}
    • {% endfor %}
    {% include "includes/pagination.html" %} diff --git a/src/coriplus/templates/privacy.html b/src/coriplus/templates/privacy.html index a7b8570..df4248a 100644 --- a/src/coriplus/templates/privacy.html +++ b/src/coriplus/templates/privacy.html @@ -1,47 +1,54 @@ {% extends "base.html" %} {% block body %} -

    Privacy Policy

    +
    +

    Privacy Policy

    -

    At {{ site_name }}, accessible from {{ request.host }}, one of our main priorities is the privacy of our visitors. This Privacy Policy document contains types of information that is collected and recorded by {{ site_name }} and how we use it.

    +

    At {{ site_name }}, accessible from {{ request.host }}, one of our main priorities is the privacy of our visitors. This Privacy Policy document contains types of information that is collected and recorded by {{ site_name }} and how we use it.

    -

    If you have additional questions or require more information about our Privacy Policy, do not hesitate to contact us through email at sakuragasaki46@gmail.com

    +

    If you have additional questions or require more information about our Privacy Policy, do not hesitate to contact us through email at sakuragasaki46@gmail.com

    -

    Log Files

    +

    Log Files

    -

    {{ site_name }} follows a standard procedure of using log files. These files log visitors when they visit websites. All hosting companies do this and a part of hosting services' analytics. The information collected by log files include internet protocol (IP) addresses, browser type, Internet Service Provider (ISP), date and time stamp, referring/exit pages, and possibly the number of clicks. These are not linked to any information that is personally identifiable. The purpose of the information is for analyzing trends, administering the site, tracking users' movement on the website, and gathering demographic information.

    +

    {{ site_name }} follows a standard procedure of using log files. These files log visitors when they visit websites. All hosting companies do this and a part of hosting services' analytics. The information collected by log files include internet protocol (IP) addresses, browser type, Internet Service Provider (ISP), date and time stamp, referring/exit pages, and possibly the number of clicks. These are not linked to any information that is personally identifiable. The purpose of the information is for analyzing trends, administering the site, tracking users' movement on the website, and gathering demographic information.

    -

    Cookies and Web Beacons

    +

    Cookies and Web Beacons

    -

    Like any other website, {{ site_name }} uses 'cookies'. These cookies are used to store information including visitors' preferences, and the pages on the website that the visitor accessed or visited. The information is used to optimize the users' experience by customizing our web page content based on visitors' browser type and/or other information.

    +

    Like any other website, {{ site_name }} uses 'cookies'. These cookies are used to store information including visitors' preferences, and the pages on the website that the visitor accessed or visited. The information is used to optimize the users' experience by customizing our web page content based on visitors' browser type and/or other information.

    +

    You can choose to disable cookies through your individual browser options. This, however, can and will hurt Your usage of {{ site_name }}

    + +

    Privacy Policies

    +

    You may consult this list to find the Privacy Policy for each of the advertising partners of {{ site_name }}. Our Privacy Policy was created with the help of the Privacy Policy Generator and the Generate Privacy Policy Generator.

    -

    Privacy Policies

    +

    Third-party ad servers or ad networks uses technologies like cookies, JavaScript, or Web Beacons that are used in their respective advertisements and links that appear on {{ site_name }}, which are sent directly to users' browser. They automatically receive your IP address when this occurs. These technologies are used to measure the effectiveness of their advertising campaigns and/or to personalize the advertising content that you see on websites that you visit.

    -

    You may consult this list to find the Privacy Policy for each of the advertising partners of {{ site_name }}. Our Privacy Policy was created with the help of the Privacy Policy Generator and the Generate Privacy Policy Generator.

    +

    Note that {{ site_name }} has no access to or control over these cookies that are used by third-party advertisers.

    -

    Third-party ad servers or ad networks uses technologies like cookies, JavaScript, or Web Beacons that are used in their respective advertisements and links that appear on {{ site_name }}, which are sent directly to users' browser. They automatically receive your IP address when this occurs. These technologies are used to measure the effectiveness of their advertising campaigns and/or to personalize the advertising content that you see on websites that you visit.

    +

    Third Party Privacy Policies

    -

    Note that {{ site_name }} has no access to or control over these cookies that are used by third-party advertisers.

    +

    {{ site_name }}'s Privacy Policy does not apply to other advertisers or websites. Thus, we are advising you to consult the respective Privacy Policies of these third-party ad servers for more detailed information. It may include their practices and instructions about how to opt-out of certain options. You may find a complete list of these Privacy Policies and their links here: Privacy Policy Links.

    -

    Third Party Privacy Policies

    +

    Legal Basis

    -

    {{ site_name }}'s Privacy Policy does not apply to other advertisers or websites. Thus, we are advising you to consult the respective Privacy Policies of these third-party ad servers for more detailed information. It may include their practices and instructions about how to opt-out of certain options. You may find a complete list of these Privacy Policies and their links here: Privacy Policy Links.

    +

    Legal Basis for treatment is Legitimate Interest, except:

    +
      +
    • Transactional information, such as username, email and essential cookies, are treated according to Providing a Service.
    • +
    -

    You can choose to disable cookies through your individual browser options. To know more detailed information about cookie management with specific web browsers, it can be found at the browsers' respective websites. What Are Cookies?

    +

    Children's Information

    -

    Children's Information

    +

    Another part of our priority is adding protection for children while using the internet. We encourage parents and guardians to observe, participate in, monitor, guide and/or exercise total control on their online activity.

    -

    Another part of our priority is adding protection for children while using the internet. We encourage parents and guardians to observe, participate in, and/or monitor and guide their online activity.

    +

    {{ site_name }} does not knowingly collect any Personal Identifiable Information from children under the age of 13. If you think that your child provided this kind of information on our website, we strongly encourage you to contact us immediately and we will do our best efforts to promptly remove such information from our records.

    -

    {{ site_name }} does not knowingly collect any Personal Identifiable Information from children under the age of 13. If you think that your child provided this kind of information on our website, we strongly encourage you to contact us immediately and we will do our best efforts to promptly remove such information from our records.

    +

    Online Privacy Policy Only

    -

    Online Privacy Policy Only

    +

    This Privacy Policy applies only to our online activities and is valid for visitors to our website with regards to the information that they shared and/or collect in {{ site_name }}. This policy is not applicable to any information collected via channels other than this website.

    -

    This Privacy Policy applies only to our online activities and is valid for visitors to our website with regards to the information that they shared and/or collect in {{ site_name }}. This policy is not applicable to any information collected offline or via channels other than this website.

    +

    Consent

    -

    Consent

    - -

    By using our website, you hereby consent to our Privacy Policy and agree to its Terms and Conditions.

    +

    By using our website, you hereby consent irrevocably to our Privacy Policy and agree to its Terms and Conditions.

    +
    {% endblock %} diff --git a/src/coriplus/templates/terms.html b/src/coriplus/templates/terms.html index 203e44e..ab8ca7f 100644 --- a/src/coriplus/templates/terms.html +++ b/src/coriplus/templates/terms.html @@ -1,7 +1,9 @@ {% extends "base.html" %} {% block body %} +

    Terms of Service

    - +

    [decline to state]

    +
    {% endblock %} diff --git a/src/coriplus/templates/user_detail.html b/src/coriplus/templates/user_detail.html index 9b5e7a5..6324f1e 100644 --- a/src/coriplus/templates/user_detail.html +++ b/src/coriplus/templates/user_detail.html @@ -1,15 +1,18 @@ {% extends "base.html" %} +{% from "macros/message.html" import feed_message with context %} {% block body %} {% include "includes/infobox_profile.html" %}

    Messages from {{ user.username }}

    {% if not current_user.is_anonymous %} {% if user.username != current_user.username %} {% if current_user|is_following(user) %} -
    + +
    {% else %} -
    + +
    {% endif %} @@ -20,7 +23,7 @@ {% endif %}
      {% for message in message_list %} -
    • {% include "includes/message.html" %}
    • + {{ feed_message(message) }} {% endfor %}
    {% include "includes/pagination.html" %} diff --git a/src/coriplus/utils.py b/src/coriplus/utils.py index 5259b80..7a98d5b 100644 --- a/src/coriplus/utils.py +++ b/src/coriplus/utils.py @@ -218,12 +218,6 @@ def create_mentions(cur_user, text, privacy): pass # New in 0.9 -def inline_svg(name, width=None): - try: - with open('icons/' + name + '-24px.svg') as f: - data = f.read() - if isinstance(width, int): - data = re.sub(r'( (?:height|width)=")\d+(")', lambda x:x.group(1) + str(width) + x.group(2), data) - return Markup(data) - except OSError: - return '' +# changed in 0.10 +def inline_svg(name): + return Markup('{}').format(name) From 9071f5ff7a9f3a7a84a38bedaaf431032c87e55f Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Wed, 12 Nov 2025 10:34:57 +0100 Subject: [PATCH 39/42] change credential access for /admin/, style changes, fix and deprecate get_current_user() --- src/coriplus/__init__.py | 2 +- src/coriplus/admin.py | 7 +++---- src/coriplus/static/style.css | 11 +++++++---- .../templates/includes/infobox_profile.html | 16 ++-------------- src/coriplus/utils.py | 9 +++++---- src/coriplus/website.py | 9 ++++++--- 6 files changed, 24 insertions(+), 30 deletions(-) diff --git a/src/coriplus/__init__.py b/src/coriplus/__init__.py index 98b4d85..9543c60 100644 --- a/src/coriplus/__init__.py +++ b/src/coriplus/__init__.py @@ -24,7 +24,7 @@ from flask_wtf import CSRFProtect import dotenv import logging -__version__ = '0.10.0-dev44' +__version__ = '0.10.0-dev45' # we want to support Python 3.10+ only. # Python 2 has too many caveats. diff --git a/src/coriplus/admin.py b/src/coriplus/admin.py index a78cf1b..d8c8fb0 100644 --- a/src/coriplus/admin.py +++ b/src/coriplus/admin.py @@ -13,17 +13,16 @@ from functools import wraps bp = Blueprint('admin', __name__, url_prefix='/admin') -def _check_auth(username, password) -> bool: +def _check_auth(username) -> bool: try: - return User.select().where((User.username == username) & (User.password == pwdhash(password)) & (User.is_admin) - ).exists() + return User.get((User.username == username)).is_admin except User.DoesNotExist: return False def admin_required(f): @wraps(f) def wrapped_view(**kwargs): - if not _check_auth(current_user.username, current_user.password): + if not _check_auth(current_user.username): abort(403) return f(**kwargs) return wrapped_view diff --git a/src/coriplus/static/style.css b/src/coriplus/static/style.css index c238285..cb05c09 100644 --- a/src/coriplus/static/style.css +++ b/src/coriplus/static/style.css @@ -2,6 +2,9 @@ --accent: #f0372e; --link: #3399ff; } +* { + box-sizing: border-box; +} body, button, input, select, textarea { font-family: Inter, Roboto, sans-serif; line-height: 1.6; @@ -38,13 +41,13 @@ a:hover{text-decoration:underline} #site-name {text-align: center;flex: 1} .header h1{margin:0;display:inline-block} .flash{background-color:#ff9;border:yellow 1px solid} -.infobox{padding:12px;border:#ccc 1px solid} -@media (min-width:640px) { - .infobox{float:right;width:320px} +.infobox{width: 50%; float: right;} +@media (max-width:639px) { + .infobox{width: 100%;} } .weak{opacity:.5} .field_desc{display:block} -ul.timeline{padding:0;margin:auto;max-width:960px} +ul.timeline{padding:0;margin:auto;max-width:960px;clear: both} ul.timeline > li{list-style:none;} .message-visual img{max-width:100%;margin:auto} .message-options-showhide::before{content:'\2026'} diff --git a/src/coriplus/templates/includes/infobox_profile.html b/src/coriplus/templates/includes/infobox_profile.html index 2cec0b7..d1b1494 100644 --- a/src/coriplus/templates/includes/infobox_profile.html +++ b/src/coriplus/templates/includes/infobox_profile.html @@ -1,27 +1,15 @@ {% set profile = user.profile %} -
    +

    {{ profile.full_name }}

    {{ profile.biography|enrich }}

    {% if profile.location %}

    Location: {{ profile.location|locationdata }}

    {% endif %} - {% if profile.year %} -

    Year: {{ profile.year }}

    - {% endif %} {% if profile.website %} {% set website = profile.website %} {% set website = website if website.startswith(('http://', 'https://')) else 'http://' + website %}

    Website: {{ profile.website|urlize }}

    {% endif %} - {% if profile.instagram %} -

    Instagram: {{ profile.instagram }}

    - {% endif %} - {% if profile.facebook %} -

    Facebook: {{ profile.facebook }}

    - {% endif %} - {% if profile.telegram %} -

    Telegram: {{ profile.telegram }}

    - {% endif %}

    {{ user.messages|count }} messages - @@ -30,6 +18,6 @@ {{ user.following()|count }} following

    {% if user == current_user %} -

    {{ inline_svg('edit', 18) }} Edit profile

    +

    {{ inline_svg('edit') }} Edit profile

    {% endif %}
    diff --git a/src/coriplus/utils.py b/src/coriplus/utils.py index 7a98d5b..1db7414 100644 --- a/src/coriplus/utils.py +++ b/src/coriplus/utils.py @@ -3,6 +3,8 @@ A list of utilities used across modules. ''' import datetime, re, base64, hashlib, string, sys, json + +from flask_login import current_user from .models import User, Message, Notification, MSGPRV_PUBLIC, MSGPRV_UNLISTED, \ MSGPRV_FRIENDS, MSGPRV_ONLYME from flask import abort, render_template, request, session @@ -102,15 +104,14 @@ except OSError: # get the user from the session # changed in 0.5 to comply with flask_login +# DEPRECATED in 0.10; use current_user instead def get_current_user(): # new in 0.7; need a different method to get current user id if request.path.startswith('/api/'): # assume token validation is already done return User[request.args['access_token'].split(':')[0]] - else: - user_id = session.get('user_id') - if user_id: - return User[user_id] + elif current_user.is_authenticated: + return current_user def push_notification(type, target, **kwargs): try: diff --git a/src/coriplus/website.py b/src/coriplus/website.py index df43b86..9612c66 100644 --- a/src/coriplus/website.py +++ b/src/coriplus/website.py @@ -7,7 +7,7 @@ from .models import * from . import __version__ as app_version from sys import version as python_version from flask import Blueprint, abort, flash, redirect, render_template, request, url_for, __version__ as flask_version -from flask_login import login_required, login_user, logout_user +from flask_login import current_user, login_required, login_user, logout_user import json import logging @@ -17,7 +17,7 @@ bp = Blueprint('website', __name__) @bp.route('/') def homepage(): - if get_current_user(): + if current_user and current_user.is_authenticated: return private_timeline() else: return render_template('homepage.html') @@ -26,7 +26,7 @@ def private_timeline(): # the private timeline (aka feed) exemplifies the use of a subquery -- we are asking for # messages where the person who created the message is someone the current # user is following. these messages are then ordered newest-first. - user = get_current_user() + user = current_user messages = Visibility(Message .select() .where((Message.user << user.following()) @@ -83,6 +83,9 @@ def register(): @bp.route('/login/', methods=['GET', 'POST']) def login(): + if current_user and current_user.is_authenticated: + flash('You are already logged in') + return redirect(request.args.get('next', '/')) if request.method == 'POST' and request.form['username']: try: username = request.form['username'] From 536e49d1b9314604e2846d0740a377c3979c3512 Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Wed, 12 Nov 2025 11:02:53 +0100 Subject: [PATCH 40/42] schema changes --- genmig.sh | 3 +- src/coriplus/models.py | 19 +++-- .../templates/includes/infobox_profile.html | 15 ++-- src/coriplus/website.py | 7 +- .../002_move_columns_from_userprofile.py | 81 +++++++++++++++++++ 5 files changed, 104 insertions(+), 21 deletions(-) create mode 100644 src/migrations/002_move_columns_from_userprofile.py diff --git a/genmig.sh b/genmig.sh index a29c9fb..c141519 100755 --- a/genmig.sh +++ b/genmig.sh @@ -6,4 +6,5 @@ source .env && \ case "$1" in ("+") pw_migrate create --auto --auto-source=coriplus.models --directory=src/migrations --database="$DATABASE_URL" "${@:2}" ;; ("@") pw_migrate migrate --directory=src/migrations --database="$DATABASE_URL" "${@:2}" ;; -esac \ No newline at end of file + (\\) pw_migrate rollback --directory=src/migrations --database="$DATABASE_URL" "${@:2}" ;; +esac diff --git a/src/coriplus/models.py b/src/coriplus/models.py index 5a2ae50..f47fa65 100644 --- a/src/coriplus/models.py +++ b/src/coriplus/models.py @@ -29,19 +29,25 @@ class BaseModel(Model): # A user. The user is separated from its page. class User(BaseModel): # The unique username. - username = CharField(unique=True) + username = CharField(30, unique=True) # The user's full name (here for better search since 0.8) - full_name = TextField() + full_name = CharField(80) # The password hash. - password = CharField() + password = CharField(256) # An email address. - email = CharField() + email = CharField(256) # The date of birth (required because of Terms of Service) birthday = DateField() # The date joined join_date = DateTimeField() # A disabled flag. 0 = active, 1 = disabled by user, 2 = banned is_disabled = IntegerField(default=0) + # Short description of user. + biography = CharField(256, default='') + # Personal website. + website = TextField(null=True) + + # Helpers for flask_login def get_id(self): @@ -110,15 +116,12 @@ class UserAdminship(BaseModel): # User profile. # Additional info for identifying users. # New in 0.6 +# Deprecated in 0.10 and merged with User class UserProfile(BaseModel): user = ForeignKeyField(User, primary_key=True) biography = TextField(default='') location = IntegerField(null=True) - year = IntegerField(null=True) website = TextField(null=True) - instagram = TextField(null=True) - facebook = TextField(null=True) - telegram = TextField(null=True) @property def full_name(self): ''' diff --git a/src/coriplus/templates/includes/infobox_profile.html b/src/coriplus/templates/includes/infobox_profile.html index d1b1494..bc08a20 100644 --- a/src/coriplus/templates/includes/infobox_profile.html +++ b/src/coriplus/templates/includes/infobox_profile.html @@ -1,14 +1,11 @@ -{% set profile = user.profile %} +
    -

    {{ profile.full_name }}

    -

    {{ profile.biography|enrich }}

    - {% if profile.location %} -

    Location: {{ profile.location|locationdata }}

    - {% endif %} - {% if profile.website %} - {% set website = profile.website %} +

    {{ user.full_name }}

    +

    {{ user.biography|enrich }}

    + {% if user.website %} + {% set website = user.website %} {% set website = website if website.startswith(('http://', 'https://')) else 'http://' + website %} -

    Website: {{ profile.website|urlize }}

    +

    Website: {{ website|urlize }}

    {% endif %}

    {{ user.messages|count }} messages diff --git a/src/coriplus/website.py b/src/coriplus/website.py index 9612c66..14b944c 100644 --- a/src/coriplus/website.py +++ b/src/coriplus/website.py @@ -137,11 +137,12 @@ def user_follow(username): from_user=cur_user, to_user=user, created_date=datetime.datetime.now()) + push_notification('follow', user, user=cur_user.id) + flash('You are now following %s' % user.username) except IntegrityError: - pass + flash(f'Error following {user.username}') + - flash('You are following %s' % user.username) - push_notification('follow', user, user=cur_user.id) return redirect(url_for('website.user_detail', username=user.username)) @bp.route('/+/unfollow/', methods=['POST']) diff --git a/src/migrations/002_move_columns_from_userprofile.py b/src/migrations/002_move_columns_from_userprofile.py new file mode 100644 index 0000000..0b2d004 --- /dev/null +++ b/src/migrations/002_move_columns_from_userprofile.py @@ -0,0 +1,81 @@ +"""Peewee migrations -- 002_move_columns_from_userprofile.py. + +Some examples (model - class or model name):: + + > Model = migrator.orm['table_name'] # Return model in current state by name + > Model = migrator.ModelClass # Return model in current state by name + + > migrator.sql(sql) # Run custom SQL + > migrator.run(func, *args, **kwargs) # Run python function with the given args + > migrator.create_model(Model) # Create a model (could be used as decorator) + > migrator.remove_model(model, cascade=True) # Remove a model + > migrator.add_fields(model, **fields) # Add fields to a model + > migrator.change_fields(model, **fields) # Change fields + > migrator.remove_fields(model, *field_names, cascade=True) + > migrator.rename_field(model, old_field_name, new_field_name) + > migrator.rename_table(model, new_table_name) + > migrator.add_index(model, *col_names, unique=False) + > migrator.add_not_null(model, *field_names) + > migrator.add_default(model, field_name, default) + > migrator.add_constraint(model, name, sql) + > migrator.drop_index(model, *col_names) + > migrator.drop_not_null(model, *field_names) + > migrator.drop_constraints(model, *constraints) + +""" + +from contextlib import suppress + +import peewee as pw +from peewee_migrate import Migrator + + +with suppress(ImportError): + import playhouse.postgres_ext as pw_pext + + +def migrate(migrator: Migrator, database: pw.Database, *, fake=False): + """Write your migrations here.""" + + migrator.add_fields( + 'user', + + biography=pw.CharField(max_length=256, default=""), + website=pw.TextField(null=True)) + + migrator.change_fields('user', username=pw.CharField(max_length=30, unique=True)) + + migrator.change_fields('user', full_name=pw.CharField(max_length=80)) + + migrator.change_fields('user', password=pw.CharField(max_length=256)) + + migrator.change_fields('user', email=pw.CharField(max_length=256)) + + migrator.sql(""" + UPDATE "user" SET biography = (SELECT p.biography FROM userprofile p WHERE p.user_id = id LIMIT 1), + website = (SELECT p.website FROM userprofile p WHERE p.user_id = id LIMIT 1); + """) + + migrator.remove_fields('userprofile', 'year', 'instagram', 'facebook', 'telegram') + + +def rollback(migrator: Migrator, database: pw.Database, *, fake=False): + """Write your rollback migrations here.""" + + migrator.add_fields( + 'userprofile', + + year=pw.IntegerField(null=True), + instagram=pw.TextField(null=True), + facebook=pw.TextField(null=True), + telegram=pw.TextField(null=True)) + + migrator.remove_fields('user', 'biography', 'website') + + migrator.change_fields('user', username=pw.CharField(max_length=255, unique=True)) + + migrator.change_fields('user', full_name=pw.TextField()) + + migrator.change_fields('user', password=pw.CharField(max_length=255)) + + migrator.change_fields('user', email=pw.CharField(max_length=255)) From 8369035693c15eb304fa54da34d4e61f0e7e05bb Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Wed, 26 Nov 2025 16:50:42 +0100 Subject: [PATCH 41/42] implement permanent deletion, make user profile migration reversible --- .gitignore | 3 ++- CHANGELOG.md | 3 +++ src/coriplus/__init__.py | 4 ++-- src/coriplus/models.py | 2 ++ src/coriplus/templates/confirm_delete.html | 2 +- src/coriplus/website.py | 9 ++++++--- src/migrations/002_move_columns_from_userprofile.py | 5 +++++ 7 files changed, 21 insertions(+), 7 deletions(-) diff --git a/.gitignore b/.gitignore index 546be68..75b7704 100644 --- a/.gitignore +++ b/.gitignore @@ -17,4 +17,5 @@ config/ .\#* node_modules/ alembic.ini -**.egg-info \ No newline at end of file +**.egg-info +.vscode \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 24a2232..6bf0e72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,9 +2,12 @@ ## 0.10.0 + Codebase refactor (with breaking changes!) ++ Dropped support for Python<=3.9 + Move ALL config to .env (config.py is NO MORE supported) + Config SITE_NAME replaced with APP_NAME + Add CSRF token and flask_WTF ++ Schema changes: biography and website moved to `User`; `UserProfile` table deprecated (and useless fields removed) ++ Posts can now be permanently deleted ## 0.9.0 diff --git a/src/coriplus/__init__.py b/src/coriplus/__init__.py index 9543c60..305ea7b 100644 --- a/src/coriplus/__init__.py +++ b/src/coriplus/__init__.py @@ -24,7 +24,7 @@ from flask_wtf import CSRFProtect import dotenv import logging -__version__ = '0.10.0-dev45' +__version__ = '0.10.0-dev47' # we want to support Python 3.10+ only. # Python 2 has too many caveats. @@ -63,7 +63,7 @@ def before_request(): try: g.db.connect() except OperationalError: - logger.error('database connected twice.\n') + logger.error('database connected twice') @app.after_request def after_request(response): diff --git a/src/coriplus/models.py b/src/coriplus/models.py index f47fa65..07d52b7 100644 --- a/src/coriplus/models.py +++ b/src/coriplus/models.py @@ -23,6 +23,8 @@ from . import BASEDIR database = connect(os.environ['DATABASE_URL']) class BaseModel(Model): + id = AutoField(primary_key=True) + class Meta: database = database diff --git a/src/coriplus/templates/confirm_delete.html b/src/coriplus/templates/confirm_delete.html index 3d89e16..7343dad 100644 --- a/src/coriplus/templates/confirm_delete.html +++ b/src/coriplus/templates/confirm_delete.html @@ -16,7 +16,7 @@

- +
diff --git a/src/coriplus/website.py b/src/coriplus/website.py index 14b944c..ab9ab53 100644 --- a/src/coriplus/website.py +++ b/src/coriplus/website.py @@ -239,12 +239,15 @@ def edit(id): @bp.route('/delete/', methods=['GET', 'POST']) def confirm_delete(id): - user = get_current_user() - message = get_object_or_404(Message, Message.id == id) + user: User = current_user + message: Message = get_object_or_404(Message, Message.id == id) if message.user != user: abort(404) if request.method == 'POST': - abort(501, 'CSRF-Token missing.') + if message.user == user: + message.delete_instance() + flash('Your message has been deleted forever') + return redirect(request.args.get('next', '/')) return render_template('confirm_delete.html', message=message) # Workaround for problems related to invalid data. diff --git a/src/migrations/002_move_columns_from_userprofile.py b/src/migrations/002_move_columns_from_userprofile.py index 0b2d004..d8637f7 100644 --- a/src/migrations/002_move_columns_from_userprofile.py +++ b/src/migrations/002_move_columns_from_userprofile.py @@ -70,6 +70,11 @@ def rollback(migrator: Migrator, database: pw.Database, *, fake=False): facebook=pw.TextField(null=True), telegram=pw.TextField(null=True)) + migrator.sql(""" + UPDATE "userprofile" SET biography = (SELECT p.biography FROM user p WHERE p.user_id = id LIMIT 1), + website = (SELECT p.website FROM user p WHERE p.user_id = id LIMIT 1); + """) + migrator.remove_fields('user', 'biography', 'website') migrator.change_fields('user', username=pw.CharField(max_length=255, unique=True)) From b29fa7522613f09ebc2762129dd4ca25f546ee7d Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Fri, 19 Dec 2025 11:10:32 +0100 Subject: [PATCH 42/42] add csrf_token to JavaScript actions --- CHANGELOG.md | 2 ++ src/coriplus/__init__.py | 2 +- src/coriplus/ajax.py | 5 +++-- src/coriplus/static/lib.js | 12 ++++++++++-- src/coriplus/templates/base.html | 1 + 5 files changed, 17 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bf0e72..cfb7a8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,11 +3,13 @@ ## 0.10.0 + Codebase refactor (with breaking changes!) + Dropped support for Python<=3.9 ++ Switched database to PostgreSQL + Move ALL config to .env (config.py is NO MORE supported) + Config SITE_NAME replaced with APP_NAME + Add CSRF token and flask_WTF + Schema changes: biography and website moved to `User`; `UserProfile` table deprecated (and useless fields removed) + Posts can now be permanently deleted ++ Miscellaneous style changes ## 0.9.0 diff --git a/src/coriplus/__init__.py b/src/coriplus/__init__.py index 305ea7b..09cecf6 100644 --- a/src/coriplus/__init__.py +++ b/src/coriplus/__init__.py @@ -24,7 +24,7 @@ from flask_wtf import CSRFProtect import dotenv import logging -__version__ = '0.10.0-dev47' +__version__ = '0.10.0-dev50' # we want to support Python 3.10+ only. # Python 2 has too many caveats. diff --git a/src/coriplus/ajax.py b/src/coriplus/ajax.py index d2c0be3..cad9c74 100644 --- a/src/coriplus/ajax.py +++ b/src/coriplus/ajax.py @@ -5,8 +5,9 @@ Warning: this is not the public API. ''' from flask import Blueprint, jsonify +from flask_login import current_user from .models import User, Message, MessageUpvote -from .utils import locations, get_current_user, is_username +from .utils import locations, is_username import datetime bp = Blueprint('ajax', __name__, url_prefix='/ajax') @@ -39,7 +40,7 @@ def location_search(name): @bp.route('/score//toggle', methods=['POST']) def score_toggle(id): - user = get_current_user() + user = current_user message = Message[id] upvoted_by_self = (MessageUpvote .select() diff --git a/src/coriplus/static/lib.js b/src/coriplus/static/lib.js index cc78bea..11a2316 100644 --- a/src/coriplus/static/lib.js +++ b/src/coriplus/static/lib.js @@ -99,12 +99,20 @@ function showHideMessageOptions(id){ } } +function getCsrfToken () { + var csrf_token = document.querySelector('meta[name="csrf_token"]'); + return csrf_token?.getAttribute('content'); +} + function toggleUpvote(id){ var msgElem = document.getElementById(id); - var upvoteLink = msgElem.getElementsByClassName('message-upvote')[0]; + //var upvoteLink = msgElem.getElementsByClassName('message-upvote')[0]; var scoreCounter = msgElem.getElementsByClassName('message-score')[0]; + var body = "csrf_token=" + getCsrfToken(); var xhr = new XMLHttpRequest(); xhr.open("POST", "/ajax/score/" + id + "/toggle", true); + xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded"); + // TODO add csrf token somewhere xhr.onreadystatechange = function(){ if(xhr.readyState == XMLHttpRequest.DONE){ if(xhr.status == 200){ @@ -114,5 +122,5 @@ function toggleUpvote(id){ } } }; - xhr.send(); + xhr.send(body); } diff --git a/src/coriplus/templates/base.html b/src/coriplus/templates/base.html index 415711b..60667ea 100644 --- a/src/coriplus/templates/base.html +++ b/src/coriplus/templates/base.html @@ -6,6 +6,7 @@ +