├── translations ├── .gitignore └── messages+intl-icu.cs.yaml ├── src ├── Controller │ ├── .gitignore │ ├── MainController.php │ ├── PreferenceController.php │ └── LemmyLinkController.php ├── Exception │ └── UnsupportedFeatureException.php ├── Dto │ ├── ActivityPubItem.php │ └── ParsedName.php ├── Kernel.php └── Service │ ├── TwigExtension.php │ ├── PreferenceManager.php │ ├── PopularInstancesService.php │ ├── NameParser.php │ ├── LinkProvider │ ├── LinkProviderManager.php │ ├── LinkProvider.php │ ├── KbinLinkProvider.php │ └── LemmyLinkProvider.php │ ├── LemmyApiFactory.php │ ├── ActivityPubResolver.php │ ├── WebFingerParser.php │ └── LemmyObjectResolver.php ├── php └── conf.d │ └── php.ini ├── assets ├── controllers.json ├── tsconfig.json ├── app.ts ├── bootstrap.js ├── controllers │ ├── redirect-controller.ts │ ├── generate-link-controller.ts │ └── save-preference-controller.ts └── styles │ └── app.scss ├── doc └── assets │ ├── lemmy-01.png │ └── lemmy-02.png ├── phpstan.neon.dist ├── config ├── routes.yaml ├── packages │ ├── twig.yaml │ ├── debug.yaml │ ├── routing.yaml │ ├── web_profiler.yaml │ ├── translation.yaml │ ├── nyholm_psr7.yaml │ ├── cache.yaml │ ├── framework.yaml │ ├── webpack_encore.yaml │ └── dev │ │ └── monolog.yaml ├── routes │ ├── framework.yaml │ └── web_profiler.yaml ├── preload.php ├── bundles.php └── services.yaml ├── .env.test ├── public └── index.php ├── templates ├── invalid-user.html.twig ├── invalid-community.html.twig ├── redirect.html.twig ├── base.html.twig ├── index.html.twig ├── how-does-it-work.html.twig └── save-instance-preference.html.twig ├── tests ├── bootstrap.php └── Service │ ├── PreferenceManagerTest.php │ └── NameParserTest.php ├── bin ├── console └── phpunit ├── .gitignore ├── .env ├── .github └── workflows │ ├── code-coverage.yml │ ├── tests.yaml │ └── publish.yaml ├── shell.nix ├── LICENSE ├── package.json ├── phpunit.xml.dist ├── webpack.config.js ├── composer.json ├── README.md ├── .php-cs-fixer.dist.php ├── symfony.lock └── serverless.yml /translations/.gitignore: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/Controller/.gitignore: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /php/conf.d/php.ini: -------------------------------------------------------------------------------- 1 | extension=intl 2 | -------------------------------------------------------------------------------- /assets/controllers.json: -------------------------------------------------------------------------------- 1 | { 2 | "controllers": [], 3 | "entrypoints": [] 4 | } 5 | -------------------------------------------------------------------------------- /doc/assets/lemmy-01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RikudouSage/lemmyverse.link/HEAD/doc/assets/lemmy-01.png -------------------------------------------------------------------------------- /doc/assets/lemmy-02.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RikudouSage/lemmyverse.link/HEAD/doc/assets/lemmy-02.png -------------------------------------------------------------------------------- /phpstan.neon.dist: -------------------------------------------------------------------------------- 1 | parameters: 2 | level: max 3 | symfony: 4 | containerXmlPath: var/cache/dev/App_KernelDevDebugContainer.xml 5 | -------------------------------------------------------------------------------- /config/routes.yaml: -------------------------------------------------------------------------------- 1 | controllers: 2 | resource: 3 | path: ../src/Controller/ 4 | namespace: App\Controller 5 | type: attribute 6 | -------------------------------------------------------------------------------- /assets/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES6", 4 | "moduleResolution": "NodeNext", 5 | "module": "NodeNext" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /config/packages/twig.yaml: -------------------------------------------------------------------------------- 1 | twig: 2 | default_path: '%kernel.project_dir%/templates' 3 | 4 | when@test: 5 | twig: 6 | strict_variables: true 7 | -------------------------------------------------------------------------------- /config/routes/framework.yaml: -------------------------------------------------------------------------------- 1 | when@dev: 2 | _errors: 3 | resource: '@FrameworkBundle/Resources/config/routing/errors.xml' 4 | prefix: /_error 5 | -------------------------------------------------------------------------------- /src/Exception/UnsupportedFeatureException.php: -------------------------------------------------------------------------------- 1 | 7 | {{ "The link you clicked is invalid because the user cannot be resolved:"|trans }} 8 | {{ user }}. 9 |

10 | {% endblock %} 11 | -------------------------------------------------------------------------------- /assets/app.ts: -------------------------------------------------------------------------------- 1 | import './bootstrap.js'; 2 | /* 3 | * Welcome to your app's main JavaScript file! 4 | * 5 | * We recommend including the built version of this JavaScript file 6 | * (and its CSS file) in your base layout (base.html.twig). 7 | */ 8 | 9 | // any CSS you import will output into a single css file (app.css in this case) 10 | import './styles/app.scss'; 11 | -------------------------------------------------------------------------------- /templates/invalid-community.html.twig: -------------------------------------------------------------------------------- 1 | {% extends "base.html.twig" %} 2 | 3 | {% block title %}{{ "Error" | trans }}{% endblock %} 4 | 5 | {% block body %} 6 |

7 | {{ "The link you clicked is invalid because the community cannot be resolved:"|trans }} 8 | {{ community }}. 9 |

10 | {% endblock %} 11 | -------------------------------------------------------------------------------- /src/Kernel.php: -------------------------------------------------------------------------------- 1 | getProjectDir() . '/var/cache/' . $this->environment; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /config/packages/routing.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | router: 3 | utf8: true 4 | 5 | # Configure how to generate URLs in non-HTTP contexts, such as CLI commands. 6 | # See https://symfony.com/doc/current/routing.html#generating-urls-in-commands 7 | #default_uri: http://localhost 8 | 9 | when@prod: 10 | framework: 11 | router: 12 | strict_requirements: null 13 | -------------------------------------------------------------------------------- /config/packages/web_profiler.yaml: -------------------------------------------------------------------------------- 1 | when@dev: 2 | web_profiler: 3 | toolbar: true 4 | intercept_redirects: false 5 | 6 | framework: 7 | profiler: 8 | only_exceptions: false 9 | collect_serializer_data: true 10 | 11 | when@test: 12 | web_profiler: 13 | toolbar: false 14 | intercept_redirects: false 15 | 16 | framework: 17 | profiler: { collect: false } 18 | -------------------------------------------------------------------------------- /tests/bootstrap.php: -------------------------------------------------------------------------------- 1 | bootEnv(dirname(__DIR__) . '/.env'); 11 | } 12 | 13 | if ($_SERVER['APP_DEBUG']) { 14 | umask(0); 15 | } 16 | -------------------------------------------------------------------------------- /assets/bootstrap.js: -------------------------------------------------------------------------------- 1 | import { startStimulusApp } from '@symfony/stimulus-bridge'; 2 | 3 | // Registers Stimulus controllers from controllers.json and in the controllers/ directory 4 | export const app = startStimulusApp(require.context( 5 | '@symfony/stimulus-bridge/lazy-controller-loader!./controllers', 6 | true, 7 | /\.[jt]sx?$/ 8 | )); 9 | // register any custom, 3rd party controllers here 10 | // app.register('some_controller_name', SomeImportedController); 11 | -------------------------------------------------------------------------------- /src/Service/TwigExtension.php: -------------------------------------------------------------------------------- 1 | urlDecode(...)), 14 | ]; 15 | } 16 | 17 | private function urlDecode(string $text): string 18 | { 19 | return urldecode($text); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /config/packages/translation.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | default_locale: en 3 | set_locale_from_accept_language: true 4 | set_content_language_from_locale: true 5 | enabled_locales: [en, cs] 6 | translator: 7 | default_path: '%kernel.project_dir%/translations' 8 | fallbacks: 9 | - en 10 | # providers: 11 | # crowdin: 12 | # dsn: '%env(CROWDIN_DSN)%' 13 | # loco: 14 | # dsn: '%env(LOCO_DSN)%' 15 | # lokalise: 16 | # dsn: '%env(LOKALISE_DSN)%' 17 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | requestStack->getCurrentRequest()?->cookies->get($this->cookieName); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /config/bundles.php: -------------------------------------------------------------------------------- 1 | ['all' => true], 5 | Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true], 6 | Twig\Extra\TwigExtraBundle\TwigExtraBundle::class => ['all' => true], 7 | Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true], 8 | Symfony\Bundle\MonologBundle\MonologBundle::class => ['dev' => true], 9 | Symfony\Bundle\DebugBundle\DebugBundle::class => ['dev' => true], 10 | Symfony\WebpackEncoreBundle\WebpackEncoreBundle::class => ['all' => true], 11 | Symfony\UX\StimulusBundle\StimulusBundle::class => ['all' => true], 12 | ]; 13 | -------------------------------------------------------------------------------- /config/packages/nyholm_psr7.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | # Register nyholm/psr7 services for autowiring with PSR-17 (HTTP factories) 3 | Psr\Http\Message\RequestFactoryInterface: '@nyholm.psr7.psr17_factory' 4 | Psr\Http\Message\ResponseFactoryInterface: '@nyholm.psr7.psr17_factory' 5 | Psr\Http\Message\ServerRequestFactoryInterface: '@nyholm.psr7.psr17_factory' 6 | Psr\Http\Message\StreamFactoryInterface: '@nyholm.psr7.psr17_factory' 7 | Psr\Http\Message\UploadedFileFactoryInterface: '@nyholm.psr7.psr17_factory' 8 | Psr\Http\Message\UriFactoryInterface: '@nyholm.psr7.psr17_factory' 9 | 10 | nyholm.psr7.psr17_factory: 11 | class: Nyholm\Psr7\Factory\Psr17Factory 12 | -------------------------------------------------------------------------------- /src/Service/PopularInstancesService.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * @todo actually get the list without hardcoding it and remove @codeCoverageIgnore 11 | * 12 | * @codeCoverageIgnore 13 | */ 14 | public function getPopularInstances(): array 15 | { 16 | return [ 17 | 'lemmy.world', 18 | 'lemm.ee', 19 | 'lemmy.ca', 20 | 'beehaw.org', 21 | 'lemmy.dbzer0.com', 22 | 'lemmings.world', 23 | 'lemmy.blahaj.zone', 24 | 'discuss.online', 25 | ]; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /bin/phpunit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | [a-zA-Z0-9_-]+)@(?[a-zA-Z0-9][a-zA-Z0-9-.]{0,61}[a-zA-Z0-9])$/'; 13 | if (!preg_match($regex, $identifier, $matches)) { 14 | throw new InvalidArgumentException("Invalid community: {$identifier}"); 15 | } 16 | 17 | return new ParsedName( 18 | name: $matches['CommunityOrUserName'], 19 | homeInstance: $matches['Instance'], 20 | fullName: $identifier, 21 | ); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /config/packages/cache.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | cache: 3 | # Unique name of your app: used to compute stable namespaces for cache keys. 4 | #prefix_seed: your_vendor_name/app_name 5 | 6 | # The "app" cache stores to the filesystem by default. 7 | # The data in this cache should persist between deploys. 8 | # Other options include: 9 | 10 | # Redis 11 | #app: cache.adapter.redis 12 | #default_redis_provider: redis://localhost 13 | 14 | # APCu (not recommended with heavy random-write workloads as memory fragmentation can cause perf issues) 15 | #app: cache.adapter.apcu 16 | 17 | # Namespaced pools use the above "app" backend by default 18 | #pools: 19 | #my.dedicated.cache: null 20 | -------------------------------------------------------------------------------- /src/Service/LinkProvider/LinkProviderManager.php: -------------------------------------------------------------------------------- 1 | $linkProviders 11 | */ 12 | public function __construct( 13 | #[TaggedIterator('app.link_provider')] 14 | private iterable $linkProviders, 15 | ) { 16 | } 17 | 18 | public function findProvider(string $software): ?LinkProvider 19 | { 20 | foreach ($this->linkProviders as $linkProvider) { 21 | if ($linkProvider->supports($software)) { 22 | return $linkProvider; 23 | } 24 | } 25 | 26 | return null; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /assets/controllers/redirect-controller.ts: -------------------------------------------------------------------------------- 1 | import {Controller} from "@hotwired/stimulus"; 2 | 3 | export default class extends Controller { 4 | static override targets = ['countdown']; 5 | static override values = {countdown: Number, url: String}; 6 | 7 | private interval: number; 8 | 9 | private countdownTarget: HTMLSpanElement; 10 | 11 | private countdownValue: number; 12 | private urlValue: string; 13 | 14 | public connect() { 15 | this.interval = window.setInterval(() => { 16 | this.countdownValue -= 1; 17 | this.countdownTarget.innerText = String(this.countdownValue); 18 | if (this.countdownValue === 0) { 19 | clearInterval(this.interval); 20 | window.location.href = this.urlValue; 21 | } 22 | }, 1_000); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea 2 | 3 | ###> symfony/framework-bundle ### 4 | /.env.local 5 | /.env.local.php 6 | /.env.*.local 7 | /config/secrets/prod/prod.decrypt.private.php 8 | /public/bundles/ 9 | /var/ 10 | /vendor/ 11 | ###< symfony/framework-bundle ### 12 | 13 | ###> symfony/webpack-encore-bundle ### 14 | /node_modules/ 15 | /public/build/ 16 | npm-debug.log 17 | yarn-error.log 18 | ###< symfony/webpack-encore-bundle ### 19 | 20 | ###> friendsofphp/php-cs-fixer ### 21 | /.php-cs-fixer.php 22 | /.php-cs-fixer.cache 23 | ###< friendsofphp/php-cs-fixer ### 24 | 25 | ###> symfony/phpunit-bridge ### 26 | .phpunit.result.cache 27 | /phpunit.xml 28 | ###< symfony/phpunit-bridge ### 29 | 30 | ###> phpunit/phpunit ### 31 | /phpunit.xml 32 | .phpunit.result.cache 33 | ###< phpunit/phpunit ### 34 | 35 | ###> bref/symfony-bridge ### 36 | /.serverless/ 37 | ###< bref/symfony-bridge ### 38 | -------------------------------------------------------------------------------- /templates/redirect.html.twig: -------------------------------------------------------------------------------- 1 | {% extends "base.html.twig" %} 2 | 3 | {% block title %}{{ "Redirecting..." | trans }}{% endblock %} 4 | 5 | {% block body %} 6 |

