Compare commits

...

35 Commits

Author SHA1 Message Date
0964abdf37 wip: semi-working PHP8.5/FrankenPHP w/ async 2025-07-19 21:30:18 -05:00
3b0ba81ce3 fix: bad type checking in media file dto 2025-07-17 22:17:55 -05:00
dc2845d74d fix: bad caching causing turbo frames not to reload properly 2025-07-16 20:11:30 -05:00
5e722dcbc7 fix: deletes media file when download deleted 2025-07-15 23:53:19 -05:00
a126871af8 fix: allows local or queued downloads 2025-07-15 10:29:32 -05:00
70f551cea9 feat: command to download media 2025-07-15 10:17:42 -05:00
4824c2d344 fix: adds pull-to-refresh to pwa 2025-07-14 21:11:57 -05:00
c09c7ad030 chore: remove unused code 2025-07-14 20:55:47 -05:00
f610297294 fix: turbo frame for tvshow results 2025-07-14 20:54:12 -05:00
f2971eee9c wip: returns movie results in turbo frames 2025-07-14 16:20:36 -05:00
47108af1f8 fix: pre-loads media postes 2025-07-14 15:32:08 -05:00
f7163b5e00 fix: typo in download list season id 2025-07-13 22:42:39 -05:00
31e364d691 fix: renders media exists badge on movie results 2025-07-13 22:21:04 -05:00
b42981b2a1 chore: cleanup 2025-07-13 21:27:54 -05:00
accfa9c9bf fix: missing/exists badge on tvshows results 2025-07-13 21:13:42 -05:00
8b50b50466 fix: dev env tweaks 2025-07-12 14:30:33 -05:00
e38498f69b fix: adds basic auth 2025-07-12 08:33:59 -05:00
490f341875 fix: increase font weight of mobile 'T' logo 2025-07-12 00:26:43 -05:00
b1b28864ea fix: manifest colors 2025-07-11 23:30:15 -05:00
891ce81902 feat: basic pwa 2025-07-11 23:14:36 -05:00
b7d7025114 feat: adds step to define filter in getting started process 2025-07-11 22:12:11 -05:00
41114446d0 chore: code reorganization 2025-07-11 19:05:50 -05:00
592e02484e fix: docs 2025-07-11 16:38:49 -05:00
bd9fde94d1 fix: updates example compose 2025-07-11 16:37:36 -05:00
d0b2852de5 fix: blocks pw resets when auth method = oidc 2025-07-11 15:58:45 -05:00
2fae99e24b fix: creates new users on demand from idp 2025-07-11 15:40:19 -05:00
b74b563c56 wip: adds config options for oidc 2025-07-11 12:30:56 -05:00
04993ebb27 wip: working oidc login 2025-07-11 11:27:34 -05:00
db521ad9a9 fix: style tweaks 2025-07-10 13:40:31 -05:00
6a7474173e fix: update reset password controller to use smtp settings from config 2025-07-10 12:16:01 -05:00
9f38429c2a feat: adds command to rest user password 2025-07-10 11:32:53 -05:00
9fd6745125 chore: adds descriptions to command 2025-07-10 10:39:32 -05:00
60376ca0a2 chore: adds description to command 2025-07-10 10:35:43 -05:00
6f1f1032f6 fix: standardizes styles of the 'bare' template for pre-authenticated pages 2025-07-10 10:32:38 -05:00
c6e98eff4c fix: puts posters in 2 columns on mobile 2025-07-09 23:43:54 -05:00
92 changed files with 2236 additions and 368 deletions

9
.env
View File

@@ -42,3 +42,12 @@ REDIS_HOST=redis://redis
###> symfony/mailer ### ###> symfony/mailer ###
MAILER_DSN=null://null MAILER_DSN=null://null
###< symfony/mailer ### ###< symfony/mailer ###
AUTH_METHOD=form_login
###> drenso/symfony-oidc-bundle ###
OIDC_WELL_KNOWN_URL="https://oidc/.well-known"
OIDC_CLIENT_ID="Enter your OIDC client id"
OIDC_CLIENT_SECRET="Enter your OIDC client secret"
OIDC_BYPASS_FORM_LOGIN=false
###< drenso/symfony-oidc-bundle ###

1
.gitignore vendored
View File

@@ -18,3 +18,4 @@ bolt.db
###> phpstan/phpstan ### ###> phpstan/phpstan ###
phpstan.neon phpstan.neon
###< phpstan/phpstan ### ###< phpstan/phpstan ###
.php-cs-fixer.cache

View File

@@ -1,16 +1,17 @@
FROM dunglas/frankenphp:php8.4 FROM registry.caldwell.digital/home/frankenphp:1.9.0-php8.5.0-ubuntu
ENV SERVER_NAME=":80" ENV SERVER_NAME=":80"
ENV CADDY_GLOBAL_OPTIONS="auto_https off" ENV CADDY_GLOBAL_OPTIONS="auto_https off"
ENV APP_RUNTIME="Runtime\\FrankenPhpSymfony\\Runtime" ENV APP_RUNTIME="Runtime\\FrankenPhpSymfony\\Runtime"
ENV APP_VERSION="0.0.1" ENV APP_VERSION="0.0.1"
ENV SERVER_ROOT="/app/public"
RUN install-php-extensions \ #RUN install-php-extensions \
pdo_mysql \ # pdo_mysql \
gd \ # gd \
intl \ # intl \
zip \ # zip \
opcache # opcache
RUN apt update && apt install -y wget RUN apt update && apt install -y wget

View File

