Secure OIDC login and cleanup

This commit is contained in:
Ventilaar 2024-04-06 22:57:46 +02:00
parent 1be9729720
commit f90b0bdc42
No known key found for this signature in database
15 changed files with 404 additions and 208 deletions

View File

@ -7,30 +7,25 @@ def create_app(test_config=None):
from . import filters
config = {'MONGO_CONNECTION': os.environ.get('AYTA_MONGOCONNECTION', 'mongodb://root:example@192.168.66.140:27017'),
'OIDC_CLIENT_SECRETS': os.environ.get('AYTA_OIDC_PATH', None),
'OIDC_PROVIDER': os.environ.get('AYTA_OIDC_PROVIDER', 'https://auth.ventilaar.nl'),
'OIDC_ID': os.environ.get('AYTA_OIDC_ID', 'ayta'),
'CACHE_TYPE': os.environ.get('AYTA_CACHETYPE', 'SimpleCache'),
'CACHE_DEFAULT_TIMEOUT': int(os.environ.get('AYTA_CACHETIMEOUT', 6)),
'SECRET_KEY': os.environ.get('AYTA_SECRETKEY', secrets.token_hex(32)),
'DEBUG': bool(os.environ.get('AYTA_DEBUG', False)),
'DOMAIN': os.environ.get('AYTA_DOMAIN', 'testing.mashallah.nl'),
'DOMAIN': os.environ.get('AYTA_DOMAIN', 'https://testing.mashallah.nl'),
'CELERY': dict(broker_url=str(os.environ.get('AYTA_CELERYBROKER', 'amqp://guest:guest@192.168.66.140:5672/')),
task_ignore_result=True,)
}
# Static configuration settings, do not change
config['OIDC_CALLBACK_ROUTE'] = '/api/oidc/callback' # why is this excension not using it? maybe i should implement oidc by myself?
app = Flask(__name__)
app.config.from_mapping(config)
limiter.init_app(app)
caching.init_app(app)
oidc.init_app(app)
celery_init_app(app)
if app.config['OIDC_CLIENT_SECRETS']:
oidc.init_app(app)
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1)
app.jinja_env.filters['pretty_duration'] = filters.pretty_duration

View File

@ -1,6 +1,5 @@
from flask import Blueprint, render_template, request, redirect, url_for, flash
from ..nosql import get_nosql
from ..s3 import get_s3
from ..dlp import checkChannelId, getChannelInfo
from ..decorators import login_required
from ..tasks import subscribe_websub_callback, unsubscribe_websub_callback
@ -192,10 +191,30 @@ def posters():
return render_template('admin/posters.html', endpoints=endpoints, queue=queue)
@bp.route('/files', methods=['GET', 'POST'])
@bp.route('/users', methods=['GET', 'POST'])
@login_required
def files():
run = get_s3().list_objects()
return str(run)
def users():
if request.method == 'POST':
task = request.form.get('task', None)
value = request.form.get('value', None)
if task == 'add-user':
alias = request.form.get('alias', None)
description = request.form.get('description', None)
if value is None or alias is None:
flash('Missing fields')
return redirect(url_for('admin.users'))
doc_id = get_nosql().add_user(value, alias, description)
flash(f'User added: {doc_id}')
return redirect(url_for('admin.users'))
if task == 'delete-user':
get_nosql().delete_user(value)
flash(f'User deleted: {value}')
return redirect(url_for('admin.users'))
users = get_nosql().list_all_users()
return render_template('admin/users.html', users=users)

View File

