ownfoil 2.4.0.dev0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of ownfoil might be problematic. Click here for more details.
- ownfoil/__init__.py +1 -0
- ownfoil/_app/app.py +653 -0
- ownfoil/_app/auth.py +370 -0
- ownfoil/_app/clients/__init__.py +9 -0
- ownfoil/_app/clients/client.py +171 -0
- ownfoil/_app/clients/cyberfoil.py +137 -0
- ownfoil/_app/clients/sphaira.py +197 -0
- ownfoil/_app/clients/tinfoil.py +189 -0
- ownfoil/_app/constants.py +149 -0
- ownfoil/_app/db.py +583 -0
- ownfoil/_app/file_compression.py +191 -0
- ownfoil/_app/file_watcher.py +308 -0
- ownfoil/_app/library.py +555 -0
- ownfoil/_app/local.py +42 -0
- ownfoil/_app/migrations/README +1 -0
- ownfoil/_app/migrations/alembic.ini +51 -0
- ownfoil/_app/migrations/env.py +111 -0
- ownfoil/_app/migrations/script.py.mako +23 -0
- ownfoil/_app/migrations/versions/78c33e9bffce_drop_old_files_table_and_create_new_.py +109 -0
- ownfoil/_app/migrations/versions/a1b2c3d4e5f6_add_tasks_and_ignored_events.py +63 -0
- ownfoil/_app/migrations/versions/b2c3d4e5f6a7_add_organized_column_to_files.py +22 -0
- ownfoil/_app/migrations/versions/c3d4e5f6a7b8_add_worker_id_to_tasks.py +22 -0
- ownfoil/_app/migrations/versions/d4e5f6a7b8c9_add_temp_files.py +28 -0
- ownfoil/_app/run.py +97 -0
- ownfoil/_app/settings.py +327 -0
- ownfoil/_app/shop.py +52 -0
- ownfoil/_app/static/favicon/apple-touch-icon.png +0 -0
- ownfoil/_app/static/favicon/favicon-96x96.png +0 -0
- ownfoil/_app/static/favicon/favicon.ico +0 -0
- ownfoil/_app/static/favicon/favicon.svg +5 -0
- ownfoil/_app/static/favicon/site.webmanifest +21 -0
- ownfoil/_app/static/favicon/web-app-manifest-192x192.png +0 -0
- ownfoil/_app/static/favicon/web-app-manifest-512x512.png +0 -0
- ownfoil/_app/static/logo/logo-1024.png +0 -0
- ownfoil/_app/static/logo/logo-128.png +0 -0
- ownfoil/_app/static/logo/logo-256.png +0 -0
- ownfoil/_app/static/logo/logo-512.png +0 -0
- ownfoil/_app/static/logo/logo-64.png +0 -0
- ownfoil/_app/static/logo/logo.svg +3 -0
- ownfoil/_app/static/style.css +125 -0
- ownfoil/_app/tasks.py +967 -0
- ownfoil/_app/templates/base.html +36 -0
- ownfoil/_app/templates/index.html +656 -0
- ownfoil/_app/templates/login.html +34 -0
- ownfoil/_app/templates/nav.html +43 -0
- ownfoil/_app/templates/profile.html +7 -0
- ownfoil/_app/templates/settings.html +1411 -0
- ownfoil/_app/templates/setup.html +546 -0
- ownfoil/_app/titledb.py +100 -0
- ownfoil/_app/titledb_store.py +533 -0
- ownfoil/_app/titles.py +239 -0
- ownfoil/_app/utils.py +352 -0
- ownfoil/_app/worker.py +149 -0
- ownfoil/_app/worker_pool.py +89 -0
- ownfoil/cli.py +64 -0
- ownfoil-2.4.0.dev0.dist-info/METADATA +221 -0
- ownfoil-2.4.0.dev0.dist-info/RECORD +60 -0
- ownfoil-2.4.0.dev0.dist-info/WHEEL +4 -0
- ownfoil-2.4.0.dev0.dist-info/entry_points.txt +2 -0
- ownfoil-2.4.0.dev0.dist-info/licenses/LICENSE.md +661 -0
ownfoil/_app/auth.py
ADDED
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
from flask import Blueprint, render_template, redirect, url_for, request, jsonify
|
|
2
|
+
from flask_login import login_user, login_required, logout_user, current_user
|
|
3
|
+
from werkzeug.security import generate_password_hash, check_password_hash
|
|
4
|
+
from functools import wraps
|
|
5
|
+
from db import *
|
|
6
|
+
from flask_login import LoginManager
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
import re
|
|
10
|
+
|
|
11
|
+
# Retrieve main logger
|
|
12
|
+
logger = logging.getLogger('main')
|
|
13
|
+
|
|
14
|
+
def validate_password(password):
|
|
15
|
+
"""
|
|
16
|
+
Validate password according to Basic Auth specifications and Tinfoil compatibility.
|
|
17
|
+
Rejects:
|
|
18
|
+
- Empty passwords
|
|
19
|
+
- Control characters (null, line breaks, etc.)
|
|
20
|
+
- Tinfoil forbidden characters: @ & / ? # =
|
|
21
|
+
- Non-UTF-8 characters
|
|
22
|
+
"""
|
|
23
|
+
if not password:
|
|
24
|
+
return False, "Password cannot be empty"
|
|
25
|
+
|
|
26
|
+
# Check for control characters (null, line breaks, etc.)
|
|
27
|
+
# This includes: null (\x00), tab (\x09), newline (\x0A), carriage return (\x0D), and other control chars
|
|
28
|
+
control_chars = r'[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]'
|
|
29
|
+
if re.search(control_chars, password):
|
|
30
|
+
return False, "Password contains invalid control characters"
|
|
31
|
+
|
|
32
|
+
# Also check for tab, newline, and carriage return explicitly
|
|
33
|
+
if '\t' in password or '\n' in password or '\r' in password:
|
|
34
|
+
return False, "Password contains invalid control characters"
|
|
35
|
+
|
|
36
|
+
# Check for Tinfoil forbidden characters
|
|
37
|
+
tinfoil_forbidden = r'[@&/?#=]'
|
|
38
|
+
if re.search(tinfoil_forbidden, password):
|
|
39
|
+
return False, "Password contains invalid characters. Please avoid: @ & / ? # ="
|
|
40
|
+
|
|
41
|
+
# Check for UTF-8 encoding validity
|
|
42
|
+
try:
|
|
43
|
+
password.encode('utf-8')
|
|
44
|
+
except UnicodeEncodeError:
|
|
45
|
+
return False, "Password contains invalid UTF-8 characters"
|
|
46
|
+
|
|
47
|
+
return True, "Password is valid"
|
|
48
|
+
|
|
49
|
+
def validate_username(username):
|
|
50
|
+
"""
|
|
51
|
+
Validate username according to Basic Auth specifications.
|
|
52
|
+
Rejects:
|
|
53
|
+
- Empty usernames
|
|
54
|
+
- Colons (:)
|
|
55
|
+
- Control characters (null, line breaks, etc.)
|
|
56
|
+
- Non-UTF-8 characters
|
|
57
|
+
"""
|
|
58
|
+
if not username:
|
|
59
|
+
return False, "Username cannot be empty"
|
|
60
|
+
|
|
61
|
+
# Check for colons (not allowed in Basic Auth usernames)
|
|
62
|
+
if ':' in username:
|
|
63
|
+
return False, "Username cannot contain colons (:)"
|
|
64
|
+
|
|
65
|
+
# Check for control characters (null, line breaks, etc.)
|
|
66
|
+
control_chars = r'[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]'
|
|
67
|
+
if re.search(control_chars, username):
|
|
68
|
+
return False, "Username contains invalid control characters"
|
|
69
|
+
|
|
70
|
+
# Also check for tab, newline, and carriage return explicitly
|
|
71
|
+
if '\t' in username or '\n' in username or '\r' in username:
|
|
72
|
+
return False, "Username contains invalid control characters"
|
|
73
|
+
|
|
74
|
+
# Check for UTF-8 encoding validity
|
|
75
|
+
try:
|
|
76
|
+
username.encode('utf-8')
|
|
77
|
+
except UnicodeEncodeError:
|
|
78
|
+
return False, "Username contains invalid UTF-8 characters"
|
|
79
|
+
|
|
80
|
+
return True, "Username is valid"
|
|
81
|
+
|
|
82
|
+
def admin_account_created():
|
|
83
|
+
return len(User.query.filter_by(admin_access=True).all())
|
|
84
|
+
|
|
85
|
+
def unauthorized_json():
|
|
86
|
+
response = login_manager.unauthorized()
|
|
87
|
+
resp = {
|
|
88
|
+
'success': False,
|
|
89
|
+
'status_code': response.status_code,
|
|
90
|
+
'location': response.location
|
|
91
|
+
}
|
|
92
|
+
return jsonify(resp)
|
|
93
|
+
|
|
94
|
+
def access_required(access: str):
|
|
95
|
+
def _access_required(f):
|
|
96
|
+
@wraps(f)
|
|
97
|
+
def decorated_view(*args, **kwargs):
|
|
98
|
+
if not admin_account_created():
|
|
99
|
+
# Auth disabled, request ok
|
|
100
|
+
return f(*args, **kwargs)
|
|
101
|
+
|
|
102
|
+
if not current_user.is_authenticated:
|
|
103
|
+
# return unauthorized_json()
|
|
104
|
+
return login_manager.unauthorized()
|
|
105
|
+
|
|
106
|
+
if not current_user.has_access(access):
|
|
107
|
+
return 'Forbidden', 403
|
|
108
|
+
return f(*args, **kwargs)
|
|
109
|
+
return decorated_view
|
|
110
|
+
return _access_required
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def roles_required(roles: list, require_all=False):
|
|
114
|
+
def _roles_required(f):
|
|
115
|
+
@wraps(f)
|
|
116
|
+
def decorated_view(*args, **kwargs):
|
|
117
|
+
if not roles:
|
|
118
|
+
raise ValueError('Empty list used when requiring a role.')
|
|
119
|
+
if not current_user.is_authenticated:
|
|
120
|
+
return login_manager.unauthorized()
|
|
121
|
+
if require_all and not all(current_user.has_role(role) for role in roles):
|
|
122
|
+
return 'Forbidden', 403
|
|
123
|
+
elif not require_all and not any(current_user.has_role(role) for role in roles):
|
|
124
|
+
return 'Forbidden', 403
|
|
125
|
+
return f(*args, **kwargs)
|
|
126
|
+
|
|
127
|
+
return decorated_view
|
|
128
|
+
|
|
129
|
+
return _roles_required
|
|
130
|
+
|
|
131
|
+
def basic_auth(request):
|
|
132
|
+
success = True
|
|
133
|
+
error = ''
|
|
134
|
+
user = None
|
|
135
|
+
|
|
136
|
+
auth = request.authorization
|
|
137
|
+
if auth is None:
|
|
138
|
+
success = False
|
|
139
|
+
error = 'No authentication provided.'
|
|
140
|
+
return success, error, user
|
|
141
|
+
|
|
142
|
+
username = auth.username
|
|
143
|
+
password = auth.password
|
|
144
|
+
user = User.query.filter_by(user=username).first()
|
|
145
|
+
if user is None:
|
|
146
|
+
success = False
|
|
147
|
+
error = f'Unknown user {username}.'
|
|
148
|
+
|
|
149
|
+
elif not check_password_hash(user.password, password):
|
|
150
|
+
success = False
|
|
151
|
+
error = f'Incorrect password for user {username}.'
|
|
152
|
+
|
|
153
|
+
return success, error, user
|
|
154
|
+
|
|
155
|
+
auth_blueprint = Blueprint('auth', __name__)
|
|
156
|
+
|
|
157
|
+
login_manager = LoginManager()
|
|
158
|
+
login_manager.login_view = 'auth.login'
|
|
159
|
+
|
|
160
|
+
def create_or_update_user(username, password, admin_access=False, shop_access=False, backup_access=False):
|
|
161
|
+
"""
|
|
162
|
+
Create a new user or update an existing user with the given credentials and access rights.
|
|
163
|
+
"""
|
|
164
|
+
# Validate username before creating/updating user
|
|
165
|
+
is_valid, error_message = validate_username(username)
|
|
166
|
+
if not is_valid:
|
|
167
|
+
logger.error(f'Username validation failed for user {username}: {error_message}')
|
|
168
|
+
raise ValueError(f"Username validation failed: {error_message}")
|
|
169
|
+
|
|
170
|
+
# Validate password before creating/updating user
|
|
171
|
+
is_valid, error_message = validate_password(password)
|
|
172
|
+
if not is_valid:
|
|
173
|
+
logger.error(f'Password validation failed for user {username}: {error_message}')
|
|
174
|
+
raise ValueError(f"Password validation failed: {error_message}")
|
|
175
|
+
|
|
176
|
+
user = User.query.filter_by(user=username).first()
|
|
177
|
+
if user:
|
|
178
|
+
logger.info(f'Updating existing user {username}')
|
|
179
|
+
user.admin_access = admin_access
|
|
180
|
+
user.shop_access = shop_access
|
|
181
|
+
user.backup_access = backup_access
|
|
182
|
+
user.password = generate_password_hash(password, method='scrypt')
|
|
183
|
+
else:
|
|
184
|
+
logger.info(f'Creating new user {username}')
|
|
185
|
+
new_user = User(user=username, password=generate_password_hash(password, method='scrypt'), admin_access=admin_access, shop_access=shop_access, backup_access=backup_access)
|
|
186
|
+
db.session.add(new_user)
|
|
187
|
+
db.session.commit()
|
|
188
|
+
|
|
189
|
+
def init_user_from_environment(environment_name, admin=False):
|
|
190
|
+
"""
|
|
191
|
+
allow to init some user from environment variable to init some users without using the UI
|
|
192
|
+
"""
|
|
193
|
+
username = os.getenv(environment_name + '_NAME')
|
|
194
|
+
password = os.getenv(environment_name + '_PASSWORD')
|
|
195
|
+
if username and password:
|
|
196
|
+
if admin:
|
|
197
|
+
logger.info('Initializing an admin user from environment variable...')
|
|
198
|
+
admin_access = True
|
|
199
|
+
shop_access = True
|
|
200
|
+
backup_access = True
|
|
201
|
+
else:
|
|
202
|
+
logger.info('Initializing a regular user from environment variable...')
|
|
203
|
+
admin_access = False
|
|
204
|
+
shop_access = True
|
|
205
|
+
backup_access = False
|
|
206
|
+
|
|
207
|
+
if not admin:
|
|
208
|
+
existing_admin = admin_account_created()
|
|
209
|
+
if not existing_admin and not admin_access:
|
|
210
|
+
logger.error(f'Error creating user {username}, first account created must be admin')
|
|
211
|
+
return
|
|
212
|
+
|
|
213
|
+
create_or_update_user(username, password, admin_access, shop_access, backup_access)
|
|
214
|
+
|
|
215
|
+
def init_users(app):
|
|
216
|
+
with app.app_context():
|
|
217
|
+
# init users from ENV
|
|
218
|
+
if os.environ.get('USER_ADMIN_NAME') is not None:
|
|
219
|
+
init_user_from_environment(environment_name="USER_ADMIN", admin=True)
|
|
220
|
+
if os.environ.get('USER_GUEST_NAME') is not None:
|
|
221
|
+
init_user_from_environment(environment_name="USER_GUEST", admin=False)
|
|
222
|
+
|
|
223
|
+
@auth_blueprint.route("/login", methods=["GET", "POST"])
|
|
224
|
+
def login():
|
|
225
|
+
if request.method == "GET":
|
|
226
|
+
next_url = request.args.get('next', '')
|
|
227
|
+
if current_user.is_authenticated:
|
|
228
|
+
return redirect(next_url if len(next_url) else '/')
|
|
229
|
+
return render_template('login.html', title='Login')
|
|
230
|
+
|
|
231
|
+
# login code goes here
|
|
232
|
+
username = request.form.get('user')
|
|
233
|
+
password = request.form.get('password')
|
|
234
|
+
remember = bool(request.form.get('remember'))
|
|
235
|
+
next_url = request.form.get('next', '')
|
|
236
|
+
|
|
237
|
+
user = User.query.filter_by(user=username).first()
|
|
238
|
+
|
|
239
|
+
# check if the user actually exists
|
|
240
|
+
# take the user-supplied password, hash it, and compare it to the hashed password in the database
|
|
241
|
+
if not user or not check_password_hash(user.password, password):
|
|
242
|
+
logger.warning(f'Incorrect login for user {username}')
|
|
243
|
+
return redirect(url_for('auth.login')) # if the user doesn't exist or password is wrong, reload the page
|
|
244
|
+
|
|
245
|
+
# if the above check passes, then we know the user has the right credentials
|
|
246
|
+
logger.info(f'Sucessfull login for user {username}')
|
|
247
|
+
login_user(user, remember=remember)
|
|
248
|
+
|
|
249
|
+
return redirect(next_url if len(next_url) else '/')
|
|
250
|
+
|
|
251
|
+
@auth_blueprint.route('/profile')
|
|
252
|
+
@login_required
|
|
253
|
+
@access_required('backup')
|
|
254
|
+
def profile():
|
|
255
|
+
return render_template('profile.html')
|
|
256
|
+
|
|
257
|
+
@auth_blueprint.route('/api/users')
|
|
258
|
+
@access_required('admin')
|
|
259
|
+
def get_users():
|
|
260
|
+
all_users = [
|
|
261
|
+
dict(db_user._mapping)
|
|
262
|
+
for db_user in db.session.query(User.id, User.user, User.admin_access, User.shop_access, User.backup_access).all()
|
|
263
|
+
]
|
|
264
|
+
return jsonify(all_users)
|
|
265
|
+
|
|
266
|
+
@auth_blueprint.route('/api/user', methods=['DELETE'])
|
|
267
|
+
@login_required
|
|
268
|
+
@access_required('admin')
|
|
269
|
+
def delete_user():
|
|
270
|
+
success = True
|
|
271
|
+
data = request.json
|
|
272
|
+
user_id = data['user_id']
|
|
273
|
+
try:
|
|
274
|
+
User.query.filter_by(id=user_id).delete()
|
|
275
|
+
db.session.commit()
|
|
276
|
+
logger.info(f'Successfully deleted user with id {user_id}.')
|
|
277
|
+
except Exception as e:
|
|
278
|
+
logger.error(f'Could not delete user with id {user_id}: {e}')
|
|
279
|
+
success = False
|
|
280
|
+
|
|
281
|
+
resp = {
|
|
282
|
+
'success': success
|
|
283
|
+
}
|
|
284
|
+
return jsonify(resp)
|
|
285
|
+
|
|
286
|
+
@auth_blueprint.route('/api/user/signup', methods=['POST'])
|
|
287
|
+
@access_required('admin')
|
|
288
|
+
def signup_post():
|
|
289
|
+
signup_success = True
|
|
290
|
+
data = request.json
|
|
291
|
+
|
|
292
|
+
username = data['user']
|
|
293
|
+
password = data['password']
|
|
294
|
+
admin_access = data['admin_access']
|
|
295
|
+
if admin_access:
|
|
296
|
+
shop_access = True
|
|
297
|
+
backup_access = True
|
|
298
|
+
else:
|
|
299
|
+
shop_access = data['shop_access']
|
|
300
|
+
backup_access = data['backup_access']
|
|
301
|
+
|
|
302
|
+
# Validate username first
|
|
303
|
+
is_valid, error_message = validate_username(username)
|
|
304
|
+
if not is_valid:
|
|
305
|
+
logger.error(f'Username validation failed for user {username}: {error_message}')
|
|
306
|
+
resp = {
|
|
307
|
+
'success': False,
|
|
308
|
+
'error': f"Username validation failed: {error_message}"
|
|
309
|
+
}
|
|
310
|
+
return jsonify(resp)
|
|
311
|
+
|
|
312
|
+
# Validate password
|
|
313
|
+
is_valid, error_message = validate_password(password)
|
|
314
|
+
if not is_valid:
|
|
315
|
+
logger.error(f'Password validation failed for user {username}: {error_message}')
|
|
316
|
+
resp = {
|
|
317
|
+
'success': False,
|
|
318
|
+
'error': f"Password validation failed: {error_message}"
|
|
319
|
+
}
|
|
320
|
+
return jsonify(resp)
|
|
321
|
+
|
|
322
|
+
user = User.query.filter_by(user=username).first() # if this returns a user, then the user already exists in database
|
|
323
|
+
|
|
324
|
+
if user: # if a user is found, we want to redirect back to signup page so user can try again
|
|
325
|
+
logger.error(f'Error creating user {username}, user already exists')
|
|
326
|
+
resp = {
|
|
327
|
+
'success': False,
|
|
328
|
+
'error': 'User already exists'
|
|
329
|
+
}
|
|
330
|
+
return jsonify(resp)
|
|
331
|
+
|
|
332
|
+
existing_admin = admin_account_created()
|
|
333
|
+
if not existing_admin and not admin_access:
|
|
334
|
+
logger.error(f'Error creating user {username}, first account created must be admin')
|
|
335
|
+
resp = {
|
|
336
|
+
'success': False,
|
|
337
|
+
'status_code': 400,
|
|
338
|
+
'location': '/settings',
|
|
339
|
+
}
|
|
340
|
+
return jsonify(resp)
|
|
341
|
+
|
|
342
|
+
try:
|
|
343
|
+
# create a new user with the form data. Hash the password so the plaintext version isn't saved.
|
|
344
|
+
create_or_update_user(username, password, admin_access, shop_access, backup_access)
|
|
345
|
+
logger.info(f'Successfully created user {username}.')
|
|
346
|
+
except ValueError as e:
|
|
347
|
+
# This should not happen since we validate above, but just in case
|
|
348
|
+
resp = {
|
|
349
|
+
'success': False,
|
|
350
|
+
'error': str(e)
|
|
351
|
+
}
|
|
352
|
+
return jsonify(resp)
|
|
353
|
+
|
|
354
|
+
resp = {
|
|
355
|
+
'success': signup_success
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
if not existing_admin and admin_access:
|
|
359
|
+
logger.debug('First admin account created')
|
|
360
|
+
resp['status_code'] = 302,
|
|
361
|
+
resp['location'] = '/settings'
|
|
362
|
+
|
|
363
|
+
return jsonify(resp)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
@auth_blueprint.route('/logout')
|
|
367
|
+
@login_required
|
|
368
|
+
def logout():
|
|
369
|
+
logout_user()
|
|
370
|
+
return redirect('/')
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Client module for handling different shop clients
|
|
3
|
+
"""
|
|
4
|
+
from .client import BaseClient
|
|
5
|
+
from .tinfoil import TinfoilClient
|
|
6
|
+
from .sphaira import SphairaClient, SPHAIRA_ENDPOINT
|
|
7
|
+
from .cyberfoil import CyberFoilClient
|
|
8
|
+
|
|
9
|
+
__all__ = ['BaseClient', 'CyberFoilClient', 'TinfoilClient', 'SphairaClient', 'SPHAIRA_ENDPOINT']
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Base client class for shop clients.
|
|
3
|
+
All client implementations must inherit from this class and implement the required methods.
|
|
4
|
+
"""
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from flask import Request, Response
|
|
7
|
+
from typing import Tuple, Optional, Dict, Any
|
|
8
|
+
from functools import wraps
|
|
9
|
+
from db import get_filtered_files
|
|
10
|
+
from auth import basic_auth
|
|
11
|
+
import logging
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger('main')
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class BaseClient(ABC):
|
|
17
|
+
"""Base class for shop clients implementing common interface for authentication, shop serving, and file delivery."""
|
|
18
|
+
|
|
19
|
+
# Class variables - should be overridden by subclasses
|
|
20
|
+
CLIENT_NAME = "BaseClient"
|
|
21
|
+
|
|
22
|
+
# ==================== Initialization ====================
|
|
23
|
+
|
|
24
|
+
def __init__(self, app_settings: dict):
|
|
25
|
+
"""Initialize the client with application settings and database."""
|
|
26
|
+
self.app_settings = app_settings
|
|
27
|
+
logger.debug(f"Initialized {self.CLIENT_NAME} client")
|
|
28
|
+
|
|
29
|
+
# ==================== Authentication Decorator ====================
|
|
30
|
+
|
|
31
|
+
@staticmethod
|
|
32
|
+
def authenticate(handler):
|
|
33
|
+
"""Decorator that handles authentication for handle_<method> functions."""
|
|
34
|
+
@wraps(handler)
|
|
35
|
+
def wrapper(self, request: Request) -> Response:
|
|
36
|
+
# Initialize auth flags on request object
|
|
37
|
+
request.basic_auth_success = False
|
|
38
|
+
request.basic_auth_error = None
|
|
39
|
+
request.client_auth_success = False
|
|
40
|
+
request.client_auth_error = None
|
|
41
|
+
request.user = None
|
|
42
|
+
request.auth_data = {}
|
|
43
|
+
|
|
44
|
+
# Perform host verification only for HTTPS requests
|
|
45
|
+
if request.is_secure or request.headers.get("X-Forwarded-Proto") == "https":
|
|
46
|
+
shop_host = self.app_settings["shop"].get("host")
|
|
47
|
+
if not shop_host:
|
|
48
|
+
self.log_error("Missing shop host configuration, Host verification is disabled.")
|
|
49
|
+
elif request.host != shop_host:
|
|
50
|
+
return self.error_response(f"Incorrect URL referrer detected: {request.host}.")
|
|
51
|
+
|
|
52
|
+
# Generic Basic Auth
|
|
53
|
+
request.basic_auth_success, request.basic_auth_error, request.user = basic_auth(request)
|
|
54
|
+
if request.basic_auth_success:
|
|
55
|
+
self.log_info(f"Basic authentication successful for user: {request.user.user}")
|
|
56
|
+
else:
|
|
57
|
+
self.log_warning(f"Authentication failed: {request.basic_auth_error}")
|
|
58
|
+
|
|
59
|
+
# Client-specific authentication
|
|
60
|
+
request.client_auth_success, request.client_auth_error, client_auth_data = self._client_authenticate(request)
|
|
61
|
+
if request.client_auth_success:
|
|
62
|
+
self.log_info("Client-specific authentication successful.")
|
|
63
|
+
if client_auth_data:
|
|
64
|
+
request.auth_data.update(client_auth_data)
|
|
65
|
+
|
|
66
|
+
else:
|
|
67
|
+
self.log_warning(f"Client-specific auth failed: {request.client_auth_error}")
|
|
68
|
+
|
|
69
|
+
# Call the actual handler
|
|
70
|
+
return handler(self, request)
|
|
71
|
+
|
|
72
|
+
return wrapper
|
|
73
|
+
|
|
74
|
+
@staticmethod
|
|
75
|
+
def verify_shop_access(handler):
|
|
76
|
+
"""Decorator that enforces authenticated access to the shop."""
|
|
77
|
+
@wraps(handler)
|
|
78
|
+
def wrapper(self, request: Request) -> Response:
|
|
79
|
+
# Check if shop requires authentication
|
|
80
|
+
if not self.app_settings['shop']['public']:
|
|
81
|
+
if not request.basic_auth_success:
|
|
82
|
+
return self.error_response("Shop requires authentication.\n" + (request.basic_auth_error))
|
|
83
|
+
# Check if user has shop access
|
|
84
|
+
if request.user and not request.user.has_shop_access():
|
|
85
|
+
return self.error_response(f'User {request.user.user} does not have access to the shop.')
|
|
86
|
+
|
|
87
|
+
# Call the actual handler
|
|
88
|
+
return handler(self, request)
|
|
89
|
+
|
|
90
|
+
return wrapper
|
|
91
|
+
|
|
92
|
+
def _client_authenticate(self, request: Request) -> Tuple[bool, Optional[str], Optional[Dict[str, Any]]]:
|
|
93
|
+
"""
|
|
94
|
+
Client-specific authentication logic. Override in subclasses for custom behavior.
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
Tuple of (success: bool, error_message: Optional[str], auth_data: Optional[Dict])
|
|
98
|
+
- success: True if authentication passed
|
|
99
|
+
- error_message: Error message if authentication failed
|
|
100
|
+
- auth_data: Additional authentication data to be stored in request.auth_data
|
|
101
|
+
"""
|
|
102
|
+
# Default implementation: no additional authentication required
|
|
103
|
+
return True, None, {}
|
|
104
|
+
|
|
105
|
+
# ==================== Abstract Methods (Required) ====================
|
|
106
|
+
|
|
107
|
+
@classmethod
|
|
108
|
+
@abstractmethod
|
|
109
|
+
def identify_client(cls, request: Request) -> bool:
|
|
110
|
+
"""Identify if the request is from this client type."""
|
|
111
|
+
pass
|
|
112
|
+
|
|
113
|
+
@abstractmethod
|
|
114
|
+
def error_response(self, error_message: str) -> Response:
|
|
115
|
+
"""Generate an error response in the format expected by the client."""
|
|
116
|
+
pass
|
|
117
|
+
|
|
118
|
+
@abstractmethod
|
|
119
|
+
def info_response(self, info_message: str) -> Response:
|
|
120
|
+
"""Generate an info response in the format expected by the client."""
|
|
121
|
+
pass
|
|
122
|
+
|
|
123
|
+
@abstractmethod
|
|
124
|
+
def _handle_get(self, request: Request) -> Response:
|
|
125
|
+
"""Handle GET requests for specific paths."""
|
|
126
|
+
pass
|
|
127
|
+
|
|
128
|
+
# ==================== Public Methods ====================
|
|
129
|
+
|
|
130
|
+
def handle_request(self, request: Request) -> Response:
|
|
131
|
+
"""Handle an incoming HTTP request and route to appropriate handler."""
|
|
132
|
+
method = request.method
|
|
133
|
+
path = request.path
|
|
134
|
+
headers = request.headers
|
|
135
|
+
|
|
136
|
+
# Route request based on method and path
|
|
137
|
+
if method == "OPTIONS":
|
|
138
|
+
return self._handle_options(path, headers)
|
|
139
|
+
elif method == "HEAD":
|
|
140
|
+
return self._handle_head(request)
|
|
141
|
+
elif method == "GET":
|
|
142
|
+
return self._handle_get(request)
|
|
143
|
+
|
|
144
|
+
def get_filtered_files(self, content_filter: Optional[str] = None) -> list:
|
|
145
|
+
"""Get filtered files from the database based on content type."""
|
|
146
|
+
return get_filtered_files(content_filter)
|
|
147
|
+
|
|
148
|
+
def log_info(self, message: str):
|
|
149
|
+
"""Log an info message with client context."""
|
|
150
|
+
logger.info(f"({self.CLIENT_NAME}) {message}")
|
|
151
|
+
|
|
152
|
+
def log_warning(self, message: str):
|
|
153
|
+
"""Log a warning message with client context."""
|
|
154
|
+
logger.warning(f"({self.CLIENT_NAME}) {message}")
|
|
155
|
+
|
|
156
|
+
def log_error(self, message: str):
|
|
157
|
+
"""Log an error message with client context."""
|
|
158
|
+
logger.error(f"({self.CLIENT_NAME}) {message}")
|
|
159
|
+
|
|
160
|
+
# ==================== Private/Helper Methods ====================
|
|
161
|
+
|
|
162
|
+
def _handle_options(self, path: str, headers: dict) -> Response:
|
|
163
|
+
"""Handle OPTIONS requests for CORS preflight."""
|
|
164
|
+
pass
|
|
165
|
+
|
|
166
|
+
def _handle_head(self, request: Request) -> Response:
|
|
167
|
+
"""
|
|
168
|
+
Handle HEAD requests. Override in subclasses if needed.
|
|
169
|
+
Default implementation returns 404.
|
|
170
|
+
"""
|
|
171
|
+
return self.error_response("HEAD method not implemented"), 404
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CyberFoil client implementation.
|
|
3
|
+
"""
|
|
4
|
+
from flask import Request, Response, jsonify
|
|
5
|
+
from typing import Tuple, Optional, Dict, Any
|
|
6
|
+
import json
|
|
7
|
+
|
|
8
|
+
from .client import BaseClient
|
|
9
|
+
from settings import set_shop_settings
|
|
10
|
+
from constants import APP_TYPE_FILTERS
|
|
11
|
+
|
|
12
|
+
CYBERFOIL_HEADERS = [
|
|
13
|
+
'Theme',
|
|
14
|
+
'Uid',
|
|
15
|
+
'Version',
|
|
16
|
+
'Revision',
|
|
17
|
+
'Language',
|
|
18
|
+
'Hauth',
|
|
19
|
+
'Uauth'
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
class CyberFoilClient(BaseClient):
|
|
23
|
+
"""CyberFoil client with header-based identification, Hauth verification."""
|
|
24
|
+
|
|
25
|
+
# Class variables
|
|
26
|
+
CLIENT_NAME = "CyberFoil"
|
|
27
|
+
|
|
28
|
+
# ==================== Abstract Method Implementations (Required) ====================
|
|
29
|
+
|
|
30
|
+
@classmethod
|
|
31
|
+
def identify_client(cls, request: Request) -> bool:
|
|
32
|
+
"""Identify CyberFoil client by checking for required headers."""
|
|
33
|
+
return all(header in request.headers for header in CYBERFOIL_HEADERS) and request.headers.get('User-Agent') == 'cyberfoil'
|
|
34
|
+
|
|
35
|
+
def error_response(self, error_message: str) -> Response:
|
|
36
|
+
"""Generate CyberFoil error response in JSON format."""
|
|
37
|
+
return jsonify({'error': error_message})
|
|
38
|
+
|
|
39
|
+
def info_response(self, info_message: str) -> Response:
|
|
40
|
+
"""Generate CyberFoil info response in JSON format."""
|
|
41
|
+
return jsonify({'success': info_message})
|
|
42
|
+
|
|
43
|
+
@BaseClient.authenticate
|
|
44
|
+
@BaseClient.verify_shop_access
|
|
45
|
+
def _handle_get(self, request: Request) -> Response:
|
|
46
|
+
"""Handle GET requests for specific paths."""
|
|
47
|
+
# Access auth flags from request object (set by @authenticate decorator)
|
|
48
|
+
if not request.client_auth_success:
|
|
49
|
+
return self.error_response(request.client_auth_error)
|
|
50
|
+
|
|
51
|
+
# Get client-specific settings
|
|
52
|
+
client_settings = self.app_settings['shop']['clients']['cyberfoil']
|
|
53
|
+
|
|
54
|
+
paths = request.path.strip('/').split('/')
|
|
55
|
+
content_filter = paths[0] if paths and paths[0] in APP_TYPE_FILTERS else None
|
|
56
|
+
# Build shop content
|
|
57
|
+
shop = {"success": self.app_settings['shop']['motd']}
|
|
58
|
+
shop["files"] = self._generate_shop_files(content_filter)
|
|
59
|
+
|
|
60
|
+
# Get verified_host from auth_data
|
|
61
|
+
verified_host = request.auth_data.get('verified_host')
|
|
62
|
+
if verified_host:
|
|
63
|
+
# Enforce client side host verification
|
|
64
|
+
shop["referrer"] = f"https://{verified_host}"
|
|
65
|
+
|
|
66
|
+
# Serve the shop
|
|
67
|
+
return jsonify(shop)
|
|
68
|
+
|
|
69
|
+
# ==================== Private/Helper Methods ====================
|
|
70
|
+
|
|
71
|
+
def _client_authenticate(self, request: Request) -> Tuple[bool, Optional[str], Optional[Dict[str, Any]]]:
|
|
72
|
+
"""Cyberfoil-specific authentication: Host verification for HTTPS requests."""
|
|
73
|
+
success = True
|
|
74
|
+
error = None
|
|
75
|
+
verified_host = None
|
|
76
|
+
|
|
77
|
+
# Perform host verification only for HTTPS requests
|
|
78
|
+
if request.is_secure or request.headers.get("X-Forwarded-Proto") == "https":
|
|
79
|
+
success, error, verified_host = self._verify_host(request)
|
|
80
|
+
|
|
81
|
+
# Return auth data with verified_host
|
|
82
|
+
auth_data = {'verified_host': verified_host}
|
|
83
|
+
return success, error, auth_data
|
|
84
|
+
|
|
85
|
+
def _verify_host(self, request: Request) -> Tuple[bool, Optional[str], Optional[str]]:
|
|
86
|
+
"""Verify Hauth to prevent hotlinking."""
|
|
87
|
+
request_host = request.host
|
|
88
|
+
request_hauth = request.headers.get('Hauth')
|
|
89
|
+
shop_host = self.app_settings["shop"].get("host")
|
|
90
|
+
client_settings = self.app_settings["shop"]["clients"]["cyberfoil"]
|
|
91
|
+
hauth_dict = client_settings.get("hauth", {})
|
|
92
|
+
|
|
93
|
+
# Get hauth for this specific host
|
|
94
|
+
shop_hauth = hauth_dict.get(request_host)
|
|
95
|
+
|
|
96
|
+
self.log_info(f"Secure request from remote host {request_host}, proceeding with host verification.")
|
|
97
|
+
|
|
98
|
+
if not shop_host:
|
|
99
|
+
self.log_error("Missing shop host configuration, Host verification is disabled.")
|
|
100
|
+
return True, None, None
|
|
101
|
+
|
|
102
|
+
if not shop_hauth:
|
|
103
|
+
return self._handle_missing_hauth(request, request_host, request_hauth)
|
|
104
|
+
|
|
105
|
+
if request_hauth != shop_hauth:
|
|
106
|
+
self.log_warning(f"Incorrect Hauth detected for host: {request_host}.")
|
|
107
|
+
return False, f"Incorrect Hauth for URL `{request_host}`.", None
|
|
108
|
+
|
|
109
|
+
return True, None, shop_host
|
|
110
|
+
|
|
111
|
+
def _handle_missing_hauth(self, request: Request, request_host: str, request_hauth: str) -> Tuple[bool, Optional[str], Optional[str]]:
|
|
112
|
+
"""Handle case when Hauth is not configured."""
|
|
113
|
+
basic_auth_success = request.basic_auth_success
|
|
114
|
+
user_is_admin = request.user.has_admin_access() if request.user else False
|
|
115
|
+
|
|
116
|
+
if basic_auth_success and user_is_admin:
|
|
117
|
+
# Save hauth to client-specific settings as a dict with host as key
|
|
118
|
+
shop_settings = self.app_settings['shop']
|
|
119
|
+
hauth_dict = shop_settings['clients']['cyberfoil'].get('hauth', {})
|
|
120
|
+
|
|
121
|
+
# Set hauth for this specific host
|
|
122
|
+
hauth_dict[request_host] = request_hauth
|
|
123
|
+
shop_settings['clients']['cyberfoil']['hauth'] = hauth_dict
|
|
124
|
+
set_shop_settings(shop_settings)
|
|
125
|
+
self.log_info(f"Successfully set Hauth value for host {request_host}.")
|
|
126
|
+
return True, None, request_host
|
|
127
|
+
|
|
128
|
+
self.log_warning(
|
|
129
|
+
f"Hauth value not set for host {request_host}, Host verification is disabled. "
|
|
130
|
+
f"Connect to the shop from Cyberfoil with an admin account to set it."
|
|
131
|
+
)
|
|
132
|
+
return True, None, None
|
|
133
|
+
|
|
134
|
+
def _generate_shop_files(self, content_filter: Optional[str] = None) -> list:
|
|
135
|
+
"""Generate the files list for the shop with optional content type filtering."""
|
|
136
|
+
files = self.get_filtered_files(content_filter)
|
|
137
|
+
return [{'url': f'/api/get_game/{f.id}#{f.filename}', 'size': f.size} for f in files]
|