@@ -6,6 +6,7 @@ import './bootstrap.js';
* which should already be in your base.html.twig. * which should already be in your base.html.twig.
*/ */
import './styles/app.css'; import './styles/app.css';
import PullToRefresh from 'pulltorefreshjs';
console.log('This log comes from assets/app.js - welcome to AssetMapper! 🎉'); console.log('This log comes from assets/app.js - welcome to AssetMapper! 🎉');
@@ -18,3 +19,10 @@ var observer = new MutationObserver(function(mutations) {
observer.observe(document, {attributes: false, childList: true, characterData: false, subtree:true}); observer.observe(document, {attributes: false, childList: true, characterData: false, subtree:true});
const ptr = PullToRefresh.init({
mainElement: 'body',
onRefresh() {
window.location.reload();
}
});

View File

@@ -1,5 +1,23 @@
{ {
"controllers": { "controllers": {
"@spomky-labs/pwa-bundle": {
"connection-status": {
"enabled": true,
"fetch": "eager"
},
"backgroundsync-form": {
"enabled": true,
"fetch": "eager"
},
"sync-broadcast": {
"enabled": true,
"fetch": "eager"
},
"prefetch-on-demand": {
"enabled": true,
"fetch": "eager"
}
},
"@symfony/ux-autocomplete": { "@symfony/ux-autocomplete": {
"autocomplete": { "autocomplete": {
"enabled": true, "enabled": true,

View File

@@ -7,7 +7,7 @@ import { getComponent } from '@symfony/ux-live-component';
/* stimulusFetch: 'lazy' */ /* stimulusFetch: 'lazy' */
export default class extends Controller { export default class extends Controller {
static targets = ['download'] static targets = ['download', 'deleteFileInput']
async initialize() { async initialize() {
this.component = await getComponent(this.element); this.component = await getComponent(this.element);
@@ -42,7 +42,8 @@ export default class extends Controller {
} }
deleteDownload(data) { deleteDownload(data) {
fetch(`/api/download/${data.params.id}`, {method: 'DELETE'}) const deleteFileInput = document.querySelector(`#delete_file_${data.params.id}`)
fetch(`/api/download/${data.params.id}?deleteFile=${deleteFileInput.checked}`, {method: 'DELETE'})
.then(res => res.json()) .then(res => res.json())
.then(json => console.debug(json)); .then(json => console.debug(json));
} }

View File

@@ -20,24 +20,19 @@ export default class extends Controller {
options = [] options = []
optionsLoaded = false optionsLoaded = false
resultCountEl = null
async connect() { async connect() {
await this.setOptions(); this.resultCountEl = document.querySelector('#movie_results_count');
} }
async setOptions() { async listTargetConnected() {
if (false === this.optionsLoaded) { this.optionsLoaded = true;
this.optionsLoaded = true; this.options = this.element.querySelectorAll('tbody tr');
await fetch(`/torrentio/movies/${this.tmdbIdValue}/${this.imdbIdValue}`) this.options.forEach((option) => option.querySelector('.download-btn').dataset['title'] = this.titleValue);
.then(res => res.text()) this.dispatch('optionsLoaded', {detail: {options: this.options}})
.then(response => { this.loadingIconOutlet.toggleIcon();
this.element.innerHTML = response; this.resultCountEl.innerText = this.options.length;
this.options = this.element.querySelectorAll('tbody tr');
this.options.forEach((option) => option.querySelector('.download-btn').dataset['title'] = this.titleValue);
this.dispatch('optionsLoaded', {detail: {options: this.options}})
this.loadingIconOutlet.toggleIcon();
});
}
} }
// Keeps compatible with Filter & TV Shows // Keeps compatible with Filter & TV Shows
@@ -102,5 +97,6 @@ export default class extends Controller {
count = count + 1; count = count + 1;
} }
}); });
this.resultCountEl.innerText = count;
} }
} }

View File

@@ -25,37 +25,18 @@ export default class extends Controller {
optionsLoaded = false optionsLoaded = false
isOpen = false isOpen = false
async connect() { async listTargetConnected() {
await this.setOptions(); this.options = this.element.querySelectorAll('tbody tr');
} if (this.options.length > 0) {
this.options.forEach((option) =>
async setOptions() { option.querySelector('.download-btn').dataset['title'] = this.titleValue
if (this.optionsLoaded === false) { );
this.optionsLoaded = true; this.options[0].querySelector('input[type="checkbox"]').checked = true;
let response; this.dispatch('optionsLoaded', {detail: {options: this.options}})
this.loadingIconOutlet.increaseCount();
try { } else {
response = await fetch(`/torrentio/tvshows/${this.tmdbIdValue}/${this.imdbIdValue}/${this.seasonValue}/${this.episodeValue}`) this.countTarget.innerText = 0;
} catch (error) { this.episodeSelectorTarget.disabled = true;
console.log('There was an error', error);
}
if (response?.ok) {
response = await response.text()
this.listContainerTarget.innerHTML = response;
this.options = this.element.querySelectorAll('tbody tr');
if (this.options.length > 0) {
this.options.forEach((option) => option.querySelector('.download-btn').dataset['title'] = this.titleValue);
this.options[0].querySelector('input[type="checkbox"]').checked = true;
} else {
this.countTarget.innerText = 0;
this.episodeSelectorTarget.disabled = true;
}
this.dispatch('optionsLoaded', {detail: {options: this.options}})
this.loadingIconOutlet.increaseCount();
} else {
console.log(`HTTP Response Code: ${response?.status}`)
}
} }
} }
@@ -134,6 +115,7 @@ export default class extends Controller {
"codec": option.querySelector('#codec').textContent.trim(), "codec": option.querySelector('#codec').textContent.trim(),
"provider": option.querySelector('#provider').textContent.trim(), "provider": option.querySelector('#provider').textContent.trim(),
"languages": JSON.parse(option.dataset['languages']), "languages": JSON.parse(option.dataset['languages']),
"quality": option.dataset['quality'],
} }
let include = true; let include = true;

View File

@@ -1,6 +1,11 @@
dev.caldwell.digital:443 {
log {
level DEBUG
}
}
tls /etc/ssl/wildcard.crt /etc/ssl/wildcard.pem dev.caldwell.digital:443 {
tls /etc/ssl/wildcard.crt /etc/ssl/wildcard.pem
reverse_proxy app:80
reverse_proxy app:80
}

View File

@@ -2,6 +2,7 @@ services:
caddy: caddy:
image: caddy:2.9.1 image: caddy:2.9.1
restart: unless-stopped restart: unless-stopped
tty: true
cap_add: cap_add:
- NET_ADMIN - NET_ADMIN
ports: ports:
@@ -22,6 +23,9 @@ services:
- mercure_data:/data - mercure_data:/data
- mercure_config:/config - mercure_config:/config
tty: true tty: true
ports:
- "8001:80"
command: "frankenphp php-server --root=/app/public"
environment: environment:
TZ: America/Chicago TZ: America/Chicago
MERCURE_PUBLISHER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!' MERCURE_PUBLISHER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!'

View File

@@ -16,6 +16,7 @@
"doctrine/doctrine-migrations-bundle": "^3.4", "doctrine/doctrine-migrations-bundle": "^3.4",
"doctrine/orm": "^3.3", "doctrine/orm": "^3.3",
"dragonmantank/cron-expression": "^3.4", "dragonmantank/cron-expression": "^3.4",
"drenso/symfony-oidc-bundle": "^4.2",
"guzzlehttp/guzzle": "^7.9", "guzzlehttp/guzzle": "^7.9",
"league/pipeline": "^1.1", "league/pipeline": "^1.1",
"nesbot/carbon": "^3.9", "nesbot/carbon": "^3.9",
@@ -26,6 +27,7 @@
"php-tmdb/api": "^4.1", "php-tmdb/api": "^4.1",
"predis/predis": "^2.4", "predis/predis": "^2.4",
"runtime/frankenphp-symfony": "^0.2.0", "runtime/frankenphp-symfony": "^0.2.0",
"spomky-labs/pwa-bundle": "^1.2",
"stof/doctrine-extensions-bundle": "^1.14", "stof/doctrine-extensions-bundle": "^1.14",
"symfony/asset": "7.3.*", "symfony/asset": "7.3.*",
"symfony/console": "7.3.*", "symfony/console": "7.3.*",
@@ -36,6 +38,7 @@
"symfony/flex": "^2", "symfony/flex": "^2",
"symfony/form": "7.3.*", "symfony/form": "7.3.*",
"symfony/framework-bundle": "7.3.*", "symfony/framework-bundle": "7.3.*",
"symfony/http-client": "7.3.*",
"symfony/ldap": "7.3.*", "symfony/ldap": "7.3.*",
"symfony/mailer": "7.3.*", "symfony/mailer": "7.3.*",
"symfony/mercure-bundle": "^0.3.9", "symfony/mercure-bundle": "^0.3.9",
@@ -54,7 +57,8 @@
"symfonycasts/reset-password-bundle": "^1.23", "symfonycasts/reset-password-bundle": "^1.23",
"symfonycasts/tailwind-bundle": "^0.10.0", "symfonycasts/tailwind-bundle": "^0.10.0",
"twig/extra-bundle": "^2.12|^3.0", "twig/extra-bundle": "^2.12|^3.0",
"twig/twig": "^2.12|^3.0" "twig/twig": "^2.12|^3.0",
"web-token/jwt-library": "^4.0"
}, },
"config": { "config": {
"allow-plugins": { "allow-plugins": {

710
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "f368fdd2e0f36d53131785b857200062", "content-hash": "0f98dada0a01d471cebf4eb1b51b9006",
"packages": [ "packages": [
{ {
"name": "1tomany/rich-bundle", "name": "1tomany/rich-bundle",
@@ -167,6 +167,66 @@
"abandoned": true, "abandoned": true,
"time": "2022-03-30T09:27:43+00:00" "time": "2022-03-30T09:27:43+00:00"
}, },
{
"name": "brick/math",
"version": "0.13.1",
"source": {
"type": "git",
"url": "https://github.com/brick/math.git",
"reference": "fc7ed316430118cc7836bf45faff18d5dfc8de04"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/brick/math/zipball/fc7ed316430118cc7836bf45faff18d5dfc8de04",
"reference": "fc7ed316430118cc7836bf45faff18d5dfc8de04",
"shasum": ""
},
"require": {
"php": "^8.1"
},
"require-dev": {
"php-coveralls/php-coveralls": "^2.2",
"phpunit/phpunit": "^10.1",
"vimeo/psalm": "6.8.8"
},
"type": "library",
"autoload": {
"psr-4": {
"Brick\\Math\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "Arbitrary-precision arithmetic library",
"keywords": [
"Arbitrary-precision",
"BigInteger",
"BigRational",
"arithmetic",
"bigdecimal",
"bignum",
"bignumber",
"brick",
"decimal",
"integer",
"math",
"mathematics",
"rational"
],
"support": {
"issues": "https://github.com/brick/math/issues",
"source": "https://github.com/brick/math/tree/0.13.1"
},
"funding": [
{
"url": "https://github.com/BenMorel",
"type": "github"
}
],
"time": "2025-03-29T13:50:30+00:00"
},
{ {
"name": "carbonphp/carbon-doctrine-types", "name": "carbonphp/carbon-doctrine-types",
"version": "2.1.0", "version": "2.1.0",
@@ -1883,6 +1943,93 @@
], ],
"time": "2024-10-09T13:47:03+00:00" "time": "2024-10-09T13:47:03+00:00"
}, },
{
"name": "drenso/symfony-oidc-bundle",
"version": "v4.2.0",
"source": {
"type": "git",
"url": "https://github.com/Drenso/symfony-oidc.git",
"reference": "6da6a17e206487646799489a1c1dce18ed2f10eb"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Drenso/symfony-oidc/zipball/6da6a17e206487646799489a1c1dce18ed2f10eb",
"reference": "6da6a17e206487646799489a1c1dce18ed2f10eb",
"shasum": ""
},
"require": {
"ext-curl": "*",
"ext-filter": "*",
"ext-hash": "*",
"ext-json": "*",
"ext-mbstring": "*",
"lcobucci/jwt": "^5.0",
"php": ">=8.1",
"phpseclib/phpseclib": "^3.0.36",
"psr/clock": "^1.0",
"psr/container": "^1.1 || ^2.0",
"psr/log": "^1.1 || ^2.0 || ^3.0",
"symfony/config": "^5.4 || ^6.3 || ^7.0",
"symfony/dependency-injection": "^5.4 || ^6.3 || ^7.0",
"symfony/event-dispatcher": "^5.4 || ^6.3 || ^7.0",
"symfony/http-foundation": "^5.4 || ^6.3 || ^7.0",
"symfony/http-kernel": "^5.4 || ^6.3 || ^7.0",
"symfony/property-access": "^5.4 || ^6.3 || ^7.0",
"symfony/security-bundle": "^5.4 || ^6.3 || ^7.0",
"symfony/security-core": "^5.4 || ^6.3 || ^7.0",
"symfony/security-http": "^5.4 || ^6.3 || ^7.0",
"symfony/string": "^5.4 || ^6.3 || ^7.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "3.75.0",
"phpstan/extension-installer": "1.4.3",
"phpstan/phpstan": "2.1.17",
"phpstan/phpstan-deprecation-rules": "^2.0",
"rector/rector": "2.0.18",
"symfony/cache": "^5.4 || ^6.3 || ^7.0",
"symfony/translation-contracts": "^2.0 || ^3.0"
},
"suggest": {
"symfony/cache": "When installed, IdP information will be automatically cached"
},
"type": "symfony-bundle",
"extra": {
"branch-alias": {
"dev-master": "v3.x-dev"
}
},
"autoload": {
"psr-4": {
"Drenso\\OidcBundle\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"Apache-2.0"
],
"authors": [
{
"name": "Bob van de Vijver",
"email": "bob@drenso.nl"
},
{
"name": "Tobias Feijten",
"email": "tobias@drenso.nl"
}
],
"description": "OpenID connect bundle for Symfony",
"homepage": "https://gitlab.drenso.nl/intern/symfony-oidc",
"keywords": [
"OpenID Connect",
"oidc",
"symfony"
],
"support": {
"issues": "https://github.com/Drenso/symfony-oidc/issues",
"source": "https://github.com/Drenso/symfony-oidc/tree/v4.2.0"
},
"time": "2025-06-19T09:43:57+00:00"
},
{ {
"name": "egulias/email-validator", "name": "egulias/email-validator",
"version": "4.0.4", "version": "4.0.4",
@@ -2998,6 +3145,123 @@
}, },
"time": "2024-02-19T18:29:05+00:00" "time": "2024-02-19T18:29:05+00:00"
}, },
{
"name": "paragonie/constant_time_encoding",
"version": "v3.0.0",
"source": {
"type": "git",
"url": "https://github.com/paragonie/constant_time_encoding.git",
"reference": "df1e7fde177501eee2037dd159cf04f5f301a512"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/paragonie/constant_time_encoding/zipball/df1e7fde177501eee2037dd159cf04f5f301a512",
"reference": "df1e7fde177501eee2037dd159cf04f5f301a512",
"shasum": ""
},
"require": {
"php": "^8"
},
"require-dev": {
"phpunit/phpunit": "^9",
"vimeo/psalm": "^4|^5"
},
"type": "library",
"autoload": {
"psr-4": {
"ParagonIE\\ConstantTime\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Paragon Initiative Enterprises",
"email": "security@paragonie.com",
"homepage": "https://paragonie.com",
"role": "Maintainer"
},
{
"name": "Steve 'Sc00bz' Thomas",
"email": "steve@tobtu.com",
"homepage": "https://www.tobtu.com",
"role": "Original Developer"
}
],
"description": "Constant-time Implementations of RFC 4648 Encoding (Base-64, Base-32, Base-16)",
"keywords": [
"base16",
"base32",
"base32_decode",
"base32_encode",
"base64",
"base64_decode",
"base64_encode",
"bin2hex",
"encoding",
"hex",
"hex2bin",
"rfc4648"
],
"support": {
"email": "info@paragonie.com",
"issues": "https://github.com/paragonie/constant_time_encoding/issues",
"source": "https://github.com/paragonie/constant_time_encoding"
},
"time": "2024-05-08T12:36:18+00:00"
},
{
"name": "paragonie/random_compat",
"version": "v9.99.100",
"source": {
"type": "git",
"url": "https://github.com/paragonie/random_compat.git",
"reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/paragonie/random_compat/zipball/996434e5492cb4c3edcb9168db6fbb1359ef965a",
"reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a",
"shasum": ""
},
"require": {
"php": ">= 7"
},
"require-dev": {
"phpunit/phpunit": "4.*|5.*",
"vimeo/psalm": "^1"
},
"suggest": {
"ext-libsodium": "Provides a modern crypto API that can be used to generate random bytes."
},
"type": "library",
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Paragon Initiative Enterprises",
"email": "security@paragonie.com",
"homepage": "https://paragonie.com"
}
],
"description": "PHP 5.x polyfill for random_bytes() and random_int() from PHP 7",
"keywords": [
"csprng",
"polyfill",
"pseudorandom",
"random"
],
"support": {
"email": "info@paragonie.com",
"issues": "https://github.com/paragonie/random_compat/issues",
"source": "https://github.com/paragonie/random_compat"
},
"time": "2020-10-15T08:29:30+00:00"
},
{ {
"name": "php-http/cache-plugin", "name": "php-http/cache-plugin",
"version": "2.0.1", "version": "2.0.1",
@@ -3661,6 +3925,116 @@
}, },
"time": "2024-11-09T15:12:26+00:00" "time": "2024-11-09T15:12:26+00:00"
}, },
{
"name": "phpseclib/phpseclib",
"version": "3.0.46",
"source": {
"type": "git",
"url": "https://github.com/phpseclib/phpseclib.git",
"reference": "56483a7de62a6c2a6635e42e93b8a9e25d4f0ec6"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/56483a7de62a6c2a6635e42e93b8a9e25d4f0ec6",
"reference": "56483a7de62a6c2a6635e42e93b8a9e25d4f0ec6",
"shasum": ""
},
"require": {
"paragonie/constant_time_encoding": "^1|^2|^3",
"paragonie/random_compat": "^1.4|^2.0|^9.99.99",
"php": ">=5.6.1"
},
"require-dev": {
"phpunit/phpunit": "*"
},
"suggest": {
"ext-dom": "Install the DOM extension to load XML formatted public keys.",
"ext-gmp": "Install the GMP (GNU Multiple Precision) extension in order to speed up arbitrary precision integer arithmetic operations.",
"ext-libsodium": "SSH2/SFTP can make use of some algorithms provided by the libsodium-php extension.",
"ext-mcrypt": "Install the Mcrypt extension in order to speed up a few other cryptographic operations.",
"ext-openssl": "Install the OpenSSL extension in order to speed up a wide variety of cryptographic operations."
},
"type": "library",
"autoload": {
"files": [
"phpseclib/bootstrap.php"
],
"psr-4": {
"phpseclib3\\": "phpseclib/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Jim Wigginton",
"email": "terrafrost@php.net",
"role": "Lead Developer"
},
{
"name": "Patrick Monnerat",
"email": "pm@datasphere.ch",
"role": "Developer"
},
{
"name": "Andreas Fischer",
"email": "bantu@phpbb.com",
"role": "Developer"
},
{
"name": "Hans-Jürgen Petrich",
"email": "petrich@tronic-media.com",
"role": "Developer"
},
{
"name": "Graham Campbell",
"email": "graham@alt-three.com",
"role": "Developer"
}
],
"description": "PHP Secure Communications Library - Pure-PHP implementations of RSA, AES, SSH2, SFTP, X.509 etc.",
"homepage": "http://phpseclib.sourceforge.net",
"keywords": [
"BigInteger",
"aes",
"asn.1",
"asn1",
"blowfish",
"crypto",
"cryptography",
"encryption",
"rsa",
"security",
"sftp",
"signature",
"signing",
"ssh",
"twofish",
"x.509",
"x509"
],
"support": {
"issues": "https://github.com/phpseclib/phpseclib/issues",
"source": "https://github.com/phpseclib/phpseclib/tree/3.0.46"
},
"funding": [
{
"url": "https://github.com/terrafrost",
"type": "github"
},
{
"url": "https://www.patreon.com/phpseclib",
"type": "patreon"
},
{
"url": "https://tidelift.com/funding/github/packagist/phpseclib/phpseclib",
"type": "tidelift"
}
],
"time": "2025-06-26T16:29:55+00:00"
},
{ {
"name": "phpstan/phpdoc-parser", "name": "phpstan/phpdoc-parser",
"version": "2.1.0", "version": "2.1.0",
@@ -4383,6 +4757,236 @@
], ],
"time": "2023-12-12T12:06:11+00:00" "time": "2023-12-12T12:06:11+00:00"
}, },
{
"name": "spomky-labs/pki-framework",
"version": "1.3.0",
"source": {
"type": "git",
"url": "https://github.com/Spomky-Labs/pki-framework.git",
"reference": "eced5b5ce70518b983ff2be486e902bbd15135ae"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Spomky-Labs/pki-framework/zipball/eced5b5ce70518b983ff2be486e902bbd15135ae",
"reference": "eced5b5ce70518b983ff2be486e902bbd15135ae",
"shasum": ""
},
"require": {
"brick/math": "^0.10|^0.11|^0.12|^0.13",
"ext-mbstring": "*",
"php": ">=8.1"
},
"require-dev": {
"ekino/phpstan-banned-code": "^1.0|^2.0|^3.0",
"ext-gmp": "*",
"ext-openssl": "*",
"infection/infection": "^0.28|^0.29",
"php-parallel-lint/php-parallel-lint": "^1.3",
"phpstan/extension-installer": "^1.3|^2.0",
"phpstan/phpstan": "^1.8|^2.0",
"phpstan/phpstan-deprecation-rules": "^1.0|^2.0",
"phpstan/phpstan-phpunit": "^1.1|^2.0",
"phpstan/phpstan-strict-rules": "^1.3|^2.0",
"phpunit/phpunit": "^10.1|^11.0|^12.0",
"rector/rector": "^1.0|^2.0",
"roave/security-advisories": "dev-latest",
"symfony/string": "^6.4|^7.0",
"symfony/var-dumper": "^6.4|^7.0",
"symplify/easy-coding-standard": "^12.0"
},
"suggest": {
"ext-bcmath": "For better performance (or GMP)",
"ext-gmp": "For better performance (or BCMath)",
"ext-openssl": "For OpenSSL based cyphering"
},
"type": "library",
"autoload": {
"psr-4": {
"SpomkyLabs\\Pki\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Joni Eskelinen",
"email": "jonieske@gmail.com",
"role": "Original developer"
},
{
"name": "Florent Morselli",
"email": "florent.morselli@spomky-labs.com",
"role": "Spomky-Labs PKI Framework developer"
}
],
"description": "A PHP framework for managing Public Key Infrastructures. It comprises X.509 public key certificates, attribute certificates, certification requests and certification path validation.",
"homepage": "https://github.com/spomky-labs/pki-framework",
"keywords": [
"DER",
"Private Key",
"ac",
"algorithm identifier",
"asn.1",
"asn1",
"attribute certificate",
"certificate",
"certification request",
"cryptography",
"csr",
"decrypt",
"ec",
"encrypt",
"pem",
"pkcs",
"public key",
"rsa",
"sign",
"signature",
"verify",
"x.509",
"x.690",
"x509",
"x690"
],
"support": {
"issues": "https://github.com/Spomky-Labs/pki-framework/issues",
"source": "https://github.com/Spomky-Labs/pki-framework/tree/1.3.0"
},
"funding": [
{
"url": "https://github.com/Spomky",
"type": "github"
},
{
"url": "https://www.patreon.com/FlorentMorselli",
"type": "patreon"
}
],
"time": "2025-06-13T08:35:04+00:00"
},
{
"name": "spomky-labs/pwa-bundle",
"version": "1.2.5",
"source": {
"type": "git",
"url": "https://github.com/Spomky-Labs/pwa-bundle.git",
"reference": "c24711ea8428d14a01132d9e42cb17d84218a1ee"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Spomky-Labs/pwa-bundle/zipball/c24711ea8428d14a01132d9e42cb17d84218a1ee",
"reference": "c24711ea8428d14a01132d9e42cb17d84218a1ee",
"shasum": ""
},
"require": {
"php": ">=8.2",
"phpdocumentor/reflection-docblock": "^5.3",
"psr/log": "^1.0|^2.0|^3.0",
"symfony/asset": "^6.4|^7.0",
"symfony/asset-mapper": "^6.4|^7.0",
"symfony/config": "^6.4|^7.0",
"symfony/dependency-injection": "^6.4|^7.0",
"symfony/deprecation-contracts": "^3.5",
"symfony/http-kernel": "^6.4|^7.0",
"symfony/property-access": "^6.4|^7.0",
"symfony/property-info": "^6.4|^7.0",
"symfony/routing": "^6.4|^7.0",
"symfony/serializer": "^6.4|^7.0",
"twig/twig": "^3.8"
},
"require-dev": {
"dbrekelmans/bdi": "^1.1",
"ekino/phpstan-banned-code": "^1.0|^2.0|^3.0",
"ergebnis/phpunit-slow-test-detector": "^2.14",
"ext-sockets": "*",
"infection/infection": "^0.28|^0.29",
"matthiasnoback/symfony-config-test": "^5.1",
"php-parallel-lint/php-parallel-lint": "^1.4",
"phpstan/extension-installer": "^1.1",
"phpstan/phpdoc-parser": "^1.28|^2.0",
"phpstan/phpstan": "^1.0|^2.0",
"phpstan/phpstan-beberlei-assert": "^1.0|^2.0",
"phpstan/phpstan-deprecation-rules": "^1.0|^2.0",
"phpstan/phpstan-phpunit": "^1.4|^2.0",
"phpstan/phpstan-strict-rules": "^1.0|^2.0",
"phpstan/phpstan-symfony": "^1.4|^2.0",
"phpunit/phpunit": "^10.1|^11.0",
"rector/rector": "^1.0|^2.0",
"staabm/phpstan-todo-by": "^0.1.27|^0.2",
"struggle-for-php/sfp-phpstan-psr-log": "^0.21.0|^0.22|^0.23",
"symfony/filesystem": "^6.4|^7.0",
"symfony/framework-bundle": "^6.4|^7.0",
"symfony/mime": "^6.4|^7.0",
"symfony/monolog-bundle": "^3.10",
"symfony/panther": "^2.1",
"symfony/phpunit-bridge": "^6.4|^7.0",
"symfony/translation": "^7.0",
"symfony/yaml": "^6.4|^7.0",
"symplify/easy-coding-standard": "^12.0"
},
"suggest": {
"ext-gd": "Required to generate icons (or Imagick).",
"ext-imagick": "Required to generate icons (or GD).",
"symfony/filesystem": "For generating and manipulating icons or screenshots",
"symfony/mime": "For generating and manipulating icons or screenshots",
"symfony/panther": "For generating screenshots directly from your application"
},
"type": "symfony-bundle",
"extra": {
"thanks": {
"url": "https://github.com/spomky-labs/pwa-bundle",
"name": "spomky-labs/pwa-bundle"
}
},
"autoload": {
"psr-4": {
"SpomkyLabs\\PwaBundle\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Florent Morselli",
"homepage": "https://github.com/Spomky"
},
{
"name": "All contributors",
"homepage": "https://github.com/spomky-labs/pwa-bundle/contributors"
}
],
"description": "Progressive Web App Manifest Generator Bundle for Symfony.",
"homepage": "https://github.com/spomky-labs",
"keywords": [
"bundle",
"pwa",
"symfony",
"symfony-ux"
],
"support": {
"issues": "https://github.com/Spomky-Labs/pwa-bundle/issues",
"source": "https://github.com/Spomky-Labs/pwa-bundle/tree/1.2.5"
},
"funding": [
{
"url": "https://www.buymeacoffee.com/FlorentMorselli",
"type": "custom"
},
{
"url": "https://github.com/Spomky",
"type": "github"
},
{
"url": "https://www.patreon.com/FlorentMorselli",
"type": "patreon"
}
],
"time": "2024-12-16T08:02:21+00:00"
},
{ {
"name": "stof/doctrine-extensions-bundle", "name": "stof/doctrine-extensions-bundle",
"version": "v1.14.0", "version": "v1.14.0",
@@ -6116,16 +6720,16 @@
}, },
{ {
"name": "symfony/http-client", "name": "symfony/http-client",
"version": "v7.3.0", "version": "v7.3.1",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/http-client.git", "url": "https://github.com/symfony/http-client.git",
"reference": "57e4fb86314015a695a750ace358d07a7e37b8a9" "reference": "4403d87a2c16f33345dca93407a8714ee8c05a64"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/http-client/zipball/57e4fb86314015a695a750ace358d07a7e37b8a9", "url": "https://api.github.com/repos/symfony/http-client/zipball/4403d87a2c16f33345dca93407a8714ee8c05a64",
"reference": "57e4fb86314015a695a750ace358d07a7e37b8a9", "reference": "4403d87a2c16f33345dca93407a8714ee8c05a64",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -6137,6 +6741,7 @@
}, },
"conflict": { "conflict": {
"amphp/amp": "<2.5", "amphp/amp": "<2.5",
"amphp/socket": "<1.1",
"php-http/discovery": "<1.15", "php-http/discovery": "<1.15",
"symfony/http-foundation": "<6.4" "symfony/http-foundation": "<6.4"
}, },
@@ -6149,7 +6754,6 @@
"require-dev": { "require-dev": {
"amphp/http-client": "^4.2.1|^5.0", "amphp/http-client": "^4.2.1|^5.0",
"amphp/http-tunnel": "^1.0|^2.0", "amphp/http-tunnel": "^1.0|^2.0",
"amphp/socket": "^1.1",
"guzzlehttp/promises": "^1.4|^2.0", "guzzlehttp/promises": "^1.4|^2.0",
"nyholm/psr7": "^1.0", "nyholm/psr7": "^1.0",
"php-http/httplug": "^1.0|^2.0", "php-http/httplug": "^1.0|^2.0",
@@ -6191,7 +6795,7 @@
"http" "http"
], ],
"support": { "support": {
"source": "https://github.com/symfony/http-client/tree/v7.3.0" "source": "https://github.com/symfony/http-client/tree/v7.3.1"
}, },
"funding": [ "funding": [
{ {
@@ -6207,7 +6811,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2025-05-02T08:23:16+00:00" "time": "2025-06-28T07:58:39+00:00"
}, },
{ {
"name": "symfony/http-client-contracts", "name": "symfony/http-client-contracts",
@@ -10514,6 +11118,96 @@
], ],
"time": "2025-05-03T07:21:55+00:00" "time": "2025-05-03T07:21:55+00:00"
}, },
{
"name": "web-token/jwt-library",
"version": "4.0.4",
"source": {
"type": "git",
"url": "https://github.com/web-token/jwt-library.git",
"reference": "650108fa2cdd6cbaaead0dc0ab5302e178b23b0a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/web-token/jwt-library/zipball/650108fa2cdd6cbaaead0dc0ab5302e178b23b0a",
"reference": "650108fa2cdd6cbaaead0dc0ab5302e178b23b0a",
"shasum": ""
},
"require": {
"brick/math": "^0.12 || ^0.13",
"ext-json": "*",
"php": ">=8.2",
"psr/clock": "^1.0",
"spomky-labs/pki-framework": "^1.2.1"
},
"conflict": {
"spomky-labs/jose": "*"
},
"suggest": {
"ext-bcmath": "GMP or BCMath is highly recommended to improve the library performance",
"ext-gmp": "GMP or BCMath is highly recommended to improve the library performance",
"ext-openssl": "For key management (creation, optimization, etc.) and some algorithms (AES, RSA, ECDSA, etc.)",
"ext-sodium": "Sodium is required for OKP key creation, EdDSA signature algorithm and ECDH-ES key encryption with OKP keys",
"paragonie/sodium_compat": "Sodium is required for OKP key creation, EdDSA signature algorithm and ECDH-ES key encryption with OKP keys",
"spomky-labs/aes-key-wrap": "For all Key Wrapping algorithms (AxxxKW, AxxxGCMKW, PBES2-HSxxx+AyyyKW...)",
"symfony/console": "Needed to use console commands",
"symfony/http-client": "To enable JKU/X5U support."
},
"type": "library",
"autoload": {
"psr-4": {
"Jose\\Component\\": ""
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Florent Morselli",
"homepage": "https://github.com/Spomky"
},
{
"name": "All contributors",
"homepage": "https://github.com/web-token/jwt-framework/contributors"
}
],
"description": "JWT library",
"homepage": "https://github.com/web-token",
"keywords": [
"JOSE",
"JWE",
"JWK",
"JWKSet",
"JWS",
"Jot",
"RFC7515",
"RFC7516",
"RFC7517",
"RFC7518",
"RFC7519",
"RFC7520",
"bundle",
"jwa",
"jwt",
"symfony"
],
"support": {
"issues": "https://github.com/web-token/jwt-library/issues",
"source": "https://github.com/web-token/jwt-library/tree/4.0.4"
},
"funding": [
{
"url": "https://github.com/Spomky",
"type": "github"
},
{
"url": "https://www.patreon.com/FlorentMorselli",
"type": "patreon"
}
],
"time": "2025-03-12T11:25:35+00:00"
},
{ {
"name": "webmozart/assert", "name": "webmozart/assert",
"version": "1.11.0", "version": "1.11.0",

View File

@@ -21,4 +21,6 @@ return [
Stof\DoctrineExtensionsBundle\StofDoctrineExtensionsBundle::class => ['all' => true], Stof\DoctrineExtensionsBundle\StofDoctrineExtensionsBundle::class => ['all' => true],
Symfony\UX\Autocomplete\AutocompleteBundle::class => ['all' => true], Symfony\UX\Autocomplete\AutocompleteBundle::class => ['all' => true],
SymfonyCasts\Bundle\ResetPassword\SymfonyCastsResetPasswordBundle::class => ['all' => true], SymfonyCasts\Bundle\ResetPassword\SymfonyCastsResetPasswordBundle::class => ['all' => true],
Drenso\OidcBundle\DrensoOidcBundle::class => ['all' => true],
SpomkyLabs\PwaBundle\SpomkyLabsPwaBundle::class => ['all' => true],
]; ];

View File

@@ -0,0 +1,19 @@
drenso_oidc:
#default_client: default # The default client, will be aliased to OidcClientInterface
clients:
default: # The client name, each client will be aliased to its name (for example, $defaultOidcClient)
# Required OIDC client configuration
well_known_url: '%env(OIDC_WELL_KNOWN_URL)%'
client_id: '%env(OIDC_CLIENT_ID)%'
client_secret: '%env(OIDC_CLIENT_SECRET)%'
redirect_route: '/login/oidc/auth'
# Extra configuration options
#redirect_route: '/login_check'
#custom_client_headers: []
# Add any extra client
#link: # Will be accessible using $linkOidcClient
#well_known_url: '%env(LINK_WELL_KNOWN_URL)%'
#client_id: '%env(LINK_CLIENT_ID)%'
#client_secret: '%env(LINK_CLIENT_SECRET)%'

21
config/packages/pwa.yaml Normal file
View File

@@ -0,0 +1,21 @@
pwa:
manifest:
enabled: true
name: "Torsearch"
short_name: "torsearch"
start_url: "/"
display: "standalone"
id: "/"
background_color: "#f98e44"
theme_color: "#083344"
description: Torsearch provides a simple and intuitive way to manage your personal media library.
icons:
- src: "icon.png"
sizes: [ 192 ]
- src: "icon.png"
sizes: [ 192 ]
purpose: maskable
categories:
- entertainment
- multimedia
- utilities

View File

@@ -10,6 +10,9 @@ security:
class: App\User\Framework\Entity\User class: App\User\Framework\Entity\User
property: email property: email
app_oidc:
id: App\User\Framework\Security\OidcUserProvider
app_ldap: app_ldap:
id: App\User\Framework\Security\LdapUserProvider id: App\User\Framework\Security\LdapUserProvider
@@ -18,14 +21,20 @@ security:
pattern: ^/(_(profiler|wdt)|css|images|js)/ pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false security: false
main: main:
lazy: true logout:
provider: app_local path: /logout
provider: app_oidc
form_login: form_login:
login_path: app_login login_path: app_login
check_path: app_login check_path: app_login
enable_csrf: true enable_csrf: true
logout: oidc:
path: app_logout login_path: '/login/oidc'
check_path: '/login/oidc/auth'
enable_end_session_listener: true
http_basic:
realm: Secured Area
entry_point: form_login
# activate different ways to authenticate # activate different ways to authenticate
# https://symfony.com/doc/current/security.html#the-firewall # https://symfony.com/doc/current/security.html#the-firewall

View File

@@ -6,6 +6,14 @@ controllersBase:
defaults: defaults:
schemes: [ 'https' ] schemes: [ 'https' ]
controllersLibrary:
resource:
path: ../src/Library/Framework/Controller/
namespace: App\Library\Framework\Controller
type: attribute
defaults:
schemes: [ 'https' ]
controllersSearch: controllersSearch:
resource: resource:
path: ../src/Search/Framework/Controller/ path: ../src/Search/Framework/Controller/

View File

@@ -1,61 +0,0 @@
security:
# https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords
password_hashers:
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
# https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider
providers:
users_in_memory: { memory: null }
app_local:
entity:
class: App\User\Framework\Entity\User
property: email
app_ldap:
id: App\User\Framework\Security\LdapUserProvider
firewalls:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
main:
lazy: true
provider: app_ldap
entry_point: form_login_ldap
form_login_ldap:
login_path: app_login
check_path: app_login
enable_csrf: true
service: Symfony\Component\Ldap\Ldap
dn_string: '%env(LDAP_DN_STRING)%'
form_login:
login_path: app_login
check_path: app_login
enable_csrf: true
logout:
path: app_logout
# activate different ways to authenticate
# https://symfony.com/doc/current/security.html#the-firewall
# https://symfony.com/doc/current/security/impersonating_user.html
# switch_user: true
# Easy way to control access for large sections of your site
# Note: Only the *first* access control that matches will be used
access_control:
- { path: ^/reset-password, roles: PUBLIC_ACCESS }
- { path: ^/login, roles: PUBLIC_ACCESS }
- { path: ^/, roles: ROLE_USER } # Or ROLE_ADMIN, ROLE_SUPER_ADMIN,
when@test:
security:
password_hashers:
# By default, password hashers are resource intensive and take time. This is
# important to generate secure password hashes. In tests however, secure hashes
# are not important, waste resources and increase test times. The following
# reduces the work factor to the lowest possible values.
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface:
algorithm: auto
cost: 4 # Lowest possible value for bcrypt
time_cost: 3 # Lowest possible value for argon
memory_cost: 10 # Lowest possible value for argon

View File

@@ -6,6 +6,7 @@
parameters: parameters:
# App # App
app.url: '%env(APP_URL)%' app.url: '%env(APP_URL)%'
app.version: '%env(default:app.default.version:APP_VERSION)%'
# Debrid Services # Debrid Services
app.debrid.real_debrid.key: '%env(REAL_DEBRID_KEY)%' app.debrid.real_debrid.key: '%env(REAL_DEBRID_KEY)%'
@@ -34,7 +35,14 @@ parameters:
app.default.version: '0.dev' app.default.version: '0.dev'
app.default.timezone: 'America/Chicago' app.default.timezone: 'America/Chicago'
app.version: '%env(default:app.default.version:APP_VERSION)%' # Auth
auth.default.method: 'form_login'
auth.method: '%env(default:auth.default.method:AUTH_METHOD)%'
auth.oidc.well_known_url: '%env(OIDC_WELL_KNOWN_URL)%'
auth.oidc.client_id: '%env(OIDC_CLIENT_ID)%'
auth.oidc.client_secret: '%env(OIDC_CLIENT_SECRET)%'
auth.oidc.bypass_form_login: '%env(bool:OIDC_BYPASS_FORM_LOGIN)%'
services: services:
# default configuration for services in *this* file # default configuration for services in *this* file

View File

@@ -3,6 +3,8 @@
frankenphp { frankenphp {
{$FRANKENPHP_CONFIG} {$FRANKENPHP_CONFIG}
num_threads 10
max_threads 20
} }
} }

View File

@@ -7,6 +7,11 @@ APP_URL="https://dev.caldwell.digital"
APP_SECRET="70169beadfbc8101c393cbfbba27a313" APP_SECRET="70169beadfbc8101c393cbfbba27a313"
APP_ENV=prod APP_ENV=prod
# Mercure is a Caddy module built into the webserver
# that facilitates the usage of websockets to transmit
# real time data (download progress, etc.)
MERCURE_JWT_SECRET="!ChangeThisMercureHubJWTSecretKey!"
# Use the DATABASE_URL below to use the MariaDB container # Use the DATABASE_URL below to use the MariaDB container
# provided in the example.compose.yml file, or remove this # provided in the example.compose.yml file, or remove this
# line and fill in the details of your own MySQL/MariaDB server # line and fill in the details of your own MySQL/MariaDB server
@@ -19,39 +24,48 @@ DATABASE_URL="mysql://root:password@database:3306/app?serverVersion=10.6.19.2-Ma
# This key is never saved anywhere # This key is never saved anywhere
# else and is passed to Torrentio # else and is passed to Torrentio
# to retrieve download options # to retrieve download options
#REAL_DEBRID_KEY="" REAL_DEBRID_KEY=""
# Enter you TMDB API key # Enter your TMDB API key
# This is used to provide rich search results # This is used to provide rich search results
# when searching for media and rendering the # when searching for media and rendering the
# Popular Movies and TV Shows section. # Popular Movies and TV Shows section.
#TMDB_API= TMDB_API=""
REAL_DEBRID_KEY=""
TMDB_API=eyJhbGciOiJIUzI1NiJ9.eyJhdWQiOiI0ZTJjYjJhOGUzOGJhNjdiNjVhOGU1NGM0ZWI1MzhmOCIsIm5iZiI6MTczNzkyNjA0NC41NjQsInN1YiI6IjY3OTZhNTljYzdiMDFiNzJjNzIzZWM5YiIsInNjb3BlcyI6WyJhcGlfcmVhZCJdLCJ2ZXJzaW9uIjoxfQ.e8DbNe9qrSBC1y-ANRv-VWBAtls-ZS2r7aNCiI68mpw
MERCURE_JWT_SECRET="!ChangeThisMercureHubJWTSecretKey!"
# Use your own Redis instance or use the # Use your own Redis instance or use the
# below value to use the container included # below value to use the container included
# in the example compose.yml file. # in the example compose.yml file.
REDIS_HOST="redis://redis" REDIS_HOST="redis://redis"
### Auth ###
# Change to "oidc" to and provide the required
# environment variables below to use OIDC auth.
AUTH_METHOD=form_login
# OIDC
OIDC_WELL_KNOWN_URL=
OIDC_CLIENT_ID=
OIDC_CLIENT_SECRET=
# Allows you to skip the login page and directly
# rely on your IdP for auth.
OIDC_BYPASS_FORM_LOGIN=
# LDAP Config: To use LDAP, enter the below fields # LDAP Config: To use LDAP, enter the below fields
# and run 'php bin/console config:set auth.method ldap' # and run 'php bin/console config:set auth.method ldap'
LDAP_HOST= # (LDAP is still in progress and not ready for use)
LDAP_PORT= #LDAP_HOST=
LDAP_ENCRYPTION= #LDAP_PORT=
LDAP_BASE_DN= #LDAP_ENCRYPTION=
LDAP_BIND_USER= #LDAP_BASE_DN=
LDAP_BIND_PASS= #LDAP_BIND_USER=
LDAP_DN_STRING= #LDAP_BIND_PASS=
LDAP_UID_KEY="uid" #LDAP_DN_STRING=
#LDAP_UID_KEY="uid"
# LDAP group that identifies an Admin # LDAP group that identifies an Admin
# Users with this LDAP group will automatically # Users with this LDAP group will automatically
# get the admin role in this system. # get the admin role in this system.
LDAP_ADMIN_ROLE_DN="" #LDAP_ADMIN_ROLE_DN=""
LDAP_EMAIL_ATTRIBUTE=mail #LDAP_EMAIL_ATTRIBUTE=mail
LDAP_USERNAME_ATTRIBUTE=uid #LDAP_USERNAME_ATTRIBUTE=uid
LDAP_NAME_ATTRIBUTE=displayname #LDAP_NAME_ATTRIBUTE=displayname

View File

@@ -64,4 +64,7 @@ return [
'version' => '2.4.3', 'version' => '2.4.3',
'type' => 'css', 'type' => 'css',
], ],
'pulltorefreshjs' => [
'version' => '0.1.22',
],
]; ];

