Compare commits

..

No commits in common. "master" and "pregitcleanup" have entirely different histories.

117 changed files with 1593 additions and 4595 deletions

2
.env
View File

@ -16,7 +16,7 @@
###> symfony/framework-bundle ###
APP_ENV=dev
APP_SECRET=
APP_SECRET=a617c2ab616c5688ff5b0e95ad646641
###< symfony/framework-bundle ###
###> doctrine/doctrine-bundle ###

View File

@ -1,4 +0,0 @@
###> symfony/framework-bundle ###
APP_SECRET=452d8323de922537717fb88b5fa6f80e
###< symfony/framework-bundle ###

2
.gitignore vendored
View File

@ -8,6 +8,4 @@
/var/
/vendor/
###< symfony/framework-bundle ###
release.json
http-client.private.env.json

19
.http
View File

@ -1,19 +0,0 @@
### 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"
}

View File

@ -4,10 +4,6 @@
use App\Kernel;
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')) {
throw new LogicException('Symfony Runtime is missing. Try running "composer require symfony/runtime".');
}

View File

@ -4,42 +4,35 @@
"minimum-stability": "stable",
"prefer-stable": true,
"require": {
"php": ">=8.4",
"php": ">=8.3",
"ext-ctype": "*",
"ext-iconv": "*",
"czproject/git-php": "^4.1",
"doctrine/doctrine-bundle": "^2.9",
"doctrine/doctrine-migrations-bundle": "^3.2",
"doctrine/orm": "^2.14",
"league/commonmark": "^2.6",
"league/pipeline": "^1.0",
"phpdocumentor/reflection-docblock": "^5.6",
"phpstan/phpdoc-parser": "^2.1",
"symfony/asset": "7.2.*",
"symfony/console": "*",
"symfony/dotenv": "*",
"symfony/console": "7.0.*",
"symfony/dotenv": "7.0.*",
"symfony/flex": "^2",
"symfony/form": "*",
"symfony/framework-bundle": "*",
"symfony/form": "7.0.*",
"symfony/framework-bundle": "7.0.*",
"symfony/monolog-bundle": "^3.0",
"symfony/property-access": "*",
"symfony/property-info": "*",
"symfony/runtime": "*",
"symfony/security-bundle": "*",
"symfony/serializer": "*",
"symfony/twig-bundle": "*",
"symfony/uid": "*",
"symfony/validator": "*",
"symfony/yaml": "*",
"tempest/highlight": "^2.11",
"symfony/runtime": "7.0.*",
"symfony/security-bundle": "7.0.*",
"symfony/twig-bundle": "7.0.*",
"symfony/uid": "7.0.*",
"symfony/validator": "7.0.*",
"symfony/yaml": "7.0.*",
"twig/extra-bundle": "^2.12|^3.0",
"twig/twig": "^3.0"
"twig/twig": "^2.12|^3.0"
},
"require-dev": {
"deployer/deployer": "^7.3",
"symfony/debug-bundle": "*",
"symfony/debug-bundle": "7.0.*",
"symfony/maker-bundle": "^1.48",
"symfony/stopwatch": "*",
"symfony/web-profiler-bundle": "*"
"symfony/stopwatch": "7.0.*",
"symfony/web-profiler-bundle": "7.0.*"
},
"config": {
"allow-plugins": {
@ -54,6 +47,11 @@
"App\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"App\\Tests\\": "tests/"
}
},
"replace": {
"symfony/polyfill-ctype": "*",
"symfony/polyfill-iconv": "*",
@ -61,10 +59,7 @@
"symfony/polyfill-php73": "*",
"symfony/polyfill-php74": "*",
"symfony/polyfill-php80": "*",
"symfony/polyfill-php81": "*",
"symfony/polyfill-php82": "*",
"symfony/polyfill-php83": "*",
"symfony/polyfill-php84": "*"
"symfony/polyfill-php81": "*"
},
"scripts": {
"auto-scripts": {
@ -84,7 +79,7 @@
"extra": {
"symfony": {
"allow-contrib": false,
"require": "7.2.*"
"require": "7.0.*"
}
}
}

2455
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,11 +0,0 @@
# Enable stateless CSRF protection for forms and logins/logouts
framework:
form:
csrf_protection:
token_id: submit
csrf_protection:
stateless_token_ids:
- submit
- authenticate
- logout

View File

@ -4,28 +4,18 @@ doctrine:
# IMPORTANT: You MUST configure your server version,
# either here or in the DATABASE_URL env var (see .env file)
#server_version: '16'
profiling_collect_backtrace: '%kernel.debug%'
use_savepoints: true
#server_version: '15'
orm:
report_fields_where_declared: true
auto_generate_proxy_classes: true
enable_lazy_ghost_objects: true
validate_xml_mapping: true
naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
identity_generation_preferences:
Doctrine\DBAL\Platforms\PostgreSQLPlatform: identity
auto_mapping: true
mappings:
App:
type: attribute
is_bundle: false
dir: '%kernel.project_dir%/src/Entity'
prefix: 'App\Entity'
alias: App
controller_resolver:
auto_mapping: false
when@test:
doctrine:

View File

@ -1,12 +1,22 @@
# see https://symfony.com/doc/current/reference/configuration/framework.html
framework:
secret: '%env(APP_SECRET)%'
#csrf_protection: true
http_method_override: false
handle_all_throwables: true
# Note that the session will be started ONLY if you read or write from it.
session: true
# Enables session support. Note that the session will ONLY be started if you read or write from it.
# Remove or comment this section to explicitly disable session support.
session:
handler_id: null
cookie_secure: auto
cookie_samesite: lax
storage_factory_id: session.storage.factory.native
#esi: true
#fragments: true
php_errors:
log: true
when@test:
framework:

View File

@ -59,4 +59,3 @@ when@prod:
type: stream
channels: [deprecation]
path: php://stderr
formatter: monolog.formatter.json

View File

@ -1,5 +1,7 @@
framework:
router:
utf8: true
# 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
#default_uri: http://localhost

View File

@ -24,8 +24,6 @@ security:
remember_me:
secret: '%kernel.secret%' # required
lifetime: 2419200 # 4 weeks in seconds
custom_authenticators:
- App\Security\TokenAuthenticator
secured_area:
form_login:
@ -41,14 +39,11 @@ security:
# Note: Only the *first* access control that matches will be used
access_control:
- { path: ^/login$, role: PUBLIC_ACCESS }
- { path: ^/register$, role: PUBLIC_ACCESS }
- { path: ^/register, role: PUBLIC_ACCESS }
- { path: ^/logout$, role: ROLE_USER }
- { path: ^/admin, role: ROLE_ADMIN }
- { path: ^/$, role: PUBLIC_ACCESS }
- { path: ^/snip/single, role: PUBLIC_ACCESS }
- { path: ^/snip/raw, role: PUBLIC_ACCESS }
- { path: ^/snip/public$, role: PUBLIC_ACCESS }
- { path: ^/, role: ROLE_USER }

View File

@ -1,6 +1,6 @@
twig:
default_path: '%kernel.project_dir%/templates'
form_themes: ['bootstrap_5_layout.html.twig']
file_name_pattern: '*.twig'
when@test:
twig:

4
config/packages/uid.yaml Normal file
View File

@ -0,0 +1,4 @@
framework:
uid:
default_uuid_version: 7
time_based_uuid_version: 7

View File

@ -1,5 +1,7 @@
framework:
validation:
email_validation_mode: html5
# Enables validator auto-mapping support.
# For instance, basic validation constraints will be inferred from Doctrine's metadata.
#auto_mapping:

View File

@ -1,11 +1,17 @@
when@dev:
web_profiler:
toolbar: true
intercept_redirects: false
framework:
profiler:
only_exceptions: false
collect_serializer_data: true
when@test:
web_profiler:
toolbar: false
intercept_redirects: false
framework:
profiler: { collect: false }

View File

@ -3,12 +3,3 @@ controllers:
path: ../src/Controller/
namespace: App\Controller
type: attribute
exclude: ../src/Controller/Api/
rest_controllers:
resource:
path: ../src/Controller/Api/
namespace: App\Controller\Api
type: attribute
prefix: /api
defaults: { _format: "json" }

View File

@ -1,3 +0,0 @@
_security_logout:
resource: security.route_loader.logout
type: service

View File

@ -4,6 +4,8 @@
# 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
parameters:
snipStorageType: 'db' # 'db' or 'git
gitStoragePath: '%kernel.project_dir%/var/snips'
services:
# default configuration for services in *this* file
@ -18,4 +20,13 @@ services:
exclude:
- '../src/DependencyInjection/'
- '../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%'

View File

@ -2,16 +2,107 @@
namespace Deployer;
require_once 'deploy/symfony.php';
require_once 'recipe/common.php';
// Project name
set('application', 'snips');
// Project repository
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')
->setRemoteUser('tim')
->setRemoteUser('www-data')
->set('branch', function () {
return input()->getOption('branch') ?: 'master';
return input()->getOption('branch') ?: 'main';
})
->set('deploy_path', '/var/www/snips.loken.nl')
;
->set('deploy_path', '~/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');

View File

@ -1,27 +0,0 @@
<?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');
});

