Compare commits

..

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

96 changed files with 1384 additions and 3474 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,40 +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/console": "7.2.*",
"symfony/dotenv": "7.2.*",
"symfony/console": "7.0.*",
"symfony/dotenv": "7.0.*",
"symfony/flex": "^2",
"symfony/form": "7.2.*",
"symfony/framework-bundle": "7.2.*",
"symfony/form": "7.0.*",
"symfony/framework-bundle": "7.0.*",
"symfony/monolog-bundle": "^3.0",
"symfony/property-access": "7.2.*",
"symfony/property-info": "7.2.*",
"symfony/runtime": "7.2.*",
"symfony/security-bundle": "7.2.*",
"symfony/serializer": "7.2.*",
"symfony/twig-bundle": "7.2.*",
"symfony/uid": "7.2.*",
"symfony/validator": "7.2.*",
"symfony/yaml": "7.2.*",
"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": "7.2.*",
"symfony/debug-bundle": "7.0.*",
"symfony/maker-bundle": "^1.48",
"symfony/stopwatch": "7.2.*",
"symfony/web-profiler-bundle": "7.2.*"
"symfony/stopwatch": "7.0.*",
"symfony/web-profiler-bundle": "7.0.*"
},
"config": {
"allow-plugins": {
@ -52,6 +47,11 @@
"App\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"App\\Tests\\": "tests/"
}
},
"replace": {
"symfony/polyfill-ctype": "*",
"symfony/polyfill-iconv": "*",
@ -79,7 +79,7 @@
"extra": {
"symfony": {
"allow-contrib": false,
"require": "7.2.*"
"require": "7.0.*"
}
}
}

2338
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

@ -3,7 +3,6 @@
namespace Deployer;
require_once 'recipe/common.php';
require_once 'deploy/git.php';
// Project name
set('application', 'snips');
@ -14,64 +13,96 @@ 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']);
// 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);
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');
// Hosts
host('snips.loken.nl')
->setRemoteUser('www-data')
->set('branch', function () {
return input()->getOption('branch') ?: 'master';
return input()->getOption('branch') ?: 'main';
})
->set('deploy_path', '~/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', fn() => run('{{bin/php}} {{bin/console}} cache:clear {{console_options}} --no-warmup'));
task('cache:clear', function () {
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}}'));
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));
// $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');
});
desc('Shows current deployed version');
task('deploy:current', function () {
$current = run('readlink {{deploy_path}}/current');
writeln("Current deployed version: $current");
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',
'database:migrate',
'cache:clear',
'cache:warmup',
'database:migrate',
'deployment:log',
'deploy:symlink',
'deploy:unlock',
'deploy:cleanup',
'deploy:current',
]);
after('deploy', 'deploy:success');
after('deploy:failed', 'deploy:unlock');
// [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,5 +0,0 @@
{
"dev": {
"host": "http://snips.local.loken.nl"
}
}

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,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, SnipContentService $cs): Response
{
$this->denyAccessUnlessGranted(SnipVoter::VIEW, $snip);
return $this->successResponse([
'id' => $snip->getId(),
'content' => $cs->getActiveText($snip),
'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' => $cs->getActiveText($snip),
'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

@ -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,39 +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(
private readonly SnipContentService $contentService,
) {}
#[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(
$this->contentService->rebuildText($from),
$this->contentService->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\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\HttpKernel\Attribute\MapQueryString;
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 SnipContentService $contentService,
) {}
private readonly SnipServiceFactory $snipServiceFactory,
)
{
}
#[Route('/', name: '_index')]
public function index(#[MapQueryString] 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($this->contentService->getActiveText($snip)),
'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($this->contentService->getActiveText($snip)),
$pl->clean($this->snipServiceFactory->create($snip)->get()),
Response::HTTP_OK,
['Content-Type' => 'text/html']
);
@ -67,8 +71,7 @@ class SnipController extends AbstractController
->setVary(['Accept', 'Accept-Encoding'])
->setEtag(md5($response->getContent()))
->setTtl(3600)
->setClientTtl(300)
;
->setClientTtl(300);
if (!$request->isNoCache()) {
$response->isNotModified($request);
@ -82,30 +85,16 @@ class SnipController extends AbstractController
{
$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->contentService->getActiveText($snip));
$form->get('content')->setData($this->snipServiceFactory->create($snip)->get());
}
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
if (!$isLatest) {
return $this->redirectToRoute('snip_single', [
'snip' => $snip->getId(),
]);
}
$this->repository->save($snip);
$this->contentService->update($snip, $form->get('content')->getData());
$this->snipServiceFactory->create($snip)->update($form->get('content')->getData());
$this->addFlash('success', sprintf('Snip "%s" saved', $snip));
@ -124,9 +113,8 @@ class SnipController extends AbstractController
public function new(Request $request): Response
{
$snip = new Snip();
$snip->setCreatedAtNow()
->setCreatedBy($this->getUser())
;
$snip->setCreatedAtTodayNoSeconds()
->setCreatedBy($this->getUser());
return $this->edit($snip, $request);
}
@ -139,13 +127,13 @@ class SnipController extends AbstractController
$form = $this->createForm(ConfirmationType::class);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$this->contentService->delete($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(),
]);