@ -1,10 +1,8 @@
from flask import Blueprint, redirect, url_for, render_template, request, session, flash, current_app
from ..extensions import limiter, caching, caching_unless
from flask import Blueprint, redirect, url_for, render_template, request, session, flash, current_app, redirect
from ..extensions import limiter, caching, caching_unless, oidc
from ..nosql import get_nosql
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
corr = '$argon2id$v=19$m=65536,t=3,p=4$XzX9K2MKRrGWEf/0iHf2AA$m6Q/aHoj1/uct+8a00QTS5xVWnANeMPKVUg4P822sbM'
from time import sleep
bp = Blueprint('auth', __name__, url_prefix='/auth')
@ -27,7 +25,7 @@ def login():
password = request.form.get('password', None)
if current_app.config.get('DEBUG'):
session['username'] = 'admin'
session['username'] = 'DEBUG ADMIN'
flash('You have been logged in')
return redirect(request.args.get('next', url_for('admin.base')))
@ -35,19 +33,40 @@ def login():
flash('Password was empty')
return redirect(url_for('auth.login'))
try:
ph = PasswordHasher()
if ph.verify(corr, password):
session['username'] = 'admin'
flash('You have been logged in')
return redirect(request.args.get('next', url_for('admin.base')))
except VerifyMismatchError:
flash('Wrong password')
return redirect(url_for('auth.login'))
except:
flash('Something went wrong')
return redirect(url_for('auth.login'))
return render_template('login.html')
sleep(0.3)
flash('Wrong password')
return redirect(url_for('auth.login'))
return render_template('login.html')
@bp.route('/oidc', methods=['GET'])
def start_oidc():
return redirect(oidc.generate_redirect(), code=302)
@bp.route('/callback', methods=['POST'])
@limiter.limit('30 per day', override_defaults=False)
@caching.cached(unless=caching_unless)
def callback():
state = request.form.get('state', None)
id_token = request.form.get('id_token', None)
if request.form.get('error', None):
return f'We got an error from the authentication provider with the message: {request.form.get("error_description", None)}', 400
if state is None or id_token is None:
return 'Request error', 400
if not oidc.state_check(state):
return 'CSRF Error, state is not valid', 400
sub = oidc.check_bearer(id_token)
if not sub:
return f'Invalid JWT token we got: {id_token}', 400
if not get_nosql().get_user(sub):
return f'Authentication successful, but you are not allowed to access authenticated pages. Please report this ID to the administrators if you want access: {sub}', 403
session['username'] = sub
flash('You have been logged in')
return redirect(request.args.get('next', url_for('admin.base')))

View File

@ -1,6 +1,5 @@
from flask import Blueprint, render_template, flash, url_for, redirect
from ..nosql import get_nosql
from ..s3 import get_s3
from ..extensions import caching, caching_unless
bp = Blueprint('channel', __name__, url_prefix='/channel')

View File