10 | {{ "Redirecting to {link} in {targetStart}{seconds}{targetEnd} seconds..." | trans({ 11 | '{link}': url, 12 | '{seconds}': timeout, 13 | '{targetStart}': '', 14 | '{targetEnd}': '' 15 | }) | raw }} 16 |

17 |

18 | 19 | 20 | {{ "Change your instance" | trans }} 21 | 22 | 23 |

24 | {% endblock %} 25 | -------------------------------------------------------------------------------- /templates/base.html.twig: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {% block title %}Welcome!{% endblock %} 6 | 7 | 8 | {% block stylesheets %} 9 | {{ encore_entry_link_tags('app') }} 10 | {% endblock %} 11 | 12 | {% block javascripts %} 13 | {{ encore_entry_script_tags('app') }} 14 | {% endblock %} 15 | 16 | 17 |
18 |

{% block heading %}{{ block('title') }}{% endblock %}

19 | {% block body %}{% endblock %} 20 |
21 | 22 | 23 | -------------------------------------------------------------------------------- /src/Service/LemmyApiFactory.php: -------------------------------------------------------------------------------- 1 | client, 25 | requestFactory: $this->requestFactory, 26 | strictDeserialization: false, 27 | ); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Controller/MainController.php: -------------------------------------------------------------------------------- 1 | render('index.html.twig', [ 19 | 'host' => $request->getHost(), 20 | ]); 21 | } 22 | 23 | #[Route('/how-does-it-work', name: 'app.explanation', methods: [Request::METHOD_GET])] 24 | public function howDoesItWork(Request $request): Response 25 | { 26 | return $this->render('how-does-it-work.html.twig', [ 27 | 'host' => $request->getHost(), 28 | ]); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Service/LinkProvider/LinkProvider.php: -------------------------------------------------------------------------------- 1 | =1.2). 15 | # https://symfony.com/doc/current/best_practices.html#use-environment-variables-for-infrastructure-configuration 16 | 17 | ###> symfony/framework-bundle ### 18 | APP_ENV=dev 19 | APP_SECRET=af04cc19c79ca73c29f946e6d5bfd25b 20 | ###< symfony/framework-bundle ### 21 | 22 | DOMAIN_NAME= 23 | -------------------------------------------------------------------------------- /src/Service/LinkProvider/KbinLinkProvider.php: -------------------------------------------------------------------------------- 1 | {} }: 2 | pkgs.mkShell { 3 | nativeBuildInputs = with pkgs.buildPackages; 4 | let 5 | php82 = pkgs.php82.buildEnv { 6 | extensions = ({ enabled, all }: enabled ++ (with all; [ 7 | ctype 8 | iconv 9 | intl 10 | mbstring 11 | pdo 12 | redis 13 | xdebug 14 | xsl 15 | ])); 16 | extraConfig = '' 17 | memory_limit=8G 18 | post_max_size=200M 19 | upload_max_filesize=200M 20 | date.timezone=Europe/Prague 21 | phar.readonly=Off 22 | xdebug.mode=debug 23 | ''; 24 | }; 25 | in 26 | [ 27 | php82 28 | php82.packages.composer 29 | php82.extensions.redis 30 | php82.extensions.xsl 31 | php82.extensions.mbstring 32 | symfony-cli 33 | git 34 | nodejs_18 35 | nodePackages.serverless 36 | ]; 37 | } 38 | -------------------------------------------------------------------------------- /src/Service/LinkProvider/LemmyLinkProvider.php: -------------------------------------------------------------------------------- 1 | requestStack = self::getContainer()->get('request_stack'); 19 | $this->instance = new PreferenceManager( 20 | $this->requestStack, 21 | 'test_cookie', 22 | ); 23 | } 24 | 25 | public function testGetPreferredLemmyInstance(): void 26 | { 27 | // no request 28 | $this->assertNull($this->instance->getPreferredLemmyInstance()); 29 | 30 | // request without cookie 31 | $this->requestStack->push(new Request()); 32 | $this->assertNull($this->instance->getPreferredLemmyInstance()); 33 | 34 | $this->requestStack->push(new Request(cookies: [ 35 | 'test_cookie' => 'lemmings.world', 36 | ])); 37 | $this->assertSame('lemmings.world', $this->instance->getPreferredLemmyInstance()); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /config/services.yaml: -------------------------------------------------------------------------------- 1 | # This file is the entry point to configure your own services. 2 | # Files in the packages/ subdirectory configure your dependencies. 3 | 4 | # Put parameters here that don't need to change on each machine where the app is deployed 5 | # https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration 6 | parameters: 7 | app.redirect_timeout: 4 8 | app.preferred_instance_cookie: preferred_instance 9 | app.skip_preferred_cookie: skip_preferred 10 | app.delay_cookie: delay_time_s 11 | 12 | services: 13 | # default configuration for services in *this* file 14 | _defaults: 15 | autowire: true # Automatically injects dependencies in your services. 16 | autoconfigure: true # Automatically registers your services as commands, event subscribers, etc. 17 | 18 | # makes classes in src/ available to be used as services 19 | # this creates a service per class whose id is the fully-qualified class name 20 | App\: 21 | resource: '../src/' 22 | exclude: 23 | - '../src/DependencyInjection/' 24 | - '../src/Entity/' 25 | - '../src/Kernel.php' 26 | 27 | # add more service definitions when explicit configuration is needed 28 | # please note that last definitions always *replace* previous ones 29 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | tests 24 | 25 | 26 | 27 | 28 | 29 | src 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /src/Service/ActivityPubResolver.php: -------------------------------------------------------------------------------- 1 | httpClient->request( 23 | Request::METHOD_GET, 24 | $id, 25 | [ 26 | 'headers' => [ 27 | 'Accept' => 'application/activity+json', 28 | ], 29 | ], 30 | ); 31 | 32 | $content = $response->getContent(); 33 | $json = json_decode($content, true, JSON_THROW_ON_ERROR); 34 | assert(is_array($json)); 35 | 36 | return new ActivityPubItem( 37 | id: $json['id'], 38 | name: $json['name'], 39 | ); 40 | } catch (TransportExceptionInterface | ClientExceptionInterface | ServerExceptionInterface) { 41 | return null; 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /tests/Service/NameParserTest.php: -------------------------------------------------------------------------------- 1 | expectException(InvalidArgumentException::class); 24 | $instance->parse($valueToTest); 25 | } else { 26 | $result = $instance->parse($valueToTest); 27 | $this->assertSame($expectedName, $result->name); 28 | $this->assertSame($expectedInstance, $result->homeInstance); 29 | } 30 | } 31 | 32 | public static function getTestData(): iterable 33 | { 34 | yield ['wwdits@lemmings.world', true, 'wwdits', 'lemmings.world']; 35 | 36 | yield ['some_community@subdomain.example.com', true, 'some_community', 'subdomain.example.com']; 37 | 38 | yield ['https://wwdits@lemmings.world', false]; 39 | 40 | yield ['wwdits', false]; 41 | 42 | yield ['lemmings.world', false]; 43 | 44 | yield ['', false]; 45 | 46 | yield ['', false]; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /.github/workflows/tests.yaml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | on: 3 | push: 4 | branches: 5 | - master 6 | pull_request: 7 | workflow_dispatch: 8 | 9 | jobs: 10 | code_style: 11 | name: Test code style 12 | runs-on: ubuntu-latest 13 | env: 14 | PHP_CS_FIXER_IGNORE_ENV: 1 15 | strategy: 16 | matrix: 17 | version: ['8.2'] 18 | steps: 19 | - name: Setup PHP 20 | uses: shivammathur/setup-php@v2 21 | with: 22 | php-version: ${{ matrix.version }} 23 | - name: Checkout Code 24 | uses: actions/checkout@v2 25 | - name: Install Dependencies 26 | run: composer install 27 | - name: Test code style 28 | run: composer fixer -- --dry-run 29 | static_analysis: 30 | name: Static analysis 31 | runs-on: ubuntu-latest 32 | strategy: 33 | matrix: 34 | version: ['8.2'] 35 | steps: 36 | - name: Setup PHP 37 | uses: shivammathur/setup-php@v2 38 | with: 39 | php-version: ${{ matrix.version }} 40 | - name: Checkout Code 41 | uses: actions/checkout@v2 42 | - name: Install Dependencies 43 | run: composer install 44 | - name: Run static analysis 45 | run: composer phpstan 46 | tests: 47 | name: Tests 48 | runs-on: ubuntu-latest 49 | strategy: 50 | matrix: 51 | version: ['8.2'] 52 | steps: 53 | - name: Setup PHP 54 | uses: shivammathur/setup-php@v2 55 | with: 56 | php-version: ${{ matrix.version }} 57 | - name: Checkout Code 58 | uses: actions/checkout@v2 59 | with: 60 | submodules: true 61 | - name: Install Dependencies (without optional) 62 | run: composer install 63 | - name: Run tests 64 | run: composer phpunit 65 | -------------------------------------------------------------------------------- /config/packages/webpack_encore.yaml: -------------------------------------------------------------------------------- 1 | webpack_encore: 2 | # The path where Encore is building the assets - i.e. Encore.setOutputPath() 3 | output_path: '%kernel.project_dir%/public/build' 4 | # If multiple builds are defined (as shown below), you can disable the default build: 5 | # output_path: false 6 | 7 | # Set attributes that will be rendered on all script and link tags 8 | script_attributes: 9 | defer: true 10 | # Uncomment (also under link_attributes) if using Turbo Drive 11 | # https://turbo.hotwired.dev/handbook/drive#reloading-when-assets-change 12 | 'data-turbo-track': reload 13 | link_attributes: 14 | # Uncomment if using Turbo Drive 15 | 'data-turbo-track': reload 16 | 17 | # If using Encore.enableIntegrityHashes() and need the crossorigin attribute (default: false, or use 'anonymous' or 'use-credentials') 18 | # crossorigin: 'anonymous' 19 | 20 | # Preload all rendered script and link tags automatically via the HTTP/2 Link header 21 | # preload: true 22 | 23 | # Throw an exception if the entrypoints.json file is missing or an entry is missing from the data 24 | # strict_mode: false 25 | 26 | # If you have multiple builds: 27 | # builds: 28 | # frontend: '%kernel.project_dir%/public/frontend/build' 29 | 30 | # pass the build name as the 3rd argument to the Twig functions 31 | # {{ encore_entry_script_tags('entry1', null, 'frontend') }} 32 | 33 | framework: 34 | assets: 35 | json_manifest_path: '%kernel.project_dir%/public/build/manifest.json' 36 | 37 | #when@prod: 38 | # webpack_encore: 39 | # # Cache the entrypoints.json (rebuild Symfony's cache when entrypoints.json changes) 40 | # # Available in version 1.2 41 | # cache: true 42 | 43 | #when@test: 44 | # webpack_encore: 45 | # strict_mode: false 46 | -------------------------------------------------------------------------------- /config/packages/dev/monolog.yaml: -------------------------------------------------------------------------------- 1 | monolog: 2 | channels: 3 | - deprecation # Deprecations are logged in the dedicated "deprecation" channel when it exists 4 | 5 | when@dev: 6 | monolog: 7 | handlers: 8 | main: 9 | type: stream 10 | path: "%kernel.logs_dir%/%kernel.environment%.log" 11 | level: debug 12 | channels: ["!event"] 13 | # uncomment to get logging in your browser 14 | # you may have to allow bigger header sizes in your Web server configuration 15 | #firephp: 16 | # type: firephp 17 | # level: info 18 | #chromephp: 19 | # type: chromephp 20 | # level: info 21 | console: 22 | type: console 23 | process_psr_3_messages: false 24 | channels: ["!event", "!doctrine", "!console"] 25 | 26 | when@test: 27 | monolog: 28 | handlers: 29 | main: 30 | type: fingers_crossed 31 | action_level: error 32 | handler: nested 33 | excluded_http_codes: [404, 405] 34 | channels: ["!event"] 35 | nested: 36 | type: stream 37 | path: "%kernel.logs_dir%/%kernel.environment%.log" 38 | level: debug 39 | 40 | when@prod: 41 | monolog: 42 | handlers: 43 | main: 44 | type: fingers_crossed 45 | action_level: error 46 | handler: nested 47 | excluded_http_codes: [404, 405] 48 | buffer_size: 50 # How many messages should be saved? Prevent memory leaks 49 | nested: 50 | type: stream 51 | path: php://stderr 52 | level: debug 53 | formatter: monolog.formatter.json 54 | console: 55 | type: console 56 | process_psr_3_messages: false 57 | channels: ["!event", "!doctrine"] 58 | deprecation: 59 | type: stream 60 | channels: [deprecation] 61 | path: php://stderr 62 | -------------------------------------------------------------------------------- /.github/workflows/publish.yaml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: 7 | - master 8 | 9 | jobs: 10 | publish: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | domain: [lemmyverse.link, threadiverse.link, sublinks.link] 15 | include: 16 | - domain: lemmyverse.link 17 | zoneSecret: LEMMYVERSE_ZONE_ID 18 | domainId: Lemmyverse 19 | - domain: threadiverse.link 20 | zoneSecret: THREADIVERSE_ZONE_ID 21 | domainId: Threadiverse 22 | - domain: sublinks.link 23 | zoneSecret: SUBLINKS_ZONE_ID 24 | domainId: Sublinks 25 | env: 26 | DOMAIN_NAME: ${{ matrix.domain }} 27 | DOMAIN_ZONE: ${{ secrets[matrix.zoneSecret] }} 28 | DOMAIN_ID: ${{ matrix.domainId }} 29 | AWS_REGION: eu-central-1 30 | APP_ENV: prod 31 | AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} 32 | AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 33 | steps: 34 | - name: Setup PHP 35 | uses: shivammathur/setup-php@v2 36 | with: 37 | php-version: 8.2 38 | - name: Install serverless 39 | run: yarn global add serverless@3 40 | - name: Checkout code 41 | uses: actions/checkout@v3 42 | - name: Setup php dependencies 43 | run: composer install --no-dev --no-scripts 44 | - name: Setup js dependencies 45 | run: yarn install 46 | - name: Build assets 47 | run: yarn build 48 | - name: Prepare cache 49 | run: ./bin/console cache:warmup --env=prod 50 | - name: Deploy infrastructure 51 | run: serverless deploy --stage prod --verbose --region $AWS_REGION 52 | - name: Deploy assets 53 | run: | 54 | export ASSETS_BUCKET=$(aws cloudformation describe-stacks --stack-name LemmyverseLink-$DOMAIN_ID-prod --query "Stacks[0].Outputs[?OutputKey=='AssetsBucket'].OutputValue" --output=text --region $AWS_REGION) 55 | aws s3 sync public/build s3://$ASSETS_BUCKET/build --delete 56 | - name: Clear CDN cache 57 | run: | 58 | export CDN_ID=$(aws cloudformation describe-stacks --stack-name LemmyverseLink-$DOMAIN_ID-prod --query "Stacks[0].Outputs[?OutputKey=='Cdn'].OutputValue" --output=text --region $AWS_REGION) 59 | aws cloudfront create-invalidation --distribution-id $CDN_ID --paths "/*" 2>&1 > /dev/null 60 | -------------------------------------------------------------------------------- /src/Service/WebFingerParser.php: -------------------------------------------------------------------------------- 1 | cacheItemPool->getItem("web_finger_{$instance}"); 22 | if ($cacheItem->isHit()) { 23 | assert(is_string($cacheItem->get()) || $cacheItem->get() === null); 24 | 25 | return $cacheItem->get(); 26 | } 27 | 28 | try { 29 | $url = "https://{$instance}/.well-known/nodeinfo"; 30 | $response = $this->httpClient->request(Request::METHOD_GET, $url); 31 | if ($response->getStatusCode() !== Response::HTTP_OK) { 32 | $cacheItem->set(null); 33 | 34 | return null; 35 | } 36 | 37 | $json = json_decode($response->getContent(), true); 38 | assert(is_array($json)); 39 | if (!isset($json['links'])) { 40 | $cacheItem->set(null); 41 | 42 | return null; 43 | } 44 | 45 | foreach ($json['links'] as $link) { 46 | if (!str_contains($link['rel'] ?? '', 'nodeinfo.diaspora.software/ns/schema/2')) { 47 | continue; 48 | } 49 | $response = $this->httpClient->request(Request::METHOD_GET, $link['href']); 50 | if ($response->getStatusCode() !== Response::HTTP_OK) { 51 | continue; 52 | } 53 | $nodeInfo = json_decode($response->getContent(), true); 54 | assert(is_array($nodeInfo)); 55 | if (!isset($nodeInfo['software']['name'])) { 56 | continue; 57 | } 58 | 59 | $cacheItem->set($nodeInfo['software']['name']); 60 | 61 | return $nodeInfo['software']['name']; 62 | } 63 | 64 | $cacheItem->set(null); 65 | 66 | return null; 67 | } finally { 68 | $cacheItem->expiresAfter(new DateInterval('PT2H')); 69 | $this->cacheItemPool->save($cacheItem); 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /assets/styles/app.scss: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500&display=swap'); 2 | 3 | * { 4 | box-sizing: border-box; 5 | margin: 0; 6 | padding: 0; 7 | } 8 | 9 | body { 10 | height: 100vh; 11 | font-family: 'Roboto', sans-serif; 12 | background: linear-gradient(to top right, #7c89d5, #f599a5); 13 | font-size: 1.1rem; 14 | } 15 | 16 | p, h3 { 17 | margin: 0 2vw 2vh; 18 | } 19 | 20 | h1 { 21 | padding: 2vh 2vw; 22 | text-align: center; 23 | font-size: 2rem; 24 | } 25 | 26 | a { 27 | color: lighten(black, 20%); 28 | text-decoration: underline; 29 | 30 | &:hover { 31 | text-decoration: none; 32 | color: lighten(black, 40%); 33 | } 34 | } 35 | 36 | .btn { 37 | $color: #b691be; 38 | border: 0; 39 | background: $color; 40 | color: white; 41 | width: auto; 42 | padding: 1vh 1vw; 43 | border-radius: 3.5em; 44 | font-size: 1em; 45 | font-weight: 600; 46 | transition: background-color 0.15s ease; 47 | text-decoration: none; 48 | cursor: pointer; 49 | margin-bottom: 8px; 50 | display: inline-block; 51 | 52 | &:hover { 53 | background: darken($color, 10%); 54 | color: white; 55 | } 56 | } 57 | 58 | .center { 59 | background-color: #f6f6fc; 60 | width: 80vw; 61 | max-width: 1200px; 62 | margin: 3vh auto; 63 | border-radius: 3px 0 0 3px; 64 | box-shadow: 0 0 22px 2px #808080; 65 | padding-bottom: 1vh; 66 | } 67 | 68 | .button-wrapper { 69 | text-align: center; 70 | padding: 0 3vh 3vh; 71 | } 72 | 73 | .align-center { 74 | text-align: center; 75 | } 76 | 77 | .hidden { 78 | display: none; 79 | } 80 | 81 | .form-control { 82 | width: 100%; 83 | height: 60px; 84 | padding: 20px; 85 | border-radius: 16px; 86 | border: 1px solid black; 87 | margin-bottom: 1vh; 88 | } 89 | 90 | .flex { 91 | display: flex; 92 | 93 | & > * { 94 | margin-right: 1vw; 95 | 96 | &:last-child { 97 | margin-right: 0; 98 | } 99 | } 100 | } 101 | 102 | .error { 103 | color: darkred; 104 | } 105 | 106 | ol { 107 | margin-left: calc(3vw + 15px); 108 | margin-bottom: 3vh; 109 | 110 | ol { 111 | margin-bottom: 0; 112 | } 113 | } 114 | 115 | .no-wrap { 116 | white-space: nowrap; 117 | } 118 | 119 | hr { 120 | margin-bottom: 3vh; 121 | } 122 | 123 | @media screen and (max-width: 768px) { 124 | .flex { 125 | display: block; 126 | } 127 | .explanation-link { 128 | text-align: center; 129 | display: block; 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /templates/index.html.twig: -------------------------------------------------------------------------------- 1 | {% extends "base.html.twig" %} 2 | 3 | {% block title %}{{ '{name} - Create shareable link to Lemmy' | trans({ 4 | '{name}': host | capitalize, 5 | }) }}{% endblock %} 6 | 7 | {% block body %} 8 |
14 |

