Compare commits

...

7 Commits

Author SHA1 Message Date
Ventilaar
7ccb827a9c hotfix the hotfix of the hotfix 2024-04-09 13:01:23 +02:00
Ventilaar
9c0e4fb63c Hotfix the websub hotfix. Add button to easily monitor websub callbacks. Clean stuck websub requests after 3 days 2024-04-09 12:56:57 +02:00
Ventilaar
75d42ad3cd Websub callback domain hotfix 2024-04-09 12:16:47 +02:00
Ventilaar
4fa0ee2c68 Hotfix channel sorting 2024-04-09 12:11:14 +02:00
Ventilaar
7e06c8673b Update PyJWT requirement 2024-04-06 23:27:18 +02:00
Ventilaar
96565e9e2b Add small time difference leeway 2024-04-06 23:23:32 +02:00
Ventilaar
f90b0bdc42 Secure OIDC login and cleanup 2024-04-06 22:57:46 +02:00
18 changed files with 416 additions and 217 deletions

View File

@@ -48,7 +48,7 @@ Extra functionality for further development of features.
- [x] Video reporting functionality - [x] Video reporting functionality
- [x] Ability (for external applications) to queue up video ids for download - [x] Ability (for external applications) to queue up video ids for download
- [x] Add websub requesting and receiving ability. (not fully usable yet without celery tasks) - [x] Add websub requesting and receiving ability. (not fully usable yet without celery tasks)
- [] OIDC or Webauthn logins instead of static argon2 passwords - [x] OIDC or Webauthn logins instead of static argon2 passwords
### Stage 3 ### Stage 3
Mainly focused on retiring the cronjob based scripts and moving it to celery based tasks Mainly focused on retiring the cronjob based scripts and moving it to celery based tasks

View File

@@ -7,29 +7,24 @@ def create_app(test_config=None):
from . import filters from . import filters
config = {'MONGO_CONNECTION': os.environ.get('AYTA_MONGOCONNECTION', 'mongodb://root:example@192.168.66.140:27017'), 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_TYPE': os.environ.get('AYTA_CACHETYPE', 'SimpleCache'),
'CACHE_DEFAULT_TIMEOUT': int(os.environ.get('AYTA_CACHETIMEOUT', 6)), 'CACHE_DEFAULT_TIMEOUT': int(os.environ.get('AYTA_CACHETIMEOUT', 6)),
'SECRET_KEY': os.environ.get('AYTA_SECRETKEY', secrets.token_hex(32)), 'SECRET_KEY': os.environ.get('AYTA_SECRETKEY', secrets.token_hex(32)),
'DEBUG': bool(os.environ.get('AYTA_DEBUG', False)), '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/')), 'CELERY': dict(broker_url=str(os.environ.get('AYTA_CELERYBROKER', 'amqp://guest:guest@192.168.66.140:5672/')),
task_ignore_result=True,) 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 = Flask(__name__)
app.config.from_mapping(config) app.config.from_mapping(config)
limiter.init_app(app) limiter.init_app(app)
caching.init_app(app) caching.init_app(app)
celery_init_app(app)
if app.config['OIDC_CLIENT_SECRETS']:
oidc.init_app(app) oidc.init_app(app)
celery_init_app(app)
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1) app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1)

View File

