ownfoil 2.4.0__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.
- ownfoil/__init__.py +0 -0
- ownfoil/_app/app.py +545 -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 +154 -0
- ownfoil/_app/clients/tinfoil.py +189 -0
- ownfoil/_app/constants.py +158 -0
- ownfoil/_app/containers/__init__.py +8 -0
- ownfoil/_app/containers/cnmt.py +67 -0
- ownfoil/_app/containers/compression.py +182 -0
- ownfoil/_app/containers/container.py +28 -0
- ownfoil/_app/containers/verification.py +156 -0
- ownfoil/_app/db.py +691 -0
- ownfoil/_app/file_watcher.py +323 -0
- ownfoil/_app/gql/README.md +550 -0
- ownfoil/_app/gql/__init__.py +5 -0
- ownfoil/_app/gql/cache.py +77 -0
- ownfoil/_app/gql/context.py +40 -0
- ownfoil/_app/gql/docs.py +44 -0
- ownfoil/_app/gql/filters.py +665 -0
- ownfoil/_app/gql/mutations.py +238 -0
- ownfoil/_app/gql/resolvers.py +1174 -0
- ownfoil/_app/gql/scalars.py +15 -0
- ownfoil/_app/gql/schema.py +227 -0
- ownfoil/_app/gql/selection.py +43 -0
- ownfoil/_app/gql/types.py +557 -0
- ownfoil/_app/gql/view.py +111 -0
- ownfoil/_app/library.py +381 -0
- ownfoil/_app/local.py +76 -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/a7b8c9d0e1f2_add_title_overrides.py +61 -0
- ownfoil/_app/migrations/versions/b2c3d4e5f6a7_add_organized_column_to_files.py +22 -0
- ownfoil/_app/migrations/versions/b8c9d0e1f2a3_add_added_at_to_files.py +29 -0
- ownfoil/_app/migrations/versions/c3d4e5f6a7b8_add_worker_id_to_tasks.py +22 -0
- ownfoil/_app/migrations/versions/c9d0e1f2a3b4_add_verification_to_files.py +37 -0
- ownfoil/_app/migrations/versions/d4e5f6a7b8c9_add_temp_files.py +28 -0
- ownfoil/_app/migrations/versions/e5f6a7b8c9d0_add_release_date_to_apps.py +25 -0
- ownfoil/_app/migrations/versions/f6a7b8c9d0e1_add_app_files_file_id_index.py +25 -0
- ownfoil/_app/realtime.py +233 -0
- ownfoil/_app/run.py +101 -0
- ownfoil/_app/settings.py +334 -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/js/format.js +9 -0
- ownfoil/_app/static/js/realtime.js +87 -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 +411 -0
- ownfoil/_app/task_events.py +150 -0
- ownfoil/_app/tasks.py +1089 -0
- ownfoil/_app/templates/base.html +36 -0
- ownfoil/_app/templates/index.html +707 -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 +1469 -0
- ownfoil/_app/templates/settings_layout.html +33 -0
- ownfoil/_app/templates/setup.html +542 -0
- ownfoil/_app/templates/stats.html +169 -0
- ownfoil/_app/templates/tasks.html +467 -0
- ownfoil/_app/titledb/__init__.py +9 -0
- ownfoil/_app/titledb/schema.py +142 -0
- ownfoil/_app/titledb/store.py +611 -0
- ownfoil/_app/titledb/update.py +106 -0
- ownfoil/_app/titles.py +177 -0
- ownfoil/_app/utils.py +397 -0
- ownfoil/_app/worker.py +153 -0
- ownfoil/_app/worker_pool.py +89 -0
- ownfoil/cli.py +77 -0
- ownfoil-2.4.0.dist-info/METADATA +79 -0
- ownfoil-2.4.0.dist-info/RECORD +90 -0
- ownfoil-2.4.0.dist-info/WHEEL +4 -0
- ownfoil-2.4.0.dist-info/entry_points.txt +2 -0
- ownfoil-2.4.0.dist-info/licenses/LICENSE.md +661 -0
ownfoil/__init__.py
ADDED
|
File without changes
|
ownfoil/_app/app.py
ADDED
|
@@ -0,0 +1,545 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
from flask import Flask, render_template, request, redirect, url_for, jsonify, send_from_directory, Response
|
|
3
|
+
from flask_login import LoginManager
|
|
4
|
+
from flask_sock import Sock
|
|
5
|
+
from functools import wraps
|
|
6
|
+
from file_watcher import Watcher
|
|
7
|
+
import threading
|
|
8
|
+
import logging
|
|
9
|
+
import sys
|
|
10
|
+
import copy
|
|
11
|
+
import flask.cli
|
|
12
|
+
flask.cli.show_server_banner = lambda *args: None
|
|
13
|
+
from constants import *
|
|
14
|
+
from settings import *
|
|
15
|
+
from db import *
|
|
16
|
+
from auth import *
|
|
17
|
+
from utils import *
|
|
18
|
+
from library import *
|
|
19
|
+
import json
|
|
20
|
+
import tasks as tasks_mod
|
|
21
|
+
import realtime
|
|
22
|
+
import titledb
|
|
23
|
+
import os
|
|
24
|
+
from clients import CyberFoilClient, TinfoilClient, SphairaClient
|
|
25
|
+
|
|
26
|
+
def init():
|
|
27
|
+
global watcher
|
|
28
|
+
global watcher_thread
|
|
29
|
+
# Create the file watcher and register callbacks BEFORE starting the observer
|
|
30
|
+
logger.info('Initializing File Watcher...')
|
|
31
|
+
watcher = Watcher(on_library_change)
|
|
32
|
+
watcher.add_file_callback(CONFIG_FILE, on_settings_change)
|
|
33
|
+
watcher_thread = threading.Thread(target=watcher.run)
|
|
34
|
+
watcher_thread.daemon = True
|
|
35
|
+
watcher_thread.start()
|
|
36
|
+
|
|
37
|
+
# init libraries
|
|
38
|
+
library_paths = get_library_paths()
|
|
39
|
+
init_libraries(app, watcher, library_paths)
|
|
40
|
+
|
|
41
|
+
# Enqueue initial titledb update (re-enqueues itself on completion)
|
|
42
|
+
with app.app_context():
|
|
43
|
+
tasks_mod.enqueue_task('startup')
|
|
44
|
+
|
|
45
|
+
os.makedirs(CONFIG_DIR, exist_ok=True)
|
|
46
|
+
os.makedirs(DATA_DIR, exist_ok=True)
|
|
47
|
+
|
|
48
|
+
## Global variables
|
|
49
|
+
watcher = None
|
|
50
|
+
watcher_thread = None
|
|
51
|
+
pool = None # Set by entrypoint after WorkerPool is created
|
|
52
|
+
|
|
53
|
+
# Configure logging
|
|
54
|
+
formatter = ColoredFormatter(LOG_FORMAT, datefmt=LOG_DATEFMT)
|
|
55
|
+
handler = logging.StreamHandler(sys.stdout)
|
|
56
|
+
handler.setFormatter(formatter)
|
|
57
|
+
|
|
58
|
+
logging.basicConfig(
|
|
59
|
+
level=logging.INFO,
|
|
60
|
+
handlers=[handler]
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
# Create main logger
|
|
64
|
+
logger = logging.getLogger('main')
|
|
65
|
+
logger.setLevel(logging.DEBUG)
|
|
66
|
+
|
|
67
|
+
# Apply filter to hide date from http access logs
|
|
68
|
+
logging.getLogger('werkzeug').addFilter(FilterRemoveDateFromWerkzeugLogs())
|
|
69
|
+
|
|
70
|
+
# Suppress specific Alembic INFO logs
|
|
71
|
+
logging.getLogger('alembic.runtime.migration').setLevel(logging.WARNING)
|
|
72
|
+
|
|
73
|
+
@login_manager.user_loader
|
|
74
|
+
def load_user(user_id):
|
|
75
|
+
# since the user_id is just the primary key of our user table, use it in the query for the user
|
|
76
|
+
return User.query.filter_by(id=user_id).first()
|
|
77
|
+
|
|
78
|
+
def on_settings_change():
|
|
79
|
+
"""Settings file changed: refresh cache, scale workers, and re-apply watcher config."""
|
|
80
|
+
settings = get_settings()
|
|
81
|
+
if pool is not None:
|
|
82
|
+
desired = max(1, settings.get('worker', {}).get('count', 1))
|
|
83
|
+
if desired != pool.count:
|
|
84
|
+
logger.info(f'Settings changed: scaling workers from {pool.count} to {desired}')
|
|
85
|
+
pool.scale(desired)
|
|
86
|
+
if watcher is not None:
|
|
87
|
+
# Reconcile off this thread: this callback runs inside the native observer's dispatch,
|
|
88
|
+
# which holds the observer lock that schedule/unschedule also need.
|
|
89
|
+
threading.Thread(target=watcher.reconcile, daemon=True).start()
|
|
90
|
+
|
|
91
|
+
def on_library_change(events):
|
|
92
|
+
"""Enqueue individual tasks per file event, skipping ignored events."""
|
|
93
|
+
with app.app_context():
|
|
94
|
+
for event in events:
|
|
95
|
+
if event.type == 'deleted':
|
|
96
|
+
# Ignore marks are written before our own removes, so they are checked ahead of
|
|
97
|
+
# the temp mark: a converted source carries both, and a mark left unpopped leaks.
|
|
98
|
+
if pop_ignored_event(src_path=event.src_path, dest_path=''):
|
|
99
|
+
continue
|
|
100
|
+
# Also check if this delete is part of a move (dest_path != '')
|
|
101
|
+
if pop_ignored_event(src_path=event.src_path):
|
|
102
|
+
continue
|
|
103
|
+
# A path a task is writing is not a library file - unless it is a committed row,
|
|
104
|
+
# in which case the file really is gone and has to leave the library.
|
|
105
|
+
if is_temp_file(event.src_path) and not file_exists_in_db(event.src_path):
|
|
106
|
+
continue
|
|
107
|
+
tasks_mod.enqueue_task('handle_file_deleted', {
|
|
108
|
+
'filepath': event.src_path,
|
|
109
|
+
})
|
|
110
|
+
continue
|
|
111
|
+
if is_temp_file(event.src_path) or (
|
|
112
|
+
event.dest_path and is_temp_file(event.dest_path)):
|
|
113
|
+
continue
|
|
114
|
+
if event.type == 'moved':
|
|
115
|
+
if pop_ignored_event(src_path=event.src_path, dest_path=event.dest_path):
|
|
116
|
+
continue
|
|
117
|
+
tasks_mod.enqueue_task('handle_file_moved', {
|
|
118
|
+
'library_path': event.directory,
|
|
119
|
+
'src_path': event.src_path,
|
|
120
|
+
'dest_path': event.dest_path,
|
|
121
|
+
})
|
|
122
|
+
elif event.type == 'created':
|
|
123
|
+
if pop_ignored_event(dest_path=event.src_path):
|
|
124
|
+
continue
|
|
125
|
+
tasks_mod.enqueue_task('handle_file_added', {
|
|
126
|
+
'library_path': event.directory,
|
|
127
|
+
'filepath': event.src_path,
|
|
128
|
+
})
|
|
129
|
+
elif event.type == 'modified':
|
|
130
|
+
tasks_mod.enqueue_task('handle_file_added', {
|
|
131
|
+
'library_path': event.directory,
|
|
132
|
+
'filepath': event.src_path,
|
|
133
|
+
})
|
|
134
|
+
elif event.type == 'dir_deleted':
|
|
135
|
+
# A folder was moved out/removed; its files are gone, delete them by path prefix.
|
|
136
|
+
tasks_mod.enqueue_task('handle_dir_deleted', {'dirpath': event.src_path})
|
|
137
|
+
|
|
138
|
+
def create_app(db_uri=None):
|
|
139
|
+
app = Flask(__name__)
|
|
140
|
+
app.url_map.strict_slashes = False # Disable automatic trailing slash redirects globally, needed for Sphaira
|
|
141
|
+
app.config["SQLALCHEMY_DATABASE_URI"] = db_uri or OWNFOIL_DB
|
|
142
|
+
# TODO: generate random secret_key
|
|
143
|
+
app.config['SECRET_KEY'] = '8accb915665f11dfa15c2db1a4e8026905f57716'
|
|
144
|
+
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
|
|
145
|
+
|
|
146
|
+
db.init_app(app)
|
|
147
|
+
migrate.init_app(app, db)
|
|
148
|
+
login_manager.init_app(app)
|
|
149
|
+
|
|
150
|
+
app.register_blueprint(auth_blueprint)
|
|
151
|
+
|
|
152
|
+
return app
|
|
153
|
+
|
|
154
|
+
# Create app
|
|
155
|
+
app = create_app()
|
|
156
|
+
|
|
157
|
+
# Realtime WebSocket. ping_interval keeps idle sockets alive through proxies and lets
|
|
158
|
+
# the server notice a peer that went away without closing.
|
|
159
|
+
app.config['SOCK_SERVER_OPTIONS'] = {'ping_interval': 25}
|
|
160
|
+
sock = Sock(app)
|
|
161
|
+
realtime.init_app(app)
|
|
162
|
+
import task_events # noqa: F401 — registers the tasks/workers topics
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@sock.route('/api/ws')
|
|
166
|
+
def realtime_ws(ws):
|
|
167
|
+
"""Multiplexed realtime stream: /api/ws?topics=tasks,workers"""
|
|
168
|
+
requested = [t for t in request.args.get('topics', '').split(',') if t]
|
|
169
|
+
realtime.serve(ws, requested)
|
|
170
|
+
|
|
171
|
+
# Add proper Seek support to Range requests
|
|
172
|
+
from werkzeug.wsgi import FileWrapper as WerkzeugFileWrapper
|
|
173
|
+
|
|
174
|
+
@app.before_request
|
|
175
|
+
def use_seekable_file_wrapper_for_range_requests():
|
|
176
|
+
"""
|
|
177
|
+
Gunicorn's WSGI FileWrapper does not expose seekable(), seek()
|
|
178
|
+
and tell(). Werkzeug therefore handles Range requests by reading
|
|
179
|
+
and discarding the file from the beginning.
|
|
180
|
+
|
|
181
|
+
Use Werkzeug's seekable wrapper for Range requests.
|
|
182
|
+
"""
|
|
183
|
+
if request.headers.get("Range"):
|
|
184
|
+
request.environ["wsgi.file_wrapper"] = WerkzeugFileWrapper
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
# List of supported client classes
|
|
188
|
+
SUPPORTED_CLIENTS = [CyberFoilClient, TinfoilClient, SphairaClient]
|
|
189
|
+
|
|
190
|
+
def get_client_for_request(request):
|
|
191
|
+
"""Identify and return the appropriate client for the request, or None if no client matches."""
|
|
192
|
+
for client_class in SUPPORTED_CLIENTS:
|
|
193
|
+
if client_class.identify_client(request):
|
|
194
|
+
return client_class(get_settings())
|
|
195
|
+
return None
|
|
196
|
+
|
|
197
|
+
def file_access(f):
|
|
198
|
+
"""Decorator for file serving endpoints with basic authentication (no client identification required)."""
|
|
199
|
+
@wraps(f)
|
|
200
|
+
def _file_access(*args, **kwargs):
|
|
201
|
+
# Check if shop is private
|
|
202
|
+
if not get_settings()['shop']['public']:
|
|
203
|
+
# Shop is private, require authentication
|
|
204
|
+
auth_success, auth_error, user = basic_auth(request)
|
|
205
|
+
if not auth_success:
|
|
206
|
+
return jsonify({'error': auth_error}), 401
|
|
207
|
+
elif not user.has_shop_access():
|
|
208
|
+
return jsonify({'error': f'User "{user.user}" does not have access to the shop.'}), 403
|
|
209
|
+
|
|
210
|
+
return f(*args, **kwargs)
|
|
211
|
+
return _file_access
|
|
212
|
+
|
|
213
|
+
def access_shop():
|
|
214
|
+
return render_template('index.html', title='Library', admin_account_created=admin_account_created())
|
|
215
|
+
|
|
216
|
+
@access_required('shop')
|
|
217
|
+
def access_shop_auth():
|
|
218
|
+
return access_shop()
|
|
219
|
+
|
|
220
|
+
@app.route('/', defaults={'path': ''})
|
|
221
|
+
@app.route('/<path:path>')
|
|
222
|
+
def index(path=None):
|
|
223
|
+
"""Main shop endpoint routing to either client-specific shop or web browser UI."""
|
|
224
|
+
# Check if this is a client request
|
|
225
|
+
client = get_client_for_request(request)
|
|
226
|
+
|
|
227
|
+
if client:
|
|
228
|
+
# Check if client is enabled
|
|
229
|
+
client_name = client.CLIENT_NAME.lower()
|
|
230
|
+
client_settings = get_settings().get('shop', {}).get('clients', {}).get(client_name, {})
|
|
231
|
+
if not client_settings.get('enabled', False):
|
|
232
|
+
logger.warning(f"{client.CLIENT_NAME} connection from {request.remote_addr} - Client is disabled")
|
|
233
|
+
return client.error_response(f"Shop access from {client.CLIENT_NAME} is disabled.")
|
|
234
|
+
|
|
235
|
+
# Handle client request
|
|
236
|
+
logger.info(f"{client.CLIENT_NAME} connection from {request.remote_addr}")
|
|
237
|
+
return client.handle_request(request)
|
|
238
|
+
|
|
239
|
+
# Browser request - serve web UI
|
|
240
|
+
elif path:
|
|
241
|
+
return redirect('/')
|
|
242
|
+
|
|
243
|
+
if not get_settings()['shop']['public']:
|
|
244
|
+
return access_shop_auth()
|
|
245
|
+
return access_shop()
|
|
246
|
+
|
|
247
|
+
@app.route('/admin')
|
|
248
|
+
@access_required('admin')
|
|
249
|
+
def admin_page():
|
|
250
|
+
return redirect('/admin/settings')
|
|
251
|
+
|
|
252
|
+
@app.route('/admin/settings')
|
|
253
|
+
@access_required('admin')
|
|
254
|
+
def settings_page():
|
|
255
|
+
with open(os.path.join(TITLEDB_DIR, 'languages.json')) as f:
|
|
256
|
+
languages = json.load(f)
|
|
257
|
+
languages = dict(sorted(languages.items()))
|
|
258
|
+
return render_template(
|
|
259
|
+
'settings.html',
|
|
260
|
+
title='Settings',
|
|
261
|
+
languages_from_titledb=languages,
|
|
262
|
+
admin_account_created=admin_account_created())
|
|
263
|
+
|
|
264
|
+
@app.route('/admin/tasks')
|
|
265
|
+
@access_required('admin')
|
|
266
|
+
def tasks_page():
|
|
267
|
+
import task_events
|
|
268
|
+
return render_template('tasks.html', title='Tasks',
|
|
269
|
+
max_tasks=task_events.MAX_TASKS,
|
|
270
|
+
admin_account_created=admin_account_created())
|
|
271
|
+
|
|
272
|
+
@app.route('/admin/stats')
|
|
273
|
+
@access_required('admin')
|
|
274
|
+
def stats_page():
|
|
275
|
+
return render_template('stats.html', title='Stats',
|
|
276
|
+
admin_account_created=admin_account_created())
|
|
277
|
+
|
|
278
|
+
@app.route('/setup')
|
|
279
|
+
def setup_page():
|
|
280
|
+
"""Setup page showing client information and connection instructions."""
|
|
281
|
+
settings = get_settings()
|
|
282
|
+
|
|
283
|
+
# Check if user has access (must have shop access or shop must be public)
|
|
284
|
+
if not settings['shop']['public'] and admin_account_created():
|
|
285
|
+
if not current_user.is_authenticated:
|
|
286
|
+
return login_manager.unauthorized()
|
|
287
|
+
if not current_user.has_shop_access():
|
|
288
|
+
return 'Forbidden', 403
|
|
289
|
+
|
|
290
|
+
local_address = None
|
|
291
|
+
local_port = None
|
|
292
|
+
|
|
293
|
+
# Get remote host from configuration
|
|
294
|
+
remote_host = settings['shop'].get('host', '')
|
|
295
|
+
|
|
296
|
+
# Check if we're accessing via the configured remote host
|
|
297
|
+
# If so, hide the local tab since we're already remote
|
|
298
|
+
show_local_tab = not remote_host or remote_host != request.host
|
|
299
|
+
if show_local_tab:
|
|
300
|
+
# The Host header is the browser's view of the server (localhost, an mDNS name, a
|
|
301
|
+
# container name) and is often unreachable from the Switch, so prefer our own LAN IP.
|
|
302
|
+
local_address = get_lan_ip() or request.host.split(':')[0]
|
|
303
|
+
local_port = request.host.split(':')[1] if ':' in request.host else 80
|
|
304
|
+
|
|
305
|
+
# Check if clients are enabled
|
|
306
|
+
tinfoil_enabled = settings.get('shop', {}).get('clients', {}).get('tinfoil', {}).get('enabled', False)
|
|
307
|
+
sphaira_enabled = settings.get('shop', {}).get('clients', {}).get('sphaira', {}).get('enabled', False)
|
|
308
|
+
cyberfoil_enabled = settings.get('shop', {}).get('clients', {}).get('cyberfoil', {}).get('enabled', False)
|
|
309
|
+
|
|
310
|
+
# Check if shop is public
|
|
311
|
+
shop_public = settings['shop']['public']
|
|
312
|
+
|
|
313
|
+
return render_template(
|
|
314
|
+
'setup.html',
|
|
315
|
+
title='Setup',
|
|
316
|
+
local_address=local_address,
|
|
317
|
+
local_port=local_port,
|
|
318
|
+
remote_host=remote_host,
|
|
319
|
+
show_local_tab=show_local_tab,
|
|
320
|
+
tinfoil_enabled=tinfoil_enabled,
|
|
321
|
+
sphaira_enabled=sphaira_enabled,
|
|
322
|
+
cyberfoil_enabled=cyberfoil_enabled,
|
|
323
|
+
shop_public=shop_public,
|
|
324
|
+
admin_account_created=admin_account_created()
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
@app.get('/api/settings')
|
|
328
|
+
@access_required('admin')
|
|
329
|
+
def get_settings_api():
|
|
330
|
+
settings = copy.deepcopy(get_settings())
|
|
331
|
+
# Inject runtime key status for the UI
|
|
332
|
+
valid, missing, corrupt = load_keys()
|
|
333
|
+
settings['titles']['valid_keys'] = valid
|
|
334
|
+
settings['titles']['missing_keys'] = missing
|
|
335
|
+
settings['titles']['corrupt_keys'] = corrupt
|
|
336
|
+
# Strip hauth values for privacy (don't send to client)
|
|
337
|
+
if 'clients' in settings['shop']:
|
|
338
|
+
for client_name, client_settings in settings['shop']['clients'].items():
|
|
339
|
+
if 'hauth' in client_settings:
|
|
340
|
+
# Replace hauth dict with empty dict to keep it private
|
|
341
|
+
settings['shop']['clients'][client_name]['hauth'] = {}
|
|
342
|
+
return jsonify(settings)
|
|
343
|
+
|
|
344
|
+
@app.post('/api/settings/titles')
|
|
345
|
+
@access_required('admin')
|
|
346
|
+
def set_titles_settings_api():
|
|
347
|
+
title_settings = request.json
|
|
348
|
+
region = title_settings['region']
|
|
349
|
+
language = title_settings['language']
|
|
350
|
+
with open(os.path.join(TITLEDB_DIR, 'languages.json')) as f:
|
|
351
|
+
languages = json.load(f)
|
|
352
|
+
languages = dict(sorted(languages.items()))
|
|
353
|
+
|
|
354
|
+
if region not in languages or language not in languages[region]:
|
|
355
|
+
resp = {
|
|
356
|
+
'success': False,
|
|
357
|
+
'errors': [{
|
|
358
|
+
'path': 'titles',
|
|
359
|
+
'error': f"The region/language pair {region}/{language} is not available."
|
|
360
|
+
}]
|
|
361
|
+
}
|
|
362
|
+
return jsonify(resp)
|
|
363
|
+
|
|
364
|
+
current = get_settings()
|
|
365
|
+
if region != current['titles']['region'] or language != current['titles']['language']:
|
|
366
|
+
set_titles_settings(region, language)
|
|
367
|
+
tasks_mod.update_scheduled_task('update_titledb', datetime.datetime.utcnow())
|
|
368
|
+
|
|
369
|
+
resp = {
|
|
370
|
+
'success': True,
|
|
371
|
+
'errors': []
|
|
372
|
+
}
|
|
373
|
+
return jsonify(resp)
|
|
374
|
+
|
|
375
|
+
@app.post('/api/settings/shop')
|
|
376
|
+
@access_required('admin')
|
|
377
|
+
def set_shop_settings_api():
|
|
378
|
+
data = request.json
|
|
379
|
+
set_shop_settings(data)
|
|
380
|
+
resp = {
|
|
381
|
+
'success': True,
|
|
382
|
+
'errors': []
|
|
383
|
+
}
|
|
384
|
+
return jsonify(resp)
|
|
385
|
+
|
|
386
|
+
@app.route('/api/settings/library/paths', methods=['GET', 'POST', 'DELETE'])
|
|
387
|
+
@access_required('admin')
|
|
388
|
+
def library_paths_api():
|
|
389
|
+
global watcher
|
|
390
|
+
if request.method == 'POST':
|
|
391
|
+
data = request.json
|
|
392
|
+
success, errors = add_library_complete(app, watcher, data['path'])
|
|
393
|
+
if success:
|
|
394
|
+
tasks_mod.enqueue_task('scan_library', {'library_path': data['path']})
|
|
395
|
+
resp = {
|
|
396
|
+
'success': success,
|
|
397
|
+
'errors': errors
|
|
398
|
+
}
|
|
399
|
+
elif request.method == 'GET':
|
|
400
|
+
resp = {'success': True, 'errors': [], 'paths': get_library_paths(),
|
|
401
|
+
'watcher': get_watcher_config()}
|
|
402
|
+
elif request.method == 'DELETE':
|
|
403
|
+
data = request.json
|
|
404
|
+
success, errors = remove_library_complete(app, watcher, data['path'])
|
|
405
|
+
resp = {
|
|
406
|
+
'success': success,
|
|
407
|
+
'errors': errors
|
|
408
|
+
}
|
|
409
|
+
return jsonify(resp)
|
|
410
|
+
|
|
411
|
+
@app.post('/api/settings/library/watcher')
|
|
412
|
+
@access_required('admin')
|
|
413
|
+
def set_library_watcher_settings_api():
|
|
414
|
+
global watcher
|
|
415
|
+
success, errors = set_watcher_settings(request.json)
|
|
416
|
+
if not success:
|
|
417
|
+
return jsonify({'success': success, 'errors': errors})
|
|
418
|
+
# Re-apply the new config live (runs in the request thread, not the observer thread)
|
|
419
|
+
watcher.reconcile()
|
|
420
|
+
return jsonify({'success': True, 'errors': []})
|
|
421
|
+
|
|
422
|
+
@app.post('/api/settings/library/management')
|
|
423
|
+
@access_required('admin')
|
|
424
|
+
def set_library_management_settings_api():
|
|
425
|
+
data = request.json
|
|
426
|
+
set_library_management_settings(data)
|
|
427
|
+
tasks_mod.enqueue_task('process_library')
|
|
428
|
+
resp = {
|
|
429
|
+
'success': True,
|
|
430
|
+
'errors': []
|
|
431
|
+
}
|
|
432
|
+
return jsonify(resp)
|
|
433
|
+
|
|
434
|
+
@app.post('/api/settings/scheduler')
|
|
435
|
+
@access_required('admin')
|
|
436
|
+
def set_scheduler_settings_api():
|
|
437
|
+
from utils import interval_string_to_timedelta
|
|
438
|
+
data = request.json
|
|
439
|
+
titledb_update_interval_str = data.get('titledb_update_interval')
|
|
440
|
+
|
|
441
|
+
if titledb_update_interval_str is not None:
|
|
442
|
+
is_valid, error_msg = validate_interval_string(titledb_update_interval_str)
|
|
443
|
+
if not is_valid:
|
|
444
|
+
return jsonify({
|
|
445
|
+
'success': False,
|
|
446
|
+
'errors': [{'path': 'scheduler/titledb_update_interval', 'error': error_msg}]
|
|
447
|
+
})
|
|
448
|
+
|
|
449
|
+
set_scheduler_settings(data)
|
|
450
|
+
|
|
451
|
+
if titledb_update_interval_str is not None:
|
|
452
|
+
delta = interval_string_to_timedelta(titledb_update_interval_str)
|
|
453
|
+
run_after = datetime.datetime.utcnow() + delta if delta else None
|
|
454
|
+
tasks_mod.update_scheduled_task('update_titledb', run_after)
|
|
455
|
+
|
|
456
|
+
return jsonify({'success': True, 'errors': []})
|
|
457
|
+
|
|
458
|
+
@app.post('/api/settings/worker')
|
|
459
|
+
@access_required('admin')
|
|
460
|
+
def set_worker_settings_api():
|
|
461
|
+
data = request.json
|
|
462
|
+
count = data.get('count')
|
|
463
|
+
if count is not None:
|
|
464
|
+
try:
|
|
465
|
+
count = int(count)
|
|
466
|
+
except (TypeError, ValueError):
|
|
467
|
+
return jsonify({'success': False, 'errors': [{'path': 'worker/count', 'error': 'Must be an integer'}]})
|
|
468
|
+
if count < 1:
|
|
469
|
+
return jsonify({'success': False, 'errors': [{'path': 'worker/count', 'error': 'Must be at least 1'}]})
|
|
470
|
+
data['count'] = count
|
|
471
|
+
group_limits = data.get('group_limits')
|
|
472
|
+
if group_limits is not None:
|
|
473
|
+
try:
|
|
474
|
+
io = int(group_limits['io'])
|
|
475
|
+
except (TypeError, ValueError, KeyError):
|
|
476
|
+
return jsonify({'success': False, 'errors': [{'path': 'worker/group_limits', 'error': 'Must be an integer'}]})
|
|
477
|
+
if io < 1:
|
|
478
|
+
return jsonify({'success': False, 'errors': [{'path': 'worker/group_limits', 'error': 'Must be at least 1'}]})
|
|
479
|
+
# Merge into current limits so any other groups are preserved.
|
|
480
|
+
current = get_settings().get('worker', {}).get('group_limits', {})
|
|
481
|
+
data['group_limits'] = {**current, 'io': io}
|
|
482
|
+
set_worker_settings(data)
|
|
483
|
+
return jsonify({'success': True, 'errors': []})
|
|
484
|
+
|
|
485
|
+
@app.post('/api/upload')
|
|
486
|
+
@access_required('admin')
|
|
487
|
+
def upload_file():
|
|
488
|
+
errors = []
|
|
489
|
+
success = False
|
|
490
|
+
valid_keys = None
|
|
491
|
+
try:
|
|
492
|
+
file = request.files['file']
|
|
493
|
+
if file and allowed_file(file.filename):
|
|
494
|
+
# filename = secure_filename(file.filename)
|
|
495
|
+
file.save(KEYS_FILE)
|
|
496
|
+
logger.info(f'Validating {file.filename}...')
|
|
497
|
+
valid_keys, missing_keys, corrupt_keys = load_keys(KEYS_FILE)
|
|
498
|
+
if valid_keys:
|
|
499
|
+
tasks_mod.enqueue_task('process_library')
|
|
500
|
+
else:
|
|
501
|
+
logger.warning(f'Invalid keys from {file.filename}')
|
|
502
|
+
success = True
|
|
503
|
+
logger.info('Successfully saved keys.txt')
|
|
504
|
+
|
|
505
|
+
except Exception as e:
|
|
506
|
+
logger.error(f'Failed to upload console keys file: {e}')
|
|
507
|
+
os.remove(KEYS_FILE)
|
|
508
|
+
success = False
|
|
509
|
+
errors.append(str(e))
|
|
510
|
+
|
|
511
|
+
resp = {
|
|
512
|
+
'success': success,
|
|
513
|
+
'errors': errors,
|
|
514
|
+
'data': {}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
if valid_keys is not None:
|
|
518
|
+
resp['data']['valid_keys'] = valid_keys
|
|
519
|
+
resp['data']['missing_keys'] = missing_keys
|
|
520
|
+
resp['data']['corrupt_keys'] = corrupt_keys
|
|
521
|
+
|
|
522
|
+
return jsonify(resp)
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
from gql import graphql_dispatch
|
|
526
|
+
|
|
527
|
+
app.add_url_rule(
|
|
528
|
+
'/api/graphql',
|
|
529
|
+
view_func=graphql_dispatch,
|
|
530
|
+
methods=['GET', 'POST'],
|
|
531
|
+
)
|
|
532
|
+
|
|
533
|
+
@app.route('/api/get_game/<int:id>')
|
|
534
|
+
@file_access
|
|
535
|
+
def serve_game(id):
|
|
536
|
+
"""Serve a game file to authenticated clients."""
|
|
537
|
+
filepath = db.session.query(Files.filepath).filter_by(id=id).scalar()
|
|
538
|
+
if not filepath:
|
|
539
|
+
return jsonify({'error': f'No file with id {id}.'}), 404
|
|
540
|
+
filedir, filename = os.path.split(filepath)
|
|
541
|
+
# Count only once the response exists: clients probe files with a Range before taking
|
|
542
|
+
# them, and a range past the end of the file raises out of here without transferring.
|
|
543
|
+
response = send_from_directory(filedir, filename)
|
|
544
|
+
increment_download_count_throttled(filepath, client_address(request))
|
|
545
|
+
return response
|