Skip to content

Commit 4dee33b

Browse files
authored
Merge pull request #398 from KSP-SpaceDock/beta
2 parents ebb54cb + c8ba134 commit 4dee33b

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

63 files changed

+6826
-828
lines changed

.github/workflows/mypy.yml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
name: Mypy
22

3-
on: [push, pull_request]
3+
on:
4+
- push
5+
- pull_request
46

57
jobs:
68
build:
@@ -15,6 +17,7 @@ jobs:
1517
- name: Install Dependencies
1618
run: |
1719
pip install mypy
20+
pip install -r requirements-tests.txt
1821
- name: mypy
1922
run: |
2023
mypy KerbalStuff alembic/versions

.github/workflows/pytest.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
name: PyTest
22

3-
on: [push, pull_request]
3+
on:
4+
- push
5+
- pull_request
46

57
jobs:
68
build:

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ alembic.ini
1818
/spacedock-db
1919
/static
2020
content/
21+
.bash_history
2122

2223
venv/
2324
build/

KerbalStuff/app.py

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from flaskext.markdown import Markdown
1717
from sqlalchemy import desc
1818
from werkzeug.exceptions import HTTPException, InternalServerError
19+
from jinja2 import ChainableUndefined
1920

2021
from .blueprints.accounts import accounts
2122
from .blueprints.admin import admin
@@ -26,6 +27,7 @@
2627
from .blueprints.login_oauth import list_defined_oauths, login_oauth
2728
from .blueprints.mods import mods
2829
from .blueprints.profile import profiles
30+
from .middleware.session_interface import OnlyLoggedInSessionInterface
2931
from .celery import update_from_github
3032
from .common import first_paragraphs, many_paragraphs, json_output, jsonify_exception, dumb_object, sanitize_text
3133
from .config import _cfg, _cfgb, _cfgd, _cfgi, site_logger
@@ -49,11 +51,14 @@
4951
SESSION_COOKIE_SECURE=True,
5052
REMEMBER_COOKIE_SECURE=True
5153
)
54+
# Render None and any accesses of its properties and sub-properties as the empty string instead of throwing exceptions
55+
app.jinja_env.undefined = ChainableUndefined
5256
app.jinja_env.filters['first_paragraphs'] = first_paragraphs
5357
app.jinja_env.filters['bleach'] = sanitize_text
5458
app.jinja_env.auto_reload = app.debug
5559
app.secret_key = _cfg("secret-key")
5660
app.json_encoder = CustomJSONEncoder
61+
app.session_interface = OnlyLoggedInSessionInterface()
5762
Markdown(app, extensions=[KerbDown(), 'fenced_code'])
5863
login_manager = LoginManager(app)
5964