@ -5,7 +5,7 @@ from flask_caching import Cache
from celery import Celery, Task
from flask_oidc import OpenIDConnect
from .oidc import OIDC
from flask import Flask, request, session
@ -48,4 +48,4 @@ limiter = Limiter(
caching = Cache()
oidc = OpenIDConnect()
oidc = OIDC()

View File

@ -3,8 +3,7 @@ import pymongo
import secrets
from bson.objectid import ObjectId
from flask import current_app
from flask import g
from flask import current_app, g
from .filters import current_time
@ -19,20 +18,6 @@ def get_nosql():
return g.nosql
def close_nosql(e=None):
"""If this request connected to the database, close the connection."""
nosql = g.pop("nosql", None)
if nosql is not None:
nosql.close()
def init_app(app):
"""Register database functions with the Flask app. This is called by the application factory."""
app.teardown_appcontext(close_nosql)
#app.cli.add_command(init_db_command)
##########################################
# ORM #
##########################################
@ -58,9 +43,9 @@ class Mango:
self.websub_data = self.db['websub_data']
self.reports = self.db['reports']
self.posters_endpoints = self.db['posters_endpoints']
self.users = self.db['users']
self.ensure_indexes()
#self.clean_info_json()
def ensure_indexes(self):
required = {
@ -132,6 +117,27 @@ class Mango:
results = sorted(results, key=lambda x: x.get('score'), reverse=True)[:20]
return tuple(results)
##########################################
# user operations #
##########################################
def list_all_users(self):
return self.users.find({})
def add_user(self, sub, alias, description=None):
return self.users.insert_one({'sub': sub, 'alias': alias, 'description': description}).inserted_id
def delete_user(self, sub):
self.users.delete_one({'sub': sub})
def get_user(self, sub):
""" Returns True if sub exists, otherwise False """
if self.users.count_documents({'sub': sub}) >= 1:
return True
return False
##########################################
# channel operations #
##########################################

125
ayta/oidc.py Normal file
View File

@ -0,0 +1,125 @@
class OIDC():
def __init__(self, app=None):
self.states = {}
self.nonces = {}
if app is not None:
self.init_app(app)
def init_app(self, app):
import requests
import jwt
config = app.config.copy()
self.client_id = config['OIDC_ID']
self.provider = config['OIDC_PROVIDER']
self.domain = config['DOMAIN']
if self.provider[:8] != 'https://' or self.provider[-1] == '/':
print('Incorrect OIDC provider URI', flush=True)
exit()
configuration = requests.get(f'{self.provider}/.well-known/openid-configuration').json()
jwks_uri = configuration.get('jwks_uri')
self.authorize_uri = configuration.get('authorization_endpoint')
self.jwks_manager = jwt.PyJWKClient(jwks_uri)
#################################
def state_maintenance(self):
from datetime import datetime
pivot = datetime.now().timestamp() - 120
expired_states = [state for state, timestamp in self.states.items() if timestamp <= pivot]
for state in expired_states:
del self.states[state]
def state_gen(self):
import secrets
from datetime import datetime
self.state_maintenance()
state = secrets.token_urlsafe(8)
timestamp = datetime.now().timestamp()
self.states[state] = timestamp
return state
def state_check(self, state):
self.state_maintenance()
if state in self.states:
del self.states[state]
return True
return False
#################################
def nonce_maintenance(self):
from datetime import datetime
pivot = datetime.now().timestamp() - 120
expired_nonces = [nonce for nonce, timestamp in self.nonces.items() if timestamp <= pivot]
for nonce in expired_nonces:
del self.nonces[nonce]
def nonce_gen(self):
import secrets
from datetime import datetime
self.nonce_maintenance()
nonce = secrets.token_urlsafe(8)
timestamp = datetime.now().timestamp()
self.nonces[nonce] = timestamp
return nonce
def nonce_check(self, nonce):
self.nonce_maintenance()
if nonce in self.nonces:
del self.nonces[nonce]
return True
return False
#################################
def generate_redirect(self):
return str(f'{self.authorize_uri}'
'?response_mode=form_post&response_type=id_token&scope=openid'
f'&redirect_uri={self.domain}/auth/callback'
f'&client_id={self.client_id}'
f'&nonce={self.nonce_gen()}'
f'&state={self.state_gen()}')
def check_bearer(self, token):
import jwt
try:
signing_key = self.jwks_manager.get_signing_key_from_jwt(token).key
decoded = jwt.decode(token, signing_key,
algorithms=jwt.algorithms.get_default_algorithms(),
issuer=self.provider,
require=['aud', 'client_id', 'exp', 'iat', 'iss', 'rat', 'sub'],
audience=self.client_id)
except:
return False
# double check if given token is really requested by us
if not self.nonce_check(decoded.get('nonce', None)):
return False
return decoded.get('sub', False)

View File

@ -1,50 +0,0 @@
from minio import Minio
from minio.error import S3Error
from flask import current_app
from flask import g
##########################################
# SETUP FLASK #
##########################################
def get_s3():
"""Connect to the application's configured database. The connection is unique for each request and will be reused if this is called again."""
if "s3" not in g:
g.s3 = Mineral(current_app.config["S3_CONNECTION"], current_app.config["S3_ACCESSKEY"], current_app.config["S3_SECRETKEY"])
return g.s3
def close_s3(e=None):
"""If this request connected to the database, close the connection."""
s3 = g.pop("s3", None)
if s3 is not None:
s3.close()
def init_app(app):
"""Register database functions with the Flask app. This is called by the application factory."""
app.teardown_appcontext(close_s3)
#app.cli.add_command(init_db_command)
##########################################
# ORM #
##########################################
class Mineral:
def __init__(self, location, access, secret):
try:
self.client = Minio(location, access_key=access, secret_key=secret, secure=False)
except S3Error as exc:
print('Minio connection error ', exc)
def list_objects(self, bucket='ytarchive'):
ret = self.client.list_objects(bucket, '')
rett = []
for r in ret:
print(r.object_name, flush=True)
rett.append(r)
return rett

View File

@ -70,7 +70,17 @@
<div class="card black-text">
<div class="card-content">
<span class="card-title">Posters</span>
<p class="grey-text">User extension posters</p>
<p class="grey-text">Extension posters</p>
</div>
</div>
</a>
</div>
<div class="col s6 l4 m-4">
<a href="{{ url_for('admin.users') }}">
<div class="card black-text">
<div class="card-content">
<span class="card-title">Users</span>
<p class="grey-text">Authenticated users</p>
</div>
</div>
</a>

View File

@ -0,0 +1,82 @@
{% extends 'material_base.html' %}
{% block title %}Users administration page{% endblock %}
{% block description %}Users administration page of the AYTA system{% endblock %}
{% block content %}
<div class="row">
<div class="col s12 l11">
<h4>Users administration page</h4>
</div>
</div>
<div class="divider"></div>
<div class="row">
<div class="col s6 l9">
<h5>All users</h5>
</div>
</div>
<div class="row">
<div class="col s12 l4 m-4">
<div class="card">
<div class="card-content">
<span class="card-title">Authorize new user</span>
<form method="post">
<div class="row">
<div class="col s12 input-field">
<input placeholder="sub" name="value" type="text" class="validate" required>
<span class="supporting-text">Unique identifier</span>
</div>
<div class="col s12 input-field">
<input placeholder="Alias" name="alias" type="text" class="validate"required>
<span class="supporting-text">Name of the user</span>
</div>
<div class="col s12 input-field">
<input placeholder="Description" name="description" type="text" class="validate">
<span class="supporting-text">Additional information</span>
</div>
<button class="btn mt-4" type="submit" name="task" value="add-user">Create</button>
</div>
</form>
</div>
</div>
</div>
</div>
<div class="divider"></div>
<div class="row">
<div class="col s6 l9">
<h5>Registered users</h5>
</div>
<div class="col s6 l3 m-4 input-field">
<input id="filter_query" type="text">
<label for="filter_query">Filter results</label>
</div>
</div>
<div class="row">
<div class="col s12">
<table class="striped highlight responsive-table">
<thead>
<tr>
<th>Actions</th>
<th>sub</th>
<th>Alias</th>
<th>Description</th>
</tr>
</thead>
<tbody>
{% for user in users %}
<tr class="filterable">
<td>
<form method="post">
<input type="text" value="{{ user.get('sub') }}" name="value" hidden>
<button class="btn-small waves-effect waves-light" type="submit" name="task" value="delete-user" title="Delete user">🗑️</button>
</form>
</td>
<td>{{ user.get('sub') }}</td>
<td>{{ user.get('alias') }}</td>
<td>{{ user.get('description') }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}

View File

@ -19,7 +19,7 @@
</div>
</div>
<div class="row">
<div class="col s6 m-4 filterable">
<div class="col s6 m-4">
<a href="{{ url_for('channel.recent') }}">
<div class="card black-text">
<div class="card-content center">
@ -29,7 +29,7 @@
</div>
</a>
</div>
<div class="col s6 m-4 filterable">
<div class="col s6 m-4">
<a href="{{ url_for('channel.orphaned') }}">
<div class="card black-text">
<div class="card-content center">

View File

@ -4,7 +4,7 @@
{% block content %}
<div class="row">
<div class="col s12 l3">
<div class="col s12 l3 mr-4">
<h4>pls login</h4>
<form method="post">
<div class="input-field">
@ -12,10 +12,9 @@
</div>
<button class="btn mt-4" type="submit" name="action" value="login">Login</button>
</form>
<div class="divider"></div>
<a href="{{ url_for('auth.start_oidc') }}"><button class="btn mt-4 green">Login with OIDC</button></a>
</div>
</div>
<div class="divider"></div>
<div class="row">
<div class="col s12 l3">
<p>This is a WEBP-free archive</p>
<img class="responsive-img" src="{{ url_for('static', filename='img/fuck_webp.png') }}">

View File

@ -3,15 +3,15 @@
<head>
<link rel="icon" type="image/x-icon" href="/favicon.ico">
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='css/materialize.min.css') }}">
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='css/custom.css') }}">
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='css/custom.css') }}">
<script src="{{ url_for('static', filename='js/jquery-3.7.1.slim.min.js') }}"></script>
<script src="{{ url_for('static', filename='js/materialize.min.js') }}"></script>
<script src="{{ url_for('static', filename='js/custom.js') }}"></script>
<script src="{{ url_for('static', filename='js/materialize.min.js') }}"></script>
<script src="{{ url_for('static', filename='js/custom.js') }}"></script>
<title>{% block title %}{% endblock %} | AYTA</title>
<meta charset="UTF-8">
<meta name="description" content="{% block description %}{% endblock %}">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
{% block opengraph %}{% endblock %}
<meta name="viewport" content="width=device-width, initial-scale=1.0">
{% block opengraph %}{% endblock %}
</head>
<body class="grey lighten-2">
<header>
@ -20,45 +20,49 @@
<ul id="nav-mobile" class="left">
<li><a href="{{ url_for('channel.base') }}">Channels</a></li>
<li><a href="{{ url_for('admin.base') }}">Admin</a></li>
{% if config.get('DEBUG') %}<li><span class="new badge mt-5" data-badge-caption="True">Debug mode is</span></li>{% endif %}
</ul>
<a href="{{ url_for('index.base') }}" class="brand-logo center">AYTA</a>
<ul id="nav-mobile" class="right">
{% if 'username' in session %}<li><a href="{{ url_for('auth.logout') }}"><span class="new badge" data-badge-caption="{{ session.username }}">Logged in as</span></a></li>{% endif %}
<a href="{{ url_for('index.base') }}" class="brand-logo center">AYTA</a>
<ul id="nav-mobile" class="right">
<li><a href="{{ url_for('search.base') }}">Search</a></li>
<li><a href="{{ url_for('index.help') }}">Help</a></li>
</ul>
</div>
</nav>
</header>
<main>
{% with messages = get_flashed_messages() %}
{% if messages %}
</header>
<main>
{% with messages = get_flashed_messages() %}
{% if messages %}
{% for message in messages %}
<script>M.toast({text: '{{ message }}', displayLength: 5000, outDuration: 999, inDuration: 666})</script>
{% endfor %}
{% endif %}
{% endwith %}
<div class="container">
<noscript>Hey there, while I did build this application in mind to minimize javascript usage, the experience would be much better if you would enable it!</noscript>
{% block content %}{% endblock %}
</div>
</main>
<script>M.toast({text: '{{ message }}', displayLength: 5000, outDuration: 999, inDuration: 666})</script>
<noscript>A message appeared without supporting javasript: {{ message }}</noscript>
{% endfor %}
{% endif %}
{% endwith %}
<div class="container">
<noscript>Hey there, while I did build this application in mind to minimize javascript usage, the experience would be much better if you would enable it!</noscript>
{% block content %}{% endblock %}
</div>
</main>
<footer class="page-footer deep-orange">
<div class="container">
<div class="row">
<div class="s12 l6">
<div class="s12 l6 mr-4">
<h5>Awesome YouTube Archive</h5>
<p>A custom content management system for archived YouTube videos!</p>
<p>A custom content management system for archived YouTube videos.</p>
</div>
<div class="s12 l6">
<span class="new badge" data-badge-caption="{{ null|current_time }}">Page generated on</span>
<div class="s12 l6">
<h6>Still in development, slowly...</h6>
<h6>This is not a streaming website! Videos may buffer (a lot)!</h6>
<h6>This is not a streaming website! Videos may buffer (a lot)!</h6>
<div class="section mb-4">
<span class="new badge" data-badge-caption="{{ null|current_time }}">Page generated on</span>
{% if config.get('DEBUG') %}<span class="new badge" data-badge-caption="True">Debug mode is</span>{% endif %}
{% if 'username' in session %}<a href="{{ url_for('auth.logout') }}"><span class="new badge" data-badge-caption="{{ session.username }}">Logged in as</span></a>{% endif %}
</div>
</div>
</div>
</div>
</footer>
<script>M.AutoInit();</script>
<script>M.AutoInit();</script>
</body>
</html>