BIN
public/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

60
public/test.php Normal file
View File

@@ -0,0 +1,60 @@
<?php
require_once '../vendor/autoload.php';
use App\Torrentio\Result\ResultFactory;
$realDebridKey = "";
$tasks = [];
$results = [];
$start = microtime(true);
for ($i = 1; $i <= 20; $i++) {
$tasks[] = \Async\spawn(function () use ($i, &$results, &$realDebridKey) {
$baseUrl = "https://torrentio.strem.fun/providers%253Dyts%252Ceztv%252Crarbg%252C1337x%252Cthepiratebay%252Ckickasstorrents%252Ctorrentgalaxy%252Cmagnetdl%252Chorriblesubs%252Cnyaasi%7Csort%253Dqualitysize%7Cqualityfilter%253D480p%252Cscr%252Ccam%7Crealdebrid={$realDebridKey}/stream/movie/tt0412142:1:$i.json";
$options = \json_decode(file_get_contents($baseUrl), true);
foreach ($options['streams'] as $stream) {
if (!str_starts_with($stream['url'], "https")) {
continue;
}
if (
array_key_exists('behaviorHints', $stream) &&
array_key_exists('bingeGroup', $stream['behaviorHints'])
) {
$bingeGroup = $stream['behaviorHints']['bingeGroup'];
} else {
$bingeGroup = '-';
}
$result = ResultFactory::map(
$stream['url'],
$stream['title'],
$bingeGroup
);
$results[] = $result;
}
});
}
\Async\awaitAll($tasks);
$end = microtime(true) - $start;
dd($end, $results);
//
//
//// Spawn multiple concurrent coroutines
//Async\spawn(function() {
// echo "Starting coroutine 1\n";
// sleep(2); // Non-blocking in async context
// echo "Coroutine 1 completed\n";
//});
//
//Async\spawn(function() {
// echo "Starting coroutine 2\n";
// sleep(1); // Non-blocking in async context
// echo "Coroutine 2 completed\n";
//});
//
//echo "All coroutines started\n";

View File

@@ -23,6 +23,21 @@ final class ConfigResolver
#[Autowire(param: 'media.tvshows.path')] #[Autowire(param: 'media.tvshows.path')]
private readonly ?string $tvshowsPath = null, private readonly ?string $tvshowsPath = null,
#[Autowire(param: 'auth.method')]
private readonly ?string $authMethod = null,
#[Autowire(param: 'auth.oidc.well_known_url')]
private readonly ?string $authOidcWellKnownUrl = null,
#[Autowire(param: 'auth.oidc.client_id')]
private readonly ?string $authOidcClientId = null,
#[Autowire(param: 'auth.oidc.client_secret')]
private readonly ?string $authOidcClientSecret = null,
#[Autowire(param: 'auth.oidc.bypass_form_login')]
private ?bool $authOidcBypassFormLogin = null,
) {} ) {}
public function validate(): bool public function validate(): bool
@@ -46,4 +61,35 @@ final class ConfigResolver
{ {
return $this->messages; return $this->messages;
} }
public function authIs(string $method): bool
{
if (strtolower($method) === strtolower($this->getAuthMethod())) {
return true;
}
return false;
}
public function getAuthMethod(): string
{
return strtolower($this->authMethod);
}
public function bypassFormLogin(): bool
{
return $this->authOidcBypassFormLogin;
}
public function getAuthConfig(): array
{
return [
'method' => $this->authMethod,
'oidc' => [
'well_known_url' => $this->authOidcWellKnownUrl,
'client_id' => $this->authOidcClientId,
'client_secret' => $this->authOidcClientSecret,
'bypass_form_login' => $this->authOidcBypassFormLogin,
]
];
}
} }