View File

@ -1,65 +0,0 @@
<?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');

View File

@ -1,5 +0,0 @@
{
"dev": {
"host": "https://snips.localhost"
}
}

View File

@ -1,35 +0,0 @@
<?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');
}
}

View File

@ -1,35 +0,0 @@
<?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);
}
}

View File

@ -1,38 +0,0 @@
<?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);
}
}

View File

@ -1,38 +0,0 @@
<?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);
}
}

View File

@ -1,35 +0,0 @@
<?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);
}
}

View File

@ -1,44 +0,0 @@
<?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);
}
}

View File

@ -1,59 +0,0 @@
<?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);
}
}

View File

@ -1,35 +0,0 @@
<?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);
}
}

View File

@ -1,87 +0,0 @@
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;
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

View File

@ -1,45 +0,0 @@
<?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;
}
}

View File

@ -1,39 +0,0 @@
<?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);
}
}

View File

@ -1,76 +0,0 @@
<?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(),
],
]);
}
}

View File

@ -1,8 +0,0 @@
<?php
namespace App\Controller\Api;
interface NormalizableInterface
{
public function normalize(): array;
}

View File

@ -1,14 +0,0 @@
<?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);
}
}

View File

@ -3,37 +3,39 @@
namespace App\Controller;
use App\Entity\Snip;
use App\Entity\SnipContent;
use App\Security\Voter\SnipVoter;
use App\Service\SnipContent\SnipContentService;
use App\Service\SnipServiceFactory;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Routing\Annotation\Route;
#[Route('/version/{snip}', name: 'version')]
class VersionController extends AbstractController
#[Route('/history/{snip}', name: 'history')]
class HistoryController extends AbstractController
{
public function __construct(
private readonly SnipContentService $contentService,
private readonly SnipServiceFactory $snipServiceFactory,
) {}
#[Route('/', name: '_index')]
public function index(Snip $snip): Response
{
$this->denyAccessUnlessGranted(SnipVoter::EDIT, $snip);
return $this->render('version/index.html.twig', [
$snipService = $this->snipServiceFactory->create($snip);
return $this->render('history/index.html.twig', [
'snip' => $snip,
'versions' => $snipService->getVersions(),
'latestVersion' => $snipService->getLatestVersion(),
]);
}
#[Route('/set/{version}', name: '_set')]
public function set(Snip $snip, SnipContent $version): Response
public function set(Snip $snip, string $version): Response
{
$this->denyAccessUnlessGranted(SnipVoter::EDIT, $snip);
$this->contentService->setVersion($snip, $version);
$this->addFlash('success', 'Snip version set to ' . $version->getId());
$this->snipServiceFactory->create($snip)->setVersion($version);
$this->addFlash('success', 'Snip version set to ' . $version);
return $this->redirectToRoute('snip_single', ['snip' => $snip->getId()]);
}
}

View File

@ -4,17 +4,13 @@ namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Routing\Annotation\Route;
class HomeController extends AbstractController
{
#[Route('/', name: 'home')]
public function home(): Response
{
if ($this->getUser()) {
return $this->redirectToRoute('snip_index');
} else {
return $this->redirectToRoute('snip_public');
}
return $this->redirectToRoute('snip_index');
}
}

View File

@ -11,7 +11,7 @@ use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
class SecurityController extends AbstractController

View File

@ -1,37 +0,0 @@
<?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,
]);
}
}

