Compare commits
80 Commits
pregitclea
...
feature/hi
Author | SHA1 | Date | |
---|---|---|---|
33bb4e77e5 | |||
978c075a3e | |||
a741ee102d | |||
5fcc32de6d | |||
797d7a2e8f | |||
39f6aaea23 | |||
116fed5acc | |||
6d20661305 | |||
f6771782e4 | |||
73a3b93760 | |||
5ae5db985b | |||
62136a0ca0 | |||
04b2037f2d | |||
e0ca39933c | |||
e3549f722a | |||
6adc8c4a69 | |||
d64cadb47b | |||
3a16e4609f | |||
951b227efc | |||
42bcd39453 | |||
e8c545af4c | |||
0648db62e3 | |||
b8ae8bb8a7 | |||
e2bd1a7c3b | |||
47ea226ed7 | |||
50a7ab7985 | |||
a7c94a8f21 | |||
cda03f7b67 | |||
771f354346 | |||
ab97482ae6 | |||
06def04cae | |||
f52058c250 | |||
59e068fbf7 | |||
d62d34fb63 | |||
9b699ea4c7 | |||
aeb2d3ee4b | |||
6651aefc6b | |||
683cd5074c | |||
aa05a1c98e | |||
38a53c6bd3 | |||
e2c8376482 | |||
74154e240f | |||
f56c78f626 | |||
ba00351201 | |||
8553a06cd0 | |||
7c4a2b46c0 | |||
5a940b9ebd | |||
ced70fd650 | |||
d7e558cae9 | |||
c1b896a63a | |||
3c4c470547 | |||
d906b980c4 | |||
bf55e069e0 | |||
af9f83f189 | |||
0ef77b2aa7 | |||
3a7870a68b | |||
0f6cc78e5e | |||
cc3e050304 | |||
28a2706525 | |||
31cfeca93a | |||
ca7a093e55 | |||
4e16444d2f | |||
701bf6c1ed | |||
943177bc08 | |||
5cec259469 | |||
22c8126cea | |||
5937a9d4f2 | |||
2db9c5f1d9 | |||
a18eda6748 | |||
c250c57a22 | |||
473a5294ea | |||
b2bc519e26 | |||
6f64f29177 | |||
a5619e2307 | |||
be5e457d1b | |||
3225ec5fe0 | |||
caa6d4da17 | |||
9e3590a39d | |||
5624fc3a74 | |||
64bd7e3642 |
2
.env
2
.env
@ -16,7 +16,7 @@
|
|||||||
|
|
||||||
###> symfony/framework-bundle ###
|
###> symfony/framework-bundle ###
|
||||||
APP_ENV=dev
|
APP_ENV=dev
|
||||||
APP_SECRET=a617c2ab616c5688ff5b0e95ad646641
|
APP_SECRET=
|
||||||
###< symfony/framework-bundle ###
|
###< symfony/framework-bundle ###
|
||||||
|
|
||||||
###> doctrine/doctrine-bundle ###
|
###> doctrine/doctrine-bundle ###
|
||||||
|
4
.env.dev
Normal file
4
.env.dev
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
|
||||||
|
###> symfony/framework-bundle ###
|
||||||
|
APP_SECRET=452d8323de922537717fb88b5fa6f80e
|
||||||
|
###< symfony/framework-bundle ###
|
2
.gitignore
vendored
2
.gitignore
vendored
@ -8,4 +8,6 @@
|
|||||||
/var/
|
/var/
|
||||||
/vendor/
|
/vendor/
|
||||||
###< symfony/framework-bundle ###
|
###< symfony/framework-bundle ###
|
||||||
|
|
||||||
release.json
|
release.json
|
||||||
|
http-client.private.env.json
|
19
.http
Normal file
19
.http
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
### api me
|
||||||
|
GET {{host}}/api/me
|
||||||
|
Accept: application/json
|
||||||
|
X-AUTH-TOKEN: {{apiKey}}
|
||||||
|
|
||||||
|
### api snip get
|
||||||
|
GET {{host}}/api/snip/22
|
||||||
|
Accept: application/json
|
||||||
|
X-AUTH-TOKEN: {{apiKey}}
|
||||||
|
|
||||||
|
### api snip post edit
|
||||||
|
POST {{host}}/api/snip/22
|
||||||
|
Content-Type: application/json
|
||||||
|
X-AUTH-TOKEN: {{apiKey}}
|
||||||
|
|
||||||
|
{
|
||||||
|
"name": "new snip name",
|
||||||
|
"content": "new snip content"
|
||||||
|
}
|
@ -4,6 +4,10 @@
|
|||||||
use App\Kernel;
|
use App\Kernel;
|
||||||
use Symfony\Bundle\FrameworkBundle\Console\Application;
|
use Symfony\Bundle\FrameworkBundle\Console\Application;
|
||||||
|
|
||||||
|
if (!is_dir(dirname(__DIR__).'/vendor')) {
|
||||||
|
throw new LogicException('Dependencies are missing. Try running "composer install".');
|
||||||
|
}
|
||||||
|
|
||||||
if (!is_file(dirname(__DIR__).'/vendor/autoload_runtime.php')) {
|
if (!is_file(dirname(__DIR__).'/vendor/autoload_runtime.php')) {
|
||||||
throw new LogicException('Symfony Runtime is missing. Try running "composer require symfony/runtime".');
|
throw new LogicException('Symfony Runtime is missing. Try running "composer require symfony/runtime".');
|
||||||
}
|
}
|
||||||
|
@ -4,35 +4,42 @@
|
|||||||
"minimum-stability": "stable",
|
"minimum-stability": "stable",
|
||||||
"prefer-stable": true,
|
"prefer-stable": true,
|
||||||
"require": {
|
"require": {
|
||||||
"php": ">=8.3",
|
"php": ">=8.4",
|
||||||
"ext-ctype": "*",
|
"ext-ctype": "*",
|
||||||
"ext-iconv": "*",
|
"ext-iconv": "*",
|
||||||
"czproject/git-php": "^4.1",
|
|
||||||
"doctrine/doctrine-bundle": "^2.9",
|
"doctrine/doctrine-bundle": "^2.9",
|
||||||
"doctrine/doctrine-migrations-bundle": "^3.2",
|
"doctrine/doctrine-migrations-bundle": "^3.2",
|
||||||
"doctrine/orm": "^2.14",
|
"doctrine/orm": "^2.14",
|
||||||
|
"league/commonmark": "^2.6",
|
||||||
"league/pipeline": "^1.0",
|
"league/pipeline": "^1.0",
|
||||||
"symfony/console": "7.0.*",
|
"phpdocumentor/reflection-docblock": "^5.6",
|
||||||
"symfony/dotenv": "7.0.*",
|
"phpstan/phpdoc-parser": "^2.1",
|
||||||
|
"symfony/asset": "7.2.*",
|
||||||
|
"symfony/console": "*",
|
||||||
|
"symfony/dotenv": "*",
|
||||||
"symfony/flex": "^2",
|
"symfony/flex": "^2",
|
||||||
"symfony/form": "7.0.*",
|
"symfony/form": "*",
|
||||||
"symfony/framework-bundle": "7.0.*",
|
"symfony/framework-bundle": "*",
|
||||||
"symfony/monolog-bundle": "^3.0",
|
"symfony/monolog-bundle": "^3.0",
|
||||||
"symfony/runtime": "7.0.*",
|
"symfony/property-access": "*",
|
||||||
"symfony/security-bundle": "7.0.*",
|
"symfony/property-info": "*",
|
||||||
"symfony/twig-bundle": "7.0.*",
|
"symfony/runtime": "*",
|
||||||
"symfony/uid": "7.0.*",
|
"symfony/security-bundle": "*",
|
||||||
"symfony/validator": "7.0.*",
|
"symfony/serializer": "*",
|
||||||
"symfony/yaml": "7.0.*",
|
"symfony/twig-bundle": "*",
|
||||||
|
"symfony/uid": "*",
|
||||||
|
"symfony/validator": "*",
|
||||||
|
"symfony/yaml": "*",
|
||||||
|
"tempest/highlight": "^2.11",
|
||||||
"twig/extra-bundle": "^2.12|^3.0",
|
"twig/extra-bundle": "^2.12|^3.0",
|
||||||
"twig/twig": "^2.12|^3.0"
|
"twig/twig": "^3.0"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"deployer/deployer": "^7.3",
|
"deployer/deployer": "^7.3",
|
||||||
"symfony/debug-bundle": "7.0.*",
|
"symfony/debug-bundle": "*",
|
||||||
"symfony/maker-bundle": "^1.48",
|
"symfony/maker-bundle": "^1.48",
|
||||||
"symfony/stopwatch": "7.0.*",
|
"symfony/stopwatch": "*",
|
||||||
"symfony/web-profiler-bundle": "7.0.*"
|
"symfony/web-profiler-bundle": "*"
|
||||||
},
|
},
|
||||||
"config": {
|
"config": {
|
||||||
"allow-plugins": {
|
"allow-plugins": {
|
||||||
@ -47,11 +54,6 @@
|
|||||||
"App\\": "src/"
|
"App\\": "src/"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"autoload-dev": {
|
|
||||||
"psr-4": {
|
|
||||||
"App\\Tests\\": "tests/"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"replace": {
|
"replace": {
|
||||||
"symfony/polyfill-ctype": "*",
|
"symfony/polyfill-ctype": "*",
|
||||||
"symfony/polyfill-iconv": "*",
|
"symfony/polyfill-iconv": "*",
|
||||||
@ -59,7 +61,10 @@
|
|||||||
"symfony/polyfill-php73": "*",
|
"symfony/polyfill-php73": "*",
|
||||||
"symfony/polyfill-php74": "*",
|
"symfony/polyfill-php74": "*",
|
||||||
"symfony/polyfill-php80": "*",
|
"symfony/polyfill-php80": "*",
|
||||||
"symfony/polyfill-php81": "*"
|
"symfony/polyfill-php81": "*",
|
||||||
|
"symfony/polyfill-php82": "*",
|
||||||
|
"symfony/polyfill-php83": "*",
|
||||||
|
"symfony/polyfill-php84": "*"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"auto-scripts": {
|
"auto-scripts": {
|
||||||
@ -79,7 +84,7 @@
|
|||||||
"extra": {
|
"extra": {
|
||||||
"symfony": {
|
"symfony": {
|
||||||
"allow-contrib": false,
|
"allow-contrib": false,
|
||||||
"require": "7.0.*"
|
"require": "7.2.*"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
2471
composer.lock
generated
2471
composer.lock
generated
File diff suppressed because it is too large
Load Diff
11
config/packages/csrf.yaml
Normal file
11
config/packages/csrf.yaml
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
# Enable stateless CSRF protection for forms and logins/logouts
|
||||||
|
framework:
|
||||||
|
form:
|
||||||
|
csrf_protection:
|
||||||
|
token_id: submit
|
||||||
|
|
||||||
|
csrf_protection:
|
||||||
|
stateless_token_ids:
|
||||||
|
- submit
|
||||||
|
- authenticate
|
||||||
|
- logout
|
@ -4,18 +4,28 @@ doctrine:
|
|||||||
|
|
||||||
# IMPORTANT: You MUST configure your server version,
|
# IMPORTANT: You MUST configure your server version,
|
||||||
# either here or in the DATABASE_URL env var (see .env file)
|
# either here or in the DATABASE_URL env var (see .env file)
|
||||||
#server_version: '15'
|
#server_version: '16'
|
||||||
|
|
||||||
|
profiling_collect_backtrace: '%kernel.debug%'
|
||||||
|
use_savepoints: true
|
||||||
orm:
|
orm:
|
||||||
|
report_fields_where_declared: true
|
||||||
auto_generate_proxy_classes: true
|
auto_generate_proxy_classes: true
|
||||||
enable_lazy_ghost_objects: true
|
enable_lazy_ghost_objects: true
|
||||||
|
validate_xml_mapping: true
|
||||||
naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
|
naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
|
||||||
|
identity_generation_preferences:
|
||||||
|
Doctrine\DBAL\Platforms\PostgreSQLPlatform: identity
|
||||||
auto_mapping: true
|
auto_mapping: true
|
||||||
mappings:
|
mappings:
|
||||||
App:
|
App:
|
||||||
|
type: attribute
|
||||||
is_bundle: false
|
is_bundle: false
|
||||||
dir: '%kernel.project_dir%/src/Entity'
|
dir: '%kernel.project_dir%/src/Entity'
|
||||||
prefix: 'App\Entity'
|
prefix: 'App\Entity'
|
||||||
alias: App
|
alias: App
|
||||||
|
controller_resolver:
|
||||||
|
auto_mapping: false
|
||||||
|
|
||||||
when@test:
|
when@test:
|
||||||
doctrine:
|
doctrine:
|
||||||
|
@ -1,22 +1,12 @@
|
|||||||
# see https://symfony.com/doc/current/reference/configuration/framework.html
|
# see https://symfony.com/doc/current/reference/configuration/framework.html
|
||||||
framework:
|
framework:
|
||||||
secret: '%env(APP_SECRET)%'
|
secret: '%env(APP_SECRET)%'
|
||||||
#csrf_protection: true
|
|
||||||
http_method_override: false
|
|
||||||
handle_all_throwables: true
|
|
||||||
|
|
||||||
# Enables session support. Note that the session will ONLY be started if you read or write from it.
|
# Note that the session will be started ONLY if you read or write from it.
|
||||||
# Remove or comment this section to explicitly disable session support.
|
session: true
|
||||||
session:
|
|
||||||
handler_id: null
|
|
||||||
cookie_secure: auto
|
|
||||||
cookie_samesite: lax
|
|
||||||
storage_factory_id: session.storage.factory.native
|
|
||||||
|
|
||||||
#esi: true
|
#esi: true
|
||||||
#fragments: true
|
#fragments: true
|
||||||
php_errors:
|
|
||||||
log: true
|
|
||||||
|
|
||||||
when@test:
|
when@test:
|
||||||
framework:
|
framework:
|
||||||
|
@ -59,3 +59,4 @@ when@prod:
|
|||||||
type: stream
|
type: stream
|
||||||
channels: [deprecation]
|
channels: [deprecation]
|
||||||
path: php://stderr
|
path: php://stderr
|
||||||
|
formatter: monolog.formatter.json
|
||||||
|
@ -1,7 +1,5 @@
|
|||||||
framework:
|
framework:
|
||||||
router:
|
router:
|
||||||
utf8: true
|
|
||||||
|
|
||||||
# Configure how to generate URLs in non-HTTP contexts, such as CLI commands.
|
# Configure how to generate URLs in non-HTTP contexts, such as CLI commands.
|
||||||
# See https://symfony.com/doc/current/routing.html#generating-urls-in-commands
|
# See https://symfony.com/doc/current/routing.html#generating-urls-in-commands
|
||||||
#default_uri: http://localhost
|
#default_uri: http://localhost
|
||||||
|
@ -24,6 +24,8 @@ security:
|
|||||||
remember_me:
|
remember_me:
|
||||||
secret: '%kernel.secret%' # required
|
secret: '%kernel.secret%' # required
|
||||||
lifetime: 2419200 # 4 weeks in seconds
|
lifetime: 2419200 # 4 weeks in seconds
|
||||||
|
custom_authenticators:
|
||||||
|
- App\Security\TokenAuthenticator
|
||||||
|
|
||||||
secured_area:
|
secured_area:
|
||||||
form_login:
|
form_login:
|
||||||
@ -39,11 +41,14 @@ security:
|
|||||||
# Note: Only the *first* access control that matches will be used
|
# Note: Only the *first* access control that matches will be used
|
||||||
access_control:
|
access_control:
|
||||||
- { path: ^/login$, role: PUBLIC_ACCESS }
|
- { path: ^/login$, role: PUBLIC_ACCESS }
|
||||||
- { path: ^/register, role: PUBLIC_ACCESS }
|
- { path: ^/register$, role: PUBLIC_ACCESS }
|
||||||
- { path: ^/logout$, role: ROLE_USER }
|
- { path: ^/logout$, role: ROLE_USER }
|
||||||
- { path: ^/admin, role: ROLE_ADMIN }
|
- { path: ^/admin, role: ROLE_ADMIN }
|
||||||
|
|
||||||
|
- { path: ^/$, role: PUBLIC_ACCESS }
|
||||||
|
- { path: ^/snip/single, role: PUBLIC_ACCESS }
|
||||||
- { path: ^/snip/raw, role: PUBLIC_ACCESS }
|
- { path: ^/snip/raw, role: PUBLIC_ACCESS }
|
||||||
|
- { path: ^/snip/public$, role: PUBLIC_ACCESS }
|
||||||
|
|
||||||
- { path: ^/, role: ROLE_USER }
|
- { path: ^/, role: ROLE_USER }
|
||||||
|
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
twig:
|
twig:
|
||||||
default_path: '%kernel.project_dir%/templates'
|
|
||||||
form_themes: ['bootstrap_5_layout.html.twig']
|
form_themes: ['bootstrap_5_layout.html.twig']
|
||||||
|
file_name_pattern: '*.twig'
|
||||||
|
|
||||||
when@test:
|
when@test:
|
||||||
twig:
|
twig:
|
||||||
|
@ -1,4 +0,0 @@
|
|||||||
framework:
|
|
||||||
uid:
|
|
||||||
default_uuid_version: 7
|
|
||||||
time_based_uuid_version: 7
|
|
@ -1,7 +1,5 @@
|
|||||||
framework:
|
framework:
|
||||||
validation:
|
validation:
|
||||||
email_validation_mode: html5
|
|
||||||
|
|
||||||
# Enables validator auto-mapping support.
|
# Enables validator auto-mapping support.
|
||||||
# For instance, basic validation constraints will be inferred from Doctrine's metadata.
|
# For instance, basic validation constraints will be inferred from Doctrine's metadata.
|
||||||
#auto_mapping:
|
#auto_mapping:
|
||||||
|
@ -1,17 +1,11 @@
|
|||||||
when@dev:
|
when@dev:
|
||||||
web_profiler:
|
web_profiler:
|
||||||
toolbar: true
|
toolbar: true
|
||||||
intercept_redirects: false
|
|
||||||
|
|
||||||
framework:
|
framework:
|
||||||
profiler:
|
profiler:
|
||||||
only_exceptions: false
|
|
||||||
collect_serializer_data: true
|
collect_serializer_data: true
|
||||||
|
|
||||||
when@test:
|
when@test:
|
||||||
web_profiler:
|
|
||||||
toolbar: false
|
|
||||||
intercept_redirects: false
|
|
||||||
|
|
||||||
framework:
|
framework:
|
||||||
profiler: { collect: false }
|
profiler: { collect: false }
|
||||||
|
@ -3,3 +3,12 @@ controllers:
|
|||||||
path: ../src/Controller/
|
path: ../src/Controller/
|
||||||
namespace: App\Controller
|
namespace: App\Controller
|
||||||
type: attribute
|
type: attribute
|
||||||
|
exclude: ../src/Controller/Api/
|
||||||
|
|
||||||
|
rest_controllers:
|
||||||
|
resource:
|
||||||
|
path: ../src/Controller/Api/
|
||||||
|
namespace: App\Controller\Api
|
||||||
|
type: attribute
|
||||||
|
prefix: /api
|
||||||
|
defaults: { _format: "json" }
|
||||||
|
3
config/routes/security.yaml
Normal file
3
config/routes/security.yaml
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
_security_logout:
|
||||||
|
resource: security.route_loader.logout
|
||||||
|
type: service
|
@ -4,8 +4,6 @@
|
|||||||
# Put parameters here that don't need to change on each machine where the app is deployed
|
# Put parameters here that don't need to change on each machine where the app is deployed
|
||||||
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
|
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
|
||||||
parameters:
|
parameters:
|
||||||
snipStorageType: 'db' # 'db' or 'git
|
|
||||||
gitStoragePath: '%kernel.project_dir%/var/snips'
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
# default configuration for services in *this* file
|
# default configuration for services in *this* file
|
||||||
@ -20,13 +18,4 @@ services:
|
|||||||
exclude:
|
exclude:
|
||||||
- '../src/DependencyInjection/'
|
- '../src/DependencyInjection/'
|
||||||
- '../src/Entity/'
|
- '../src/Entity/'
|
||||||
- '../src/Kernel.php'
|
- '../src/Kernel.php'
|
||||||
|
|
||||||
App\Service\LastRelease:
|
|
||||||
arguments:
|
|
||||||
- '%kernel.project_dir%/release.json'
|
|
||||||
|
|
||||||
App\Service\SnipServiceFactory:
|
|
||||||
arguments:
|
|
||||||
$gitStoragePath: '%gitStoragePath%'
|
|
||||||
$storageType: '%snipStorageType%'
|
|
103
deploy.php
103
deploy.php
@ -2,107 +2,16 @@
|
|||||||
|
|
||||||
namespace Deployer;
|
namespace Deployer;
|
||||||
|
|
||||||
require_once 'recipe/common.php';
|
require_once 'deploy/symfony.php';
|
||||||
|
|
||||||
|
|
||||||
// Project name
|
|
||||||
set('application', 'snips');
|
set('application', 'snips');
|
||||||
|
|
||||||
// Project repository
|
|
||||||
set('repository', 'git@git.loken.nl:ardent/Snips.git');
|
set('repository', 'git@git.loken.nl:ardent/Snips.git');
|
||||||
|
|
||||||
// [Optional] Allocate tty for git clone. Default value is false.
|
|
||||||
set('git_tty', true);
|
|
||||||
|
|
||||||
// Shared files/dirs between deploys
|
|
||||||
set('shared_dirs', ['var/log', 'var/sessions', 'var/snips']);
|
|
||||||
set('shared_files', ['.env.local']);
|
|
||||||
//set('writable_dirs', ['var']);
|
|
||||||
set('migrations_config', '');
|
|
||||||
set('allow_anonymous_stats', false);
|
|
||||||
|
|
||||||
// Hosts
|
|
||||||
host('snips.loken.nl')
|
host('snips.loken.nl')
|
||||||
->setRemoteUser('www-data')
|
->setRemoteUser('tim')
|
||||||
->set('branch', function () {
|
->set('branch', function () {
|
||||||
return input()->getOption('branch') ?: 'main';
|
return input()->getOption('branch') ?: 'master';
|
||||||
})
|
})
|
||||||
->set('deploy_path', '~/snips.loken.nl');
|
->set('deploy_path', '/var/www/snips.loken.nl')
|
||||||
|
;
|
||||||
set('bin/console', function () {
|
|
||||||
return parse('{{release_path}}/bin/console');
|
|
||||||
});
|
|
||||||
|
|
||||||
set('console_options', function () {
|
|
||||||
return '--no-interaction';
|
|
||||||
});
|
|
||||||
|
|
||||||
desc('Clear cache');
|
|
||||||
task('cache:clear', function () {
|
|
||||||
run('{{bin/php}} {{bin/console}} cache:clear {{console_options}} --no-warmup');
|
|
||||||
});
|
|
||||||
|
|
||||||
desc('Warm up cache');
|
|
||||||
task('cache:warmup', function () {
|
|
||||||
run('{{bin/php}} {{bin/console}} cache:warmup {{console_options}}');
|
|
||||||
});
|
|
||||||
|
|
||||||
desc('Migrate database');
|
|
||||||
task('database:migrate', function () {
|
|
||||||
// $options = '--allow-no-migration';
|
|
||||||
// if (get('migrations_config') !== '') {
|
|
||||||
// $options = sprintf('%s --configuration={{release_path}}/{{migrations_config}}', $options);
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// run(sprintf('{{bin/php}} {{bin/console}} doctrine:migrations:migrate %s {{console_options}}', $options));
|
|
||||||
run('{{bin/php}} {{bin/console}} doctrine:schema:update --force');
|
|
||||||
});
|
|
||||||
|
|
||||||
task('deployment:log', function () { //https://stackoverflow.com/questions/59686270/how-to-log-deployments-in-deployer
|
|
||||||
$branch = parse('{{branch}}');
|
|
||||||
$date = date('Y-m-d H:i:s');
|
|
||||||
$commitHashShort = runLocally('git rev-parse --short HEAD');
|
|
||||||
// $commitHash = runLocally('git rev-parse HEAD');
|
|
||||||
$commit = explode(PHP_EOL, runLocally('git log -1 --pretty="%H%n%ci"'));
|
|
||||||
$commitHash = $commit[0];
|
|
||||||
$commitDate = $commit[1];
|
|
||||||
|
|
||||||
// $line = sprintf('%s %s branch="%s" hash="%s"', $date, $commitHashShort, $branch, $commitHash);
|
|
||||||
$projectUrlBase = 'https://git.loken.nl/ardent/AnimeRSS4';
|
|
||||||
$array = [
|
|
||||||
'branch' => $branch,
|
|
||||||
'branchUrl' => sprintf('%s/src/branch/%s', $projectUrlBase, $branch),
|
|
||||||
'date' => $date,
|
|
||||||
'commitHashShort' => $commitHashShort,
|
|
||||||
'commitHashLong' => $commitHash,
|
|
||||||
'commitDate' => $commitDate,
|
|
||||||
'commitUrl' => sprintf('%s/commit/%s', $projectUrlBase, $commitHash),
|
|
||||||
'projectUrl' => $projectUrlBase,
|
|
||||||
];
|
|
||||||
$json = json_encode($array, JSON_PRETTY_PRINT);
|
|
||||||
|
|
||||||
runLocally("echo '$json' > release.json");
|
|
||||||
upload('release.json', '{{release_path}}/release.json');
|
|
||||||
});
|
|
||||||
|
|
||||||
//desc('Deploy project');
|
|
||||||
//task('deploy', [
|
|
||||||
// 'deployment:log',
|
|
||||||
//]);
|
|
||||||
|
|
||||||
desc('Deploy project');
|
|
||||||
task('deploy', [
|
|
||||||
'deploy:prepare',
|
|
||||||
'deploy:vendors',
|
|
||||||
'cache:clear',
|
|
||||||
'cache:warmup',
|
|
||||||
'database:migrate',
|
|
||||||
'deployment:log',
|
|
||||||
'deploy:symlink',
|
|
||||||
'deploy:unlock',
|
|
||||||
'deploy:cleanup',
|
|
||||||
]);
|
|
||||||
|
|
||||||
after('deploy', 'deploy:success');
|
|
||||||
|
|
||||||
// [Optional] if deploy fails automatically unlock.
|
|
||||||
after('deploy:failed', 'deploy:unlock');
|
|
||||||
|
27
deploy/git.php
Normal file
27
deploy/git.php
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Deployer;
|
||||||
|
|
||||||
|
desc('Transfers information about current git commit to server');
|
||||||
|
task('deployment:log', function () { //https://stackoverflow.com/questions/59686270/how-to-log-deployments-in-deployer
|
||||||
|
$branch = parse('{{branch}}');
|
||||||
|
$date = date('Y-m-d H:i:s');
|
||||||
|
$commitHashShort = runLocally('git rev-parse --short HEAD');
|
||||||
|
// $commitHash = runLocally('git rev-parse HEAD');
|
||||||
|
$commit = explode(PHP_EOL, runLocally('git log -1 --pretty="%H%n%ci"'));
|
||||||
|
$commitHash = $commit[0];
|
||||||
|
$commitDate = $commit[1];
|
||||||
|
|
||||||
|
// $line = sprintf('%s %s branch="%s" hash="%s"', $date, $commitHashShort, $branch, $commitHash);
|
||||||
|
$array = [
|
||||||
|
'branch' => $branch,
|
||||||
|
'date' => $date,
|
||||||
|
'commitHashShort' => $commitHashShort,
|
||||||
|
'commitHashLong' => $commitHash,
|
||||||
|
'commitDate' => $commitDate,
|
||||||
|
];
|
||||||
|
$json = json_encode($array, JSON_PRETTY_PRINT);
|
||||||
|
|
||||||
|
runLocally("echo '$json' > release.json");
|
||||||
|
upload('release.json', '{{release_path}}/release.json');
|
||||||
|
});
|
65
deploy/symfony.php
Normal file
65
deploy/symfony.php
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Deployer;
|
||||||
|
|
||||||
|
require_once 'recipe/common.php';
|
||||||
|
require_once 'deploy/git.php';
|
||||||
|
|
||||||
|
// [Optional] Allocate tty for git clone. Default value is false.
|
||||||
|
set('git_tty', true);
|
||||||
|
|
||||||
|
// Shared files/dirs between deploys
|
||||||
|
set('shared_dirs', ['var/log', 'var/sessions']);
|
||||||
|
set('shared_files', ['.env.local']);
|
||||||
|
|
||||||
|
set('writable_dirs', ['.']);
|
||||||
|
set('writable_mode', 'sticky');
|
||||||
|
set('http-group', 'www-data');
|
||||||
|
|
||||||
|
set('migrations_config', '');
|
||||||
|
set('allow_anonymous_stats', false);
|
||||||
|
|
||||||
|
set('console_options', fn() => '--no-interaction');
|
||||||
|
set('bin/console', fn() => parse('{{release_path}}/bin/console'));
|
||||||
|
|
||||||
|
set('composer_options', '--verbose --prefer-dist --no-progress --no-interaction --no-dev --optimize-autoloader --no-scripts');
|
||||||
|
|
||||||
|
desc('Clear cache');
|
||||||
|
task('cache:clear', fn() => run('{{bin/php}} {{bin/console}} cache:clear {{console_options}} --no-warmup'));
|
||||||
|
|
||||||
|
desc('Warm up cache');
|
||||||
|
task('cache:warmup', fn() => run('{{bin/php}} {{bin/console}} cache:warmup {{console_options}}'));
|
||||||
|
|
||||||
|
desc('Migrate database');
|
||||||
|
task('database:migrate', function () {
|
||||||
|
$options = '--allow-no-migration';
|
||||||
|
if (get('migrations_config') !== '') {
|
||||||
|
$options = sprintf('%s --configuration={{release_path}}/{{migrations_config}}', $options);
|
||||||
|
}
|
||||||
|
|
||||||
|
run(sprintf('{{bin/php}} {{bin/console}} doctrine:migrations:migrate %s {{console_options}}', $options));
|
||||||
|
});
|
||||||
|
|
||||||
|
desc('Shows current deployed version');
|
||||||
|
task('deploy:current', function () {
|
||||||
|
$current = run('readlink {{deploy_path}}/current');
|
||||||
|
writeln("Current deployed version: $current");
|
||||||
|
});
|
||||||
|
|
||||||
|
desc('Deploy project');
|
||||||
|
task('deploy', [
|
||||||
|
'deploy:prepare',
|
||||||
|
'deploy:vendors',
|
||||||
|
'database:migrate',
|
||||||
|
'cache:clear',
|
||||||
|
'cache:warmup',
|
||||||
|
'deployment:log',
|
||||||
|
'deploy:symlink',
|
||||||
|
'deploy:unlock',
|
||||||
|
'deploy:cleanup',
|
||||||
|
'deploy:current',
|
||||||
|
]);
|
||||||
|
|
||||||
|
after('deploy', 'deploy:success');
|
||||||
|
|
||||||
|
after('deploy:failed', 'deploy:unlock');
|
5
http-client.env.json
Normal file
5
http-client.env.json
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"dev": {
|
||||||
|
"host": "https://snips.localhost"
|
||||||
|
}
|
||||||
|
}
|
35
migrations/Version20231220204107.php
Normal file
35
migrations/Version20231220204107.php
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-generated Migration: Please modify to your needs!
|
||||||
|
*/
|
||||||
|
final class Version20231220204107 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this up() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql('ALTER TABLE snip ADD active_version_id BINARY(16) DEFAULT NULL COMMENT \'(DC2Type:ulid)\'');
|
||||||
|
$this->addSql('ALTER TABLE snip ADD CONSTRAINT FK_FEBD97966A1E45F3 FOREIGN KEY (active_version_id) REFERENCES snip_content (id)');
|
||||||
|
$this->addSql('CREATE INDEX IDX_FEBD97966A1E45F3 ON snip (active_version_id)');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this down() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql('ALTER TABLE snip DROP FOREIGN KEY FK_FEBD97966A1E45F3');
|
||||||
|
$this->addSql('DROP INDEX IDX_FEBD97966A1E45F3 ON snip');
|
||||||
|
$this->addSql('ALTER TABLE snip DROP active_version_id');
|
||||||
|
}
|
||||||
|
}
|
35
migrations/Version20250414192457.php
Normal file
35
migrations/Version20250414192457.php
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-generated Migration: Please modify to your needs!
|
||||||
|
*/
|
||||||
|
final class Version20250414192457 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this up() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
ALTER TABLE snip_content ADD diff JSON DEFAULT NULL COMMENT '(DC2Type:json)'
|
||||||
|
SQL);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this down() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
ALTER TABLE snip_content DROP diff
|
||||||
|
SQL);
|
||||||
|
}
|
||||||
|
}
|
38
migrations/Version20250422222542.php
Normal file
38
migrations/Version20250422222542.php
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-generated Migration: Please modify to your needs!
|
||||||
|
*/
|
||||||
|
final class Version20250422222542 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this up() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
ALTER TABLE snip ADD parser VARCHAR(255) NOT NULL
|
||||||
|
SQL);
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
UPDATE snip SET parser = 'generic'
|
||||||
|
SQL);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this down() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
ALTER TABLE snip DROP parser
|
||||||
|
SQL);
|
||||||
|
}
|
||||||
|
}
|
38
migrations/Version20250425182334.php
Normal file
38
migrations/Version20250425182334.php
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-generated Migration: Please modify to your needs!
|
||||||
|
*/
|
||||||
|
final class Version20250425182334 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this up() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
ALTER TABLE snip ADD visible TINYINT(1) NOT NULL
|
||||||
|
SQL);
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
UPDATE snip SET visible = 1
|
||||||
|
SQL);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this down() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
ALTER TABLE snip DROP visible
|
||||||
|
SQL);
|
||||||
|
}
|
||||||
|
}
|
35
migrations/Version20250427184240.php
Normal file
35
migrations/Version20250427184240.php
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-generated Migration: Please modify to your needs!
|
||||||
|
*/
|
||||||
|
final class Version20250427184240 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this up() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
ALTER TABLE user ADD api_key VARCHAR(255) DEFAULT NULL
|
||||||
|
SQL);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this down() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
ALTER TABLE `user` DROP api_key
|
||||||
|
SQL);
|
||||||
|
}
|
||||||
|
}
|
44
migrations/Version20250510142748.php
Normal file
44
migrations/Version20250510142748.php
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-generated Migration: Please modify to your needs!
|
||||||
|
*/
|
||||||
|
final class Version20250510142748 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this up() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
ALTER TABLE snip DROP INDEX IDX_FEBD97966A1E45F3, ADD UNIQUE INDEX UNIQ_FEBD97966A1E45F3 (active_version_id)
|
||||||
|
SQL);
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
ALTER TABLE snip ADD archived TINYINT(1) NOT NULL
|
||||||
|
SQL);
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
UPDATE snip SET archived = 0
|
||||||
|
SQL);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this down() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
ALTER TABLE snip DROP INDEX UNIQ_FEBD97966A1E45F3, ADD INDEX IDX_FEBD97966A1E45F3 (active_version_id)
|
||||||
|
SQL);
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
ALTER TABLE snip DROP archived
|
||||||
|
SQL);
|
||||||
|
}
|
||||||
|
}
|
59
migrations/Version20250510180413.php
Normal file
59
migrations/Version20250510180413.php
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-generated Migration: Please modify to your needs!
|
||||||
|
*/
|
||||||
|
final class Version20250510180413 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this up() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
CREATE TABLE tag (id INT AUTO_INCREMENT NOT NULL, user_id INT NOT NULL, name VARCHAR(255) NOT NULL, INDEX IDX_389B783A76ED395 (user_id), UNIQUE INDEX user_tag_unique (name, user_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB
|
||||||
|
SQL);
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
CREATE TABLE tag_snip (tag_id INT NOT NULL, snip_id INT NOT NULL, INDEX IDX_10B22820BAD26311 (tag_id), INDEX IDX_10B22820140FD260 (snip_id), PRIMARY KEY(tag_id, snip_id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB
|
||||||
|
SQL);
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
ALTER TABLE tag ADD CONSTRAINT FK_389B783A76ED395 FOREIGN KEY (user_id) REFERENCES `user` (id)
|
||||||
|
SQL);
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
ALTER TABLE tag_snip ADD CONSTRAINT FK_10B22820BAD26311 FOREIGN KEY (tag_id) REFERENCES tag (id) ON DELETE CASCADE
|
||||||
|
SQL);
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
ALTER TABLE tag_snip ADD CONSTRAINT FK_10B22820140FD260 FOREIGN KEY (snip_id) REFERENCES snip (id) ON DELETE CASCADE
|
||||||
|
SQL);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this down() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
ALTER TABLE tag DROP FOREIGN KEY FK_389B783A76ED395
|
||||||
|
SQL);
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
ALTER TABLE tag_snip DROP FOREIGN KEY FK_10B22820BAD26311
|
||||||
|
SQL);
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
ALTER TABLE tag_snip DROP FOREIGN KEY FK_10B22820140FD260
|
||||||
|
SQL);
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
DROP TABLE tag
|
||||||
|
SQL);
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
DROP TABLE tag_snip
|
||||||
|
SQL);
|
||||||
|
}
|
||||||
|
}
|
35
migrations/Version20250513103236.php
Normal file
35
migrations/Version20250513103236.php
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-generated Migration: Please modify to your needs!
|
||||||
|
*/
|
||||||
|
final class Version20250513103236 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this up() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
ALTER TABLE snip_content ADD name VARCHAR(255) DEFAULT NULL
|
||||||
|
SQL);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this down() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
ALTER TABLE snip_content DROP name
|
||||||
|
SQL);
|
||||||
|
}
|
||||||
|
}
|
87
public/github-light-default.css
Normal file
87
public/github-light-default.css
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
pre, code {
|
||||||
|
color: #1f2328;
|
||||||
|
background-color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hl-keyword {
|
||||||
|
color: #cf222e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hl-property {
|
||||||
|
color: #8250df;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hl-attribute {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hl-type {
|
||||||
|
color: #EA4334;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hl-generic {
|
||||||
|
color: #9d3af6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hl-value {
|
||||||
|
color: #0a3069;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hl-literal {
|
||||||
|
color: #0a3069;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hl-number {
|
||||||
|
color: #0a3069;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hl-variable {
|
||||||
|
color: #953800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hl-comment {
|
||||||
|
color: #6e7781;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hl-blur {
|
||||||
|
filter: blur(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hl-strong {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hl-em {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hl-addition {
|
||||||
|
display: inline-block;
|
||||||
|
min-width: 100%;
|
||||||
|
background-color: #00FF0022;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hl-deletion {
|
||||||
|
display: inline-block;
|
||||||
|
min-width: 100%;
|
||||||
|
background-color: #FF000011;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hl-gutter {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: #555;
|
||||||
|
padding: 0 1ch;
|
||||||
|
margin-right: 1ch;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hl-gutter-addition {
|
||||||
|
background-color: #34A853;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hl-gutter-deletion {
|
||||||
|
background-color: #EA4334;
|
||||||
|
color: #fff;
|
||||||
|
}
|
BIN
public/snips.png
Normal file
BIN
public/snips.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.2 MiB |
45
src/Command/SnipUpdateContentCommand.php
Normal file
45
src/Command/SnipUpdateContentCommand.php
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Command;
|
||||||
|
|
||||||
|
use App\Entity\SnipContent;
|
||||||
|
use App\Repository\SnipContentRepository;
|
||||||
|
use App\Service\SnipContent\Lexer;
|
||||||
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||||
|
|
||||||
|
#[AsCommand(
|
||||||
|
name: 'app:snip:update-content',
|
||||||
|
description: 'Update Snip content line endings',
|
||||||
|
)]
|
||||||
|
class SnipUpdateContentCommand extends Command
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly SnipContentRepository $snipContentRepository,
|
||||||
|
)
|
||||||
|
{
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
|
{
|
||||||
|
$io = new SymfonyStyle($input, $output);
|
||||||
|
|
||||||
|
$qb = $this->snipContentRepository->createQueryBuilder('s');
|
||||||
|
$qb->where('s.text IS NOT NULL');
|
||||||
|
|
||||||
|
$c = 0;
|
||||||
|
/** @var SnipContent $snipContent */
|
||||||
|
foreach ($qb->getQuery()->getResult() as $snipContent) {
|
||||||
|
$text = $snipContent->getText();
|
||||||
|
$text = Lexer::reconstruct(Lexer::tokenize($text));
|
||||||
|
$snipContent->setText($text);
|
||||||
|
$this->snipContentRepository->save($snipContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
39
src/Controller/Api/AbstractApiController.php
Normal file
39
src/Controller/Api/AbstractApiController.php
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controller\Api;
|
||||||
|
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class AbstractApiController extends AbstractController
|
||||||
|
{
|
||||||
|
private function apiResponse(bool $success, array|NormalizableInterface $data): Response
|
||||||
|
{
|
||||||
|
if ($data instanceof NormalizableInterface) {
|
||||||
|
$data = $data->normalize();
|
||||||
|
}
|
||||||
|
if (is_array($data)) {
|
||||||
|
foreach ($data as $key => $value) {
|
||||||
|
if ($value instanceof NormalizableInterface) {
|
||||||
|
$data[$key] = $value->normalize();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new JsonResponse([
|
||||||
|
'success' => $success,
|
||||||
|
'data' => $data,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
final protected function errorResponse(string $message): Response
|
||||||
|
{
|
||||||
|
return $this->apiResponse(false, ['message' => $message]);
|
||||||
|
}
|
||||||
|
|
||||||
|
final protected function successResponse(array|NormalizableInterface $data = []): Response
|
||||||
|
{
|
||||||
|
return $this->apiResponse(true, $data);
|
||||||
|
}
|
||||||
|
}
|
76
src/Controller/Api/ApiController.php
Normal file
76
src/Controller/Api/ApiController.php
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controller\Api;
|
||||||
|
|
||||||
|
use App\Dto\SnipPostRequest;
|
||||||
|
use App\Entity\Snip;
|
||||||
|
use App\Entity\User;
|
||||||
|
use App\Repository\SnipRepository;
|
||||||
|
use App\Security\Voter\SnipVoter;
|
||||||
|
use App\Service\SnipContent\SnipContentService;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
|
||||||
|
class ApiController extends AbstractApiController
|
||||||
|
{
|
||||||
|
#[Route('/me', methods: ['GET'])]
|
||||||
|
public function me(): Response
|
||||||
|
{
|
||||||
|
/** @var User $user */
|
||||||
|
$user = $this->getUser();
|
||||||
|
|
||||||
|
return $this->successResponse([
|
||||||
|
'id' => $user->getId(),
|
||||||
|
'name' => $user->getName(),
|
||||||
|
'email' => $user->getEmail(),
|
||||||
|
'apiKey' => $user->getApiKey(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/snip/{snip}', methods: ['GET'])]
|
||||||
|
public function getSnip(Snip $snip): Response
|
||||||
|
{
|
||||||
|
$this->denyAccessUnlessGranted(SnipVoter::VIEW, $snip);
|
||||||
|
|
||||||
|
return $this->successResponse([
|
||||||
|
'id' => $snip->getId(),
|
||||||
|
'content' => $snip->getActiveText(),
|
||||||
|
'createdBy' => [
|
||||||
|
'id' => $snip->getCreatedBy()->getId(),
|
||||||
|
'name' => $snip->getCreatedBy()->getName(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/snip/{snip}', methods: ['POST'])]
|
||||||
|
public function postSnip(
|
||||||
|
Snip $snip,
|
||||||
|
#[MapRequestPayload] SnipPostRequest $request,
|
||||||
|
SnipContentService $cs,
|
||||||
|
SnipRepository $repo,
|
||||||
|
): Response
|
||||||
|
{
|
||||||
|
$this->denyAccessUnlessGranted(SnipVoter::EDIT, $snip);
|
||||||
|
|
||||||
|
if (!($snip->getActiveVersion() === $snip->getLatestVersion())) {
|
||||||
|
return $this->errorResponse('Snip is not the latest version');
|
||||||
|
}
|
||||||
|
|
||||||
|
$request->pushToSnip($snip);
|
||||||
|
$repo->save($snip);
|
||||||
|
if ($request->content !== null) {
|
||||||
|
$cs->update($snip, $request->content);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->successResponse([
|
||||||
|
'id' => $snip->getId(),
|
||||||
|
'name' => $snip->getName(),
|
||||||
|
'content' => $snip->getActiveText(),
|
||||||
|
'createdBy' => [
|
||||||
|
'id' => $snip->getCreatedBy()->getId(),
|
||||||
|
'name' => $snip->getCreatedBy()->getName(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
8
src/Controller/Api/NormalizableInterface.php
Normal file
8
src/Controller/Api/NormalizableInterface.php
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controller\Api;
|
||||||
|
|
||||||
|
interface NormalizableInterface
|
||||||
|
{
|
||||||
|
public function normalize(): array;
|
||||||
|
}
|
14
src/Controller/Attribute/MapQueryCached.php
Normal file
14
src/Controller/Attribute/MapQueryCached.php
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controller\Attribute;
|
||||||
|
|
||||||
|
use App\Service\RequestDtoCache;
|
||||||
|
use Symfony\Component\HttpKernel\Attribute\MapQueryString;
|
||||||
|
|
||||||
|
#[\Attribute(\Attribute::TARGET_PARAMETER)]
|
||||||
|
class MapQueryCached extends MapQueryString
|
||||||
|
{
|
||||||
|
public function __construct() {
|
||||||
|
return parent::__construct(resolver: RequestDtoCache::class);
|
||||||
|
}
|
||||||
|
}
|
@ -4,13 +4,17 @@ namespace App\Controller;
|
|||||||
|
|
||||||
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\Annotation\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
|
||||||
class HomeController extends AbstractController
|
class HomeController extends AbstractController
|
||||||
{
|
{
|
||||||
#[Route('/', name: 'home')]
|
#[Route('/', name: 'home')]
|
||||||
public function home(): Response
|
public function home(): Response
|
||||||
{
|
{
|
||||||
return $this->redirectToRoute('snip_index');
|
if ($this->getUser()) {
|
||||||
|
return $this->redirectToRoute('snip_index');
|
||||||
|
} else {
|
||||||
|
return $this->redirectToRoute('snip_public');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -11,7 +11,7 @@ use Symfony\Bundle\SecurityBundle\Security;
|
|||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||||
use Symfony\Component\Routing\Annotation\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
|
use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
|
||||||
|
|
||||||
class SecurityController extends AbstractController
|
class SecurityController extends AbstractController
|
||||||
|
37
src/Controller/SnipContentController.php
Normal file
37
src/Controller/SnipContentController.php
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controller;
|
||||||
|
|
||||||
|
use App\Entity\SnipContent;
|
||||||
|
use App\Security\Voter\SnipVoter;
|
||||||
|
use App\Service\SnipContent\MyersDiff;
|
||||||
|
use App\Service\SnipContent\SnipContentService;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
|
||||||
|
#[Route('/content', name: 'content')]
|
||||||
|
class SnipContentController extends AbstractController
|
||||||
|
{
|
||||||
|
public function __construct() {}
|
||||||
|
|
||||||
|
#[Route('/compare/{to}/{from}', name: '_compare')]
|
||||||
|
public function compare(SnipContent $to, ?SnipContent $from = null): Response
|
||||||
|
{
|
||||||
|
$this->denyAccessUnlessGranted(SnipVoter::VIEW, $to->getSnip());
|
||||||
|
|
||||||
|
if ($from === null) {
|
||||||
|
$from = $to->getParent();
|
||||||
|
}
|
||||||
|
|
||||||
|
$diff = MyersDiff::buildDiffLines(
|
||||||
|
SnipContentService::rebuildText($from),
|
||||||
|
SnipContentService::rebuildText($to),
|
||||||
|
);
|
||||||
|
|
||||||
|
return $this->render('content/compare.html.twig', [
|
||||||
|
'snip' => $to->getSnip(),
|
||||||
|
'diff' => $diff,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
@ -2,68 +2,63 @@
|
|||||||
|
|
||||||
namespace App\Controller;
|
namespace App\Controller;
|
||||||
|
|
||||||
|
use App\Controller\Attribute\MapQueryCached;
|
||||||
|
use App\Dto\SnipFilterRequest;
|
||||||
use App\Entity\Snip;
|
use App\Entity\Snip;
|
||||||
use App\Form\ConfirmationType;
|
use App\Form\ConfirmationType;
|
||||||
use App\Form\SnipType;
|
use App\Form\SnipType;
|
||||||
use App\Repository\SnipRepository;
|
use App\Repository\SnipRepository;
|
||||||
use App\Security\Voter\SnipVoter;
|
use App\Security\Voter\SnipVoter;
|
||||||
use App\Service\SnipParser\Pipeline;
|
use App\Service\SnipContent\SnipContentService;
|
||||||
use App\Service\SnipServiceFactory;
|
use App\Service\SnipParser\ParserFactory;
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
|
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
use Symfony\Component\Routing\Annotation\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
|
||||||
#[Route('/snip', name: 'snip')]
|
#[Route('/snip', name: 'snip')]
|
||||||
class SnipController extends AbstractController
|
class SnipController extends AbstractController
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly SnipRepository $repository,
|
private readonly SnipRepository $repository,
|
||||||
private readonly SnipServiceFactory $snipServiceFactory,
|
) {}
|
||||||
)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Route('/', name: '_index')]
|
#[Route('/', name: '_index')]
|
||||||
public function index(): Response
|
public function index(#[MapQueryCached] SnipFilterRequest $request): Response
|
||||||
{
|
{
|
||||||
return $this->render('snip/index.html.twig', [
|
return $this->render('snip/index.html.twig', [
|
||||||
'snips' => $this->repository->findByUser($this->getUser()),
|
'snips' => $this->repository->findByRequest($this->getUser(), $request),
|
||||||
'title' => 'My Snips',
|
'request' => $request,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Route('/public', name: '_public')]
|
#[Route('/public', name: '_public')]
|
||||||
public function public(): Response
|
public function public(): Response
|
||||||
{
|
{
|
||||||
return $this->render('snip/index.html.twig', [
|
return $this->render('snip/public.html.twig', [
|
||||||
'snips' => $this->repository->findPublic($this->getUser()),
|
'snips' => $this->repository->findPublic(),
|
||||||
'title' => 'Public Snips',
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Route('/single/{snip}', name: '_single')]
|
#[Route('/single/{snip}', name: '_single')]
|
||||||
public function single(Snip $snip, Pipeline $pl): Response
|
public function single(Snip $snip, ParserFactory $pf): Response
|
||||||
{
|
{
|
||||||
$this->denyAccessUnlessGranted(SnipVoter::VIEW, $snip);
|
$this->denyAccessUnlessGranted(SnipVoter::VIEW, $snip);
|
||||||
|
|
||||||
$snipService = $this->snipServiceFactory->create($snip);
|
|
||||||
dump($snipService);
|
|
||||||
return $this->render('snip/single.html.twig', [
|
return $this->render('snip/single.html.twig', [
|
||||||
'snip' => $snip,
|
'snip' => $snip,
|
||||||
'content' => $pl->parse($snipService->get()),
|
'content' => $pf->getBySnip($snip)->parseView($snip->getActiveText()),
|
||||||
'branch' => $snipService->getCommit(),
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Route('/raw/{snip}', name: '_raw')]
|
#[Route('/raw/{snip}', name: '_raw')]
|
||||||
public function raw(Snip $snip, Pipeline $pl, Request $request): Response
|
public function raw(Snip $snip, ParserFactory $pf, Request $request): Response
|
||||||
{
|
{
|
||||||
$this->denyAccessUnlessGranted(SnipVoter::VIEW, $snip);
|
$this->denyAccessUnlessGranted(SnipVoter::VIEW, $snip);
|
||||||
|
|
||||||
$response = new Response(
|
$response = new Response(
|
||||||
$pl->clean($this->snipServiceFactory->create($snip)->get()),
|
$pf->getBySnip($snip)->parseRaw($snip->getActiveText()),
|
||||||
Response::HTTP_OK,
|
Response::HTTP_OK,
|
||||||
['Content-Type' => 'text/html']
|
['Content-Type' => 'text/html']
|
||||||
);
|
);
|
||||||
@ -71,7 +66,8 @@ class SnipController extends AbstractController
|
|||||||
->setVary(['Accept', 'Accept-Encoding'])
|
->setVary(['Accept', 'Accept-Encoding'])
|
||||||
->setEtag(md5($response->getContent()))
|
->setEtag(md5($response->getContent()))
|
||||||
->setTtl(3600)
|
->setTtl(3600)
|
||||||
->setClientTtl(300);
|
->setClientTtl(300)
|
||||||
|
;
|
||||||
|
|
||||||
if (!$request->isNoCache()) {
|
if (!$request->isNoCache()) {
|
||||||
$response->isNotModified($request);
|
$response->isNotModified($request);
|
||||||
@ -81,20 +77,38 @@ class SnipController extends AbstractController
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[Route('/edit/{snip}', name: '_edit')]
|
#[Route('/edit/{snip}', name: '_edit')]
|
||||||
public function edit(Snip $snip, Request $request): Response
|
public function edit(Snip $snip, Request $request, SnipContentService $contentService): Response
|
||||||
{
|
{
|
||||||
$this->denyAccessUnlessGranted(SnipVoter::EDIT, $snip);
|
$this->denyAccessUnlessGranted(SnipVoter::EDIT, $snip);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Temporary solution to prevent editing of old versions
|
||||||
|
* It technically fully works, but rendering the version history needs an update first
|
||||||
|
*/
|
||||||
|
$isLatest = $snip->getActiveVersion() === $snip->getLatestVersion();
|
||||||
|
if (!$isLatest) {
|
||||||
|
$this->addFlash('error', 'Snip is not the latest version, changes will not be saved.');
|
||||||
|
}
|
||||||
|
|
||||||
$form = $this->createForm(SnipType::class, $snip);
|
$form = $this->createForm(SnipType::class, $snip);
|
||||||
$form->add('Save', SubmitType::class);
|
$form->add('Save', SubmitType::class);
|
||||||
if ($snip->getId()) {
|
if ($snip->getId()) {
|
||||||
$form->get('content')->setData($this->snipServiceFactory->create($snip)->get());
|
$form->get('content')->setData($snip->getActiveText());
|
||||||
}
|
}
|
||||||
|
|
||||||
$form->handleRequest($request);
|
$form->handleRequest($request);
|
||||||
if ($form->isSubmitted() && $form->isValid()) {
|
if ($form->isSubmitted() && $form->isValid()) {
|
||||||
|
if (!$isLatest) {
|
||||||
|
return $this->redirectToRoute('snip_single', [
|
||||||
|
'snip' => $snip->getId(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
$this->repository->save($snip);
|
$this->repository->save($snip);
|
||||||
$this->snipServiceFactory->create($snip)->update($form->get('content')->getData());
|
$contentService->update(
|
||||||
|
$snip,
|
||||||
|
$form->get('content')->getData(),
|
||||||
|
$form->get('contentName')->getData()
|
||||||
|
);
|
||||||
|
|
||||||
$this->addFlash('success', sprintf('Snip "%s" saved', $snip));
|
$this->addFlash('success', sprintf('Snip "%s" saved', $snip));
|
||||||
|
|
||||||
@ -110,13 +124,14 @@ class SnipController extends AbstractController
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[Route('/new', name: '_new')]
|
#[Route('/new', name: '_new')]
|
||||||
public function new(Request $request): Response
|
public function new(Request $request, SnipContentService $contentService): Response
|
||||||
{
|
{
|
||||||
$snip = new Snip();
|
$snip = new Snip();
|
||||||
$snip->setCreatedAtTodayNoSeconds()
|
$snip->setCreatedAtNow()
|
||||||
->setCreatedBy($this->getUser());
|
->setCreatedBy($this->getUser())
|
||||||
|
;
|
||||||
|
|
||||||
return $this->edit($snip, $request);
|
return $this->edit($snip, $request, $contentService);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Route('/delete/{snip}', name: '_delete')]
|
#[Route('/delete/{snip}', name: '_delete')]
|
||||||
@ -127,15 +142,31 @@ class SnipController extends AbstractController
|
|||||||
$form = $this->createForm(ConfirmationType::class);
|
$form = $this->createForm(ConfirmationType::class);
|
||||||
$form->handleRequest($request);
|
$form->handleRequest($request);
|
||||||
if ($form->isSubmitted() && $form->isValid()) {
|
if ($form->isSubmitted() && $form->isValid()) {
|
||||||
$this->snipServiceFactory->create($snip)->delete();
|
$snip->setActiveVersion(null);
|
||||||
|
$this->repository->save($snip);
|
||||||
$this->repository->remove($snip);
|
$this->repository->remove($snip);
|
||||||
$this->addFlash('success', sprintf('Snip "%s" deleted', $snip));
|
$this->addFlash('success', sprintf('Snip "%s" deleted', $snip));
|
||||||
return $this->redirectToRoute('snip_index');
|
return $this->redirectToRoute('snip_index');
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->render('form.html.twig', [
|
return $this->render('generic/form.html.twig', [
|
||||||
'message' => sprintf('Do you really want to delete "%s"?', $snip),
|
'message' => sprintf('Do you really want to delete "%s"?', $snip),
|
||||||
'form' => $form->createView(),
|
'form' => $form->createView(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[Route('/archive/{snip}', name: '_archive')]
|
||||||
|
public function archive(Snip $snip): Response
|
||||||
|
{
|
||||||
|
$this->denyAccessUnlessGranted(SnipVoter::EDIT, $snip);
|
||||||
|
$snip->setArchived(!$snip->isArchived());
|
||||||
|
$this->repository->save($snip);
|
||||||
|
if ($snip->isArchived()) {
|
||||||
|
$this->addFlash('success', sprintf('Snip "%s" archived', $snip));
|
||||||
|
} else {
|
||||||
|
$this->addFlash('success', sprintf('Snip "%s" unarchived', $snip));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->redirectToRoute('snip_edit', ['snip' => $snip->getId()]);
|
||||||
|
}
|
||||||
}
|
}
|
@ -4,24 +4,21 @@ namespace App\Controller;
|
|||||||
|
|
||||||
use App\Entity\User;
|
use App\Entity\User;
|
||||||
use App\Form\ProfileType;
|
use App\Form\ProfileType;
|
||||||
use App\Form\UserSettingsType;
|
|
||||||
use App\Service\LastRelease;
|
use App\Service\LastRelease;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
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\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||||
use Symfony\Component\Routing\Annotation\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
use Symfony\Component\Uid\Uuid;
|
use Symfony\Component\Uid\Uuid;
|
||||||
|
|
||||||
#[Route('/user', name: 'user')]
|
#[Route('/user', name: 'user')]
|
||||||
class UserController extends AbstractController
|
class UserController extends AbstractController
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private EntityManagerInterface $em,
|
private readonly EntityManagerInterface $em,
|
||||||
)
|
) {}
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Route('/profile', name: '_profile')]
|
#[Route('/profile', name: '_profile')]
|
||||||
public function profile(
|
public function profile(
|
||||||
@ -46,7 +43,8 @@ class UserController extends AbstractController
|
|||||||
$user,
|
$user,
|
||||||
$form->get('plainPassword')->getData()
|
$form->get('plainPassword')->getData()
|
||||||
)
|
)
|
||||||
);
|
)
|
||||||
|
;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
$this->addFlash('success', 'Profile updated successfully');
|
$this->addFlash('success', 'Profile updated successfully');
|
||||||
@ -60,4 +58,20 @@ class UserController extends AbstractController
|
|||||||
'release' => $lastRelease,
|
'release' => $lastRelease,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[Route('/apikey/generate', name: '_apikey_generate')]
|
||||||
|
public function apiKeyGenerate(): Response
|
||||||
|
{
|
||||||
|
/** @var User $user */
|
||||||
|
$user = $this->getUser();
|
||||||
|
|
||||||
|
$apiKey = Uuid::v4()->toBase58();
|
||||||
|
$user->setApiKey($apiKey);
|
||||||
|
$this->em->persist($user);
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
$this->addFlash('success', sprintf('Successfully generated new api key: "%s"', $apiKey));
|
||||||
|
|
||||||
|
return $this->redirectToRoute('user_profile');
|
||||||
|
}
|
||||||
}
|
}
|
@ -3,39 +3,37 @@
|
|||||||
namespace App\Controller;
|
namespace App\Controller;
|
||||||
|
|
||||||
use App\Entity\Snip;
|
use App\Entity\Snip;
|
||||||
|
use App\Entity\SnipContent;
|
||||||
use App\Security\Voter\SnipVoter;
|
use App\Security\Voter\SnipVoter;
|
||||||
use App\Service\SnipServiceFactory;
|
use App\Service\SnipContent\SnipContentService;
|
||||||
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\Annotation\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
|
||||||
#[Route('/history/{snip}', name: 'history')]
|
#[Route('/version/{snip}', name: 'version')]
|
||||||
class HistoryController extends AbstractController
|
class VersionController extends AbstractController
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly SnipServiceFactory $snipServiceFactory,
|
private readonly SnipContentService $contentService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
#[Route('/', name: '_index')]
|
#[Route('/', name: '_index')]
|
||||||
public function index(Snip $snip): Response
|
public function index(Snip $snip): Response
|
||||||
{
|
{
|
||||||
$this->denyAccessUnlessGranted(SnipVoter::EDIT, $snip);
|
$this->denyAccessUnlessGranted(SnipVoter::EDIT, $snip);
|
||||||
|
|
||||||
$snipService = $this->snipServiceFactory->create($snip);
|
return $this->render('version/index.html.twig', [
|
||||||
return $this->render('history/index.html.twig', [
|
|
||||||
'snip' => $snip,
|
'snip' => $snip,
|
||||||
'versions' => $snipService->getVersions(),
|
|
||||||
'latestVersion' => $snipService->getLatestVersion(),
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Route('/set/{version}', name: '_set')]
|
#[Route('/set/{version}', name: '_set')]
|
||||||
public function set(Snip $snip, string $version): Response
|
public function set(Snip $snip, SnipContent $version): Response
|
||||||
{
|
{
|
||||||
$this->denyAccessUnlessGranted(SnipVoter::EDIT, $snip);
|
$this->denyAccessUnlessGranted(SnipVoter::EDIT, $snip);
|
||||||
|
|
||||||
$this->snipServiceFactory->create($snip)->setVersion($version);
|
$this->contentService->setVersion($snip, $version);
|
||||||
$this->addFlash('success', 'Snip version set to ' . $version);
|
$this->addFlash('success', 'Snip version set to ' . $version->getId());
|
||||||
return $this->redirectToRoute('snip_single', ['snip' => $snip->getId()]);
|
return $this->redirectToRoute('snip_single', ['snip' => $snip->getId()]);
|
||||||
}
|
}
|
||||||
}
|
}
|
8
src/Dto/CachableDtoInterface.php
Normal file
8
src/Dto/CachableDtoInterface.php
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Dto;
|
||||||
|
|
||||||
|
interface CachableDtoInterface
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
32
src/Dto/SnipFilterRequest.php
Normal file
32
src/Dto/SnipFilterRequest.php
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Dto;
|
||||||
|
|
||||||
|
readonly class SnipFilterRequest implements CachableDtoInterface
|
||||||
|
{
|
||||||
|
public const string VISIBILITY_ALL = 'all';
|
||||||
|
public const string VISIBILITY_VISIBLE = 'visible';
|
||||||
|
public const string VISIBILITY_HIDDEN = 'hidden';
|
||||||
|
public const string VISIBILITY_ARCHIVED = 'archived';
|
||||||
|
|
||||||
|
public const string SORT_NAME = 'name';
|
||||||
|
public const string SORT_DATE = 'date';
|
||||||
|
|
||||||
|
public const string TAG_ALL = 'all';
|
||||||
|
public const string TAG_NONE = 'none';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public ?string $visibility = self::VISIBILITY_VISIBLE,
|
||||||
|
public ?string $sort = self::SORT_NAME,
|
||||||
|
public ?string $tag = self::TAG_ALL,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function toArray(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'visibility' => $this->visibility,
|
||||||
|
'sort' => $this->sort,
|
||||||
|
'tag' => $this->tag,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
28
src/Dto/SnipPostRequest.php
Normal file
28
src/Dto/SnipPostRequest.php
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Dto;
|
||||||
|
|
||||||
|
use App\Entity\Snip;
|
||||||
|
|
||||||
|
class SnipPostRequest
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public ?string $name = null,
|
||||||
|
public ?string $content = null,
|
||||||
|
public ?bool $public = null,
|
||||||
|
public ?bool $visible = null,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function pushToSnip(Snip $snip): void
|
||||||
|
{
|
||||||
|
if ($this->name !== null) {
|
||||||
|
$snip->setName($this->name);
|
||||||
|
}
|
||||||
|
if ($this->public !== null) {
|
||||||
|
$snip->setPublic($this->public);
|
||||||
|
}
|
||||||
|
if ($this->visible !== null) {
|
||||||
|
$snip->setVisible($this->visible);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -40,10 +40,17 @@ trait TrackedTrait
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function setCreatedAtTodayNoSeconds(): self
|
public function setCreatedAtNowNoSeconds(): self
|
||||||
{
|
{
|
||||||
$this->setCreatedAt(DateTime::createFromFormat('Y-m-d H:i', date('Y-m-d H:i')));
|
$this->setCreatedAt(DateTime::createFromFormat('Y-m-d H:i', date('Y-m-d H:i')));
|
||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function setCreatedAtNow(): self
|
||||||
|
{
|
||||||
|
$this->setCreatedAt(new DateTime());
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
}
|
}
|
@ -4,6 +4,7 @@ namespace App\Entity;
|
|||||||
|
|
||||||
use App\Entity\Helpers\TrackedTrait;
|
use App\Entity\Helpers\TrackedTrait;
|
||||||
use App\Repository\SnipRepository;
|
use App\Repository\SnipRepository;
|
||||||
|
use App\Service\SnipContent\SnipContentService;
|
||||||
use Doctrine\Common\Collections\ArrayCollection;
|
use Doctrine\Common\Collections\ArrayCollection;
|
||||||
use Doctrine\Common\Collections\Collection;
|
use Doctrine\Common\Collections\Collection;
|
||||||
use Doctrine\ORM\Mapping as ORM;
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
@ -22,17 +23,33 @@ class Snip
|
|||||||
private ?string $name = null;
|
private ?string $name = null;
|
||||||
|
|
||||||
#[ORM\Column]
|
#[ORM\Column]
|
||||||
private ?bool $public = null;
|
private bool $public = false;
|
||||||
|
|
||||||
#[ORM\OneToMany(mappedBy: 'snip', targetEntity: SnipContent::class, orphanRemoval: true)]
|
#[ORM\OneToMany(mappedBy: 'snip', targetEntity: SnipContent::class, orphanRemoval: true)]
|
||||||
private Collection $snipContents;
|
private Collection $snipContents;
|
||||||
|
|
||||||
#[ORM\Column(length: 255, nullable: true)]
|
#[ORM\OneToOne]
|
||||||
private ?string $activeCommit = null;
|
private ?SnipContent $activeVersion = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 255)]
|
||||||
|
private ?string $parser = null;
|
||||||
|
|
||||||
|
#[ORM\Column]
|
||||||
|
private bool $visible = true;
|
||||||
|
|
||||||
|
#[ORM\Column]
|
||||||
|
private bool $archived = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var Collection<int, Tag>
|
||||||
|
*/
|
||||||
|
#[ORM\ManyToMany(targetEntity: Tag::class, mappedBy: 'snips')]
|
||||||
|
private Collection $tags;
|
||||||
|
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
$this->snipContents = new ArrayCollection();
|
$this->snipContents = new ArrayCollection();
|
||||||
|
$this->tags = new ArrayCollection();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function __toString(): string
|
public function __toString(): string
|
||||||
@ -40,6 +57,11 @@ class Snip
|
|||||||
return $this->name ?? '';
|
return $this->name ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getActiveText(): string
|
||||||
|
{
|
||||||
|
return SnipContentService::rebuildText($this->getActiveVersion());
|
||||||
|
}
|
||||||
|
|
||||||
public function getId(): ?int
|
public function getId(): ?int
|
||||||
{
|
{
|
||||||
return $this->id;
|
return $this->id;
|
||||||
@ -99,14 +121,82 @@ class Snip
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getActiveCommit(): ?string
|
public function getLatestVersion(): ?SnipContent
|
||||||
{
|
{
|
||||||
return $this->activeCommit;
|
return $this->snipContents->last() ?: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function setActiveCommit(?string $activeCommit): static
|
public function getActiveVersion(): ?SnipContent
|
||||||
{
|
{
|
||||||
$this->activeCommit = $activeCommit;
|
return $this->activeVersion;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setActiveVersion(?SnipContent $activeVersion): static
|
||||||
|
{
|
||||||
|
$this->activeVersion = $activeVersion;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getParser(): ?string
|
||||||
|
{
|
||||||
|
return $this->parser;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setParser(string $parser): static
|
||||||
|
{
|
||||||
|
$this->parser = $parser;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isVisible(): ?bool
|
||||||
|
{
|
||||||
|
return $this->visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setVisible(bool $visible): static
|
||||||
|
{
|
||||||
|
$this->visible = $visible;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isArchived(): ?bool
|
||||||
|
{
|
||||||
|
return $this->archived;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setArchived(bool $archived): static
|
||||||
|
{
|
||||||
|
$this->archived = $archived;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Collection<int, Tag>
|
||||||
|
*/
|
||||||
|
public function getTags(): Collection
|
||||||
|
{
|
||||||
|
return $this->tags;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function addTag(Tag $tag): static
|
||||||
|
{
|
||||||
|
if (!$this->tags->contains($tag)) {
|
||||||
|
$this->tags->add($tag);
|
||||||
|
$tag->addSnip($this);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function removeTag(Tag $tag): static
|
||||||
|
{
|
||||||
|
if ($this->tags->removeElement($tag)) {
|
||||||
|
$tag->removeSnip($this);
|
||||||
|
}
|
||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
@ -32,6 +32,12 @@ class SnipContent
|
|||||||
#[ORM\Column(type: Types::TEXT, nullable: true)]
|
#[ORM\Column(type: Types::TEXT, nullable: true)]
|
||||||
private ?string $text = null;
|
private ?string $text = null;
|
||||||
|
|
||||||
|
#[ORM\Column(nullable: true)]
|
||||||
|
private ?array $diff = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 255, nullable: true)]
|
||||||
|
private ?string $name = null;
|
||||||
|
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
$this->children = new ArrayCollection();
|
$this->children = new ArrayCollection();
|
||||||
@ -107,4 +113,28 @@ class SnipContent
|
|||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getDiff(): ?array
|
||||||
|
{
|
||||||
|
return $this->diff;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setDiff(?array $diff): static
|
||||||
|
{
|
||||||
|
$this->diff = $diff;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getName(): ?string
|
||||||
|
{
|
||||||
|
return $this->name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setName(?string $name): static
|
||||||
|
{
|
||||||
|
$this->name = $name;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
98
src/Entity/Tag.php
Normal file
98
src/Entity/Tag.php
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Entity;
|
||||||
|
|
||||||
|
use App\Dto\SnipFilterRequest;
|
||||||
|
use App\Repository\TagRepository;
|
||||||
|
use Doctrine\Common\Collections\ArrayCollection;
|
||||||
|
use Doctrine\Common\Collections\Collection;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
use Symfony\Component\Validator\Constraints as Assert;
|
||||||
|
|
||||||
|
#[ORM\Entity(repositoryClass: TagRepository::class)]
|
||||||
|
#[ORM\UniqueConstraint(name: 'user_tag_unique', columns: ['name', 'user_id'])]
|
||||||
|
class Tag
|
||||||
|
{
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\GeneratedValue]
|
||||||
|
#[ORM\Column]
|
||||||
|
private ?int $id = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 255)]
|
||||||
|
#[Assert\NotEqualTo(SnipFilterRequest::TAG_ALL)]
|
||||||
|
#[Assert\NotEqualTo(SnipFilterRequest::TAG_NONE)]
|
||||||
|
private ?string $name = null;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne]
|
||||||
|
#[ORM\JoinColumn(nullable: false)]
|
||||||
|
private ?User $user = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var Collection<int, Snip>
|
||||||
|
*/
|
||||||
|
#[ORM\ManyToMany(targetEntity: Snip::class, inversedBy: 'tags')]
|
||||||
|
private Collection $snips;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->snips = new ArrayCollection();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function __toString(): string
|
||||||
|
{
|
||||||
|
return $this->name ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getId(): ?int
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getName(): ?string
|
||||||
|
{
|
||||||
|
return $this->name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setName(string $name): static
|
||||||
|
{
|
||||||
|
$this->name = $name;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUser(): ?User
|
||||||
|
{
|
||||||
|
return $this->user;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setUser(?User $user): static
|
||||||
|
{
|
||||||
|
$this->user = $user;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Collection<int, Snip>
|
||||||
|
*/
|
||||||
|
public function getSnips(): Collection
|
||||||
|
{
|
||||||
|
return $this->snips;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function addSnip(Snip $snip): static
|
||||||
|
{
|
||||||
|
if (!$this->snips->contains($snip)) {
|
||||||
|
$this->snips->add($snip);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function removeSnip(Snip $snip): static
|
||||||
|
{
|
||||||
|
$this->snips->removeElement($snip);
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
}
|
@ -34,6 +34,9 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
|||||||
#[ORM\Column(length: 255)]
|
#[ORM\Column(length: 255)]
|
||||||
private ?string $email = null;
|
private ?string $email = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 255, nullable: true)]
|
||||||
|
private ?string $apiKey = null;
|
||||||
|
|
||||||
public function __toString(): string
|
public function __toString(): string
|
||||||
{
|
{
|
||||||
return $this->name ?? '';
|
return $this->name ?? '';
|
||||||
@ -132,4 +135,16 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
|||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getApiKey(): ?string
|
||||||
|
{
|
||||||
|
return $this->apiKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setApiKey(?string $apiKey): static
|
||||||
|
{
|
||||||
|
$this->apiKey = $apiKey;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
}
|
}
|
@ -3,22 +3,40 @@
|
|||||||
namespace App\Form;
|
namespace App\Form;
|
||||||
|
|
||||||
use App\Entity\Snip;
|
use App\Entity\Snip;
|
||||||
|
use App\Service\SnipParser\ParserFactory;
|
||||||
use Symfony\Component\Form\AbstractType;
|
use Symfony\Component\Form\AbstractType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||||
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
|
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||||
use Symfony\Component\Form\FormBuilderInterface;
|
use Symfony\Component\Form\FormBuilderInterface;
|
||||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||||
|
|
||||||
class SnipType extends AbstractType
|
class SnipType extends AbstractType
|
||||||
{
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ParserFactory $parserFactory,
|
||||||
|
) {}
|
||||||
|
|
||||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||||
{
|
{
|
||||||
$builder
|
$builder
|
||||||
->add('name')
|
->add('name')
|
||||||
|
->add('parser', ChoiceType::class, [
|
||||||
|
'choice_label' => fn(string $parser) => ucfirst($parser),
|
||||||
|
'choices' => $this->parserFactory->getChoices(),
|
||||||
|
])
|
||||||
->add('content', TextareaType::class, [
|
->add('content', TextareaType::class, [
|
||||||
'attr' => ['rows' => 20],
|
'attr' => ['rows' => 20],
|
||||||
'mapped' => false,
|
'mapped' => false,
|
||||||
])
|
])
|
||||||
->add('public')
|
->add('tags', TagsType::class)
|
||||||
|
->add('public', SwitchType::class)
|
||||||
|
->add('visible', SwitchType::class)
|
||||||
|
->add('contentName', TextType::class, [
|
||||||
|
'label' => 'Change description (optional)',
|
||||||
|
'mapped' => false,
|
||||||
|
'required' => false,
|
||||||
|
])
|
||||||
;
|
;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
22
src/Form/SwitchType.php
Normal file
22
src/Form/SwitchType.php
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Form;
|
||||||
|
|
||||||
|
use Symfony\Component\Form\AbstractType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
|
||||||
|
use Symfony\Component\Form\FormInterface;
|
||||||
|
use Symfony\Component\Form\FormView;
|
||||||
|
|
||||||
|
class SwitchType extends AbstractType
|
||||||
|
{
|
||||||
|
public function getParent(): string
|
||||||
|
{
|
||||||
|
return CheckboxType::class;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function buildView(FormView $view, FormInterface $form, array $options): void
|
||||||
|
{
|
||||||
|
$view->vars['label_attr']['class'] = trim(($view->vars['label_attr']['class'] ?? '') . ' checkbox-switch');
|
||||||
|
$view->vars['required'] = false;
|
||||||
|
}
|
||||||
|
}
|
93
src/Form/TagsType.php
Normal file
93
src/Form/TagsType.php
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Form;
|
||||||
|
|
||||||
|
use App\Entity\Tag;
|
||||||
|
use App\Repository\TagRepository;
|
||||||
|
use Doctrine\Common\Collections\Collection;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Symfony\Component\Form\AbstractType;
|
||||||
|
use Symfony\Component\Form\DataTransformerInterface;
|
||||||
|
use Symfony\Component\Form\Exception\TransformationFailedException;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||||
|
use Symfony\Component\Form\FormBuilderInterface;
|
||||||
|
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||||
|
use Symfony\Component\Validator\ConstraintViolationInterface;
|
||||||
|
use Symfony\Component\Validator\Validator\ValidatorInterface;
|
||||||
|
|
||||||
|
class TagsType extends AbstractType implements DataTransformerInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly TagRepository $repository,
|
||||||
|
private readonly Security $security,
|
||||||
|
private readonly ValidatorInterface $validator,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function transform($value): string
|
||||||
|
{
|
||||||
|
if ($value === null) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($value instanceof Collection) {
|
||||||
|
$value = $value->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_array($value)) {
|
||||||
|
$tags = array_map(fn(Tag $tag) => $tag->getName(), $value);
|
||||||
|
} else {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return implode(', ', $tags);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function reverseTransform($value): array
|
||||||
|
{
|
||||||
|
$tags = array_filter(array_map('trim', explode(',', $value)));
|
||||||
|
$user = $this->security->getUser();
|
||||||
|
|
||||||
|
$tagEntities = [];
|
||||||
|
foreach ($tags as $tag) {
|
||||||
|
$tagEntity = $this->repository->findOneBy(['name' => $tag, 'user' => $user]);
|
||||||
|
if ($tagEntity === null) {
|
||||||
|
$tagEntity = new Tag();
|
||||||
|
$tagEntity->setName($tag)->setUser($user);
|
||||||
|
|
||||||
|
// Validate the new Tag entity
|
||||||
|
$errors = $this->validator->validate($tagEntity);
|
||||||
|
if (count($errors) > 0) {
|
||||||
|
$exception = new TransformationFailedException();
|
||||||
|
$exception->setInvalidMessage(implode(', ', array_map(
|
||||||
|
fn(ConstraintViolationInterface $error) => $error->getMessage(),
|
||||||
|
iterator_to_array($errors)
|
||||||
|
)));
|
||||||
|
throw $exception;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->repository->save($tagEntity);
|
||||||
|
}
|
||||||
|
$tagEntities[] = $tagEntity;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $tagEntities;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||||
|
{
|
||||||
|
$builder->addModelTransformer($this);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function configureOptions(OptionsResolver $resolver): void
|
||||||
|
{
|
||||||
|
$resolver->setDefaults([
|
||||||
|
'data_class' => null, // No specific entity class
|
||||||
|
'label' => 'Tags (comma-separated)',
|
||||||
|
'required' => false,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getParent(): string
|
||||||
|
{
|
||||||
|
return TextType::class;
|
||||||
|
}
|
||||||
|
}
|
@ -1,13 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Git;
|
|
||||||
|
|
||||||
use CzProject\GitPhp\Git;
|
|
||||||
|
|
||||||
class CustomGit extends Git
|
|
||||||
{
|
|
||||||
public function open($directory): CustomGitRepository
|
|
||||||
{
|
|
||||||
return new CustomGitRepository($directory, $this->runner);
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,33 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Git;
|
|
||||||
|
|
||||||
use CzProject\GitPhp\GitRepository;
|
|
||||||
use DateTime;
|
|
||||||
|
|
||||||
class CustomGitRepository extends GitRepository
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @return array<SimpleCommit>
|
|
||||||
* @throws \CzProject\GitPhp\GitException
|
|
||||||
*/
|
|
||||||
public function getAllCommits(): array
|
|
||||||
{
|
|
||||||
$result = $this->run('log', '--pretty=%H,%cI');
|
|
||||||
|
|
||||||
if (empty($result->getOutput())) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$commits = [];
|
|
||||||
foreach ($result->getOutput() as $line) {
|
|
||||||
$parts = explode(',', $line);
|
|
||||||
$commits[] = new SimpleCommit(
|
|
||||||
$parts[0],
|
|
||||||
new DateTime($parts[1])
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $commits;
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,26 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Git;
|
|
||||||
|
|
||||||
use DateTime;
|
|
||||||
|
|
||||||
class SimpleCommit
|
|
||||||
{
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
private readonly string $hash,
|
|
||||||
private readonly DateTime $date,
|
|
||||||
)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getHash(): string
|
|
||||||
{
|
|
||||||
return $this->hash;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getDate(): DateTime
|
|
||||||
{
|
|
||||||
return $this->date;
|
|
||||||
}
|
|
||||||
}
|
|
0
src/Repository/.gitignore
vendored
0
src/Repository/.gitignore
vendored
@ -2,10 +2,12 @@
|
|||||||
|
|
||||||
namespace App\Repository;
|
namespace App\Repository;
|
||||||
|
|
||||||
|
use App\Dto\SnipFilterRequest;
|
||||||
use App\Entity\Snip;
|
use App\Entity\Snip;
|
||||||
use App\Entity\User;
|
use App\Entity\User;
|
||||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
use Doctrine\Persistence\ManagerRegistry;
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
use Symfony\Component\Security\Core\User\UserInterface;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @extends ServiceEntityRepository<Snip>
|
* @extends ServiceEntityRepository<Snip>
|
||||||
@ -40,24 +42,72 @@ class SnipRepository extends ServiceEntityRepository
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function findByUser(User $user): array
|
public function findByRequest(UserInterface $user, SnipFilterRequest $request): array
|
||||||
{
|
{
|
||||||
$qb = $this->createQueryBuilder('s');
|
$qb = $this
|
||||||
$qb->where('s.createdBy = :user')
|
->createQueryBuilder('s')
|
||||||
|
->where('s.createdBy = :user')
|
||||||
->setParameter('user', $user)
|
->setParameter('user', $user)
|
||||||
->orderBy('s.createdAt', 'DESC');
|
;
|
||||||
|
|
||||||
|
$showArchived = false;
|
||||||
|
switch ($request->visibility) {
|
||||||
|
case SnipFilterRequest::VISIBILITY_ALL:
|
||||||
|
break;
|
||||||
|
case SnipFilterRequest::VISIBILITY_VISIBLE:
|
||||||
|
$qb->andWhere('s.visible = true');
|
||||||
|
break;
|
||||||
|
case SnipFilterRequest::VISIBILITY_HIDDEN:
|
||||||
|
$qb->andWhere('s.visible = false');
|
||||||
|
break;
|
||||||
|
case SnipFilterRequest::VISIBILITY_ARCHIVED:
|
||||||
|
$showArchived = true;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new \InvalidArgumentException('Invalid visibility option: ', $request->visibility);
|
||||||
|
}
|
||||||
|
$qb->andWhere('s.archived = ' . ($showArchived ? 'true' : 'false'));
|
||||||
|
|
||||||
|
switch ($request->sort) {
|
||||||
|
case SnipFilterRequest::SORT_NAME:
|
||||||
|
$qb->orderBy('s.name', 'ASC');
|
||||||
|
break;
|
||||||
|
case SnipFilterRequest::SORT_DATE:
|
||||||
|
$qb->orderBy('s.createdAt', 'DESC');
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new \InvalidArgumentException('Invalid sort option: ', $request->sort);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->tag === 'none') {
|
||||||
|
$qb->andWhere('s.tags IS EMPTY');
|
||||||
|
} elseif ($request->tag === 'all') {
|
||||||
|
// No filter needed
|
||||||
|
} else {
|
||||||
|
$qb->innerJoin('s.tags', 't')
|
||||||
|
->andWhere('t.name = :tag')
|
||||||
|
->setParameter('tag', $request->tag)
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
return $qb->getQuery()->getResult();
|
return $qb->getQuery()->getResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function findPublic(User $user): array
|
public function findPublic(?User $user = null): array
|
||||||
{
|
{
|
||||||
$qb = $this->createQueryBuilder('s');
|
$qb = $this
|
||||||
$qb->where('s.public = :public')
|
->createQueryBuilder('s')
|
||||||
->andWhere('s.createdBy != :user')
|
->where('s.public = true')
|
||||||
->setParameter('public', true)
|
->andWhere('s.visible = true')
|
||||||
->setParameter('user', $user)
|
->andWhere('s.archived = false')
|
||||||
->orderBy('s.createdAt', 'DESC');
|
->orderBy('s.createdAt', 'DESC')
|
||||||
|
;
|
||||||
|
|
||||||
|
if ($user) {
|
||||||
|
$qb->andWhere('s.createdBy != :user')
|
||||||
|
->setParameter('user', $user)
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
return $qb->getQuery()->getResult();
|
return $qb->getQuery()->getResult();
|
||||||
}
|
}
|
||||||
|
30
src/Repository/TagRepository.php
Normal file
30
src/Repository/TagRepository.php
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Repository;
|
||||||
|
|
||||||
|
use App\Entity\Tag;
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
use Symfony\Component\Security\Core\User\UserInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends ServiceEntityRepository<Tag>
|
||||||
|
*/
|
||||||
|
class TagRepository extends ServiceEntityRepository
|
||||||
|
{
|
||||||
|
public function __construct(ManagerRegistry $registry)
|
||||||
|
{
|
||||||
|
parent::__construct($registry, Tag::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function save(Tag $tag): void
|
||||||
|
{
|
||||||
|
$this->getEntityManager()->persist($tag);
|
||||||
|
$this->getEntityManager()->flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function findAllByUser(UserInterface $user): array
|
||||||
|
{
|
||||||
|
return $this->findBy(['user' => $user]);
|
||||||
|
}
|
||||||
|
}
|
68
src/Security/TokenAuthenticator.php
Normal file
68
src/Security/TokenAuthenticator.php
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Security;
|
||||||
|
|
||||||
|
use App\Entity\User;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||||
|
use Symfony\Component\Security\Core\Exception\AuthenticationException;
|
||||||
|
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
|
||||||
|
use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator;
|
||||||
|
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
|
||||||
|
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
|
||||||
|
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;
|
||||||
|
|
||||||
|
class TokenAuthenticator extends AbstractAuthenticator
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly EntityManagerInterface $em,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called on every request to decide if this authenticator should be
|
||||||
|
* used for the request. Returning false will cause this authenticator
|
||||||
|
* to be skipped.
|
||||||
|
*/
|
||||||
|
public function supports(Request $request): ?bool
|
||||||
|
{
|
||||||
|
return $request->headers->has('X-AUTH-TOKEN');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function authenticate(Request $request): Passport
|
||||||
|
{
|
||||||
|
$apiToken = $request->headers->get('X-AUTH-TOKEN');
|
||||||
|
if (empty($apiToken)) {
|
||||||
|
// The token header was empty, authentication fails with HTTP Status
|
||||||
|
// Code 401 "Unauthorized"
|
||||||
|
throw new CustomUserMessageAuthenticationException('No API token provided');
|
||||||
|
}
|
||||||
|
|
||||||
|
return new SelfValidatingPassport(
|
||||||
|
new UserBadge($apiToken, function ($apiToken) {
|
||||||
|
return $this->em->getRepository(User::class)->findOneBy(['apiKey' => $apiToken]);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
|
||||||
|
{
|
||||||
|
// on success, let the request continue
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
|
||||||
|
{
|
||||||
|
$data = [
|
||||||
|
// you may want to customize or obfuscate the message first
|
||||||
|
'message' => strtr($exception->getMessageKey(), $exception->getMessageData()),
|
||||||
|
|
||||||
|
// or to translate this message
|
||||||
|
// $this->translator->trans($exception->getMessageKey(), $exception->getMessageData())
|
||||||
|
];
|
||||||
|
|
||||||
|
return new JsonResponse($data, Response::HTTP_UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
}
|
@ -24,13 +24,14 @@ class SnipVoter extends Voter
|
|||||||
{
|
{
|
||||||
/** @var Snip $subject */
|
/** @var Snip $subject */
|
||||||
|
|
||||||
|
$user = $token->getUser();
|
||||||
|
|
||||||
switch ($attribute) {
|
switch ($attribute) {
|
||||||
case self::VIEW:
|
case self::VIEW:
|
||||||
if ($subject->isPublic()) {
|
if ($subject->isPublic()) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
case self::EDIT:
|
case self::EDIT:
|
||||||
$user = $token->getUser();
|
|
||||||
if (!$user instanceof UserInterface) {
|
if (!$user instanceof UserInterface) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
@ -3,6 +3,7 @@
|
|||||||
namespace App\Service;
|
namespace App\Service;
|
||||||
|
|
||||||
use JetBrains\PhpStorm\ArrayShape;
|
use JetBrains\PhpStorm\ArrayShape;
|
||||||
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
|
|
||||||
class LastRelease
|
class LastRelease
|
||||||
{
|
{
|
||||||
@ -18,7 +19,7 @@ class LastRelease
|
|||||||
])]
|
])]
|
||||||
private array $lastRelease = [];
|
private array $lastRelease = [];
|
||||||
|
|
||||||
public function __construct(string $jsonFile)
|
public function __construct(#[Autowire('%kernel.project_dir%/release.json')] string $jsonFile)
|
||||||
{
|
{
|
||||||
if (file_exists($jsonFile)) {
|
if (file_exists($jsonFile)) {
|
||||||
$this->lastRelease = json_decode(file_get_contents($jsonFile), true);
|
$this->lastRelease = json_decode(file_get_contents($jsonFile), true);
|
||||||
|
65
src/Service/RequestDtoCache.php
Normal file
65
src/Service/RequestDtoCache.php
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Service;
|
||||||
|
|
||||||
|
use App\Dto\CachableDtoInterface;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use ReflectionClass;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpKernel\Controller\ValueResolverInterface;
|
||||||
|
use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata;
|
||||||
|
|
||||||
|
class RequestDtoCache implements ValueResolverInterface
|
||||||
|
{
|
||||||
|
private const string SESSION_CACHE_PREFIX = 'dto.';
|
||||||
|
|
||||||
|
public function resolve(Request $request, ArgumentMetadata $argument): iterable
|
||||||
|
{
|
||||||
|
$session = $request->getSession();
|
||||||
|
$className = $argument->getType();
|
||||||
|
if (!$className || !is_subclass_of($className, CachableDtoInterface::class)
|
||||||
|
) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$reflection = new ReflectionClass($className);
|
||||||
|
$constructor = $reflection->getConstructor();
|
||||||
|
|
||||||
|
if (!$constructor) {
|
||||||
|
return []; // No constructor: return empty instance
|
||||||
|
}
|
||||||
|
|
||||||
|
$cacheKey = self::SESSION_CACHE_PREFIX . (implode('.', [
|
||||||
|
$argument->getControllerName(),
|
||||||
|
$argument->getName(),
|
||||||
|
$className,
|
||||||
|
]));
|
||||||
|
|
||||||
|
$cacheData = $session->get($cacheKey, []);
|
||||||
|
$queryData = $request->query->all();
|
||||||
|
$params = $constructor->getParameters();
|
||||||
|
|
||||||
|
if (isset($queryData['reset'])) {
|
||||||
|
$queryData = $cacheData = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$args = [];
|
||||||
|
foreach ($params as $param) {
|
||||||
|
$name = $param->getName();
|
||||||
|
if (array_key_exists($name, $queryData)) {
|
||||||
|
$args[$name] = $queryData[$name];
|
||||||
|
} elseif (array_key_exists($name, $cacheData)) {
|
||||||
|
$args[$name] = $cacheData[$name];
|
||||||
|
} elseif ($param->isDefaultValueAvailable()) {
|
||||||
|
$args[$name] = $param->getDefaultValue();
|
||||||
|
} else {
|
||||||
|
throw new InvalidArgumentException(sprintf('Missing required parameter "%s" for class "%s"', $name, $className));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store the cache data in the session
|
||||||
|
$session->set($cacheKey, $args);
|
||||||
|
|
||||||
|
yield $reflection->newInstanceArgs($args);
|
||||||
|
}
|
||||||
|
}
|
15
src/Service/SnipContent/DiffTypeEnum.php
Normal file
15
src/Service/SnipContent/DiffTypeEnum.php
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Service\SnipContent;
|
||||||
|
|
||||||
|
enum DiffTypeEnum: string
|
||||||
|
{
|
||||||
|
case INSERT = 'I';
|
||||||
|
case DELETE = 'D';
|
||||||
|
case KEEP = 'K';
|
||||||
|
|
||||||
|
public function is(string $diffType): bool
|
||||||
|
{
|
||||||
|
return $this->value === $diffType;
|
||||||
|
}
|
||||||
|
}
|
16
src/Service/SnipContent/Lexer.php
Normal file
16
src/Service/SnipContent/Lexer.php
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Service\SnipContent;
|
||||||
|
|
||||||
|
class Lexer
|
||||||
|
{
|
||||||
|
public static function tokenize(string $text): array {
|
||||||
|
$text = str_replace("\r", '', $text);
|
||||||
|
return explode(PHP_EOL, $text);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function reconstruct(array $tokens): string
|
||||||
|
{
|
||||||
|
return implode(PHP_EOL, $tokens);
|
||||||
|
}
|
||||||
|
}
|
222
src/Service/SnipContent/MyersDiff.php
Normal file
222
src/Service/SnipContent/MyersDiff.php
Normal file
@ -0,0 +1,222 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Service\SnipContent;
|
||||||
|
|
||||||
|
class MyersDiff
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Backtrack through the intermediate results to extract the "snakes" that
|
||||||
|
* are visited on the chosen "D-path".
|
||||||
|
*
|
||||||
|
* @param string[] $v_save Intermediate results
|
||||||
|
* @param int $x End position
|
||||||
|
* @param int $y End position
|
||||||
|
*
|
||||||
|
* @return int[][]
|
||||||
|
*/
|
||||||
|
private static function extractSnakes(array $v_save, int $x, int $y): array
|
||||||
|
{
|
||||||
|
$snakes = [];
|
||||||
|
for ($d = count($v_save) - 1; $x >= 0 && $y >= 0; $d--) {
|
||||||
|
array_unshift($snakes, [$x, $y]);
|
||||||
|
|
||||||
|
$v = $v_save[$d];
|
||||||
|
$k = $x - $y;
|
||||||
|
|
||||||
|
if ($k === -$d || $k !== $d && $v[$k - 1] < $v[$k + 1]) {
|
||||||
|
$k_prev = $k + 1;
|
||||||
|
} else {
|
||||||
|
$k_prev = $k - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
$x = $v[$k_prev];
|
||||||
|
$y = $x - $k_prev;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $snakes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function formatCompact(array $snakes, array $b): array
|
||||||
|
{
|
||||||
|
$solution = [];
|
||||||
|
$x = 0;
|
||||||
|
$y = 0;
|
||||||
|
|
||||||
|
foreach ($snakes as $snake) {
|
||||||
|
// Deletions
|
||||||
|
while ($snake[0] - $snake[1] > $x - $y) {
|
||||||
|
$count = 0;
|
||||||
|
while ($snake[0] - $snake[1] > $x - $y) {
|
||||||
|
$x++;
|
||||||
|
$count++;
|
||||||
|
}
|
||||||
|
$solution[] = [DiffTypeEnum::DELETE->value, $count];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insertions
|
||||||
|
while ($snake[0] - $snake[1] < $x - $y) {
|
||||||
|
$values = [];
|
||||||
|
while ($snake[0] - $snake[1] < $x - $y) {
|
||||||
|
$values[] = $b[$y];
|
||||||
|
$y++;
|
||||||
|
}
|
||||||
|
$solutionKey = count($solution) - 1;
|
||||||
|
if ($solutionKey >= 0 && DiffTypeEnum::INSERT->is($solution[$solutionKey][0])) {
|
||||||
|
$solution[$solutionKey][1] = array_merge($solution[$solutionKey][1], $values);
|
||||||
|
} else {
|
||||||
|
$solution[] = [DiffTypeEnum::INSERT->value, $values];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keeps (snake diagonals)
|
||||||
|
$count = 0;
|
||||||
|
while ($x < $snake[0]) {
|
||||||
|
$x++;
|
||||||
|
$y++;
|
||||||
|
$count++;
|
||||||
|
}
|
||||||
|
if ($count > 0) {
|
||||||
|
$solution[] = [DiffTypeEnum::KEEP->value, $count];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $solution;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate the shortest edit sequence to convert $x into $y.
|
||||||
|
*
|
||||||
|
* @param string|array $textFrom - tokens (characters, words or lines)
|
||||||
|
* @param string|array $textTo - tokens (characters, words or lines)
|
||||||
|
* @param ?callable $compare - comparison function for tokens. Signature is compare($x, $y):bool. If null, === is used.
|
||||||
|
*
|
||||||
|
* @return array[] - pairs of token and edit (-1 for delete, 0 for keep, +1 for insert)
|
||||||
|
*/
|
||||||
|
public static function calculate(string|array $textFrom, string|array $textTo, ?callable $compare = null): array
|
||||||
|
{
|
||||||
|
if (is_string($textFrom)) {
|
||||||
|
$a = Lexer::tokenize($textFrom);
|
||||||
|
} else {
|
||||||
|
$a = $textFrom;
|
||||||
|
}
|
||||||
|
if (is_string($textTo)) {
|
||||||
|
$b = Lexer::tokenize($textTo);
|
||||||
|
} else {
|
||||||
|
$b = $textTo;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($compare === null) {
|
||||||
|
$compare = fn($x, $y) => $x === $y;
|
||||||
|
}
|
||||||
|
|
||||||
|
$n = count($a);
|
||||||
|
$m = count($b);
|
||||||
|
$a = array_values($a);
|
||||||
|
$b = array_values($b);
|
||||||
|
$max = $m + $n;
|
||||||
|
|
||||||
|
$v_save = [];
|
||||||
|
|
||||||
|
$v = [1 => 0];
|
||||||
|
for ($d = 0; $d <= $max; $d++) {
|
||||||
|
for ($k = -$d; $k <= $d; $k += 2) {
|
||||||
|
if ($k === -$d || $k !== $d && $v[$k - 1] < $v[$k + 1]) {
|
||||||
|
$x = $v[$k + 1];
|
||||||
|
} else {
|
||||||
|
$x = $v[$k - 1] + 1;
|
||||||
|
}
|
||||||
|
$y = $x - $k;
|
||||||
|
while ($x < $n && $y < $m && $compare($a[$x], $b[$y])) {
|
||||||
|
$x++;
|
||||||
|
$y++;
|
||||||
|
}
|
||||||
|
$v[$k] = $x;
|
||||||
|
$v_save[$d] = $v;
|
||||||
|
if ($x === $n && $y === $m) {
|
||||||
|
break 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::formatCompact(self::extractSnakes($v_save, $n, $m), $b);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function rebuildBFromCompact(string $textFrom, array $diff): string
|
||||||
|
{
|
||||||
|
$a = Lexer::tokenize($textFrom);
|
||||||
|
$b = [];
|
||||||
|
$x = 0;
|
||||||
|
|
||||||
|
foreach ($diff as [$op, $data]) {
|
||||||
|
switch ($op) {
|
||||||
|
case DiffTypeEnum::KEEP->value:
|
||||||
|
for ($i = 0; $i < $data; $i++) {
|
||||||
|
$b[] = $a[$x++];
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case DiffTypeEnum::DELETE->value:
|
||||||
|
$x += $data; // skip deleted
|
||||||
|
break;
|
||||||
|
case DiffTypeEnum::INSERT->value:
|
||||||
|
foreach ($data as $v) {
|
||||||
|
$b[] = $v;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new \InvalidArgumentException('Invalid diff operation');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Lexer::reconstruct($b);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function buildDiffLines(string $textFrom, string $textTo): array
|
||||||
|
{
|
||||||
|
$a = Lexer::tokenize($textFrom);
|
||||||
|
$b = Lexer::tokenize($textTo);
|
||||||
|
$diff = MyersDiff::calculate($a, $b);
|
||||||
|
|
||||||
|
$lines = [];
|
||||||
|
$x = 0;
|
||||||
|
foreach ($diff as [$op, $data]) {
|
||||||
|
switch ($op) {
|
||||||
|
case DiffTypeEnum::KEEP->value:
|
||||||
|
for ($i = 0; $i < $data; $i++) {
|
||||||
|
$lines[] = [
|
||||||
|
'line' => $x,
|
||||||
|
'type' => 'keep',
|
||||||
|
'from' => $a[$x],
|
||||||
|
'to' => $a[$x],
|
||||||
|
];
|
||||||
|
$x++;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case DiffTypeEnum::DELETE->value:
|
||||||
|
for ($i = 0; $i < $data; $i++) {
|
||||||
|
$lines[] = [
|
||||||
|
'line' => $x,
|
||||||
|
'type' => 'delete',
|
||||||
|
'from' => $a[$x],
|
||||||
|
'to' => '',
|
||||||
|
];
|
||||||
|
$x++;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case DiffTypeEnum::INSERT->value:
|
||||||
|
foreach ($data as $v) {
|
||||||
|
$lines[] = [
|
||||||
|
'line' => $x,
|
||||||
|
'type' => 'insert',
|
||||||
|
'from' => '',
|
||||||
|
'to' => $v,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new \InvalidArgumentException('Invalid diff operation');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $lines;
|
||||||
|
}
|
||||||
|
}
|
@ -1,74 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Service\SnipContent;
|
|
||||||
|
|
||||||
use App\Entity\Snip;
|
|
||||||
use App\Entity\SnipContent;
|
|
||||||
use App\Entity\User;
|
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
|
||||||
|
|
||||||
readonly class SnipContentDB implements SnipContentInterface
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private Snip $snip,
|
|
||||||
private User $user,
|
|
||||||
private EntityManagerInterface $em,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function update(string $snipContents): void
|
|
||||||
{
|
|
||||||
// Create new snipContent entity with previous one as parent
|
|
||||||
$content = new SnipContent();
|
|
||||||
$content
|
|
||||||
->setText($snipContents)
|
|
||||||
->setSnip($this->snip)
|
|
||||||
;
|
|
||||||
if ($this->snip->getSnipContents()->count() > 0) {
|
|
||||||
$content->setParent($this->snip->getSnipContents()->last());
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->em->persist($content);
|
|
||||||
$this->em->flush();
|
|
||||||
|
|
||||||
$this->snip->setActiveCommit($content->getId());
|
|
||||||
$this->em->persist($this->snip);
|
|
||||||
$this->em->flush();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function get(): string
|
|
||||||
{
|
|
||||||
$contentRepo = $this->em->getRepository(SnipContent::class);
|
|
||||||
return $contentRepo->find($this->snip->getActiveCommit())->getText();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getVersions(): array
|
|
||||||
{
|
|
||||||
// Return all snipContent entities (by parent)
|
|
||||||
return array_map(fn(SnipContent $content) => [
|
|
||||||
'id' => (string)$content->getId(),
|
|
||||||
'name' => $content->getId()->getDateTime()->format('Y-m-d H:i:s'),
|
|
||||||
], $this->snip->getSnipContents()->toArray());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setVersion(string $version): void
|
|
||||||
{
|
|
||||||
$this->snip->setActiveCommit($version);
|
|
||||||
$this->em->persist($this->snip);
|
|
||||||
$this->em->flush();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getCommit(): string
|
|
||||||
{
|
|
||||||
return $this->snip->getActiveCommit();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function delete(): void
|
|
||||||
{
|
|
||||||
// Cleanup the history
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getLatestVersion(): string
|
|
||||||
{
|
|
||||||
return $this->snip->getSnipContents()->last()->getId();
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,80 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Service\SnipContent;
|
|
||||||
|
|
||||||
use App\Entity\User;
|
|
||||||
use App\Git\CustomGitRepository;
|
|
||||||
use App\Git\SimpleCommit;
|
|
||||||
use Symfony\Component\Security\Core\User\UserInterface;
|
|
||||||
|
|
||||||
class SnipContentGit implements SnipContentInterface
|
|
||||||
{
|
|
||||||
private const string SNIP_FILE_NAME = 'snip.txt';
|
|
||||||
private const string MASTER_BRANCH_NAME = 'master';
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
private readonly CustomGitRepository $repo,
|
|
||||||
private readonly ?User $user,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
private function snipExists(): bool
|
|
||||||
{
|
|
||||||
return file_exists($this->getSnipPath());
|
|
||||||
}
|
|
||||||
|
|
||||||
private function getSnipPath(): string
|
|
||||||
{
|
|
||||||
return sprintf('%s/snip.txt', $this->repo->getRepositoryPath());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function update(string $snipContents): void
|
|
||||||
{
|
|
||||||
if (!$this->user instanceof UserInterface) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if ($this->repo->getCurrentBranchName() !== self::MASTER_BRANCH_NAME) {
|
|
||||||
$this->repo->checkout(self::MASTER_BRANCH_NAME);
|
|
||||||
}
|
|
||||||
file_put_contents($this->getSnipPath(), $snipContents);
|
|
||||||
$this->repo->addFile(self::SNIP_FILE_NAME);
|
|
||||||
if ($this->repo->hasChanges()) {
|
|
||||||
$this->repo->commit(sprintf('Updated snip at %s by %s', date('Y-m-d H:i:s'), $this->user));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function get(): string
|
|
||||||
{
|
|
||||||
if (!$this->snipExists()) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
return file_get_contents($this->getSnipPath());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getVersions(): array
|
|
||||||
{
|
|
||||||
return array_map(fn(SimpleCommit $c) => [
|
|
||||||
'id' => $c->getHash(),
|
|
||||||
'name' => $c->getDate()->format('Y-m-d H:i:s'),
|
|
||||||
], $this->repo->getAllCommits());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setVersion(string $version): void
|
|
||||||
{
|
|
||||||
$this->repo->checkout($version);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getCommit(): string
|
|
||||||
{
|
|
||||||
return $this->repo->getCurrentBranchName();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function delete(): void
|
|
||||||
{
|
|
||||||
system("rm -rf " . escapeshellarg($this->repo->getRepositoryPath()));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getLatestVersion(): string
|
|
||||||
{
|
|
||||||
return self::MASTER_BRANCH_NAME;
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,21 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Service\SnipContent;
|
|
||||||
|
|
||||||
interface SnipContentInterface
|
|
||||||
{
|
|
||||||
public function update(string $snipContents): void;
|
|
||||||
|
|
||||||
public function get(): string;
|
|
||||||
|
|
||||||
/** @return array{id: string, name: string} */
|
|
||||||
public function getVersions(): array;
|
|
||||||
|
|
||||||
public function setVersion(string $version): void;
|
|
||||||
|
|
||||||
public function getCommit(): string;
|
|
||||||
|
|
||||||
public function getLatestVersion(): string;
|
|
||||||
|
|
||||||
public function delete(): void;
|
|
||||||
}
|
|
97
src/Service/SnipContent/SnipContentService.php
Normal file
97
src/Service/SnipContent/SnipContentService.php
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Service\SnipContent;
|
||||||
|
|
||||||
|
use App\Entity\Snip;
|
||||||
|
use App\Entity\SnipContent;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
|
||||||
|
readonly class SnipContentService
|
||||||
|
{
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private EntityManagerInterface $em,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function update(Snip $snip, string $contents, ?string $contentName): void
|
||||||
|
{
|
||||||
|
$parentContent = $snip->getActiveVersion();
|
||||||
|
if (self::rebuildText($parentContent) === $contents) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new snipContent entity with previous one as parent
|
||||||
|
$content = new SnipContent();
|
||||||
|
$content
|
||||||
|
->setText($contents)
|
||||||
|
->setSnip($snip)
|
||||||
|
->setName($contentName)
|
||||||
|
;
|
||||||
|
if ($parentContent !== null) {
|
||||||
|
$content->setParent($parentContent);
|
||||||
|
$this->contentToRelative($parentContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->em->persist($content);
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
$snip->setActiveVersion($content);
|
||||||
|
$this->em->persist($snip);
|
||||||
|
$this->em->flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function rebuildText(?SnipContent $snipContent): string
|
||||||
|
{
|
||||||
|
if ($snipContent === null) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if ($snipContent->getText()) {
|
||||||
|
return $snipContent->getText();
|
||||||
|
}
|
||||||
|
|
||||||
|
$parentContent = $snipContent->getParent();
|
||||||
|
if ($parentContent === null && $snipContent->getDiff() === null) {
|
||||||
|
return '---Something went very wrong, cant rebuild the text---';
|
||||||
|
}
|
||||||
|
|
||||||
|
return MyersDiff::rebuildBFromCompact(
|
||||||
|
self::rebuildText($parentContent), $snipContent->getDiff()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setVersion(Snip $snip, SnipContent $version): void
|
||||||
|
{
|
||||||
|
$activeVersion = $snip->getActiveVersion();
|
||||||
|
$this->contentToAbsolute($version);
|
||||||
|
$this->contentToRelative($activeVersion);
|
||||||
|
|
||||||
|
$snip->setActiveVersion($version);
|
||||||
|
$this->em->persist($snip);
|
||||||
|
$this->em->flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function contentToRelative(SnipContent $content): void
|
||||||
|
{
|
||||||
|
if ($content->getText() === null || $content->getParent() === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$contentText = $content->getText();
|
||||||
|
$parentText = self::rebuildText($content->getParent());
|
||||||
|
$diff = MyersDiff::calculate($parentText, $contentText);
|
||||||
|
$content->setDiff($diff);
|
||||||
|
$content->setText(null);
|
||||||
|
$this->em->persist($content);
|
||||||
|
$this->em->flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function contentToAbsolute(SnipContent $content): void
|
||||||
|
{
|
||||||
|
if ($content->getDiff() === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$content->setText(self::rebuildText($content));
|
||||||
|
$content->setDiff(null);
|
||||||
|
$this->em->persist($content);
|
||||||
|
$this->em->flush();
|
||||||
|
}
|
||||||
|
}
|
28
src/Service/SnipParser/AbstractParser.php
Normal file
28
src/Service/SnipParser/AbstractParser.php
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Service\SnipParser;
|
||||||
|
|
||||||
|
abstract class AbstractParser implements ParserInterface
|
||||||
|
{
|
||||||
|
public static function getName(): string
|
||||||
|
{
|
||||||
|
$path = explode('\\', static::class);
|
||||||
|
return strtolower(str_replace('Parser', '', array_pop($path)));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function parseRaw(string $content): string
|
||||||
|
{
|
||||||
|
return $content;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function parseView(string $content): string
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
return $this->safeParseView($content);
|
||||||
|
} catch (\Exception $exception) {
|
||||||
|
return sprintf('<pre><code>%s</code></pre>', htmlspecialchars($exception->getMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract function safeParseView(string $content): string;
|
||||||
|
}
|
30
src/Service/SnipParser/Generic/GenericParser.php
Normal file
30
src/Service/SnipParser/Generic/GenericParser.php
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Service\SnipParser\Generic;
|
||||||
|
|
||||||
|
use App\Service\SnipParser\AbstractParser;
|
||||||
|
use League\Pipeline\PipelineBuilder;
|
||||||
|
|
||||||
|
class GenericParser extends AbstractParser
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly UrlReferenceStage $referenceStage,
|
||||||
|
private readonly IncludeReferenceStage $includeStage,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function safeParseView(string $content): string
|
||||||
|
{
|
||||||
|
$builder = new PipelineBuilder();
|
||||||
|
$pipeline = $builder
|
||||||
|
->add(new HtmlEscapeStage())
|
||||||
|
// ->add(new ReplaceBlocksStage('<pre>', '</pre>', '```'))
|
||||||
|
// ->add(new ReplaceBlocksStage('<code>', '</code>', '``'))
|
||||||
|
->add(new ReplaceStage(PHP_EOL, '<br>'))
|
||||||
|
->add($this->referenceStage)
|
||||||
|
->add($this->includeStage)
|
||||||
|
->build()
|
||||||
|
;
|
||||||
|
|
||||||
|
return $pipeline->process($content);
|
||||||
|
}
|
||||||
|
}
|
@ -1,6 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Service\SnipParser\Stages;
|
namespace App\Service\SnipParser\Generic;
|
||||||
|
|
||||||
use League\Pipeline\StageInterface;
|
use League\Pipeline\StageInterface;
|
||||||
|
|
59
src/Service/SnipParser/Generic/IncludeReferenceStage.php
Normal file
59
src/Service/SnipParser/Generic/IncludeReferenceStage.php
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Service\SnipParser\Generic;
|
||||||
|
|
||||||
|
use App\Repository\SnipContentRepository;
|
||||||
|
use App\Repository\SnipRepository;
|
||||||
|
use App\Security\Voter\SnipVoter;
|
||||||
|
use App\Service\SnipContent\SnipContentService;
|
||||||
|
use League\Pipeline\StageInterface;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
|
|
||||||
|
class IncludeReferenceStage implements StageInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
#[Autowire(lazy: true)] private readonly Security $security,
|
||||||
|
#[Autowire(lazy: true)] private readonly SnipRepository $snipRepository,
|
||||||
|
#[Autowire(lazy: true)] private readonly SnipContentRepository $snipContentRepository,
|
||||||
|
#[Autowire(lazy: true)] private readonly GenericParser $pipeline,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function __invoke(mixed $payload): string
|
||||||
|
{
|
||||||
|
return $this->replaceReferences($payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function replaceReferences(mixed $payload): string
|
||||||
|
{
|
||||||
|
// replaces all references ({{ID}}) with the content of the snip
|
||||||
|
$pattern = '/\{\{([A-Z0-9]+)\}\}/';
|
||||||
|
|
||||||
|
return preg_replace_callback($pattern, function ($matches) {
|
||||||
|
$id = $matches[1];
|
||||||
|
try {
|
||||||
|
$content = $this->snipContentRepository->find($id);
|
||||||
|
} catch (\Exception) {
|
||||||
|
$content = null;
|
||||||
|
}
|
||||||
|
if ($content) {
|
||||||
|
$snip = $content->getSnip();
|
||||||
|
} else {
|
||||||
|
$snip = $this->snipRepository->find($id);
|
||||||
|
if ($snip) {
|
||||||
|
$content = $this->snipContentRepository->find($snip->getActiveVersion());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($content === null) {
|
||||||
|
return sprintf('<span title="snip or content not found">%s</span>', $matches[0]);
|
||||||
|
}
|
||||||
|
if (!$this->security->isGranted(SnipVoter::VIEW, $snip)) {
|
||||||
|
return sprintf('<span title="access denied">%s</span>', $matches[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->pipeline->parseView(
|
||||||
|
SnipContentService::rebuildText($content)
|
||||||
|
);
|
||||||
|
}, $payload);
|
||||||
|
}
|
||||||
|
}
|
@ -1,16 +1,17 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Service\SnipParser\Stages;
|
namespace App\Service\SnipParser\Generic;
|
||||||
|
|
||||||
use InvalidArgumentException;
|
use InvalidArgumentException;
|
||||||
use League\Pipeline\StageInterface;
|
use League\Pipeline\StageInterface;
|
||||||
|
use Tempest\Highlight\Highlighter;
|
||||||
|
|
||||||
class ReplaceBlocksStage implements StageInterface
|
readonly class ReplaceBlocksStage implements StageInterface
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public readonly string $openTag = '<pre><code>',
|
public string $openTag = '<pre><code>',
|
||||||
public readonly string $closeTag = '</code></pre>',
|
public string $closeTag = '</code></pre>',
|
||||||
public readonly string $delimiter = '```'
|
public string $delimiter = '```'
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function __invoke(mixed $payload): string
|
public function __invoke(mixed $payload): string
|
||||||
@ -26,8 +27,9 @@ class ReplaceBlocksStage implements StageInterface
|
|||||||
{
|
{
|
||||||
$pattern = sprintf('/%s(.+?)%s/s', preg_quote($this->delimiter), preg_quote($this->delimiter));
|
$pattern = sprintf('/%s(.+?)%s/s', preg_quote($this->delimiter), preg_quote($this->delimiter));
|
||||||
|
|
||||||
return preg_replace_callback($pattern, function ($matches) {
|
$highlighter = new Highlighter()->withGutter();
|
||||||
return $this->openTag . trim($matches[1]) . $this->closeTag;
|
return preg_replace_callback($pattern, function ($matches) use ($highlighter) {
|
||||||
|
return $this->openTag . $highlighter->parse(trim($matches[1]), 'php') . $this->closeTag;
|
||||||
}, $text);
|
}, $text);
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -1,15 +1,15 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Service\SnipParser\Stages;
|
namespace App\Service\SnipParser\Generic;
|
||||||
|
|
||||||
use League\Pipeline\StageInterface;
|
use League\Pipeline\StageInterface;
|
||||||
|
|
||||||
class ReplaceStage implements StageInterface
|
readonly class ReplaceStage implements StageInterface
|
||||||
{
|
{
|
||||||
// replaces a string with another string
|
// replaces a string with another string
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public readonly string $search,
|
public string $search,
|
||||||
public readonly string $replace,
|
public string $replace,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function __invoke(mixed $payload): string
|
public function __invoke(mixed $payload): string
|
@ -1,6 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Service\SnipParser\Stages;
|
namespace App\Service\SnipParser\Generic;
|
||||||
|
|
||||||
use App\Repository\SnipRepository;
|
use App\Repository\SnipRepository;
|
||||||
use App\Security\Voter\SnipVoter;
|
use App\Security\Voter\SnipVoter;
|
||||||
@ -9,12 +9,12 @@ use Symfony\Bundle\SecurityBundle\Security;
|
|||||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||||
|
|
||||||
class UrlReferenceStage implements StageInterface
|
readonly class UrlReferenceStage implements StageInterface
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
#[Autowire(lazy: true)] private readonly UrlGeneratorInterface $router,
|
#[Autowire(lazy: true)] private UrlGeneratorInterface $router,
|
||||||
#[Autowire(lazy: true)] private readonly Security $security,
|
#[Autowire(lazy: true)] private Security $security,
|
||||||
#[Autowire(lazy: true)] private readonly SnipRepository $snipRepository,
|
#[Autowire(lazy: true)] private SnipRepository $snipRepository,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function __invoke(mixed $payload): string
|
public function __invoke(mixed $payload): string
|
||||||
@ -37,7 +37,7 @@ class UrlReferenceStage implements StageInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
$url = $this->router->generate('snip_single', ['snip' => $snip->getId()]);
|
$url = $this->router->generate('snip_single', ['snip' => $snip->getId()]);
|
||||||
return sprintf('<a href="%s" title="Owner: %s">#%s</a>', $url, $snip->getCreatedBy(), $snip->getId());
|
return sprintf('<a href="%s">%s</a>', $url, $snip);
|
||||||
}, $payload);
|
}, $payload);
|
||||||
}
|
}
|
||||||
}
|
}
|
16
src/Service/SnipParser/Html/HtmlParser.php
Normal file
16
src/Service/SnipParser/Html/HtmlParser.php
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Service\SnipParser\Html;
|
||||||
|
|
||||||
|
use App\Service\SnipParser\AbstractParser;
|
||||||
|
use Tempest\Highlight\Highlighter;
|
||||||
|
|
||||||
|
class HtmlParser extends AbstractParser
|
||||||
|
{
|
||||||
|
public function safeParseView(string $content): string
|
||||||
|
{
|
||||||
|
$highlighter = new Highlighter()->withGutter();
|
||||||
|
|
||||||
|
return '<pre data-lang="html" class="notranslate">' . $highlighter->parse($content, 'html') . '</pre>';
|
||||||
|
}
|
||||||
|
}
|
82
src/Service/SnipParser/Markdown/MarkdownParser.php
Normal file
82
src/Service/SnipParser/Markdown/MarkdownParser.php
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Service\SnipParser\Markdown;
|
||||||
|
|
||||||
|
use App\Repository\SnipRepository;
|
||||||
|
use App\Service\SnipParser\AbstractParser;
|
||||||
|
use League\CommonMark\Event\DocumentParsedEvent;
|
||||||
|
use League\CommonMark\Extension\CommonMark\Node\Inline\Link;
|
||||||
|
use League\CommonMark\Extension\DefaultAttributes\DefaultAttributesExtension;
|
||||||
|
use League\CommonMark\Extension\Footnote\FootnoteExtension;
|
||||||
|
use League\CommonMark\Extension\Table\Table;
|
||||||
|
use League\CommonMark\GithubFlavoredMarkdownConverter;
|
||||||
|
use League\CommonMark\Node\Inline\Text;
|
||||||
|
use League\CommonMark\Node\Query;
|
||||||
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
|
use Symfony\Component\Routing\RouterInterface;
|
||||||
|
use Tempest\Highlight\CommonMark\HighlightExtension;
|
||||||
|
use Tempest\Highlight\Highlighter;
|
||||||
|
|
||||||
|
class MarkdownParser extends AbstractParser
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
#[Autowire(lazy: true)] private readonly RouterInterface $router,
|
||||||
|
#[Autowire(lazy: true)] private readonly SnipRepository $snipRepo,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function safeParseView(string $content): string
|
||||||
|
{
|
||||||
|
$config = [
|
||||||
|
'default_attributes' => [
|
||||||
|
Table::class => [
|
||||||
|
'class' => 'table table-hover',
|
||||||
|
],
|
||||||
|
Link::class => [
|
||||||
|
'class' => 'btn btn-sm btn-secondary',
|
||||||
|
'target' => '_blank',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$converter = new GithubFlavoredMarkdownConverter($config);
|
||||||
|
$converter
|
||||||
|
->getEnvironment()
|
||||||
|
->addExtension(new HighlightExtension(new Highlighter()->withGutter()))
|
||||||
|
->addExtension(new FootnoteExtension())
|
||||||
|
->addExtension(new DefaultAttributesExtension())
|
||||||
|
->addEventListener(DocumentParsedEvent::class, $this->documentParsed(...))
|
||||||
|
;
|
||||||
|
return $converter->convert($content);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function documentParsed(DocumentParsedEvent $event): void
|
||||||
|
{
|
||||||
|
$document = $event->getDocument();
|
||||||
|
|
||||||
|
$linkNodes = new Query()
|
||||||
|
->where(Query::type(Link::class))
|
||||||
|
->findAll($document)
|
||||||
|
;
|
||||||
|
|
||||||
|
/** @var Link $linkNode */
|
||||||
|
foreach ($linkNodes as $linkNode) {
|
||||||
|
$url = $linkNode->getUrl();
|
||||||
|
|
||||||
|
if (!is_int($url)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$snip = $this->snipRepo->find($url);
|
||||||
|
if ($snip === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$linkNode->setUrl($this->router->generate('snip_single', [
|
||||||
|
'snip' => $url,
|
||||||
|
]));
|
||||||
|
$textNode = $linkNode->firstChild();
|
||||||
|
if (!$textNode) {
|
||||||
|
$linkNode->appendChild(new Text($snip));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
52
src/Service/SnipParser/ParserFactory.php
Normal file
52
src/Service/SnipParser/ParserFactory.php
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Service\SnipParser;
|
||||||
|
|
||||||
|
use App\Entity\Snip;
|
||||||
|
use Symfony\Component\DependencyInjection\Attribute\AutowireLocator;
|
||||||
|
use Symfony\Component\DependencyInjection\Exception\ServiceNotFoundException;
|
||||||
|
use Symfony\Component\DependencyInjection\ServiceLocator;
|
||||||
|
|
||||||
|
readonly class ParserFactory
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
#[AutowireLocator(ParserInterface::class, defaultIndexMethod: 'getName')]
|
||||||
|
private ServiceLocator $locator
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @template T of ParserInterface
|
||||||
|
*
|
||||||
|
* @param class-string<T> $id
|
||||||
|
*
|
||||||
|
* @return T
|
||||||
|
* @throws ServiceNotFoundException
|
||||||
|
*/
|
||||||
|
public function get(string $id): ParserInterface
|
||||||
|
{
|
||||||
|
return $this->locator->get($id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getBySnip(Snip $snip): ParserInterface
|
||||||
|
{
|
||||||
|
$parser = $snip->getParser();
|
||||||
|
if (null === $parser) {
|
||||||
|
throw new ServiceNotFoundException(sprintf('Unknown parser for snip "%s"', $snip->getParser()));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->get($parser);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return iterable<string, ParserInterface>
|
||||||
|
*/
|
||||||
|
public function getAll(): iterable
|
||||||
|
{
|
||||||
|
return $this->locator->getIterator();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getChoices(): iterable
|
||||||
|
{
|
||||||
|
foreach ($this->getAll() as $parser) yield $parser::getName();
|
||||||
|
}
|
||||||
|
}
|
15
src/Service/SnipParser/ParserInterface.php
Normal file
15
src/Service/SnipParser/ParserInterface.php
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Service\SnipParser;
|
||||||
|
|
||||||
|
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;
|
||||||
|
|
||||||
|
#[AutoconfigureTag]
|
||||||
|
interface ParserInterface
|
||||||
|
{
|
||||||
|
public function parseView(string $content): string;
|
||||||
|
|
||||||
|
public function parseRaw(string $content): string;
|
||||||
|
|
||||||
|
public static function getName(): string;
|
||||||
|
}
|
@ -1,43 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Service\SnipParser;
|
|
||||||
|
|
||||||
use App\Service\SnipParser\Stages\HtmlEscapeStage;
|
|
||||||
use App\Service\SnipParser\Stages\IncludeReferenceStage;
|
|
||||||
use App\Service\SnipParser\Stages\UrlReferenceStage;
|
|
||||||
use App\Service\SnipParser\Stages\ReplaceBlocksStage;
|
|
||||||
use App\Service\SnipParser\Stages\ReplaceStage;
|
|
||||||
use League\Pipeline\PipelineBuilder;
|
|
||||||
|
|
||||||
class Pipeline
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private readonly UrlReferenceStage $referenceStage,
|
|
||||||
private readonly IncludeReferenceStage $includeStage,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function parse(string $payload): string
|
|
||||||
{
|
|
||||||
$builder = new PipelineBuilder();
|
|
||||||
$pipeline = $builder
|
|
||||||
->add(new HtmlEscapeStage())
|
|
||||||
->add(new ReplaceStage(PHP_EOL, '<br>'))
|
|
||||||
->add(new ReplaceBlocksStage('<pre><code class="hljs">', '</code></pre>', '```'))
|
|
||||||
->add(new ReplaceBlocksStage('<code class="hljs">', '</code>', '``'))
|
|
||||||
->add($this->referenceStage)
|
|
||||||
->add($this->includeStage)
|
|
||||||
->build()
|
|
||||||
;
|
|
||||||
|
|
||||||
return $pipeline->process($payload);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function clean(string $payload): string
|
|
||||||
{
|
|
||||||
return str_replace(
|
|
||||||
['```', '``'],
|
|
||||||
'',
|
|
||||||
$payload
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,44 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Service\SnipParser\Stages;
|
|
||||||
|
|
||||||
use App\Repository\SnipRepository;
|
|
||||||
use App\Security\Voter\SnipVoter;
|
|
||||||
use App\Service\SnipParser\Pipeline;
|
|
||||||
use App\Service\SnipServiceFactory;
|
|
||||||
use League\Pipeline\StageInterface;
|
|
||||||
use Symfony\Bundle\SecurityBundle\Security;
|
|
||||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
|
||||||
|
|
||||||
class IncludeReferenceStage implements StageInterface
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
#[Autowire(lazy: true)] private readonly Security $security,
|
|
||||||
#[Autowire(lazy: true)] private readonly SnipRepository $snipRepository,
|
|
||||||
#[Autowire(lazy: true)] private readonly SnipServiceFactory $snipServiceFactory,
|
|
||||||
#[Autowire(lazy: true)] private readonly Pipeline $pipeline,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function __invoke(mixed $payload): string
|
|
||||||
{
|
|
||||||
return $this->replaceReferences($payload);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function replaceReferences(mixed $payload): string
|
|
||||||
{
|
|
||||||
// replaces all references (#n) to other snips with links
|
|
||||||
$pattern = '/\{\{(\d+)\}\}/';
|
|
||||||
|
|
||||||
return preg_replace_callback($pattern, function ($matches) {
|
|
||||||
$snip = $this->snipRepository->find($matches[1]);
|
|
||||||
if ($snip === null) {
|
|
||||||
return sprintf('<span title="not found">%s</span>', $matches[0]);
|
|
||||||
}
|
|
||||||
if (!$this->security->isGranted(SnipVoter::VIEW, $snip)) {
|
|
||||||
return sprintf('<span title="access denied">%s</span>', $matches[0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->pipeline->parse($this->snipServiceFactory->create($snip)->get());
|
|
||||||
}, $payload);
|
|
||||||
}
|
|
||||||
}
|
|
61
src/Service/SnipParser/Twig/SnipLoader.php
Normal file
61
src/Service/SnipParser/Twig/SnipLoader.php
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Service\SnipParser\Twig;
|
||||||
|
|
||||||
|
use App\Entity\Snip;
|
||||||
|
use App\Repository\SnipRepository;
|
||||||
|
use App\Security\Voter\SnipVoter;
|
||||||
|
use App\Service\SnipContent\SnipContentService;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Twig\Error\LoaderError;
|
||||||
|
use Twig\Loader\LoaderInterface;
|
||||||
|
use Twig\Source;
|
||||||
|
|
||||||
|
class SnipLoader implements LoaderInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly SnipRepository $repository,
|
||||||
|
private readonly Security $security,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function getSourceContext(string $name): Source
|
||||||
|
{
|
||||||
|
return new Source($this->getFromKey($name)->getActiveText(), $name);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCacheKey(string $name): string
|
||||||
|
{
|
||||||
|
return $this->getFromKey($name)->getActiveVersion()->getId();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isFresh(string $name, int $time): bool
|
||||||
|
{
|
||||||
|
$this->getFromKey($name);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function exists(string $name): bool
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$this->getFromKey($name);
|
||||||
|
} catch (LoaderError) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getFromKey(string $key): Snip
|
||||||
|
{
|
||||||
|
$snip = $this->repository->find($key);
|
||||||
|
if (!$snip) {
|
||||||
|
throw new LoaderError(\sprintf('Template "%s" is not defined.', $key));
|
||||||
|
}
|
||||||
|
if (!$this->security->isGranted(SnipVoter::VIEW, $snip)) {
|
||||||
|
throw new LoaderError(\sprintf('You do not have permission to view the template "%s".', $key));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $snip;
|
||||||
|
}
|
||||||
|
}
|
63
src/Service/SnipParser/Twig/SnipTwigExtension.php
Normal file
63
src/Service/SnipParser/Twig/SnipTwigExtension.php
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Service\SnipParser\Twig;
|
||||||
|
|
||||||
|
use App\Dto\SnipFilterRequest;
|
||||||
|
use App\Entity\Snip;
|
||||||
|
use App\Repository\SnipRepository;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
|
use Symfony\Component\Routing\RouterInterface;
|
||||||
|
use Twig\Extension\AbstractExtension;
|
||||||
|
use Twig\TwigFunction;
|
||||||
|
|
||||||
|
class SnipTwigExtension extends AbstractExtension
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
#[Autowire(lazy: true)] private readonly RouterInterface $router,
|
||||||
|
#[Autowire(lazy: true)] private readonly SnipRepository $snipRepo,
|
||||||
|
#[Autowire(lazy: true)] private readonly Security $security,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function getFunctions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
new TwigFunction('snipPath', $this->snipPath(...)),
|
||||||
|
new TwigFunction('snipLink', $this->snipLink(...), [
|
||||||
|
'is_safe' => ['html'],
|
||||||
|
]),
|
||||||
|
new TwigFunction('snipsByTag', $this->snipsByTag(...)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function snipPath(int $id): string
|
||||||
|
{
|
||||||
|
return $this->router->generate('snip_single', [
|
||||||
|
'snip' => $id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function snipLink(int $id): string
|
||||||
|
{
|
||||||
|
$snip = $this->snipRepo->find($id);
|
||||||
|
if ($snip === null) {
|
||||||
|
throw new \Exception(sprintf('Snip not found with id: %d', $id));
|
||||||
|
}
|
||||||
|
return sprintf('<a class="btn btn-sm btn-primary" href="%s">%s</a>', $this->snipPath($id), $snip);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function snipsByTag(string $tag): array
|
||||||
|
{
|
||||||
|
// Todo: get 'context' user from the snip it is called from
|
||||||
|
$user = $this->security->getUser();
|
||||||
|
if ($user === null) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$request = new SnipFilterRequest(SnipFilterRequest::VISIBILITY_ALL, tag: $tag);
|
||||||
|
$snips = $this->snipRepo->findByRequest($user, $request);
|
||||||
|
return array_map(fn(Snip $snip) => [
|
||||||
|
'id' => $snip->getId(),
|
||||||
|
'name' => $snip->getName(),
|
||||||
|
], $snips);
|
||||||
|
}
|
||||||
|
}
|
30
src/Service/SnipParser/Twig/TwigParser.php
Normal file
30
src/Service/SnipParser/Twig/TwigParser.php
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Service\SnipParser\Twig;
|
||||||
|
|
||||||
|
use App\Service\SnipParser\AbstractParser;
|
||||||
|
use Twig\Environment;
|
||||||
|
use Twig\Loader\ArrayLoader;
|
||||||
|
use Twig\Loader\ChainLoader;
|
||||||
|
|
||||||
|
class TwigParser extends AbstractParser
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly SnipLoader $snipLoader,
|
||||||
|
private readonly SnipTwigExtension $snipTwigExtension,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function safeParseView(string $content): string
|
||||||
|
{
|
||||||
|
$loader = new ChainLoader([
|
||||||
|
new ArrayLoader([
|
||||||
|
'index' => $content,
|
||||||
|
]),
|
||||||
|
$this->snipLoader,
|
||||||
|
]);
|
||||||
|
$twig = new Environment($loader);
|
||||||
|
$twig->addExtension($this->snipTwigExtension);
|
||||||
|
|
||||||
|
return $twig->render('index');
|
||||||
|
}
|
||||||
|
}
|
@ -3,48 +3,17 @@
|
|||||||
namespace App\Service;
|
namespace App\Service;
|
||||||
|
|
||||||
use App\Entity\Snip;
|
use App\Entity\Snip;
|
||||||
use App\Git\CustomGit;
|
use App\Service\SnipContent\SnipContentService;
|
||||||
use App\Service\SnipContent\SnipContentDB;
|
|
||||||
use App\Service\SnipContent\SnipContentGit;
|
|
||||||
use App\Service\SnipContent\SnipContentInterface;
|
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Symfony\Bundle\SecurityBundle\Security;
|
|
||||||
|
|
||||||
class SnipServiceFactory
|
readonly class SnipServiceFactory
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly string $gitStoragePath,
|
private EntityManagerInterface $em,
|
||||||
private readonly string $storageType,
|
|
||||||
private readonly Security $security,
|
|
||||||
private readonly EntityManagerInterface $em,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function create(Snip $snip): SnipContentInterface
|
public function create(Snip $snip): SnipContentService
|
||||||
{
|
{
|
||||||
return match ($this->storageType) {
|
return new SnipContentService($snip, $this->em);
|
||||||
'git' => $this->createGit($snip),
|
|
||||||
'db' => $this->createDB($snip),
|
|
||||||
default => throw new \Exception('Unknown storage type'),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private function createGit(Snip $snip): SnipContentGit
|
|
||||||
{
|
|
||||||
$git = new CustomGit();
|
|
||||||
$repoPath = sprintf('%s/%s', $this->gitStoragePath, $snip->getId());
|
|
||||||
if (!is_dir($repoPath)) {
|
|
||||||
$repo = $git->init($repoPath);
|
|
||||||
touch(sprintf('%s/.gitignore', $repoPath));
|
|
||||||
$repo->addFile('.gitignore');
|
|
||||||
$repo->commit('Initial commit');
|
|
||||||
} else {
|
|
||||||
$repo = $git->open($repoPath);
|
|
||||||
}
|
|
||||||
return new SnipContentGit($repo, $this->security->getUser());
|
|
||||||
}
|
|
||||||
|
|
||||||
private function createDB(Snip $snip): SnipContentDB
|
|
||||||
{
|
|
||||||
return new SnipContentDB($snip, $this->security->getUser(), $this->em);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
45
src/Twig/Extension/SnipFilterExtension.php
Normal file
45
src/Twig/Extension/SnipFilterExtension.php
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Twig\Extension;
|
||||||
|
|
||||||
|
use App\Dto\SnipFilterRequest;
|
||||||
|
use App\Repository\TagRepository;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Twig\Extension\AbstractExtension;
|
||||||
|
use Twig\TwigFunction;
|
||||||
|
|
||||||
|
class SnipFilterExtension extends AbstractExtension
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly TagRepository $tagRepository,
|
||||||
|
private readonly Security $security,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function getFunctions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
new TwigFunction('snipSortOptions', fn() => [
|
||||||
|
SnipFilterRequest::SORT_NAME,
|
||||||
|
SnipFilterRequest::SORT_DATE,
|
||||||
|
]),
|
||||||
|
new TwigFunction('snipFilterOptions', fn() => [
|
||||||
|
SnipFilterRequest::VISIBILITY_ALL,
|
||||||
|
SnipFilterRequest::VISIBILITY_VISIBLE,
|
||||||
|
SnipFilterRequest::VISIBILITY_HIDDEN,
|
||||||
|
SnipFilterRequest::VISIBILITY_ARCHIVED,
|
||||||
|
]),
|
||||||
|
new TwigFunction('snipTagOptions', fn() => $this->getSnipTagOptions()),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getSnipTagOptions(): array
|
||||||
|
{
|
||||||
|
$tags[SnipFilterRequest::TAG_ALL] = 'All tags';
|
||||||
|
$tags[SnipFilterRequest::TAG_NONE] = 'No tags';
|
||||||
|
foreach ($this->tagRepository->findAllByUser($this->security->getUser()) as $tag) {
|
||||||
|
$tags[(string)$tag] = (string)$tag;
|
||||||
|
}
|
||||||
|
return $tags;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
82
symfony.lock
82
symfony.lock
@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"doctrine/doctrine-bundle": {
|
"doctrine/doctrine-bundle": {
|
||||||
"version": "2.9",
|
"version": "2.14",
|
||||||
"recipe": {
|
"recipe": {
|
||||||
"repo": "github.com/symfony/recipes",
|
"repo": "github.com/symfony/recipes",
|
||||||
"branch": "main",
|
"branch": "main",
|
||||||
"version": "2.8",
|
"version": "2.13",
|
||||||
"ref": "6b43b7b6ff6bf2551f2933ebeb66721fa3db8fbc"
|
"ref": "620b57f496f2e599a6015a9fa222c2ee0a32adcb"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"config/packages/doctrine.yaml",
|
"config/packages/doctrine.yaml",
|
||||||
@ -27,12 +27,12 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"symfony/console": {
|
"symfony/console": {
|
||||||
"version": "6.2",
|
"version": "7.2",
|
||||||
"recipe": {
|
"recipe": {
|
||||||
"repo": "github.com/symfony/recipes",
|
"repo": "github.com/symfony/recipes",
|
||||||
"branch": "main",
|
"branch": "main",
|
||||||
"version": "5.3",
|
"version": "5.3",
|
||||||
"ref": "da0c8be8157600ad34f10ff0c9cc91232522e047"
|
"ref": "1781ff40d8a17d87cf53f8d4cf0c8346ed2bb461"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"bin/console"
|
"bin/console"
|
||||||
@ -51,24 +51,37 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"symfony/flex": {
|
"symfony/flex": {
|
||||||
"version": "2.2",
|
"version": "2.5",
|
||||||
"recipe": {
|
"recipe": {
|
||||||
"repo": "github.com/symfony/recipes",
|
"repo": "github.com/symfony/recipes",
|
||||||
"branch": "main",
|
"branch": "main",
|
||||||
"version": "1.0",
|
"version": "2.4",
|
||||||
"ref": "146251ae39e06a95be0fe3d13c807bcf3938b172"
|
"ref": "52e9754527a15e2b79d9a610f98185a1fe46622a"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
".env"
|
".env",
|
||||||
|
".env.dev"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"symfony/form": {
|
||||||
|
"version": "7.2",
|
||||||
|
"recipe": {
|
||||||
|
"repo": "github.com/symfony/recipes",
|
||||||
|
"branch": "main",
|
||||||
|
"version": "7.2",
|
||||||
|
"ref": "7d86a6723f4a623f59e2bf966b6aad2fc461d36b"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"config/packages/csrf.yaml"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"symfony/framework-bundle": {
|
"symfony/framework-bundle": {
|
||||||
"version": "6.2",
|
"version": "7.2",
|
||||||
"recipe": {
|
"recipe": {
|
||||||
"repo": "github.com/symfony/recipes",
|
"repo": "github.com/symfony/recipes",
|
||||||
"branch": "main",
|
"branch": "main",
|
||||||
"version": "6.2",
|
"version": "7.2",
|
||||||
"ref": "af47254c5e4cd543e6af3e4508298ffebbdaddd3"
|
"ref": "87bcf6f7c55201f345d8895deda46d2adbdbaa89"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"config/packages/cache.yaml",
|
"config/packages/cache.yaml",
|
||||||
@ -91,24 +104,24 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"symfony/monolog-bundle": {
|
"symfony/monolog-bundle": {
|
||||||
"version": "3.8",
|
"version": "3.10",
|
||||||
"recipe": {
|
"recipe": {
|
||||||
"repo": "github.com/symfony/recipes",
|
"repo": "github.com/symfony/recipes",
|
||||||
"branch": "main",
|
"branch": "main",
|
||||||
"version": "3.7",
|
"version": "3.7",
|
||||||
"ref": "213676c4ec929f046dfde5ea8e97625b81bc0578"
|
"ref": "aff23899c4440dd995907613c1dd709b6f59503f"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"config/packages/monolog.yaml"
|
"config/packages/monolog.yaml"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"symfony/routing": {
|
"symfony/routing": {
|
||||||
"version": "6.2",
|
"version": "7.2",
|
||||||
"recipe": {
|
"recipe": {
|
||||||
"repo": "github.com/symfony/recipes",
|
"repo": "github.com/symfony/recipes",
|
||||||
"branch": "main",
|
"branch": "main",
|
||||||
"version": "6.2",
|
"version": "7.0",
|
||||||
"ref": "e0a11b4ccb8c9e70b574ff5ad3dfdcd41dec5aa6"
|
"ref": "21b72649d5622d8f7da329ffb5afb232a023619d"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"config/packages/routing.yaml",
|
"config/packages/routing.yaml",
|
||||||
@ -116,24 +129,25 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"symfony/security-bundle": {
|
"symfony/security-bundle": {
|
||||||
"version": "6.2",
|
"version": "7.2",
|
||||||
"recipe": {
|
"recipe": {
|
||||||
"repo": "github.com/symfony/recipes",
|
"repo": "github.com/symfony/recipes",
|
||||||
"branch": "main",
|
"branch": "main",
|
||||||
"version": "6.0",
|
"version": "6.4",
|
||||||
"ref": "8a5b112826f7d3d5b07027f93786ae11a1c7de48"
|
"ref": "2ae08430db28c8eb4476605894296c82a642028f"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"config/packages/security.yaml"
|
"config/packages/security.yaml",
|
||||||
|
"config/routes/security.yaml"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"symfony/twig-bundle": {
|
"symfony/twig-bundle": {
|
||||||
"version": "6.2",
|
"version": "7.2",
|
||||||
"recipe": {
|
"recipe": {
|
||||||
"repo": "github.com/symfony/recipes",
|
"repo": "github.com/symfony/recipes",
|
||||||
"branch": "main",
|
"branch": "main",
|
||||||
"version": "5.4",
|
"version": "6.4",
|
||||||
"ref": "bb2178c57eee79e6be0b297aa96fc0c0def81387"
|
"ref": "cab5fd2a13a45c266d45a7d9337e28dee6272877"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"config/packages/twig.yaml",
|
"config/packages/twig.yaml",
|
||||||
@ -141,36 +155,34 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"symfony/uid": {
|
"symfony/uid": {
|
||||||
"version": "7.0",
|
"version": "7.2",
|
||||||
"recipe": {
|
"recipe": {
|
||||||
"repo": "github.com/symfony/recipes",
|
"repo": "github.com/symfony/recipes",
|
||||||
"branch": "main",
|
"branch": "main",
|
||||||
"version": "6.2",
|
"version": "7.0",
|
||||||
"ref": "d294ad4add3e15d7eb1bae0221588ca89b38e558"
|
"ref": "0df5844274d871b37fc3816c57a768ffc60a43a5"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": []
|
||||||
"config/packages/uid.yaml"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"symfony/validator": {
|
"symfony/validator": {
|
||||||
"version": "6.2",
|
"version": "7.2",
|
||||||
"recipe": {
|
"recipe": {
|
||||||
"repo": "github.com/symfony/recipes",
|
"repo": "github.com/symfony/recipes",
|
||||||
"branch": "main",
|
"branch": "main",
|
||||||
"version": "5.3",
|
"version": "7.0",
|
||||||
"ref": "c32cfd98f714894c4f128bb99aa2530c1227603c"
|
"ref": "8c1c4e28d26a124b0bb273f537ca8ce443472bfd"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"config/packages/validator.yaml"
|
"config/packages/validator.yaml"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"symfony/web-profiler-bundle": {
|
"symfony/web-profiler-bundle": {
|
||||||
"version": "6.2",
|
"version": "7.2",
|
||||||
"recipe": {
|
"recipe": {
|
||||||
"repo": "github.com/symfony/recipes",
|
"repo": "github.com/symfony/recipes",
|
||||||
"branch": "main",
|
"branch": "main",
|
||||||
"version": "6.1",
|
"version": "6.1",
|
||||||
"ref": "e42b3f0177df239add25373083a564e5ead4e13a"
|
"ref": "8b51135b84f4266e3b4c8a6dc23c9d1e32e543b7"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"config/packages/web_profiler.yaml",
|
"config/packages/web_profiler.yaml",
|
||||||
|
@ -3,12 +3,15 @@
|
|||||||
<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">
|
||||||
<title>{% block title %}SNIPS{% endblock %}</title>
|
<title>
|
||||||
<link rel="shortcut icon" type="image/jpg" href="/favicon.png">
|
{% if app.environment == 'dev' %}D{% endif %}
|
||||||
|
{% if title is defined %}{{ title }}{% else %}SNIPS{% endif %}
|
||||||
|
</title>
|
||||||
|
<link rel="shortcut icon" type="image/jpg" href="/snips.png">
|
||||||
|
|
||||||
{% block css %}
|
{% block css %}
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet"
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"
|
||||||
integrity="sha384-1BmE4kWBq78iYhFldvKuhfTAU6auU8tT94WrHftjDbrCEXSU1oBoqyl2QvZ6jIW3"
|
integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH"
|
||||||
crossorigin="anonymous">
|
crossorigin="anonymous">
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
@ -37,27 +40,13 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# body blocks #}
|
{# body blocks #}
|
||||||
{% block bodyraw %}
|
{% block content %}
|
||||||
<div class="container">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-sm mx-auto">
|
|
||||||
{% block body %}{% endblock %}
|
|
||||||
</div>
|
|
||||||
{% if block('body2') is defined %}
|
|
||||||
<div class="col-sm mx-auto">
|
|
||||||
{{ block('body2') }}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{# javascript block #}
|
{# javascript block #}
|
||||||
{% block js %}
|
{% block js %}
|
||||||
<script src="https://kit.fontawesome.com/3471b6556e.js" crossorigin="anonymous"></script>
|
<script src="https://kit.fontawesome.com/3471b6556e.js" crossorigin="anonymous"></script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.min.js"
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.min.js"></script>
|
||||||
integrity="sha384-QJHtvGhmr9XOIpI6YVutG+2QOK9T+ZnN4kzFN1RtK3zEFEIsxhlmWl5/YESvpZ13"
|
|
||||||
crossorigin="anonymous"></script>
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/jquery@3.6.0/dist/jquery.min.js"
|
<script src="https://cdn.jsdelivr.net/npm/jquery@3.6.0/dist/jquery.min.js"
|
||||||
integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4="
|
integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4="
|
||||||
crossorigin="anonymous"></script>
|
crossorigin="anonymous"></script>
|
||||||
|
8
templates/base/container.html.twig
Normal file
8
templates/base/container.html.twig
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{% extends 'base/base.html.twig' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container">
|
||||||
|
{% block container %}
|
||||||
|
{% endblock %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
@ -1,6 +1,9 @@
|
|||||||
<nav class="navbar navbar-expand-md navbar-dark bg-dark" style="z-index: 1;">
|
<nav class="navbar navbar-expand-md navbar-dark bg-dark" style="z-index: 1;">
|
||||||
<div class="container-fluid">
|
<div class="container-fluid">
|
||||||
<a title="BlueLinked Eco System" class="navbar-brand" href="{{ path('home') }}">SNIPS</a>
|
<a title="Snips" class="navbar-brand" href="{{ path('home') }}">
|
||||||
|
<img src="/snips.png" width="30" height="30" class="d-inline-block align-top rounded" alt="">
|
||||||
|
SNIPS
|
||||||
|
</a>
|
||||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbar"
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbar"
|
||||||
aria-controls="navbar" aria-expanded="false" aria-label="Toggle navigation">
|
aria-controls="navbar" aria-expanded="false" aria-label="Toggle navigation">
|
||||||
<span class="navbar-toggler-icon"></span>
|
<span class="navbar-toggler-icon"></span>
|
||||||
@ -15,10 +18,10 @@
|
|||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" href="{{ path('snip_new') }}">New snip</a>
|
<a class="nav-link" href="{{ path('snip_new') }}">New snip</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link" href="{{ path('snip_public') }}">Public snips</a>
|
|
||||||
</li>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="{{ path('snip_public') }}">Public snips</a>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<ul class="navbar-nav my-2 my-lg-0">
|
<ul class="navbar-nav my-2 my-lg-0">
|
||||||
{% if app.environment == 'dev' %}
|
{% if app.environment == 'dev' %}
|
||||||
@ -35,6 +38,9 @@
|
|||||||
<a class="nav-link" href="{{ path('logout') }}">Logout</a>
|
<a class="nav-link" href="{{ path('logout') }}">Logout</a>
|
||||||
</li>
|
</li>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="{{ path('login') }}">Login</a>
|
||||||
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" href="{{ path('register') }}">Register</a>
|
<a class="nav-link" href="{{ path('register') }}">Register</a>
|
||||||
</li>
|
</li>
|
||||||
|
10
templates/base/one.column.html.twig
Normal file
10
templates/base/one.column.html.twig
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
{% extends 'base/container.html.twig' %}
|
||||||
|
|
||||||
|
{% block container %}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-sm mx-auto">
|
||||||
|
{% if title is defined %}<h3>{{ title }}</h3>{% endif %}
|
||||||
|
{% block body %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
17
templates/base/two.column.6-6.html.twig
Normal file
17
templates/base/two.column.6-6.html.twig
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
{% extends 'base/container.html.twig' %}
|
||||||
|
|
||||||
|
{% block container %}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-sm mx-auto">
|
||||||
|
{% if title is defined %}<h3>{{ title }}</h3>{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 mx-auto">
|
||||||
|
{% block column1 %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 mx-auto">
|
||||||
|
{% block column2 %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
17
templates/base/two.column.8-4.html.twig
Normal file
17
templates/base/two.column.8-4.html.twig
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
{% extends 'base/container.html.twig' %}
|
||||||
|
|
||||||
|
{% block container %}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-sm mx-auto">
|
||||||
|
{% if title is defined %}<h3>{{ title }}</h3>{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-8 mx-auto">
|
||||||
|
{% block column1 %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 mx-auto">
|
||||||
|
{% block column2 %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
36
templates/content/compare.html.twig
Normal file
36
templates/content/compare.html.twig
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
{% extends 'base/one.column.html.twig' %}
|
||||||
|
|
||||||
|
{% set title = 'Snip compare ' ~ snip %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<a href="{{ path('snip_single', {snip: snip.id}) }}" class="btn btn-primary">
|
||||||
|
<i class="fa fa-arrow-left"></i> Back
|
||||||
|
</a>
|
||||||
|
<br><br>
|
||||||
|
<table class="table table-sm table-hover">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Line</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Old</th>
|
||||||
|
<th>New</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for line in diff %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ line.line }}.</td>
|
||||||
|
<td class="table-{{ line.type == 'insert' ? 'success' : (line.type == 'delete' ? 'danger' : 'info') }}">
|
||||||
|
{{ line.type }}
|
||||||
|
</td>
|
||||||
|
<td class="table-{{ line.type == 'insert' ? '' : (line.type == 'delete' ? 'danger' : 'info') }}">
|
||||||
|
{{ line.from }}
|
||||||
|
</td>
|
||||||
|
<td class="table-{{ line.type == 'insert' ? 'success' : (line.type == 'delete' ? '' : 'info') }}">
|
||||||
|
{{ line.to }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endblock %}
|
@ -1,6 +0,0 @@
|
|||||||
{% extends 'base/base.html.twig' %}
|
|
||||||
|
|
||||||
{% block body %}
|
|
||||||
<h3>{{ message }}</h3>
|
|
||||||
{{ form(form) }}
|
|
||||||
{% endblock %}
|
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user