View File

@@ -11,7 +11,7 @@ use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand( #[AsCommand(
name: 'config:set', name: 'config:set',
description: 'Add a short description for your command', description: '[deprecated] This command currently serves no use. It may be re-purposed or removed in the future.',
)] )]
class ConfigSetCommand extends Command class ConfigSetCommand extends Command
{ {

View File

@@ -15,7 +15,7 @@ use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand( #[AsCommand(
name: 'db:seed', name: 'db:seed',
description: 'Seed the database with required data.', description: 'Seeds the database with required data. This command is run every time a new container is created from the torsearch-app image and is part of the init process.',
)] )]
class SeedDatabaseCommand extends Command class SeedDatabaseCommand extends Command
{ {

View File

@@ -11,7 +11,7 @@ use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand( #[AsCommand(
name: 'startup:status', name: 'startup:status',
description: 'Add a short description for your command', description: 'Used by the Docker healthcheck system to signal when the container is healthy.',
)] )]
class StartupStatusCommand extends Command class StartupStatusCommand extends Command
{ {

View File

@@ -0,0 +1,112 @@
<?php
namespace App\Base\Framework\Command;
use App\User\Framework\Repository\UserRepository;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\QuestionHelper;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\Question;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Security\Core\User\UserInterface;
#[AsCommand(name: 'user:reset-password', description: 'Resets the password for the given user. Requires either the ID or email of the User. You will be asked for the password after running the command.')]
class UserResetPasswordCommand extends Command
{
private readonly Security $security;
private readonly UserRepository $userRepository;
private readonly UserPasswordHasherInterface $hasher;
public function __construct(
Security $security,
UserRepository $userRepository,
UserPasswordHasherInterface $hasher,
) {
parent::__construct();
$this->security = $security;
$this->userRepository = $userRepository;
$this->hasher = $hasher;
}
protected function configure(): void
{
$this
->addOption('id', null, InputOption::VALUE_REQUIRED, 'The ID of the user in the database.')
->addOption('email', null, InputOption::VALUE_REQUIRED, 'The email of the user.')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$queryParams = $this->parseInput($input, $io);
if ([] === $queryParams) {
$io->error('No ID or Email specified. Please run again and pass the "--id" or "--email" option.');
return Command::FAILURE;
}
$user = $this->userRepository->findOneBy($queryParams);
if (null === $user) {
$io->error('No such user exists.');
return Command::FAILURE;
}
try {
$newPassword = $this->askForPassword($input, $output);
$this->updateUsersPassword($user, $newPassword);
} catch (\Throwable $exception) {
$io->error($exception->getMessage());
return Command::FAILURE;
}
$io->success('Success. The password has been reset.');
return Command::SUCCESS;
}
private function parseInput(InputInterface $input, SymfonyStyle $io): array
{
if ($input->getOption('id')) {
return ['id' => $input->getOption('id')];
} elseif ($input->getOption('email')) {
return ['email' => $input->getOption('email')];
}
return [];
}
private function askForPassword(InputInterface $input, OutputInterface $output): ?string
{
$questionHelper = new QuestionHelper();
$question = new Question('New password (input is hidden): ')
->setHidden(true)
->setHiddenFallback(false)
->setNormalizer(function (?string $value): string {
return $value ?? '';
})
->setValidator(function (string $value): string {
if ('' === trim($value)) {
throw new \Exception('The password cannot be empty');
}
return $value;
})
->setMaxAttempts(5)
;
return $questionHelper->ask($input, $output, $question);
}
private function updateUsersPassword(UserInterface $user, string $newPassword): void
{
$user->setPassword(
$this->hasher->hashPassword($user, $newPassword)
);
$this->userRepository->getEntityManager()->flush();
}
}

View File

@@ -2,7 +2,7 @@
namespace App\Base\Framework\Controller; namespace App\Base\Framework\Controller;
use App\Base\Util\Broadcaster; use App\Base\Service\Broadcaster;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;

View File

@@ -1,6 +1,6 @@
<?php <?php
namespace App\Base\Util; namespace App\Base\Service;
use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpFoundation\RequestStack;

View File

@@ -5,6 +5,7 @@ namespace App\Base\Service;
use Aimeos\Map; use Aimeos\Map;
use App\Download\Framework\Entity\Download; use App\Download\Framework\Entity\Download;
use Nihilarr\PTN; use Nihilarr\PTN;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Filesystem\Filesystem; use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\Finder\Finder; use Symfony\Component\Finder\Finder;
@@ -21,6 +22,7 @@ class MediaFiles
private string $tvShowsPath; private string $tvShowsPath;
private Filesystem $filesystem; private Filesystem $filesystem;
private LoggerInterface $logger;
public function __construct( public function __construct(
#[Autowire(param: 'media.base_path')] #[Autowire(param: 'media.base_path')]
@@ -33,12 +35,14 @@ class MediaFiles
string $tvShowsPath, string $tvShowsPath,
Filesystem $filesystem, Filesystem $filesystem,
LoggerInterface $logger,
) { ) {
$this->finder = new Finder(); $this->finder = new Finder();
$this->basePath = $basePath; $this->basePath = $basePath;
$this->moviesPath = $moviesPath; $this->moviesPath = $moviesPath;
$this->tvShowsPath = $tvShowsPath; $this->tvShowsPath = $tvShowsPath;
$this->filesystem = $filesystem; $this->filesystem = $filesystem;
$this->logger = $logger;
} }
public function getPathByType(string $mediaType): string public function getPathByType(string $mediaType): string
@@ -140,7 +144,7 @@ class MediaFiles
return $path; return $path;
} }
public function episodeExists(string $tvshowTitle, int $seasonNumber, int $episodeNumber) public function episodeExists(string $tvshowTitle, int $seasonNumber, int $episodeNumber): SplFileInfo|false
{ {
$existingEpisodes = $this->getEpisodes($tvshowTitle, false); $existingEpisodes = $this->getEpisodes($tvshowTitle, false);
@@ -220,4 +224,26 @@ class MediaFiles
{ {
$this->filesystem->chmod($filepath, $permissions); $this->filesystem->chmod($filepath, $permissions);
} }
/**
* @param string $filepath
* @return bool
* Returns true if file was deleted
* Returns false is file not found or was not deleted
*/
public function removeFile(string $filepath): bool
{
if (true === $this->filesystem->exists($filepath)) {
try {
$this->filesystem->remove($filepath);
return true;
} catch (\Throwable $exception) {
$this->logger->error($exception->getMessage(), ['file' => $filepath]);
return false;
}
}
$this->logger->warning('> [MediaFiles] Attempted to remove file, but it did not exist.', ['file' => $filepath]);
return false;
}
} }

View File

@@ -11,5 +11,6 @@ class DeleteDownloadCommand implements CommandInterface
{ {
public function __construct( public function __construct(
public int $downloadId, public int $downloadId,
public bool $deleteFile = false,
) {} ) {}
} }

View File

@@ -5,6 +5,8 @@ namespace App\Download\Action\Handler;
use App\Download\Action\Command\DeleteDownloadCommand; use App\Download\Action\Command\DeleteDownloadCommand;
use App\Download\Action\Result\DeleteDownloadResult; use App\Download\Action\Result\DeleteDownloadResult;
use App\Download\Framework\Repository\DownloadRepository; use App\Download\Framework\Repository\DownloadRepository;
use App\Library\Action\Command\DeleteMediaFileCommand;
use App\Library\Action\Handler\DeleteMediaFileHandler;
use OneToMany\RichBundle\Contract\CommandInterface; use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\HandlerInterface; use OneToMany\RichBundle\Contract\HandlerInterface;
use OneToMany\RichBundle\Contract\ResultInterface; use OneToMany\RichBundle\Contract\ResultInterface;
@@ -14,13 +16,26 @@ readonly class DeleteDownloadHandler implements HandlerInterface
{ {
public function __construct( public function __construct(
private DownloadRepository $downloadRepository, private DownloadRepository $downloadRepository,
private DeleteMediaFileHandler $deleteMediaFileHandler,
) {} ) {}
public function handle(CommandInterface $command): ResultInterface public function handle(CommandInterface $command): ResultInterface
{ {
$download = $this->downloadRepository->find($command->downloadId); $download = $this->downloadRepository->find($command->downloadId);
if (true === $command->deleteFile) {
$deletedFileResult = $this->deleteMediaFileHandler->handle(new DeleteMediaFileCommand(
filename: $download->getFilename(),
downloadId: $command->downloadId
));
}
$this->downloadRepository->delete($command->downloadId); $this->downloadRepository->delete($command->downloadId);
return new DeleteDownloadResult(200, 'Success', $download); return new DeleteDownloadResult(
status: 200,
message: 'Success',
download: $download,
deleteMediaFileResult: $deletedFileResult ?? null
);
} }
} }

View File

@@ -3,6 +3,7 @@
namespace App\Download\Action\Input; namespace App\Download\Action\Input;
use App\Download\Action\Command\DeleteDownloadCommand; use App\Download\Action\Command\DeleteDownloadCommand;
use OneToMany\RichBundle\Attribute\SourceQuery;
use OneToMany\RichBundle\Attribute\SourceRoute; use OneToMany\RichBundle\Attribute\SourceRoute;
use OneToMany\RichBundle\Contract\CommandInterface; use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\InputInterface; use OneToMany\RichBundle\Contract\InputInterface;
@@ -13,12 +14,15 @@ class DeleteDownloadInput implements InputInterface
public function __construct( public function __construct(
#[SourceRoute('downloadId')] #[SourceRoute('downloadId')]
public int $downloadId, public int $downloadId,
#[SourceQuery('deleteFile')]
public bool $deleteFile = false,
) {} ) {}
public function toCommand(): CommandInterface public function toCommand(): CommandInterface
{ {
return new DeleteDownloadCommand( return new DeleteDownloadCommand(
$this->downloadId, $this->downloadId,
$this->deleteFile,
); );
} }
} }

View File

@@ -12,5 +12,6 @@ class DeleteDownloadResult implements ResultInterface
public int $status, public int $status,
public string $message, public string $message,
public Download $download, public Download $download,
public ?DeleteMediaFileResult $deleteMediaFileResult = null,
) {} ) {}
} }

View File

@@ -0,0 +1,14 @@
<?php
namespace App\Download\Action\Result;
use OneToMany\RichBundle\Contract\ResultInterface;
class DeleteMediaFileResult implements ResultInterface
{
public function __construct(
public string $message,
public string $filepath,
public bool $isDeleted,
) {}
}

View File

@@ -2,9 +2,8 @@
namespace App\Download\Framework\Controller; namespace App\Download\Framework\Controller;
use App\Base\Util\Broadcaster; use App\Base\Service\Broadcaster;
use App\Download\Action\Handler\DeleteDownloadHandler; use App\Download\Action\Handler\DeleteDownloadHandler;
use App\Download\Action\Handler\DownloadSeasonHandler;
use App\Download\Action\Handler\PauseDownloadHandler; use App\Download\Action\Handler\PauseDownloadHandler;
use App\Download\Action\Handler\ResumeDownloadHandler; use App\Download\Action\Handler\ResumeDownloadHandler;
use App\Download\Action\Input\DeleteDownloadInput; use App\Download\Action\Input\DeleteDownloadInput;
@@ -13,8 +12,6 @@ use App\Download\Action\Input\DownloadSeasonInput;
use App\Download\Action\Input\PauseDownloadInput; use App\Download\Action\Input\PauseDownloadInput;
use App\Download\Action\Input\ResumeDownloadInput; use App\Download\Action\Input\ResumeDownloadInput;
use App\Download\Framework\Repository\DownloadRepository; use App\Download\Framework\Repository\DownloadRepository;
use App\User\Dto\UserPreferencesFactory;
use Nihilarr\PTN;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Messenger\MessageBusInterface; use Symfony\Component\Messenger\MessageBusInterface;
@@ -69,7 +66,7 @@ class ApiController extends AbstractController
message: "{$result->download->getTitle()} has been deleted.", message: "{$result->download->getTitle()} has been deleted.",
); );
return $this->json(['status' => 200, 'message' => 'Download Deleted']); return $this->json($result);
} }
#[Route('/api/download/{downloadId}/pause', name: 'api_download_pause', methods: ['PATCH'])] #[Route('/api/download/{downloadId}/pause', name: 'api_download_pause', methods: ['PATCH'])]

View File