View File

@ -4,13 +4,14 @@ 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')]
@ -59,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,17 +0,0 @@
<?php
namespace App\Dto;
readonly class SnipFilterRequest
{
public function __construct(
public bool $onlyVisible = true,
) {}
public function toArray(): array
{
return [
'onlyVisible' => $this->onlyVisible,
];
}
}

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

@ -22,19 +22,13 @@ 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(length: 255, nullable: true)]
private ?string $activeCommit = null;
public function __construct()
{
@ -105,43 +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;
$this->activeCommit = $activeCommit;
return $this;
}

View File

@ -32,9 +32,6 @@ class SnipContent
#[ORM\Column(type: Types::TEXT, nullable: true)]
private ?string $text = null;
#[ORM\Column(nullable: true)]
private ?array $diff = null;
public function __construct()
{
$this->children = new ArrayCollection();
@ -110,16 +107,4 @@ class SnipContent
return $this;
}
public function getDiff(): ?array
{
return $this->diff;
}
public function setDiff(?array $diff): static
{
$this->diff = $diff;
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,33 +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\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('public', SwitchType::class)
->add('visible', SwitchType::class)
->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;
}
}

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,36 +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)
->orderBy('s.createdAt', 'DESC')
;
$qb->andWhere('s.visible = :visible')
->setParameter('visible', $request->onlyVisible)
;
->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')
->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,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

