├── assets ├── controllers │ └── .gitkeep ├── controllers.json ├── images │ ├── hooli.webp │ ├── ogshare.png │ ├── logo_jobbsy.png │ ├── pied_piper_logo.jpg │ ├── acme-corporation-logo.png │ └── icon.svg ├── app.js ├── bootstrap.js └── styles │ └── app.scss ├── tools ├── rector │ ├── .gitignore │ └── composer.json ├── bin │ ├── rector │ └── php-cs-fixer └── php-cs-fixer │ ├── .gitignore │ └── composer.json ├── public ├── robots.txt ├── index.php └── sitemap.xml ├── .ansible ├── requirements.yml ├── inventory.yml ├── group_vars │ └── all.yml ├── host_vars │ └── web │ │ ├── vars.yml │ │ └── vault.yml ├── config │ ├── symfony_after_composer_tasks_file.yml │ ├── after-cache-tasks.yml │ └── after-symlink.yml └── deploy.yml ├── .github ├── FUNDING.yml ├── workflows │ ├── release.yaml │ ├── auto-merge.yaml │ ├── deploy-dokku.yaml │ └── tests.yaml └── dependabot.yaml ├── .dokku ├── app.json └── nginx_app.conf ├── src ├── Repository │ ├── JobNotFoundException.php │ ├── News │ │ ├── FeedRepository.php │ │ └── EntryRepository.php │ └── CommunityEvent │ │ └── SourceRepository.php ├── News │ ├── Aggregator │ │ ├── FeedType.php │ │ ├── Atom │ │ │ ├── Model │ │ │ │ ├── Author.php │ │ │ │ ├── Feed.php │ │ │ │ └── Entry.php │ │ │ └── Client.php │ │ ├── FetchArticlesFromFeedInterface.php │ │ ├── RSS │ │ │ ├── Client.php │ │ │ └── Model │ │ │ │ ├── Document.php │ │ │ │ └── Channel.php │ │ ├── XmlHelper.php │ │ ├── AggregateNews.php │ │ ├── FetchArticlesFromFeed.php │ │ ├── FetchArticlesFromAtomFeed.php │ │ └── FetchArticlesFromRSSFeed.php │ ├── FetchFeedCommand.php │ ├── FeedRepositoryInterface.php │ ├── EntryRepositoryInterface.php │ └── FetchFeedCommandHandler.php ├── Job │ ├── LocationType.php │ ├── SearchParameters.php │ ├── EmploymentType.php │ ├── Event │ │ └── JobPostedEvent.php │ ├── JobProviderInterface.php │ ├── Repository │ │ └── JobRepositoryInterface.php │ ├── Bridge │ │ └── OpenAI │ │ │ └── CreateJobPromptForClassification.php │ ├── JobCollection.php │ ├── AccessToken.php │ ├── JobProvider.php │ ├── Scraping │ │ └── JobScraper.php │ ├── EventSubscriber │ │ └── SendForClassificationSubscriber.php │ └── Command │ │ └── PostJobOfferCommandHandler.php ├── Subscription │ ├── SubscriptionMailingListInterface.php │ ├── SubscribeMailingListCommand.php │ ├── SubscribeMailingListCommandHandler.php │ └── MailjetSubscriptionAdapter.php ├── CommunityEvent │ ├── AttendanceMode.php │ ├── SourceRepositoryInterface.php │ ├── FetchSourceCommand.php │ ├── EventRepositoryInterface.php │ └── EventScraping.php ├── Kernel.php ├── Mailjet │ └── Model │ │ ├── TestCampaignDraft │ │ ├── Recipient.php │ │ ├── TestCampaignDraftResponse.php │ │ └── TestCampaignDraftRequest.php │ │ ├── SendCampaignDraft │ │ ├── SendCampaignDraftRequest.php │ │ └── SendCampaignDraftResponse.php │ │ ├── ManageContact │ │ ├── Action.php │ │ ├── ManageContactResponse.php │ │ └── ManageContactRequest.php │ │ ├── CreateCampaignDraft │ │ ├── CreateCampaignDraftResponse.php │ │ └── CreateCampaignDraftRequest.php │ │ └── CreateCampaignDraftContent │ │ ├── CreateCampaignDraftContentRequest.php │ │ └── CreateCampaignDraftContentResponse.php ├── Donation │ ├── CreatePaymentUrlInterface.php │ └── Command │ │ ├── CreateDonationPaymentUrlCommand.php │ │ └── CreateDonationPaymentUrlCommandHandler.php ├── Message │ └── ClassifyMessage.php ├── Scheduler.php ├── Twig │ ├── Extension │ │ ├── AssetExtension.php │ │ └── CountryEmojiExtension.php │ └── Runtime │ │ └── AssetExtensionRuntime.php ├── Media │ ├── MediaRemover.php │ ├── MediaUploader.php │ └── MediaFactory.php ├── Form │ ├── SubscriptionType.php │ └── SponsorType.php ├── OpenAI │ ├── Model │ │ └── CompletionRequest.php │ └── Client.php ├── Schedule.php ├── ConsoleCommand │ ├── ClearPinnedCommand.php │ ├── AggregateNewsCommand.php │ └── AggregateCommunityEventCommand.php ├── Controller │ ├── Admin │ │ ├── SourceCrudController.php │ │ ├── EntryCrudController.php │ │ └── FeedCrudController.php │ ├── EventController.php │ └── AssetsController.php ├── EasyAdmin │ ├── UploadMediaOnFeedCreatedSubscriber.php │ ├── ReplaceMediaOnJobUpdatedSubscriber.php │ └── ReplaceMediaOnFeedUpdatedSubscriber.php ├── Entity │ └── CommunityEvent │ │ └── Source.php ├── MessageHandler │ └── ClassifyHandler.php └── Security │ └── User.php ├── config ├── preload.php ├── secrets │ └── prod │ │ ├── prod.encrypt.public.php │ │ ├── prod.MAILJET_SENDER_ID.db8500.php │ │ ├── prod.MAILJET_CONTACT_LIST_ID.32350e.php │ │ ├── prod.TWITTER_API_KEY.ec36c5.php │ │ ├── prod.AWS_ACCESS_KEY_ID.9c5e53.php │ │ ├── prod.APP_SECRET.1cf811.php │ │ ├── prod.GLIDE_KEY.64b5e7.php │ │ ├── prod.STRIPE_TAX_RATE_ID.0b3e88.php │ │ ├── prod.MAILJET_API_KEY.2d5def.php │ │ ├── prod.ARBEITSAGENTUR_CLIENT_ID.ba1d23.php │ │ ├── prod.MAILJET_API_SECRET_KEY.0e6755.php │ │ ├── prod.ARBEITSAGENTUR_CLIENT_SECRET.36273d.php │ │ ├── prod.AWS_ACCESS_KEY_SECRET.5cdb8d.php │ │ ├── prod.OPENAI_API_KEY.66949e.php │ │ ├── prod.TWITTER_ACCESS_TOKEN.de72d7.php │ │ ├── prod.TWITTER_ACCESS_TOKEN_SECRET.761c49.php │ │ ├── prod.TWITTER_API_KEY_SECRET.1f3ea0.php │ │ ├── prod.SENTRY_DSN.25b27f.php │ │ ├── prod.POLE_EMPLOI_CLIENT_SECRET.7704fd.php │ │ ├── prod.ADMIN_PASSWORD.bd8ff1.php │ │ ├── prod.POLE_EMPLOI_CLIENT_ID.9624e0.php │ │ ├── prod.SLACK_DSN.b2b579.php │ │ ├── prod.STRIPE_API_KEY.f94f16.php │ │ ├── prod.DATABASE_URL.8ea85a.php │ │ └── prod.list.php ├── routes.php ├── routes │ ├── easy_admin.php │ ├── api_platform.php │ ├── framework.php │ └── web_profiler.php ├── packages │ ├── validator.php │ ├── ramsey_uuid_doctrine.php │ ├── mailer.php │ ├── cache.php │ ├── doctrine_migrations.php │ ├── translation.php │ ├── asset_mapper.php │ ├── paginator.php │ ├── debug.php │ ├── routing.php │ ├── twig_component.php │ ├── twig.php │ ├── dama_doctrine_test_bundle.php │ ├── nelmio_cors.php │ ├── api_platform.php │ ├── flysystem.php │ ├── notifier.php │ ├── sentry.php │ ├── web_profiler.php │ ├── messenger.php │ ├── security.php │ └── framework.php └── bundles.php ├── Procfile ├── frankenphp ├── conf.d │ ├── 20-app.prod.ini │ ├── 20-app.dev.ini │ └── 10-app.ini ├── Caddyfile ├── docker-entrypoint.sh └── certs │ ├── tls.pem │ └── tls.key ├── tests ├── Subscription │ ├── NullSubscriptionAdapter.php │ └── SubscribeMailingListCommandHandlerTest.php ├── bootstrap.php ├── object-manager.php ├── Controller │ ├── NewsControllerTest.php │ ├── EventControllerTest.php │ └── DefaultControllerTest.php ├── News │ ├── Aggregator │ │ ├── InMemoryFetchArticlesFromFeed.php │ │ └── Atom │ │ │ └── ClientTest.php │ └── FetchFeedCommandHandlerTest.php ├── Job │ ├── InMemoryJobProvider.php │ ├── AccessTokenTest.php │ ├── JobProviderTest.php │ └── FranceTravail │ │ └── FranceTravailApiTest.php ├── Repository │ ├── InMemorySourceRepository.php │ ├── InMemoryFeedRepository.php │ ├── InMemoryJobRepository.php │ ├── InMemoryEntryRepository.php │ └── InMemoryEventRepository.php ├── Mock │ ├── MockStripeClient.php │ ├── create_session.json │ ├── retrieve_session_paid.json │ └── retrieve_session_unpaid.json ├── CommunityEvent │ └── EventScrapingTest.php └── MessageHandler │ └── ClassifyHandlerTest.php ├── .env.test ├── templates ├── bundles │ └── TwigBundle │ │ └── Exception │ │ ├── error.html.twig │ │ ├── error403.html.twig │ │ └── error404.html.twig ├── job │ ├── donation_success.html.twig │ ├── _donation_description.html.twig │ ├── donation_cancel.html.twig │ ├── index.xml.twig │ ├── _subscription_form.html.twig │ ├── _job_list_item.html.twig │ ├── location_type.html.twig │ └── employment_type.html.twig ├── default │ ├── _flash_messages.html.twig │ └── pagination.html.twig ├── event │ ├── index.xml.twig │ ├── _event.html.twig │ └── index.html.twig └── news │ ├── index.xml.twig │ ├── index.html.twig │ └── _entry.html.twig ├── phpstan.neon.dist ├── .castor ├── helper.php ├── worker.php └── database.php ├── .gitignore ├── bin └── console ├── .dockerignore ├── migrations ├── Version20251004140754.php └── Version20230428105411.php ├── .devcontainer └── devcontainer.json ├── importmap.php ├── compose.override.yaml ├── rector.php ├── .php-cs-fixer.dist.php ├── compose.yaml └── phpunit.xml.dist /assets/controllers/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tools/rector/.gitignore: -------------------------------------------------------------------------------- 1 | /vendor/ 2 | -------------------------------------------------------------------------------- /tools/bin/rector: -------------------------------------------------------------------------------- 1 | ../rector/vendor/bin/rector -------------------------------------------------------------------------------- /tools/php-cs-fixer/.gitignore: -------------------------------------------------------------------------------- 1 | /vendor/ 2 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Allow: / 3 | -------------------------------------------------------------------------------- /tools/bin/php-cs-fixer: -------------------------------------------------------------------------------- 1 | ../php-cs-fixer/vendor/bin/php-cs-fixer -------------------------------------------------------------------------------- /.ansible/requirements.yml: -------------------------------------------------------------------------------- 1 | - src: cbrunnkvist.ansistrano-symfony-deploy 2 | -------------------------------------------------------------------------------- /assets/controllers.json: -------------------------------------------------------------------------------- 1 | { 2 | "controllers": [], 3 | "entrypoints": [] 4 | } 5 | -------------------------------------------------------------------------------- /assets/images/hooli.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jobbsy-dev/jobbsy/HEAD/assets/images/hooli.webp -------------------------------------------------------------------------------- /assets/images/ogshare.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jobbsy-dev/jobbsy/HEAD/assets/images/ogshare.png -------------------------------------------------------------------------------- /tools/rector/composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "require": { 3 | "rector/rector": "^2.0" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | custom: "https://jobbsy.dev/job/new" 3 | -------------------------------------------------------------------------------- /assets/app.js: -------------------------------------------------------------------------------- 1 | import './styles/app.scss'; 2 | 3 | // start the Stimulus application 4 | import './bootstrap.js'; -------------------------------------------------------------------------------- /assets/images/logo_jobbsy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jobbsy-dev/jobbsy/HEAD/assets/images/logo_jobbsy.png -------------------------------------------------------------------------------- /.ansible/inventory.yml: -------------------------------------------------------------------------------- 1 | all: 2 | children: 3 | webservers: 4 | hosts: 5 | web: 6 | -------------------------------------------------------------------------------- /assets/images/pied_piper_logo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jobbsy-dev/jobbsy/HEAD/assets/images/pied_piper_logo.jpg -------------------------------------------------------------------------------- /.ansible/group_vars/all.yml: -------------------------------------------------------------------------------- 1 | --- 2 | symfony_env: prod 3 | private_key_path: 'config/secrets/prod/prod.decrypt.private.php' 4 | -------------------------------------------------------------------------------- /assets/images/acme-corporation-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jobbsy-dev/jobbsy/HEAD/assets/images/acme-corporation-logo.png -------------------------------------------------------------------------------- /.dokku/app.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "dokku": { 4 | "predeploy": "php bin/console asset-map:compile" 5 | } 6 | } 7 | } -------------------------------------------------------------------------------- /src/Repository/JobNotFoundException.php: -------------------------------------------------------------------------------- 1 | import('../src/Controller/', 'attribute'); 9 | }; 10 | -------------------------------------------------------------------------------- /config/routes/easy_admin.php: -------------------------------------------------------------------------------- 1 | import('.', 'easyadmin.routes'); 9 | }; 10 | -------------------------------------------------------------------------------- /frankenphp/conf.d/20-app.prod.ini: -------------------------------------------------------------------------------- 1 | ; https://symfony.com/doc/current/performance.html#use-the-opcache-class-preloading 2 | opcache.preload_user = root 3 | opcache.preload = /app/config/preload.php 4 | ; https://symfony.com/doc/current/performance.html#don-t-check-php-files-timestamps 5 | opcache.validate_timestamps = 0 6 | -------------------------------------------------------------------------------- /src/Message/ClassifyMessage.php: -------------------------------------------------------------------------------- 1 | validation()->emailValidationMode('html5'); 9 | $config->validation()->notCompromisedPassword()->enabled(false); 10 | }; 11 | -------------------------------------------------------------------------------- /config/secrets/prod/prod.MAILJET_CONTACT_LIST_ID.32350e.php: -------------------------------------------------------------------------------- 1 | dbal() 10 | ->type('uuid') 11 | ->class(UuidType::class); 12 | }; 13 | -------------------------------------------------------------------------------- /config/routes/api_platform.php: -------------------------------------------------------------------------------- 1 | import('.', 'api_platform') 9 | ->prefix('/api'); 10 | }; 11 | -------------------------------------------------------------------------------- /.ansible/host_vars/web/vars.yml: -------------------------------------------------------------------------------- 1 | --- 2 | ansible_host: "{{ vault_ansible_host }}" 3 | ansistrano_get_url: "{{ vault_download_url }}" 4 | deploy_to: "{{ vault_deploy_to }}" 5 | ansible_user: "{{ vault_ansible_user }}" 6 | ansistrano_deploy_via: download_unarchive 7 | composer_path: "{{ vault_composer_path }}" 8 | ansible_port: "{{ valut_ansible_port }}" 9 | -------------------------------------------------------------------------------- /src/News/FetchFeedCommand.php: -------------------------------------------------------------------------------- 1 | mailer([ 10 | 'dsn' => env('MAILER_DSN'), 11 | ]); 12 | }; 13 | -------------------------------------------------------------------------------- /src/Scheduler.php: -------------------------------------------------------------------------------- 1 | extension('framework', [ 9 | 'cache' => null, 10 | ]); 11 | }; 12 | -------------------------------------------------------------------------------- /config/secrets/prod/prod.TWITTER_API_KEY.ec36c5.php: -------------------------------------------------------------------------------- 1 | migrationsPath('DoctrineMigrations', '%kernel.project_dir%/migrations') 10 | ->enableProfiler(false); 11 | }; 12 | -------------------------------------------------------------------------------- /config/packages/translation.php: -------------------------------------------------------------------------------- 1 | defaultLocale('en') 10 | ->translator() 11 | ->defaultPath(__DIR__.'/../../translations') 12 | ->fallbacks('en'); 13 | }; 14 | -------------------------------------------------------------------------------- /src/News/FeedRepositoryInterface.php: -------------------------------------------------------------------------------- 1 | bootEnv(dirname(__DIR__).'/.env'); 11 | } 12 | -------------------------------------------------------------------------------- /tests/object-manager.php: -------------------------------------------------------------------------------- 1 | bootEnv(__DIR__.'/../.env'); 9 | 10 | $kernel = new Kernel($_SERVER['APP_ENV'], (bool) $_SERVER['APP_DEBUG']); 11 | $kernel->boot(); 12 | 13 | return $kernel->getContainer()->get('doctrine')->getManager(); 14 | -------------------------------------------------------------------------------- /config/secrets/prod/prod.STRIPE_TAX_RATE_ID.0b3e88.php: -------------------------------------------------------------------------------- 1 | 5 |

Internal server error

6 | 7 |

8 | There was an internal server error. 9 | Return to the homepage. 10 |

11 | 12 | {% endblock %} -------------------------------------------------------------------------------- /templates/job/donation_success.html.twig: -------------------------------------------------------------------------------- 1 | {% extends 'base.html.twig' %} 2 | 3 | {% block title %}Thanks for your donation - {{ parent() }}{% endblock %} 4 | 5 | {% block main %} 6 |
7 |
8 |

Thank you for your donation 😍

9 |
10 |
11 | {% endblock %} 12 | -------------------------------------------------------------------------------- /config/secrets/prod/prod.MAILJET_API_KEY.2d5def.php: -------------------------------------------------------------------------------- 1 | getDescription()); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /templates/bundles/TwigBundle/Exception/error403.html.twig: -------------------------------------------------------------------------------- 1 | {% extends 'base.html.twig' %} 2 | 3 | {% block main %} 4 |
5 |