@@ -7,6 +7,7 @@ use App\User\Framework\Entity\User;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use Gedmo\Timestampable\Traits\TimestampableEntity; use Gedmo\Timestampable\Traits\TimestampableEntity;
use Nihilarr\PTN; use Nihilarr\PTN;
use Symfony\Component\Serializer\Attribute\Ignore;
use Symfony\UX\Turbo\Attribute\Broadcast; use Symfony\UX\Turbo\Attribute\Broadcast;
#[ORM\Entity(repositoryClass: DownloadRepository::class)] #[ORM\Entity(repositoryClass: DownloadRepository::class)]
@@ -44,6 +45,7 @@ class Download
#[ORM\Column(length: 255, nullable: true)] #[ORM\Column(length: 255, nullable: true)]
private ?string $episodeId = null; private ?string $episodeId = null;
#[Ignore]
#[ORM\ManyToOne(inversedBy: 'downloads')] #[ORM\ManyToOne(inversedBy: 'downloads')]
private ?User $user = null; private ?User $user = null;

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Library\Action\Command;
use OneToMany\RichBundle\Contract\CommandInterface;
/**
* @implements CommandInterface<DeleteMediaFileCommand>
*/
class DeleteMediaFileCommand implements CommandInterface
{
public function __construct(
public string $filename,
public ?int $downloadId = null,
) {}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Library\Action\Command;
use OneToMany\RichBundle\Contract\CommandInterface;
class LibrarySearchCommand implements CommandInterface
{
public function __construct(
public ?string $term = null,
public ?string $title = null,
public ?string $imdbId = null,
public ?string $season = null,
public ?string $episode = null,
) {}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Library\Action\Handler;
use App\Base\Service\MediaFiles;
use App\Download\Action\Result\DeleteMediaFileResult;
use App\Download\Framework\Entity\Download;
use App\Download\Framework\Repository\DownloadRepository;
use App\Library\Action\Command\DeleteMediaFileCommand;
use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\HandlerInterface;
use OneToMany\RichBundle\Contract\ResultInterface;
/**
* @implements HandlerInterface<DeleteMediaFileCommand,DeleteMediaFileResult>
*/
class DeleteMediaFileHandler implements HandlerInterface
{
public function __construct(
private readonly DownloadRepository $downloadRepository,
private readonly MediaFiles $mediaFiles,
) {}
public function handle(CommandInterface $command): ResultInterface
{
/** @var Download $downloadRecord */
$downloadRecord = $this->downloadRepository->find($command->downloadId);
$filepath = $this->getFullFilepath($downloadRecord);
$result = $this->mediaFiles->removeFile($filepath);
return new DeleteMediaFileResult(
message: true === $result ? 'File removed' : 'File not removed',
filepath: $filepath,
isDeleted: $result
);
}
private function getFullFilepath(Download $download): string
{
return $this->mediaFiles->getPathByType($download->getMediaType()) . DIRECTORY_SEPARATOR . $download->getFilename();
}
}

View File

@@ -0,0 +1,83 @@
<?php
namespace App\Library\Action\Handler;
use App\Base\Service\MediaFiles;
use App\Library\Action\Command\LibrarySearchCommand;
use App\Library\Action\Result\LibrarySearchResult;
use App\Library\Dto\MediaFileDto;
use Nihilarr\PTN;
use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\HandlerInterface;
use OneToMany\RichBundle\Contract\ResultInterface;
/**
* @implements HandlerInterface<LibrarySearchCommand,LibrarySearchHandler>
*/
class LibrarySearchHandler implements HandlerInterface
{
private array $searchTypes = [
'episode_by_title' => 'episodeByTitle',
'movie_by_title' => 'movieByTitle',
];
public function __construct(
private readonly MediaFiles $mediaFiles,
) {}
public function handle(CommandInterface $command): ResultInterface
{
$searchType = $this->getSearchType($command);
$function = $this->searchTypes[$searchType];
return $this->$function($command);
}
private function getSearchType(CommandInterface $command): ?string
{
if (!is_null($command->title) &&
is_null($command->imdbId) &&
is_null($command->season) &&
is_null($command->episode)
) {
return 'movie_by_title';
}
if ((!is_null($command->title) || is_null($command->imdbId)) &&
!is_null($command->season) &&
!is_null($command->episode)
) {
return 'episode_by_title';
}
return null;
}
private function episodeByTitle(CommandInterface $command): ?LibrarySearchResult
{
$result = $this->mediaFiles->episodeExists(
$command->title,
(int) $command->season,
(int) $command->episode,
);
$exists = $result instanceof \SplFileInfo;
return new LibrarySearchResult(
input: $command,
exists: $exists,
file: true === $exists ? MediaFileDto::fromSplFileInfo($result) : null,
ptn: true === $exists ? (object) new PTN()->parse($result->getFilename()) : null,
);
}
private function movieByTitle(CommandInterface $command): ?LibrarySearchResult
{
$result = $this->mediaFiles->movieExists($command->title);
$exists = $result instanceof \SplFileInfo;
return new LibrarySearchResult(
input: $command,
exists: $exists,
file: true === $exists ? MediaFileDto::fromSplFileInfo($result) : null,
ptn: true === $exists ? (object) new PTN()->parse($result->getFilename()) : null,
);
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Library\Action\Input;
use App\Library\Action\Command\DeleteMediaFileCommand;
use OneToMany\RichBundle\Attribute\SourceRequest;
use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\InputInterface;
/**
* @implements InputInterface<DeleteMediaFileInput,DeleteMediaFileCommand>
*/
class DeleteMediaFileInput implements InputInterface
{
public function __construct(
#[SourceRequest('filename')]
public string $filename,
#[SourceRequest('downloadId', nullify: true)]
public ?int $downloadId = null,
) {}
public function toCommand(): CommandInterface
{
return new DeleteMediaFileCommand(
$this->filename,
$this->downloadId,
);
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace App\Library\Action\Input;
use App\Library\Action\Command\LibrarySearchCommand;
use OneToMany\RichBundle\Attribute\SourceQuery;
use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\InputInterface;
/**
* @implements InputInterface<LibrarySearchInput, LibrarySearchCommand>
*/
class LibrarySearchInput implements InputInterface
{
public function __construct(
#[SourceQuery('term', nullify: true)]
private ?string $term = null,
#[SourceQuery('title', nullify: true)]
private ?string $title = null,
#[SourceQuery('imdbId', nullify: true)]
private ?string $imdbId = null,
#[SourceQuery('season', nullify: true)]
private ?string $season = null,
#[SourceQuery('episode', nullify: true)]
private ?string $episode = null,
) {}
public function toCommand(): CommandInterface
{
return new LibrarySearchCommand(
term: $this->term,
title: $this->title,
imdbId: $this->imdbId,
season: $this->season,
episode: $this->episode,
);
}
}

View File

@@ -0,0 +1,15 @@
<?php
namespace App\Library\Action\Result;
use OneToMany\RichBundle\Contract\ResultInterface;
/**
* @implements ResultInterface
*/
class DeleteMediaFileResult implements ResultInterface
{
public function __construct(
public string $status,
) {}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Library\Action\Result;
use App\Library\Dto\MediaFileDto;
use OneToMany\RichBundle\Contract\ResultInterface;
class LibrarySearchResult implements ResultInterface
{
public function __construct(
public object|array $input,
public bool $exists,
public ?MediaFileDto $file = null,
public ?object $ptn = null,
) {}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Library\Dto;
readonly class MediaFileDto
{
public function __construct(
public string $path,
public string $filename,
public string $extension,
public string $size,
) {}
public static function fromSplFileInfo(\SplFileInfo|false $fileInfo): self|false
{
return false === $fileInfo ? false : new static(
path: $fileInfo->getRealPath(),
filename: $fileInfo->getFilename(),
extension: $fileInfo->getExtension(),
size: $fileInfo->getSize(),
);
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Library\Framework\Controller;
use App\Library\Action\Handler\LibrarySearchHandler;
use App\Library\Action\Input\LibrarySearchInput;
use App\Library\Action\Result\LibrarySearchResult;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\UX\Turbo\TurboBundle;
class Api extends AbstractController
{
#[Route('/api/library/search', name: 'api.library.search', methods: ['GET'])]
public function search(LibrarySearchInput $input, LibrarySearchHandler $handler, Request $request): Response
{
$result = $handler->handle($input->toCommand());
if ($request->headers->get('Turbo-Frame')) {
return $this->sendFragmentResponse($result, $request);
}
return $this->json($handler->handle($input->toCommand()));
}
private function sendFragmentResponse(LibrarySearchResult $result, Request $request): Response
{
$request->setRequestFormat(TurboBundle::STREAM_FORMAT);
return $this->renderBlock(
'search/fragments.html.twig',
$request->query->get('block'),
[
'result' => $result,
'target' => $request->query->get('target')
]
);
}
}

View File

@@ -2,7 +2,7 @@
namespace App\Monitor\Framework\Controller; namespace App\Monitor\Framework\Controller;
use App\Base\Util\Broadcaster; use App\Base\Service\Broadcaster;
use App\Monitor\Action\Handler\AddMonitorHandler; use App\Monitor\Action\Handler\AddMonitorHandler;
use App\Monitor\Action\Handler\DeleteMonitorHandler; use App\Monitor\Action\Handler\DeleteMonitorHandler;
use App\Monitor\Action\Input\AddMonitorInput; use App\Monitor\Action\Input\AddMonitorInput;

View File

@@ -2,7 +2,6 @@
namespace App\Search\Action\Handler; namespace App\Search\Action\Handler;
use App\Base\Service\MediaFiles;
use App\Search\Action\Command\GetMediaInfoCommand; use App\Search\Action\Command\GetMediaInfoCommand;
use App\Search\Action\Result\GetMediaInfoResult; use App\Search\Action\Result\GetMediaInfoResult;
use App\Tmdb\Tmdb; use App\Tmdb\Tmdb;
@@ -15,19 +14,12 @@ class GetMediaInfoHandler implements HandlerInterface
{ {
public function __construct( public function __construct(
private readonly Tmdb $tmdb, private readonly Tmdb $tmdb,
private readonly MediaFiles $mediaFiles
) {} ) {}
public function handle(CommandInterface $command): ResultInterface public function handle(CommandInterface $command): ResultInterface
{ {
$media = $this->tmdb->mediaDetails($command->imdbId, $command->mediaType); $media = $this->tmdb->mediaDetails($command->imdbId, $command->mediaType);
if ("tvshows" === $command->mediaType) {
foreach ($media->episodes[$command->season] as $key => $episode) {
$media->episodes[$command->season][$key]['file'] = $this->mediaFiles->episodeExists($media->title, $command->season, $episode['episode_number']);
}
}
return new GetMediaInfoResult($media, $command->season); return new GetMediaInfoResult($media, $command->season);
} }
} }

View File

@@ -0,0 +1,213 @@
<?php
namespace App\Search\Framework\Command;
use Aimeos\Map;
use App\Download\Action\Command\DownloadMediaCommand;
use App\Download\Action\Handler\DownloadMediaHandler;
use App\Download\Framework\Repository\DownloadRepository;
use App\Search\Action\Handler\SearchHandler;
use App\Search\Action\Command\SearchCommand as CommandInput;
use App\Search\Action\Result\SearchResult;
use App\Tmdb\TmdbResult;
use App\Torrentio\Action\Command\GetMovieOptionsCommand;
use App\Torrentio\Action\Command\GetTvShowOptionsCommand;
use App\Torrentio\Action\Handler\GetMovieOptionsHandler;
use App\Torrentio\Action\Handler\GetTvShowOptionsHandler;
use App\Torrentio\Result\TorrentioResult;
use App\User\Framework\Repository\UserRepository;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\Table;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\ChoiceQuestion;
use Symfony\Component\Console\Question\Question;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Messenger\MessageBusInterface;
#[AsCommand('search')]
class SearchCommand extends Command
{
private SearchHandler $searchHandler;
private GetTvShowOptionsHandler $getTvShowOptionsHandler;
private GetMovieOptionsHandler $getMovieOptionsHandler;
private UserRepository $userRepository;
private DownloadRepository $downloadRepository;
private DownloadMediaHandler $downloadMediaHandler;
private MessageBusInterface $bus;
public function __construct(SearchHandler $searchHandler, GetTvShowOptionsHandler $getTvShowOptionsHandler,
GetMovieOptionsHandler $getMovieOptionsHandler,
UserRepository $userRepository,
DownloadRepository $downloadRepository,
DownloadMediaHandler $downloadMediaHandler,
MessageBusInterface $bus,
?string $name = null
) {
parent::__construct($name);
$this->searchHandler = $searchHandler;
$this->getTvShowOptionsHandler = $getTvShowOptionsHandler;
$this->getMovieOptionsHandler = $getMovieOptionsHandler;
$this->userRepository = $userRepository;
$this->downloadRepository = $downloadRepository;
$this->downloadMediaHandler = $downloadMediaHandler;
$this->bus = $bus;
}
protected function configure()
{
$this->addArgument('term', InputArgument::REQUIRED);
$this->addOption('local', 'l', InputOption::VALUE_NONE, 'Perform the download locally instead of submitting it to the queue.');
}
public function run(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$command = new CommandInput($input->getArgument('term'));
// Perform search
$mediaOptions = $this->searchHandler->handle($command);
// Render results and ask the User to pick one
$mediaChoice = $this->askToChooseMediaOption($io, $output, $mediaOptions);
// Find download options based on the User's choice
$downloadOptions = $this->fetchDownloadOptions($mediaChoice);
// Render results and ask the User to pick one
$downloadChoice = $this->askToChooseDownloadOption($io, $output, $downloadOptions);
// Have user confirm download action
$confirmation = $this->askToConfirmDownload($io, $output, $downloadChoice);
// Begin download or submit to the queue
if (true === $confirmation) {
$downloadLocally = $input->getOption('local');
$this->submitDownload($io, $mediaChoice, $downloadChoice, $downloadLocally);
} else {
$io->success('No results found.');
}
$io->success('Success!');
return Command::SUCCESS;
}
private function askToChooseMediaOption(SymfonyStyle $io, OutputInterface $output, SearchResult $result): TmdbResult
{
$table = new Table($output)
->setHeaders(['ID', 'Title', 'Year', 'IMDb ID', 'Description'])
->setRows(
Map::from($result->results)
->map(fn ($result, $index) => [$index, $result->title, $result->year, $result->imdbId, substr($result->description, 0, 80) . '...'])
->toArray()
);
$table->render();
$question = new Question('Enter the ID of the correct result: ');
$choiceId = $io->askQuestion($question);
$choice = $result->results[$choiceId];
$io->info('You chose: ' . $choice->title);
$io->table(
['ID', 'Title', 'Year', 'IMDb ID', 'Description'],
[
[$choiceId, $choice->title, $choice->year, $choice->imdbId, substr($choice->description, 0, 80) . '...'],
]
);
return $choice;
}
private function fetchDownloadOptions(TmdbResult $result): array
{
$handlers = [
'movies' => $this->getMovieOptionsHandler,
'tvshows' => $this->getTvShowOptionsHandler,
];
$handler = $handlers[$result->mediaType];
if ("movies" === $result->mediaType) {
$command = new GetMovieOptionsCommand(
$result->tmdbId,
$result->imdbId
);
} else {
$command = new GetTvShowOptionsCommand(
$result->tmdbId,
$result->imdbId,
1,
1
);
}
$result = $handler->handle($command);
return $result->results;
}
private function askToChooseDownloadOption(SymfonyStyle $io, OutputInterface $output, array $options): TorrentioResult
{
$table = new Table($output)
->setHeaders(['ID', 'Size', 'Resolution', 'Codec', 'Seeders', 'Provider', 'Language'])
->setRows(
Map::from($options)
->map(fn (TorrentioResult $result, $index) => [$index, $result->size, $result->resolution, $result->codec, $result->seeders, $result->provider, implode(', ', $result->languages)])
->toArray()
);
$table->render();
$question = new Question('Enter the ID of the item to download: ');
$choiceId = $io->askQuestion($question);
$choice = $options[$choiceId];
$io->info('You chose: ' . $choice->title);
return $choice;
}
private function askToConfirmDownload(SymfonyStyle $io, OutputInterface $output, TorrentioResult $downloadOption): bool
{
$question = new ChoiceQuestion('Are you sure you want to download the above file?', ['yes', 'no']);
$choice = $io->askQuestion($question);
return $choice === 'yes';
}
private function submitDownload(SymfonyStyle $io, TmdbResult $mediaChoice, TorrentioResult $downloadOption, bool $downloadLocally = false): void
{
$io->writeln('> Adding download record');
$user = $this->userRepository->find(1);
$download = $this->downloadRepository->insert(
$user,
$downloadOption->url,
$downloadOption->title,
$downloadOption->filename,
$mediaChoice->imdbId,
$mediaChoice->mediaType,
);
$io->writeln('> Download record added: ' . $download->getId());
$downloadCommand = new DownloadMediaCommand(
$download->getUrl(),
$download->getTitle(),
$download->getFilename(),
$download->getMEdiaType(),
$download->getImdbId(),
$download->getUser()->getId(),
$download->getId()
);
if (true === $downloadLocally) {
$io->writeln('> Beginning local download');
$this->downloadMediaHandler->handle($downloadCommand);
} else {
$io->writeln('> Submitting download to queue');
$this->bus->dispatch($downloadCommand);
}
$io->writeln('> Download added to queue');
}
}

View File

@@ -37,7 +37,8 @@ final class WebController extends AbstractController
public function result( public function result(
GetMediaInfoInput $input, GetMediaInfoInput $input,
?int $season = null, ?int $season = null,
): Response { ): Response
{
$result = $this->getMediaInfoHandler->handle($input->toCommand()); $result = $this->getMediaInfoHandler->handle($input->toCommand());
return $this->render('search/result.html.twig', [ return $this->render('search/result.html.twig', [
@@ -52,32 +53,4 @@ final class WebController extends AbstractController
] ]
]); ]);
} }
private function warmDownloadOptionCache(TmdbResult $result)
{
if ($result->mediaType === 'tvshows') {
// dispatches a job to get the download options
// for each episode and load them in cache
foreach ($result->episodes as $season => $episodes) {
// Only do the first 2 seasons, so we reduce
// getting rate-limited by Torrentio
if ($season > 2) {
return;
}
foreach ($episodes as $episode) {
$this->bus->dispatch(new GetTvShowOptionsCommand(
tmdbId: $result->tmdbId,
imdbId: $result->imdbId,
season: $season,
episode: $episode['episode_number'],
));
}
}
} elseif ($result->mediaType === 'movies') {
$this->bus->dispatch(new GetMovieOptionsCommand(
$result->tmdbId,
$result->imdbId,
));
}
}
} }

View File

@@ -2,10 +2,12 @@
namespace App\Tmdb; namespace App\Tmdb;
use \Async;
use Aimeos\Map; use Aimeos\Map;
use App\Base\Enum\MediaType; use App\Base\Enum\MediaType;
use App\ValueObject\ResultFactory; use App\ValueObject\ResultFactory;
use Psr\Cache\CacheItemPoolInterface; use Psr\Cache\CacheItemPoolInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Contracts\Cache\ItemInterface; use Symfony\Contracts\Cache\ItemInterface;
@@ -42,6 +44,7 @@ class Tmdb
public function __construct( public function __construct(
private readonly CacheItemPoolInterface $cache, private readonly CacheItemPoolInterface $cache,
private readonly EventDispatcherInterface $eventDispatcher, private readonly EventDispatcherInterface $eventDispatcher,
private readonly LoggerInterface $logger,
#[Autowire(env: 'TMDB_API')] string $apiKey, #[Autowire(env: 'TMDB_API')] string $apiKey,
) { ) {
$this->client = new Client( $this->client = new Client(
@@ -214,7 +217,6 @@ class Tmdb
if ($season['episode_count'] <= 0 || $season['name'] === 'Specials') { if ($season['episode_count'] <= 0 || $season['name'] === 'Specials') {
continue; continue;
} }
$series['episodes'][$season['season_number']] = Map::from( $series['episodes'][$season['season_number']] = Map::from(
$client->getApi()->getSeason($series['id'], $season['season_number'])['episodes'] $client->getApi()->getSeason($series['id'], $season['season_number'])['episodes']
)->map(function ($data) { )->map(function ($data) {
@@ -301,6 +303,7 @@ class Tmdb
description: $data['overview'], description: $data['overview'],
year: (new \DateTime($data['release_date']))->format('Y'), year: (new \DateTime($data['release_date']))->format('Y'),
mediaType: "movies", mediaType: "movies",
episodeAirDate: (new \DateTime($data['release_date']))->format('m/d/Y'),
); );
} }

View File

@@ -3,6 +3,7 @@
namespace App\Torrentio\Action\Handler; namespace App\Torrentio\Action\Handler;
use App\Base\Service\MediaFiles; use App\Base\Service\MediaFiles;
use App\Library\Dto\MediaFileDto;
use App\Tmdb\Tmdb; use App\Tmdb\Tmdb;
use App\Torrentio\Action\Command\GetTvShowOptionsCommand; use App\Torrentio\Action\Command\GetTvShowOptionsCommand;
use App\Torrentio\Action\Result\GetTvShowOptionsResult; use App\Torrentio\Action\Result\GetTvShowOptionsResult;
@@ -28,7 +29,7 @@ class GetTvShowOptionsHandler implements HandlerInterface
return new GetTvShowOptionsResult( return new GetTvShowOptionsResult(
media: $media, media: $media,
file: $file, file: MediaFileDto::fromSplFileInfo($file),
season: $command->season, season: $command->season,
episode: $command->episode, episode: $command->episode,
results: $this->torrentio->fetchEpisodeResults( results: $this->torrentio->fetchEpisodeResults(

View File

@@ -2,15 +2,15 @@
namespace App\Torrentio\Action\Result; namespace App\Torrentio\Action\Result;
use App\Library\Dto\MediaFileDto;
use App\Tmdb\TmdbResult; use App\Tmdb\TmdbResult;
use OneToMany\RichBundle\Contract\ResultInterface; use OneToMany\RichBundle\Contract\ResultInterface;
use Symfony\Component\Finder\SplFileInfo;
class GetTvShowOptionsResult implements ResultInterface class GetTvShowOptionsResult implements ResultInterface
{ {
public function __construct( public function __construct(
public TmdbResult $media, public TmdbResult $media,
public bool|SplFileInfo $file, public MediaFileDto|false $file,
public string $season, public string $season,
public string $episode, public string $episode,
public array $results public array $results

View File

@@ -2,19 +2,22 @@
namespace App\Torrentio\Framework\Controller; namespace App\Torrentio\Framework\Controller;
use App\Base\Util\Broadcaster; use App\Base\Service\Broadcaster;
use App\Torrentio\Action\Handler\GetMovieOptionsHandler; use App\Torrentio\Action\Handler\GetMovieOptionsHandler;
use App\Torrentio\Action\Handler\GetTvShowOptionsHandler; use App\Torrentio\Action\Handler\GetTvShowOptionsHandler;
use App\Torrentio\Action\Input\GetMovieOptionsInput; use App\Torrentio\Action\Input\GetMovieOptionsInput;
use App\Torrentio\Action\Input\GetTvShowOptionsInput; use App\Torrentio\Action\Input\GetTvShowOptionsInput;
use App\Torrentio\Exception\TorrentioRateLimitException; use App\Torrentio\Exception\TorrentioRateLimitException;
use Carbon\Carbon; use Carbon\Carbon;
use OneToMany\RichBundle\Contract\ResultInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\Cache;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
use Symfony\Contracts\Cache\CacheInterface; use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface; use Symfony\Contracts\Cache\ItemInterface;
use Symfony\UX\Turbo\TurboBundle;
final class WebController extends AbstractController final class WebController extends AbstractController
{ {
@@ -24,8 +27,9 @@ final class WebController extends AbstractController
private readonly Broadcaster $broadcaster, private readonly Broadcaster $broadcaster,
) {} ) {}
#[Cache(expires: 3600, public: false, mustRevalidate: true)]
#[Route('/torrentio/movies/{tmdbId}/{imdbId}', name: 'app_torrentio_movies')] #[Route('/torrentio/movies/{tmdbId}/{imdbId}', name: 'app_torrentio_movies')]
public function movieOptions(GetMovieOptionsInput $input, CacheInterface $cache): Response public function movieOptions(GetMovieOptionsInput $input, CacheInterface $cache, Request $request): Response
{ {
$cacheId = sprintf( $cacheId = sprintf(
"page.torrentio.movies.%s.%s", "page.torrentio.movies.%s.%s",
@@ -33,17 +37,22 @@ final class WebController extends AbstractController
$input->imdbId $input->imdbId
); );
return $cache->get($cacheId, function (ItemInterface $item) use ($input) { $results = $cache->get($cacheId, function (ItemInterface $item) use ($input, $request) {
$item->expiresAt(Carbon::now()->addHour()->setMinute(0)->setSecond(0)); $item->expiresAt(Carbon::now()->addHour()->setMinute(0)->setSecond(0));
$results = $this->getMovieOptionsHandler->handle($input->toCommand()); return $this->getMovieOptionsHandler->handle($input->toCommand());
return $this->render('torrentio/movies.html.twig', [
'results' => $results,
]);
}); });
if ($request->headers->get('Turbo-Frame')) {
return $this->sendFragmentResponse($results, $request);
}
return $this->render('torrentio/movies.html.twig', [
'results' => $results,
]);
} }
#[Route('/torrentio/tvshows/{tmdbId}/{imdbId}/{season?}/{episode?}', name: 'app_torrentio_tvshows')] #[Route('/torrentio/tvshows/{tmdbId}/{imdbId}/{season?}/{episode?}', name: 'app_torrentio_tvshows')]
public function tvShowOptions(GetTvShowOptionsInput $input, CacheInterface $cache): Response public function tvShowOptions(GetTvShowOptionsInput $input, CacheInterface $cache, Request $request): Response
{ {
$cacheId = sprintf( $cacheId = sprintf(
"page.torrentio.tvshows.%s.%s.%s.%s", "page.torrentio.tvshows.%s.%s.%s.%s",
@@ -54,13 +63,18 @@ final class WebController extends AbstractController
); );
try { try {
// return $cache->get($cacheId, function (ItemInterface $item) use ($input) { $results = $cache->get($cacheId, function (ItemInterface $item) use ($input) {
// $item->expiresAt(Carbon::now()->addHour()->setMinute(0)->setSecond(0)); $item->expiresAt(Carbon::now()->addHour()->setMinute(0)->setSecond(0));
$results = $this->getTvShowOptionsHandler->handle($input->toCommand()); return $this->getTvShowOptionsHandler->handle($input->toCommand());
return $this->render('torrentio/tvshows.html.twig', [ });
'results' => $results,
]); if ($request->headers->get('Turbo-Frame')) {
// }); return $this->sendFragmentResponse($results, $request);
}
return $this->render('torrentio/tvshows.html.twig', [
'results' => $results,
]);
} catch (TorrentioRateLimitException $exception) { } catch (TorrentioRateLimitException $exception) {
$this->broadcaster->alert('Warning', 'Torrentio has rate limited your requests. Please wait a few minutes before trying again.', 'warning'); $this->broadcaster->alert('Warning', 'Torrentio has rate limited your requests. Please wait a few minutes before trying again.', 'warning');
return $this->render('bare.html.twig', return $this->render('bare.html.twig',
@@ -73,29 +87,16 @@ final class WebController extends AbstractController
} }
} }
#[Route('/torrentio/tvshows/clear/{tmdbId}/{imdbId}/{season?}/{episode?}', name: 'app_clear_torrentio_tvshows')] private function sendFragmentResponse(ResultInterface $result, Request $request): Response
public function clearTvShowOptions(GetTvShowOptionsInput $input, CacheInterface $cache, Request $request): Response
{ {
$cacheId = sprintf( $request->setRequestFormat(TurboBundle::STREAM_FORMAT);
"page.torrentio.tvshows.%s.%s.%s.%s", return $this->renderBlock(
$input->tmdbId, 'torrentio/fragments.html.twig',
$input->imdbId, $request->query->get('block'),
$input->season, [
$input->episode, 'results' => $result,
'target' => $request->query->get('target')
]
); );
$cache->delete($cacheId);
$this->broadcaster->alert(
title: 'Success',
message: 'Torrentio cache Cleared.'
);
return $cache->get($cacheId, function (ItemInterface $item) use ($input) {
$item->expiresAt(Carbon::now()->addHour()->setMinute(0)->setSecond(0));
$results = $this->getTvShowOptionsHandler->handle($input->toCommand());
return $this->render('torrentio/tvshows.html.twig', [
'results' => $results,
]);
});
} }
} }

View File

@@ -2,7 +2,7 @@
namespace App\Torrentio\Result; namespace App\Torrentio\Result;
use App\Base\Util\CountryLanguages; use App\User\Database\CountryLanguages;
use Nihilarr\PTN; use Nihilarr\PTN;
class ResultFactory class ResultFactory

View File

@@ -3,7 +3,7 @@
namespace App\Twig\Components; namespace App\Twig\Components;
use Aimeos\Map; use Aimeos\Map;
use App\Base\Util\QualityList; use App\User\Database\QualityList;
use App\User\Framework\Repository\PreferencesRepository; use App\User\Framework\Repository\PreferencesRepository;
use Symfony\Bundle\SecurityBundle\Security; use Symfony\Bundle\SecurityBundle\Security;
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;

View File

@@ -47,7 +47,7 @@ class UtilExtension
} }
#[AsTwigFilter('episode_id_from_results')] #[AsTwigFilter('episode_id_from_results')]
public function episodeId($result): ?string public function episodeIdFromResults($result): ?string
{ {
if (!$result instanceof GetTvShowOptionsResult) { if (!$result instanceof GetTvShowOptionsResult) {
return null; return null;
@@ -56,4 +56,11 @@ class UtilExtension
return "S". str_pad($result->season, 2, "0", STR_PAD_LEFT) . return "S". str_pad($result->season, 2, "0", STR_PAD_LEFT) .
"E". str_pad($result->episode, 2, "0", STR_PAD_LEFT); "E". str_pad($result->episode, 2, "0", STR_PAD_LEFT);
} }
#[AsTwigFunction('episode_id')]
public function episodeId($season, $episode): ?string
{
return "S". str_pad($season, 2, "0", STR_PAD_LEFT) .
"E". str_pad($episode, 2, "0", STR_PAD_LEFT);
}
} }

View File

@@ -1,6 +1,6 @@
<?php <?php
namespace App\Base\Util; namespace App\User\Database;
class CountryCodes class CountryCodes
{ {

View File

@@ -1,6 +1,6 @@
<?php <?php
namespace App\Base\Util; namespace App\User\Database;
class CountryLanguages class CountryLanguages
{ {
@@ -137,4 +137,13 @@ class CountryLanguages
return $countryLanguages[$countryName] ?? null; return $countryLanguages[$countryName] ?? null;
} }
public static function asSelectOptions(): array
{
$result = [];
foreach (static::$languages as $language) {
$result[$language] = $language;
}
return $result;
}
} }

View File

@@ -1,6 +1,6 @@
<?php <?php
namespace App\Base\Util; namespace App\User\Database;
class ProviderList class ProviderList
{ {
@@ -23,4 +23,13 @@ class ProviderList
{ {
return self::$providers; return self::$providers;
} }
public static function asSelectOptions(): array
{
$result = [];
foreach (static::$providers as $provider) {
$result[$provider] = $provider;
}
return $result;
}
} }

View File

@@ -1,6 +1,6 @@
<?php <?php
namespace App\Base\Util; namespace App\User\Database;
class QualityList class QualityList
{ {
@@ -100,6 +100,15 @@ class QualityList
return array_search($key, self::$qualities) ?? null; return array_search($key, self::$qualities) ?? null;
} }
public static function asSelectOptions(): array
{
$result = [];
foreach (array_keys(static::$qualities) as $quality) {
$result[$quality] = $quality;
}
return $result;
}
public static function getAsReverseMap(): array public static function getAsReverseMap(): array
{ {
$results = []; $results = [];

View File

@@ -2,22 +2,30 @@
namespace App\User\Framework\Controller\Web; namespace App\User\Framework\Controller\Web;
use App\Base\ConfigResolver;
use App\User\Framework\Repository\UserRepository; use App\User\Framework\Repository\UserRepository;
use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\ArrayCollection;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Authentication\AuthenticationUtils; use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
class LoginController extends AbstractController class LoginController extends AbstractController
{ {
#[Route(path: '/login', name: 'app_login')] #[Route(path: '/login', name: 'app_login')]
public function login(AuthenticationUtils $authenticationUtils, UserRepository $userRepository): Response public function login(ConfigResolver $config, AuthenticationUtils $authenticationUtils, UserRepository $userRepository): Response
{ {
if ((new ArrayCollection($userRepository->findAll()))->count() === 0) { if ((new ArrayCollection($userRepository->findAll()))->count() === 0) {
return $this->redirectToRoute('app_getting_started'); return $this->redirectToRoute('app_getting_started');
} }
if ($config->authIs('oidc') && $config->bypassFormLogin()) {
return $this->redirectToRoute('app_login_oidc');
}
// get the login error if there is one // get the login error if there is one
$error = $authenticationUtils->getLastAuthenticationError(); $error = $authenticationUtils->getLastAuthenticationError();
@@ -25,13 +33,14 @@ class LoginController extends AbstractController
$lastUsername = $authenticationUtils->getLastUsername(); $lastUsername = $authenticationUtils->getLastUsername();
return $this->render('user/login.html.twig', [ return $this->render('user/login.html.twig', [
'show_oidc_button' => $config->authIs('oidc'),
'last_username' => $lastUsername, 'last_username' => $lastUsername,
'error' => $error, 'error' => $error,
]); ]);
} }
#[Route(path: '/logout', name: 'app_logout')] #[Route(path: '/logout', name: 'app_logout')]
public function logout(): void public function logout(Security $security, Request $request): void
{ {
throw new \LogicException('This method can be blank - it will be intercepted by the logout key on your firewall.'); throw new \LogicException('This method can be blank - it will be intercepted by the logout key on your firewall.');
} }

View File

@@ -0,0 +1,46 @@
<?php
namespace App\User\Framework\Controller\Web;
use App\Base\ConfigResolver;
use Drenso\OidcBundle\OidcClientInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
class LoginOidcController extends AbstractController
{
public function __construct(
private ConfigResolver $configResolver,
) {}
#[Route('/login/oidc', name: 'app_login_oidc')]
public function oidcStart(OidcClientInterface $oidcClient): RedirectResponse
{
if (false === $this->configResolver->authIs('oidc')) {
throw new \Exception('You must configure the OIDC environment variables before logging in at this route.');
}
// Redirect to authorization @ OIDC provider
return $oidcClient->generateAuthorizationRedirect(scopes: ['openid', 'profile']);
}
#[Route('/login/oidc/auth', name: 'app_login_oidc_auth')]
public function oidcAuthenticate(): RedirectResponse
{
if (false === $this->configResolver->authIs('oidc')) {
throw new \Exception('You must configure the OIDC environment variables before logging in at this route.');
}
throw new \LogicException('This method can be blank - it will be intercepted by the "oidc" key on your firewall.');
}
#[Route('/logout/oidc', 'app_logout_oidc')]
public function oidcLogout(OidcClientInterface $oidcClient, Request $request, Security $security): RedirectResponse
{
// ToDo: Configure multiple authentication methods and redirect to the form login here
}
}

View File

@@ -4,14 +4,16 @@ declare(strict_types=1);
namespace App\User\Framework\Controller\Web; namespace App\User\Framework\Controller\Web;
use App\Base\Util\Broadcaster; use App\Base\Service\Broadcaster;
use App\Base\Util\CountryLanguages;
use App\Base\Util\ProviderList;
use App\Base\Util\QualityList;
use App\User\Action\Handler\SaveUserDownloadPreferencesHandler; use App\User\Action\Handler\SaveUserDownloadPreferencesHandler;
use App\User\Action\Handler\SaveUserMediaPreferencesHandler; use App\User\Action\Handler\SaveUserMediaPreferencesHandler;
use App\User\Action\Input\SaveUserDownloadPreferencesInput; use App\User\Action\Input\SaveUserDownloadPreferencesInput;
use App\User\Action\Input\SaveUserMediaPreferencesInput; use App\User\Action\Input\SaveUserMediaPreferencesInput;
use App\User\Database\CountryLanguages;
use App\User\Database\ProviderList;
use App\User\Database\QualityList;
use App\User\Dto\UserPreferencesFactory;
use App\User\Framework\Form\GettingStartedFilterForm;
use App\User\Framework\Repository\PreferencesRepository; use App\User\Framework\Repository\PreferencesRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
@@ -41,6 +43,7 @@ class PreferencesController extends AbstractController
'qualities' => QualityList::getBaseQualities(), 'qualities' => QualityList::getBaseQualities(),
'mediaPreferences' => $mediaPreferences, 'mediaPreferences' => $mediaPreferences,
'downloadPreferences' => $downloadPreferences, 'downloadPreferences' => $downloadPreferences,
'filterForm' => $this->createForm(GettingStartedFilterForm::class, (array) UserPreferencesFactory::createFromUser($this->getUser())),
] ]
); );
} }
@@ -72,6 +75,7 @@ class PreferencesController extends AbstractController
'qualities' => QualityList::getBaseQualities(), 'qualities' => QualityList::getBaseQualities(),
'mediaPreferences' => $mediaPreferences, 'mediaPreferences' => $mediaPreferences,
'downloadPreferences' => $downloadPreferences, 'downloadPreferences' => $downloadPreferences,
'filterForm' => $this->createForm(GettingStartedFilterForm::class ?? null),
] ]
); );
} }

View File

@@ -5,30 +5,22 @@ namespace App\User\Framework\Controller\Web;
use App\User\Action\Command\RegisterUserCommand; use App\User\Action\Command\RegisterUserCommand;
use App\User\Action\Handler\RegisterUserHandler; use App\User\Action\Handler\RegisterUserHandler;
use App\User\Framework\Entity\User; use App\User\Framework\Entity\User;
use App\User\Framework\Form\GettingStartedFilterForm;
use App\User\Framework\Form\RegistrationFormType; use App\User\Framework\Form\RegistrationFormType;
use App\User\Framework\Pipeline\GettingStarted\AddPreferencesToDatabase;
use App\User\Framework\Pipeline\GettingStarted\GettingStartedInput;
use App\User\Framework\Pipeline\GettingStarted\MigrateDatabase;
use App\User\Framework\Repository\PreferencesRepository;
use App\User\Framework\Repository\UserRepository; use App\User\Framework\Repository\UserRepository;
use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\ArrayCollection;
use League\Pipeline\Pipeline;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\SecurityBundle\Security; use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\KernelInterface;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
class RegistrationController extends AbstractController class RegistrationController extends AbstractController
{ {
public function __construct(private readonly RegisterUserHandler $registerUserHandler, public function __construct(private readonly RegisterUserHandler $registerUserHandler,
private readonly RequestStack $requestStack private readonly RequestStack $requestStack,
) ) {}
{
}
#[Route('/register', name: 'app_register')] #[Route('/register', name: 'app_register')]
public function register( public function register(
@@ -57,7 +49,7 @@ class RegistrationController extends AbstractController
} }
#[Route(path: '/getting-started', name: 'app_getting_started')] #[Route(path: '/getting-started', name: 'app_getting_started')]
public function gettingStarted(Request $request, Security $security, UserRepository $userRepository, PreferencesRepository $preferencesRepository, KernelInterface $kernel, LoggerInterface $logger): Response public function gettingStarted(Request $request, Security $security, UserRepository $userRepository): Response
{ {
if ((new ArrayCollection($userRepository->findAll()))->count() !== 0) { if ((new ArrayCollection($userRepository->findAll()))->count() !== 0) {
return $this->redirectToRoute('app_index'); return $this->redirectToRoute('app_index');
@@ -73,14 +65,42 @@ class RegistrationController extends AbstractController
password: $form->get('plainPassword')->getData(), password: $form->get('plainPassword')->getData(),
)); ));
$security->login($user->user); $security->login($user->user, 'form_login');
$this->requestStack->getCurrentRequest()->getSession()->set('mercure_alert_topic', 'alerts_' . uniqid()); $this->requestStack->getCurrentRequest()->getSession()->set('mercure_alert_topic', 'alerts_' . uniqid());
return $this->redirectToRoute('app_index'); return $this->redirectToRoute('app_getting_started_filter');
} }
return $this->render('user/getting-started.html.twig', [ return $this->render('user/getting_started/register-user.html.twig', [
'registrationForm' => $form, 'registrationForm' => $form,
]); ]);
} }
#[Route(path: '/getting-started/filter', name: 'app_getting_started_filter')]
public function gettingStartedPreferences(Request $request, UserRepository $userRepository): Response
{
if ((new ArrayCollection($userRepository->findAll()))->count() !== 0) {
return $this->redirectToRoute('app_index');
}
$form = $this->createForm(GettingStartedFilterForm::class);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
foreach ($form->getData() as $preference => $value) {
if (null !== $value) {
$this->getUser()->updateUserPreference($preference, $value);
}
}
$userRepository->getEntityManager()->flush();
return $this->redirectToRoute('app_index');
}
return $this->render(
'user/getting_started/filter.html.twig',
[
'form' => $form,
]
);
}
} }

View File

@@ -2,20 +2,21 @@
namespace App\User\Framework\Controller\Web; namespace App\User\Framework\Controller\Web;
use App\Base\ConfigResolver;
use App\User\Framework\Entity\User; use App\User\Framework\Entity\User;
use App\User\Framework\Form\ChangePasswordForm; use App\User\Framework\Form\ChangePasswordForm;
use App\User\Framework\Form\ResetPasswordRequestForm; use App\User\Framework\Form\ResetPasswordRequestForm;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bridge\Twig\Mime\TemplatedEmail; use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Mailer\MailerInterface; use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Address;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Contracts\Translation\TranslatorInterface; use Symfony\Contracts\Translation\TranslatorInterface;
use SymfonyCasts\Bundle\ResetPassword\Controller\ResetPasswordControllerTrait; use SymfonyCasts\Bundle\ResetPassword\Controller\ResetPasswordControllerTrait;
use SymfonyCasts\Bundle\ResetPassword\Exception\ResetPasswordExceptionInterface; use SymfonyCasts\Bundle\ResetPassword\Exception\ResetPasswordExceptionInterface;
@@ -28,7 +29,9 @@ class ResetPasswordController extends AbstractController
public function __construct( public function __construct(
private ResetPasswordHelperInterface $resetPasswordHelper, private ResetPasswordHelperInterface $resetPasswordHelper,
private EntityManagerInterface $entityManager private EntityManagerInterface $entityManager,
private readonly ConfigResolver $configResolver,
private readonly Security $security
) { ) {
} }
@@ -36,17 +39,25 @@ class ResetPasswordController extends AbstractController
* Display & process form to request a password reset. * Display & process form to request a password reset.
*/ */
#[Route('', name: 'app_forgot_password_request')] #[Route('', name: 'app_forgot_password_request')]
public function request(Request $request, MailerInterface $mailer, TranslatorInterface $translator): Response public function request(
{ Request $request,
MailerInterface $mailer,
LoggerInterface $logger
): Response {
$form = $this->createForm(ResetPasswordRequestForm::class); $form = $this->createForm(ResetPasswordRequestForm::class);
$form->handleRequest($request); $form->handleRequest($request);
if ($this->configResolver->authIs('oidc')) {
$this->addFlash('reset_password_error', 'Your auth method is set to "oidc", so you will need to reset your password with your identity provider.');
return $this->render('user/reset_password/request.html.twig', [
'requestForm' => $form,
])->setStatusCode(Response::HTTP_ACCEPTED);
}
if ($form->isSubmitted() && $form->isValid()) { if ($form->isSubmitted() && $form->isValid()) {
/** @var string $email */ /** @var string $email */
$email = $form->get('email')->getData(); $email = $form->get('email')->getData();
return $this->processSendingPasswordResetEmail($email, $mailer, $logger);
return $this->processSendingPasswordResetEmail($email, $mailer, $translator
);
} }
return $this->render('user/reset_password/request.html.twig', [ return $this->render('user/reset_password/request.html.twig', [
@@ -75,8 +86,12 @@ class ResetPasswordController extends AbstractController
* Validates and process the reset URL that the user clicked in their email. * Validates and process the reset URL that the user clicked in their email.
*/ */
#[Route('/reset/{token}', name: 'app_reset_password')] #[Route('/reset/{token}', name: 'app_reset_password')]
public function reset(Request $request, UserPasswordHasherInterface $passwordHasher, TranslatorInterface $translator, ?string $token = null): Response public function reset(
{ Request $request,
UserPasswordHasherInterface $passwordHasher,
TranslatorInterface $translator,
?string $token = null
): Response {
if ($token) { if ($token) {
// We store the token in session and remove it from the URL, to avoid the URL being // We store the token in session and remove it from the URL, to avoid the URL being
// loaded in a browser and potentially leaking the token to 3rd party JavaScript. // loaded in a browser and potentially leaking the token to 3rd party JavaScript.
@@ -130,8 +145,11 @@ class ResetPasswordController extends AbstractController
]); ]);
} }
private function processSendingPasswordResetEmail(string $emailFormData, MailerInterface $mailer, TranslatorInterface $translator): RedirectResponse private function processSendingPasswordResetEmail(
{ string $emailFormData,
MailerInterface $mailer,
LoggerInterface $logger
): RedirectResponse {
$user = $this->entityManager->getRepository(User::class)->findOneBy([ $user = $this->entityManager->getRepository(User::class)->findOneBy([
'email' => $emailFormData, 'email' => $emailFormData,
]); ]);
@@ -144,21 +162,17 @@ class ResetPasswordController extends AbstractController
try { try {
$resetToken = $this->resetPasswordHelper->generateResetToken($user); $resetToken = $this->resetPasswordHelper->generateResetToken($user);
} catch (ResetPasswordExceptionInterface $e) { } catch (ResetPasswordExceptionInterface $e) {
// If you want to tell the user why a reset email was not sent, uncomment $logger->error('> [ResetPasswordController@processSendingPasswordResetEmail] ' . $e->getMessage());
// the lines below and change the redirect to 'app_forgot_password_request'.
// Caution: This may reveal if a user is registered or not. $this->addFlash(
// 'reset_password_error',
// $this->addFlash('reset_password_error', sprintf( 'Your password reset token could not be generated. If you\'re the system administrator, check the server logs for more details.'
// '%s - %s', );
// $translator->trans(ResetPasswordExceptionInterface::MESSAGE_PROBLEM_HANDLE, [], 'ResetPasswordBundle'),
// $translator->trans($e->getReason(), [], 'ResetPasswordBundle')
// ));
return $this->redirectToRoute('app_check_email'); return $this->redirectToRoute('app_check_email');
} }
$email = (new TemplatedEmail()) $email = (new TemplatedEmail())
->from(new Address('notify@caldwell.digital', 'Torsearch'))
->to((string) $user->getEmail()) ->to((string) $user->getEmail())
->subject('Your password reset request') ->subject('Your password reset request')
->htmlTemplate('user/reset_password/email.html.twig') ->htmlTemplate('user/reset_password/email.html.twig')

View File

@@ -3,7 +3,7 @@
namespace App\User\Framework\EventListener; namespace App\User\Framework\EventListener;
use App\Base\ConfigResolver; use App\Base\ConfigResolver;
use App\Base\Util\Broadcaster; use App\Base\Service\Broadcaster;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener; use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Security\Core\Event\AuthenticationSuccessEvent; use Symfony\Component\Security\Core\Event\AuthenticationSuccessEvent;

View File

@@ -0,0 +1,59 @@
<?php
namespace App\User\Framework\Form;
use Aimeos\Map;
use App\User\Database\CountryLanguages;
use App\User\Database\ProviderList;
use App\User\Database\QualityList;
use App\User\Framework\Repository\PreferenceOptionRepository;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class GettingStartedFilterForm extends AbstractType
{
public function __construct(
private readonly PreferenceOptionRepository $preferenceOptionRepository,
) {}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$this->addChoiceField($builder, 'language', CountryLanguages::asSelectOptions());
$this->addChoiceField($builder, 'quality', QualityList::asSelectOptions());
$this->addChoiceField($builder, 'provider', ProviderList::asSelectOptions());
$this->addChoiceField($builder, 'resolution', $this->getPreferenceChoices('resolution'));
$this->addChoiceField($builder, 'codec', $this->getPreferenceChoices('codec'));
}
private function addChoiceField(FormBuilderInterface $builder, string $fieldName, array $choices): void
{
$question = [
'attr' => ['class' => 'w-full text-input mb-4'],
'label_attr' => ['class' => 'w-full block font-semibold'],
'choices' => $this->addDefaultChoice($choices),
];
$builder->add($fieldName, ChoiceType::class, $question);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([]);
}
private function getPreferenceChoices(string $preference): array
{
$options = $this->preferenceOptionRepository->findBy(['preference' => $preference]);
$result = [];
foreach ($options as $item) {
$result[$item->getName()] = $item->getId();
}
return $result;
}
private function addDefaultChoice(array $choices): iterable
{
return ['n/a' => null] + $choices;
}
}

View File

@@ -27,7 +27,7 @@ class RegistrationFormType extends AbstractType
'message' => 'Please enter a password', 'message' => 'Please enter a password',
]), ]),
new Length([ new Length([
'min' => 6, 'min' => 8,
'minMessage' => 'Your password should be at least {{ limit }} characters', 'minMessage' => 'Your password should be at least {{ limit }} characters',
// max length allowed by Symfony for security reasons // max length allowed by Symfony for security reasons
'max' => 4096, 'max' => 4096,

View File

@@ -0,0 +1,57 @@
<?php
namespace App\User\Framework\Security;
use App\User\Framework\Entity\User;
use App\User\Framework\Repository\UserRepository;
use Drenso\OidcBundle\Exception\OidcException;
use Drenso\OidcBundle\Model\OidcTokens;
use Drenso\OidcBundle\Model\OidcUserData;
use Drenso\OidcBundle\Security\UserProvider\OidcUserProviderInterface;
use Symfony\Component\PasswordHasher\PasswordHasherInterface;
use Symfony\Component\Security\Core\Exception\UnsupportedUserException;
use Symfony\Component\Security\Core\Exception\UserNotFoundException;
use Symfony\Component\Security\Core\User\OidcUser;
use Symfony\Component\Security\Core\User\UserInterface;
class OidcUserProvider implements OidcUserProviderInterface
{
public function __construct(
private readonly UserRepository $userRepository,
) {}
public function ensureUserExists(string $userIdentifier, OidcUserData $userData, OidcTokens $tokens): void
{
$user = $this->userRepository->findOneBy(['email' => $userIdentifier]);
if (null === $user) {
$user = new User()
->setEmail(!empty($userData->getEmail()) ? $userData->getEmail() : $userData->getSub())
->setName(!empty($userData->getFullName()) ? $userData->getFullName() : $userData->getGivenName())
->setPassword('n/a')
;
$this->userRepository->getEntityManager()->persist($user);
$this->userRepository->getEntityManager()->flush();
}
}
public function loadOidcUser(string $userIdentifier): UserInterface
{
return $this->userRepository->findOneBy(['email' => $userIdentifier]);
}
public function refreshUser(UserInterface $user): UserInterface
{
return $this->userRepository->findOneBy(['email' => $user->getUserIdentifier()]);
}
public function supportsClass(string $class): bool
{
return User::class === $class || OidcUser::class === $class;
}
public function loadUserByIdentifier(string $identifier): UserInterface
{
return $this->userRepository->findOneBy(['email' => $identifier]);
}
}

View File

@@ -50,6 +50,18 @@
"migrations/.gitignore" "migrations/.gitignore"
] ]
}, },
"drenso/symfony-oidc-bundle": {
"version": "4.2",
"recipe": {
"repo": "github.com/symfony/recipes-contrib",
"branch": "main",
"version": "2.0",
"ref": "e2b975158d940a191f48e3ff2c59108a1d7225e6"
},
"files": [
"config/packages/drenso_oidc.yaml"
]
},
"php-http/discovery": { "php-http/discovery": {
"version": "1.20", "version": "1.20",
"recipe": { "recipe": {
@@ -74,6 +86,9 @@
"phpstan.dist.neon" "phpstan.dist.neon"
] ]
}, },
"spomky-labs/pwa-bundle": {
"version": "1.2.5"
},
"stof/doctrine-extensions-bundle": { "stof/doctrine-extensions-bundle": {
"version": "1.14", "version": "1.14",
"recipe": { "recipe": {

View File

@@ -3,6 +3,7 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
{{ pwa() }}
<title>{% block title %}Welcome!{% endblock %}</title> <title>{% block title %}Welcome!{% endblock %}</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 128 128%22><text y=%221.2em%22 font-size=%2296%22>⚫️</text><text y=%221.3em%22 x=%220.2em%22 font-size=%2276%22 fill=%22%23fff%22>sf</text></svg>"> <link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 128 128%22><text y=%221.2em%22 font-size=%2296%22>⚫️</text><text y=%221.3em%22 x=%220.2em%22 font-size=%2276%22 fill=%22%23fff%22>sf</text></svg>">
{% block stylesheets %} {% block stylesheets %}
@@ -15,7 +16,7 @@
</head> </head>
<body class="bg-cyan-950 flex flex-col h-full"> <body class="bg-cyan-950 flex flex-col h-full">
<h1 class="px-4 py-4 text-3xl font-extrabold text-orange-500">Torsearch</h1> <h1 class="px-4 py-4 text-3xl font-extrabold text-orange-500">Torsearch</h1>
<div class="flex flex-col justify-center items-center"> <div class="p-4 flex flex-col justify-center items-center">
{% block body %}{% endblock %} {% block body %}{% endblock %}
<div class="mt-2 inline-flex gap-4 justify-between text-white"> <div class="mt-2 inline-flex gap-4 justify-between text-white">
<a class="text-sm" href="{{ path('app_login') }}">Sign In</a> <a class="text-sm" href="{{ path('app_login') }}">Sign In</a>

View File

@@ -3,6 +3,7 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
{{ pwa() }}
<title>{% block title %}Welcome!{% endblock %}</title> <title>{% block title %}Welcome!{% endblock %}</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 128 128%22><text y=%221.2em%22 font-size=%2296%22>⚫️</text><text y=%221.3em%22 x=%220.2em%22 font-size=%2276%22 fill=%22%23fff%22>sf</text></svg>"> <link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 128 128%22><text y=%221.2em%22 font-size=%2296%22>⚫️</text><text y=%221.3em%22 x=%220.2em%22 font-size=%2276%22 fill=%22%23fff%22>sf</text></svg>">
{% block stylesheets %} {% block stylesheets %}

View File

@@ -7,7 +7,7 @@
</a> </a>
{% if download.mediaType == "tvshows" and download.episodeId != null %} {% if download.mediaType == "tvshows" and download.episodeId != null %}
&mdash; <span class="ml-1">(S{{ download.episodeId }})</span> &mdash; <span class="ml-1">({{ download.episodeId }})</span>
{% endif %} {% endif %}
</td> </td>
@@ -44,7 +44,12 @@
{% set delete_button = component('ux:icon', {name: 'ic:twotone-cancel', height: '17.75px', width: '17.75px', class: 'rounded-full align-middle text-red-600 hover:text-red-700' }) %} {% set delete_button = component('ux:icon', {name: 'ic:twotone-cancel', height: '17.75px', width: '17.75px', class: 'rounded-full align-middle text-red-600 hover:text-red-700' }) %}
<twig:Modal heading="But wait!" button_text="{{ delete_button }}" submit_action="{{ stimulus_action('download_list', 'deleteDownload', 'click', {id: download.id}) }}" show_cancel show_submit> <twig:Modal heading="But wait!" button_text="{{ delete_button }}" submit_action="{{ stimulus_action('download_list', 'deleteDownload', 'click', {id: download.id}) }}" show_cancel show_submit>
Are you sure you want to delete <span class="font-bold">{{ download.filename }}</span>? <p class="mb-1">Are you sure you want to delete the following record?</p>
<p class="mb-1 ml-4 italic">{{ download.filename }}</p>
<div class="">
<input id="delete_file_{{ download.id }}" class="accent-orange-500" type="checkbox" value="false" name="delete_file" />
<label for="delete_file_{{ download.id }}">Delete the file as well?</label>
</div>
</twig:Modal> </twig:Modal>
</td> </td>
</tr> </tr>

View File

@@ -1,7 +1,7 @@
<header {{ attributes }} class="bg-cyan-950 z-40"> <header {{ attributes }} class="bg-cyan-950 z-40">
<div class="px-4 sm:px-6 lg:px-8"> <div class="px-4 sm:px-6 lg:px-8">
<div class="h-16 flex flex-row items-center justify-between"> <div class="h-16 flex flex-row items-center justify-between">
<a href="{{ path('app_index') }}" class="text-2xl text-orange-500 mr-4 md:hidden">T</a> <a href="{{ path('app_index') }}" class="text-2xl font-extrabold text-orange-500 mr-4 md:hidden">T</a>
<twig:SearchBar /> <twig:SearchBar />
<div class="md:flex md:items-center md:gap-12"> <div class="md:flex md:items-center md:gap-12">
<nav aria-label="Global" class="md:block"> <nav aria-label="Global" class="md:block">
@@ -32,6 +32,9 @@
{% for message in app.flashes('warning') %} {% for message in app.flashes('warning') %}
<twig:Alert :title="'Warning'" :message="message" :alert_id="''" type="warning" data-controller="alert" /> <twig:Alert :title="'Warning'" :message="message" :alert_id="''" type="warning" data-controller="alert" />
{% endfor %} {% endfor %}
{% for message in app.flashes('success') %}
<twig:Alert :title="'Success'" :message="message" :alert_id="''" type="warning" data-controller="alert" />
{% endfor %}
</ul> </ul>
</div> </div>
</div> </div>

View File

@@ -3,12 +3,12 @@
mediaType: mediaType, mediaType: mediaType,
imdbId: imdbId imdbId: imdbId
}) }}"> }) }}">
<img src="{{ image }}" class="w-full md:w-40 rounded-md" /> <img src="{{ preload(image) }}" class="w-full md:w-40 rounded-md" />
</a> </a>
<a href="{{ path('app_search_result', { <a href="{{ path('app_search_result', {
mediaType: mediaType, mediaType: mediaType,
imdbId: imdbId imdbId: imdbId
}) }}"> }) }}">
<h3 class="text-center text-white text-xl md:text-base md:max-w-[16ch]">{{ title }}</h3> <h3 class="text-center text-white md:text-md md:text-base md:max-w-[16ch]">{{ title }}</h3>
</a> </a>
</div> </div>

View File

@@ -40,30 +40,17 @@
{{ episode['air_date']|date(null, 'UTC') }} {{ episode['air_date']|date(null, 'UTC') }}
</small> </small>
{% if episode['file'] != false %} <twig:Turbo:Frame id="meb_{{ this.imdbId }}_{{ episode_id(episode['season_number'], episode['episode_number']) }}" src="{{ path('api.library.search', {
<span data-controller="popover"> title: this.title,
<template data-popover-target="content"> season: episode['season_number'],
<div data-popover-target="card" class="absolute z-40 p-1 bg-stone-400 p-1 text-black rounded-md m-1 animate-fade"> episode: episode['episode_number'],
<p class="font-bold text-sm text-left">Existing file(s) for this episode:</p> block: 'media_exists_badge',
<ul class="list-disc ml-3"> target: "meb_" ~ this.imdbId ~"_" ~ episode_id(episode['season_number'], episode['episode_number'])
<li class="font-normal">{{ episode['file'].realPath|strip_media_path }} &mdash; <strong>{{ episode['file'].size|filesize }}</strong></li> }) }}">
</ul>
</div>
</template>
<small
class="py-1 px-1.5 mr-1 grow-0 font-bold bg-blue-600 rounded-lg text-center text-white"
data-action="mouseenter->popover#show mouseleave->popover#hide"
>
exists
</small>
</span>
{% endif %}
{% if episode['file'] == false %}
<small class="py-1 px-1.5 mr-1 grow-0 font-bold bg-rose-600 rounded-lg text-white" title="Episode has not been downloaded yet."> <small class="py-1 px-1.5 mr-1 grow-0 font-bold bg-rose-600 rounded-lg text-white" title="Episode has not been downloaded yet.">
missing missing
</small> </small>
{% endif %} </twig:Turbo:Frame>
</div> </div>
</div> </div>
<div class="flex flex-col gap-4 justify-between"> <div class="flex flex-col gap-4 justify-between">
@@ -87,8 +74,15 @@
</button> </button>
</div> </div>
</div> </div>
<div {{ stimulus_target('tv-results', 'listContainer') }} class="inline-block overflow-hidden rounded-lg"> <div class="inline-block overflow-hidden rounded-lg">
<twig:Turbo:Frame id="results_{{ episode_id(episode['season_number'], episode['episode_number']) }}" src="{{ path('app_torrentio_tvshows', {
tmdbId: this.tmdbId,
imdbId: this.imdbId,
season: episode['season_number'],
episode: episode['episode_number'],
target: 'results_' ~ episode_id(episode['season_number'], episode['episode_number']),
block: 'tvshow_results'
}) }}" />
</div> </div>
</div> </div>
</div> </div>