View File

@ -2,64 +2,68 @@
namespace App\Controller;
use App\Controller\Attribute\MapQueryCached;
use App\Dto\SnipFilterRequest;
use App\Entity\Snip;
use App\Form\ConfirmationType;
use App\Form\SnipType;
use App\Repository\SnipRepository;
use App\Security\Voter\SnipVoter;
use App\Service\SnipContent\SnipContentService;
use App\Service\SnipParser\ParserFactory;
use App\Service\SnipParser\Pipeline;
use App\Service\SnipServiceFactory;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Routing\Annotation\Route;
#[Route('/snip', name: 'snip')]
class SnipController extends AbstractController
{
public function __construct(
private readonly SnipRepository $repository,
) {
private readonly SnipRepository $repository,
private readonly SnipServiceFactory $snipServiceFactory,
)
{
}
#[Route('/', name: '_index')]
public function index(#[MapQueryCached] SnipFilterRequest $request): Response
public function index(): Response
{
return $this->render('snip/index.html.twig', [
'snips' => $this->repository->findByRequest($this->getUser(), $request),
'request' => $request,
'snips' => $this->repository->findByUser($this->getUser()),
'title' => 'My Snips',
]);
}
#[Route('/public', name: '_public')]
public function public(): Response
{
return $this->render('snip/public.html.twig', [
'snips' => $this->repository->findPublic(),
return $this->render('snip/index.html.twig', [
'snips' => $this->repository->findPublic($this->getUser()),
'title' => 'Public Snips',
]);
}
#[Route('/single/{snip}', name: '_single')]
public function single(Snip $snip, ParserFactory $pf): Response
public function single(Snip $snip, Pipeline $pl): Response
{
$this->denyAccessUnlessGranted(SnipVoter::VIEW, $snip);
$snipService = $this->snipServiceFactory->create($snip);
dump($snipService);
return $this->render('snip/single.html.twig', [
'snip' => $snip,
'content' => $pf->getBySnip($snip)->parseView($snip->getActiveText()),
'content' => $pl->parse($snipService->get()),
'branch' => $snipService->getCommit(),
]);
}
#[Route('/raw/{snip}', name: '_raw')]
public function raw(Snip $snip, ParserFactory $pf, Request $request): Response
public function raw(Snip $snip, Pipeline $pl, Request $request): Response
{
$this->denyAccessUnlessGranted(SnipVoter::VIEW, $snip);
$response = new Response(
$pf->getBySnip($snip)->parseRaw($snip->getActiveText()),
$pl->clean($this->snipServiceFactory->create($snip)->get()),
Response::HTTP_OK,
['Content-Type' => 'text/html']
);
@ -77,36 +81,20 @@ class SnipController extends AbstractController
}
#[Route('/edit/{snip}', name: '_edit')]
public function edit(Snip $snip, Request $request, SnipContentService $contentService): Response
public function edit(Snip $snip, Request $request): Response
{
$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->add('Save', SubmitType::class);
if ($snip->getId()) {
$form->get('content')->setData($this->snipServiceFactory->create($snip)->get());
}
$form = $this->createForm(SnipType::class, $snip)
->add('Save', SubmitType::class);
$form->get('content')->setData($snip->getActiveText());
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
if (!$isLatest) {
return $this->redirectToRoute('snip_single', [
'snip' => $snip->getId(),
]);
}
$this->repository->save($snip);
$contentService->update(
$snip,
$form->get('content')->getData(),
$form->get('contentName')->getData()
);
$this->snipServiceFactory->create($snip)->update($form->get('content')->getData());
$this->addFlash('success', sprintf('Snip "%s" saved', $snip));
@ -122,35 +110,13 @@ class SnipController extends AbstractController
}
#[Route('/new', name: '_new')]
public function new(Request $request, SnipContentService $contentService): Response
public function new(Request $request): Response
{
$snip = new Snip();
$snip->setCreatedAtNow()
$snip->setCreatedAtTodayNoSeconds()
->setCreatedBy($this->getUser());
$form = $this->createForm(SnipType::class, $snip);
$form->add('Create', SubmitType::class);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$this->repository->save($snip);
$contentService->update(
$snip,
$form->get('content')->getData(),
$form->get('contentName')->getData()
);
$this->addFlash('success', sprintf('Snip "%s" created', $snip));
return $this->redirectToRoute('snip_single', [
'snip' => $snip->getId(),
]);
}
return $this->render('snip/create.html.twig', [
'snip' => $snip,
'form' => $form->createView(),
]);
return $this->edit($snip, $request);
}
#[Route('/delete/{snip}', name: '_delete')]
@ -161,31 +127,15 @@ class SnipController extends AbstractController
$form = $this->createForm(ConfirmationType::class);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$snip->setActiveVersion(null);
$this->repository->save($snip);
$this->snipServiceFactory->create($snip)->delete();
$this->repository->remove($snip);
$this->addFlash('success', sprintf('Snip "%s" deleted', $snip));
return $this->redirectToRoute('snip_index');
}
return $this->render('generic/form.html.twig', [
return $this->render('form.html.twig', [
'message' => sprintf('Do you really want to delete "%s"?', $snip),
'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()]);
}
}

View File

@ -4,21 +4,24 @@ namespace App\Controller;
use App\Entity\User;
use App\Form\ProfileType;
use App\Form\UserSettingsType;
use App\Service\LastRelease;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Uid\Uuid;
#[Route('/user', name: 'user')]
class UserController extends AbstractController
{
public function __construct(
private readonly EntityManagerInterface $em,
) {}
private EntityManagerInterface $em,
)
{
}
#[Route('/profile', name: '_profile')]
public function profile(
@ -43,8 +46,7 @@ class UserController extends AbstractController
$user,
$form->get('plainPassword')->getData()
)
)
;
);
}
}
$this->addFlash('success', 'Profile updated successfully');
@ -58,20 +60,4 @@ class UserController extends AbstractController
'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');
}
}