@@ -154,7 +159,7 @@ def handle_generic_exception(e: Union[Exception, HTTPException]) -> Union[Tuple[
154159
site_logger.exception(e)
155160
try:
156161
db.rollback()
157-
db.close()
162+
# Session will be closed in app.teardown_request so templates can be rendered
158163
except:
159164
pass
160165

@@ -166,13 +171,18 @@ def handle_generic_exception(e: Union[Exception, HTTPException]) -> Union[Tuple[
166171
else:
167172
if not isinstance(e, HTTPException):
168173
# Create an HTTPException so it has a code, name and description which we access in the template.
169-
# We deliberately loose the original message here because it can contain confidential data.
174+
# We deliberately lose the original message here because it can contain confidential data.
170175
e = InternalServerError()
171176
if e.description == werkzeug.exceptions.InternalServerError.description:
172177
e.description = "Clearly you've broken something. Maybe if you refresh no one will notice."
173178
return render_template("error_5XX.html", error=e), e.code or 500
174179

175180

181+
@app.teardown_request
182+
def teardown_request(exception: Optional[Exception]) -> None:
183+
db.close()
184+
185+
176186
# I am unsure if this function is still needed or rather, if it still works.
177187
# TODO(Thomas): Investigate and remove
178188
@app.route('/ksp-profile-proxy/<fragment>')
@@ -280,7 +290,9 @@ def inject() -> Dict[str, Any]:
280290
if request.cookies.get('dismissed_donation') is not None:
281291
dismissed_donation = True
282292
return {
283-
'announcements': get_announcement_posts(),
293+
'announcements': (get_all_announcement_posts()
294+
if current_user
295+
else get_non_member_announcement_posts()),
284296
'many_paragraphs': many_paragraphs,
285297
'analytics_id': _cfg("google_analytics_id"),
286298
'analytics_domain': _cfg("google_analytics_domain"),
@@ -300,6 +312,7 @@ def inject() -> Dict[str, Any]:
300312
'url_for': url_for,
301313
'strftime': strftime,
302314
'site_name': _cfg('site-name'),
315+
'caption': _cfg('caption'),
303316
'support_mail': _cfg('support-mail'),
304317
'source_code': _cfg('source-code'),
305318
'support_channels': _cfgd('support-channels'),
@@ -309,5 +322,11 @@ def inject() -> Dict[str, Any]:
309322
}
310323

311324

312-
def get_announcement_posts() -> List[BlogPost]:
313-
return BlogPost.query.filter(BlogPost.announcement == True).order_by(desc(BlogPost.created)).all()
325+
def get_all_announcement_posts() -> List[BlogPost]:
326+
return BlogPost.query.filter(BlogPost.announcement).order_by(desc(BlogPost.created)).all()
327+
328+
329+
def get_non_member_announcement_posts() -> List[BlogPost]:
330+
return BlogPost.query.filter(
331+
BlogPost.announcement, BlogPost.members_only != True
332+
).order_by(desc(BlogPost.created)).all()

KerbalStuff/blueprints/accounts.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
from ..objects import Mod, User
1818
from ..search import get_mod_score
1919

20-
accounts = Blueprint('accounts', __name__, template_folder='../../templates/accounts')
20+
accounts = Blueprint('accounts', __name__)
2121

2222

2323
@accounts.route("/register", methods=['GET', 'POST'])

KerbalStuff/blueprints/admin.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
from ..email import send_bulk_email
1818
from ..objects import Mod, GameVersion, Game, Publisher, User
1919

20-
admin = Blueprint('admin', __name__, template_folder='../../templates/admin')
20+
admin = Blueprint('admin', __name__)
2121
ITEMS_PER_PAGE = 10
2222

2323

KerbalStuff/blueprints/anonymous.py

Lines changed: 27 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,18 @@
11
import os.path
22

33
import werkzeug.wrappers
4-
from flask import Blueprint, render_template, send_from_directory, abort, request, Response
4+
from flask import Blueprint, render_template, abort, request, Response, make_response, send_file
55
from flask_login import current_user
66
from sqlalchemy import desc
7+
from datetime import timezone
78

89
from ..common import dumb_object, paginate_query, get_paginated_mods, get_game_info, get_games, \
9-
get_featured_mods, get_top_mods, get_new_mods, get_updated_mods
10+
get_featured_mods, get_top_mods, get_new_mods, get_updated_mods, sendfile
1011
from ..config import _cfg
1112
from ..database import db
1213
from ..objects import Featured, Mod, ModVersion, User
1314

14-
anonymous = Blueprint('anonymous', __name__, template_folder='../../templates/anonymous')
15+
anonymous = Blueprint('anonymous', __name__)
1516

1617

1718
@anonymous.route("/")
@@ -44,9 +45,10 @@ def game(gameshort: str) -> str:
4445
@anonymous.route("/content/<path:path>")
4546
def content(path: str) -> werkzeug.wrappers.Response:
4647
storage = _cfg('storage')
47-
if not storage or not os.path.isfile(os.path.join(storage, path)):
48+
if not storage:
4849
abort(404)
49-
return send_from_directory(storage + "/", path)
50+
51+
return sendfile(path, True)
5052

5153

5254
@anonymous.route("/browse")
@@ -114,18 +116,19 @@ def browse_featured() -> str:
114116

115117
@anonymous.route("/browse/featured.rss")
116118
def browse_featured_rss() -> Response:
117-
mods = get_featured_mods(None, 30)
118-
# Fix dates
119-
for f in mods:
120-
f.mod.created = f.created
121-
mods = [dumb_object(f.mod) for f in mods]
122-
db.rollback()
123119
site_name = _cfg('site-name')
124120
if not site_name:
125121
abort(404)
122+
mods = []
123+
for fm in get_featured_mods(None, 30):
124+
# Add each mod but with created set to when it was featured
125+
fmod = dumb_object(fm.mod)
126+
fmod['created'] = fm.created.astimezone(timezone.utc)
127+
mods.append(fmod)
126128
return Response(render_template("rss.xml", mods=mods, title="Featured mods on " + site_name,
127129
description="Featured mods on " + site_name,
128-
url="/browse/featured"), mimetype="text/xml")
130+
url="/browse/featured"),
131+
mimetype="text/xml")
129132

130133

131134
@anonymous.route("/browse/all")
@@ -162,7 +165,8 @@ def singlegame_browse_new_rss(gameshort: str) -> Response:
162165
mods = get_new_mods(ga.id, 30)
163166
return Response(render_template("rss.xml", mods=mods, title="New mods on " + site_name, ga=ga,
164167
description="The newest mods on " + site_name,
165-
url="/browse/new"), mimetype="text/xml")
168+
url="/browse/new"),
169+
mimetype="text/xml")
166170