{{ "Without it being tied to any particular instance." | trans }}

15 |
16 | 19 | 20 |
21 |

22 | {{ "Just paste the link in the box above." | trans }} 23 |

24 | 29 | 41 |
42 |

43 | {{ "How does it work?" | trans }} 45 | 49 | {{ "Configure instance and delay" | trans }} 50 | 51 |

52 |
53 | {% endblock %} 54 | -------------------------------------------------------------------------------- /templates/how-does-it-work.html.twig: -------------------------------------------------------------------------------- 1 | {% extends "base.html.twig" %} 2 | 3 | {% block title %}{{ '{name} - Lemmy redirect service' | trans({ 4 | '{name}': host | capitalize, 5 | }) }}{% endblock %} 6 | 7 | {% block body %} 8 |

9 | {{ "This service is used for linking to Lemmy communities, users, posts etc. in a universal way." | trans }} 10 | {{ "When you use this service, you give your users the option to view a community or a user profile on their preferred instance instead of going through a complicated process just to interact with the link." | trans }} 11 |

12 |

{{ "How it works without this service:" | trans }}

13 |
    14 |
  1. 15 | {{ "You link to a community (user, post), let's say {example}, using a link:"|trans({ 16 | '{example}': 'wwdits@lemmings.world' 17 | }) | raw }} https://lemmings.world/c/wwdits 18 |
  2. 19 |
  3. 20 | {{ "After the user clicks it, one of two things happens:" | trans }} 21 |
      22 |
    1. {{ "They have an account on the same instance which means everything is fine and the link works, you're done." | trans }}
    2. 23 |
    3. {{ "The user doesn't have an account on the same instance, so the process gets more complicated." | trans }}
    4. 24 |
    25 |
  4. 26 |
  5. 27 | {{ "The user has to open their own instance." | trans }} 28 |
  6. 29 |
  7. 30 | {{ "The user has to copy the link." | trans }} 31 |
  8. 32 |
  9. 33 | {{ "The user has to paste the link into a search bar on their own instance." | trans }} 34 |
  10. 35 |
  11. 36 | {{ "The user has to click the link in search results." | trans }} 37 |
  12. 38 |
39 |

40 | {{ "How it works with this service:" | trans }} 41 |

42 |
    43 |
  1. 44 | {{ "You link to the same community like this:" | trans }} 45 | 46 | {{ url('app.community', {community: 'wwdits@lemmings.world'}) }} 47 | 48 |
  2. 49 |
  3. 50 | {{ "After the user clicks it, one of two things happens:" | trans }} 51 |
      52 |
    1. {{ "If this isn't the first time the user is here, they will be redirected to their preferred instance." | trans }}
    2. 53 |
    3. {{ "If this is the first time, the user is given the option to either set their own instance or to continue to the target instance." | trans }}
    4. 54 |
    55 |
  4. 56 |
  5. 57 | {{ "Done! Either way the user is on an instance, either one of their choice or on the one the community is hosted on if the user doesn't care." | trans }} 58 |
  6. 59 |
