├── 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 |
6 | {{ message|trans }}
7 |
8 |
9 |
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 |
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 |
4 |
5 | Newer posts
6 |
7 | {% else %}
8 |
12 | {% endif %}
13 |
14 | {% if next is defined %}
15 |
16 | Older posts
17 |
18 |
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 |
15 | -
16 |
17 | {{ ('employment_type.' ~ job.employmentType.value)|trans }}
18 |
19 | -
20 |
21 | {{ job.location|u.truncate(100, '...') }}
22 | {% if job.locationType %}
23 | | {{ ('location_type.' ~ job.locationType.value)|trans }}
24 | {% endif %}
25 |
26 |
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 |
6 |
7 | -
8 |
9 |
10 | {% if event.startDate|date is same as event.endDate|date %}
11 | {{ event.startDate|format_datetime('medium', 'none') }}
12 | {% else %}
13 | {{ event.startDate|format_datetime('medium', 'none') }} - {{ event.endDate|format_datetime('medium', 'none') }}
14 | {% endif %}
15 |
16 | -
17 | {% if event.online %}
18 |
19 | Online
20 | {% else %}
21 |
22 | {{ event.location }} {% if event.mixed %}& Online {% endif %} | {{ event.country|country_emoji }} {{ event.country|country_name }}
23 | {% endif %}
24 |
25 |
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 | {#
#}
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 |
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 | {#
#}
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 |
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 |
32 | {{ knp_pagination_render(pagination) }}
33 |
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 |
28 | {{ entry.publishedAt|ago }}
29 |
30 | {% endif %}
31 |
32 |
33 |
34 |
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 |
--------------------------------------------------------------------------------