167171

168172
@anonymous.route("/<gameshort>/browse/updated")
@@ -184,7 +188,8 @@ def singlegame_browse_updated_rss(gameshort: str) -> Response:
184188
return Response(render_template("rss.xml", mods=mods, title="Recently updated on " + site_name, ga=ga,
185189
description="Mods on " +
186190
site_name + " updated recently",
187-
url="/browse/updated"), mimetype="text/xml")
191+
url="/browse/updated"),
192+
mimetype="text/xml")
188193

189194

190195
@anonymous.route("/<gameshort>/browse/top")
@@ -212,15 +217,16 @@ def singlegame_browse_featured_rss(gameshort: str) -> Response:
212217
if not site_name:
213218
abort(404)
214219
ga = get_game_info(short=gameshort)
215-
mods = get_featured_mods(ga.id, 30)
216-
# Fix dates
217-
for f in mods:
218-
f.mod.created = f.created
219-
mods = [dumb_object(f.mod) for f in mods]
220-
db.rollback()
220+
mods = []
221+
for fm in get_featured_mods(ga.id, 30):
222+
# Add each mod but with created set to when it was featured
223+
fmod = dumb_object(fm.mod)
224+
fmod['created'] = fm.created.astimezone(timezone.utc)
225+
mods.append(fmod)
221226
return Response(render_template("rss.xml", mods=mods, title="Featured mods on " + site_name, ga=ga,
222227
description="Featured mods on " + site_name,
223-
url="/browse/featured"), mimetype="text/xml")
228+
url="/browse/featured"),
229+
mimetype="text/xml")
224230

225231

226232
@anonymous.route("/<gameshort>/browse/all")

KerbalStuff/blueprints/api.py

Lines changed: 46 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
from functools import wraps
77
from typing import Dict, Any, Callable, Optional, Tuple, Iterable, List, Union
88

9-
import bcrypt
109
from flask import Blueprint, url_for, current_app, request, abort
1110
from flask_login import login_user, current_user
1211
from sqlalchemy import desc, asc
@@ -22,6 +21,7 @@
2221
from ..objects import GameVersion, Game, Publisher, Mod, Featured, User, ModVersion, SharedAuthor, \
2322
ModList
2423
from ..search import search_mods, search_users, typeahead_mods, get_mod_score
24+
from ..thumbnail import thumb_path_from_background_path
2525

2626
api = Blueprint('api', __name__)
2727