@ -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,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,236 +0,0 @@
<?php
namespace App\Service\SnipContent;
class MyersDiff
{
private const string NEWLINE = "\r\n";
/**
* 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 = self::explode($textFrom);
} else {
$a = $textFrom;
}
if (is_string($textTo)) {
$b = self::explode($textTo);
} else {
$b = $textTo;
}
if ($compare === null) {
$compare = function ($x, $y) {
return $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 = self::explode($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 self::implode($b);
}
public static function buildDiffLines(string $textFrom, string $textTo): array
{
$a = self::explode($textFrom);
$b = self::explode($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;
}
private static function explode(string $text): array
{
return explode(self::NEWLINE, $text);
}
private static function implode(array $text): string
{
return implode(self::NEWLINE, $text);
}
}

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,108 +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 $snipContents): void
{
$parentContent = $snip->getActiveVersion();
if ($this->rebuildText($parentContent) === $snipContents) {
return;
}
// Create new snipContent entity with previous one as parent
$content = new SnipContent();
$content
->setText($snipContents)
->setSnip($snip)
;
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 function getActiveText(Snip $snip): string
{
return $this->rebuildText($snip->getActiveVersion());
}
public 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(
$this->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 = $this->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($this->rebuildText($content));
$content->setDiff(null);
$this->em->persist($content);
$this->em->flush();
}
public function delete(Snip $snip): void
{
foreach ($snip->getSnipContents() as $snipContent) {
$this->em->remove($snipContent);
}
}
}

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 class="hljs">%s</code></pre>', htmlspecialchars($exception->getMessage()));
}
}
abstract function safeParseView(string $content): string;
}

View File

@ -1,60 +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,
#[Autowire(lazy: true)] private readonly SnipContentService $snipContentService,
) {}
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(
$this->snipContentService->rebuildText($content)
);
}, $payload);
}
}

View File

@ -1,13 +0,0 @@
<?php
namespace App\Service\SnipParser\Html;
use App\Service\SnipParser\AbstractParser;
class HtmlParser extends AbstractParser
{
public function safeParseView(string $content): string
{
return sprintf('<pre><code class="hljs">%s</code></pre>', htmlspecialchars($content));
}
}

View File

@ -1,53 +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\GithubFlavoredMarkdownConverter;
use League\CommonMark\Node\Inline\Text;
use League\CommonMark\Node\Query;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Routing\RouterInterface;
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
{
$converter = new GithubFlavoredMarkdownConverter();
$converter->getEnvironment()->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);
foreach ($linkNodes as $linkNode) {
$url = $linkNode->getUrl();
$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

@ -1,18 +1,22 @@
<?php
namespace App\Service\SnipParser\Generic;
namespace App\Service\SnipParser;
use App\Service\SnipParser\AbstractParser;
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 GenericParser extends AbstractParser
class Pipeline
{
public function __construct(
private readonly UrlReferenceStage $referenceStage,
private readonly IncludeReferenceStage $includeStage,
private readonly UrlReferenceStage $referenceStage,
private readonly IncludeReferenceStage $includeStage,
) {}
public function safeParseView(string $content): string
public function parse(string $payload): string
{
$builder = new PipelineBuilder();
$pipeline = $builder
@ -25,15 +29,15 @@ class GenericParser extends AbstractParser
->build()
;
return $pipeline->process($content);
return $pipeline->process($payload);
}
public function parseRaw(string $content): string
public function clean(string $payload): string
{
return str_replace(
['```', '``'],
'',
$content
$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,6 +1,6 @@
<?php
namespace App\Service\SnipParser\Generic;
namespace App\Service\SnipParser\Stages;
use InvalidArgumentException;
use League\Pipeline\StageInterface;

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,56 +0,0 @@
<?php
namespace App\Service\SnipParser\Twig;
use App\Entity\Snip;
use App\Repository\SnipRepository;
use App\Service\SnipContent\SnipContentService;
use Twig\Error\LoaderError;
use Twig\Loader\LoaderInterface;
use Twig\Source;
class SnipLoader implements LoaderInterface
{
public function __construct(
private readonly SnipRepository $repository,
private readonly SnipContentService $contentService,
) {}
public function getSourceContext(string $name): Source
{
return new Source($this->contentService->getActiveText($this->getFromKey($name)), $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));
}
return $snip;
}
}

View File

@ -1,43 +0,0 @@
<?php
namespace App\Service\SnipParser\Twig;
use App\Repository\SnipRepository;
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,
) {}
public function getFunctions(): array
{
return [
new TwigFunction('snipPath', $this->snipPath(...)),
new TwigFunction('snipLink', $this->snipLink(...), [
'is_safe' => ['html'],
]),
];
}
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);
}
}

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,11 +1,11 @@
{
"doctrine/doctrine-bundle": {
"version": "2.14",
"version": "2.9",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "2.13",
"ref": "8d96c0b51591ffc26794d865ba3ee7d193438a83"
"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,12 +3,12 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% if title is defined %}{{ title }}{% else %}SNIPS{% endif %}</title>
<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>
@ -37,7 +37,19 @@
</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 #}

View File

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

View File

@ -1,6 +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') }}">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>
@ -15,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' %}
@ -35,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-sm mx-auto">
{% block column1 %}{% endblock %}
</div>
<div class="col-sm mx-auto">
{% block column2 %}{% endblock %}
</div>
</div>
{% endblock %}

View File

@ -1,32 +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>{{ line.from }}</td>
<td>{{ 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 %}

View File

@ -1,7 +0,0 @@
{% extends 'base/one.column.html.twig' %}
{% set title %}{{ message }}{% endset %}
{% block body %}
{{ form(form) }}
{% endblock %}

View File

@ -1,5 +0,0 @@
{% extends 'base/one.column.html.twig' %}
{% block body %}
{{ text | nl2br }}
{% endblock %}

View File

@ -0,0 +1,20 @@
{% extends 'base/base.html.twig' %}
{% block title %}Snip {{ snip }}{% endblock %}
{% block body %}
<a href="{{ path('snip_single', {snip: snip.id}) }}" class="btn btn-primary">
<i class="fa fa-arrow-left"></i> Back
</a>
<a href="{{ path('history_set', {version: latestVersion, snip: snip.id}) }}" class="btn btn-warning">
<i class="fa fa-refresh"></i> Latest
</a>
<br><br>
<div class="list-group">
{% for version in versions %}
<a class="list-group-item" href="{{ path('history_set', {version: version.id, snip: snip.id}) }}">
{{ version.name }} - {{ version.id }}
</a>
{% endfor %}
</div>
{% endblock %}

View File

@ -1,6 +1,6 @@
{% extends 'base/one.column.html.twig' %}
{% extends 'base/base.html.twig' %}
{% set title %}Login{% endset %}
{% block title %}Login{% endblock %}
{% block body %}
<form action="{{ path('login') }}" method="post">
@ -12,6 +12,7 @@
You are already logged in as {{ app.user }}, <a href="{{ path('logout') }}">Logout</a>
</div>
{% endif %}
<h1 class="h3 mb-3 font-weight-normal">Please login</h1>
<label for="inputUsername">Username</label>
<input type="text" value="{{ last_username }}" name="_username" id="inputUsername" class="form-control" required
autofocus>

View File

@ -1,7 +1,8 @@
{% extends 'base/one.column.html.twig' %}
{% extends 'base/base.html.twig' %}
{% set title %}Register{% endset %}
{% block title %}Register{% endblock %}
{% block body %}
<h1 class="h3 mb-3 font-weight-normal">Register new user</h1>
{{ form(registrationForm) }}
{% endblock %}

View File

@ -0,0 +1,5 @@
{% extends 'base/base.html.twig' %}
{% block body %}
{{ text | nl2br }}
{% endblock %}

View File

@ -1,13 +1,3 @@
{% if snip.visible %}
<span class="badge bg-success">
<i class="fa fa-eye"></i>
</span>
{% else %}
<span class="badge bg-secondary">
<i class="fa fa-eye-slash"></i>
</span>
{% endif %}
{% if snip.public %}
<span class="badge bg-info">
<i class="fa fa-lock-open"></i>

View File

@ -1,10 +1,6 @@
{% extends 'base/one.column.html.twig' %}
{% extends 'base/base.html.twig' %}
{% if snip.id %}
{% set title = 'Edit Snip ' ~ snip %}
{% else %}
{% set title = 'Create Snip' %}
{% endif %}
{% block title %}Edit {{ snip }}{% endblock %}
{% block body %}
{% if snip.id %}
@ -17,5 +13,6 @@
<i class="fa fa-list"></i>
Index
</a><br><br>
<h2>Editing {{ snip }}</h2>
{{ form(form) }}
{% endblock %}

View File

@ -1,24 +1,22 @@
{% extends 'base/one.column.html.twig' %}
{% extends 'base/base.html.twig' %}
{% set title = 'My Snips' %}
{% block title %}{{ title }}{% endblock %}
{% block body %}
<h1>{{ title }}</h1>
<a class="btn btn-success" href="{{ path('snip_new') }}">
<i class="fa fa-plus"></i> Add
</a>
{% if request.onlyVisible %}
<a class="btn btn-secondary" href="{{ path('snip_index', {onlyVisible: false}) }}">Show hidden</a>
{% else %}
<a class="btn btn-secondary" href="{{ path('snip_index', {onlyVisible: true}) }}">Hide hidden</a>
{% endif %}
<br><br>
</a><br><br>
<div class="list-group">
{% for snip in snips %}
<a class="list-group-item d-flex justify-content-between" href="{{ path('snip_single', {snip: snip.id}) }}">
<span>
<a class="list-group-item" href="{{ path('snip_single', {snip: snip.id}) }}">
{% if snip.createdBy == app.user %}
{{ include('snip/badge.html.twig', {snip: snip}) }}
{{ snip }}
</span>
{% endif %}
{{ snip }}
{% if snip.createdBy != app.user %}
{{ include('user/badge.html.twig', {user: snip.createdBy}) }}
{% endif %}
</a>
{% endfor %}
</div>

View File

@ -1,16 +0,0 @@
{% extends 'base/one.column.html.twig' %}
{% set title = 'Public Snips' %}
{% block body %}
<div class="list-group">
{% for snip in snips %}
<a class="list-group-item d-flex justify-content-between" href="{{ path('snip_single', {snip: snip.id}) }}">
<span>
{{ snip }}
</span>
{{ include('user/badge.html.twig', {user: snip.createdBy}) }}
</a>
{% endfor %}
</div>
{% endblock %}

View File

@ -1,23 +1,17 @@
{% extends 'base/one.column.html.twig' %}
{% extends 'base/base.html.twig' %}
{% set title %}Snip {{ snip }}{% endset %}
{% block title %}Snip {{ snip }}{% endblock %}
{% block body %}
{% if app.user %}
<a href="{{ path('snip_index') }}" class="btn btn-primary">
<i class="fa fa-arrow-left"></i> Back
</a>
{% else %}
<a href="{{ path('snip_public') }}" class="btn btn-primary">
<i class="fa fa-arrow-left"></i> Index
</a>
{% endif %}
<a href="{{ path('snip_index') }}" class="btn btn-primary">
<i class="fa fa-arrow-left"></i> Back
</a>
{% if is_granted('edit', snip) %}
<a class="btn btn-warning" href="{{ path('snip_edit', {snip: snip.id}) }}">
<i class="fa fa-pencil" aria-hidden="true"></i> Edit
</a>
<a class="btn btn-info" href="{{ path('version_index', {snip: snip.id}) }}">
<i class="fa fa-history" aria-hidden="true"></i> Versions
<a class="btn btn-info" href="{{ path('history_index', {snip: snip.id}) }}">
<i class="fa fa-history" aria-hidden="true"></i> History
</a>
<a href="{{ path('snip_delete', {snip: snip.id}) }}" class="btn btn-danger">
<i class="fa fa-trash"></i> Delete
@ -32,19 +26,12 @@
{{ include('snip/badge.html.twig', {snip: snip}) }}
{{ snip }} <small class="text-muted">#{{ snip.id }}</small>
</h4>
<div class="card-header">
<p class="card-text">Current version: {{ branch }}</p>
</div>
<div class="card-body">
{{ content|raw }}
</div>
<div class="card-footer">
<p class="card-text text-muted">
Current version: {{ snip.activeVersion.id }}
{% if snip.activeVersion == snip.latestVersion %}(latest){% endif %}
Created at {{ snip.activeVersion.id.dateTime|date('Y-m-d H:i:s') }}
{{ include('user/badge.html.twig', {user: snip.createdBy}) }}
</p>
</div>
</div>
{% endblock %}

View File

@ -1 +1 @@
<span class="badge {% if user == app.user %}bg-success{% else %}bg-secondary{% endif %}">{{ user }}</span>
<span class="badge bg-secondary">{{ user }}</span>

View File

@ -1,51 +1,23 @@
{% extends 'base/two.column.html.twig' %}
{% extends "base/base.html.twig" %}
{% set title = app.user.name %}
{% block column2 %}
<h5>Change profile</h5>
{{ form(form) }}
{% endblock %}
{% block column1 %}
<h5>Api</h5>
<div class="input-group mb-3">
<span class="input-group-text" id="api-key">Api Key</span>
<input type="text" class="form-control" aria-label="Username" aria-describedby="api-key"
value="{{ app.user.apiKey }}" readonly>
<a type="button" class="btn btn-outline-secondary" href="{{ path('user_apikey_generate') }}">Regenerate</a> <br/>
{% block body %}
<div class="row">
<div class="col-sm">
<h4>{{ app.user.name }}</h4>
<br/>
{% if is_granted('ROLE_ADMIN') %}
<br/><br/>
<h4>Latest release stats</h4>
Branch: {{ release.branch }} <br/>
Date: {{ release.date }} <br/>
Hash short: {{ release.commitHashShort }} <br/>
Hash long: {{ release.commitHashLong }} <br/>
Commit date: {{ release.commitDate }} <br/>
{% endif %}
</div>
<div class="col-sm">
<h4>Change profile</h4>
{{ form(form) }}
</div>
</div>
{% if is_granted('ROLE_ADMIN') %}
<h5>Latest release stats</h5>
<table class="table table-bordered table-hover">
<thead>
<tr>
<th>Property</th>
<th>Value</th>
</tr>
</thead>
<tbody>
<tr>
<td>Branch</td>
<td>{{ release.branch }}</td>
</tr>
<tr>
<td>Date</td>
<td>{{ release.date }}</td>
</tr>
<tr>
<td>Hash short</td>
<td>{{ release.commitHashShort }}</td>
</tr>
<tr>
<td>Hash long</td>
<td>{{ release.commitHashLong }}</td>
</tr>
<tr>
<td>Commit date</td>
<td>{{ release.commitDate }}</td>
</tr>
</tbody>
</table>
{% endif %}
{% endblock %}

View File

@ -1,23 +0,0 @@
{% extends 'base/one.column.html.twig' %}
{% set title = 'Snip ' ~ snip %}
{% block body %}
<a href="{{ path('snip_single', {snip: snip.id}) }}" class="btn btn-primary">
<i class="fa fa-arrow-left"></i> Back
</a>
<a href="{{ path('version_set', {version: snip.latestVersion.id, snip: snip.id}) }}" class="btn btn-warning">
<i class="fa fa-refresh"></i> Latest
</a>
<a href="{{ path('content_compare', {to: snip.activeVersion.id}) }}" class="btn btn-warning">
<i class="fa fa-left-right"></i> Compare
</a>
<br><br>
<div class="list-group">
{% for version in snip.snipContents|reverse %}
<a class="list-group-item {% if version.id == snip.activeVersion.id %}list-group-item-success{% endif %}" href="{{ path('version_set', {version: version.id, snip: snip.id}) }}">
{{ version.id.dateTime|date('Y-m-d H:i:s') }} - {{ version.id }}
</a>
{% endfor %}
</div>
{% endblock %}