View File

@@ -20,7 +20,7 @@
</twig:Card> </twig:Card>
</div> </div>
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
<twig:Card title="Popular Movies" contentClass="flex flex-col gap-4 md:flex-row md:justify-between w-full"> <twig:Card title="Popular Movies" contentClass="grid grid-cols-2 gap-4 md:flex md:flex-row md:justify-between w-full">
{% for movie in popular_movies %} {% for movie in popular_movies %}
<twig:Poster imdbId="{{ movie.imdbId }}" <twig:Poster imdbId="{{ movie.imdbId }}"
tmdbId="{{ movie.tmdbId }}" tmdbId="{{ movie.tmdbId }}"
@@ -32,7 +32,7 @@
/> />
{% endfor %} {% endfor %}
</twig:Card> </twig:Card>
<twig:Card title="Popular TV Shows" contentClass="flex flex-col md:flex-row justify-between w-full"> <twig:Card title="Popular TV Shows" contentClass="grid grid-cols-2 gap-4 md:flex flex-col md:flex-row justify-between w-full">
{% for movie in popular_tvshows %} {% for movie in popular_tvshows %}
<twig:Poster imdbId="{{ movie.imdbId }}" <twig:Poster imdbId="{{ movie.imdbId }}"
tmdbId="{{ movie.tmdbId }}" tmdbId="{{ movie.tmdbId }}"