60 | {% endblock %} 61 | -------------------------------------------------------------------------------- /src/Controller/PreferenceController.php: -------------------------------------------------------------------------------- 1 | query->has('instance')) { 30 | if ($request->query->has('post')) { 31 | try { 32 | $post = $lemmyObjectResolver->getPostById( 33 | $request->query->getString('instance'), 34 | $request->query->getInt('post'), 35 | ); 36 | } catch (LemmyApiException) { 37 | $post = null; 38 | } 39 | } 40 | if ($request->query->has('comment')) { 41 | try { 42 | $comment = $lemmyObjectResolver->getCommentById( 43 | $request->query->getString('instance'), 44 | $request->query->getInt('comment'), 45 | ); 46 | $post = $lemmyObjectResolver->getPostById( 47 | $request->query->getString('instance'), 48 | $comment->postId, 49 | ); 50 | } catch (LemmyApiException) { 51 | $comment = null; 52 | } 53 | } 54 | } 55 | 56 | return $this->render('save-instance-preference.html.twig', [ 57 | 'redirectTo' => $request->query->get('redirectTo'), 58 | 'community' => $request->query->get('community'), 59 | 'user' => $request->query->get('user'), 60 | 'instances' => $popularInstances->getPopularInstances(), 61 | 'cookieName' => $cookieName, 62 | 'skipCookieName' => $skipCookieName, 63 | 'delayCookieName' => $delayCookieName, 64 | 'post' => $post ?? null, 65 | 'comment' => $comment ?? null, 66 | 'home' => $request->query->getBoolean('home'), 67 | 'delay' => $request->cookies->getInt($delayCookieName, $redirectTimeout), 68 | ]); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const Encore = require('@symfony/webpack-encore'); 2 | const dotenv = require('dotenv'); 3 | 4 | // Manually configure the runtime environment if not already configured yet by the "encore" command. 5 | // It's useful when you use tools that rely on webpack.config.js file. 6 | if (!Encore.isRuntimeEnvironmentConfigured()) { 7 | Encore.configureRuntimeEnvironment(process.env.NODE_ENV || 'dev'); 8 | } 9 | 10 | Encore 11 | .configureDefinePlugin(options => { 12 | const env = dotenv.config(); 13 | options['process.env.DOMAIN_NAME'] = env.parsed.DOMAIN_NAME; 14 | }) 15 | // directory where compiled assets will be stored 16 | .setOutputPath('public/build/') 17 | // public path used by the web server to access the output path 18 | .setPublicPath('/build') 19 | // only needed for CDN's or subdirectory deploy 20 | //.setManifestKeyPrefix('build/') 21 | 22 | /* 23 | * ENTRY CONFIG 24 | * 25 | * Each entry will result in one JavaScript file (e.g. app.js) 26 | * and one CSS file (e.g. app.css) if your JavaScript imports CSS. 27 | */ 28 | .addEntry('app', './assets/app.ts') 29 | 30 | // When enabled, Webpack "splits" your files into smaller pieces for greater optimization. 31 | .splitEntryChunks() 32 | 33 | // enables the Symfony UX Stimulus bridge (used in assets/bootstrap.js) 34 | .enableStimulusBridge('./assets/controllers.json') 35 | 36 | // will require an extra script tag for runtime.js 37 | // but, you probably want this, unless you're building a single-page app 38 | .enableSingleRuntimeChunk() 39 | 40 | /* 41 | * FEATURE CONFIG 42 | * 43 | * Enable & configure other features below. For a full 44 | * list of features, see: 45 | * https://symfony.com/doc/current/frontend.html#adding-more-features 46 | */ 47 | .cleanupOutputBeforeBuild() 48 | .enableBuildNotifications() 49 | .enableSourceMaps(!Encore.isProduction()) 50 | // enables hashed filenames (e.g. app.abc123.css) 51 | .enableVersioning(Encore.isProduction()) 52 | 53 | // configure Babel 54 | // .configureBabel((config) => { 55 | // config.plugins.push('@babel/a-babel-plugin'); 56 | // }) 57 | 58 | // enables and configure @babel/preset-env polyfills 59 | .configureBabelPresetEnv((config) => { 60 | config.useBuiltIns = 'usage'; 61 | config.corejs = '3.23'; 62 | }) 63 | 64 | // enables Sass/SCSS support 65 | .enableSassLoader() 66 | 67 | // uncomment if you use TypeScript 68 | .enableTypeScriptLoader() 69 | 70 | // uncomment if you use React 71 | //.enableReactPreset() 72 | 73 | // uncomment to get integrity="..." attributes on your script & link tags 74 | // requires WebpackEncoreBundle 1.4 or higher 75 | //.enableIntegrityHashes(Encore.isProduction()) 76 | 77 | // uncomment if you're having problems with a jQuery plugin 78 | //.autoProvidejQuery() 79 | ; 80 | 81 | if (Encore.isProduction()) { 82 | Encore 83 | .setPublicPath(`https://assets.${process.env.DOMAIN_NAME}/build`) 84 | .setManifestKeyPrefix('build/') 85 | } 86 | 87 | 88 | module.exports = Encore.getWebpackConfig(); 89 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "project", 3 | "license": "proprietary", 4 | "minimum-stability": "stable", 5 | "prefer-stable": true, 6 | "require": { 7 | "php": "^8.2", 8 | "ext-ctype": "*", 9 | "ext-iconv": "*", 10 | "bref/bref": "^2.0", 11 | "bref/symfony-bridge": "^0.2.2", 12 | "nyholm/psr7": "^1.8", 13 | "rikudou/lemmy-api": "^0.8.1", 14 | "symfony/console": "6.4.*", 15 | "symfony/dotenv": "6.4.*", 16 | "symfony/flex": "^2", 17 | "symfony/framework-bundle": "6.4.*", 18 | "symfony/http-client": "6.4.*", 19 | "symfony/runtime": "6.4.*", 20 | "symfony/stimulus-bundle": "^2.10", 21 | "symfony/translation": "6.4.*", 22 | "symfony/twig-bundle": "6.4.*", 23 | "symfony/webpack-encore-bundle": "^2.0", 24 | "symfony/yaml": "6.4.*", 25 | "twig/extra-bundle": "^2.12|^3.0", 26 | "twig/twig": "^2.12|^3.0" 27 | }, 28 | "config": { 29 | "allow-plugins": { 30 | "php-http/discovery": true, 31 | "symfony/flex": true, 32 | "symfony/runtime": true, 33 | "phpstan/extension-installer": true 34 | }, 35 | "sort-packages": true 36 | }, 37 | "autoload": { 38 | "psr-4": { 39 | "App\\": "src/" 40 | } 41 | }, 42 | "autoload-dev": { 43 | "psr-4": { 44 | "App\\Tests\\": "tests/" 45 | } 46 | }, 47 | "replace": { 48 | "symfony/polyfill-ctype": "*", 49 | "symfony/polyfill-iconv": "*", 50 | "symfony/polyfill-php72": "*", 51 | "symfony/polyfill-php73": "*", 52 | "symfony/polyfill-php74": "*", 53 | "symfony/polyfill-php80": "*", 54 | "symfony/polyfill-php81": "*" 55 | }, 56 | "scripts": { 57 | "auto-scripts": { 58 | "cache:clear": "symfony-cmd", 59 | "assets:install %PUBLIC_DIR%": "symfony-cmd" 60 | }, 61 | "post-install-cmd": [ 62 | "@auto-scripts" 63 | ], 64 | "post-update-cmd": [ 65 | "@auto-scripts" 66 | ], 67 | "fixer": "php-cs-fixer fix --verbose --allow-risky=yes", 68 | "translations": "php bin/console translation:extract cs --force --format yaml", 69 | "phpstan": "phpstan analyse --level=max src", 70 | "phpunit": "bin/phpunit", 71 | "test": [ 72 | "@fixer --dry-run", 73 | "@phpstan", 74 | "@phpunit" 75 | ] 76 | }, 77 | "conflict": { 78 | "symfony/symfony": "*" 79 | }, 80 | "extra": { 81 | "symfony": { 82 | "allow-contrib": true, 83 | "require": "6.4.*" 84 | } 85 | }, 86 | "require-dev": { 87 | "friendsofphp/php-cs-fixer": "^3.22", 88 | "phpstan/extension-installer": "^1.3", 89 | "phpstan/phpstan": "^1.10", 90 | "phpstan/phpstan-symfony": "^1.3", 91 | "phpunit/phpunit": "^9", 92 | "symfony/browser-kit": "6.4.*", 93 | "symfony/css-selector": "6.4.*", 94 | "symfony/debug-bundle": "6.4.*", 95 | "symfony/monolog-bundle": "^3.0", 96 | "symfony/phpunit-bridge": "^6.3", 97 | "symfony/stopwatch": "6.4.*", 98 | "symfony/web-profiler-bundle": "6.4.*" 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Coverage Status](https://img.shields.io/coverallsCoverage/github/RikudouSage/lemmyverse.link)](https://coveralls.io/github/RikudouSage/lemmyverse.link?branch=master) 2 | [![Tests](https://github.com/RikudouSage/lemmyverse.link/actions/workflows/tests.yaml/badge.svg)](https://github.com/RikudouSage/lemmyverse.link/actions/workflows/tests.yaml) 3 | 4 | # Lemmyverse.link 5 | 6 | This is a redirect service for linking to Lemmy communities throughout the internet. When you're on Lemmy, universal 7 | links work (either in the form of relative link or the `!community_name@instance.tld` form). 8 | 9 | But when linking to a Lemmy community from outside Lemmy, you face the problem of forcing the user to go to the instance 10 | you linked to instead of their own. 11 | 12 | Using this project you can create a link like this: `https://lemmyverse.link/c/community_name@instance.tld`, the user 13 | will be given the option to set their home instance and every further link to `lemmyverse.link` will work as usual. 14 | 15 | ![Preview of a screen for setting instance to redirect](doc/assets/lemmy-01.png) 16 | 17 | ![Preview of a screen with redirect to target instance](doc/assets/lemmy-02.png) 18 | 19 | ## Available domains 20 | 21 | This project is currently hosted on: 22 | 23 | - lemmyverse.link 24 | - threadiverse.link 25 | - sublinks.link 26 | 27 | ## Translating 28 | 29 | If you'd like to translate this project to your language, run the following command: 30 | 31 | `./bin/console translation:extract --force --format yaml [language]` 32 | 33 | Replace `[language]` with your two-letter country code, for example for German it would be: 34 | 35 | `./bin/console translation:extract --force --format yaml de` 36 | 37 | Edit the file `translations/messages+intl-icu.[language].yaml` 38 | 39 | 40 | ## Deploying 41 | 42 | If you want to deploy this project using serverless, follow these steps: 43 | 44 | - `export DOMAIN_NAME=lemmyverse.link` (replace `lemmyverse.link` with your domain) 45 | - `export AWS_REGION=eu-central-1` 46 | - `rm -rf ./var/{cache,log} public/build` 47 | - `APP_ENV=prod composer install --no-dev --no-scripts` 48 | - `yarn install` 49 | - `yarn build` 50 | - `./bin/console cache:warmup --env=prod` 51 | - `export DOMAIN_ZONE=XXX` (replace `XXX` with your AWS domain zone id) 52 | - `export DOMAIN_ID=Lemmyverse` (replace `Lemmyverse` with any identifier for your domain) 53 | - `serverless deploy --stage prod --verbose --region $AWS_REGION` 54 | - `export ASSETS_BUCKET=$(aws cloudformation describe-stacks --stack-name LemmyverseLink-$DOMAIN_ID-prod --query "Stacks[0].Outputs[?OutputKey=='AssetsBucket'].OutputValue" --output=text --region $AWS_REGION)` 55 | - `export CDN_ID=$(aws cloudformation describe-stacks --stack-name LemmyverseLink-$DOMAIN_ID-prod --query "Stacks[0].Outputs[?OutputKey=='Cdn'].OutputValue" --output=text --region $AWS_REGION)` 56 | - `aws s3 sync public/build s3://$ASSETS_BUCKET/build --delete` 57 | - `aws cloudfront create-invalidation --distribution-id $CDN_ID --paths "/*"` 58 | 59 | ### Removing deployed code 60 | 61 | - `export DOMAIN_ID=Lemmyverse` (replace `Lemmyverse` with any identifier for your domain) 62 | - `export AWS_REGION=eu-central-1` 63 | - `export ASSETS_BUCKET=$(aws cloudformation describe-stacks --stack-name LemmyverseLink-$DOMAIN_ID --query "Stacks[0].Outputs[?OutputKey=='AssetsBucket'].OutputValue" --output=text --region $AWS_REGION)` 64 | - `aws s3 rm s3://$ASSETS_BUCKET/ --recursive` 65 | - `serverless remove --stage prod --verbose --region $AWS_REGION` 66 | 67 | -------------------------------------------------------------------------------- /assets/controllers/generate-link-controller.ts: -------------------------------------------------------------------------------- 1 | import {Controller} from "@hotwired/stimulus"; 2 | import {sprintf} from "sprintf-js"; 3 | 4 | export default class extends Controller { 5 | private readonly regexes = { 6 | community: /^https:\/\/(?[a-zA-Z0-9][a-zA-Z0-9-.]{0,61}[a-zA-Z0-9])\/c\/(?[a-zA-Z0-9_]+)(?:@(?[a-zA-Z0-9][a-zA-Z0-9-.]{0,61}[a-zA-Z0-9]))?$/, 7 | user: /^https:\/\/(?[a-zA-Z0-9][a-zA-Z0-9-.]{0,61}[a-zA-Z0-9])\/u\/(?[a-zA-Z0-9_-]+)(?:@(?[a-zA-Z0-9][a-zA-Z0-9-.]{0,61}[a-zA-Z0-9]))?$/, 8 | post: /^https:\/\/(?[a-zA-Z0-9][a-zA-Z0-9-.]{0,61}[a-zA-Z0-9])\/post\/(?[0-9_]+)$/, 9 | comment: /^https:\/\/(?[a-zA-Z0-9][a-zA-Z0-9-.]{0,61}[a-zA-Z0-9])\/comment\/(?[0-9_]+)$/, 10 | } 11 | 12 | static override targets = [ 13 | 'linkInput', 14 | 'linkPlaceholder', 15 | 'copyToClipboardResult', 16 | 'error', 17 | 'result', 18 | ]; 19 | static override values = { 20 | linkTemplateCommunity: String, 21 | linkTemplateUser: String, 22 | linkTemplatePost: String, 23 | linkTemplateComment: String, 24 | }; 25 | 26 | private linkInputTarget: HTMLInputElement; 27 | private linkPlaceholderTarget: HTMLSpanElement; 28 | private copyToClipboardResultTarget: HTMLDivElement; 29 | private errorTarget: HTMLParagraphElement; 30 | private resultTarget: HTMLDivElement; 31 | 32 | private linkTemplateCommunityValue: string; 33 | private linkTemplateUserValue: string; 34 | private linkTemplatePostValue: string; 35 | private linkTemplateCommentValue: string; 36 | 37 | public createLink(): void { 38 | this.errorTarget.classList.add('hidden'); 39 | this.resultTarget.classList.add('hidden'); 40 | this.copyToClipboardResultTarget.classList.add('hidden'); 41 | 42 | const url = new URL(this.linkInputTarget.value); 43 | const link = ((url: URL) => `${url.protocol}//${url.host}${url.pathname}`)(url); 44 | console.log(link, this.linkInputTarget.value); 45 | 46 | let target: string | null = null; 47 | if (this.regexes.community.test(link)) { 48 | const matches = link.match(this.regexes.community); 49 | target = sprintf(this.linkTemplateCommunityValue, `${matches.groups!['Community']}@${matches.groups!['IncludedInstance'] ?? matches.groups!['Instance']}`); 50 | } else if (this.regexes.user.test(link)) { 51 | const matches = link.match(this.regexes.user); 52 | target = sprintf(this.linkTemplateUserValue, `${matches.groups!['Username']}@${matches.groups!['IncludedInstance'] ?? matches.groups!['Instance']}`); 53 | } else if (this.regexes.post.test(link)) { 54 | const matches = link.match(this.regexes.post); 55 | target = sprintf(this.linkTemplatePostValue, matches.groups!['Instance'], matches.groups!['PostId']); 56 | } else if (this.regexes.comment.test(link)) { 57 | const matches = link.match(this.regexes.comment); 58 | target = sprintf(this.linkTemplateCommentValue, matches.groups!['Instance'], matches.groups!['CommentId']); 59 | } else { 60 | this.errorTarget.classList.remove('hidden'); 61 | return; 62 | } 63 | 64 | if (url.search) { 65 | target += url.search; 66 | } 67 | 68 | this.linkPlaceholderTarget.innerHTML = `${target}`; 69 | this.linkPlaceholderTarget.dataset.link = target; 70 | this.resultTarget.classList.remove('hidden'); 71 | } 72 | 73 | public async copyToClipboard(): Promise { 74 | this.copyToClipboardResultTarget.classList.add('hidden'); 75 | await navigator.clipboard.writeText(this.linkPlaceholderTarget.dataset.link); 76 | this.copyToClipboardResultTarget.classList.remove('hidden'); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /assets/controllers/save-preference-controller.ts: -------------------------------------------------------------------------------- 1 | import {Controller} from "@hotwired/stimulus"; 2 | 3 | export default class extends Controller { 4 | private readonly domainRegex = /^[a-zA-Z0-9][a-zA-Z0-9-.]{0,61}[a-zA-Z0-9]$/; 5 | 6 | static override targets = [ 7 | 'preferredInstances', 8 | 'customInstanceInputWrapper', 9 | 'customInstanceInput', 10 | 'errorText', 11 | 'delayConfigWrapper', 12 | 'delayConfigInput', 13 | ]; 14 | static override values = { 15 | cookieName: String, 16 | redirectUrl: String, 17 | emptyInputError: String, 18 | invalidValueError: String, 19 | skipCookieName: String, 20 | delayCookieName: String, 21 | }; 22 | 23 | private preferredInstancesTarget: HTMLDivElement; 24 | private customInstanceInputWrapperTarget: HTMLDivElement; 25 | private customInstanceInputTarget: HTMLInputElement; 26 | private errorTextTarget: HTMLParagraphElement; 27 | private delayConfigWrapperTarget: HTMLDivElement; 28 | private delayConfigInputTarget: HTMLInputElement; 29 | 30 | private cookieNameValue: string; 31 | private redirectUrlValue: string; 32 | private emptyInputErrorValue: string; 33 | private invalidValueErrorValue: string; 34 | private skipCookieNameValue: string; 35 | private delayCookieNameValue: string; 36 | 37 | public toggleInstanceRow(): void { 38 | const className = 'hidden'; 39 | this.preferredInstancesTarget.classList.contains(className) 40 | ? this.preferredInstancesTarget.classList.remove(className) 41 | : this.preferredInstancesTarget.classList.add(className) 42 | ; 43 | } 44 | 45 | public saveInstance(event: Event): void { 46 | const target = event.target; 47 | const instance = target.dataset.instance; 48 | 49 | this.savePreference(instance); 50 | this.removeCookie(this.skipCookieNameValue); 51 | window.location.href = this.redirectUrlValue; 52 | } 53 | 54 | public showCustomInstanceField(): void { 55 | this.customInstanceInputWrapperTarget.classList.remove('hidden'); 56 | } 57 | 58 | public saveCustomInstance(): void { 59 | this.errorTextTarget.innerText = ''; 60 | if (!this.customInstanceInputTarget.value) { 61 | this.errorTextTarget.innerText = this.emptyInputErrorValue; 62 | return; 63 | } 64 | if (!this.domainRegex.test(this.customInstanceInputTarget.value)) { 65 | this.errorTextTarget.innerText = this.invalidValueErrorValue; 66 | return; 67 | } 68 | 69 | this.savePreference(this.customInstanceInputTarget.value); 70 | this.removeCookie(this.skipCookieNameValue); 71 | if (this.redirectUrlValue) { 72 | window.location.href = this.redirectUrlValue; 73 | } 74 | } 75 | 76 | public skipPreferred(): void { 77 | this.savePreference('1', this.skipCookieNameValue); 78 | this.removeCookie(this.cookieNameValue); 79 | if (this.redirectUrlValue) { 80 | window.location.href = this.redirectUrlValue; 81 | } 82 | } 83 | 84 | public toggleDelayConfig(): void { 85 | this.delayConfigWrapperTarget.classList.toggle('hidden'); 86 | } 87 | 88 | public saveDelay(): void { 89 | const value = this.delayConfigInputTarget.valueAsNumber; 90 | const targetDate = new Date(); 91 | targetDate.setFullYear(targetDate.getFullYear() + 100); 92 | 93 | document.cookie = `${this.delayCookieNameValue}=${isNaN(value) ? 5 : value}; expires=${targetDate.toString()}; path=/`; 94 | 95 | if (this.redirectUrlValue) { 96 | window.location.href = this.redirectUrlValue; 97 | } 98 | } 99 | 100 | private savePreference(instance: string, cookieName: string = this.cookieNameValue): void { 101 | const targetDate = new Date(); 102 | targetDate.setFullYear(targetDate.getFullYear() + 100); 103 | 104 | document.cookie = `${cookieName}=${instance}; expires=${targetDate.toString()}; path=/` 105 | } 106 | 107 | private removeCookie(cookieName: string): void { 108 | const targetDate = new Date(1970, 0); 109 | document.cookie = `${cookieName}=; expires=${targetDate.toString()}; path=/`; 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /templates/save-instance-preference.html.twig: -------------------------------------------------------------------------------- 1 | {% extends "base.html.twig" %} 2 | 3 | {% block title %}{{ "Configure Lemmy redirect" | trans }}{% endblock %} 4 | 5 | {% block body %} 6 |
14 | {% if community %} 15 |