View File

@ -1,8 +0,0 @@
<?php
namespace App\Dto;
interface CachableDtoInterface
{
}

View File

@ -1,32 +0,0 @@
<?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,
];
}
}

View File

@ -1,28 +0,0 @@
<?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);
}
}
}

View File

@ -40,17 +40,10 @@ trait TrackedTrait
return $this;
}
public function setCreatedAtNowNoSeconds(): self
public function setCreatedAtTodayNoSeconds(): self
{
$this->setCreatedAt(DateTime::createFromFormat('Y-m-d H:i', date('Y-m-d H:i')));
return $this;
}
public function setCreatedAtNow(): self
{
$this->setCreatedAt(new DateTime());
return $this;
}
}

View File

@ -4,7 +4,6 @@ namespace App\Entity;
use App\Entity\Helpers\TrackedTrait;
use App\Repository\SnipRepository;
use App\Service\SnipContent\SnipContentService;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
@ -23,33 +22,17 @@ class Snip
private ?string $name = null;
#[ORM\Column]
private bool $public = false;
private ?bool $public = null;
#[ORM\OneToMany(mappedBy: 'snip', targetEntity: SnipContent::class, orphanRemoval: true)]
private Collection $snipContents;
#[ORM\OneToOne]
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;
#[ORM\Column(length: 255, nullable: true)]
private ?string $activeCommit = null;
public function __construct()
{
$this->snipContents = new ArrayCollection();
$this->tags = new ArrayCollection();
}
public function __toString(): string
@ -57,11 +40,6 @@ class Snip
return $this->name ?? '';
}
public function getActiveText(): string
{
return SnipContentService::rebuildText($this->getActiveVersion());
}
public function getId(): ?int
{
return $this->id;
@ -121,82 +99,14 @@ class Snip
return $this;
}
public function getLatestVersion(): ?SnipContent
public function getActiveCommit(): ?string
{
return $this->snipContents->last() ?: null;
return $this->activeCommit;
}
public function getActiveVersion(): ?SnipContent
public function setActiveCommit(?string $activeCommit): static
{
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);
}
$this->activeCommit = $activeCommit;
return $this;
}