View File

@@ -0,0 +1,32 @@
{% block media_exists_badge %}
<turbo-stream action="replace" targets="#{{ target }}">
<template>
{% if result.exists == true %}
<span data-controller="popover">
<template data-popover-target="content">
<div data-popover-target="card"
class="absolute z-40 p-1 bg-stone-400 p-1 text-black rounded-md m-1 animate-fade">
<p class="font-bold text-sm text-left">Existing file(s) for this media:</p>
<ul class="list-disc ml-3">
<li class="font-normal">{{ result.file.filename|strip_media_path }} &mdash; <strong>{{ result.file.size|filesize }}</strong></li>
</ul>
</div>
</template>
<small
class="py-1 px-1.5 mr-1 grow-0 font-bold bg-blue-600 rounded-lg text-center text-white"
data-action="mouseenter->popover#show mouseleave->popover#hide"
>
exists
</small>
</span>
{% endif %}
{% if result.exists == false %}
<small class="py-1 px-1.5 mr-1 grow-0 font-bold bg-rose-600 rounded-lg text-white"
title="Media has not been downloaded yet.">
missing
</small>
{% endif %}
</template>
</turbo-stream>
{% endblock %}

View File

@@ -49,9 +49,32 @@
</div> </div>
<p class="text-gray-50"> <p class="text-gray-50">
{{ results.media.description }} {{ results.media.description }}
</p> </p>
{% if "movies" == results.media.mediaType %}
<div class="flex flex-row justify-start items-end grow">
<span class="py-1 px-1.5 mr-1 grow-0 font-bold text-xs bg-green-600 rounded-lg hover:cursor-pointer hover:bg-green-700 text-white">
<span id="movie_results_count">-</span> results
</span>
<small class="py-1 px-1.5 mr-1 grow-0 font-bold bg-gray-700 rounded-lg font-normal text-white" title="Release date {{ results.media.episodeAirDate }}">
{{ results.media.episodeAirDate|date(null, 'UTC') }}
</small>
<twig:Turbo:Frame id="meb_{{ results.media.imdbId }}" src="{{ path('api.library.search', {
title: results.media.title,
block: 'media_exists_badge',
target: "meb_" ~ results.media.imdbId
}) }}">
<small class="py-1 px-1.5 mr-1 grow-0 font-bold bg-rose-600 rounded-lg text-white" title="Movie has not been downloaded yet.">
missing
</small>
</twig:Turbo:Frame>
</div>
{% endif %}
</div> </div>
</div> </div>
@@ -62,6 +85,12 @@
{{ stimulus_controller('movie_results', {title: results.media.title, tmdbId: results.media.tmdbId, imdbId: results.media.imdbId}) }} {{ stimulus_controller('movie_results', {title: results.media.title, tmdbId: results.media.tmdbId, imdbId: results.media.imdbId}) }}
data-movie-results-loading-icon-outlet=".loading-icon" data-movie-results-loading-icon-outlet=".loading-icon"
> >
<twig:Turbo:Frame id="movie_results_frame" src="{{ path('app_torrentio_movies', {
tmdbId: results.media.tmdbId,
imdbId: results.media.imdbId,
target: 'movie_results_frame',
block: 'movie_results'
}) }}" />
</div> </div>
{% elseif "tvshows" == results.media.mediaType %} {% elseif "tvshows" == results.media.mediaType %}
<twig:TvEpisodeList <twig:TvEpisodeList