16 | {{ "You are being taken to the '{community}' community. You might configure your home instance so that you are redirected automatically in the future." | trans({ 17 | '{community}': community, 18 | }) }} 19 |

20 | {% elseif user %} 21 |

22 | {{ "You are being taken to the '{user}' user profile. You might configure your home instance so that you are redirected automatically in the future." | trans({ 23 | '{user}': user, 24 | }) }} 25 |

26 | {% elseif comment and post %} 27 |

28 | {{ "You are being taken to a comment for a post '{post}'. You might configure your home instance so that you are redirected automatically in the future." | trans({ 29 | '{post}': post.name, 30 | }) }} 31 |

32 | {% elseif post %} 33 |

34 | {{ "You are being taken to a post '{post}'. You might configure your home instance so that you are redirected automatically in the future." | trans({ 35 | '{post}': post.name, 36 | }) }} 37 |

38 | {% elseif home %} 39 |

40 | {{ "Here you can configure your target instance and redirect delay." | trans }} 41 | {{ "Back to homepage" | trans }}. 42 |

43 | {% else %} 44 |

45 | {{ "You are being taken to a link on Lemmy. You might configure your home instance so that you are redirected automatically in the future." | trans}} 46 |

47 | {% endif %} 48 | 49 |
50 | 51 | 58 | 59 |
60 | 67 | 75 | 76 | 85 | 86 |