View File

@ -32,12 +32,6 @@ class SnipContent
#[ORM\Column(type: Types::TEXT, nullable: true)]
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()
{
$this->children = new ArrayCollection();
@ -113,28 +107,4 @@ class SnipContent
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;
}
}

View File

@ -1,98 +0,0 @@
<?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;
}
}

View File

@ -34,9 +34,6 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
#[ORM\Column(length: 255)]
private ?string $email = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $apiKey = null;
public function __toString(): string
{
return $this->name ?? '';
@ -135,16 +132,4 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
return $this;
}
public function getApiKey(): ?string
{
return $this->apiKey;
}
public function setApiKey(?string $apiKey): static
{
$this->apiKey = $apiKey;
return $this;
}
}

View File

@ -3,40 +3,22 @@
namespace App\Form;
use App\Entity\Snip;
use App\Service\SnipParser\ParserFactory;
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\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class SnipType extends AbstractType
{
public function __construct(
private readonly ParserFactory $parserFactory,
) {}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('name')
->add('parser', ChoiceType::class, [
'choice_label' => fn(string $parser) => ucfirst($parser),
'choices' => $this->parserFactory->getChoices(),
])
->add('content', TextareaType::class, [
'attr' => ['rows' => 20],
'mapped' => false,
])
->add('tags', TagsType::class)
->add('public', SwitchType::class)
->add('visible', SwitchType::class)
->add('contentName', TextType::class, [
'label' => 'Change description (optional)',
'mapped' => false,
'required' => false,
])
->add('public')
;
}

View File

@ -1,22 +0,0 @@
<?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;
}
}

View File

@ -1,93 +0,0 @@
<?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;
}
}

13
src/Git/CustomGit.php Normal file
View File

@ -0,0 +1,13 @@
<?php
namespace App\Git;
use CzProject\GitPhp\Git;
class CustomGit extends Git
{
public function open($directory): CustomGitRepository
{
return new CustomGitRepository($directory, $this->runner);
}
}

View File

@ -0,0 +1,33 @@
<?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;
}
}

26
src/Git/SimpleCommit.php Normal file
View File

@ -0,0 +1,26 @@
<?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 Normal file
View File

View File