Access forbidden

6 | 7 |

8 | You don't have permission to access to this resource. 9 | Return to the homepage. 10 |

11 |
12 | {% endblock %} -------------------------------------------------------------------------------- /config/secrets/prod/prod.MAILJET_API_SECRET_KEY.0e6755.php: -------------------------------------------------------------------------------- 1 | extension('framework', [ 9 | 'asset_mapper' => [ 10 | 'paths' => ['assets/'], 11 | ], 12 | ]); 13 | }; 14 | -------------------------------------------------------------------------------- /config/packages/paginator.php: -------------------------------------------------------------------------------- 1 | extension('knp_paginator', [ 9 | 'template' => [ 10 | 'pagination' => 'default/pagination.html.twig', 11 | ] 12 | ]); 13 | }; 14 | -------------------------------------------------------------------------------- /config/secrets/prod/prod.ARBEITSAGENTUR_CLIENT_SECRET.36273d.php: -------------------------------------------------------------------------------- 1 | 5 |

Page not found

6 | 7 |

8 | The requested page couldn't be located. Checkout for any URL 9 | misspelling or return to the homepage. 10 |

11 | 12 | {% endblock %} -------------------------------------------------------------------------------- /frankenphp/conf.d/10-app.ini: -------------------------------------------------------------------------------- 1 | expose_php = 0 2 | date.timezone = UTC 3 | apc.enable_cli = 1 4 | session.use_strict_mode = 1 5 | zend.detect_unicode = 0 6 | 7 | ; https://symfony.com/doc/current/performance.html 8 | realpath_cache_size = 4096K 9 | realpath_cache_ttl = 600 10 | opcache.interned_strings_buffer = 16 11 | opcache.max_accelerated_files = 20000 12 | opcache.memory_consumption = 256 13 | opcache.enable_file_override = 1 14 | -------------------------------------------------------------------------------- /config/routes/framework.php: -------------------------------------------------------------------------------- 1 | env() === 'dev') { 9 | $routingConfigurator->import('@FrameworkBundle/Resources/config/routing/errors.xml') 10 | ->prefix('/_error'); 11 | } 12 | }; 13 | -------------------------------------------------------------------------------- /src/News/EntryRepositoryInterface.php: -------------------------------------------------------------------------------- 1 | env() === 'dev') { 9 | $containerConfigurator->extension('debug', [ 10 | 'dump_destination' => 'tcp://%env(VAR_DUMPER_SERVER)%', 11 | ]); 12 | } 13 | }; 14 | -------------------------------------------------------------------------------- /.castor/helper.php: -------------------------------------------------------------------------------- 1 | withQuiet()); 12 | } 13 | 14 | function uid(): string 15 | { 16 | return capture('id -u'); 17 | } 18 | 19 | function gid(): string 20 | { 21 | return capture('id -g'); 22 | } 23 | -------------------------------------------------------------------------------- /src/Twig/Extension/AssetExtension.php: -------------------------------------------------------------------------------- 1 | router()->utf8(true); 10 | 11 | if ('prod' === $containerConfigurator->env()) { 12 | $config->router()->strictRequirements(null); 13 | } 14 | }; 15 | -------------------------------------------------------------------------------- /src/News/Aggregator/FetchArticlesFromFeedInterface.php: -------------------------------------------------------------------------------- 1 | subscriptionMailingList->subscribe($command->email, $command->listId); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /config/packages/twig_component.php: -------------------------------------------------------------------------------- 1 | extension('twig_component', [ 9 | 'anonymous_template_directory' => 'components/', 10 | 'defaults' => [ 11 | 'App\Twig\Components\\' => 'components/', 12 | ], 13 | ]); 14 | }; 15 | -------------------------------------------------------------------------------- /config/secrets/prod/prod.SENTRY_DSN.25b27f.php: -------------------------------------------------------------------------------- 1 | 2 | By making a donation you will support open source and boost your job offer by putting it at the top of the list for 6 months! 3 |

4 |

Each donation will sponsor Symfony open source ecosystem.

5 | 6 |
7 | Any questions? Get in touch 8 | 9 | 10 |
11 | -------------------------------------------------------------------------------- /src/Mailjet/Model/ManageContact/ManageContactResponse.php: -------------------------------------------------------------------------------- 1 | getPath())) { 17 | return; 18 | } 19 | 20 | $this->mediaStorage->delete($path); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /config/secrets/prod/prod.ADMIN_PASSWORD.bd8ff1.php: -------------------------------------------------------------------------------- 1 | $this->htmlPart, 15 | ], \JSON_THROW_ON_ERROR | \JSON_UNESCAPED_UNICODE); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ###> symfony/framework-bundle ### 2 | /.env.local 3 | /.env.local.php 4 | /.env.*.local 5 | /config/secrets/prod/prod.decrypt.private.php 6 | /public/bundles/ 7 | /var/ 8 | /vendor/ 9 | ###< symfony/framework-bundle ### 10 | 11 | ###> symfony/phpunit-bridge ### 12 | .phpunit.result.cache 13 | /phpunit.xml 14 | ###< symfony/phpunit-bridge ### 15 | 16 | .ansible/.vault_pass 17 | phpstan.neon 18 | 19 | ###> symfony/asset-mapper ### 20 | /public/assets/ 21 | /assets/vendor 22 | ###< symfony/asset-mapper ### 23 | 24 | .castor.stub.php 25 | .env.docker 26 | -------------------------------------------------------------------------------- /assets/styles/app.scss: -------------------------------------------------------------------------------- 1 | $primary: #3dabde; 2 | $min-contrast-ratio: 2; 3 | 4 | @import '../../vendor/twbs/bootstrap/scss/bootstrap'; 5 | 6 | .pinned { 7 | border: 1px solid $primary; 8 | 9 | & + & { 10 | border-top-width: 0; 11 | } 12 | } 13 | 14 | .job-organization-logo { 15 | width: 75px; 16 | height: 75px; 17 | } 18 | 19 | @media screen and (max-width: 768px) { 20 | .job-organization-logo { 21 | width: 60px; 22 | height: 60px; 23 | } 24 | 25 | .feed-name { 26 | max-width: 200px 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | defaultPath(__DIR__.'/../../templates') 11 | ->formThemes(['bootstrap_5_layout.html.twig', 'form/layout.html.twig']); 12 | 13 | if ('test' === $containerConfigurator->env()) { 14 | $config->strictVariables(true); 15 | } 16 | }; 17 | -------------------------------------------------------------------------------- /src/Mailjet/Model/CreateCampaignDraftContent/CreateCampaignDraftContentResponse.php: -------------------------------------------------------------------------------- 1 | env() === 'dev') { 9 | $routingConfigurator->import('@WebProfilerBundle/Resources/config/routing/wdt.xml') 10 | ->prefix('/_wdt'); 11 | 12 | $routingConfigurator->import('@WebProfilerBundle/Resources/config/routing/profiler.xml') 13 | ->prefix('/_profiler'); 14 | } 15 | }; 16 | -------------------------------------------------------------------------------- /templates/job/donation_cancel.html.twig: -------------------------------------------------------------------------------- 1 | {% extends 'base.html.twig' %} 2 | 3 | {% block title %}You cancel your donation - {{ parent() }}{% endblock %} 4 | 5 | {% block main %} 6 |
7 |
8 |

Your job offer was successfully posted, but you cancel your donation 🥲

9 |

If you prefer your can sponsor Symfony on symfony.com/sponsor