View File

@ -7,95 +7,85 @@
<meta property="og:type" content="website" />
<meta property="og:url" content="{{ url_for('watch.base') }}?v={{ render.get('info').get('id') }}" />
<meta property="og:image" content="https://archive.ventilaar.net/videos/automatic/{{ render.get('info').get('channel_id') }}/{{ render.get('info').get('id') }}/{{ render.get('info').get('title') }}.jpg" />
<meta property="og:description" content="{{ render.get('info').get('description')|truncate(100) }}" />
<meta property="og:description" content="{{ render.get('info').get('description', '')|truncate(100) }}" />
{% endblock %}
{% block content %}
<div class="row">
<div class="col s12">
<h4>{{ render.get('info').get('title') }}</h4>
</div>
<div class="col s3">
<p><b>Video by:</b> <a href="{{ url_for('channel.channel', channelId=render.get('info').get('channel_id')) }}">{{ render.get('info').get('uploader') }}</a></p>
</div>
<div class="col s3">
<p><b>Upload date:</b> {{ render.get('info').get('upload_date')|pretty_time }}</p>
</div>
<div class="col s3">
<p><b>Archive date:</b> {{ render.get('info').get('epoch')|epoch_time }}</p>
</div>
<div class="col s3">
<p><b>Video length:</b> {{ render.get('info').get('duration')|pretty_duration }}</p>
</div>
</div>
<div class="row">
<div class="col s12 center-align">
<div class="col s12 mt-4 center-align">
<video controls class="responsive-video">
<source src="https://archive.ventilaar.net/videos/automatic/{{ render.get('info').get('channel_id') }}/{{ render.get('info').get('id') }}/{{ render.get('info').get('title_slug') }}.mp4">
<source src="https://archive.ventilaar.net/videos/automatic/{{ render.get('info').get('channel_id') }}/{{ render.get('info').get('id') }}/{{ render.get('info').get('title_slug') }}.webm">
<source src="https://archive.ventilaar.net/videos/automatic/{{ render.get('info').get('channel_id') }}/{{ render.get('info').get('id') }}/{{ render.get('info').get('title_slug') }}.webm">
Your browser does not support the video tag.
</video>
</div>
</div>
<div class="row">
<div class="col s12 l9 center-align mr-4">
<div class="section">
<div class="row">
<div class="col s12 m3">
<p><a href="https://youtu.be/{{ render.get('info').get('id') }}" target="_blank" rel="noopener noreferrer">▶️ Watch on YouTube</a></p>
</div>
<div class="col s12 m3">
<p><a href="https://archive.ventilaar.net/videos/automatic/{{ render.get('info').get('channel_id') }}/{{ render.get('info').get('id') }}/">🗄️ Source files</a></p>
</div>
<div class="col s12 m3">
<p>Sample text</p>
</div>
<div class="col s12 m3 input-field">
<form method="post">
<select id="report" name="reason">
<option value="" disabled selected></option>
<option value="auto-video">Auto/Video Problems</option>
<option value="metadata">Incorrect metadata</option>
<option value="illegal">Illegal video</option>
</select>
<label for="report">Report a problem</label>
<button for="report" class="btn" type="submit" name="action">Submit report</button>
</form>
</div>
</div>
</div>
<div class="divider"></div>
<div class="section">
<div class="col s12 l9 mr-4">
<h5>{{ render.get('info').get('title') }}</h5>
</div>
<div class="col s12 l3">
<p><b>Video by:</b> <a href="{{ url_for('channel.channel', channelId=render.get('info').get('channel_id')) }}">{{ render.get('info').get('uploader') }}</a></p>
<p><b>Upload date:</b> {{ render.get('info').get('upload_date')|pretty_time }}</p>
<p><b>Archive date:</b> {{ render.get('info').get('epoch')|epoch_time }}</p>
<p><b>Video length:</b> {{ render.get('info').get('duration')|pretty_duration }}</p>
</div>
<div class="col s4 l3 center-align">
<p><a href="https://youtu.be/{{ render.get('info').get('id') }}" target="_blank" rel="noopener noreferrer">▶️ Watch on YouTube</a></p>
</div>
<div class="col s4 l3 center-align">
<p><a href="https://archive.ventilaar.net/videos/automatic/{{ render.get('info').get('channel_id') }}/{{ render.get('info').get('id') }}/">🗄️ Source files</a></p>
</div>
<div class="col s4 l3 center-align">
<p></p>
</div>
<div class="col s12 l3 center-align input-field">
<form method="post">
<select id="report" name="reason">
<option value="" disabled selected></option>
<option value="auto-video">Auto/Video Problems</option>
<option value="metadata">Incorrect metadata</option>
<option value="illegal">Illegal video</option>
</select>
<label for="report">Report a problem</label>
<button for="report" class="btn mt-4" type="submit" name="action">Submit report</button>
</form>
</div>
</div>
<div class="divider mt-4"></div>
<div class="row">
<div class="col s12 l9 mr-4">
<div class="section center-align">
<h5>Description</h5>
<p style="white-space: pre-wrap;" class="left-align">{{ render.get('info').get('description') }}</p>
</div>
<div class="divider"></div>
<div class="section input-field">
<div class="section center-align input-field">
<h5>Full info JSON dump</h5>
<textarea readonly class="materialize-textarea grey lighten">{{ render.get('info') }}</textarea>
<textarea readonly class="materialize-textarea grey lighten">{{ render.get('info') }}</textarea>
</div>
</div>
<div class="col s12 l3 ml-4">
<div class="section">
{% if render.get('info').get('categories') %}
{% if render.get('info').get('categories') %}
<h5>Categories</h5>
<ul class="collection">
{% for category in render.get('info').get('categories') %}
<ul class="collection">
{% for category in render.get('info').get('categories') %}
<li class="collection-item">{{ category }}</li>
{% endfor %}
{% endfor %}
</ul>
{% endif %}
{% endif %}
</div>
<div class="divider"></div>
<div class="section">
{% if render.get('info').get('tags') %}
{% if render.get('info').get('tags') %}
<h5>Tags</h5>
<ul class="collection">
{% for tag in render.get('info').get('tags') %}
{% for tag in render.get('info').get('tags') %}
<li class="collection-item">{{ tag }}</li>
{% endfor %}
{% endfor %}
</ul>
{% endif %}
{% endif %}
</div>
</div>
</div>

View File

@ -2,13 +2,11 @@
flask
flask-caching
flask-login
flask-oidc
flask-limiter
minio
pymongo
yt-dlp
argon2-cffi
gunicorn
celery
sqlalchemy
sqlalchemy
pyjwt