@ -2,12 +2,10 @@
namespace App\Repository;
use App\Dto\SnipFilterRequest;
use App\Entity\Snip;
use App\Entity\User;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
use Symfony\Component\Security\Core\User\UserInterface;
/**
* @extends ServiceEntityRepository<Snip>
@ -42,72 +40,24 @@ class SnipRepository extends ServiceEntityRepository
}
}
public function findByRequest(UserInterface $user, SnipFilterRequest $request): array
public function findByUser(User $user): array
{
$qb = $this
->createQueryBuilder('s')
->where('s.createdBy = :user')
$qb = $this->createQueryBuilder('s');
$qb->where('s.createdBy = :user')
->setParameter('user', $user)
;
$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)
;
}
->orderBy('s.createdAt', 'DESC');
return $qb->getQuery()->getResult();
}
public function findPublic(?User $user = null): array
public function findPublic(User $user): array
{
$qb = $this
->createQueryBuilder('s')
->where('s.public = true')
->andWhere('s.visible = true')
->andWhere('s.archived = false')
->orderBy('s.createdAt', 'DESC')
;
if ($user) {
$qb->andWhere('s.createdBy != :user')
->setParameter('user', $user)
;
}
$qb = $this->createQueryBuilder('s');
$qb->where('s.public = :public')
->andWhere('s.createdBy != :user')
->setParameter('public', true)
->setParameter('user', $user)
->orderBy('s.createdAt', 'DESC');
return $qb->getQuery()->getResult();
}

View File

@ -1,30 +0,0 @@
<?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]);
}
}

View File

@ -1,68 +0,0 @@
<?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);
}
}

View File

@ -24,14 +24,13 @@ class SnipVoter extends Voter
{
/** @var Snip $subject */
$user = $token->getUser();
switch ($attribute) {
case self::VIEW:
if ($subject->isPublic()) {
return true;
}
case self::EDIT:
$user = $token->getUser();
if (!$user instanceof UserInterface) {
return false;
}

View File

@ -3,7 +3,6 @@
namespace App\Service;
use JetBrains\PhpStorm\ArrayShape;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
class LastRelease
{
@ -19,7 +18,7 @@ class LastRelease
])]
private array $lastRelease = [];
public function __construct(#[Autowire('%kernel.project_dir%/release.json')] string $jsonFile)
public function __construct(string $jsonFile)
{
if (file_exists($jsonFile)) {
$this->lastRelease = json_decode(file_get_contents($jsonFile), true);

View File

@ -1,65 +0,0 @@
<?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);
}
}

View File

@ -1,15 +0,0 @@
<?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;
}
}

View File

@ -1,16 +0,0 @@
<?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);
}
}

View File

@ -1,222 +0,0 @@
<?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;
}
}

View File

@ -0,0 +1,74 @@
<?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();
}
}

View File

@ -0,0 +1,80 @@
<?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;
}
}

View File

@ -0,0 +1,21 @@
<?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;
}

View File

@ -1,97 +0,0 @@
<?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();
}
}

View File

@ -1,28 +0,0 @@
<?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;
}

View File

@ -1,30 +0,0 @@
<?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);
}
}

View File

@ -1,59 +0,0 @@
<?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);
}
}

View File

@ -1,16 +0,0 @@
<?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>';
}
}

View File

@ -1,81 +0,0 @@
<?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',
],
],
];
$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_numeric($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));
}
}
}
}

View File

@ -1,52 +0,0 @@
<?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();
}
}

View File

@ -1,15 +0,0 @@
<?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;
}

View File

@ -0,0 +1,43 @@
<?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
);
}
}

View File

@ -1,6 +1,6 @@
<?php
namespace App\Service\SnipParser\Generic;
namespace App\Service\SnipParser\Stages;
use League\Pipeline\StageInterface;

View File

@ -0,0 +1,44 @@
<?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);
}
}

View File