87 |
88 | {% endblock %} 89 | -------------------------------------------------------------------------------- /.php-cs-fixer.dist.php: -------------------------------------------------------------------------------- 1 | in(__DIR__ . '/src') 5 | ->in(__DIR__ . '/tests') 6 | ; 7 | 8 | return (new PhpCsFixer\Config()) 9 | ->setRules([ 10 | '@PSR2' => true, 11 | '@PSR12' => true, 12 | 'array_syntax' => ['syntax' => 'short'], 13 | 'assign_null_coalescing_to_coalesce_equal' => true, 14 | 'backtick_to_shell_exec' => true, 15 | 'binary_operator_spaces' => true, 16 | 'blank_line_before_statement' => [ 17 | 'statements' => ['declare', 'return', 'try', 'yield', 'yield_from'], 18 | ], 19 | 'cast_spaces' => ['space' => 'single'], 20 | 'class_attributes_separation' => true, 21 | 'class_reference_name_casing' => true, 22 | 'clean_namespace' => true, 23 | 'concat_space' => [ 24 | 'spacing' => 'one', 25 | ], 26 | 'control_structure_continuation_position' => true, 27 | 'date_time_immutable' => true, 28 | 'dir_constant' => true, 29 | 'explicit_indirect_variable' => true, 30 | 'explicit_string_variable' => true, 31 | 'final_internal_class' => true, 32 | 'fully_qualified_strict_types' => true, 33 | 'function_to_constant' => true, 34 | 'function_typehint_space' => true, 35 | 'get_class_to_class_keyword' => true, 36 | 'global_namespace_import' => true, 37 | 'include' => true, 38 | 'increment_style' => true, 39 | 'integer_literal_case' => true, 40 | 'is_null' => true, 41 | 'lambda_not_used_import' => true, 42 | 'linebreak_after_opening_tag' => true, 43 | 'list_syntax' => true, 44 | 'magic_constant_casing' => true, 45 | 'magic_method_casing' => true, 46 | 'modernize_strpos' => true, 47 | 'modernize_types_casting' => true, 48 | 'multiline_comment_opening_closing' => true, 49 | 'native_function_casing' => true, 50 | 'native_function_type_declaration_casing' => true, 51 | 'new_with_braces' => true, 52 | 'no_alternative_syntax' => true, 53 | 'no_blank_lines_after_phpdoc' => true, 54 | 'no_break_comment' => false, 55 | 'no_empty_comment' => true, 56 | 'no_empty_phpdoc' => true, 57 | 'no_empty_statement' => true, 58 | 'no_extra_blank_lines' => [ 59 | 'tokens' => [ 60 | 'extra', 61 | 'break', 62 | 'continue', 63 | 'curly_brace_block', 64 | 'parenthesis_brace_block', 65 | 'return', 66 | 'square_brace_block', 67 | 'throw', 68 | 'use', 69 | 'switch', 70 | 'case', 71 | 'default', 72 | ], 73 | ], 74 | 'no_homoglyph_names' => true, 75 | 'no_leading_namespace_whitespace' => true, 76 | 'no_mixed_echo_print' => true, 77 | 'no_null_property_initialization' => true, 78 | 'no_spaces_around_offset' => true, 79 | 'no_superfluous_elseif' => true, 80 | 'no_superfluous_phpdoc_tags' => true, 81 | 'no_trailing_comma_in_singleline_array' => true, 82 | 'no_unneeded_control_parentheses' => true, 83 | 'no_unneeded_curly_braces' => true, 84 | 'no_unneeded_final_method' => true, 85 | 'no_unset_cast' => true, 86 | 'no_unused_imports' => true, 87 | 'no_useless_return' => true, 88 | 'no_useless_sprintf' => true, 89 | 'no_whitespace_before_comma_in_array' => true, 90 | 'normalize_index_brace' => true, 91 | 'nullable_type_declaration_for_default_null_value' => true, 92 | 'object_operator_without_whitespace' => true, 93 | 'octal_notation' => true, 94 | 'operator_linebreak' => true, 95 | 'php_unit_construct' => true, 96 | 'php_unit_dedicate_assert' => true, 97 | 'php_unit_dedicate_assert_internal_type' => true, 98 | 'php_unit_method_casing' => true, 99 | 'php_unit_namespaced' => true, 100 | 'php_unit_set_up_tear_down_visibility' => true, 101 | 'php_unit_test_case_static_method_calls' => ['call_type' => 'this'], 102 | 'phpdoc_align' => true, 103 | 'phpdoc_indent' => true, 104 | 'phpdoc_no_package' => true, 105 | 'phpdoc_no_useless_inheritdoc' => true, 106 | 'phpdoc_order' => true, 107 | 'phpdoc_return_self_reference' => true, 108 | 'phpdoc_scalar' => true, 109 | 'phpdoc_separation' => true, 110 | 'phpdoc_single_line_var_spacing' => true, 111 | 'phpdoc_trim' => true, 112 | 'pow_to_exponentiation' => true, 113 | 'protected_to_private' => true, 114 | 'regular_callable_call' => true, 115 | 'self_accessor' => true, 116 | 'self_static_accessor' => true, 117 | 'set_type_to_cast' => true, 118 | 'simple_to_complex_string_variable' => true, 119 | 'simplified_null_return' => true, 120 | 'single_line_comment_style' => true, 121 | 'single_quote' => true, 122 | 'standardize_not_equals' => true, 123 | 'static_lambda' => true, 124 | 'switch_case_semicolon_to_colon' => true, 125 | 'ternary_to_null_coalescing' => true, 126 | 'trailing_comma_in_multiline' => true, 127 | 'unary_operator_spaces' => true, 128 | 'void_return' => true, 129 | 'whitespace_after_comma_in_array' => true, 130 | ]) 131 | ->setFinder($finder); 132 | -------------------------------------------------------------------------------- /translations/messages+intl-icu.cs.yaml: -------------------------------------------------------------------------------- 1 | Redirecting...: Přesměrování... 2 | "Redirecting to {link} in {targetStart}{seconds}{targetEnd} seconds...": "Probíhá přesměrování na {link} za {targetStart}{seconds}{targetEnd} sekund..." 3 | 'Change your instance': 'Změnit vaši instanci' 4 | 'Configure Lemmy redirect': 'Nastavení přesměrování na Lemmy' 5 | 'The field cannot be empty.': 'Políčko nemůže být prázdné.' 6 | "The domain is not valid, it shouldn't contain any scheme (like https://) or path, just the domain.": 'Doména není platná, neměla by obsahovat žádné schéma (např. https://), ani cestu, pouze doménu.' 7 | "You are being taken to the '{community}' community. You might configure your home instance so that you are redirected automatically in the future.": "Právě jste přesměrováváni do komunity ''{community}''. Můžete si nastavit vaši domovskou instanci, a příště budete přesměrováni automaticky." 8 | 'Set my home instance': 'Nastavit domácí instanci' 9 | 'Just take me there!': 'Prostě mě přesměruj!' 10 | 'Custom instance': 'Vlastní instance' 11 | 'Your custom instance (without https:// - just the domain, for example lemmings.world)': 'Vaše vlastní instance (bez https:// - pouze doména, např. lemmings.world)' 12 | Save: Uložit 13 | Error: Chyba 14 | 'The link you clicked is invalid because the community cannot be resolved:': 'Odkaz, na který jste klikli, není platný, protože nelze zjistit, na jakou komunitu přesměrovat:' 15 | '{name} - Lemmy redirect service': '{name} - přesměrovávací služba pro Lemmy' 16 | 'This service is used for linking to Lemmy communities, users, posts etc. in a universal way.': 'Tato služba se používá pro odkazování na Lemmy komunity, uživatele, příspěvky atp. univerzálním způsobem.' 17 | 'How it works without this service:': 'Jak to funguje bez této služby:' 18 | "You link to a community (user, post), let's say {example}, using a link:": 'Vytvoříte odkaz na komunitu (uživatele, příspěvek), například {example}, pomocí standardního odkazu:' 19 | 'After the user clicks it, one of two things happens:': 'Když na něj uživatel klikne, stane se jedna ze dvou věcí:' 20 | "They have an account on the same instance which means everything is fine and the link works, you're done.": 'Mají účet na stejné instanci, všechno je tedy v pohodě a odkaz funguje, máte hotovo.' 21 | "The user doesn't have an account on the same instance, so the process gets more complicated.": 'Uživatel nemá účet na stejné instanci, takže se to trochu komplikuje.' 22 | 'The user has to open their own instance.': 'Uživatel musí otevřít svou vlastní instanci.' 23 | 'The user has to copy the link.': 'Uživatel musí zkopírovat váš odkaz.' 24 | 'The user has to paste the link into a search bar on their own instance.': 'Uživatel musí odkaz zkopírovat do vyhledávacího pole na své instanci.' 25 | 'The user has to click the link in search results.': 'Uživatel se musí prokliknout z výsledků vyhledávání.' 26 | 'How it works with this service:': 'Jak to funguje s touto službou:' 27 | 'You link to the same community like this:': 'Odkážete na komunitu takto:' 28 | "If this isn't the first time the user is here, they will be redirected to their preferred instance.": 'Pokud tohle není poprvé, co je tento uživatel zde, bude přesměrován na svou oblíbenou instanci.' 29 | 'If this is the first time, the user is given the option to either set their own instance or to continue to the target instance.': 'Pokud zde je poprvé, uživatel dostane možnost si buď nastavit oblíbenou instanci, nebo pokračovat na cílovou instanci.' 30 | "Done! Either way the user is on an instance, either one of their choice or on the one the community is hosted on if the user doesn't care.": 'Hotovo! Tak či onak je uživatel přesměrován, buď na instanci dle svého výběru, nebo na instanci, kde se nachází komunita, pokud je to uživateli jedno.' 31 | 'When you use this service, you give your users the option to view a community or a user profile on their preferred instance instead of going through a complicated process just to interact with the link.': 'Použitím této služby dáváte uživatelům možnost si zobrazit komunitu nebo uživatelský profil na jejich oblíbené instanci, namísto zdlouhavého procesu, jen aby mohli interagovat s vaším odkazem.' 32 | 'Create link': 'Vytvořit odkaz' 33 | 'Done! Here is your link:': 'Hotovo! Zde je váš odkaz:' 34 | 'Copy to clipboard': 'Zkopírovat do schránky' 35 | Copied!: Zkopírováno 36 | 'How does it work?': 'Jak to funguje?' 37 | 'Without it being tied to any particular instance.': 'Aniž byste odkazovali na konkrétní instanci.' 38 | 'The link you clicked is invalid because the user cannot be resolved:': 'Odkaz, na který jste klikli, není platný, protože nelze zjistit, na jakého uživatele přesměrovat:' 39 | "You are being taken to the '{user}' user profile. You might configure your home instance so that you are redirected automatically in the future.": "Právě jste přesměrováváni na uživatele ''{user}''. Můžete si nastavit vaši domovskou instanci, a příště budete přesměrováni automaticky." 40 | "You are being taken to a post '{post}'. You might configure your home instance so that you are redirected automatically in the future.": "Právě jste přesměrováváni na příspěvek s názvem ''{post}''. Můžete si nastavit vaši domovskou instanci, a příště budete přesměrováni automaticky." 41 | 'You are being taken to a link on Lemmy. You might configure your home instance so that you are redirected automatically in the future.': 'Právě jste přesměrováváni na odkaz na Lemmy. Můžete si nastavit vaši domovskou instanci, a příště budete přesměrováni automaticky.' 42 | '{name} - Create shareable link to Lemmy': '{name} - Vytvořte odkaz na Lemmy, který lze sdílet' 43 | 'Just paste the link in the box above.': 'Stačí vložit odkaz do políčka nahoře.' 44 | "We don't support this link :/": 'Tenhle odkaz neumíme :/' 45 | "If you think that's a mistake, please let us know on GitHub": 'Pokud si myslíte, že se jedná o chybu, dejte nám to prosím vědět na GitHubu' 46 | "You are being taken to a comment for a post '{post}'. You might configure your home instance so that you are redirected automatically in the future.": "Právě jste přesměrováváni na komentář k příspěvku s názvem ''{post}''. Můžete si nastavit vaši domovskou instanci, a příště budete přesměrováni automaticky." 47 | 'Configure instance and delay': 'Nastavit instanci a zpoždění' 48 | 'Here you can configure your target instance and redirect delay.': 'Zde můžete nastavit svou cílovou instanci a zpoždění při přesměrování.' 49 | 'Back to homepage': 'Zpět na hlavní stránku' 50 | 'Always use original instance': 'Vždy použít původní instanci' 51 | 'Configure delay': 'Nastavit zpoždění' 52 | 'Your preferred delay (in seconds)': 'Vámi preferované zpoždění (v sekundách)' 53 | -------------------------------------------------------------------------------- /src/Service/LemmyObjectResolver.php: -------------------------------------------------------------------------------- 1 | getPostById($originalInstance, $originalPostId); 35 | $activityPubId = $originalPost->id; 36 | 37 | try { 38 | $targetInstance = $this->getRealTargetInstance($targetInstance); 39 | $targetPost = $this->getPostByActivityPubId($targetInstance, $activityPubId); 40 | } catch (LogicException) { 41 | return null; 42 | } 43 | 44 | return $targetPost?->id; 45 | } 46 | 47 | public function getCommentId(int $originalCommentId, string $originalInstance, string $targetInstance): ?int 48 | { 49 | if ($originalInstance === $targetInstance) { 50 | return $originalCommentId; 51 | } 52 | 53 | $originalComment = $this->getCommentById($originalInstance, $originalCommentId); 54 | $activityPubId = $originalComment->apId; 55 | 56 | try { 57 | $targetInstance = $this->getRealTargetInstance($targetInstance); 58 | $targetComment = $this->getCommentByActivityPubId($targetInstance, $activityPubId); 59 | } catch (LogicException) { 60 | return null; 61 | } 62 | 63 | return $targetComment?->id; 64 | } 65 | 66 | public function getPostById(string $instance, int $postId): ActivityPubItem 67 | { 68 | $cacheItem = $this->cache->getItem("post_ap_{$instance}_{$postId}"); 69 | if ($cacheItem->isHit()) { 70 | return $cacheItem->get(); // @phpstan-ignore-line 71 | } 72 | 73 | $post = $this->activityPubResolver->getItem("https://{$instance}/post/{$postId}"); 74 | if (!$post) { 75 | throw new RuntimeException('Post not found'); 76 | } 77 | $cacheItem->set($post); 78 | $this->cache->save($cacheItem); 79 | 80 | return $post; 81 | } 82 | 83 | public function getCommentById(string $instance, int $commentId): Comment 84 | { 85 | $cacheItem = $this->cache->getItem("comment_{$instance}_{$commentId}"); 86 | if ($cacheItem->isHit()) { 87 | return $cacheItem->get(); // @phpstan-ignore-line 88 | } 89 | 90 | $api = $this->apiFactory->getForInstance($instance); 91 | $comment = $api->comment()->get($commentId); 92 | $cacheItem->set($comment->comment); 93 | $this->cache->save($cacheItem); 94 | 95 | return $comment->comment; 96 | } 97 | 98 | private function getPostByActivityPubId(string $instance, string $postId): ?Post 99 | { 100 | $cacheKeyPostId = str_replace(str_split(ItemInterface::RESERVED_CHARACTERS), '_', $postId); 101 | $cacheItem = $this->cache->getItem("post_{$instance}_{$cacheKeyPostId}"); 102 | if ($cacheItem->isHit()) { 103 | return $cacheItem->get(); // @phpstan-ignore-line 104 | } 105 | 106 | $api = $this->apiFactory->getForInstance($instance); 107 | 108 | try { 109 | $post = $api->miscellaneous()->resolveObject(query: $postId)->post; 110 | } catch (LemmyApiException) { 111 | return null; 112 | } 113 | if ($post === null) { 114 | return null; 115 | } 116 | $cacheItem->set($post->post); 117 | $this->cache->save($cacheItem); 118 | 119 | return $post->post; 120 | } 121 | 122 | private function getCommentByActivityPubId(string $instance, string $commentId): ?Comment 123 | { 124 | $cacheKeyCommentId = str_replace(str_split(ItemInterface::RESERVED_CHARACTERS), '_', $commentId); 125 | $cacheItem = $this->cache->getItem("comment_{$instance}_{$cacheKeyCommentId}"); 126 | if ($cacheItem->isHit()) { 127 | return $cacheItem->get(); // @phpstan-ignore-line 128 | } 129 | 130 | $api = $this->apiFactory->getForInstance($instance); 131 | 132 | try { 133 | $comment = $api->miscellaneous()->resolveObject(query: $commentId)->comment; 134 | } catch (LemmyApiException) { 135 | return null; 136 | } 137 | if ($comment === null) { 138 | return null; 139 | } 140 | $cacheItem->set($comment->comment); 141 | $this->cache->save($cacheItem); 142 | 143 | return $comment->comment; 144 | } 145 | 146 | private function getRealTargetInstance(string $targetInstance, ?string $instanceToCheck = null): string 147 | { 148 | $instanceToCheck ??= $targetInstance; 149 | 150 | $result = fn () => $this->getRealTargetInstance($targetInstance, $this->getParentDomain($instanceToCheck)); 151 | 152 | $cacheItem = $this->cache->getItem("target_instance_{$targetInstance}"); 153 | if ($cacheItem->isHit()) { 154 | return $cacheItem->get(); // @phpstan-ignore-line 155 | } 156 | 157 | $url = "https://{$instanceToCheck}/.well-known/nodeinfo"; 158 | $response = $this->httpClient->request(Request::METHOD_GET, $url); 159 | if ($response->getStatusCode() !== Response::HTTP_OK) { 160 | return $result(); 161 | } 162 | 163 | try { 164 | $json = json_decode($response->getContent(), true, flags: JSON_THROW_ON_ERROR); 165 | assert(is_array($json)); 166 | } catch (JsonException) { 167 | return $result(); 168 | } 169 | 170 | if (!isset($json['links'][0]['href'])) { 171 | return $result(); 172 | } 173 | 174 | $response = $this->httpClient->request(Request::METHOD_GET, $json['links'][0]['href']); 175 | if ($response->getStatusCode() !== Response::HTTP_OK) { 176 | return $result(); 177 | } 178 | 179 | try { 180 | $json = json_decode($response->getContent(), true, flags: JSON_THROW_ON_ERROR); 181 | assert(is_array($json)); 182 | } catch (JsonException) { 183 | return $result(); 184 | } 185 | 186 | if (($json['software']['name'] ?? null) !== 'lemmy') { 187 | return $result(); 188 | } 189 | $cacheItem->set($instanceToCheck); 190 | $this->cache->save($cacheItem); 191 | 192 | return $instanceToCheck; 193 | } 194 | 195 | private function getParentDomain(string $domain): string 196 | { 197 | $parts = explode('.', $domain); 198 | if (count($parts) <= 2) { 199 | throw new LogicException('Cannot get a parent domain for a top level domain'); 200 | } 201 | 202 | unset($parts[0]); 203 | 204 | return implode('.', $parts); 205 | } 206 | } 207 | -------------------------------------------------------------------------------- /symfony.lock: -------------------------------------------------------------------------------- 1 | { 2 | "bref/symfony-bridge": { 3 | "version": "0.2", 4 | "recipe": { 5 | "repo": "github.com/symfony/recipes-contrib", 6 | "branch": "main", 7 | "version": "0.1", 8 | "ref": "073e577186ef96e4f26df1f8a3dc14a48826d364" 9 | }, 10 | "files": [ 11 | "serverless.yml" 12 | ] 13 | }, 14 | "doctrine/annotations": { 15 | "version": "2.0", 16 | "recipe": { 17 | "repo": "github.com/symfony/recipes", 18 | "branch": "main", 19 | "version": "1.10", 20 | "ref": "64d8583af5ea57b7afa4aba4b159907f3a148b05" 21 | } 22 | }, 23 | "friendsofphp/php-cs-fixer": { 24 | "version": "3.22", 25 | "recipe": { 26 | "repo": "github.com/symfony/recipes", 27 | "branch": "main", 28 | "version": "3.0", 29 | "ref": "be2103eb4a20942e28a6dd87736669b757132435" 30 | }, 31 | "files": [ 32 | ".php-cs-fixer.dist.php" 33 | ] 34 | }, 35 | "nyholm/psr7": { 36 | "version": "1.8", 37 | "recipe": { 38 | "repo": "github.com/symfony/recipes", 39 | "branch": "main", 40 | "version": "1.0", 41 | "ref": "4a8c0345442dcca1d8a2c65633dcf0285dd5a5a2" 42 | }, 43 | "files": [ 44 | "config/packages/nyholm_psr7.yaml" 45 | ] 46 | }, 47 | "phpstan/phpstan": { 48 | "version": "1.10", 49 | "recipe": { 50 | "repo": "github.com/symfony/recipes-contrib", 51 | "branch": "main", 52 | "version": "1.0", 53 | "ref": "d74d4d719d5f53856c9c13544aa22d44144b1819" 54 | }, 55 | "files": [ 56 | "phpstan.neon" 57 | ] 58 | }, 59 | "phpunit/phpunit": { 60 | "version": "10.2", 61 | "recipe": { 62 | "repo": "github.com/symfony/recipes", 63 | "branch": "main", 64 | "version": "9.6", 65 | "ref": "7364a21d87e658eb363c5020c072ecfdc12e2326" 66 | }, 67 | "files": [ 68 | ".env.test", 69 | "phpunit.xml.dist", 70 | "tests/bootstrap.php" 71 | ] 72 | }, 73 | "symfony/console": { 74 | "version": "6.3", 75 | "recipe": { 76 | "repo": "github.com/symfony/recipes", 77 | "branch": "main", 78 | "version": "5.3", 79 | "ref": "da0c8be8157600ad34f10ff0c9cc91232522e047" 80 | }, 81 | "files": [ 82 | "bin/console" 83 | ] 84 | }, 85 | "symfony/debug-bundle": { 86 | "version": "6.3", 87 | "recipe": { 88 | "repo": "github.com/symfony/recipes", 89 | "branch": "main", 90 | "version": "5.3", 91 | "ref": "5aa8aa48234c8eb6dbdd7b3cd5d791485d2cec4b" 92 | }, 93 | "files": [ 94 | "config/packages/debug.yaml" 95 | ] 96 | }, 97 | "symfony/flex": { 98 | "version": "2.3", 99 | "recipe": { 100 | "repo": "github.com/symfony/recipes", 101 | "branch": "main", 102 | "version": "1.0", 103 | "ref": "146251ae39e06a95be0fe3d13c807bcf3938b172" 104 | }, 105 | "files": [ 106 | ".env" 107 | ] 108 | }, 109 | "symfony/framework-bundle": { 110 | "version": "6.3", 111 | "recipe": { 112 | "repo": "github.com/symfony/recipes", 113 | "branch": "main", 114 | "version": "6.2", 115 | "ref": "af47254c5e4cd543e6af3e4508298ffebbdaddd3" 116 | }, 117 | "files": [ 118 | "config/packages/cache.yaml", 119 | "config/packages/framework.yaml", 120 | "config/preload.php", 121 | "config/routes/framework.yaml", 122 | "config/services.yaml", 123 | "public/index.php", 124 | "src/Controller/.gitignore", 125 | "src/Kernel.php" 126 | ] 127 | }, 128 | "symfony/monolog-bundle": { 129 | "version": "3.8", 130 | "recipe": { 131 | "repo": "github.com/symfony/recipes", 132 | "branch": "main", 133 | "version": "3.7", 134 | "ref": "213676c4ec929f046dfde5ea8e97625b81bc0578" 135 | }, 136 | "files": [ 137 | "config/packages/monolog.yaml" 138 | ] 139 | }, 140 | "symfony/phpunit-bridge": { 141 | "version": "6.3", 142 | "recipe": { 143 | "repo": "github.com/symfony/recipes", 144 | "branch": "main", 145 | "version": "6.3", 146 | "ref": "01dfaa98c58f7a7b5a9b30e6edb7074af7ed9819" 147 | }, 148 | "files": [ 149 | ".env.test", 150 | "bin/phpunit", 151 | "phpunit.xml.dist", 152 | "tests/bootstrap.php" 153 | ] 154 | }, 155 | "symfony/routing": { 156 | "version": "6.3", 157 | "recipe": { 158 | "repo": "github.com/symfony/recipes", 159 | "branch": "main", 160 | "version": "6.2", 161 | "ref": "e0a11b4ccb8c9e70b574ff5ad3dfdcd41dec5aa6" 162 | }, 163 | "files": [ 164 | "config/packages/routing.yaml", 165 | "config/routes.yaml" 166 | ] 167 | }, 168 | "symfony/stimulus-bundle": { 169 | "version": "2.10", 170 | "recipe": { 171 | "repo": "github.com/symfony/recipes", 172 | "branch": "main", 173 | "version": "2.9", 174 | "ref": "05c45071c7ecacc1e48f94bc43c1f8d4405fb2b2" 175 | }, 176 | "files": [ 177 | "assets/bootstrap.js", 178 | "assets/controllers.json", 179 | "assets/controllers/hello_controller.js" 180 | ] 181 | }, 182 | "symfony/translation": { 183 | "version": "6.3", 184 | "recipe": { 185 | "repo": "github.com/symfony/recipes", 186 | "branch": "main", 187 | "version": "5.3", 188 | "ref": "da64f5a2b6d96f5dc24914517c0350a5f91dee43" 189 | }, 190 | "files": [ 191 | "config/packages/translation.yaml", 192 | "translations/.gitignore" 193 | ] 194 | }, 195 | "symfony/twig-bundle": { 196 | "version": "6.3", 197 | "recipe": { 198 | "repo": "github.com/symfony/recipes", 199 | "branch": "main", 200 | "version": "6.3", 201 | "ref": "b7772eb20e92f3fb4d4fe756e7505b4ba2ca1a2c" 202 | }, 203 | "files": [ 204 | "config/packages/twig.yaml", 205 | "templates/base.html.twig" 206 | ] 207 | }, 208 | "symfony/web-profiler-bundle": { 209 | "version": "6.3", 210 | "recipe": { 211 | "repo": "github.com/symfony/recipes", 212 | "branch": "main", 213 | "version": "6.1", 214 | "ref": "e42b3f0177df239add25373083a564e5ead4e13a" 215 | }, 216 | "files": [ 217 | "config/packages/web_profiler.yaml", 218 | "config/routes/web_profiler.yaml" 219 | ] 220 | }, 221 | "symfony/webpack-encore-bundle": { 222 | "version": "2.0", 223 | "recipe": { 224 | "repo": "github.com/symfony/recipes", 225 | "branch": "main", 226 | "version": "2.0", 227 | "ref": "13ebe04e25085e2ff0bcb0f9218b561d8b5089f3" 228 | }, 229 | "files": [ 230 | "assets/app.js", 231 | "assets/styles/app.css", 232 | "config/packages/webpack_encore.yaml", 233 | "package.json", 234 | "webpack.config.js" 235 | ] 236 | }, 237 | "twig/extra-bundle": { 238 | "version": "v3.6.1" 239 | } 240 | } 241 | -------------------------------------------------------------------------------- /serverless.yml: -------------------------------------------------------------------------------- 1 | # Read the documentation at https://www.serverless.com/framework/docs/providers/aws/guide/serverless.yml/ 2 | service: LemmyverseLink-${env:DOMAIN_ID} 3 | 4 | provider: 5 | name: aws 6 | # The AWS region in which to deploy (us-east-1 is the default) 7 | region: ${opt:region, env:AWS_REGION, 'eu-central-1'} 8 | # The stage of the application, e.g. dev, production, staging… ('dev' is the default) 9 | stage: ${opt:stage, 'prod'} 10 | runtime: provided.al2 11 | stackTags: 12 | BillingProject: LemmyverseLink 13 | environment: 14 | # Symfony environment variables 15 | APP_ENV: ${self:provider.stage} 16 | APP_SECRET: !Join [ '', [ '{{resolve:secretsmanager:', !Ref AppSecret, ':SecretString:secret}}' ] ] 17 | 18 | plugins: 19 | - ./vendor/bref/bref 20 | 21 | custom: 22 | Domain: ${env:DOMAIN_NAME} 23 | DomainZone: ${env:DOMAIN_ZONE} 24 | ServiceToken: !Join [':', ['arn:aws:lambda', !Ref AWS::Region, !Ref AWS::AccountId, 'function:AcmCustomResources-prod-customResources']] 25 | CloudfrontHostedZone: Z2FDTNDATAQYW2 26 | 27 | functions: 28 | # This function runs the Symfony website/API 29 | web: 30 | handler: public/index.php 31 | timeout: 28 # in seconds (API Gateway has a timeout of 29 seconds) 32 | layers: 33 | - ${bref:layer.php-82-fpm} 34 | events: 35 | - httpApi: '*' 36 | # This function let us run console commands in Lambda 37 | console: 38 | handler: bin/console 39 | timeout: 120 # in seconds 40 | layers: 41 | - ${bref:layer.php-82} # PHP 42 | - ${bref:layer.console} # The "console" layer 43 | 44 | package: 45 | patterns: 46 | # Excluded files and folders for deployment 47 | - '!assets/**' 48 | - '!node_modules/**' 49 | - '!public/build/**' 50 | - '!tests/**' 51 | - '!var/**' 52 | # If you want to include files and folders that are part of excluded folders, 53 | # add them at the end 54 | - 'var/cache/prod/**' 55 | - 'public/build/entrypoints.json' 56 | - 'public/build/manifest.json' 57 | 58 | resources: 59 | Resources: 60 | AppSecret: 61 | Type: AWS::SecretsManager::Secret 62 | Properties: 63 | Description: Lemmyverse link App secret 64 | GenerateSecretString: 65 | SecretStringTemplate: '{}' 66 | GenerateStringKey: "secret" 67 | PasswordLength: 32 68 | RequireEachIncludedType: true 69 | ExcludeUppercase: true 70 | ExcludePunctuation: true 71 | ExcludeCharacters: ghijklmnopqrstuvwxyz 72 | 73 | Certificate: 74 | Type: Custom::Certificate 75 | Properties: 76 | DomainName: ${self:custom.Domain} 77 | ValidationMethod: DNS 78 | ServiceToken: ${self:custom.ServiceToken} 79 | CertificateBlocker: 80 | Type: Custom::IssuedCertificate 81 | DependsOn: 82 | - DnsRecordsCertificateValidation 83 | Properties: 84 | CertificateArn: !Ref Certificate 85 | ServiceToken: ${self:custom.ServiceToken} 86 | CertificateDnsRecord: 87 | Type: Custom::CertificateDNSRecord 88 | Properties: 89 | CertificateArn: !Ref Certificate 90 | DomainName: ${self:custom.Domain} 91 | ServiceToken: ${self:custom.ServiceToken} 92 | DnsRecordsCertificateValidation: 93 | Type: AWS::Route53::RecordSetGroup 94 | Properties: 95 | HostedZoneId: ${self:custom.DomainZone} 96 | RecordSets: 97 | - Name: !GetAtt CertificateDnsRecord.Name 98 | Type: !GetAtt CertificateDnsRecord.Type 99 | TTL: 60 100 | Weight: 1 101 | SetIdentifier: !Ref Certificate 102 | ResourceRecords: 103 | - !GetAtt CertificateDnsRecord.Value 104 | ApiGatewayDomainName: 105 | DependsOn: 106 | - CertificateBlocker 107 | Type: AWS::ApiGatewayV2::DomainName 108 | Properties: 109 | DomainName: ${self:custom.Domain} 110 | DomainNameConfigurations: 111 | - CertificateArn: !Ref Certificate 112 | EndpointType: REGIONAL 113 | ApiGatewayDomainMapping: 114 | DependsOn: 115 | - ApiGatewayDomainName 116 | Type: AWS::ApiGatewayV2::ApiMapping 117 | Properties: 118 | ApiId: !Ref HttpApi 119 | DomainName: ${self:custom.Domain} 120 | Stage: !Ref HttpApiStage 121 | MainDnsRecords: 122 | Type: AWS::Route53::RecordSetGroup 123 | Properties: 124 | HostedZoneId: ${self:custom.DomainZone} 125 | RecordSets: 126 | - AliasTarget: 127 | DNSName: !GetAtt ApiGatewayDomainName.RegionalDomainName 128 | HostedZoneId: !GetAtt ApiGatewayDomainName.RegionalHostedZoneId 129 | Name: ${self:custom.Domain} 130 | Type: A 131 | - AliasTarget: 132 | DNSName: !GetAtt AssetsCDN.DomainName 133 | HostedZoneId: ${self:custom.CloudfrontHostedZone} 134 | Name: assets.${self:custom.Domain} 135 | Type: A 136 | AssetsCertificate: 137 | Type: Custom::Certificate 138 | Properties: 139 | DomainName: assets.${self:custom.Domain} 140 | ValidationMethod: DNS 141 | Region: us-east-1 142 | ServiceToken: ${self:custom.ServiceToken} 143 | AssetsCertificateBlocker: 144 | Type: Custom::IssuedCertificate 145 | DependsOn: 146 | - AssetsDnsRecordsCertificateValidation 147 | Properties: 148 | CertificateArn: !Ref AssetsCertificate 149 | ServiceToken: ${self:custom.ServiceToken} 150 | AssetsCertificateDnsRecord: 151 | Type: Custom::CertificateDNSRecord 152 | Properties: 153 | CertificateArn: !Ref AssetsCertificate 154 | DomainName: assets.${self:custom.Domain} 155 | ServiceToken: ${self:custom.ServiceToken} 156 | AssetsDnsRecordsCertificateValidation: 157 | Type: AWS::Route53::RecordSetGroup 158 | Properties: 159 | HostedZoneId: ${self:custom.DomainZone} 160 | RecordSets: 161 | - Name: !GetAtt AssetsCertificateDnsRecord.Name 162 | Type: !GetAtt AssetsCertificateDnsRecord.Type 163 | TTL: 60 164 | Weight: 1 165 | SetIdentifier: !Ref AssetsCertificate 166 | ResourceRecords: 167 | - !GetAtt AssetsCertificateDnsRecord.Value 168 | AssetsBucket: 169 | Type: AWS::S3::Bucket 170 | Properties: 171 | CorsConfiguration: 172 | CorsRules: 173 | - AllowedHeaders: [ "*" ] 174 | AllowedMethods: [ GET ] 175 | AllowedOrigins: [ "*" ] 176 | PublicAccessBlockConfiguration: 177 | BlockPublicAcls: false 178 | BlockPublicPolicy: false 179 | IgnorePublicAcls: false 180 | RestrictPublicBuckets: false 181 | AssetsBucketPolicy: 182 | Type: AWS::S3::BucketPolicy 183 | Properties: 184 | Bucket: !Ref AssetsBucket 185 | PolicyDocument: 186 | Statement: 187 | - Effect: Allow 188 | Principal: '*' # everyone 189 | Action: 's3:GetObject' # to read 190 | Resource: !Join [ '/', [ !GetAtt AssetsBucket.Arn, '*' ] ] 191 | AssetsCDN: 192 | DependsOn: 193 | - AssetsCertificateBlocker 194 | Type: AWS::CloudFront::Distribution 195 | Properties: 196 | DistributionConfig: 197 | Aliases: 198 | - assets.${self:custom.Domain} 199 | Enabled: true 200 | PriceClass: PriceClass_100 201 | HttpVersion: http2 202 | Origins: 203 | - Id: AssetsBucket 204 | DomainName: !GetAtt AssetsBucket.RegionalDomainName 205 | S3OriginConfig: { } # this key is required to tell CloudFront that this is an S3 origin, even though nothing is configured 206 | DefaultCacheBehavior: 207 | TargetOriginId: AssetsBucket 208 | AllowedMethods: [ GET, HEAD ] 209 | ForwardedValues: 210 | QueryString: 'false' 211 | Cookies: 212 | Forward: none 213 | ViewerProtocolPolicy: redirect-to-https 214 | Compress: true 215 | CustomErrorResponses: 216 | - ErrorCode: 500 217 | ErrorCachingMinTTL: 0 218 | - ErrorCode: 504 219 | ErrorCachingMinTTL: 0 220 | ViewerCertificate: 221 | AcmCertificateArn: !Ref AssetsCertificate 222 | MinimumProtocolVersion: TLSv1.2_2019 223 | SslSupportMethod: sni-only 224 | Outputs: 225 | TestUrl: 226 | Value: https://${self:custom.Domain}/c/lemmings_world_instance@lemmings.world 227 | AssetsBucket: 228 | Value: !Ref AssetsBucket 229 | Cdn: 230 | Value: !Ref AssetsCDN 231 | -------------------------------------------------------------------------------- /src/Controller/LemmyLinkController.php: -------------------------------------------------------------------------------- 1 | query->has('forceHomeInstance') && $request->query->getBoolean('forceHomeInstance')) 37 | || ($request->cookies->has($skipPreferred) && $request->cookies->getBoolean($skipPreferred)) 38 | ; 39 | 40 | $redirectTimeout = $request->cookies->getInt($delayCookieName, $redirectTimeout); 41 | 42 | try { 43 | $parsedCommunityName = $communityNameParser->parse($community); 44 | } catch (InvalidArgumentException) { 45 | return $this->render('invalid-community.html.twig', [ 46 | 'community' => $community, 47 | ]); 48 | } 49 | 50 | $preferenceRedirectUrl = $this->generateUrl('app.preferences.instance', [ 51 | 'redirectTo' => $this->generateUrl('app.community', [ 52 | 'community' => $community, 53 | ...$request->query->all(), 54 | ]), 55 | 'community' => $community, 56 | ]); 57 | 58 | if ($forceHomeInstance) { 59 | $targetInstance = $parsedCommunityName->homeInstance; 60 | $resolvedCommunity = $parsedCommunityName->name; 61 | } else { 62 | $targetInstance = $preferenceManager->getPreferredLemmyInstance(); 63 | $resolvedCommunity = $parsedCommunityName->fullName; 64 | } 65 | 66 | if ($targetInstance === null) { 67 | return $this->redirect($preferenceRedirectUrl); 68 | } 69 | 70 | $linkProvider = $linkProviderManager->findProvider( 71 | $webFingerParser->getSoftware($targetInstance) ?? throw new RuntimeException('Failed to get software for target instance'), 72 | ); 73 | if ($linkProvider === null) { 74 | throw new RuntimeException("Unsupported software for target instance: {$webFingerParser->getSoftware($targetInstance)}"); 75 | } 76 | $url = $linkProvider->getCommunityLink($targetInstance, $resolvedCommunity); 77 | if ($request->query->count() > 0) { 78 | $url .= '?' . http_build_query($request->query->all()); 79 | } 80 | 81 | if ($redirectTimeout === 0) { 82 | return $this->redirect($url); 83 | } 84 | 85 | return $this->render('redirect.html.twig', [ 86 | 'timeout' => $redirectTimeout, 87 | 'url' => $url, 88 | 'preferenceUrl' => $preferenceRedirectUrl, 89 | ]); 90 | } 91 | 92 | #[Route('/u/{user}', name: 'app.user', methods: [Request::METHOD_GET])] 93 | public function userLink( 94 | string $user, 95 | #[Autowire('%app.redirect_timeout%')] int $redirectTimeout, 96 | #[Autowire('%app.skip_preferred_cookie%')] string $skipPreferred, 97 | #[Autowire('%app.delay_cookie%')] string $delayCookieName, 98 | PreferenceManager $preferenceManager, 99 | Request $request, 100 | NameParser $usernameParser, 101 | LinkProviderManager $linkProviderManager, 102 | WebFingerParser $webFingerParser, 103 | ): Response { 104 | $forceHomeInstance 105 | = ($request->query->has('forceHomeInstance') && $request->query->getBoolean('forceHomeInstance')) 106 | || ($request->cookies->has($skipPreferred) && $request->cookies->getBoolean($skipPreferred)) 107 | ; 108 | 109 | $redirectTimeout = $request->cookies->getInt($delayCookieName, $redirectTimeout); 110 | 111 | try { 112 | $parsedName = $usernameParser->parse($user); 113 | } catch (InvalidArgumentException) { 114 | return $this->render('invalid-user.html.twig', [ 115 | 'user' => $user, 116 | ]); 117 | } 118 | 119 | $preferenceRedirectUrl = $this->generateUrl('app.preferences.instance', [ 120 | 'redirectTo' => $this->generateUrl('app.user', [ 121 | 'user' => $user, 122 | ...$request->query->all(), 123 | ]), 124 | 'user' => $user, 125 | ]); 126 | 127 | if ($forceHomeInstance) { 128 | $targetInstance = $parsedName->homeInstance; 129 | $resolvedUser = $parsedName->name; 130 | } else { 131 | $targetInstance = $preferenceManager->getPreferredLemmyInstance(); 132 | $resolvedUser = $parsedName->fullName; 133 | } 134 | 135 | if ($targetInstance === null) { 136 | return $this->redirect($preferenceRedirectUrl); 137 | } 138 | 139 | $linkProvider = $linkProviderManager->findProvider( 140 | $webFingerParser->getSoftware($targetInstance) ?? throw new RuntimeException('Failed to get software for target instance'), 141 | ); 142 | if ($linkProvider === null) { 143 | throw new RuntimeException("Unsupported software for target instance: {$webFingerParser->getSoftware($targetInstance)}"); 144 | } 145 | $url = $linkProvider->getUserLink($targetInstance, $resolvedUser); 146 | if ($request->query->count() > 0) { 147 | $url .= '?' . http_build_query($request->query->all()); 148 | } 149 | 150 | if ($redirectTimeout === 0) { 151 | return $this->redirect($url); 152 | } 153 | 154 | return $this->render('redirect.html.twig', [ 155 | 'timeout' => $redirectTimeout, 156 | 'url' => $url, 157 | 'preferenceUrl' => $preferenceRedirectUrl, 158 | ]); 159 | } 160 | 161 | #[Route('{originalInstance}/post/{postId}', name: 'app.post', methods: [Request::METHOD_GET])] 162 | public function postLink( 163 | string $originalInstance, 164 | int $postId, 165 | #[Autowire('%app.redirect_timeout%')] int $redirectTimeout, 166 | #[Autowire('%app.skip_preferred_cookie%')] string $skipPreferred, 167 | #[Autowire('%app.delay_cookie%')] string $delayCookieName, 168 | PreferenceManager $preferenceManager, 169 | Request $request, 170 | LemmyObjectResolver $objectResolver, 171 | ): Response { 172 | $forceHomeInstance 173 | = ($request->query->has('forceHomeInstance') && $request->query->getBoolean('forceHomeInstance')) 174 | || ($request->cookies->has($skipPreferred) && $request->cookies->getBoolean($skipPreferred)) 175 | ; 176 | 177 | $redirectTimeout = $request->cookies->getInt($delayCookieName, $redirectTimeout); 178 | 179 | if ($forceHomeInstance) { 180 | $targetInstance = $originalInstance; 181 | $url = "https://{$originalInstance}/post/{$postId}"; 182 | } else { 183 | $targetInstance = $preferenceManager->getPreferredLemmyInstance(); 184 | if ($targetInstance === null) { 185 | $url = null; 186 | } else { 187 | $targetPostId = $objectResolver->getPostId($postId, $originalInstance, $targetInstance); 188 | if ($targetPostId === null) { 189 | $url = "https://{$originalInstance}/post/{$postId}"; 190 | } else { 191 | $url = "https://{$targetInstance}/post/{$targetPostId}"; 192 | } 193 | } 194 | } 195 | 196 | if ($redirectTimeout === 0 && $url !== null) { 197 | return $this->redirect($url); 198 | } 199 | 200 | $preferenceRedirectUrl = $this->generateUrl('app.preferences.instance', [ 201 | 'redirectTo' => $this->generateUrl('app.post', [ 202 | 'postId' => $postId, 203 | 'originalInstance' => $originalInstance, 204 | ]), 205 | 'instance' => $originalInstance, 206 | 'post' => $postId, 207 | ]); 208 | 209 | if ($targetInstance === null) { 210 | return $this->redirect($preferenceRedirectUrl); 211 | } 212 | 213 | return $this->render('redirect.html.twig', [ 214 | 'timeout' => $redirectTimeout, 215 | 'url' => $url, 216 | 'preferenceUrl' => $preferenceRedirectUrl, 217 | ]); 218 | } 219 | 220 | #[Route('{originalInstance}/comment/{commentId}', name: 'app.comment', methods: [Request::METHOD_GET])] 221 | public function commentLink( 222 | string $originalInstance, 223 | int $commentId, 224 | #[Autowire('%app.redirect_timeout%')] int $redirectTimeout, 225 | #[Autowire('%app.skip_preferred_cookie%')] string $skipPreferred, 226 | #[Autowire('%app.delay_cookie%')] string $delayCookieName, 227 | PreferenceManager $preferenceManager, 228 | Request $request, 229 | LemmyObjectResolver $objectResolver, 230 | ): Response { 231 | $forceHomeInstance 232 | = ($request->query->has('forceHomeInstance') && $request->query->getBoolean('forceHomeInstance')) 233 | || ($request->cookies->has($skipPreferred) && $request->cookies->getBoolean($skipPreferred)) 234 | ; 235 | 236 | $redirectTimeout = $request->cookies->getInt($delayCookieName, $redirectTimeout); 237 | 238 | if ($forceHomeInstance) { 239 | $targetInstance = $originalInstance; 240 | $url = "https://{$originalInstance}/comment/{$commentId}"; 241 | } else { 242 | $targetInstance = $preferenceManager->getPreferredLemmyInstance(); 243 | if ($targetInstance === null) { 244 | $url = null; 245 | } else { 246 | $targetCommentId = $objectResolver->getCommentId($commentId, $originalInstance, $targetInstance); 247 | if ($targetCommentId === null) { 248 | $url = "https://{$originalInstance}/comment/{$commentId}"; 249 | } else { 250 | $url = "https://{$targetInstance}/comment/{$targetCommentId}"; 251 | } 252 | } 253 | } 254 | 255 | if ($redirectTimeout === 0 && $url !== null) { 256 | return $this->redirect($url); 257 | } 258 | 259 | $preferenceRedirectUrl = $this->generateUrl('app.preferences.instance', [ 260 | 'redirectTo' => $this->generateUrl('app.comment', [ 261 | 'commentId' => $commentId, 262 | 'originalInstance' => $originalInstance, 263 | ]), 264 | 'instance' => $originalInstance, 265 | 'comment' => $commentId, 266 | ]); 267 | 268 | if ($targetInstance === null) { 269 | return $this->redirect($preferenceRedirectUrl); 270 | } 271 | 272 | return $this->render('redirect.html.twig', [ 273 | 'timeout' => $redirectTimeout, 274 | 'url' => $url, 275 | 'preferenceUrl' => $preferenceRedirectUrl, 276 | ]); 277 | } 278 | } 279 | --------------------------------------------------------------------------------