10 |
11 |
12 | {% endblock %} 13 | -------------------------------------------------------------------------------- /config/packages/dama_doctrine_test_bundle.php: -------------------------------------------------------------------------------- 1 | env()) { 9 | $containerConfigurator->extension('dama_doctrine_test', [ 10 | 'enable_static_connection' => true, 11 | 'enable_static_meta_data_cache' => true, 12 | 'enable_static_query_cache' => true, 13 | ]); 14 | } 15 | }; 16 | -------------------------------------------------------------------------------- /templates/default/_flash_messages.html.twig: -------------------------------------------------------------------------------- 1 | {% if app.request.hasPreviousSession %} 2 |
3 | {% for type, messages in app.flashes %} 4 | {% for message in messages %} 5 | 10 | {% endfor %} 11 | {% endfor %} 12 |
13 | {% endif %} 14 | -------------------------------------------------------------------------------- /config/secrets/prod/prod.SLACK_DSN.b2b579.php: -------------------------------------------------------------------------------- 1 | request('GET', '/news'); 14 | 15 | self::assertResponseIsSuccessful(); 16 | self::assertPageTitleContains('News'); 17 | 18 | self::assertSelectorTextContains('h1', 'News'); 19 | self::assertCount(2, $crawler->filter('article')); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Media/MediaUploader.php: -------------------------------------------------------------------------------- 1 | getPath())) { 17 | return; 18 | } 19 | 20 | if (null === $media->getContent()) { 21 | return; 22 | } 23 | 24 | $this->mediaStorage->write($path, $media->getContent()); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /config/packages/nelmio_cors.php: -------------------------------------------------------------------------------- 1 | defaults() 11 | ->originRegex(true) 12 | ->allowOrigin([env('CORS_ALLOW_ORIGIN')]) 13 | ->allowMethods(['GET', 'OPTIONS', 'POST', 'PUT', 'PATCH', 'DELETE']) 14 | ->allowHeaders(['Content-Type', 'Authorization']) 15 | ->exposeHeaders(['Link']) 16 | ->maxAge(3600); 17 | 18 | $config->paths('^/', []); 19 | }; 20 | -------------------------------------------------------------------------------- /config/secrets/prod/prod.DATABASE_URL.8ea85a.php: -------------------------------------------------------------------------------- 1 | addSql(<<<'SQL' 15 | ALTER TABLE job DROP tweet_id 16 | SQL); 17 | } 18 | 19 | public function down(Schema $schema): void 20 | { 21 | $this->addSql(<<<'SQL' 22 | ALTER TABLE job ADD tweet_id VARCHAR(255) DEFAULT NULL 23 | SQL); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "image": "mcr.microsoft.com/devcontainers/base:ubuntu", 3 | "features": { 4 | "ghcr.io/shyim/devcontainers-features/symfony-cli:0": {}, 5 | "ghcr.io/shyim/devcontainers-features/php:0": { 6 | "version": "8.3" 7 | }, 8 | "ghcr.io/devcontainers/features/sshd:1": { 9 | "version": "latest" 10 | } 11 | }, 12 | "updateContentCommand": { 13 | "composer install": ["composer", "install"], 14 | "importmap:install": ["symfony", "console", "importmap:install"] 15 | }, 16 | "postAttachCommand": { 17 | "server": "symfony server:start", 18 | "sass build": ["symfony", "console", "sass:build", "-w"] 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Subscription/MailjetSubscriptionAdapter.php: -------------------------------------------------------------------------------- 1 | api->manageContact(new ManageContactRequest( 18 | $mailingList, 19 | Action::ADD_FORCE, 20 | $email, 21 | )); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /config/packages/api_platform.php: -------------------------------------------------------------------------------- 1 | mapping([ 10 | 'paths' => [__DIR__.'/../../src/Entity'] 11 | ]) 12 | ->paths([__DIR__.'/../../src/Entity']); 13 | 14 | $apiPlatformConfig->patchFormats('json', ['mime_types' => ['application/merge-patch+json']]); 15 | $apiPlatformConfig->swagger([ 16 | 'versions' => [3] 17 | ]); 18 | $apiPlatformConfig->showWebby(false); 19 | $apiPlatformConfig->title('Jobbsy API'); 20 | $apiPlatformConfig->version('0.1.0'); 21 | }; 22 | -------------------------------------------------------------------------------- /.ansible/config/after-cache-tasks.yml: -------------------------------------------------------------------------------- 1 | - name: Set HTTPDUSER 2 | shell: "ps axo user,comm | grep -E '[a]pache|[h]ttpd|[_]www|[w]ww-data|[n]ginx' | grep -v root | head -1 | cut -d ' ' -f1" 3 | register: httpduser 4 | 5 | - set_fact: 6 | httpd_user: "{{ httpduser.stdout }}" 7 | 8 | - name: Setting up File Permissions for "var" (future files and folders) 9 | command: "setfacl -dR -m u:{{ httpd_user }}:rwX -m u:{{ ansible_user }}:rwX var" 10 | args: 11 | chdir: "{{ ansistrano_release_path.stdout }}" 12 | 13 | - name: Setting up File Permissions for "var" 14 | command: "setfacl -R -m u:{{ httpd_user }}:rwX -m u:{{ ansible_user }}:rwX var" 15 | args: 16 | chdir: "{{ ansistrano_release_path.stdout }}" 17 | -------------------------------------------------------------------------------- /.castor/worker.php: -------------------------------------------------------------------------------- 1 | title('Stopping the worker'); 21 | 22 | run(['docker', 'compose', 'exec', 'php', 'bin/console', 'messenger:stop-workers']); 23 | } 24 | -------------------------------------------------------------------------------- /src/CommunityEvent/EventRepositoryInterface.php: -------------------------------------------------------------------------------- 1 | entries; 23 | } 24 | 25 | public function supports(Feed $feed): bool 26 | { 27 | return true; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Twig/Extension/CountryEmojiExtension.php: -------------------------------------------------------------------------------- 1 | countryEmoji(...)), 16 | ]; 17 | } 18 | 19 | public function countryEmoji(string $countryCode): string 20 | { 21 | return mb_chr(self::REGIONAL_OFFSET + mb_ord($countryCode[0], 'UTF-8'), 'UTF-8') 22 | .mb_chr(self::REGIONAL_OFFSET + mb_ord($countryCode[1], 'UTF-8'), 'UTF-8'); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /tests/Subscription/SubscribeMailingListCommandHandlerTest.php: -------------------------------------------------------------------------------- 1 | expectNotToPerformAssertions(); 14 | 15 | $subscriptionAdapter = new NullSubscriptionAdapter(); 16 | $commandHandler = new SubscribeMailingListCommandHandler($subscriptionAdapter); 17 | 18 | $command = new SubscribeMailingListCommand('john@example.com', 1234); 19 | 20 | ($commandHandler)($command); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Form/SubscriptionType.php: -------------------------------------------------------------------------------- 1 | add('email', EmailType::class, [ 15 | 'constraints' => [ 16 | new Assert\Email(), 17 | new Assert\NotBlank(), 18 | ], 19 | 'attr' => [ 20 | 'placeholder' => 'Email address', 21 | ], 22 | ]); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Mailjet/Model/TestCampaignDraft/TestCampaignDraftRequest.php: -------------------------------------------------------------------------------- 1 | [], 18 | ]; 19 | 20 | foreach ($this->recipients as $recipient) { 21 | $payload['Recipients'][] = [ 22 | 'Email' => $recipient->email, 23 | 'Name' => $recipient->name, 24 | ]; 25 | } 26 | 27 | return $payload; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/News/Aggregator/Atom/Client.php: -------------------------------------------------------------------------------- 1 | httpClient->request('GET', $url, [ 17 | 'headers' => [ 18 | 'Content-Type' => 'text/xml', 19 | ], 20 | ]); 21 | 22 | if (200 !== $response->getStatusCode()) { 23 | return null; 24 | } 25 | 26 | $xmlData = $response->getContent(); 27 | 28 | return Feed::create($xmlData); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /assets/images/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/News/Aggregator/RSS/Client.php: -------------------------------------------------------------------------------- 1 | httpClient->request('GET', $url, [ 17 | 'headers' => [ 18 | 'Content-Type' => 'text/xml', 19 | ], 20 | ]); 21 | 22 | if (200 !== $response->getStatusCode()) { 23 | return null; 24 | } 25 | 26 | $xmlData = $response->getContent(); 27 | 28 | return Document::create($xmlData); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /tests/Job/InMemoryJobProvider.php: -------------------------------------------------------------------------------- 1 | jobs[(string) $job->getId()] = $job; 21 | } 22 | } 23 | 24 | public function retrieve(SearchParameters $parameters): JobCollection 25 | { 26 | return new JobCollection(...$this->jobs); 27 | } 28 | 29 | public function enabled(): bool 30 | { 31 | return true; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: "Release 🆕" 2 | 3 | on: 4 | workflow_dispatch: ~ 5 | push: 6 | branches: 7 | - main 8 | 9 | concurrency: 10 | group: ${{ github.workflow }}-${{ github.ref }} 11 | cancel-in-progress: true 12 | 13 | jobs: 14 | release: 15 | runs-on: 'ubuntu-latest' 16 | steps: 17 | - name: "Checkout code" 18 | uses: actions/checkout@v6 19 | 20 | - name: Create Sentry release 21 | uses: getsentry/action-release@v3 22 | env: 23 | SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} 24 | SENTRY_ORG: ${{ secrets.SENTRY_ORG }} 25 | SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} 26 | with: 27 | environment: prod 28 | -------------------------------------------------------------------------------- /src/Job/JobCollection.php: -------------------------------------------------------------------------------- 1 | jobs[] = $job; 18 | } 19 | } 20 | 21 | public function addJob(Job ...$jobs): void 22 | { 23 | foreach ($jobs as $job) { 24 | $this->jobs[] = $job; 25 | } 26 | } 27 | 28 | /** 29 | * @return Job[] 30 | */ 31 | public function all(): array 32 | { 33 | return $this->jobs; 34 | } 35 | 36 | public function count(): int 37 | { 38 | return \count($this->jobs); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Mailjet/Model/ManageContact/ManageContactRequest.php: -------------------------------------------------------------------------------- 1 | $this->action->value, 22 | 'Email' => $this->email, 23 | ]; 24 | 25 | if (null !== $this->name) { 26 | $payload['Name'] = $this->name; 27 | } 28 | 29 | return $payload; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /migrations/Version20230428105411.php: -------------------------------------------------------------------------------- 1 | addSql('ALTER TABLE entry ALTER published_at TYPE TIMESTAMP(0) WITH TIME ZONE'); 15 | $this->addSql('COMMENT ON COLUMN entry.published_at IS \'(DC2Type:datetimetz_immutable)\''); 16 | } 17 | 18 | public function down(Schema $schema): void 19 | { 20 | $this->addSql('ALTER TABLE entry ALTER published_at TYPE TIMESTAMP(0) WITHOUT TIME ZONE'); 21 | $this->addSql('COMMENT ON COLUMN entry.published_at IS \'(DC2Type:datetime_immutable)\''); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/OpenAI/Model/CompletionRequest.php: -------------------------------------------------------------------------------- 1 | $this->model, 25 | 'prompt' => $this->prompt, 26 | 'temperature' => $this->temperature, 27 | 'max_tokens' => $this->maxTokens, 28 | ]; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /tests/Repository/InMemorySourceRepository.php: -------------------------------------------------------------------------------- 1 | 12 | */ 13 | private array $sources; 14 | 15 | /** 16 | * @param Source[] $sources 17 | */ 18 | public function __construct(array $sources) 19 | { 20 | foreach ($sources as $source) { 21 | $this->sources[(string) $source->getId()] = $source; 22 | } 23 | } 24 | 25 | public function getAll(): array 26 | { 27 | return $this->sources; 28 | } 29 | 30 | public function get(string $id): ?Source 31 | { 32 | return $this->sources[$id] ?? null; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /importmap.php: -------------------------------------------------------------------------------- 1 | [ 18 | 'path' => './assets/app.js', 19 | 'entrypoint' => true, 20 | ], 21 | '@hotwired/stimulus' => [ 22 | 'version' => '3.2.2', 23 | ], 24 | '@symfony/stimulus-bundle' => [ 25 | 'path' => './vendor/symfony/stimulus-bundle/assets/dist/loader.js', 26 | ], 27 | ]; 28 | -------------------------------------------------------------------------------- /config/secrets/prod/prod.list.php: -------------------------------------------------------------------------------- 1 | null, 5 | 'APP_SECRET' => null, 6 | 'ARBEITSAGENTUR_CLIENT_ID' => null, 7 | 'ARBEITSAGENTUR_CLIENT_SECRET' => null, 8 | 'AWS_ACCESS_KEY_ID' => null, 9 | 'AWS_ACCESS_KEY_SECRET' => null, 10 | 'DATABASE_URL' => null, 11 | 'GLIDE_KEY' => null, 12 | 'MAILJET_API_KEY' => null, 13 | 'MAILJET_API_SECRET_KEY' => null, 14 | 'MAILJET_CONTACT_LIST_ID' => null, 15 | 'MAILJET_SENDER_ID' => null, 16 | 'OPENAI_API_KEY' => null, 17 | 'POLE_EMPLOI_CLIENT_ID' => null, 18 | 'POLE_EMPLOI_CLIENT_SECRET' => null, 19 | 'SENTRY_DSN' => null, 20 | 'SLACK_DSN' => null, 21 | 'STRIPE_API_KEY' => null, 22 | 'STRIPE_TAX_RATE_ID' => null, 23 | 'TWITTER_ACCESS_TOKEN' => null, 24 | 'TWITTER_ACCESS_TOKEN_SECRET' => null, 25 | 'TWITTER_API_KEY' => null, 26 | 'TWITTER_API_KEY_SECRET' => null, 27 | ]; 28 | -------------------------------------------------------------------------------- /src/Job/AccessToken.php: -------------------------------------------------------------------------------- 1 | expiresIn, 0); 13 | } 14 | 15 | public static function create( 16 | string $token, 17 | int $expiresIn, 18 | \DateTimeImmutable $createAt = new \DateTimeImmutable(), 19 | ): self { 20 | return new self($token, $expiresIn, $createAt); 21 | } 22 | 23 | public function getToken(): string 24 | { 25 | return $this->token; 26 | } 27 | 28 | public function hasExpired(): bool 29 | { 30 | return new \DateTimeImmutable() > $this->createdAt->add(new \DateInterval(\sprintf('PT%sS', $this->expiresIn))); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Schedule.php: -------------------------------------------------------------------------------- 1 | stateful($this->cache) // ensure missed tasks are executed 21 | ->processOnlyLastMissedRun(true) // ensure only last missed task is run 22 | 23 | // add your own tasks here 24 | // see https://symfony.com/doc/current/scheduler.html#attaching-recurring-messages-to-a-schedule 25 | ; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /config/packages/flysystem.php: -------------------------------------------------------------------------------- 1 | storage('media.storage.local') 11 | ->adapter('local') 12 | ->options([ 13 | 'directory' => '%kernel.project_dir%/public' 14 | ]); 15 | 16 | $config->storage('media.storage.aws') 17 | ->adapter('asyncaws') 18 | ->options([ 19 | 'client' => S3Client::class, 20 | 'bucket' => 'jobbsy', 21 | ]); 22 | 23 | $config->storage('media.storage.memory') 24 | ->adapter('memory'); 25 | 26 | $config->storage('media.storage') 27 | ->adapter('lazy') 28 | ->options([ 29 | 'source' => env('APP_MEDIA_SOURCE'), 30 | ]); 31 | }; 32 | -------------------------------------------------------------------------------- /tests/Controller/EventControllerTest.php: -------------------------------------------------------------------------------- 1 | get(ClockInterface::class); 17 | $clock->modify('2022-06-10'); 18 | 19 | $client->request('GET', '/events'); 20 | 21 | self::assertResponseIsSuccessful(); 22 | self::assertPageTitleContains('Symfony conferences and events'); 23 | 24 | self::assertSelectorTextContains('h1', 'Upcoming Symfony conferences and events'); 25 | self::assertSelectorTextContains('h2', 'Upcoming events & meetups'); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /.github/workflows/auto-merge.yaml: -------------------------------------------------------------------------------- 1 | name: Dependabot auto-merge 2 | 3 | on: pull_request 4 | 5 | permissions: 6 | contents: write 7 | pull-requests: write 8 | 9 | jobs: 10 | dependabot: 11 | runs-on: ubuntu-latest 12 | if: github.actor == 'dependabot[bot]' 13 | steps: 14 | - name: Dependabot metadata 15 | id: metadata 16 | uses: dependabot/fetch-metadata@v2 17 | with: 18 | github-token: "${{ secrets.GITHUB_TOKEN }}" 19 | 20 | - name: Enable auto-merge for Dependabot PRs 21 | if: contains(steps.metadata.outputs.package-ecosystem, 'composer') && steps.metadata.outputs.update-type == 'version-update:semver-patch' 22 | run: gh pr merge --auto --squash "$PR_URL" 23 | env: 24 | PR_URL: ${{ github.event.pull_request.html_url }} 25 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 26 | -------------------------------------------------------------------------------- /config/packages/notifier.php: -------------------------------------------------------------------------------- 1 | extension('framework', [ 11 | 'notifier' => [ 12 | 'channel_policy' => [ 13 | 'urgent' => ['email'], 14 | 'high' => ['email'], 15 | 'medium' => ['email'], 16 | 'low' => ['email'], 17 | ], 18 | 'admin_recipients' => [[ 19 | 'email' => 'hello@jobbsy.dev', 20 | ]], 21 | ], 22 | ]); 23 | 24 | $framework->notifier() 25 | ->chatterTransport('slack', env('SLACK_DSN')) 26 | ; 27 | }; 28 | -------------------------------------------------------------------------------- /src/Mailjet/Model/CreateCampaignDraft/CreateCampaignDraftRequest.php: -------------------------------------------------------------------------------- 1 | $this->title, 22 | 'ContactsListID' => $this->contactsListId, 23 | 'Locale' => $this->locale, 24 | 'SenderEmail' => $this->senderEmail, 25 | 'SenderName' => $this->senderName, 26 | 'Subject' => $this->subject, 27 | 'Sender' => $this->sender, 28 | ]; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /.github/workflows/deploy-dokku.yaml: -------------------------------------------------------------------------------- 1 | name: 'Deploy (Dokku) 🚀' 2 | 3 | on: 4 | workflow_dispatch: ~ 5 | push: 6 | branches: 7 | - main 8 | 9 | concurrency: 10 | group: ${{ github.workflow }}-${{ github.ref }} 11 | cancel-in-progress: true 12 | 13 | jobs: 14 | deploy: 15 | runs-on: ubuntu-latest 16 | environment: 17 | name: production 18 | url: https://jobbsy.dev 19 | steps: 20 | - name: Cloning repo 21 | uses: actions/checkout@v6 22 | with: 23 | fetch-depth: 0 24 | 25 | - name: Push to dokku 26 | uses: dokku/github-action@master 27 | with: 28 | git_remote_url: ssh://${{ secrets.SSH_DOKKU_USER }}@${{ secrets.SSH_DOKKU_IP }}:${{ secrets.SSH_DOKKU_PORT }}/jobbsy 29 | ssh_private_key: ${{ secrets.SSH_DOKKU_PRIVATE_KEY }} 30 | branch: main 31 | -------------------------------------------------------------------------------- /templates/event/index.xml.twig: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{ 'rss.title_events'|trans }} 5 | {{ 'rss.description_events'|trans }} 6 | {{ 'now'|date('r', timezone='GMT') }} 7 | {{ (events|last).publishedAt|default('now')|date('r', timezone='GMT') }} 8 | {{ url('event_index') }} 9 | {{ app.request.locale }} 10 | 11 | {% for event in events %} 12 | 13 | {{ event.name }} 14 | {{ event.url }} 15 | {{ event.createdAt|date(format='r', timezone='GMT') }} 16 | {{ url('event_redirect', {'id': event.id}) }} 17 | 18 | {% endfor %} 19 | 20 | 21 | -------------------------------------------------------------------------------- /templates/news/index.xml.twig: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{ 'rss.title_news'|trans }} 5 | {{ 'rss.description_events'|trans }} 6 | {{ 'now'|date('r', timezone='GMT') }} 7 | {{ (entries|last).publishedAt|default('now')|date('r', timezone='GMT') }} 8 | {{ url('news_index') }} 9 | {{ app.request.locale }} 10 | 11 | {% for entry in entries %} 12 | 13 | {{ entry.title }} 14 | {{ entry.link }} 15 | {{ entry.publishedAt|date(format='r', timezone='GMT') }} 16 | {{ url('news_entry', {'id': entry.id}) }} 17 | 18 | {% endfor %} 19 | 20 | 21 | -------------------------------------------------------------------------------- /public/sitemap.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | https://jobbsy.dev 5 | 6 | 7 | https://jobbsy.dev/events 8 | 9 | 10 | https://jobbsy.dev/news 11 | 12 | 13 | https://jobbsy.dev/symfony-location-remote-jobs 14 | 15 | 16 | https://jobbsy.dev/symfony-location-onsite-jobs 17 | 18 | 19 | https://jobbsy.dev/symfony-location-hybrid-jobs 20 | 21 | 22 | https://jobbsy.dev/symfony-employment-fulltime-jobs 23 | 24 | 25 | https://jobbsy.dev/symfony-employment-contract-jobs 26 | 27 | 28 | https://jobbsy.dev/symfony-employment-internship-jobs 29 | 30 | -------------------------------------------------------------------------------- /tests/Repository/InMemoryFeedRepository.php: -------------------------------------------------------------------------------- 1 | 12 | */ 13 | private array $feeds = []; 14 | 15 | /** 16 | * @param Feed[] $feeds 17 | */ 18 | public function __construct(array $feeds = []) 19 | { 20 | foreach ($feeds as $feed) { 21 | $this->feeds[(string) $feed->getId()] = $feed; 22 | } 23 | } 24 | 25 | public function save(Feed $feed): void 26 | { 27 | // TODO: Implement save() method. 28 | } 29 | 30 | public function remove(Feed $feed): void 31 | { 32 | // TODO: Implement remove() method. 33 | } 34 | 35 | public function get(string $id): ?Feed 36 | { 37 | return $this->feeds[$id] ?? null; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/ConsoleCommand/ClearPinnedCommand.php: -------------------------------------------------------------------------------- 1 | jobRepository->clearExpiredPinnedJobs(); 27 | 28 | return Command::SUCCESS; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Donation/Command/CreateDonationPaymentUrlCommandHandler.php: -------------------------------------------------------------------------------- 1 | jobRepository->get($command->jobId); 21 | 22 | return ($this->createPaymentUrl)( 23 | job: $job, 24 | amount: $command->amount, 25 | redirectSuccessUrl: $command->redirectSuccessUrl, 26 | redirectCancelUrl: $command->redirectCancelUrl 27 | ); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /.ansible/deploy.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - 3 | name: Deploy Application 4 | hosts: all 5 | gather_facts: false 6 | vars: 7 | ansistrano_deploy_from: "{{ playbook_dir }}/../" 8 | ansistrano_deploy_to: '{{ deploy_to }}' 9 | ansistrano_keep_releases: 3 10 | ansistrano_shared_paths: 11 | - var/log 12 | - var/sessions 13 | ansistrano_shared_files: 14 | - '{{ private_key_path }}' 15 | symfony_console_path: 'bin/console' 16 | symfony_run_composer: false 17 | symfony_run_assetic_dump: false 18 | symfony_run_assets_install: false 19 | symfony_run_doctrine_migrations: true 20 | ansistrano_symfony_after_composer_tasks_file: "{{ playbook_dir }}/config/symfony_after_composer_tasks_file.yml" 21 | ansistrano_after_symlink_tasks_file: "{{ playbook_dir }}/config/after-symlink.yml" 22 | ansistrano_symfony_after_cache_tasks_file: "{{ playbook_dir }}/config/after-cache-tasks.yml" 23 | 24 | roles: 25 | - { role: cbrunnkvist.ansistrano-symfony-deploy } 26 | -------------------------------------------------------------------------------- /src/Job/JobProvider.php: -------------------------------------------------------------------------------- 1 | providers as $provider) { 23 | if (false === $provider->enabled()) { 24 | continue; 25 | } 26 | 27 | $jobs->addJob(...$provider->retrieve($parameters)->all()); 28 | } 29 | 30 | return $jobs; 31 | } 32 | 33 | public function enabled(): bool 34 | { 35 | return true; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /compose.override.yaml: -------------------------------------------------------------------------------- 1 | # Development environment override 2 | services: 3 | php: 4 | build: 5 | context: . 6 | target: frankenphp_dev 7 | volumes: 8 | #- ./frankenphp/certs:/etc/caddy/certs:ro 9 | - ./:/app 10 | - ./frankenphp/Caddyfile:/etc/frankenphp/Caddyfile:ro 11 | - ./frankenphp/conf.d/20-app.dev.ini:/usr/local/etc/php/app.conf.d/20-app.dev.ini:ro 12 | environment: 13 | FRANKENPHP_WORKER_CONFIG: watch 14 | #CADDY_SERVER_EXTRA_DIRECTIVES: "tls /etc/caddy/certs/tls.pem /etc/caddy/certs/tls.key" 15 | # See https://xdebug.org/docs/all_settings#mode 16 | XDEBUG_MODE: "${XDEBUG_MODE:-off}" 17 | APP_ENV: "${APP_ENV:-dev}" 18 | extra_hosts: 19 | # Ensure that host.docker.internal is correctly defined on Linux 20 | - host.docker.internal:host-gateway 21 | tty: true 22 | 23 | database: 24 | ports: 25 | - "5499:5432" 26 | -------------------------------------------------------------------------------- /src/Job/Scraping/JobScraper.php: -------------------------------------------------------------------------------- 1 | httpBrowser->request('GET', $url); 18 | 19 | $structuredData = []; 20 | foreach ($crawler->filter('script[type="application/ld+json"]') as $domElement) { 21 | /** @var array $decodedData */ 22 | $decodedData = json_decode($domElement->textContent, true, 512, \JSON_THROW_ON_ERROR); 23 | 24 | if (isset($decodedData['@type']) && self::JOB_SCHEMA_TYPE === $decodedData['@type']) { 25 | $structuredData = $decodedData; 26 | 27 | break; 28 | } 29 | } 30 | 31 | return $structuredData; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Form/SponsorType.php: -------------------------------------------------------------------------------- 1 | add('donationAmount', MoneyType::class, [ 17 | 'label' => 'form.label.donation_amount', 18 | 'divisor' => 100, 19 | 'data' => 5000, 20 | 'constraints' => [ 21 | new GreaterThan(0), 22 | new NotBlank(), 23 | ], 24 | 'html5' => true, 25 | 'attr' => [ 26 | 'step' => 5, 27 | ], 28 | ]) 29 | ; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /tests/Controller/DefaultControllerTest.php: -------------------------------------------------------------------------------- 1 | request('GET', $url); 15 | 16 | self::assertResponseIsSuccessful(); 17 | } 18 | 19 | public static function provideUrls(): \Generator 20 | { 21 | yield ['/symfony-location-remote-jobs']; 22 | yield ['/symfony-location-onsite-jobs']; 23 | yield ['/symfony-location-hybrid-jobs']; 24 | yield ['/symfony-employment-fulltime-jobs']; 25 | yield ['/symfony-employment-contract-jobs']; 26 | yield ['/symfony-employment-internship-jobs']; 27 | yield ['/rss.xml']; 28 | yield ['/events/rss.xml']; 29 | yield ['/news/rss.xml']; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /templates/job/index.xml.twig: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{ 'rss.title_jobs'|trans }} 5 | {{ 'rss.description'|trans }} 6 | {{ 'now'|date('r', timezone='GMT') }} 7 | {{ (jobs|last).publishedAt|default('now')|date('r', timezone='GMT') }} 8 | {{ url('job_index') }} 9 | {{ app.request.locale }} 10 | 11 | {% for job in jobs %} 12 | 13 | {{ job.title }} 14 | {{ job.url }} 15 | {{ job.createdAt|date(format='r', timezone='GMT') }} 16 | {{ url('job', {'id': job.id}) }} 17 | {% for tag in job.tags %} 18 | {{ tag|trim }} 19 | {% endfor %} 20 | 21 | {% endfor %} 22 | 23 | 24 | -------------------------------------------------------------------------------- /tests/Mock/MockStripeClient.php: -------------------------------------------------------------------------------- 1 | createSessionResponse; 28 | } 29 | 30 | if (str_starts_with($absUrl, 'https://api.stripe.com/v1/checkout/sessions') && 'get' === mb_strtolower($method)) { 31 | $body = $this->retrieveSessionResponse; 32 | } 33 | 34 | return [$body, 200, []]; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Job/EventSubscriber/SendForClassificationSubscriber.php: -------------------------------------------------------------------------------- 1 | 'onJobPosted', 20 | ]; 21 | } 22 | 23 | public function onJobPosted(JobPostedEvent $event): void 24 | { 25 | $job = $event->job; 26 | 27 | if ($job->isManualPublishing()) { 28 | return; 29 | } 30 | 31 | if (null === $job->getDescription() || '' === $job->getDescription()) { 32 | return; 33 | } 34 | 35 | $this->bus->dispatch(new ClassifyMessage($job->getId())); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Media/MediaFactory.php: -------------------------------------------------------------------------------- 1 | guessExtension()); 16 | $path = \sprintf('%s/%s', $pathPrefix, $filename); 17 | 18 | $media->setName($filename); 19 | $media->setOriginalName($file->getClientOriginalName()); 20 | 21 | $imageSize = getimagesize($file); 22 | /** @var int[] $dimensions */ 23 | $dimensions = $imageSize ? array_splice($imageSize, 0, 2) : null; 24 | $media->setDimensions($dimensions); 25 | $media->setSize($file->getSize()); 26 | $media->setMimeType($file->getMimeType()); 27 | $media->setPath($path); 28 | 29 | $media->setFile($file); 30 | 31 | return $media; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/News/Aggregator/XmlHelper.php: -------------------------------------------------------------------------------- 1 | query($query, $element); 14 | 15 | if (false === $nodeList) { 16 | return false; 17 | } 18 | 19 | if ($nodeList->length <= 0) { 20 | return null; 21 | } 22 | 23 | if ($index > $nodeList->length) { 24 | return null; 25 | } 26 | 27 | return self::sanitizeValue($nodeList->item($index)?->nodeValue); 28 | } 29 | 30 | private static function sanitizeValue(mixed $value): mixed 31 | { 32 | if ('true' === $value) { 33 | return true; 34 | } 35 | 36 | if ('false' === $value) { 37 | return false; 38 | } 39 | 40 | if (empty($value)) { 41 | return null; 42 | } 43 | 44 | return $value; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /config/packages/sentry.php: -------------------------------------------------------------------------------- 1 | env()) { 13 | $containerConfigurator->extension('sentry', [ 14 | 'dsn' => env('SENTRY_DSN'), 15 | 'register_error_listener' => false, 16 | 'register_error_handler' => false, 17 | 'options' => [ 18 | 'ignore_exceptions' => [ 19 | NotFoundHttpException::class, 20 | BadRequestHttpException::class, 21 | MethodNotAllowedHttpException::class, 22 | ], 23 | ] 24 | ]); 25 | } 26 | }; 27 | -------------------------------------------------------------------------------- /config/packages/web_profiler.php: -------------------------------------------------------------------------------- 1 | env() === 'dev') { 9 | $containerConfigurator->extension('web_profiler', [ 10 | 'toolbar' => true, 11 | 'intercept_redirects' => false, 12 | ]); 13 | $containerConfigurator->extension('framework', [ 14 | 'profiler' => [ 15 | 'only_exceptions' => false, 16 | 'collect_serializer_data' => true, 17 | ], 18 | ]); 19 | } 20 | 21 | if ($containerConfigurator->env() === 'test') { 22 | $containerConfigurator->extension('web_profiler', [ 23 | 'toolbar' => false, 24 | 'intercept_redirects' => false, 25 | ]); 26 | $containerConfigurator->extension('framework', [ 27 | 'profiler' => [ 28 | 'collect' => false, 29 | ], 30 | ]); 31 | } 32 | }; 33 | -------------------------------------------------------------------------------- /templates/job/_subscription_form.html.twig: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |

Subscribe to receive the latest Symfony jobs in your inbox

5 |

Receive a weekly overview of Symfony jobs by subscribing to our mailing list

6 |
7 |
8 | {{ form_start(form, {method: 'POST', action: path('subscribe'), attr: {class: 'row gy-2 gx-3 align-items-center'}}) }} 9 |
10 | {{ form_label(form.email, null, {'label_attr': {'class': 'visually-hidden'}}) }} 11 | {{ form_widget(form.email) }} 12 |
13 |
14 | 15 |
16 | {{ form_end(form) }} 17 |
18 |
19 |
20 | 21 | -------------------------------------------------------------------------------- /frankenphp/Caddyfile: -------------------------------------------------------------------------------- 1 | { 2 | skip_install_trust 3 | 4 | {$CADDY_GLOBAL_OPTIONS} 5 | 6 | frankenphp { 7 | {$FRANKENPHP_CONFIG} 8 | 9 | worker { 10 | file ./public/index.php 11 | env APP_RUNTIME Runtime\FrankenPhpSymfony\Runtime 12 | {$FRANKENPHP_WORKER_CONFIG} 13 | } 14 | } 15 | } 16 | 17 | {$CADDY_EXTRA_CONFIG} 18 | 19 | {$SERVER_NAME:localhost} { 20 | log { 21 | {$CADDY_SERVER_LOG_OPTIONS} 22 | # Redact the authorization query parameter that can be set by Mercure 23 | format filter { 24 | request>uri query { 25 | replace authorization REDACTED 26 | } 27 | } 28 | } 29 | 30 | root /app/public 31 | encode zstd br gzip 32 | 33 | {$CADDY_SERVER_EXTRA_DIRECTIVES} 34 | 35 | # Disable Topics tracking if not enabled explicitly: https://github.com/jkarlin/topics 36 | header ?Permissions-Policy "browsing-topics=()" 37 | 38 | @phpRoute { 39 | not path /.well-known/mercure* 40 | not file {path} 41 | } 42 | rewrite @phpRoute index.php 43 | 44 | @frontController path index.php 45 | php @frontController 46 | 47 | file_server { 48 | hide *.php 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /templates/default/pagination.html.twig: -------------------------------------------------------------------------------- 1 |
2 | {% if previous is defined %} 3 | 7 | {% else %} 8 | 12 | {% endif %} 13 | 14 | {% if next is defined %} 15 | 19 | {% else %} 20 | 24 | {% endif %} 25 |
26 | -------------------------------------------------------------------------------- /tests/Job/AccessTokenTest.php: -------------------------------------------------------------------------------- 1 | expectException(InvalidArgumentException::class); 14 | AccessToken::create('', 2); 15 | } 16 | 17 | public function test_create_access_token_with_negative_expiration_throws_exception(): void 18 | { 19 | $this->expectException(InvalidArgumentException::class); 20 | AccessToken::create('xxx', -5); 21 | } 22 | 23 | public function test_access_token_has_expired(): void 24 | { 25 | $accessToken = AccessToken::create('xxx', 1); 26 | sleep(2); 27 | 28 | self::assertTrue($accessToken->hasExpired()); 29 | } 30 | 31 | public function test_access_token_has_not_expired(): void 32 | { 33 | $accessToken = AccessToken::create('xxx', 1500); 34 | 35 | self::assertFalse($accessToken->hasExpired()); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/News/Aggregator/AggregateNews.php: -------------------------------------------------------------------------------- 1 | feedRepository->findAll(); 24 | 25 | $articles = []; 26 | foreach ($feeds as $feed) { 27 | try { 28 | $articles[] = ($this->fetchArticlesFromFeed)($feed); 29 | } catch (\Exception $exception) { 30 | $this->logger->error($exception->getMessage(), [ 31 | 'feedName' => $feed->getName(), 32 | 'feedUrl' => $feed->getUrl(), 33 | ]); 34 | } 35 | } 36 | 37 | return array_merge(...$articles); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Twig/Runtime/AssetExtensionRuntime.php: -------------------------------------------------------------------------------- 1 | $parameters 21 | */ 22 | public function assetUrl( 23 | string $path, 24 | array $parameters = [], 25 | int $referenceType = UrlGeneratorInterface::ABSOLUTE_PATH, 26 | ): string { 27 | $parameters['s'] = SignatureFactory::create($this->secret)->generateSignature($path, $parameters); 28 | $parameters['path'] = mb_ltrim($path, '/'); 29 | 30 | return $this->urlGenerator->generate('asset_url', $parameters, $referenceType); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /tests/Job/JobProviderTest.php: -------------------------------------------------------------------------------- 1 | retrieve(new SearchParameters()); 37 | 38 | self::assertSame($job1, $jobCollection->all()[0]); 39 | self::assertSame($job2, $jobCollection->all()[1]); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /tests/Repository/InMemoryJobRepository.php: -------------------------------------------------------------------------------- 1 | 14 | */ 15 | private array $jobs; 16 | 17 | /** 18 | * @param Job[] $jobs 19 | */ 20 | public function __construct(array $jobs) 21 | { 22 | foreach ($jobs as $job) { 23 | $this->jobs[(string) $job->getId()] = $job; 24 | } 25 | } 26 | 27 | public function get(UuidInterface $id): Job 28 | { 29 | if (isset($this->jobs[(string) $id])) { 30 | return $this->jobs[(string) $id]; 31 | } 32 | 33 | throw new JobNotFoundException(); 34 | } 35 | 36 | public function save(Job $job): void 37 | { 38 | $this->jobs[(string) $job->getId()] = $job; 39 | } 40 | 41 | public function remove(Job $job): void 42 | { 43 | unset($this->jobs[(string) $job->getId()]); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /tests/News/Aggregator/Atom/ClientTest.php: -------------------------------------------------------------------------------- 1 | get('http://localhost'); 23 | 24 | // Assert 25 | self::assertNotNull($feed); 26 | self::assertSame('The Strangebuzz PHP/Symfony blog.', $feed->title); 27 | self::assertCount(4, $feed->getEntries()); 28 | $entry = $feed->getEntries()[0]; 29 | self::assertSame('Validating your data fixtures with the Alice Symfony bundle', mb_trim($entry->title)); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Controller/Admin/SourceCrudController.php: -------------------------------------------------------------------------------- 1 | 14 | */ 15 | final class SourceCrudController extends AbstractCrudController 16 | { 17 | public static function getEntityFqcn(): string 18 | { 19 | return Source::class; 20 | } 21 | 22 | public function configureFields(string $pageName): iterable 23 | { 24 | return [ 25 | IdField::new('id') 26 | ->onlyOnDetail(), 27 | UrlField::new('url'), 28 | DateTimeField::new('createdAt') 29 | ->onlyOnIndex(), 30 | ]; 31 | } 32 | 33 | public function configureFilters(Filters $filters): Filters 34 | { 35 | return $filters 36 | ->add('url') 37 | ; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/News/Aggregator/FetchArticlesFromFeed.php: -------------------------------------------------------------------------------- 1 | logger->info('Fetching articles from feed.', [ 28 | 'feed' => $feed->getName(), 29 | 'feedUrl' => $feed->getUrl(), 30 | ]); 31 | 32 | foreach ($this->providers as $provider) { 33 | if (false === $provider->supports($feed)) { 34 | continue; 35 | } 36 | 37 | return ($provider)($feed); 38 | } 39 | 40 | return []; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /templates/job/_job_list_item.html.twig: -------------------------------------------------------------------------------- 1 | {# @var job \App\Entity\Job #} 2 |
3 |
4 | {{ job.organization }} 5 | 6 |
7 | 8 |

9 | 10 | {{ job.title }} 11 | 12 |

13 | 14 | 27 |
28 | -------------------------------------------------------------------------------- /src/EasyAdmin/UploadMediaOnFeedCreatedSubscriber.php: -------------------------------------------------------------------------------- 1 | $beforeEntityPersistedEvent 20 | */ 21 | public function __invoke(BeforeEntityPersistedEvent $beforeEntityPersistedEvent): void 22 | { 23 | $entity = $beforeEntityPersistedEvent->getEntityInstance(); 24 | 25 | if (!$entity instanceof Feed) { 26 | return; 27 | } 28 | 29 | if (null === $entity->getImageFile()) { 30 | return; 31 | } 32 | 33 | $media = $this->mediaFactory->createFromUploadedFile($entity->getImageFile()); 34 | $entity->changeImage($media); 35 | $this->mediaUploader->upload($media); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /tests/Repository/InMemoryEntryRepository.php: -------------------------------------------------------------------------------- 1 | 12 | */ 13 | private array $entries = []; 14 | 15 | /** 16 | * @param Entry[] $entries 17 | */ 18 | public function __construct(array $entries = []) 19 | { 20 | foreach ($entries as $entry) { 21 | $this->entries[(string) $entry->getId()] = $entry; 22 | } 23 | } 24 | 25 | public function save(Entry $entry): void 26 | { 27 | $this->entries[(string) $entry->getId()] = $entry; 28 | } 29 | 30 | public function remove(Entry $entry): void 31 | { 32 | // TODO: Implement remove() method. 33 | } 34 | 35 | public function ofLink(string $link): ?Entry 36 | { 37 | foreach ($this->entries as $entry) { 38 | if ($entry->getLink() === $link) { 39 | return $entry; 40 | } 41 | } 42 | 43 | return null; 44 | } 45 | 46 | public function getAll(): array 47 | { 48 | return $this->entries; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/News/Aggregator/RSS/Model/Document.php: -------------------------------------------------------------------------------- 1 | channels[] = $channel; 19 | } 20 | 21 | /** 22 | * @return Channel[] 23 | */ 24 | public function getChannels(): array 25 | { 26 | return $this->channels; 27 | } 28 | 29 | public static function create(string $content): self 30 | { 31 | $rssDocument = new self(); 32 | 33 | $document = new \DOMDocument(); 34 | $document->loadXML($content); 35 | 36 | $xpath = new \DOMXPath($document); 37 | 38 | $channelsNode = $xpath->query('/rss/channel'); 39 | 40 | if (false === $channelsNode) { 41 | return $rssDocument; 42 | } 43 | 44 | /** @var \DOMNode $channelNode */ 45 | foreach ($channelsNode as $channelNode) { 46 | $rssDocument->addChannel(Channel::create($xpath, $channelNode)); 47 | } 48 | 49 | return $rssDocument; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /rector.php: -------------------------------------------------------------------------------- 1 | withPaths([ 14 | __DIR__.'/src', 15 | __DIR__.'/tests', 16 | ]) 17 | ->withPreparedSets( 18 | deadCode: true, 19 | codeQuality: true, 20 | codingStyle: true, 21 | typeDeclarations: true, 22 | earlyReturn: true, 23 | ) 24 | ->withPhpSets(php84: true) 25 | ->withImportNames(importShortClasses: false, removeUnusedImports: true) 26 | ->withSkip([ 27 | ClosureToArrowFunctionRector::class, 28 | FlipTypeControlToUseExclusiveTypeRector::class, 29 | SimplifyBoolIdenticalTrueRector::class, 30 | ClassPropertyAssignToConstructorPromotionRector::class => [ 31 | __DIR__.'/src/Entity', 32 | ], 33 | AddOverrideAttributeToOverriddenMethodsRector::class, 34 | ]); 35 | -------------------------------------------------------------------------------- /.github/dependabot.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | updates: 4 | # Maintain dependencies for GitHub Actions 5 | - package-ecosystem: "github-actions" 6 | directory: "/" 7 | schedule: 8 | interval: "monthly" 9 | labels: 10 | - "github-actions" 11 | - "dependencies" 12 | 13 | # Maintain dependencies for Composer 14 | - package-ecosystem: "composer" 15 | directory: "/" 16 | schedule: 17 | interval: "monthly" 18 | labels: 19 | - "php" 20 | - "dependencies" 21 | groups: 22 | symfony: 23 | patterns: 24 | - "symfony/*" 25 | phpstan: 26 | patterns: 27 | - "phpstan/*" 28 | twig: 29 | patterns: 30 | - "twig/*" 31 | 32 | # Maintain dependencies for PHP CS Fixer 33 | - package-ecosystem: "composer" 34 | directory: "/tools/php-cs-fixer/" 35 | schedule: 36 | interval: "monthly" 37 | labels: 38 | - "php" 39 | - "dependencies" 40 | 41 | # Maintain dependencies for Rector 42 | - package-ecosystem: "composer" 43 | directory: "/tools/rector/" 44 | schedule: 45 | interval: "monthly" 46 | labels: 47 | - "php" 48 | - "dependencies" 49 | -------------------------------------------------------------------------------- /src/Repository/News/FeedRepository.php: -------------------------------------------------------------------------------- 1 | 12 | * 13 | * @method Feed|null find($id, $lockMode = null, $lockVersion = null) 14 | * @method Feed|null findOneBy(array $criteria, array $orderBy = null) 15 | * @method Feed[] findAll() 16 | * @method Feed[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) 17 | */ 18 | final class FeedRepository extends ServiceEntityRepository implements FeedRepositoryInterface 19 | { 20 | public function __construct(ManagerRegistry $registry) 21 | { 22 | parent::__construct($registry, Feed::class); 23 | } 24 | 25 | public function save(Feed $feed): void 26 | { 27 | $this->getEntityManager()->persist($feed); 28 | $this->getEntityManager()->flush(); 29 | } 30 | 31 | public function remove(Feed $feed): void 32 | { 33 | $this->getEntityManager()->remove($feed); 34 | $this->getEntityManager()->flush(); 35 | } 36 | 37 | public function get(string $id): ?Feed 38 | { 39 | return $this->find($id); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Entity/CommunityEvent/Source.php: -------------------------------------------------------------------------------- 1 | id = $id; 35 | $this->createdAt = $createdAt; 36 | } 37 | 38 | public function getId(): UuidInterface 39 | { 40 | return $this->id; 41 | } 42 | 43 | public function getUrl(): string 44 | { 45 | return $this->url; 46 | } 47 | 48 | public function setUrl(string $url): void 49 | { 50 | $this->url = $url; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/OpenAI/Client.php: -------------------------------------------------------------------------------- 1 | 27 | */ 28 | public function completions(CompletionRequest $request): array 29 | { 30 | $response = $this->openaiClient->request('POST', 'completions', [ 31 | 'json' => $request->toArray(), 32 | ]); 33 | 34 | if (200 !== $response->getStatusCode()) { 35 | return []; 36 | } 37 | 38 | return $response->toArray(false); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /.ansible/host_vars/web/vault.yml: -------------------------------------------------------------------------------- 1 | $ANSIBLE_VAULT;1.1;AES256 2 | 36393261663665306339613334613131306433356463353165383038373864383463366562363738 3 | 3266356366373539666532396462333133653837343464330a666463623632623130316534313362 4 | 36306561373338336230303536616232626536613338653735633761613831333230336436623361 5 | 3738313061336537660a313431666633383966623762366236613937653761613632323437353461 6 | 62356162383164366637346234666562656439363462306163623232376339616530343733396136 7 | 34626334313632363331343133613766313863313234343261393564633034393965336566633036 8 | 34353365333134643163306632613434363738363339333135373137346131326339336364303061 9 | 39373237643230313438306435343939343636336539326532646637623662386162383936623965 10 | 31316531303338623863653965313132313362396439323831363730356339626136613564323835 11 | 61373735353831393939653537353966623530356165356533316261363033316164623637653035 12 | 64346231356431626539643838643963666336316562636165336166666531613664386161313461 13 | 62343432376330326132396534353730303066633065653535373531323161333566343135613262 14 | 36616361346263343637653761616236303239373437333061333465353234303366326335363536 15 | 64366633343431643038396561366564376265353963313565306465303166326231346565336132 16 | 66333866663166323335353265333339656334633330633865333636323434346665613634356635 17 | 63326361316362313238623335643862666362343438623839316466303062633034353534343763 18 | 61343666363066316466303462313139643266396233626365626261656136363932 19 | -------------------------------------------------------------------------------- /src/News/Aggregator/Atom/Model/Feed.php: -------------------------------------------------------------------------------- 1 | entries[] = $entry; 22 | } 23 | 24 | /** 25 | * @return Entry[] 26 | */ 27 | public function getEntries(): array 28 | { 29 | return $this->entries; 30 | } 31 | 32 | public static function create(string $content): self 33 | { 34 | $document = new \DOMDocument(); 35 | $document->loadXML($content); 36 | 37 | $xpath = new \DOMXPath($document); 38 | 39 | $xpath->registerNamespace('atom', 'http://www.w3.org/2005/Atom'); 40 | 41 | $title = XmlHelper::getNodeValue($xpath, '/atom:feed/atom:title'); 42 | Assert::notNull($title); 43 | Assert::string($title); 44 | 45 | $feed = new self($title); 46 | 47 | $entries = $xpath->query('/atom:feed/atom:entry'); 48 | Assert::isIterable($entries); 49 | 50 | /** @var \DOMNode $entryNode */ 51 | foreach ($entries as $entryNode) { 52 | $feed->addEntry(Entry::create($xpath, $entryNode)); 53 | } 54 | 55 | return $feed; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /.php-cs-fixer.dist.php: -------------------------------------------------------------------------------- 1 | in(__DIR__) 5 | ->exclude('var') 6 | ->exclude('config') 7 | ->exclude('public/build') 8 | // exclude files generated by Symfony Flex recipes 9 | ->notPath('bin/console') 10 | ->notPath('public/index.php') 11 | ; 12 | 13 | return (new PhpCsFixer\Config()) 14 | ->setRiskyAllowed(true) 15 | ->registerCustomFixers(new PedroTroller\CS\Fixer\Fixers()) 16 | ->setRules([ 17 | '@Symfony' => true, 18 | '@Symfony:risky' => true, 19 | 'linebreak_after_opening_tag' => true, 20 | 'mb_str_functions' => true, 21 | 'no_php4_constructor' => true, 22 | 'no_unreachable_default_argument_value' => true, 23 | 'no_useless_else' => true, 24 | 'no_useless_return' => true, 25 | 'php_unit_strict' => true, 26 | 'phpdoc_order' => true, 27 | 'strict_comparison' => true, 28 | 'strict_param' => true, 29 | 'php_unit_method_casing' => [ 30 | 'case' => 'snake_case', 31 | ], 32 | 'PedroTroller/exceptions_punctuation' => true, 33 | 'PedroTroller/line_break_between_method_arguments' => [ 'max-args' => 4, 'max-length' => 120, 'automatic-argument-merge' => true ], 34 | 'PedroTroller/line_break_between_statements' => true, 35 | 'PedroTroller/doctrine_migrations' => true, 36 | ]) 37 | ->setFinder($finder) 38 | ->setCacheFile(__DIR__.'/var/.php-cs-fixer.cache') 39 | ; 40 | -------------------------------------------------------------------------------- /src/News/Aggregator/FetchArticlesFromAtomFeed.php: -------------------------------------------------------------------------------- 1 | atomClient->get($feed->getUrl()); 18 | 19 | if (null === $atomFeed) { 20 | return []; 21 | } 22 | 23 | $articles = []; 24 | foreach ($atomFeed->getEntries() as $entry) { 25 | $article = new Entry(); 26 | 27 | $title = mb_trim($entry->title); 28 | if (mb_strlen($title) > 255) { 29 | continue; 30 | } 31 | 32 | $article->setTitle(mb_trim($entry->title)); 33 | $article->setLink($entry->link); 34 | $article->setDescription($entry->summary ? mb_trim((string) $entry->summary) : mb_trim((string) $entry->content)); 35 | $article->setPublishedAt($entry->published); 36 | $article->setFeed($feed); 37 | 38 | $articles[] = $article; 39 | } 40 | 41 | return $articles; 42 | } 43 | 44 | public function supports(Feed $feed): bool 45 | { 46 | return FeedType::ATOM === $feed->getType(); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/ConsoleCommand/AggregateNewsCommand.php: -------------------------------------------------------------------------------- 1 | feedRepository->findAll(); 34 | 35 | foreach ($feeds as $feed) { 36 | $this->bus->dispatch(new FetchFeedCommand($feed->getId()->toString())); 37 | 38 | $io->info(\sprintf('Feed "%s" fetching scheduled...', $feed->getName())); 39 | } 40 | 41 | return Command::SUCCESS; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/EasyAdmin/ReplaceMediaOnJobUpdatedSubscriber.php: -------------------------------------------------------------------------------- 1 | $beforeEntityUpdatedEvent 24 | */ 25 | public function __invoke(BeforeEntityUpdatedEvent $beforeEntityUpdatedEvent): void 26 | { 27 | $entity = $beforeEntityUpdatedEvent->getEntityInstance(); 28 | 29 | if (!$entity instanceof Job) { 30 | return; 31 | } 32 | 33 | if (null === ($media = $entity->getOrganizationImage())) { 34 | return; 35 | } 36 | 37 | if (null === ($file = $media->getFile())) { 38 | return; 39 | } 40 | 41 | $this->mediaRemover->delete($media); 42 | 43 | $newMedia = $this->mediaFactory->createFromUploadedFile($file); 44 | $entity->changeOrganizationImage($newMedia); 45 | $this->mediaUploader->upload($newMedia); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/Controller/Admin/EntryCrudController.php: -------------------------------------------------------------------------------- 1 | 17 | */ 18 | final class EntryCrudController extends AbstractCrudController 19 | { 20 | public static function getEntityFqcn(): string 21 | { 22 | return Entry::class; 23 | } 24 | 25 | public function configureFields(string $pageName): iterable 26 | { 27 | return [ 28 | IdField::new('id') 29 | ->onlyOnDetail(), 30 | TextField::new('title') 31 | ->setMaxLength(35), 32 | UrlField::new('link'), 33 | AssociationField::new('feed'), 34 | TextareaField::new('description') 35 | ->onlyOnForms(), 36 | DateTimeField::new('publishedAt'), 37 | ]; 38 | } 39 | 40 | public function configureFilters(Filters $filters): Filters 41 | { 42 | return $filters 43 | ->add('title') 44 | ; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /tests/CommunityEvent/EventScrapingTest.php: -------------------------------------------------------------------------------- 1 | fetch('https://www.meetup.com/backendos'); 25 | 26 | // Assert 27 | self::assertCount(1, $data); 28 | $meetupData = $data[0]; 29 | $this->assertIsArray($meetupData); 30 | $this->assertArrayHasKey('name', $meetupData); 31 | self::assertSame('Backend User Group #21', $meetupData['name']); 32 | $this->assertArrayHasKey('url', $meetupData); 33 | self::assertSame('https://www.meetup.com/backendos/events/290348177/', $meetupData['url']); 34 | $this->assertArrayHasKey('startDate', $meetupData); 35 | self::assertSame('2023-01-19T18:30+01:00', $meetupData['startDate']); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /config/packages/messenger.php: -------------------------------------------------------------------------------- 1 | messenger(); 15 | 16 | $messenger->failureTransport('failed'); 17 | 18 | $messenger->transport('sync')->dsn('sync://'); 19 | 20 | $messenger 21 | ->transport('async') 22 | ->dsn(env('MESSENGER_TRANSPORT_DSN')) 23 | ->retryStrategy()->maxRetries(3)->multiplier(2); 24 | 25 | $messenger 26 | ->transport('failed') 27 | ->dsn('doctrine://default?queue_name=failed'); 28 | 29 | $messenger->routing(SendEmailMessage::class)->senders(['async']); 30 | $messenger->routing(ChatMessage::class)->senders(['async']); 31 | $messenger->routing(SmsMessage::class)->senders(['async']); 32 | $messenger->routing(CreateDonationPaymentUrlCommand::class)->senders(['sync']); 33 | 34 | if ('test' === $containerConfigurator->env()) { 35 | $messenger 36 | ->transport('async') 37 | ->dsn('in-memory://'); 38 | } 39 | }; 40 | -------------------------------------------------------------------------------- /src/EasyAdmin/ReplaceMediaOnFeedUpdatedSubscriber.php: -------------------------------------------------------------------------------- 1 | $beforeEntityUpdatedEvent 24 | */ 25 | public function __invoke(BeforeEntityUpdatedEvent $beforeEntityUpdatedEvent): void 26 | { 27 | $entity = $beforeEntityUpdatedEvent->getEntityInstance(); 28 | 29 | if (!$entity instanceof Feed) { 30 | return; 31 | } 32 | 33 | if (null === ($file = $entity->getImageFile())) { 34 | return; 35 | } 36 | 37 | $media = $entity->getImage(); 38 | 39 | if (null !== $media && null !== $media->getPath()) { 40 | $this->mediaRemover->delete($media); 41 | } 42 | 43 | $newMedia = $this->mediaFactory->createFromUploadedFile($file); 44 | $entity->changeImage($newMedia); 45 | $this->mediaUploader->upload($newMedia); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | php: 3 | image: ${IMAGES_PREFIX:-}app-php 4 | restart: unless-stopped 5 | environment: 6 | SERVER_NAME: ${SERVER_NAME:-localhost}, php:80 7 | DATABASE_URL: postgresql://${POSTGRES_USER:-app}:${POSTGRES_PASSWORD:-!ChangeMe!}@database:5432/${POSTGRES_DB:-app}?serverVersion=${POSTGRES_VERSION:-15}&charset=${POSTGRES_CHARSET:-utf8} 8 | volumes: 9 | - caddy_data:/data 10 | - caddy_config:/config 11 | ports: 12 | # HTTP 13 | - target: 80 14 | published: ${HTTP_PORT:-80} 15 | protocol: tcp 16 | # HTTPS 17 | - target: 443 18 | published: ${HTTPS_PORT:-443} 19 | protocol: tcp 20 | # HTTP/3 21 | - target: 443 22 | published: ${HTTP3_PORT:-443} 23 | protocol: udp 24 | 25 | database: 26 | image: postgres:${POSTGRES_VERSION:-16}-alpine 27 | environment: 28 | POSTGRES_DB: ${POSTGRES_DB:-app} 29 | POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-app} 30 | POSTGRES_USER: ${POSTGRES_USER:-app} 31 | healthcheck: 32 | test: ["CMD", "pg_isready", "-d", "${POSTGRES_DB:-app}", "-U", "${POSTGRES_USER:-app}"] 33 | timeout: 5s 34 | retries: 5 35 | start_period: 60s 36 | volumes: 37 | - database_data:/var/lib/postgresql/data:rw 38 | 39 | volumes: 40 | caddy_data: 41 | caddy_config: 42 | database_data: 43 | -------------------------------------------------------------------------------- /src/Job/Command/PostJobOfferCommandHandler.php: -------------------------------------------------------------------------------- 1 | toEntity(); 27 | 28 | if (null !== $command->organizationImageFile) { 29 | $media = $this->mediaFactory->createFromUploadedFile($command->organizationImageFile); 30 | $job->changeOrganizationImage($media); 31 | $this->mediaUploader->upload($media); 32 | } 33 | 34 | $job->publish(); 35 | 36 | // Allow 1 month of boost on manual creation 37 | $job->pinUntil(new \DateTimeImmutable('+1 month')); 38 | 39 | $this->jobRepository->save($job); 40 | 41 | $this->eventDispatcher->dispatch(new JobPostedEvent($job)); 42 | 43 | $this->entityManager->flush(); 44 | 45 | return $job; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /templates/event/_event.html.twig: -------------------------------------------------------------------------------- 1 |
2 |
3 |

4 | {{ event.name }} 5 |

6 | 26 | {% if event.abstract %} 27 |

{{ event.abstract }}

28 | {% endif %} 29 |
30 |
31 | -------------------------------------------------------------------------------- /config/packages/security.php: -------------------------------------------------------------------------------- 1 | passwordHasher(PasswordAuthenticatedUserInterface::class) 14 | ->algorithm('auto'); 15 | 16 | $config 17 | ->passwordHasher(User::class) 18 | ->algorithm('auto'); 19 | 20 | $config->provider('admin') 21 | ->memory() 22 | ->user('admin') 23 | ->password(env('ADMIN_PASSWORD')->base64()) 24 | ->roles(['ROLE_ADMIN']); 25 | 26 | $config->firewall('dev') 27 | ->pattern('^/(_(profiler|wdt)|css|images|js)/') 28 | ->security(false); 29 | 30 | $config->firewall('admin') 31 | ->lazy(true) 32 | ->provider('admin') 33 | ->httpBasic() 34 | ->realm('Secured Area'); 35 | 36 | $config->accessControl() 37 | ->path('^/admin') 38 | ->roles('ROLE_ADMIN'); 39 | 40 | if ('test' === $containerConfigurator->env()) { 41 | $config 42 | ->passwordHasher(PasswordAuthenticatedUserInterface::class) 43 | ->algorithm('auto') 44 | ->cost(4) 45 | ->timeCost(3) 46 | ->memoryCost(10); 47 | } 48 | }; 49 | -------------------------------------------------------------------------------- /src/ConsoleCommand/AggregateCommunityEventCommand.php: -------------------------------------------------------------------------------- 1 | sourceRepository->findAll(); 34 | 35 | foreach ($sources as $source) { 36 | $this->bus->dispatch(new FetchSourceCommand($source->getId())); 37 | $io->info(\sprintf('Source "%s" fetching scheduled...', $source->getUrl())); 38 | } 39 | 40 | return Command::SUCCESS; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /.github/workflows/tests.yaml: -------------------------------------------------------------------------------- 1 | name: "Tests 🧪" 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - main 8 | 9 | jobs: 10 | test: 11 | runs-on: 'ubuntu-latest' 12 | env: 13 | fail-fast: true 14 | DATABASE_URL: postgresql://app:app@127.0.0.1:5432/main_test 15 | services: 16 | postgres: 17 | image: postgres:16 18 | env: 19 | POSTGRES_PASSWORD: app 20 | POSTGRES_USER: app 21 | POSTGRES_DB: main_test 22 | ports: 23 | - 5432:5432 24 | steps: 25 | - name: "Checkout code" 26 | uses: actions/checkout@v6 27 | 28 | - name: "Install PHP with extensions" 29 | uses: shivammathur/setup-php@v2 30 | with: 31 | coverage: "none" 32 | extensions: intl, ctype, iconv, gd 33 | php-version: 8.4 34 | tools: composer 35 | 36 | - name: "Composer install" 37 | uses: ramsey/composer-install@v3 38 | 39 | - name: "Init database" 40 | run: ./bin/console doctrine:database:create --env=test --if-not-exists 41 | 42 | - name: "Execute migrations" 43 | run: ./bin/console doctrine:migration:migrate -n --env=test 44 | 45 | - name: "Load fixtures" 46 | run: ./bin/console doctrine:fixtures:load -n --env=test 47 | 48 | - name: "Run tests" 49 | run: vendor/bin/phpunit 50 | -------------------------------------------------------------------------------- /src/News/Aggregator/FetchArticlesFromRSSFeed.php: -------------------------------------------------------------------------------- 1 | rssClient->get($feed->getUrl()); 18 | 19 | if (null === $document) { 20 | return []; 21 | } 22 | 23 | $articles = []; 24 | foreach ($document->getChannels() as $channel) { 25 | foreach ($channel->getItems() as $item) { 26 | $article = new Entry(); 27 | 28 | $title = mb_trim($item->title); 29 | if (mb_strlen($title) > 255) { 30 | continue; 31 | } 32 | 33 | $article->setTitle($title); 34 | $article->setLink($item->link); 35 | $article->setDescription(mb_trim($item->description)); 36 | $article->setFeed($feed); 37 | 38 | if (null !== $item->pubDate) { 39 | $article->setPublishedAt($item->pubDate); 40 | } 41 | 42 | $articles[] = $article; 43 | } 44 | } 45 | 46 | return $articles; 47 | } 48 | 49 | public function supports(Feed $feed): bool 50 | { 51 | return FeedType::RSS === $feed->getType(); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /.ansible/config/after-symlink.yml: -------------------------------------------------------------------------------- 1 | - name: restart php8.2-fpm 2 | become: true 3 | service: name=php8.2-fpm state=restarted 4 | 5 | #- name: restart caddy 6 | # become: true 7 | # service: name=caddy state=restarted 8 | 9 | - name: Pull job provider 10 | ansible.builtin.cron: 11 | name: "pull job provider" 12 | minute: "0" 13 | hour: "*/6" 14 | job: "php {{ ansistrano_release_path.stdout }}/bin/console app:job-provider:retrieve" 15 | 16 | - name: Aggregate news 17 | ansible.builtin.cron: 18 | name: "aggregate news" 19 | minute: "0" 20 | hour: "*/4" 21 | job: "php {{ ansistrano_release_path.stdout }}/bin/console app:aggregate-news" 22 | 23 | - name: Clear pinned jobs 24 | ansible.builtin.cron: 25 | name: "clear pinned jobs" 26 | minute: "0" 27 | hour: "0" 28 | job: "php {{ ansistrano_release_path.stdout }}/bin/console app:clear-pinned" 29 | 30 | - name: Send weekly jobs letter 31 | ansible.builtin.cron: 32 | disabled: true 33 | name: "Send weekly jobs letter every saturday at 8am" 34 | minute: "0" 35 | hour: "10" 36 | day: "*" 37 | month: "*" 38 | weekday: "1" 39 | job: "php {{ ansistrano_release_path.stdout }}/bin/console app:send-jobsletter" 40 | 41 | - name: Restarting Workers 42 | command: php bin/console messenger:stop-workers 43 | args: 44 | chdir: '{{ ansistrano_release_path.stdout }}' 45 | 46 | - name: Aggregate events every day at 2am 47 | ansible.builtin.cron: 48 | name: "Aggregate events every day at 2am" 49 | minute: "0" 50 | hour: "2" 51 | job: "php {{ ansistrano_release_path.stdout }}/bin/console app:aggregate-events" 52 | -------------------------------------------------------------------------------- /tests/Repository/InMemoryEventRepository.php: -------------------------------------------------------------------------------- 1 | 12 | */ 13 | private array $events = []; 14 | 15 | /** 16 | * @param Event[] $events 17 | */ 18 | public function __construct(array $events = []) 19 | { 20 | foreach ($events as $event) { 21 | $this->events[(string) $event->getId()] = $event; 22 | } 23 | } 24 | 25 | public function save(Event $event): void 26 | { 27 | $this->events[(string) $event->getId()] = $event; 28 | } 29 | 30 | public function getAll(): array 31 | { 32 | return $this->events; 33 | } 34 | 35 | public function ofUrl(string $url): ?Event 36 | { 37 | foreach ($this->events as $event) { 38 | if ($event->getUrl() === $url) { 39 | return $event; 40 | } 41 | } 42 | 43 | return null; 44 | } 45 | 46 | public function findPastEvents(): array 47 | { 48 | return []; // Todo 49 | } 50 | 51 | public function findUpcomingEvents(?int $limit = null): array 52 | { 53 | return []; // Todo 54 | } 55 | 56 | public function remove(Event $event): void 57 | { 58 | if (false === isset($this->events[(string) $event->getId()])) { 59 | return; 60 | } 61 | 62 | unset($this->events[(string) $event->getId()]); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | tests 23 | 24 | 25 | 26 | 31 | 32 | src 33 | 34 | 35 | 36 | trigger_deprecation 37 | Doctrine\Deprecations\Deprecation::trigger 38 | Doctrine\Deprecations\Deprecation::delegateTriggerToBackend 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /src/Repository/CommunityEvent/SourceRepository.php: -------------------------------------------------------------------------------- 1 | 12 | * 13 | * @method Source|null find($id, $lockMode = null, $lockVersion = null) 14 | * @method Source|null findOneBy(array $criteria, array $orderBy = null) 15 | * @method Source[] findAll() 16 | * @method Source[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) 17 | */ 18 | final class SourceRepository extends ServiceEntityRepository implements SourceRepositoryInterface 19 | { 20 | public function __construct(ManagerRegistry $registry) 21 | { 22 | parent::__construct($registry, Source::class); 23 | } 24 | 25 | public function save(Source $entity, bool $flush = false): void 26 | { 27 | $this->getEntityManager()->persist($entity); 28 | 29 | if ($flush) { 30 | $this->getEntityManager()->flush(); 31 | } 32 | } 33 | 34 | public function remove(Source $entity, bool $flush = false): void 35 | { 36 | $this->getEntityManager()->remove($entity); 37 | 38 | if ($flush) { 39 | $this->getEntityManager()->flush(); 40 | } 41 | } 42 | 43 | public function getAll(): array 44 | { 45 | return $this->findAll(); 46 | } 47 | 48 | public function get(string $id): ?Source 49 | { 50 | return $this->find($id); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /templates/job/location_type.html.twig: -------------------------------------------------------------------------------- 1 | {% extends 'base.html.twig' %} 2 | 3 | {% block title %}Latest {{ ('location_type.' ~ locationType.value)|trans }} Symfony jobs - {{ parent() }}{% endblock %} 4 | 5 | {% block main %} 6 |
7 |

8 | Latest {{ ('location_type.' ~ locationType.value)|trans }} Symfony Jobs 9 |

10 | {#
#} 11 | {#
#} 12 | {# Get notified about new jobs ↓#} 13 | {#
#} 14 | {#
#} 15 |
16 | 17 | {% set jobsCount = jobs|length %} 18 | {% if jobsCount > 0 %} 19 |
20 | {% for job in jobs %} 21 | {{ include('job/_job_card.html.twig', {'job': job}) }} 22 | 23 | {# {% if loop.index == (jobsCount/2)|round %}#} 24 | {#
#} 25 | {# {{ render(controller('App\\Controller\\JobController::subscriptionForm')) }}#} 26 | {#
#} 27 | {# {% endif %}#} 28 | {% endfor %} 29 |
30 | {% else %} 31 |
32 |

No jobs

33 | {{ 'action.post_job'|trans }} 34 |
35 | {% endif %} 36 | {% endblock %} 37 | -------------------------------------------------------------------------------- /tests/Mock/create_session.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "cs_test_a1JsI9PapSOhD1h43TtbzDegu3BRzkU5LLimT41z1Kk3QpOhn0hUfQasXM", 3 | "object": "checkout.session", 4 | "after_expiration": null, 5 | "allow_promotion_codes": null, 6 | "amount_subtotal": null, 7 | "amount_total": null, 8 | "automatic_tax": { 9 | "enabled": false, 10 | "status": null 11 | }, 12 | "billing_address_collection": null, 13 | "cancel_url": "https://example.com/cancel", 14 | "client_reference_id": null, 15 | "consent": null, 16 | "consent_collection": null, 17 | "currency": null, 18 | "customer": null, 19 | "customer_creation": null, 20 | "customer_details": null, 21 | "customer_email": null, 22 | "expires_at": 1660829677, 23 | "livemode": false, 24 | "locale": null, 25 | "metadata": {}, 26 | "mode": "payment", 27 | "payment_intent": "pi_1Drar32eZvKYlo2CWOhCeqOT", 28 | "payment_link": null, 29 | "payment_method_collection": null, 30 | "payment_method_options": {}, 31 | "payment_method_types": [ 32 | "card" 33 | ], 34 | "payment_status": "unpaid", 35 | "phone_number_collection": { 36 | "enabled": false 37 | }, 38 | "recovered_from": null, 39 | "redaction": null, 40 | "setup_intent": null, 41 | "shipping_address_collection": null, 42 | "shipping_cost": null, 43 | "shipping_details": null, 44 | "shipping_options": [], 45 | "status": "expired", 46 | "submit_type": null, 47 | "subscription": null, 48 | "success_url": "https://example.com/success", 49 | "total_details": null, 50 | "url": "https://checkout.stripe.com/pay/xxx" 51 | } 52 | -------------------------------------------------------------------------------- /templates/job/employment_type.html.twig: -------------------------------------------------------------------------------- 1 | {% extends 'base.html.twig' %} 2 | 3 | {% block title %}Latest {{ ('employment_type.' ~ employmentType.value)|trans }} Symfony jobs - {{ parent() }}{% endblock %} 4 | 5 | {% block main %} 6 |
7 |

8 | Latest {{ ('employment_type.' ~ employmentType.value)|trans }} Symfony Jobs 9 |

10 | {#
#} 11 | {#
#} 12 | {# Get notified about new jobs ↓#} 13 | {#
#} 14 | {#
#} 15 |
16 | 17 | {% set jobsCount = jobs|length %} 18 | {% if jobsCount > 0 %} 19 |
20 | {% for job in jobs %} 21 | {{ include('job/_job_card.html.twig', {'job': job}) }} 22 | 23 | {# {% if loop.index == (jobsCount/2)|round %}#} 24 | {#
#} 25 | {# {{ render(controller('App\\Controller\\JobController::subscriptionForm')) }}#} 26 | {#
#} 27 | {# {% endif %}#} 28 | {% endfor %} 29 |
30 | {% else %} 31 |
32 |

No jobs

33 | {{ 'action.post_job'|trans }} 34 |
35 | {% endif %} 36 | {% endblock %} 37 | -------------------------------------------------------------------------------- /tests/Mock/retrieve_session_paid.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "cs_test_a1JsI9PapSOhD1h43TtbzDegu3BRzkU5LLimT41z1Kk3QpOhn0hUfQasXM", 3 | "object": "checkout.session", 4 | "after_expiration": null, 5 | "allow_promotion_codes": null, 6 | "amount_subtotal": null, 7 | "amount_total": null, 8 | "automatic_tax": { 9 | "enabled": false, 10 | "status": null 11 | }, 12 | "billing_address_collection": null, 13 | "cancel_url": "https://example.com/cancel", 14 | "client_reference_id": null, 15 | "consent": null, 16 | "consent_collection": null, 17 | "currency": null, 18 | "customer": null, 19 | "customer_creation": null, 20 | "customer_details": null, 21 | "customer_email": null, 22 | "expires_at": 1660829677, 23 | "livemode": false, 24 | "locale": null, 25 | "metadata": {}, 26 | "mode": "payment", 27 | "payment_intent": "pi_1Drar32eZvKYlo2CWOhCeqOT", 28 | "payment_link": null, 29 | "payment_method_collection": null, 30 | "payment_method_options": {}, 31 | "payment_method_types": [ 32 | "card" 33 | ], 34 | "payment_status": "paid", 35 | "phone_number_collection": { 36 | "enabled": false 37 | }, 38 | "recovered_from": null, 39 | "redaction": null, 40 | "setup_intent": null, 41 | "shipping_address_collection": null, 42 | "shipping_cost": null, 43 | "shipping_details": null, 44 | "shipping_options": [], 45 | "status": "expired", 46 | "submit_type": null, 47 | "subscription": null, 48 | "success_url": "https://example.com/success", 49 | "total_details": null, 50 | "url": "https://checkout.stripe.com/pay/xxx" 51 | } 52 | -------------------------------------------------------------------------------- /tests/Mock/retrieve_session_unpaid.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "cs_test_a1JsI9PapSOhD1h43TtbzDegu3BRzkU5LLimT41z1Kk3QpOhn0hUfQasXM", 3 | "object": "checkout.session", 4 | "after_expiration": null, 5 | "allow_promotion_codes": null, 6 | "amount_subtotal": null, 7 | "amount_total": null, 8 | "automatic_tax": { 9 | "enabled": false, 10 | "status": null 11 | }, 12 | "billing_address_collection": null, 13 | "cancel_url": "https://example.com/cancel", 14 | "client_reference_id": null, 15 | "consent": null, 16 | "consent_collection": null, 17 | "currency": null, 18 | "customer": null, 19 | "customer_creation": null, 20 | "customer_details": null, 21 | "customer_email": null, 22 | "expires_at": 1660829677, 23 | "livemode": false, 24 | "locale": null, 25 | "metadata": {}, 26 | "mode": "payment", 27 | "payment_intent": "pi_1Drar32eZvKYlo2CWOhCeqOT", 28 | "payment_link": null, 29 | "payment_method_collection": null, 30 | "payment_method_options": {}, 31 | "payment_method_types": [ 32 | "card" 33 | ], 34 | "payment_status": "unpaid", 35 | "phone_number_collection": { 36 | "enabled": false 37 | }, 38 | "recovered_from": null, 39 | "redaction": null, 40 | "setup_intent": null, 41 | "shipping_address_collection": null, 42 | "shipping_cost": null, 43 | "shipping_details": null, 44 | "shipping_options": [], 45 | "status": "expired", 46 | "submit_type": null, 47 | "subscription": null, 48 | "success_url": "https://example.com/success", 49 | "total_details": null, 50 | "url": "https://checkout.stripe.com/pay/xxx" 51 | } 52 | -------------------------------------------------------------------------------- /src/CommunityEvent/EventScraping.php: -------------------------------------------------------------------------------- 1 | httpBrowser->request('GET', $url); 17 | 18 | $structuredDataElements = $crawler->filter('script[type="application/ld+json"]'); 19 | /** @var \DOMElement $domElement */ 20 | foreach ($structuredDataElements as $domElement) { 21 | $schemas = (array) json_decode($domElement->textContent, true, 512, \JSON_THROW_ON_ERROR); 22 | 23 | if (isset($schemas['@type']) && 'Event' === $schemas['@type']) { 24 | $data[] = $schemas; 25 | 26 | continue; 27 | } 28 | 29 | /** @var array|string> $schema */ 30 | foreach ($schemas as $schema) { 31 | if (false === isset($schema['@type'])) { 32 | continue; 33 | } 34 | 35 | if ('Event' !== $schema['@type']) { 36 | continue; 37 | } 38 | 39 | if (false === isset($schema['organizer']['url'])) { 40 | continue; 41 | } 42 | 43 | if (false === str_contains($schema['organizer']['url'], $url)) { 44 | continue; 45 | } 46 | 47 | $data[] = $schema; 48 | } 49 | } 50 | 51 | return $data; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Repository/News/EntryRepository.php: -------------------------------------------------------------------------------- 1 | 14 | * 15 | * @method Entry|null find($id, $lockMode = null, $lockVersion = null) 16 | * @method Entry|null findOneBy(array $criteria, array $orderBy = null) 17 | * @method Entry[] findAll() 18 | * @method Entry[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) 19 | */ 20 | final class EntryRepository extends ServiceEntityRepository implements EntryRepositoryInterface 21 | { 22 | public function __construct(ManagerRegistry $registry) 23 | { 24 | parent::__construct($registry, Entry::class); 25 | } 26 | 27 | public function save(Entry $entry): void 28 | { 29 | $this->getEntityManager()->persist($entry); 30 | } 31 | 32 | public function remove(Entry $entry): void 33 | { 34 | $this->getEntityManager()->remove($entry); 35 | } 36 | 37 | public function createQueryBuilderLastNews(): QueryBuilder 38 | { 39 | return $this->createQueryBuilder('entry') 40 | ->orderBy('entry.publishedAt', Criteria::DESC); 41 | } 42 | 43 | public function ofLink(string $link): ?Entry 44 | { 45 | return $this->findOneBy(['link' => $link]); 46 | } 47 | 48 | public function getAll(): array 49 | { 50 | return $this->findAll(); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /frankenphp/docker-entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | if [ "$1" = 'frankenphp' ] || [ "$1" = 'php' ] || [ "$1" = 'bin/console' ]; then 5 | if [ -z "$(ls -A 'vendor/' 2>/dev/null)" ]; then 6 | composer install --prefer-dist --no-progress --no-interaction 7 | fi 8 | 9 | # Display information about the current project 10 | # Or about an error in project initialization 11 | php bin/console -V 12 | 13 | if grep -q ^DATABASE_URL= .env; then 14 | echo 'Waiting for database to be ready...' 15 | ATTEMPTS_LEFT_TO_REACH_DATABASE=60 16 | until [ $ATTEMPTS_LEFT_TO_REACH_DATABASE -eq 0 ] || DATABASE_ERROR=$(php bin/console dbal:run-sql -q "SELECT 1" 2>&1); do 17 | if [ $? -eq 255 ]; then 18 | # If the Doctrine command exits with 255, an unrecoverable error occurred 19 | ATTEMPTS_LEFT_TO_REACH_DATABASE=0 20 | break 21 | fi 22 | sleep 1 23 | ATTEMPTS_LEFT_TO_REACH_DATABASE=$((ATTEMPTS_LEFT_TO_REACH_DATABASE - 1)) 24 | echo "Still waiting for database to be ready... Or maybe the database is not reachable. $ATTEMPTS_LEFT_TO_REACH_DATABASE attempts left." 25 | done 26 | 27 | if [ $ATTEMPTS_LEFT_TO_REACH_DATABASE -eq 0 ]; then 28 | echo 'The database is not up or not reachable:' 29 | echo "$DATABASE_ERROR" 30 | exit 1 31 | else 32 | echo 'The database is now ready and reachable' 33 | fi 34 | 35 | if [ "$( find ./migrations -iname '*.php' -print -quit )" ]; then 36 | php bin/console doctrine:migrations:migrate --no-interaction --all-or-nothing 37 | fi 38 | fi 39 | 40 | setfacl -R -m u:www-data:rwX -m u:"$(whoami)":rwX var 41 | setfacl -dR -m u:www-data:rwX -m u:"$(whoami)":rwX var 42 | 43 | echo 'PHP app ready!' 44 | fi 45 | 46 | exec docker-php-entrypoint "$@" 47 | -------------------------------------------------------------------------------- /frankenphp/certs/tls.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIEfzCCAuegAwIBAgIQSzIyE7Ej+VgsqoDJuO9QljANBgkqhkiG9w0BAQsFADCB 3 | qzEeMBwGA1UEChMVbWtjZXJ0IGRldmVsb3BtZW50IENBMUAwPgYDVQQLDDdxZGVx 4 | dWlwcGVAUXVlbnRpbnMtTWFjQm9vay1Qcm8ubG9jYWwgKFF1ZW50aW4gRGVxdWlw 5 | cGUpMUcwRQYDVQQDDD5ta2NlcnQgcWRlcXVpcHBlQFF1ZW50aW5zLU1hY0Jvb2st 6 | UHJvLmxvY2FsIChRdWVudGluIERlcXVpcHBlKTAeFw0yNDEwMjAxMzEyNDJaFw0y 7 | NzAxMjAxMzEyNDJaMGsxJzAlBgNVBAoTHm1rY2VydCBkZXZlbG9wbWVudCBjZXJ0 8 | aWZpY2F0ZTFAMD4GA1UECww3cWRlcXVpcHBlQFF1ZW50aW5zLU1hY0Jvb2stUHJv 9 | LmxvY2FsIChRdWVudGluIERlcXVpcHBlKTCCASIwDQYJKoZIhvcNAQEBBQADggEP 10 | ADCCAQoCggEBAOkGiJyISWk/VfdmpXmTa0HgHdCFEzxNs0XFhdmL5jGSeFoLxNAD 11 | +Ss2MOn0iM2mhGjNBqoDLA/xHSS08F0/qaYhJikGnwFjMQUASRSUSKm+ym/hIvIU 12 | S8EsfobEWogOqhgy7azTaOd6Op2H6Px8fcikonEav6F4IUVq6rrYEhX+6WO9z3KU 13 | ClBJfLvN45/BS438OH08O92NmcjmKykjwJgVEPfQQIY+/jaW/mWwruNpd5m8rWl8 14 | eEI6miegD7crgwWskKEyKKcGNe4J/v5It4jSvH4aif0M4+JauKrBuaw0LaoM9tmT 15 | uMeAhWJr3tQ+eWDkXuvCCzlgHK/X0f/6Dy8CAwEAAaNeMFwwDgYDVR0PAQH/BAQD 16 | AgWgMBMGA1UdJQQMMAoGCCsGAQUFBwMBMB8GA1UdIwQYMBaAFHDLa+EDtIKEd38O 17 | rcBgX0DGzZAKMBQGA1UdEQQNMAuCCWxvY2FsaG9zdDANBgkqhkiG9w0BAQsFAAOC 18 | AYEAS70hO46gI5tkfeT/clRqxGBWQgwB38f70Xix2maekPUU7dwVAUzJvrmt/7Z6 19 | 3EKD7Y4SXayVaUUA3umi6ksXAQAuCALyBT10RvsXgqcQZzU+M5BOAJLa078JBict 20 | roZf7O+lFB7H/03e/akGyaHzohjhVx6wzg3QTpl6QGQD7FG+/bxLSWjGbwRCCAoP 21 | fMkoO9chp5+uSNyvMLxTFF1It7wGZ7NpGDIwRIEdPo8z9Wd4g/hISS5dKHADzKTi 22 | laI7eFQMVqiER8CMsV8I+zvJzEk3vZO5XTVkIJWuA4m8Kx3gpxh3XlVY1R6F/vGg 23 | odu9vX0lnsZkcBWg0FLfZLgwwbg75tOmJt3vMr36jQreZGRWxHGddyS+WdPbr6W+ 24 | T7gjxwijWU6SS91kd2QFJuUZA5/stUB6Wu/msfILBR6jhMXeAjusZDICEnFYTY2q 25 | m8fZrmuschQIJwREY5w+P1M43gjpdoGpwAWZ7Z5eApDuzcC+S0R0y2GJPbD4P4Eu 26 | SS2p 27 | -----END CERTIFICATE----- 28 | -------------------------------------------------------------------------------- /src/Controller/EventController.php: -------------------------------------------------------------------------------- 1 | render('event/index.html.twig', [ 25 | 'upcomingEvents' => $this->eventRepository->findUpcomingEvents(), 26 | 'pastEvents' => $this->eventRepository->findPastEvents(), 27 | ]); 28 | } 29 | 30 | #[Route('/events/rss.xml', name: 'event_rss', defaults: ['_format' => 'xml'], methods: ['GET']), ] 31 | public function rss(): Response 32 | { 33 | return $this->render('event/index.xml.twig', [ 34 | 'events' => $this->eventRepository->findBy([], ['startDate' => 'DESC'], 30), 35 | ]); 36 | } 37 | 38 | #[Route('/events/{id}', name: 'event_redirect', methods: ['GET'])] 39 | public function event(Event $event): RedirectResponse 40 | { 41 | /** @var string $eventUrl */ 42 | $eventUrl = $event->getUrl(); 43 | $uri = Modifier::from($eventUrl)->appendQuery('ref=jobbsy'); 44 | 45 | return $this->redirect($uri); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /config/bundles.php: -------------------------------------------------------------------------------- 1 | ['all' => true], 5 | Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true], 6 | Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true], 7 | Symfony\Bundle\DebugBundle\DebugBundle::class => ['dev' => true], 8 | Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true], 9 | Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true], 10 | Twig\Extra\TwigExtraBundle\TwigExtraBundle::class => ['all' => true], 11 | Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true], 12 | Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true], 13 | Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true], 14 | League\FlysystemBundle\FlysystemBundle::class => ['all' => true], 15 | Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle::class => ['dev' => true, 'test' => true], 16 | DAMA\DoctrineTestBundle\DAMADoctrineTestBundle::class => ['test' => true], 17 | EasyCorp\Bundle\EasyAdminBundle\EasyAdminBundle::class => ['all' => true], 18 | Nelmio\CorsBundle\NelmioCorsBundle::class => ['all' => true], 19 | ApiPlatform\Symfony\Bundle\ApiPlatformBundle::class => ['all' => true], 20 | Knp\Bundle\TimeBundle\KnpTimeBundle::class => ['all' => true], 21 | Sentry\SentryBundle\SentryBundle::class => ['prod' => true], 22 | Knp\Bundle\PaginatorBundle\KnpPaginatorBundle::class => ['all' => true], 23 | Symfonycasts\SassBundle\SymfonycastsSassBundle::class => ['all' => true], 24 | Symfony\UX\StimulusBundle\StimulusBundle::class => ['all' => true], 25 | Symfony\UX\TwigComponent\TwigComponentBundle::class => ['all' => true], 26 | ]; 27 | -------------------------------------------------------------------------------- /.castor/database.php: -------------------------------------------------------------------------------- 1 | title('Migrating the database schema'); 15 | 16 | run(['docker', 'compose', 'exec', 'php', 'bin/console', 'doctrine:database:create', '--if-not-exists', "--env=$env"]); 17 | run(['docker', 'compose', 'exec', 'php', 'bin/console', 'doctrine:migration:migrate', '-n', "--env=$env"]); 18 | } 19 | 20 | #[AsTask(description: 'Reset database', aliases: ['reset'])] 21 | function reset(string $env = 'dev'): void 22 | { 23 | docker_compose_run("bin/console doctrine:database:drop --force --if-exists --env=$env"); 24 | migrate($env); 25 | } 26 | 27 | #[AsTask(description: 'Load fixtures',aliases: ['fixtures'])] 28 | function fixtures(string $env = 'dev', ?array $groups = null): void 29 | { 30 | $command = ['docker', 'compose', 'exec', 'php', 'bin/console', 'doctrine:fixtures:load', '-n', "--env=$env"]; 31 | 32 | if (null !== $groups) { 33 | foreach ($groups as $group) { 34 | $command[] = "--group=$group"; 35 | } 36 | } 37 | 38 | run($command); 39 | } 40 | 41 | #[AsTask(description: 'Generate a new migration', aliases: ['migration'])] 42 | function migration(): void 43 | { 44 | run(['docker', 'compose', 'exec', 'php', 'bin/console', 'doctrine:migrations:diff']); 45 | } 46 | 47 | #[AsTask(description: 'Remove a migration', aliases: ['migration-down'])] 48 | function removeMigration(): void 49 | { 50 | run(['docker', 'compose', 'exec', 'php', 'bin/console', 'doctrine:migrations:migrate', 'prev']); 51 | } 52 | -------------------------------------------------------------------------------- /src/News/Aggregator/RSS/Model/Channel.php: -------------------------------------------------------------------------------- 1 | items[] = $item; 27 | } 28 | 29 | /** 30 | * @return Item[] 31 | */ 32 | public function getItems(): array 33 | { 34 | return $this->items; 35 | } 36 | 37 | public static function create(\DOMXPath $xpath, \DOMNode $channelNode): self 38 | { 39 | Assert::string($title = XmlHelper::getNodeValue($xpath, './title', $channelNode)); 40 | Assert::string($link = XmlHelper::getNodeValue($xpath, './link', $channelNode)); 41 | Assert::string($description = XmlHelper::getNodeValue($xpath, './description', $channelNode)); 42 | 43 | $channel = new self( 44 | $title, 45 | $link, 46 | $description, 47 | ); 48 | 49 | $itemsNode = $xpath->query('./item', $channelNode); 50 | 51 | if (false === $itemsNode) { 52 | return $channel; 53 | } 54 | 55 | /** @var \DOMNode $itemNode */ 56 | foreach ($itemsNode as $itemNode) { 57 | $channel->addItem(Item::create($xpath, $itemNode)); 58 | } 59 | 60 | return $channel; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /templates/event/index.html.twig: -------------------------------------------------------------------------------- 1 | {% extends 'base.html.twig' %} 2 | 3 | {% block title %}Symfony conferences and events - {{ parent() }}{% endblock %} 4 | 5 | {% block extra_head %} 6 | 7 | {% endblock %} 8 | 9 | {% block main %} 10 |
11 |

12 | Upcoming Symfony conferences and events 13 |

14 |
15 |

16 | Don't miss any events in the Symfony community 17 |

18 |
19 |
20 | 21 |
22 |

Upcoming events & meetups

23 | {% if upcomingEvents|length > 0 %} 24 |
25 | {% for event in upcomingEvents %} 26 |
27 | {{ include('event/_event.html.twig', {'event': event}) }} 28 |
29 | {% endfor %} 30 |
31 | {% else %} 32 |
33 |

No upcoming events

34 |
35 | {% endif %} 36 |
37 | 38 |
39 |

Past events & meetups

40 |
41 | {% for event in pastEvents %} 42 |
43 | {{ include('event/_event.html.twig', {'event': event}) }} 44 |
45 | {% endfor %} 46 |
47 |
48 | {% endblock %} 49 | -------------------------------------------------------------------------------- /frankenphp/certs/tls.key: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDpBoiciElpP1X3 3 | ZqV5k2tB4B3QhRM8TbNFxYXZi+YxknhaC8TQA/krNjDp9IjNpoRozQaqAywP8R0k 4 | tPBdP6mmISYpBp8BYzEFAEkUlEipvspv4SLyFEvBLH6GxFqIDqoYMu2s02jnejqd 5 | h+j8fH3IpKJxGr+heCFFauq62BIV/uljvc9ylApQSXy7zeOfwUuN/Dh9PDvdjZnI 6 | 5ispI8CYFRD30ECGPv42lv5lsK7jaXeZvK1pfHhCOponoA+3K4MFrJChMiinBjXu 7 | Cf7+SLeI0rx+Gon9DOPiWriqwbmsNC2qDPbZk7jHgIVia97UPnlg5F7rwgs5YByv 8 | 19H/+g8vAgMBAAECggEAOeK9nOTeF578I/EDuie8xSh/P4VPOfOzTOm8TkZKcJYJ 9 | /5Rc16+k/e8AR53PPgbXbZFAzorrLyqeKrgn3YIrCnHBoP9cEGQrkSp4/Pu48THL 10 | 5+7tV2VjTCFZbPDp7FJ4PnqoP/5kWNwsI2XXoqDqPiVnlOEoKVxGJ5Bfrvptw7iN 11 | uA0L1BRmTLgHrL1F4vyoV1qcnAAIIDjriAPcb/xGnPfx6K/ZFmSFDYLLCDaDh5gB 12 | tJEJU/Q8ku1kKWgl6HTZFzXYIeDHyvWSI9RfSS91GHgLBQSPBG83Wg8nQTta/H2X 13 | ZSAHBjyD1wwBZdGsLg5v156xhm7y2/OV/rxbdqwVgQKBgQDyZxxMOF0pntxdqF32 14 | QfNcJcWSEnYxNcwpQCmVYT4Ss6hVpdDKWk8nOEnyRjUtwI330iOd1JmLhbhVzabD 15 | rHxNvSqXAsh90LvORi3ivNu4tg5jMFXkCudScMjnZbOxF50CbKJpMKmBKR7PFd7D 16 | opy0m+ZoAl4fVa2gxOfPhWKphwKBgQD2GMQsCARlJC5HYduRx/A50RVASl7Sa1sb 17 | Ol+s/jseVitVihOPvIRai/PZAGLKANYRwoXYx9+FF24nT5cvWaR9resG9TMLZDub 18 | NgkY5b0HwZPcjtAITgHoWbD+rUz+BAn2PGs+LrV5m11tjwzi1yRBNv0Q3xFAYWT2 19 | KEpMZpi3GQKBgDXEq2E4y2l1BHp940HBhUK2Wim5kJ//x+aKhe7NoCAz264L1tFb 20 | 0NzpPnXQHvGkGZmT8jSLOPfa1HWr5UbYFsaDpFPU2TYXO+FYbcsyiyZgs7ZKvXKU 21 | /JSr0dSKGZ65lyk3gZsFoUO2JFBZEI2in1HsluIRTGF09suHgcflVWo3AoGBAIT9 22 | bUW+L2HY44l/wIBPY+paqvlLN2LO0TUtnnaGDLygJFrdeyS542xrJSOnqbswKH8A 23 | eARmPsxVlRl5UWItN08TpWblKuiFChEealwiCr0eRyFxq8pRHYbKsXNvg6Ph2uBO 24 | VkYMR9WnuB10qKoNSXJUnP15DoGUIFNGAqC28OBBAoGAJeNUhsFiFlFuwlYdFYjp 25 | 98KDv9RXCRPzSpBylQ5fy8fSb/Kax89TtRFcLUA6GADxb/JLcXYlMHXVB25MBi+I 26 | ofNnekeAHDDbq+v+SxIySnv+yJ8FkV6bqXPeVHKcA60Il6cffcONrrM3tCuPhmAw 27 | XLJ1wUU8LNx2839Q9rA2lo0= 28 | -----END PRIVATE KEY----- 29 | -------------------------------------------------------------------------------- /src/News/FetchFeedCommandHandler.php: -------------------------------------------------------------------------------- 1 | feedRepository->get($command->feedId); 27 | 28 | if (null === $feed) { 29 | return; 30 | } 31 | 32 | try { 33 | $articles = $this->fetchArticlesFromFeed->__invoke($feed); 34 | } catch (\Throwable $throwable) { 35 | $this->logger->notice( 36 | \sprintf('Unable to fetch articles from feed "%s". Reason: %s', $feed->getName(), $throwable->getMessage()), 37 | [ 38 | 'feedId' => $feed->getId(), 39 | 'feedUrl' => $feed->getUrl(), 40 | ] 41 | ); 42 | 43 | return; 44 | } 45 | 46 | foreach ($articles as $article) { 47 | if (null !== $this->entryRepository->ofLink($article->getLink())) { 48 | continue; 49 | } 50 | 51 | $this->entryRepository->save($article); 52 | } 53 | 54 | $this->entityManager->flush(); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /tests/Job/FranceTravail/FranceTravailApiTest.php: -------------------------------------------------------------------------------- 1 | search([ 37 | 'minCreationDate' => $minCreationDate, 38 | 'maxCreationDate' => $maxCreationDate, 39 | ]); 40 | 41 | // Assert 42 | self::assertSame($expectedSearchUrl, $mockResponseSearch->getRequestUrl()); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /tests/MessageHandler/ClassifyHandlerTest.php: -------------------------------------------------------------------------------- 1 | setDescription('Amazing job for Symfony developer with AI skills'); 25 | 26 | $payload = [ 27 | 'choices' => [ 28 | [ 29 | 'text' => 'Symfony, AI', 30 | ], 31 | ], 32 | ]; 33 | $mockResponse = new MockResponse(json_encode($payload, \JSON_THROW_ON_ERROR)); 34 | $httpClient = new MockHttpClient([$mockResponse]); 35 | $client = new Client($httpClient); 36 | 37 | $repository = new InMemoryJobRepository([$job]); 38 | 39 | $handler = new ClassifyHandler($client, $repository, 'model', new NullLogger(), $this->createMock(EntityManagerInterface::class)); 40 | $message = new ClassifyMessage('d43b7e10-cbc7-40d1-a9d4-aa73fc825456'); 41 | 42 | // Act 43 | ($handler)($message); 44 | 45 | // Assert 46 | self::assertCount(2, $job->getTags()); 47 | self::assertSame(['Symfony', 'AI'], $job->getTags()); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /templates/news/index.html.twig: -------------------------------------------------------------------------------- 1 | {% extends 'base.html.twig' %} 2 | 3 | {% block title %}Symfony News - {{ parent() }}{% endblock %} 4 | 5 | {% block extra_head %} 6 | 7 | {% endblock %} 8 | 9 | {% block main %} 10 |
11 |

12 | News 13 |

14 |
15 |

16 | Aggregated news about Symfony 17 |

18 |
19 |
20 | 21 |
22 |
23 |
24 | {% for entry in pagination %} 25 | {{ include('news/_entry.html.twig') }} 26 | {% else %} 27 |
No news found
28 | {% endfor %} 29 |
30 | 31 | 34 |
35 |
36 |

Latest jobs

37 |
38 |
39 |
40 | {% for job in lastJobs %} 41 | {{ include('job/_job_list_item.html.twig', {'job': job}) }} 42 | {% endfor %} 43 |
44 |
45 |
46 |
47 |
48 | {% endblock %} 49 | -------------------------------------------------------------------------------- /src/MessageHandler/ClassifyHandler.php: -------------------------------------------------------------------------------- 1 | jobRepository->get(Uuid::fromString($message->jobId)); 32 | 33 | $prompt = CreateJobPromptForClassification::create($job); 34 | try { 35 | $result = $this->openAIClient->completions(new CompletionRequest($this->model, $prompt, 0.8, 30)); 36 | } catch (\Exception $exception) { 37 | $this->logger->error($exception->getMessage()); 38 | 39 | return; 40 | } 41 | 42 | if (false === isset($result['choices'][0]['text'])) { 43 | return; 44 | } 45 | 46 | $keywords = array_filter(array_map(trim(...), explode(',', (string) $result['choices'][0]['text']))); 47 | 48 | $job->setTags(\array_slice($keywords, 0, 5)); 49 | 50 | $this->jobRepository->save($job); 51 | 52 | $this->entityManager->flush(); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /templates/news/_entry.html.twig: -------------------------------------------------------------------------------- 1 |
2 |
3 | {% if entry.feed %} 4 | {% if entry.feed.image or entry.feed.imageUrl %} 5 |
6 | {% if entry.feed.image and entry.feed.image.path %} 7 | 12 | {% elseif entry.feed.imageUrl %} 13 | 14 | {% endif %} 15 |
16 | {% endif %} 17 | {% endif %} 18 | 19 |
20 | {% if entry.feed %} 21 | {{ entry.feed.name }} 22 | {% endif %} 23 | 24 | {% if entry.publishedAt %} 25 | · 26 | 27 | 30 | {% endif %} 31 |
32 |
33 | 34 |

35 | 36 | {{ entry.title }} 37 | 38 |

39 | 40 |
41 | {{ entry.description|sanitize_html(sanitizer = 'app.article_sanitizer')|u.truncate(180, '...', false)|raw }} 42 |
43 |
44 | -------------------------------------------------------------------------------- /config/packages/framework.php: -------------------------------------------------------------------------------- 1 | secret(env('APP_SECRET')) 12 | ->httpMethodOverride(false) 13 | ->phpErrors() 14 | ->log(true); 15 | 16 | $config->session() 17 | ->gcProbability(0); 18 | 19 | $config->httpClient() 20 | ->scopedClient('mailjet.client') 21 | ->baseUri('https://api.mailjet.com/v3/REST/') 22 | ->authBasic(env('MAILJET_API_KEY').':'.env('MAILJET_API_SECRET_KEY')); 23 | 24 | $config->httpClient() 25 | ->scopedClient('openai.client') 26 | ->baseUri('https://api.openai.com/v1/') 27 | ->authBearer(env('OPENAI_API_KEY')); 28 | 29 | $config->httpClient() 30 | ->scopedClient('github.client') 31 | ->baseUri('https://api.github.com') 32 | ->authBearer(env('GITHUB_API_TOKEN')); 33 | 34 | $config->session() 35 | ->handlerId(null) 36 | ->cookieSecure('auto') 37 | ->cookieSamesite('lax') 38 | ->storageFactoryId('session.storage.factory.native'); 39 | 40 | if ('test' === $containerConfigurator->env()) { 41 | $config 42 | ->test(true) 43 | ->session() 44 | ->storageFactoryId('session.storage.factory.mock_file'); 45 | } 46 | 47 | $config->htmlSanitizer() 48 | ->sanitizer('app.article_sanitizer') 49 | ->blockElements(['a', 'ul', 'li', 'p']) 50 | ->dropElements(['figure', 'img', 'hr']); 51 | 52 | $config 53 | ->trustedProxies(env('TRUSTED_PROXIES')) 54 | ->trustedHeaders([ 55 | 'x-forwarded-for', 56 | 'x-forwarded-proto', 57 | ]); 58 | }; 59 | -------------------------------------------------------------------------------- /src/Security/User.php: -------------------------------------------------------------------------------- 1 | username; 22 | } 23 | 24 | public function setUsername(string $username): void 25 | { 26 | $this->username = $username; 27 | } 28 | 29 | /** 30 | * A visual identifier that represents this user. 31 | * 32 | * @see UserInterface 33 | */ 34 | public function getUserIdentifier(): string 35 | { 36 | return (string) $this->username; 37 | } 38 | 39 | /** 40 | * @see UserInterface 41 | */ 42 | public function getRoles(): array 43 | { 44 | $roles = $this->roles; 45 | // guarantee every user at least has ROLE_USER 46 | $roles[] = 'ROLE_USER'; 47 | 48 | return array_unique($roles); 49 | } 50 | 51 | /** 52 | * @param string[] $roles 53 | */ 54 | public function setRoles(array $roles): void 55 | { 56 | $this->roles = $roles; 57 | } 58 | 59 | /** 60 | * @see PasswordAuthenticatedUserInterface 61 | */ 62 | public function getPassword(): ?string 63 | { 64 | return $this->password; 65 | } 66 | 67 | public function setPassword(string $password): void 68 | { 69 | $this->password = $password; 70 | } 71 | 72 | /** 73 | * @see UserInterface 74 | */ 75 | public function eraseCredentials(): void 76 | { 77 | // If you store any temporary, sensitive data on the user, clear it here 78 | // $this->plainPassword = null; 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /tests/News/FetchFeedCommandHandlerTest.php: -------------------------------------------------------------------------------- 1 | setTitle('Title 1'); 29 | $entry->setLink('https://example.com'); 30 | 31 | $fetchArticlesFromFeed = new InMemoryFetchArticlesFromFeed([$entry]); 32 | $fetchArticlesFromFeedMain = new FetchArticlesFromFeed([$fetchArticlesFromFeed], new NullLogger()); 33 | $entryRepository = new InMemoryEntryRepository(); 34 | 35 | $handler = new FetchFeedCommandHandler( 36 | $feedRepository, 37 | $fetchArticlesFromFeedMain, 38 | $entryRepository, 39 | new NullLogger(), 40 | $this->createMock(EntityManagerInterface::class), 41 | ); 42 | 43 | // Act 44 | ($handler)(new FetchFeedCommand('305a2ac5-0615-46f5-91b7-36d5c43e4ef0')); 45 | 46 | // Assert 47 | self::assertCount(1, $entryRepository->getAll()); 48 | /** @var Entry $entry */ 49 | $entry = current($entryRepository->getAll()); 50 | self::assertSame('Title 1', $entry->getTitle()); 51 | self::assertSame('https://example.com', $entry->getLink()); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Controller/Admin/FeedCrudController.php: -------------------------------------------------------------------------------- 1 | 19 | */ 20 | final class FeedCrudController extends AbstractCrudController 21 | { 22 | public static function getEntityFqcn(): string 23 | { 24 | return Feed::class; 25 | } 26 | 27 | public function configureFields(string $pageName): iterable 28 | { 29 | return [ 30 | IdField::new('id') 31 | ->onlyOnDetail(), 32 | TextField::new('name') 33 | ->setMaxLength(35), 34 | UrlField::new('url'), 35 | ChoiceField::new('type') 36 | ->onlyOnForms() 37 | ->setChoices(['Types' => FeedType::cases()]) 38 | ->setFormType(EnumType::class) 39 | ->setFormTypeOption('class', FeedType::class) 40 | ->setFormTypeOption('choice_label', static function (FeedType $enum): string { 41 | return $enum->value; 42 | }), 43 | UrlField::new('imageUrl') 44 | ->onlyOnForms(), 45 | Field::new('imageFile') 46 | ->onlyOnForms() 47 | ->setFormType(FileType::class), 48 | ]; 49 | } 50 | 51 | public function configureFilters(Filters $filters): Filters 52 | { 53 | return $filters 54 | ->add('name') 55 | ; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/Controller/AssetsController.php: -------------------------------------------------------------------------------- 1 | '.+'], methods: ['GET'])] 27 | #[Cache(maxage: 86400, smaxage: 86400, public: true)] 28 | public function assets(string $path, Request $request): Response 29 | { 30 | $parameters = $request->query->all(); 31 | 32 | if ([] !== $parameters) { 33 | try { 34 | SignatureFactory::create($this->secret)->validateRequest($path, $parameters); 35 | } catch (SignatureException $e) { 36 | throw $this->createNotFoundException('', $e); 37 | } 38 | } 39 | 40 | $this->glide->setResponseFactory(new SymfonyResponseFactory($request)); 41 | 42 | try { 43 | /** @var Response $response */ 44 | $response = $this->glide->getImageResponse($path, $request->query->all()); 45 | $response->headers->set(AbstractSessionListener::NO_AUTO_CACHE_CONTROL_HEADER, 'true'); 46 | 47 | return $response; 48 | } catch (\Throwable) { 49 | return new Response(status: Response::HTTP_NOT_FOUND); 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/News/Aggregator/Atom/Model/Entry.php: -------------------------------------------------------------------------------- 1 | query('./atom:link', $node); 52 | 53 | if (false === $linkNodes) { 54 | return null; 55 | } 56 | 57 | if ($linkNodes->length <= 0) { 58 | return null; 59 | } 60 | 61 | return $linkNodes 62 | ->item(0) 63 | ?->attributes 64 | ?->getNamedItem('href') 65 | ?->nodeValue; 66 | } 67 | } 68 | --------------------------------------------------------------------------------