@ -1,17 +1,16 @@
<?php
namespace App\Service\SnipParser\Generic;
namespace App\Service\SnipParser\Stages;
use InvalidArgumentException;
use League\Pipeline\StageInterface;
use Tempest\Highlight\Highlighter;
readonly class ReplaceBlocksStage implements StageInterface
class ReplaceBlocksStage implements StageInterface
{
public function __construct(
public string $openTag = '<pre><code>',
public string $closeTag = '</code></pre>',
public string $delimiter = '```'
public readonly string $openTag = '<pre><code>',
public readonly string $closeTag = '</code></pre>',
public readonly string $delimiter = '```'
) {}
public function __invoke(mixed $payload): string
@ -27,9 +26,8 @@ readonly class ReplaceBlocksStage implements StageInterface
{
$pattern = sprintf('/%s(.+?)%s/s', preg_quote($this->delimiter), preg_quote($this->delimiter));
$highlighter = new Highlighter()->withGutter();
return preg_replace_callback($pattern, function ($matches) use ($highlighter) {
return $this->openTag . $highlighter->parse(trim($matches[1]), 'php') . $this->closeTag;
return preg_replace_callback($pattern, function ($matches) {
return $this->openTag . trim($matches[1]) . $this->closeTag;
}, $text);
}
}

View File

@ -1,15 +1,15 @@
<?php
namespace App\Service\SnipParser\Generic;
namespace App\Service\SnipParser\Stages;
use League\Pipeline\StageInterface;
readonly class ReplaceStage implements StageInterface
class ReplaceStage implements StageInterface
{
// replaces a string with another string
public function __construct(
public string $search,
public string $replace,
public readonly string $search,
public readonly string $replace,
) {}
public function __invoke(mixed $payload): string

View File

@ -1,6 +1,6 @@
<?php
namespace App\Service\SnipParser\Generic;
namespace App\Service\SnipParser\Stages;
use App\Repository\SnipRepository;
use App\Security\Voter\SnipVoter;
@ -9,12 +9,12 @@ use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
readonly class UrlReferenceStage implements StageInterface
class UrlReferenceStage implements StageInterface
{
public function __construct(
#[Autowire(lazy: true)] private UrlGeneratorInterface $router,
#[Autowire(lazy: true)] private Security $security,
#[Autowire(lazy: true)] private SnipRepository $snipRepository,
#[Autowire(lazy: true)] private readonly UrlGeneratorInterface $router,
#[Autowire(lazy: true)] private readonly Security $security,
#[Autowire(lazy: true)] private readonly SnipRepository $snipRepository,
) {}
public function __invoke(mixed $payload): string
@ -37,7 +37,7 @@ readonly class UrlReferenceStage implements StageInterface
}
$url = $this->router->generate('snip_single', ['snip' => $snip->getId()]);
return sprintf('<a href="%s">%s</a>', $url, $snip);
return sprintf('<a href="%s" title="Owner: %s">#%s</a>', $url, $snip->getCreatedBy(), $snip->getId());
}, $payload);
}
}

View File

@ -1,61 +0,0 @@
<?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;
}
}

View File

@ -1,63 +0,0 @@
<?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);
}
}

View File

@ -1,30 +0,0 @@
<?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');
}
}

View File

@ -3,17 +3,48 @@
namespace App\Service;
use App\Entity\Snip;
use App\Service\SnipContent\SnipContentService;
use App\Git\CustomGit;
use App\Service\SnipContent\SnipContentDB;
use App\Service\SnipContent\SnipContentGit;
use App\Service\SnipContent\SnipContentInterface;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;
readonly class SnipServiceFactory
class SnipServiceFactory
{
public function __construct(
private EntityManagerInterface $em,
private readonly string $gitStoragePath,
private readonly string $storageType,
private readonly Security $security,
private readonly EntityManagerInterface $em,
) {}
public function create(Snip $snip): SnipContentService
public function create(Snip $snip): SnipContentInterface
{
return new SnipContentService($snip, $this->em);
return match ($this->storageType) {
'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);
}
}

View File

@ -1,45 +0,0 @@
<?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;
}
}

View File