@@ -1,6 +1,5 @@
from flask import Blueprint, render_template, request, redirect, url_for, flash from flask import Blueprint, render_template, request, redirect, url_for, flash
from ..nosql import get_nosql from ..nosql import get_nosql
from ..s3 import get_s3
from ..dlp import checkChannelId, getChannelInfo from ..dlp import checkChannelId, getChannelInfo
from ..decorators import login_required from ..decorators import login_required
from ..tasks import subscribe_websub_callback, unsubscribe_websub_callback 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) return render_template('admin/posters.html', endpoints=endpoints, queue=queue)
@bp.route('/users', methods=['GET', 'POST'])
@bp.route('/files', methods=['GET', 'POST'])
@login_required @login_required
def files(): def users():
run = get_s3().list_objects() if request.method == 'POST':
return str(run) 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 flask import Blueprint, redirect, url_for, render_template, request, session, flash, current_app, redirect
from ..extensions import limiter, caching, caching_unless from ..extensions import limiter, caching, caching_unless, oidc
from ..nosql import get_nosql
from argon2 import PasswordHasher from time import sleep
from argon2.exceptions import VerifyMismatchError
corr = '$argon2id$v=19$m=65536,t=3,p=4$XzX9K2MKRrGWEf/0iHf2AA$m6Q/aHoj1/uct+8a00QTS5xVWnANeMPKVUg4P822sbM'
bp = Blueprint('auth', __name__, url_prefix='/auth') bp = Blueprint('auth', __name__, url_prefix='/auth')
@@ -27,7 +25,7 @@ def login():
password = request.form.get('password', None) password = request.form.get('password', None)
if current_app.config.get('DEBUG'): if current_app.config.get('DEBUG'):
session['username'] = 'admin' session['username'] = 'DEBUG ADMIN'
flash('You have been logged in') flash('You have been logged in')
return redirect(request.args.get('next', url_for('admin.base'))) return redirect(request.args.get('next', url_for('admin.base')))
@@ -35,19 +33,40 @@ def login():
flash('Password was empty') flash('Password was empty')
return redirect(url_for('auth.login')) return redirect(url_for('auth.login'))
try: sleep(0.3)
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') flash('Wrong password')
return redirect(url_for('auth.login')) return redirect(url_for('auth.login'))
except:
flash('Something went wrong')
return redirect(url_for('auth.login'))
return render_template('login.html') 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 flask import Blueprint, render_template, flash, url_for, redirect
from ..nosql import get_nosql from ..nosql import get_nosql
from ..s3 import get_s3
from ..extensions import caching, caching_unless from ..extensions import caching, caching_unless
bp = Blueprint('channel', __name__, url_prefix='/channel') bp = Blueprint('channel', __name__, url_prefix='/channel')
@@ -35,7 +34,7 @@ def channel(channelId):
for videoId in videoIds: for videoId in videoIds:
videos.append(get_nosql().get_video_info(videoId, limited=True)) videos.append(get_nosql().get_video_info(videoId, limited=True))
videos = sorted(videos, key=lambda x: x.get('upload_date'), reverse=True) videos = sorted(videos, key=lambda x: x.get('upload_date', '19700101'), reverse=True)
return render_template('channel/channel.html', channel=channelInfo, videos=videos) return render_template('channel/channel.html', channel=channelInfo, videos=videos)

View File

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

View File