@@ -60,7 +60,7 @@ def mod_info(mod: Mod) -> Dict[str, Any]:
6060
"author": mod.user.username,
6161
"default_version_id": mod.default_version.id,
6262
"shared_authors": list(),
63-
"background": mod.background,
63+
"background": mod.background_url(_cfg('protocol'), _cfg('cdn-domain')),
6464
"bg_offset_y": mod.bgOffsetY,
6565
"license": mod.license,
6666
"website": mod.external_link,
@@ -174,28 +174,41 @@ def _update_image(old_path: str, base_name: str, base_path: str) -> Optional[str
174174
full_path = os.path.join(storage, base_path)
175175
if not os.path.exists(full_path):
176176
os.makedirs(full_path)
177-
try:
178-
os.remove(os.path.join(storage, old_path))
179-
except:
180-
pass # who cares
177+
178+
if old_path:
179+
try_remove_file_and_folder(os.path.join(storage, old_path))
181180
f.save(os.path.join(full_path, filename))
182181
return os.path.join(base_path, filename)
183182

184183

184+
def try_remove_file_and_folder(path: str) -> None:
185+
"""Tries to remove a file and the containing folder if empty
186+
187+
:param path: An absolute path to the file
188+
"""
189+
try:
190+
os.remove(path)
191+
# Remove the containing folder if empty
192+
folder = os.path.dirname(path)
193+
if not os.listdir(folder):
194+
os.rmdir(folder)
195+
except:
196+
pass
197+
198+
185199
def _get_modversion_paths(mod_name: str, friendly_version: str) -> Tuple[str, str]:
186200
mod_name_sec = secure_filename(mod_name)
187-
storage_base = os.path.join(f'{secure_filename(current_user.username)}_{current_user.id!s}',
188-
mod_name_sec)
201+
base_path = os.path.join(current_user.base_path(), mod_name_sec)
189202
storage = _cfg('storage')
190203
if not storage:
191-
return ('', '')
192-
storage_path = os.path.join(storage, storage_base)
204+
return '', ''
205+
storage_path = os.path.join(storage, base_path)
193206
filename = f'{mod_name_sec}-{friendly_version}.zip'
194207
if not os.path.exists(storage_path):
195208
os.makedirs(storage_path)
196209
full_path = os.path.join(storage_path, filename)
197210
# Return tuple of (full path, relative path)
198-
return (full_path, os.path.join(storage_base, filename))
211+
return full_path, os.path.join(base_path, filename)
199212

200213

201214
def serialize_mod_list(mods: Iterable[Mod]) -> Iterable[Dict[str, Any]]:
@@ -458,13 +471,23 @@ def update_mod_background(mod_id: int) -> Dict[str, Any]:
458471
mod = _get_mod(mod_id)
459472
_check_mod_editable(mod)
460473
seq_mod_name = secure_filename(mod.name)
461-
base_name = f'{seq_mod_name}-{time.time()!s}'
462-
base_path = os.path.join(f'{secure_filename(mod.user.username)}_{mod.user.id!s}', seq_mod_name)
463-
new_path = _update_image(mod.background, base_name, base_path)
474+
base_name = f'{seq_mod_name}-{int(time.time())}'
475+
old_path = mod.background
476+
new_path = _update_image(old_path, base_name, mod.base_path())
464477
if new_path:
465478
mod.background = new_path
479+
# Remove the old thumbnail
480+
storage = _cfg('storage')
481+
if storage:
482+
if mod.thumbnail:
483+
try_remove_file_and_folder(os.path.join(storage, mod.thumbnail))
484+
if old_path and (calc_path := thumb_path_from_background_path(old_path)) != mod.thumbnail:
485+
try_remove_file_and_folder(os.path.join(storage, calc_path))
486+
mod.thumbnail = None
487+
# Generate the new thumbnail
488+
mod.background_thumb()
466489
notify_ckan(mod, 'update-background')
467-
return {'path': '/content/' + new_path}
490+
return {'path': mod.background_url(_cfg('protocol'), _cfg('cdn-domain'))}
468491
return {'path': None}
469492

470493

@@ -476,12 +499,13 @@ def update_user_background(username: str) -> Union[Dict[str, Any], Tuple[Dict[st
476499
if not current_user.admin and current_user.username != username:
477500
return {'error': True, 'reason': 'You are not authorized to edit this user\'s background'}, 403
478501
user = User.query.filter(User.username == username).first()
479-
base_name = secure_filename(user.username)
480-
base_path = f'{base_name}-{time.time()!s}_{user.id!s}'
481-
new_path = _update_image(user.backgroundMedia, base_name, base_path)
502+
seq_username = secure_filename(user.username)
503+
base_name = f'{seq_username}-header-{int(time.time())}'
504+
new_path = _update_image(user.backgroundMedia, base_name, user.base_path())
482505
if new_path:
483506
user.backgroundMedia = new_path
484-
return {'path': '/content/' + new_path}
507+
# The frontend needs the new path so it can show the updated image
508+
return {'path': user.background_url(_cfg('protocol'), _cfg('cdn-domain'))}
485509
return {'path': None}
486510

487511

@@ -605,6 +629,7 @@ def create_mod() -> Tuple[Dict[str, Any], int]:
605629
return {'error': True, 'reason': 'Only users with public profiles may create mods.'}, 403
606630
mod_name = request.form.get('name')
607631
short_description = request.form.get('short-description')
632+
description = request.form.get('description', default_description)
608633
mod_friendly_version = secure_filename(request.form.get('version', ''))
609634
# 'game' is deprecated, but kept for compatibility
610635
game_id = request.form.get('game-id') or request.form.get('game')
@@ -662,7 +687,7 @@ def create_mod() -> Tuple[Dict[str, Any], int]:
662687
mod = Mod(user=current_user,
663688
name=mod_name,
664689
short_description=short_description,
665-
description=default_description,
690+
description=description,
666691
license=mod_licence,
667692
ckan=(request.form.get('ckan', '').lower() in TRUE_STR),
668693
game=game,
@@ -735,6 +760,7 @@ def update_mod(mod_id: int) -> Tuple[Dict[str, Any], int]:
735760
if mod.versions:
736761
version.sort_index = max(v.sort_index for v in mod.versions) + 1
737762
version.mod = mod
763+
version.download_size = os.path.getsize(full_path)
738764
mod.default_version = version
739765
mod.updated = datetime.now()
740766
db.commit()

0 commit comments

Comments
 (0)