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.

Files changed (60) hide show
  1. ownfoil/__init__.py +1 -0
  2. ownfoil/_app/app.py +653 -0
  3. ownfoil/_app/auth.py +370 -0
  4. ownfoil/_app/clients/__init__.py +9 -0
  5. ownfoil/_app/clients/client.py +171 -0
  6. ownfoil/_app/clients/cyberfoil.py +137 -0
  7. ownfoil/_app/clients/sphaira.py +197 -0
  8. ownfoil/_app/clients/tinfoil.py +189 -0
  9. ownfoil/_app/constants.py +149 -0
  10. ownfoil/_app/db.py +583 -0
  11. ownfoil/_app/file_compression.py +191 -0
  12. ownfoil/_app/file_watcher.py +308 -0
  13. ownfoil/_app/library.py +555 -0
  14. ownfoil/_app/local.py +42 -0
  15. ownfoil/_app/migrations/README +1 -0
  16. ownfoil/_app/migrations/alembic.ini +51 -0
  17. ownfoil/_app/migrations/env.py +111 -0
  18. ownfoil/_app/migrations/script.py.mako +23 -0
  19. ownfoil/_app/migrations/versions/78c33e9bffce_drop_old_files_table_and_create_new_.py +109 -0
  20. ownfoil/_app/migrations/versions/a1b2c3d4e5f6_add_tasks_and_ignored_events.py +63 -0
  21. ownfoil/_app/migrations/versions/b2c3d4e5f6a7_add_organized_column_to_files.py +22 -0
  22. ownfoil/_app/migrations/versions/c3d4e5f6a7b8_add_worker_id_to_tasks.py +22 -0
  23. ownfoil/_app/migrations/versions/d4e5f6a7b8c9_add_temp_files.py +28 -0
  24. ownfoil/_app/run.py +97 -0
  25. ownfoil/_app/settings.py +327 -0
  26. ownfoil/_app/shop.py +52 -0
  27. ownfoil/_app/static/favicon/apple-touch-icon.png +0 -0
  28. ownfoil/_app/static/favicon/favicon-96x96.png +0 -0
  29. ownfoil/_app/static/favicon/favicon.ico +0 -0
  30. ownfoil/_app/static/favicon/favicon.svg +5 -0
  31. ownfoil/_app/static/favicon/site.webmanifest +21 -0
  32. ownfoil/_app/static/favicon/web-app-manifest-192x192.png +0 -0
  33. ownfoil/_app/static/favicon/web-app-manifest-512x512.png +0 -0
  34. ownfoil/_app/static/logo/logo-1024.png +0 -0
  35. ownfoil/_app/static/logo/logo-128.png +0 -0
  36. ownfoil/_app/static/logo/logo-256.png +0 -0
  37. ownfoil/_app/static/logo/logo-512.png +0 -0
  38. ownfoil/_app/static/logo/logo-64.png +0 -0
  39. ownfoil/_app/static/logo/logo.svg +3 -0
  40. ownfoil/_app/static/style.css +125 -0
  41. ownfoil/_app/tasks.py +967 -0
  42. ownfoil/_app/templates/base.html +36 -0
  43. ownfoil/_app/templates/index.html +656 -0
  44. ownfoil/_app/templates/login.html +34 -0
  45. ownfoil/_app/templates/nav.html +43 -0
  46. ownfoil/_app/templates/profile.html +7 -0
  47. ownfoil/_app/templates/settings.html +1411 -0
  48. ownfoil/_app/templates/setup.html +546 -0
  49. ownfoil/_app/titledb.py +100 -0
  50. ownfoil/_app/titledb_store.py +533 -0
  51. ownfoil/_app/titles.py +239 -0
  52. ownfoil/_app/utils.py +352 -0
  53. ownfoil/_app/worker.py +149 -0
  54. ownfoil/_app/worker_pool.py +89 -0
  55. ownfoil/cli.py +64 -0
  56. ownfoil-2.4.0.dev0.dist-info/METADATA +221 -0
  57. ownfoil-2.4.0.dev0.dist-info/RECORD +60 -0
  58. ownfoil-2.4.0.dev0.dist-info/WHEEL +4 -0
  59. ownfoil-2.4.0.dev0.dist-info/entry_points.txt +2 -0
  60. 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]