@ -1,11 +1,11 @@
{
"doctrine/doctrine-bundle": {
"version": "2.14",
"version": "2.9",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "2.13",
"ref": "620b57f496f2e599a6015a9fa222c2ee0a32adcb"
"version": "2.8",
"ref": "6b43b7b6ff6bf2551f2933ebeb66721fa3db8fbc"
},
"files": [
"config/packages/doctrine.yaml",
@ -27,12 +27,12 @@
]
},
"symfony/console": {
"version": "7.2",
"version": "6.2",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "5.3",
"ref": "1781ff40d8a17d87cf53f8d4cf0c8346ed2bb461"
"ref": "da0c8be8157600ad34f10ff0c9cc91232522e047"
},
"files": [
"bin/console"
@ -51,37 +51,24 @@
]
},
"symfony/flex": {
"version": "2.5",
"version": "2.2",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "2.4",
"ref": "52e9754527a15e2b79d9a610f98185a1fe46622a"
"version": "1.0",
"ref": "146251ae39e06a95be0fe3d13c807bcf3938b172"
},
"files": [
".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"
".env"
]
},
"symfony/framework-bundle": {
"version": "7.2",
"version": "6.2",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "7.2",
"ref": "87bcf6f7c55201f345d8895deda46d2adbdbaa89"
"version": "6.2",
"ref": "af47254c5e4cd543e6af3e4508298ffebbdaddd3"
},
"files": [
"config/packages/cache.yaml",
@ -104,24 +91,24 @@
}
},
"symfony/monolog-bundle": {
"version": "3.10",
"version": "3.8",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "3.7",
"ref": "aff23899c4440dd995907613c1dd709b6f59503f"
"ref": "213676c4ec929f046dfde5ea8e97625b81bc0578"
},
"files": [
"config/packages/monolog.yaml"
]
},
"symfony/routing": {
"version": "7.2",
"version": "6.2",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "7.0",
"ref": "21b72649d5622d8f7da329ffb5afb232a023619d"
"version": "6.2",
"ref": "e0a11b4ccb8c9e70b574ff5ad3dfdcd41dec5aa6"
},
"files": [
"config/packages/routing.yaml",
@ -129,25 +116,24 @@
]
},
"symfony/security-bundle": {
"version": "7.2",
"version": "6.2",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "6.4",
"ref": "2ae08430db28c8eb4476605894296c82a642028f"
"version": "6.0",
"ref": "8a5b112826f7d3d5b07027f93786ae11a1c7de48"
},
"files": [
"config/packages/security.yaml",
"config/routes/security.yaml"
"config/packages/security.yaml"
]
},
"symfony/twig-bundle": {
"version": "7.2",
"version": "6.2",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "6.4",
"ref": "cab5fd2a13a45c266d45a7d9337e28dee6272877"
"version": "5.4",
"ref": "bb2178c57eee79e6be0b297aa96fc0c0def81387"
},
"files": [
"config/packages/twig.yaml",
@ -155,34 +141,36 @@
]
},
"symfony/uid": {
"version": "7.2",
"version": "7.0",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "7.0",
"ref": "0df5844274d871b37fc3816c57a768ffc60a43a5"
"version": "6.2",
"ref": "d294ad4add3e15d7eb1bae0221588ca89b38e558"
},
"files": []
"files": [
"config/packages/uid.yaml"
]
},
"symfony/validator": {
"version": "7.2",
"version": "6.2",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "7.0",
"ref": "8c1c4e28d26a124b0bb273f537ca8ce443472bfd"
"version": "5.3",
"ref": "c32cfd98f714894c4f128bb99aa2530c1227603c"
},
"files": [
"config/packages/validator.yaml"
]
},
"symfony/web-profiler-bundle": {
"version": "7.2",
"version": "6.2",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "6.1",
"ref": "8b51135b84f4266e3b4c8a6dc23c9d1e32e543b7"
"ref": "e42b3f0177df239add25373083a564e5ead4e13a"
},
"files": [
"config/packages/web_profiler.yaml",

View File

@ -3,15 +3,12 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>
{% 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">
<title>{% block title %}SNIPS{% endblock %}</title>
<link rel="shortcut icon" type="image/jpg" href="/favicon.png">
{% block css %}
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"
integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH"
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet"
integrity="sha384-1BmE4kWBq78iYhFldvKuhfTAU6auU8tT94WrHftjDbrCEXSU1oBoqyl2QvZ6jIW3"
crossorigin="anonymous">
{% endblock %}
</head>
@ -40,13 +37,27 @@
</div>
{# body blocks #}
{% block content %}
{% block bodyraw %}
<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 %}
{# javascript block #}
{% block js %}
<script src="https://kit.fontawesome.com/3471b6556e.js" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.min.js"
integrity="sha384-QJHtvGhmr9XOIpI6YVutG+2QOK9T+ZnN4kzFN1RtK3zEFEIsxhlmWl5/YESvpZ13"
crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/jquery@3.6.0/dist/jquery.min.js"
integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4="
crossorigin="anonymous"></script>

View File

@ -1,8 +0,0 @@
{% extends 'base/base.html.twig' %}
{% block content %}
<div class="container">
{% block container %}
{% endblock %}
</div>
{% endblock %}

View File

@ -1,9 +1,6 @@
<nav class="navbar navbar-expand-md navbar-dark bg-dark" style="z-index: 1;">
<div class="container-fluid">
<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>
<a title="BlueLinked Eco System" class="navbar-brand" href="{{ path('home') }}">SNIPS</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbar"
aria-controls="navbar" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
@ -18,10 +15,10 @@
<li class="nav-item">
<a class="nav-link" href="{{ path('snip_new') }}">New snip</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ path('snip_public') }}">Public snips</a>
</li>
{% endif %}
<li class="nav-item">
<a class="nav-link" href="{{ path('snip_public') }}">Public snips</a>
</li>
</ul>
<ul class="navbar-nav my-2 my-lg-0">
{% if app.environment == 'dev' %}
@ -38,9 +35,6 @@
<a class="nav-link" href="{{ path('logout') }}">Logout</a>
</li>
{% else %}
<li class="nav-item">
<a class="nav-link" href="{{ path('login') }}">Login</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ path('register') }}">Register</a>
</li>

View File

@ -1,10 +0,0 @@
{% 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 %}

View File

@ -1,17 +0,0 @@
{% 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 %}

View File

@ -1,17 +0,0 @@
{% 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 %}

View File

@ -1,36 +0,0 @@
{% 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 %}

6
templates/form.html.twig Normal file
View File

@ -0,0 +1,6 @@
{% 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