View File

@@ -0,0 +1,21 @@
{% block movie_results %}
<turbo-stream action="replace" targets="#{{ target }}">
<template>
<div class="p-4 flex flex-col gap-6 bg-orange-500 bg-clip-padding backdrop-filter backdrop-blur-md bg-opacity-60 rounded-md">
<div class="overflow-hidden rounded-md">
{{ include('torrentio/partial/option-table.html.twig', {controller: 'movie-results'}) }}
</div>
</div>
</template>
</turbo-stream>
{% endblock %}
{% block tvshow_results %}
<turbo-stream action="replace" targets="#{{ target }}">
<template>
<div id="{{ target }}">
{{ include('torrentio/partial/option-table.html.twig', {controller: 'tv-results'}) }}
</div>
</template>
</turbo-stream>
{% endblock %}

View File

@@ -1,20 +0,0 @@
{% extends 'base.html.twig' %}
{% block title %}Hello TorrentioController!{% endblock %}
{% block body %}
<style>
.example-wrapper { margin: 1em auto; max-width: 800px; width: 95%; font: 18px/1.5 sans-serif; }
.example-wrapper code { background: #F5F5F5; padding: 2px 6px; }
</style>
<div class="example-wrapper">
<h1>Hello {{ controller_name }}! ✅</h1>
This friendly message is coming from:
<ul>
<li>Your controller at <code>/var/www/src/Controller/TorrentioController.php</code></li>
<li>Your template at <code>/var/www/templates/torrentio/index.html.twig</code></li>
</ul>
</div>
{% endblock %}

View File

@@ -1,13 +1,4 @@
<div class="p-4 flex flex-col gap-6 bg-orange-500 bg-clip-padding backdrop-filter backdrop-blur-md bg-opacity-60 rounded-md"> <div class="p-4 flex flex-col gap-6 bg-orange-500 bg-clip-padding backdrop-filter backdrop-blur-md bg-opacity-60 rounded-md">
{% if results.file != false %}
<div class="p-3 bg-stone-400 p-1 text-black rounded-md m-1 animate-fade">
<p class="font-bold text-sm text-left">Existing file(s) for this movie:</p>
<ul class="list-disc ml-3 overflow-scroll">
<li class="font-normal">{{ results.file.realPath|strip_media_path }} &mdash; <strong>{{ results.file.size|filesize }}</strong></li>
</ul>
</div>
{% endif %}
<div class="overflow-hidden rounded-md"> <div class="overflow-hidden rounded-md">
{{ include('torrentio/partial/option-table.html.twig', {controller: 'movie-results'}) }} {{ include('torrentio/partial/option-table.html.twig', {controller: 'movie-results'}) }}
</div> </div>

View File

@@ -0,0 +1,20 @@
{% extends 'bare.html.twig' %}
{% block title %}Getting Started &mdash; Torsearch{% endblock %}
{% block body %}
<div class="flex flex-col bg-orange-500/50 p-4 rounded-lg gap-4 w-full md:w-[420px] border-orange-500 border-2 text-gray-50 animate-fade">
<h2 class="text-2xl text-bold text-center text-gray-50">Getting Started</h2>
<p class="mb-1">Now let's create your first Filter.</p>
{# <p class="mb-2 text-sm">Your filter will be pre-applied to your results, so you're only shown what you want to see. Don't worry, though, you can toggle each filter option afterwards, so you can see the rest of the results.</p>#}
{{ form_start(form) }}
{{ form_row(form.language) }}
{{ form_row(form.quality) }}
{{ form_row(form.provider) }}
{{ form_row(form.resolution) }}
{{ form_row(form.codec) }}
<button class="submit-button">Save</button>
{{ form_end(form) }}
</div>
{% endblock %}

View File

@@ -3,7 +3,7 @@
{% block title %}Getting Started &mdash; Torsearch{% endblock %} {% block title %}Getting Started &mdash; Torsearch{% endblock %}
{% block body %} {% block body %}
<div class="flex flex-col bg-orange-500/50 p-4 rounded-lg gap-4 min-w-96 border-orange-500 border-2 text-gray-50"> <div class="flex flex-col bg-orange-500/50 p-4 rounded-lg gap-4 w-full md:w-[420px] border-orange-500 border-2 text-gray-50 animate-fade">
<h2 class="text-2xl text-bold text-center text-gray-50">Getting Started</h2> <h2 class="text-2xl text-bold text-center text-gray-50">Getting Started</h2>
<p class="mb-2">Let's get started by creating your first User.</p> <p class="mb-2">Let's get started by creating your first User.</p>

View File

@@ -3,7 +3,7 @@
{% block title %}Log in &mdash; Torsearch{% endblock %} {% block title %}Log in &mdash; Torsearch{% endblock %}
{% block body %} {% block body %}
<div class="flex flex-col bg-orange-500/50 p-4 rounded-lg gap-4 min-w-96 border-orange-500 border-2 text-gray-50"> <div class="flex flex-col bg-orange-500/50 p-4 rounded-lg gap-4 w-full md:w-[420px] border-orange-500 border-2 text-gray-50 animate-fade">
<h2 class="text-xl font-bold">Login</h2> <h2 class="text-xl font-bold">Login</h2>
<form method="post" class="flex flex-col gap-2"> <form method="post" class="flex flex-col gap-2">
{% if error %} {% if error %}
@@ -52,10 +52,16 @@
<button type="submit" class="bg-green-600/40 px-1.5 py-1 w-full rounded-md text-gray-50 backdrop-filter backdrop-blur-sm border-2 border-green-500 hover:bg-green-700/40"> <button type="submit" class="bg-green-600/40 px-1.5 py-1 w-full rounded-md text-gray-50 backdrop-filter backdrop-blur-sm border-2 border-green-500 hover:bg-green-700/40">
Sign in Sign in
</button> </button>
<div class="flex">
<a href="{{ path('app_forgot_password_request') }}">Forgot password?</a>
</div>
</form> </form>
{% if show_oidc_button == "oidc" %}
<a href="{{ path('app_login_oidc') }}" class="bg-sky-950/60 px-1.5 py-1 w-full rounded-md text-gray-50 text-center backdrop-filter backdrop-blur-sm border-2 border-gray-950 hover:bg-orange-700/40">
Sign in with OIDC
</a>
{% endif %}
<div class="flex">
<a href="{{ path('app_forgot_password_request') }}">Forgot password?</a>
</div>
</div> </div>
{% endblock %} {% endblock %}

View File

@@ -1,9 +1,9 @@
{% extends 'bare.html.twig' %} {% extends 'bare.html.twig' %}
{% block title %}Password Reset Email Sent{% endblock %} {% block title %}Password Reset Email Sent &mdash; Torsearch{% endblock %}
{% block body %} {% block body %}
<div class="flex flex-col bg-orange-500/50 p-4 rounded-lg gap-4 max-w-[420px] border-orange-500 border-2 text-gray-50"> <div class="flex flex-col bg-orange-500/50 p-4 rounded-lg gap-4 w-full md:w-[420px] border-orange-500 border-2 text-gray-50">
<h2 class="text-xl font-bold">Head over to your email</h2> <h2 class="text-xl font-bold">Head over to your email</h2>
<div class="mb-3 flex flex-col gap-4"> <div class="mb-3 flex flex-col gap-4">

View File

@@ -3,7 +3,7 @@
{% block title %}Reset your password &mdash; Torsearch{% endblock %} {% block title %}Reset your password &mdash; Torsearch{% endblock %}
{% block body %} {% block body %}
<div class="flex flex-col bg-orange-500/50 p-4 rounded-lg gap-4 max-w-[420px] border-orange-500 border-2 text-gray-50"> <div class="flex flex-col bg-orange-500/50 p-4 rounded-lg gap-4 w-full md:w-[420px] border-orange-500 border-2 text-gray-50">
<h2 class="text-xl font-bold">Reset your password</h2> <h2 class="text-xl font-bold">Reset your password</h2>
<div class="mb-3"> <div class="mb-3">
@@ -12,7 +12,7 @@
<form name="reset_password_request_form" method="post" class="flex flex-col gap-2"> <form name="reset_password_request_form" method="post" class="flex flex-col gap-2">
{% for flash_error in app.flashes('reset_password_error') %} {% for flash_error in app.flashes('reset_password_error') %}
<div class="mb-3 p-2 bg-rose-500 text-black text-semibold rounded-md" role="alert">{{ flash_error }}</div> <div class="mb-3 p-2 bg-rose-500 text-black font-semibold rounded-md" role="alert">{{ flash_error }}</div>
{% endfor %} {% endfor %}
<label for="reset_password_request_form_email" class="required flex flex-col mb-2"> <label for="reset_password_request_form_email" class="required flex flex-col mb-2">

View File

@@ -3,7 +3,7 @@
{% block title %}Reset your password &mdash; Torsearch{% endblock %} {% block title %}Reset your password &mdash; Torsearch{% endblock %}
{% block body %} {% block body %}
<div class="flex flex-col bg-orange-500/50 p-4 rounded-lg gap-4 min-w-96 border-orange-500 border-2 text-gray-50"> <div class="flex flex-col bg-orange-500/50 p-4 rounded-lg gap-4 w-full md:w-[420px] border-orange-500 border-2 text-gray-50">
<h2 class="text-xl font-bold text-white">Reset your password</h2> <h2 class="text-xl font-bold text-white">Reset your password</h2>
<div class="mb-2"> <div class="mb-2">