@@ -3,8 +3,7 @@ import pymongo
import secrets import secrets
from bson.objectid import ObjectId from bson.objectid import ObjectId
from flask import current_app from flask import current_app, g
from flask import g
from .filters import current_time from .filters import current_time
@@ -19,20 +18,6 @@ def get_nosql():
return g.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 # # ORM #
########################################## ##########################################
@@ -58,9 +43,9 @@ class Mango:
self.websub_data = self.db['websub_data'] self.websub_data = self.db['websub_data']
self.reports = self.db['reports'] self.reports = self.db['reports']
self.posters_endpoints = self.db['posters_endpoints'] self.posters_endpoints = self.db['posters_endpoints']
self.users = self.db['users']
self.ensure_indexes() self.ensure_indexes()
#self.clean_info_json()
def ensure_indexes(self): def ensure_indexes(self):
required = { required = {
@@ -132,6 +117,27 @@ class Mango:
results = sorted(results, key=lambda x: x.get('score'), reverse=True)[:20] results = sorted(results, key=lambda x: x.get('score'), reverse=True)[:20]
return tuple(results) 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 # # channel operations #
########################################## ##########################################
@@ -345,6 +351,7 @@ class Mango:
days = self.datetime.utcnow() - self.timedelta(days=days) days = self.datetime.utcnow() - self.timedelta(days=days)
self.websub_callbacks.delete_many({'status': 'retired', 'retired_time': {'$lt': days}}) self.websub_callbacks.delete_many({'status': 'retired', 'retired_time': {'$lt': days}})
self.websub_callbacks.delete_many({'status': 'requesting', 'requesting_time': {'$lt': days}})
return True return True

127
ayta/oidc.py Normal file
View File

@@ -0,0 +1,127 @@
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,
leeway=5)
except Exception as e:
print(e, flush=True)
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

@@ -10,7 +10,7 @@ def subscribe_websub_callback(channelId):
url = 'https://pubsubhubbub.appspot.com/subscribe' url = 'https://pubsubhubbub.appspot.com/subscribe'
data = { data = {
'hub.callback': f'https://{current_app.config["DOMAIN"]}/api/websub//{callbackId}', 'hub.callback': f'{current_app.config["DOMAIN"]}/api/websub/{callbackId}',
'hub.topic': f'https://www.youtube.com/xml/feeds/videos.xml?channel_id={channelId}', 'hub.topic': f'https://www.youtube.com/xml/feeds/videos.xml?channel_id={channelId}',
'hub.verify': 'async', 'hub.verify': 'async',
'hub.mode': 'subscribe', 'hub.mode': 'subscribe',
@@ -32,7 +32,7 @@ def unsubscribe_websub_callback(callbackId, channelId):
from .nosql import get_nosql from .nosql import get_nosql
url = 'https://pubsubhubbub.appspot.com/subscribe' url = 'https://pubsubhubbub.appspot.com/subscribe'
data = {'hub.callback': f'https://{current_app.config["DOMAIN"]}/api/websub/{callbackId}', data = {'hub.callback': f'{current_app.config["DOMAIN"]}/api/websub/{callbackId}',
'hub.topic': f'https://www.youtube.com/xml/feeds/videos.xml?channel_id={channelId}', 'hub.topic': f'https://www.youtube.com/xml/feeds/videos.xml?channel_id={channelId}',
'hub.verify': 'async', 'hub.verify': 'async',
'hub.mode': 'unsubscribe' 'hub.mode': 'unsubscribe'

View File

@@ -70,7 +70,17 @@
<div class="card black-text"> <div class="card black-text">
<div class="card-content"> <div class="card-content">
<span class="card-title">Posters</span> <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>
</div> </div>
</a> </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

@@ -50,6 +50,7 @@
{% for callback in callbacks %} {% for callback in callbacks %}
<tr class="filterable"> <tr class="filterable">
<td> <td>
<a target="_blank" rel="noopener noreferrer" href="https://pubsubhubbub.appspot.com/subscription-details?hub.callback={{ config['DOMAIN'] }}/api/websub/{{ callbacks[callback].get('id') }}&hub.topic=https://www.youtube.com/xml/feeds/videos.xml?channel_id={{ callbacks[callback].get('channel') }}"><button class="btn-small waves-effect waves-light" title="Information on Pubsubhubbub (external link)"></button></a>
<form method="post"> <form method="post">
<input type="text" value="{{ callbacks[callback].get('id') }}" name="value" hidden> <input type="text" value="{{ callbacks[callback].get('id') }}" name="value" hidden>
<button class="btn-small waves-effect waves-light" type="submit" name="task" value="unsubscribe" title="Send unsubscribe request to hub" {% if callbacks[callback].get('status') != 'active' %}disabled{% endif %}>🗑️</button> <button class="btn-small waves-effect waves-light" type="submit" name="task" value="unsubscribe" title="Send unsubscribe request to hub" {% if callbacks[callback].get('status') != 'active' %}disabled{% endif %}>🗑️</button>

View File

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

View File

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

View File

@@ -20,11 +20,9 @@
<ul id="nav-mobile" class="left"> <ul id="nav-mobile" class="left">
<li><a href="{{ url_for('channel.base') }}">Channels</a></li> <li><a href="{{ url_for('channel.base') }}">Channels</a></li>
<li><a href="{{ url_for('admin.base') }}">Admin</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> </ul>
<a href="{{ url_for('index.base') }}" class="brand-logo center">AYTA</a> <a href="{{ url_for('index.base') }}" class="brand-logo center">AYTA</a>
<ul id="nav-mobile" class="right"> <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 %}
<li><a href="{{ url_for('search.base') }}">Search</a></li> <li><a href="{{ url_for('search.base') }}">Search</a></li>
<li><a href="{{ url_for('index.help') }}">Help</a></li> <li><a href="{{ url_for('index.help') }}">Help</a></li>
</ul> </ul>
@@ -36,6 +34,7 @@
{% if messages %} {% if messages %}
{% for message in messages %} {% for message in messages %}
<script>M.toast({text: '{{ message }}', displayLength: 5000, outDuration: 999, inDuration: 666})</script> <script>M.toast({text: '{{ message }}', displayLength: 5000, outDuration: 999, inDuration: 666})</script>
<noscript>A message appeared without supporting javasript: {{ message }}</noscript>
{% endfor %} {% endfor %}
{% endif %} {% endif %}
{% endwith %} {% endwith %}
@@ -47,14 +46,19 @@
<footer class="page-footer deep-orange"> <footer class="page-footer deep-orange">
<div class="container"> <div class="container">
<div class="row"> <div class="row">
<div class="s12 l6"> <div class="s12 l6 mr-4">
<h5>Awesome YouTube Archive</h5> <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>
<div class="s12 l6"> <div class="s12 l6">
<span class="new badge" data-badge-caption="{{ null|current_time }}">Page generated on</span>
<h6>Still in development, slowly...</h6> <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> </div>
</div> </div>

View File

@@ -7,29 +7,12 @@
<meta property="og:type" content="website" /> <meta property="og:type" content="website" />
<meta property="og:url" content="{{ url_for('watch.base') }}?v={{ render.get('info').get('id') }}" /> <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: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 %} {% endblock %}
{% block content %} {% block content %}
<div class="row"> <div class="row">
<div class="col s12"> <div class="col s12 mt-4 center-align">
<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">
<video controls class="responsive-video"> <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') }}.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">
@@ -38,19 +21,25 @@
</div> </div>
</div> </div>
<div class="row"> <div class="row">
<div class="col s12 l9 center-align mr-4"> <div class="col s12 l9 mr-4">
<div class="section"> <h5>{{ render.get('info').get('title') }}</h5>
<div class="row"> </div>
<div class="col s12 m3"> <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> <p><a href="https://youtu.be/{{ render.get('info').get('id') }}" target="_blank" rel="noopener noreferrer">▶️ Watch on YouTube</a></p>
</div> </div>
<div class="col s12 m3"> <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> <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>
<div class="col s12 m3"> <div class="col s4 l3 center-align">
<p>Sample text</p> <p></p>
</div> </div>
<div class="col s12 m3 input-field"> <div class="col s12 l3 center-align input-field">
<form method="post"> <form method="post">
<select id="report" name="reason"> <select id="report" name="reason">
<option value="" disabled selected></option> <option value="" disabled selected></option>
@@ -59,18 +48,19 @@
<option value="illegal">Illegal video</option> <option value="illegal">Illegal video</option>
</select> </select>
<label for="report">Report a problem</label> <label for="report">Report a problem</label>
<button for="report" class="btn" type="submit" name="action">Submit report</button> <button for="report" class="btn mt-4" type="submit" name="action">Submit report</button>
</form> </form>
</div> </div>
</div> </div>
</div> <div class="divider mt-4"></div>
<div class="divider"></div> <div class="row">
<div class="section"> <div class="col s12 l9 mr-4">
<div class="section center-align">
<h5>Description</h5> <h5>Description</h5>
<p style="white-space: pre-wrap;" class="left-align">{{ render.get('info').get('description') }}</p> <p style="white-space: pre-wrap;" class="left-align">{{ render.get('info').get('description') }}</p>
</div> </div>
<div class="divider"></div> <div class="divider"></div>
<div class="section input-field"> <div class="section center-align input-field">
<h5>Full info JSON dump</h5> <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>

View File

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