mirror of
https://github.com/shlinkio/shlink.git
synced 2025-02-15 18:03:31 -06:00
commit
8dbd9ca33d
5
.github/ISSUE_TEMPLATE.md
vendored
5
.github/ISSUE_TEMPLATE.md
vendored
@ -1,6 +1,7 @@
|
||||
<!--
|
||||
Before opening an issue, just take into account that this is a completely free of charge open source project.
|
||||
I'm always happy to help and provide support, but some understanding will be required.
|
||||
Before opening an issue, just take into account that this is a completely free of charge and open source project.
|
||||
I'm always happy to help and provide support, but some understanding will be expected.
|
||||
I do this in my own free time, so expect some delays when implementing new features and fixing bugs, and don't take it personal if an issue gets eventually closed.
|
||||
You may also be asked to provide tests or ways to reproduce reported bugs.
|
||||
Try to be polite, and understand it is impossible for an OSS project to cover all use cases.
|
||||
-->
|
||||
|
5
.github/ISSUE_TEMPLATE/Bug.md
vendored
5
.github/ISSUE_TEMPLATE/Bug.md
vendored
@ -5,9 +5,10 @@ labels: bug
|
||||
---
|
||||
|
||||
<!--
|
||||
Before opening an issue, just take into account that this is a completely free of charge open source project.
|
||||
I'm always happy to help and provide support, but some understanding will be required.
|
||||
Before opening an issue, just take into account that this is a completely free of charge and open source project.
|
||||
I'm always happy to help and provide support, but some understanding will be expected.
|
||||
I do this in my own free time, so expect some delays when implementing new features and fixing bugs, and don't take it personal if an issue gets eventually closed.
|
||||
You may also be asked to provide tests or ways to reproduce reported bugs.
|
||||
Try to be polite, and understand it is impossible for an OSS project to cover all use cases.
|
||||
|
||||
With that said, please fill in the information requested next. More information might be requested next (like logs or system configs).
|
||||
|
5
.github/ISSUE_TEMPLATE/Feature_Request.md
vendored
5
.github/ISSUE_TEMPLATE/Feature_Request.md
vendored
@ -5,9 +5,10 @@ labels: feature
|
||||
---
|
||||
|
||||
<!--
|
||||
Before opening an issue, just take into account that this is a completely free of charge open source project.
|
||||
I'm always happy to help and provide support, but some understanding will be required.
|
||||
Before opening an issue, just take into account that this is a completely free of charge and open source project.
|
||||
I'm always happy to help and provide support, but some understanding will be expected.
|
||||
I do this in my own free time, so expect some delays when implementing new features and fixing bugs, and don't take it personal if an issue gets eventually closed.
|
||||
You may also be asked to provide tests or ways to reproduce reported bugs.
|
||||
Try to be polite, and understand it is impossible for an OSS project to cover all use cases.
|
||||
|
||||
With that said, please fill in the information requested next. More information might be requested next (like logs or system configs).
|
||||
|
5
.github/ISSUE_TEMPLATE/Question_Support.md
vendored
5
.github/ISSUE_TEMPLATE/Question_Support.md
vendored
@ -5,9 +5,10 @@ labels: question
|
||||
---
|
||||
|
||||
<!--
|
||||
Before opening an issue, just take into account that this is a completely free of charge open source project.
|
||||
I'm always happy to help and provide support, but some understanding will be required.
|
||||
Before opening an issue, just take into account that this is a completely free of charge and open source project.
|
||||
I'm always happy to help and provide support, but some understanding will be expected.
|
||||
I do this in my own free time, so expect some delays when implementing new features and fixing bugs, and don't take it personal if an issue gets eventually closed.
|
||||
You may also be asked to provide tests or ways to reproduce reported bugs.
|
||||
Try to be polite, and understand it is impossible for an OSS project to cover all use cases.
|
||||
|
||||
With that said, please fill in the information requested next. More information might be requested next (like logs or system configs).
|
||||
|
24
.github/workflows/docker-image-build.yml
vendored
Normal file
24
.github/workflows/docker-image-build.yml
vendored
Normal file
@ -0,0 +1,24 @@
|
||||
name: Build docker image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
- name: Install buildx
|
||||
id: buildx
|
||||
uses: crazy-max/ghaction-docker-buildx@v1
|
||||
with:
|
||||
buildx-version: latest
|
||||
- name: Login to docker hub
|
||||
run: echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_USERNAME }}" --password-stdin
|
||||
- name: Build the image
|
||||
run: bash ./docker/build
|
86
.travis.yml
86
.travis.yml
@ -6,21 +6,6 @@ branches:
|
||||
only:
|
||||
- /.*/
|
||||
|
||||
jobs:
|
||||
fast_finish: true
|
||||
include:
|
||||
- name: "Docker publish"
|
||||
php: '7.4'
|
||||
if: NOT type = pull_request
|
||||
env:
|
||||
- DOCKER_PUBLISH="true"
|
||||
- name: "CI"
|
||||
php: '7.4'
|
||||
env:
|
||||
- DOCKER_PUBLISH="false"
|
||||
allow_failures:
|
||||
- name: "Docker publish"
|
||||
|
||||
services:
|
||||
- docker
|
||||
|
||||
@ -28,48 +13,55 @@ cache:
|
||||
directories:
|
||||
- $HOME/.composer/cache/files
|
||||
|
||||
jobs:
|
||||
fast_finish: true
|
||||
allow_failures:
|
||||
- php: 'nightly'
|
||||
include:
|
||||
- name: "CI - 8.0"
|
||||
php: 'nightly'
|
||||
env:
|
||||
- COMPOSER_FLAGS='--ignore-platform-reqs'
|
||||
- name: "CI - 7.4"
|
||||
php: '7.4'
|
||||
env:
|
||||
- COMPOSER_FLAGS=''
|
||||
# Deploy release only on smallest supported PHP version
|
||||
before_deploy:
|
||||
- rm -f ocular.phar
|
||||
- ./build.sh ${TRAVIS_TAG#?}
|
||||
deploy:
|
||||
- provider: releases
|
||||
api_key:
|
||||
secure: a9dbZchocqeuOViwUeNH54bQR5Sz7rEYXx5b9WPFtnFn9LGKKUaLbA2U91UQ9QKPrcTpsALubUYbw2CnNmvCwzaY+R8lCD3gkU4ohsEnbpnw3deOeixI74sqBHJAuCH9FSaRDGILoBMtUKx2xlzIymFxkIsgIukkGbdkWHDlRWY3oTUUuw1SQ2Xk9KDsbJQtjIc1+G/O6gHaV4qv/R9W8NPmJExKTNDrAZbC1vIUnxqp4UpVo1hst8qPd1at94CndDYM5rG+7imGbdtxTxzamt819qdTO1OfvtctKawNAm7YXZrrWft6c7gI6j6SI4hxd+ZrrPBqbaRFHkZHjnNssO/yn4SaOHFFzccmu0MzvpPCf0qWZwd3sGHVYer1MnR2mHYqU84QPlW3nrHwJjkrpq3+q0JcBY6GsJs+RskHNtkMTKV05Iz6QUI5YZGwTpuXaRm036SmavjGc4IDlMaYCk/NmbB9BKpthJxLdUpczOHpnjXXHziotWD6cfEnbjU3byfD8HY5WrxSjsNT7SKmXN3hRof7bk985ewQVjGT42O3NbnfnqjQQWr/B7/zFTpLR4f526Bkq12CdCyf5lvrbq+POkLVdJ+uFfR7ds248Ue/jBQy6kM1tWmKF9QiwisFlA84eQ4CW3I93Rp97URv+AQa9zmbD0Ve3Udp+g6nF5I=
|
||||
file: "./build/shlink_${TRAVIS_TAG#?}_dist.zip"
|
||||
skip_cleanup: true
|
||||
on:
|
||||
tags: true
|
||||
|
||||
before_install:
|
||||
- echo 'extension = apcu.so' >> ~/.phpenv/versions/$(phpenv version-name)/etc/php.ini
|
||||
- phpenv config-rm xdebug.ini || return 0
|
||||
- if [[ "${DOCKER_PUBLISH}" == 'false' ]]; then sudo ./data/infra/ci/install-ms-odbc.sh ; fi
|
||||
- if [[ "${DOCKER_PUBLISH}" == 'false' ]]; then docker-compose -f docker-compose.yml -f docker-compose.ci.yml up -d shlink_db_ms shlink_db shlink_db_postgres shlink_db_maria ; fi
|
||||
- if [[ "${DOCKER_PUBLISH}" == 'false' ]]; then yes | pecl install pdo_sqlsrv swoole-4.4.18 ; fi
|
||||
- sudo ./data/infra/ci/install-ms-odbc.sh
|
||||
- docker-compose -f docker-compose.yml -f docker-compose.ci.yml up -d shlink_db_ms shlink_db shlink_db_postgres shlink_db_maria
|
||||
- yes | pecl install pdo_sqlsrv swoole-4.5.2
|
||||
|
||||
install:
|
||||
- if [[ "${DOCKER_PUBLISH}" == 'true' ]]; then sudo ./data/infra/ci/install-docker.sh ; fi
|
||||
- if [[ "${DOCKER_PUBLISH}" == 'false' ]]; then composer self-update ; fi
|
||||
- if [[ "${DOCKER_PUBLISH}" == 'false' ]]; then composer install --no-interaction --prefer-dist ; fi
|
||||
- composer self-update
|
||||
- composer install --no-interaction --prefer-dist $COMPOSER_FLAGS
|
||||
|
||||
before_script:
|
||||
- if [[ "${DOCKER_PUBLISH}" == 'false' ]]; then docker-compose exec shlink_db_ms /opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P 'Passw0rd!' -Q "CREATE DATABASE shlink_test;" ; fi
|
||||
- docker-compose exec shlink_db_ms /opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P 'Passw0rd!' -Q "CREATE DATABASE shlink_test;"
|
||||
- mkdir build
|
||||
- export DOCKERFILE_CHANGED=$(git diff ${TRAVIS_COMMIT_RANGE:-origin/master} --name-only | grep Dockerfile)
|
||||
- export DOCKERFILE_CHANGED=$(git diff ${TRAVIS_COMMIT_RANGE:-origin/main} --name-only | grep Dockerfile)
|
||||
|
||||
script:
|
||||
- if [[ "${DOCKER_PUBLISH}" == 'false' ]]; then bin/test/run-api-tests.sh --coverage-php build/coverage-api.cov && composer ci ; fi
|
||||
- if [[ ! -z "${DOCKERFILE_CHANGED}" && "${TRAVIS_PHP_VERSION}" == "7.4" && "${DOCKER_PUBLISH}" == "false" ]]; then docker build -t shlink-docker-image:temp . ; fi
|
||||
- if [[ "${DOCKER_PUBLISH}" == 'true' ]]; then bash ./docker/build ; fi
|
||||
- bin/test/run-api-tests.sh --coverage-php build/coverage-api.cov && composer ci
|
||||
- if [[ ! -z "${DOCKERFILE_CHANGED}" && "${TRAVIS_PHP_VERSION}" == "7.4" ]]; then docker build -t shlink-docker-image:temp . ; fi
|
||||
|
||||
after_success:
|
||||
- rm -f build/clover.xml
|
||||
- if [[ "${DOCKER_PUBLISH}" == 'false' ]]; then wget https://phar.phpunit.de/phpcov-7.0.2.phar ; fi
|
||||
- if [[ "${DOCKER_PUBLISH}" == 'false' ]]; then phpdbg -qrr phpcov-7.0.2.phar merge build --clover build/clover.xml ; fi
|
||||
- if [[ "${DOCKER_PUBLISH}" == 'false' ]]; then wget https://scrutinizer-ci.com/ocular.phar ; fi
|
||||
- if [[ "${DOCKER_PUBLISH}" == 'false' ]]; then php ocular.phar code-coverage:upload --format=php-clover build/clover.xml ; fi
|
||||
|
||||
# Before deploying, build dist file for current travis tag
|
||||
before_deploy:
|
||||
- rm -f ocular.phar
|
||||
- if [[ ! -z ${TRAVIS_TAG} && "${TRAVIS_PHP_VERSION}" == "7.4" ]]; then ./build.sh ${TRAVIS_TAG#?} ; fi
|
||||
|
||||
deploy:
|
||||
- provider: releases
|
||||
api_key:
|
||||
secure: a9dbZchocqeuOViwUeNH54bQR5Sz7rEYXx5b9WPFtnFn9LGKKUaLbA2U91UQ9QKPrcTpsALubUYbw2CnNmvCwzaY+R8lCD3gkU4ohsEnbpnw3deOeixI74sqBHJAuCH9FSaRDGILoBMtUKx2xlzIymFxkIsgIukkGbdkWHDlRWY3oTUUuw1SQ2Xk9KDsbJQtjIc1+G/O6gHaV4qv/R9W8NPmJExKTNDrAZbC1vIUnxqp4UpVo1hst8qPd1at94CndDYM5rG+7imGbdtxTxzamt819qdTO1OfvtctKawNAm7YXZrrWft6c7gI6j6SI4hxd+ZrrPBqbaRFHkZHjnNssO/yn4SaOHFFzccmu0MzvpPCf0qWZwd3sGHVYer1MnR2mHYqU84QPlW3nrHwJjkrpq3+q0JcBY6GsJs+RskHNtkMTKV05Iz6QUI5YZGwTpuXaRm036SmavjGc4IDlMaYCk/NmbB9BKpthJxLdUpczOHpnjXXHziotWD6cfEnbjU3byfD8HY5WrxSjsNT7SKmXN3hRof7bk985ewQVjGT42O3NbnfnqjQQWr/B7/zFTpLR4f526Bkq12CdCyf5lvrbq+POkLVdJ+uFfR7ds248Ue/jBQy6kM1tWmKF9QiwisFlA84eQ4CW3I93Rp97URv+AQa9zmbD0Ve3Udp+g6nF5I=
|
||||
file: "./build/shlink_${TRAVIS_TAG#?}_dist.zip"
|
||||
skip_cleanup: true
|
||||
on:
|
||||
all_branches: true
|
||||
condition: ${DOCKER_PUBLISH} == 'false'
|
||||
tags: true
|
||||
php: '7.4'
|
||||
- wget https://phar.phpunit.de/phpcov-7.0.2.phar
|
||||
- phpdbg -qrr phpcov-7.0.2.phar merge build --clover build/clover.xml
|
||||
- wget https://scrutinizer-ci.com/ocular.phar
|
||||
- php ocular.phar code-coverage:upload --format=php-clover build/clover.xml
|
||||
|
40
CHANGELOG.md
40
CHANGELOG.md
@ -4,11 +4,49 @@ All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com), and this project adheres to [Semantic Versioning](https://semver.org).
|
||||
|
||||
## 2.3.0 - 2020-08-09
|
||||
|
||||
#### Added
|
||||
|
||||
* [#746](https://github.com/shlinkio/shlink/issues/746) Allowed to configure the kind of redirect you want to use for your short URLs. You can either set:
|
||||
|
||||
* `302` redirects: Default behavior. Visitors always hit the server.
|
||||
* `301` redirects: Better for SEO. Visitors hit the server the first time and then cache the redirect.
|
||||
|
||||
When selecting 301 redirects, you can also configure the time redirects are cached, to mitigate deviations in stats.
|
||||
|
||||
* [#734](https://github.com/shlinkio/shlink/issues/734) Added support to redirect to deeplinks and other links with schemas different from `http` and `https`.
|
||||
* [#709](https://github.com/shlinkio/shlink/issues/709) Added multi-architecture builds for the docker image.
|
||||
|
||||
* [#707](https://github.com/shlinkio/shlink/issues/707) Added `--all` flag to `short-urls:list` command, which will print all existing URLs in one go, with no pagination.
|
||||
|
||||
It has one limitation, though. Because of the way the CLI tooling works, all rows in the table must be loaded in memory. If the amount of URLs is too high, the command may fail due to too much memory usage.
|
||||
|
||||
#### Changed
|
||||
|
||||
* [#508](https://github.com/shlinkio/shlink/issues/508) Added mutation checks to database tests.
|
||||
* [#790](https://github.com/shlinkio/shlink/issues/790) Updated to doctrine/migrations v3.
|
||||
* [#798](https://github.com/shlinkio/shlink/issues/798) Updated to guzzlehttp/guzzle v7.
|
||||
* [#822](https://github.com/shlinkio/shlink/issues/822) Updated docker image to use PHP 7.4.9 with Alpine 3.12 and swoole 4.5.2.
|
||||
|
||||
#### Deprecated
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Removed
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Fixed
|
||||
|
||||
* *Nothing*
|
||||
|
||||
|
||||
## 2.2.2 - 2020-06-08
|
||||
|
||||
#### Added
|
||||
|
||||
* [#709](https://github.com/shlinkio/shlink/issues/709) Added multi-architecture builds for the docker image.
|
||||
* *Nothing*
|
||||
|
||||
#### Changed
|
||||
|
||||
|
136
CONTRIBUTING.md
Normal file
136
CONTRIBUTING.md
Normal file
@ -0,0 +1,136 @@
|
||||
# Contributing
|
||||
|
||||
This file will guide you through the process of getting to project up and running, in case you want to provide coding contributions.
|
||||
|
||||
You will also see how to ensure the code fulfills the expected code checks, and how to create a pull request.
|
||||
|
||||
## System dependencies
|
||||
|
||||
The project provides all its dependencies as docker containers through a docker-compose configuration.
|
||||
|
||||
Because of this, the only actual dependencies are [docker](https://docs.docker.com/get-docker/) and [docker-compose](https://docs.docker.com/compose/install/).
|
||||
|
||||
## Setting up the project
|
||||
|
||||
The first thing you need to do is fork the repository, and clone it in your local machine.
|
||||
|
||||
Then you will have to follow these steps:
|
||||
|
||||
* Copy all files with `.local.php.dist` extension from `config/autoload` by removing the dist extension.
|
||||
|
||||
For example the `common.local.php.dist` file should be copied as `common.local.php`.
|
||||
|
||||
* Copy the file `docker-compose.override.yml.dist` by also removing the `dist` extension.
|
||||
* Start-up the project by running `docker-compose up`.
|
||||
|
||||
The first time this command is run, it will create several containers that are used during development, so it may take some time.
|
||||
|
||||
It will also create some empty databases and install the project dependencies with composer.
|
||||
|
||||
* Run `./indocker bin/cli db:create` to create the initial database.
|
||||
* Run `./indocker bin/cli db:migrate` to get database migrations up to date.
|
||||
* Run `./indocker bin/cli api-key:generate` to get your first API key generated.
|
||||
|
||||
Once you finish this, you will have the project exposed in ports `8000` through nginx+php-fpm and `8080` through swoole.
|
||||
|
||||
> Note: The `indocker` shell script is a helper used to run commands inside the main docker container.
|
||||
|
||||
## Project structure
|
||||
|
||||
This project is structured as a modular application, using [laminas/laminas-config-aggregator](https://github.com/laminas/laminas-config-aggregator) to merge the configuration provided by every module.
|
||||
|
||||
All modules are inside the `module` folder, and each one has its own `src`, `test` and `config` folders, with the source code, tests and configuration. They also have their own `ConfigProvider` class, which is consumed by the config aggregator.
|
||||
|
||||
This is a simplified version of the project structure:
|
||||
|
||||
```
|
||||
shlink
|
||||
├── bin
|
||||
│ ├── cli
|
||||
│ ├── install
|
||||
│ └── update
|
||||
├── config
|
||||
│ ├── autoload
|
||||
│ ├── params
|
||||
│ ├── config.php
|
||||
│ └── container.php
|
||||
├── data
|
||||
│ ├── cache
|
||||
│ ├── locks
|
||||
│ ├── log
|
||||
│ ├── migrations
|
||||
│ └── proxies
|
||||
├── docs
|
||||
│ ├── async-api
|
||||
│ └── swagger
|
||||
├── module
|
||||
│ ├── CLI
|
||||
│ ├── Core
|
||||
│ └── Rest
|
||||
├── public
|
||||
├── composer.json
|
||||
└── README.md
|
||||
```
|
||||
|
||||
The purposes of every folder are:
|
||||
|
||||
* `bin`: It contains the CLI tools. The `cli` one is the main entry point to run shlink from the command line, while `install` and `update` are helper tools used to install and update shlink when not using the docker image.
|
||||
* `config`: Contains application-wide configurations, which are later merged with the ones provided by every module.
|
||||
* `data`: Common runtime-generated git-ignored assets, like logs, caches, etc.
|
||||
* `docs`: Any project documentation is stored here, like API spec definitions.
|
||||
* `module`: Contains a subfolder for every module in the project. Modules contain the source code, tests and configurations for every context in the project.
|
||||
* `public`: Few assets (like `favicon.ico` or `robots.txt`) and the web entry point are stored here. This web entry point is not used when serving the app with swoole.
|
||||
|
||||
## Project tests
|
||||
|
||||
In order to ensure stability and no regressions are introduced while developing new features, this project has different types of tests.
|
||||
|
||||
* **Unit tests**: These are the simplest to run, and usually test individual pieces of code, replacing any external dependency by mocks.
|
||||
|
||||
The code coverage of unit tests is pretty high, and only entity repositories are excluded because of their nature.
|
||||
|
||||
* **Database tests**: These are integration tests that run against a real database, and only cover entity repositories.
|
||||
|
||||
Its purpose is to verify all the database queries behave as expected and return what's expected.
|
||||
|
||||
The project provides some tooling to run them against any of the supported database engines.
|
||||
|
||||
* **API tests**: These are E2E tests that spin up an instance of the app and test it from the outside, by interacting with the REST API.
|
||||
|
||||
These are the best tests to catch regressions, and to verify everything interacts as expected.
|
||||
|
||||
They use MySQL as the database engine, and include some fixtures that ensure the same data exists at the beginning of the execution.
|
||||
|
||||
* **CLI tests**: *TBD. Once included, its purpose will be the same as API tests, but running through the command line*
|
||||
|
||||
Depending on the kind of contribution, maybe not all kinds of tests are needed, but the more you provide, the better.
|
||||
|
||||
## Running code checks
|
||||
|
||||
* Run `./indocker composer cs` to check coding styles are fulfilled.
|
||||
* Run `./indocker composer cs:fix` to fix coding styles (some may not be fixable from the CLI)
|
||||
* Run `./indocker composer stan` to statically analyze the code with [phpstan](https://phpstan.org/). This tool is the closest to "compile" PHP and verify everything would work as expected.
|
||||
* Run `./indocker composer test:unit` to run the unit tests.
|
||||
* Run `./indocker composer test:db` to run the database integration tests.
|
||||
|
||||
This command runs the same test suite against all supported database engines. If you just want to run one of them, you can add one of `:sqlite`, `:mysql`, `:maria`, `:postgres`, `:mssql` at the end of the command.
|
||||
|
||||
For example, `test:db:postgres`.
|
||||
|
||||
* Run `./indocker composer test:api` to run API E2E tests. For these, the MySQL database engine is used.
|
||||
* Run `./indocker composer infect:test` ti run both unit and database tests (over sqlite) and then apply mutations to them with [infection](https://infection.github.io/).
|
||||
* Run `./indocker composer ci` to run all previous commands together. This command is run during the project's continuous integration.
|
||||
|
||||
> Note: Due to some limitations in the tooling used by shlink, the testing databases need to exist beforehand, both for db and api tests (except sqlite).
|
||||
>
|
||||
> However, they just need to be created empty, with no tables. Also, once created, they are automatically reset before every new execution.
|
||||
>
|
||||
> The testing database is always called `shlink_test`. You can create it using the database client of your choice. [DBeaver](https://dbeaver.io/) is a good multi-platform desktop database client which supports all the engines supported by shlink.
|
||||
|
||||
## Pull request process
|
||||
|
||||
In order to provide pull requests to this project, you should always start by creating a new branch, where you will make all desired changes.
|
||||
|
||||
The base branch should always be `develop`, and the target branch for the pull request should also be `develop`.
|
||||
|
||||
Before your branch can be merged, all the checks described in [Running code checks](#running-code-checks) have to be passing. You can verify that manually by running `./indocker composer ci`, or wait for the build to be run automatically after the pull request is created.
|
@ -1,8 +1,8 @@
|
||||
FROM php:7.4.5-alpine3.11 as base
|
||||
FROM php:7.4.9-alpine3.12 as base
|
||||
|
||||
ARG SHLINK_VERSION=2.1.4
|
||||
ARG SHLINK_VERSION=2.2.2
|
||||
ENV SHLINK_VERSION ${SHLINK_VERSION}
|
||||
ENV SWOOLE_VERSION 4.4.18
|
||||
ENV SWOOLE_VERSION 4.5.2
|
||||
ENV LC_ALL "C"
|
||||
|
||||
WORKDIR /etc/shlink
|
||||
|
12
README.md
12
README.md
@ -1,17 +1,19 @@
|
||||
![Shlink](https://raw.githubusercontent.com/shlinkio/shlink.io/master/public/images/shlink-hero.png)
|
||||
![Shlink](https://raw.githubusercontent.com/shlinkio/shlink.io/main/public/images/shlink-hero.png)
|
||||
|
||||
[![Build Status](https://img.shields.io/travis/shlinkio/shlink.svg?style=flat-square)](https://travis-ci.org/shlinkio/shlink)
|
||||
[![Code Coverage](https://img.shields.io/scrutinizer/coverage/g/shlinkio/shlink.svg?style=flat-square)](https://scrutinizer-ci.com/g/shlinkio/shlink/?branch=master)
|
||||
[![Scrutinizer Code Quality](https://img.shields.io/scrutinizer/g/shlinkio/shlink.svg?style=flat-square)](https://scrutinizer-ci.com/g/shlinkio/shlink/?branch=master)
|
||||
[![Code Coverage](https://img.shields.io/scrutinizer/coverage/g/shlinkio/shlink.svg?style=flat-square)](https://scrutinizer-ci.com/g/shlinkio/shlink/)
|
||||
[![Scrutinizer Code Quality](https://img.shields.io/scrutinizer/g/shlinkio/shlink.svg?style=flat-square)](https://scrutinizer-ci.com/g/shlinkio/shlink/)
|
||||
[![Latest Stable Version](https://img.shields.io/github/release/shlinkio/shlink.svg?style=flat-square)](https://packagist.org/packages/shlinkio/shlink)
|
||||
[![Docker pulls](https://img.shields.io/docker/pulls/shlinkio/shlink.svg?style=flat-square)](https://hub.docker.com/r/shlinkio/shlink/)
|
||||
[![License](https://img.shields.io/github/license/shlinkio/shlink.svg?style=flat-square)](https://github.com/shlinkio/shlink/blob/master/LICENSE)
|
||||
[![License](https://img.shields.io/github/license/shlinkio/shlink.svg?style=flat-square)](https://github.com/shlinkio/shlink/blob/main/LICENSE)
|
||||
[![Paypal donate](https://img.shields.io/badge/Donate-paypal-blue.svg?style=flat-square&logo=paypal&colorA=aaaaaa)](https://slnk.to/donate)
|
||||
|
||||
A PHP-based self-hosted URL shortener that can be used to serve shortened URLs under your own custom domain.
|
||||
|
||||
> This document references Shlink 2.x. If you are using an older version and want to upgrade, follow the [UPGRADE](UPGRADE.md) doc.
|
||||
|
||||
> If you are trying to find out how to run the project in development mode or how to provide contributions, read the [CONTRIBUTING](CONTRIBUTING.md) doc.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Installation](#installation)
|
||||
@ -36,7 +38,7 @@ A PHP-based self-hosted URL shortener that can be used to serve shortened URLs u
|
||||
|
||||
First, make sure the host where you are going to run shlink fulfills these requirements:
|
||||
|
||||
* PHP 7.4 or greater with JSON, curl, PDO and gd extensions enabled.
|
||||
* PHP 7.4 or greater with JSON, curl, PDO, intl and gd extensions enabled.
|
||||
* MySQL, MariaDB, PostgreSQL, Microsoft SQL Server or SQLite.
|
||||
* The web server of your choice with PHP integration (Apache or Nginx recommended).
|
||||
|
||||
|
@ -20,11 +20,11 @@
|
||||
"cocur/slugify": "^4.0",
|
||||
"doctrine/cache": "^1.9",
|
||||
"doctrine/dbal": "^2.10",
|
||||
"doctrine/migrations": "^2.2",
|
||||
"doctrine/migrations": "^3.0.1",
|
||||
"doctrine/orm": "^2.7",
|
||||
"endroid/qr-code": "^3.6",
|
||||
"geoip2/geoip2": "^2.9",
|
||||
"guzzlehttp/guzzle": "^6.5.1",
|
||||
"guzzlehttp/guzzle": "^7.0",
|
||||
"laminas/laminas-config": "^3.3",
|
||||
"laminas/laminas-config-aggregator": "^1.1",
|
||||
"laminas/laminas-dependency-plugin": "^1.0",
|
||||
@ -34,6 +34,7 @@
|
||||
"laminas/laminas-servicemanager": "^3.4",
|
||||
"laminas/laminas-stdlib": "^3.2",
|
||||
"lcobucci/jwt": "^4.0@alpha",
|
||||
"league/uri": "^6.2",
|
||||
"lstrojny/functional-php": "^1.9",
|
||||
"mezzio/mezzio": "^3.2",
|
||||
"mezzio/mezzio-fastroute": "^3.0",
|
||||
@ -49,18 +50,17 @@
|
||||
"predis/predis": "^1.1",
|
||||
"pugx/shortid-php": "^0.5",
|
||||
"ramsey/uuid": "^3.9",
|
||||
"shlinkio/shlink-common": "^3.1.0",
|
||||
"shlinkio/shlink-common": "^3.2.0",
|
||||
"shlinkio/shlink-config": "^1.0",
|
||||
"shlinkio/shlink-event-dispatcher": "^1.4",
|
||||
"shlinkio/shlink-installer": "^5.0.0",
|
||||
"shlinkio/shlink-ip-geolocation": "^1.4",
|
||||
"shlinkio/shlink-installer": "^5.1.0",
|
||||
"shlinkio/shlink-ip-geolocation": "^1.5",
|
||||
"symfony/console": "^5.1",
|
||||
"symfony/filesystem": "^5.1",
|
||||
"symfony/lock": "^5.1",
|
||||
"symfony/mercure": "^0.3.0",
|
||||
"symfony/process": "^5.1",
|
||||
"symfony/string": "^5.1",
|
||||
"symfony/translation-contracts": "^2.1"
|
||||
"symfony/string": "^5.1"
|
||||
},
|
||||
"require-dev": {
|
||||
"devster/ubench": "^2.0",
|
||||
@ -71,7 +71,7 @@
|
||||
"phpunit/phpunit": "~9.0.1",
|
||||
"roave/security-advisories": "dev-master",
|
||||
"shlinkio/php-coding-standard": "~2.1.0",
|
||||
"shlinkio/shlink-test-utils": "^1.4",
|
||||
"shlinkio/shlink-test-utils": "^1.5",
|
||||
"symfony/var-dumper": "^5.0"
|
||||
},
|
||||
"autoload": {
|
||||
@ -115,27 +115,31 @@
|
||||
"@test:db"
|
||||
],
|
||||
"test:unit": "phpdbg -qrr vendor/bin/phpunit --order-by=random --colors=always --coverage-php build/coverage-unit.cov --testdox",
|
||||
"test:unit:ci": "@test:unit --coverage-clover=build/clover.xml --coverage-xml=build/coverage-xml --log-junit=build/junit.xml",
|
||||
"test:unit:ci": "@test:unit --coverage-clover=build/clover.xml --coverage-xml=build/coverage-unit/coverage-xml --log-junit=build/coverage-unit/junit.xml",
|
||||
"test:db": [
|
||||
"@test:db:sqlite",
|
||||
"@test:db:sqlite:ci",
|
||||
"@test:db:mysql",
|
||||
"@test:db:maria",
|
||||
"@test:db:postgres",
|
||||
"@test:db:ms"
|
||||
],
|
||||
"test:db:sqlite": "APP_ENV=test phpdbg -qrr vendor/bin/phpunit --order-by=random --colors=always --coverage-php build/coverage-db.cov --testdox -c phpunit-db.xml",
|
||||
"test:db:sqlite": "APP_ENV=test phpdbg -qrr vendor/bin/phpunit --order-by=random --colors=always --testdox -c phpunit-db.xml",
|
||||
"test:db:sqlite:ci": "@test:db:sqlite --coverage-php build/coverage-db.cov --coverage-xml=build/coverage-db/coverage-xml --log-junit=build/coverage-db/junit.xml",
|
||||
"test:db:mysql": "DB_DRIVER=mysql composer test:db:sqlite",
|
||||
"test:db:maria": "DB_DRIVER=maria composer test:db:sqlite",
|
||||
"test:db:postgres": "DB_DRIVER=postgres composer test:db:sqlite",
|
||||
"test:db:ms": "DB_DRIVER=mssql composer test:db:sqlite",
|
||||
"test:api": "bin/test/run-api-tests.sh",
|
||||
"test:api:ci": "@test:api --coverage-php build/coverage-api.cov",
|
||||
"test:unit:pretty": "phpdbg -qrr vendor/bin/phpunit --order-by=random --colors=always --coverage-html build/coverage",
|
||||
"infect": "infection --threads=4 --min-msi=80 --log-verbosity=default --only-covered",
|
||||
"infect:ci": "@infect --coverage=build --skip-initial-tests",
|
||||
"infect:show": "@infect --show-mutations",
|
||||
"infect:ci:base": "@infect --skip-initial-tests",
|
||||
"infect:ci": [
|
||||
"@infect:ci:base --coverage=build/coverage-unit",
|
||||
"@infect:ci:base --coverage=build/coverage-db --test-framework-options=--configuration=phpunit-db.xml"
|
||||
],
|
||||
"infect:test": [
|
||||
"@test:unit:ci",
|
||||
"@test:db:sqlite:ci",
|
||||
"@infect:ci"
|
||||
],
|
||||
"clean:dev": "rm -f data/database.sqlite && rm -f config/params/generated_config.php"
|
||||
@ -158,7 +162,6 @@
|
||||
"test:unit:pretty": "<fg=blue;options=bold>Runs unit test suites and generates an HTML code coverage report</>",
|
||||
"infect": "<fg=blue;options=bold>Checks unit tests quality applying mutation testing</>",
|
||||
"infect:ci": "<fg=blue;options=bold>Checks unit tests quality applying mutation testing with existing reports and logs</>",
|
||||
"infect:show": "<fg=blue;options=bold>Checks unit tests quality applying mutation testing and shows applied mutators</>",
|
||||
"infect:test": "<fg=blue;options=bold>Checks unit tests quality applying mutation testing</>",
|
||||
"clean:dev": "<fg=blue;options=bold>Deletes artifacts which are gitignored and could affect dev env</>"
|
||||
},
|
||||
|
@ -37,6 +37,8 @@ return [
|
||||
Option\Mercure\MercureJwtSecretConfigOption::class,
|
||||
Option\UrlShortener\GeoLiteLicenseKeyConfigOption::class,
|
||||
Option\UrlShortener\IpAnonymizationConfigOption::class,
|
||||
Option\UrlShortener\RedirectStatusCodeConfigOption::class,
|
||||
Option\UrlShortener\RedirectCacheLifeTimeConfigOption::class,
|
||||
],
|
||||
|
||||
'installation_commands' => [
|
||||
|
@ -2,9 +2,6 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Laminas\ServiceManager\Factory\InvokableFactory;
|
||||
use Mezzio\Swoole\HotCodeReload\FileWatcher\InotifyFileWatcher;
|
||||
|
||||
return [
|
||||
|
||||
'mezzio-swoole' => [
|
||||
@ -13,10 +10,4 @@ return [
|
||||
],
|
||||
],
|
||||
|
||||
'dependencies' => [
|
||||
'factories' => [
|
||||
InotifyFileWatcher::class => InvokableFactory::class,
|
||||
],
|
||||
],
|
||||
|
||||
];
|
||||
|
@ -2,6 +2,8 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use const Shlinkio\Shlink\Core\DEFAULT_REDIRECT_CACHE_LIFETIME;
|
||||
use const Shlinkio\Shlink\Core\DEFAULT_REDIRECT_STATUS_CODE;
|
||||
use const Shlinkio\Shlink\Core\DEFAULT_SHORT_CODES_LENGTH;
|
||||
|
||||
return [
|
||||
@ -15,6 +17,8 @@ return [
|
||||
'anonymize_remote_addr' => true,
|
||||
'visits_webhooks' => [],
|
||||
'default_short_codes_length' => DEFAULT_SHORT_CODES_LENGTH,
|
||||
'redirect_status_code' => DEFAULT_REDIRECT_STATUS_CODE,
|
||||
'redirect_cache_lifetime' => DEFAULT_REDIRECT_CACHE_LIFETIME,
|
||||
],
|
||||
|
||||
];
|
||||
|
@ -4,11 +4,10 @@ declare(strict_types=1);
|
||||
|
||||
use Doctrine\ORM\EntityManager;
|
||||
use Doctrine\ORM\Tools\Console\ConsoleRunner;
|
||||
use Laminas\ServiceManager\ServiceManager;
|
||||
use Psr\Container\ContainerInterface;
|
||||
|
||||
return (function () {
|
||||
/** @var ContainerInterface|ServiceManager $container */
|
||||
/** @var ContainerInterface $container */
|
||||
$container = include __DIR__ . '/container.php';
|
||||
$em = $container->get(EntityManager::class);
|
||||
|
||||
|
@ -1,12 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -ex
|
||||
|
||||
# install latest docker version
|
||||
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add -
|
||||
add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable"
|
||||
apt-get update
|
||||
apt-get -y -o Dpkg::Options::="--force-confnew" install docker-ce
|
||||
|
||||
# enable multiarch execution
|
||||
docker run --rm --privileged multiarch/qemu-user-static --reset -p yes
|
@ -1,4 +1,4 @@
|
||||
FROM php:7.4.5-fpm-alpine3.11
|
||||
FROM php:7.4.9-alpine3.12
|
||||
MAINTAINER Alejandro Celaya <alejandro@alejandrocelaya.com>
|
||||
|
||||
ENV APCU_VERSION 5.1.18
|
||||
|
@ -1,10 +1,10 @@
|
||||
FROM php:7.4.5-alpine3.11
|
||||
FROM php:7.4.9-alpine3.12
|
||||
MAINTAINER Alejandro Celaya <alejandro@alejandrocelaya.com>
|
||||
|
||||
ENV APCU_VERSION 5.1.18
|
||||
ENV APCU_BC_VERSION 1.0.5
|
||||
ENV INOTIFY_VERSION 2.0.0
|
||||
ENV SWOOLE_VERSION 4.4.18
|
||||
ENV SWOOLE_VERSION 4.5.2
|
||||
|
||||
RUN apk update
|
||||
|
||||
|
@ -7,7 +7,7 @@ namespace <namespace>;
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version<version> extends AbstractMigration
|
||||
final class <className> extends AbstractMigration
|
||||
{
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
|
@ -174,6 +174,8 @@ This is the complete list of supported env vars:
|
||||
* `MERCURE_INTERNAL_HUB_URL`: An internal URL for a mercure hub. Will be used only when publishing updates to mercure, and does not need to be public. If this is not provided but `MERCURE_PUBLIC_HUB_URL` was, the former one will be used to publish updates.
|
||||
* `MERCURE_JWT_SECRET`: The secret key that was provided to the mercure hub server, in order to be able to generate valid JWTs for publishing/subscribing to that server.
|
||||
* `ANONYMIZE_REMOTE_ADDR`: Tells if IP addresses from visitors should be obfuscated before storing them in the database. Default value is `true`. **Careful!** Setting this to `false` will make your Shlink instance no longer be in compliance with the GDPR and other similar data protection regulations.
|
||||
* `REDIRECT_STATUS_CODE`: Either **301** or **302**. Used to determine if redirects from short to long URLs should be done with a 301 or 302 status. Defaults to 302.
|
||||
* `REDIRECT_CACHE_LIFETIME`: Allows to set the amount of seconds that redirects should be cached when redirect status is 301. Default values is 30.
|
||||
|
||||
An example using all env vars could look like this:
|
||||
|
||||
@ -206,6 +208,8 @@ docker run \
|
||||
-e "MERCURE_INTERNAL_HUB_URL=http://my-mercure-hub.prod.svc.cluster.local" \
|
||||
-e MERCURE_JWT_SECRET=super_secret_key \
|
||||
-e ANONYMIZE_REMOTE_ADDR=false \
|
||||
-e REDIRECT_STATUS_CODE=301 \
|
||||
-e REDIRECT_CACHE_LIFETIME=90 \
|
||||
shlinkio/shlink:stable
|
||||
```
|
||||
|
||||
@ -251,7 +255,9 @@ The whole configuration should have this format, but it can be split into multip
|
||||
"mercure_public_hub_url": "https://example.com",
|
||||
"mercure_internal_hub_url": "http://my-mercure-hub.prod.svc.cluster.local",
|
||||
"mercure_jwt_secret": "super_secret_key",
|
||||
"anonymize_remote_addr": false
|
||||
"anonymize_remote_addr": false,
|
||||
"redirect_status_code": 301,
|
||||
"redirect_cache_lifetime": 90
|
||||
}
|
||||
```
|
||||
|
||||
@ -285,6 +291,6 @@ Versioning on this docker image works as follows:
|
||||
|
||||
* `X.X.X`: when providing a specific version number, the image version will match the shlink version it contains. For example, installing `shlinkio/shlink:1.15.0`, you will get an image containing shlink v1.15.0.
|
||||
* `stable`: always holds the latest stable tag. For example, if latest shlink version is 2.0.0, installing `shlinkio/shlink:stable`, you will get an image containing shlink v2.0.0
|
||||
* `latest`: always holds the latest contents in master, and it's considered unstable and not suitable for production.
|
||||
* `latest`: always holds the latest contents, and it's considered unstable and not suitable for production.
|
||||
|
||||
> **Important**: The docker image was introduced with shlink v1.15.0, so there are no official images previous to that versions.
|
||||
|
30
docker/build
30
docker/build
@ -1,34 +1,24 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
set -ex
|
||||
|
||||
# PLATFORMS="linux/arm/v7,linux/arm64/v8,linux/amd64"
|
||||
PLATFORMS="linux/amd64"
|
||||
PLATFORMS="linux/arm/v7,linux/arm64/v8,linux/amd64"
|
||||
DOCKER_IMAGE="shlinkio/shlink"
|
||||
BUILDX_VER=v0.4.1
|
||||
export DOCKER_CLI_EXPERIMENTAL=enabled
|
||||
|
||||
mkdir -vp ~/.docker/cli-plugins/ ~/dockercache
|
||||
curl --silent -L "https://github.com/docker/buildx/releases/download/${BUILDX_VER}/buildx-${BUILDX_VER}.linux-amd64" > ~/.docker/cli-plugins/docker-buildx
|
||||
chmod a+x ~/.docker/cli-plugins/docker-buildx
|
||||
|
||||
docker buildx create --use
|
||||
|
||||
echo "$DOCKER_PASSWORD" | docker login -u "$DOCKER_USERNAME" --password-stdin
|
||||
|
||||
# If there is a tag, regardless the branch, build that docker tag and also "stable"
|
||||
if [[ ! -z $TRAVIS_TAG ]]; then
|
||||
TAGS="-t ${DOCKER_IMAGE}:${TRAVIS_TAG#?}"
|
||||
# If ref is not develop, then this is a tag. Build that docker tag and also "stable"
|
||||
if [[ "$GITHUB_REF" != *"develop"* ]]; then
|
||||
VERSION=${GITHUB_REF#refs/tags/v}
|
||||
TAGS="-t ${DOCKER_IMAGE}:${VERSION}"
|
||||
# Push stable tag only if this is not an alpha or beta tag
|
||||
[[ $TRAVIS_TAG != *"alpha"* && $TRAVIS_TAG != *"beta"* ]] && TAGS="${TAGS} -t ${DOCKER_IMAGE}:stable"
|
||||
[[ $GITHUB_REF != *"alpha"* && $GITHUB_REF != *"beta"* ]] && TAGS="${TAGS} -t ${DOCKER_IMAGE}:stable"
|
||||
|
||||
docker buildx build --push \
|
||||
--build-arg SHLINK_VERSION=${TRAVIS_TAG#?} \
|
||||
--build-arg SHLINK_VERSION=${VERSION} \
|
||||
--platform ${PLATFORMS} \
|
||||
${TAGS} .
|
||||
|
||||
# If build branch is develop, build latest (on master, when there's no tag, do not build anything)
|
||||
elif [[ "$TRAVIS_BRANCH" == 'develop' ]]; then
|
||||
# If build branch is develop, build latest
|
||||
elif [[ "$GITHUB_REF" == *"develop"* ]]; then
|
||||
docker buildx build --push \
|
||||
--platform ${PLATFORMS} \
|
||||
-t ${DOCKER_IMAGE}:latest .
|
||||
|
@ -11,6 +11,9 @@ use function explode;
|
||||
use function Functional\contains;
|
||||
use function Shlinkio\Shlink\Common\env;
|
||||
|
||||
use const Shlinkio\Shlink\Core\DEFAULT_DELETE_SHORT_URL_THRESHOLD;
|
||||
use const Shlinkio\Shlink\Core\DEFAULT_REDIRECT_CACHE_LIFETIME;
|
||||
use const Shlinkio\Shlink\Core\DEFAULT_REDIRECT_STATUS_CODE;
|
||||
use const Shlinkio\Shlink\Core\DEFAULT_SHORT_CODES_LENGTH;
|
||||
use const Shlinkio\Shlink\Core\MIN_SHORT_CODES_LENGTH;
|
||||
|
||||
@ -104,7 +107,7 @@ return [
|
||||
|
||||
'delete_short_urls' => [
|
||||
'check_visits_threshold' => true,
|
||||
'visits_threshold' => (int) env('DELETE_SHORT_URL_THRESHOLD', 15),
|
||||
'visits_threshold' => (int) env('DELETE_SHORT_URL_THRESHOLD', DEFAULT_DELETE_SHORT_URL_THRESHOLD),
|
||||
],
|
||||
|
||||
'entity_manager' => [
|
||||
@ -120,6 +123,8 @@ return [
|
||||
'anonymize_remote_addr' => (bool) env('ANONYMIZE_REMOTE_ADDR', true),
|
||||
'visits_webhooks' => $helper->getVisitsWebhooks(),
|
||||
'default_short_codes_length' => $helper->getDefaultShortCodesLength(),
|
||||
'redirect_status_code' => (int) env('REDIRECT_STATUS_CODE', DEFAULT_REDIRECT_STATUS_CODE),
|
||||
'redirect_cache_lifetime' => (int) env('REDIRECT_CACHE_LIFETIME', DEFAULT_REDIRECT_CACHE_LIFETIME),
|
||||
],
|
||||
|
||||
'not_found_redirects' => $helper->getNotFoundRedirectsConfig(),
|
||||
|
@ -3,9 +3,13 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'name' => 'ShlinkMigrations',
|
||||
'migrations_namespace' => 'ShlinkMigrations',
|
||||
'table_name' => 'migrations',
|
||||
'migrations_directory' => 'data/migrations',
|
||||
|
||||
'migrations_paths' => [
|
||||
'ShlinkMigrations' => 'data/migrations',
|
||||
],
|
||||
'table_storage' => [
|
||||
'table_name' => 'migrations',
|
||||
],
|
||||
'custom_template' => 'data/migrations_template.txt',
|
||||
|
||||
];
|
||||
|
@ -4,7 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
|
||||
|
||||
use Laminas\Diactoros\Uri;
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCodes;
|
||||
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
|
||||
use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException;
|
||||
@ -128,19 +127,15 @@ class GenerateShortUrlCommand extends Command
|
||||
$shortCodeLength = $input->getOption('shortCodeLength') ?? $this->defaultShortCodeLength;
|
||||
|
||||
try {
|
||||
$shortUrl = $this->urlShortener->urlToShortCode(
|
||||
new Uri($longUrl),
|
||||
$tags,
|
||||
ShortUrlMeta::fromRawData([
|
||||
ShortUrlMetaInputFilter::VALID_SINCE => $input->getOption('validSince'),
|
||||
ShortUrlMetaInputFilter::VALID_UNTIL => $input->getOption('validUntil'),
|
||||
ShortUrlMetaInputFilter::CUSTOM_SLUG => $customSlug,
|
||||
ShortUrlMetaInputFilter::MAX_VISITS => $maxVisits !== null ? (int) $maxVisits : null,
|
||||
ShortUrlMetaInputFilter::FIND_IF_EXISTS => $input->getOption('findIfExists'),
|
||||
ShortUrlMetaInputFilter::DOMAIN => $input->getOption('domain'),
|
||||
ShortUrlMetaInputFilter::SHORT_CODE_LENGTH => $shortCodeLength,
|
||||
]),
|
||||
);
|
||||
$shortUrl = $this->urlShortener->urlToShortCode($longUrl, $tags, ShortUrlMeta::fromRawData([
|
||||
ShortUrlMetaInputFilter::VALID_SINCE => $input->getOption('validSince'),
|
||||
ShortUrlMetaInputFilter::VALID_UNTIL => $input->getOption('validUntil'),
|
||||
ShortUrlMetaInputFilter::CUSTOM_SLUG => $customSlug,
|
||||
ShortUrlMetaInputFilter::MAX_VISITS => $maxVisits !== null ? (int) $maxVisits : null,
|
||||
ShortUrlMetaInputFilter::FIND_IF_EXISTS => $input->getOption('findIfExists'),
|
||||
ShortUrlMetaInputFilter::DOMAIN => $input->getOption('domain'),
|
||||
ShortUrlMetaInputFilter::SHORT_CODE_LENGTH => $shortCodeLength,
|
||||
]));
|
||||
|
||||
$io->writeln([
|
||||
sprintf('Processed long URL: <info>%s</info>', $longUrl),
|
||||
|
@ -11,7 +11,6 @@ use Shlinkio\Shlink\CLI\Util\ShlinkTable;
|
||||
use Shlinkio\Shlink\Common\Paginator\Util\PaginatorUtilsTrait;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlsOrdering;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlsParams;
|
||||
use Shlinkio\Shlink\Core\Paginator\Adapter\ShortUrlRepositoryAdapter;
|
||||
use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface;
|
||||
use Shlinkio\Shlink\Core\Transformer\ShortUrlDataTransformer;
|
||||
use Shlinkio\Shlink\Core\Validation\ShortUrlsParamsInputFilter;
|
||||
@ -61,7 +60,7 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand
|
||||
'page',
|
||||
'p',
|
||||
InputOption::VALUE_REQUIRED,
|
||||
sprintf('The first page to list (%s items per page)', ShortUrlRepositoryAdapter::ITEMS_PER_PAGE),
|
||||
'The first page to list (10 items per page unless "--all" is provided)',
|
||||
'1',
|
||||
)
|
||||
->addOption(
|
||||
@ -82,7 +81,14 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand
|
||||
InputOption::VALUE_REQUIRED,
|
||||
'The field from which we want to order by. Pass ASC or DESC separated by a comma',
|
||||
)
|
||||
->addOption('showTags', null, InputOption::VALUE_NONE, 'Whether to display the tags or not');
|
||||
->addOption('showTags', null, InputOption::VALUE_NONE, 'Whether to display the tags or not')
|
||||
->addOption(
|
||||
'all',
|
||||
'a',
|
||||
InputOption::VALUE_NONE,
|
||||
'Disables pagination and just displays all existing URLs. Caution! If the amount of short URLs is big,'
|
||||
. ' this may end up failing due to memory usage.',
|
||||
);
|
||||
}
|
||||
|
||||
protected function getStartDateDesc(): string
|
||||
@ -104,24 +110,32 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand
|
||||
$tags = $input->getOption('tags');
|
||||
$tags = ! empty($tags) ? explode(',', $tags) : [];
|
||||
$showTags = (bool) $input->getOption('showTags');
|
||||
$all = (bool) $input->getOption('all');
|
||||
$startDate = $this->getDateOption($input, $output, 'startDate');
|
||||
$endDate = $this->getDateOption($input, $output, 'endDate');
|
||||
$orderBy = $this->processOrderBy($input);
|
||||
|
||||
$data = [
|
||||
ShortUrlsParamsInputFilter::SEARCH_TERM => $searchTerm,
|
||||
ShortUrlsParamsInputFilter::TAGS => $tags,
|
||||
ShortUrlsOrdering::ORDER_BY => $orderBy,
|
||||
ShortUrlsParamsInputFilter::START_DATE => $startDate !== null ? $startDate->toAtomString() : null,
|
||||
ShortUrlsParamsInputFilter::END_DATE => $endDate !== null ? $endDate->toAtomString() : null,
|
||||
];
|
||||
|
||||
if ($all) {
|
||||
$data[ShortUrlsParamsInputFilter::ITEMS_PER_PAGE] = -1;
|
||||
}
|
||||
|
||||
do {
|
||||
$result = $this->renderPage($output, $showTags, ShortUrlsParams::fromRawData([
|
||||
ShortUrlsParamsInputFilter::PAGE => $page,
|
||||
ShortUrlsParamsInputFilter::SEARCH_TERM => $searchTerm,
|
||||
ShortUrlsParamsInputFilter::TAGS => $tags,
|
||||
ShortUrlsOrdering::ORDER_BY => $orderBy,
|
||||
ShortUrlsParamsInputFilter::START_DATE => $startDate !== null ? $startDate->toAtomString() : null,
|
||||
ShortUrlsParamsInputFilter::END_DATE => $endDate !== null ? $endDate->toAtomString() : null,
|
||||
]));
|
||||
$data[ShortUrlsParamsInputFilter::PAGE] = $page;
|
||||
$result = $this->renderPage($output, $showTags, ShortUrlsParams::fromRawData($data), $all);
|
||||
$page++;
|
||||
|
||||
$continue = $this->isLastPage($result)
|
||||
? false
|
||||
: $io->confirm(sprintf('Continue with page <options=bold>%s</>?', $page), false);
|
||||
$continue = ! $this->isLastPage($result) && $io->confirm(
|
||||
sprintf('Continue with page <options=bold>%s</>?', $page),
|
||||
false,
|
||||
);
|
||||
} while ($continue);
|
||||
|
||||
$io->newLine();
|
||||
@ -130,7 +144,7 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand
|
||||
return ExitCodes::EXIT_SUCCESS;
|
||||
}
|
||||
|
||||
private function renderPage(OutputInterface $output, bool $showTags, ShortUrlsParams $params): Paginator
|
||||
private function renderPage(OutputInterface $output, bool $showTags, ShortUrlsParams $params, bool $all): Paginator
|
||||
{
|
||||
$result = $this->shortUrlService->listShortUrls($params);
|
||||
|
||||
@ -151,7 +165,7 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand
|
||||
$rows[] = array_values(array_intersect_key($shortUrl, array_flip(self::COLUMNS_WHITELIST)));
|
||||
}
|
||||
|
||||
ShlinkTable::fromOutput($output)->render($headers, $rows, $this->formatCurrentPageMessage(
|
||||
ShlinkTable::fromOutput($output)->render($headers, $rows, $all ? null : $this->formatCurrentPageMessage(
|
||||
$result,
|
||||
'Page %s of %s',
|
||||
));
|
||||
|
@ -52,7 +52,7 @@ class GeolocationDbUpdater implements GeolocationDbUpdaterInterface
|
||||
}
|
||||
|
||||
$meta = $this->geoLiteDbReader->metadata();
|
||||
if ($this->buildIsTooOld($meta->__get('buildEpoch'))) {
|
||||
if ($this->buildIsTooOld($meta->buildEpoch)) {
|
||||
$this->downloadNewDb(true, $mustBeUpdated, $handleProgress);
|
||||
}
|
||||
}
|
||||
|
@ -8,7 +8,6 @@ use PHPUnit\Framework\Assert;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Argument;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Psr\Http\Message\UriInterface;
|
||||
use Shlinkio\Shlink\CLI\Command\ShortUrl\GenerateShortUrlCommand;
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCodes;
|
||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
@ -88,7 +87,7 @@ class GenerateShortUrlCommandTest extends TestCase
|
||||
{
|
||||
$shortUrl = new ShortUrl('');
|
||||
$urlToShortCode = $this->urlShortener->urlToShortCode(
|
||||
Argument::type(UriInterface::class),
|
||||
Argument::type('string'),
|
||||
Argument::that(function (array $tags) {
|
||||
Assert::assertEquals(['foo', 'bar', 'baz', 'boo', 'zar'], $tags);
|
||||
return $tags;
|
||||
|
@ -192,4 +192,22 @@ class ListShortUrlsCommandTest extends TestCase
|
||||
yield [['--orderBy' => 'foo,ASC'], ['foo' => 'ASC']];
|
||||
yield [['--orderBy' => 'bar,DESC'], ['bar' => 'DESC']];
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function requestingAllElementsWillSetItemsPerPage(): void
|
||||
{
|
||||
$listShortUrls = $this->shortUrlService->listShortUrls(ShortUrlsParams::fromRawData([
|
||||
'page' => 1,
|
||||
'searchTerm' => null,
|
||||
'tags' => [],
|
||||
'startDate' => null,
|
||||
'endDate' => null,
|
||||
'orderBy' => null,
|
||||
'itemsPerPage' => -1,
|
||||
]))->willReturn(new Paginator(new ArrayAdapter()));
|
||||
|
||||
$this->commandTester->execute(['--all' => true]);
|
||||
|
||||
$listShortUrls->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
}
|
||||
|
@ -76,6 +76,7 @@ return [
|
||||
Service\ShortUrl\ShortUrlResolver::class,
|
||||
Service\VisitsTracker::class,
|
||||
Options\AppOptions::class,
|
||||
Options\UrlShortenerOptions::class,
|
||||
'Logger_Shlink',
|
||||
],
|
||||
Action\PixelAction::class => [
|
||||
|
@ -6,13 +6,18 @@ namespace Shlinkio\Shlink\Core;
|
||||
|
||||
use Cake\Chronos\Chronos;
|
||||
use DateTimeInterface;
|
||||
use Fig\Http\Message\StatusCodeInterface;
|
||||
use PUGX\Shortid\Factory as ShortIdFactory;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
const DEFAULT_DELETE_SHORT_URL_THRESHOLD = 15;
|
||||
const DEFAULT_SHORT_CODES_LENGTH = 5;
|
||||
const MIN_SHORT_CODES_LENGTH = 4;
|
||||
const DEFAULT_REDIRECT_STATUS_CODE = StatusCodeInterface::STATUS_FOUND;
|
||||
const DEFAULT_REDIRECT_CACHE_LIFETIME = 30;
|
||||
const LOCAL_LOCK_FACTORY = 'Shlinkio\Shlink\LocalLockFactory';
|
||||
const CUSTOM_SLUGS_REGEXP = '/[^A-Za-z0-9._~]+/';
|
||||
|
||||
function generateRandomShortCode(int $length): string
|
||||
{
|
||||
|
@ -5,7 +5,7 @@ declare(strict_types=1);
|
||||
namespace Shlinkio\Shlink\Core\Action;
|
||||
|
||||
use Fig\Http\Message\RequestMethodInterface;
|
||||
use Laminas\Diactoros\Uri;
|
||||
use League\Uri\Uri;
|
||||
use Mezzio\Router\Middleware\ImplicitHeadMiddleware;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
@ -67,14 +67,14 @@ abstract class AbstractTrackingAction implements MiddlewareInterface, RequestMet
|
||||
|
||||
private function buildUrlToRedirectTo(ShortUrl $shortUrl, array $currentQuery, ?string $disableTrackParam): string
|
||||
{
|
||||
$uri = new Uri($shortUrl->getLongUrl());
|
||||
$hardcodedQuery = parse_query($uri->getQuery());
|
||||
$uri = Uri::createFromString($shortUrl->getLongUrl());
|
||||
$hardcodedQuery = parse_query($uri->getQuery() ?? '');
|
||||
if ($disableTrackParam !== null) {
|
||||
unset($currentQuery[$disableTrackParam]);
|
||||
}
|
||||
$mergedQuery = array_merge($hardcodedQuery, $currentQuery);
|
||||
|
||||
return (string) $uri->withQuery(build_query($mergedQuery));
|
||||
return (string) (empty($mergedQuery) ? $uri : $uri->withQuery(build_query($mergedQuery)));
|
||||
}
|
||||
|
||||
private function shouldTrackRequest(ServerRequestInterface $request, array $query, ?string $disableTrackParam): bool
|
||||
|
@ -4,18 +4,41 @@ declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Action;
|
||||
|
||||
use Fig\Http\Message\StatusCodeInterface;
|
||||
use Laminas\Diactoros\Response\RedirectResponse;
|
||||
use Psr\Http\Message\ResponseInterface as Response;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Http\Server\RequestHandlerInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Shlinkio\Shlink\Core\Options;
|
||||
use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface;
|
||||
use Shlinkio\Shlink\Core\Service\VisitsTrackerInterface;
|
||||
|
||||
class RedirectAction extends AbstractTrackingAction
|
||||
use function sprintf;
|
||||
|
||||
class RedirectAction extends AbstractTrackingAction implements StatusCodeInterface
|
||||
{
|
||||
private Options\UrlShortenerOptions $urlShortenerOptions;
|
||||
|
||||
public function __construct(
|
||||
ShortUrlResolverInterface $urlResolver,
|
||||
VisitsTrackerInterface $visitTracker,
|
||||
Options\AppOptions $appOptions,
|
||||
Options\UrlShortenerOptions $urlShortenerOptions,
|
||||
?LoggerInterface $logger = null
|
||||
) {
|
||||
parent::__construct($urlResolver, $visitTracker, $appOptions, $logger);
|
||||
$this->urlShortenerOptions = $urlShortenerOptions;
|
||||
}
|
||||
|
||||
protected function createSuccessResp(string $longUrl): Response
|
||||
{
|
||||
// Return a redirect response to the long URL.
|
||||
// Use a temporary redirect to make sure browsers always hit the server for analytics purposes
|
||||
return new RedirectResponse($longUrl);
|
||||
$statusCode = $this->urlShortenerOptions->redirectStatusCode();
|
||||
$headers = $statusCode === self::STATUS_FOUND ? [] : [
|
||||
'Cache-Control' => sprintf('private,max-age=%s', $this->urlShortenerOptions->redirectCacheLifetime()),
|
||||
];
|
||||
|
||||
return new RedirectResponse($longUrl, $statusCode, $headers);
|
||||
}
|
||||
|
||||
protected function createErrorResp(ServerRequestInterface $request, RequestHandlerInterface $handler): Response
|
||||
|
@ -38,6 +38,8 @@ class SimplifiedConfigParser
|
||||
'mercure_internal_hub_url' => ['mercure', 'internal_hub_url'],
|
||||
'mercure_jwt_secret' => ['mercure', 'jwt_secret'],
|
||||
'anonymize_remote_addr' => ['url_shortener', 'anonymize_remote_addr'],
|
||||
'redirect_status_code' => ['url_shortener', 'redirect_status_code'],
|
||||
'redirect_cache_lifetime' => ['url_shortener', 'redirect_cache_lifetime'],
|
||||
];
|
||||
private const SIMPLIFIED_CONFIG_SIDE_EFFECTS = [
|
||||
'delete_short_url_threshold' => [
|
||||
|
@ -4,41 +4,32 @@ declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Model;
|
||||
|
||||
use Psr\Http\Message\UriInterface;
|
||||
|
||||
final class CreateShortUrlData
|
||||
{
|
||||
private UriInterface $longUrl;
|
||||
private string $longUrl;
|
||||
private array $tags;
|
||||
private ShortUrlMeta $meta;
|
||||
|
||||
public function __construct(
|
||||
UriInterface $longUrl,
|
||||
array $tags = [],
|
||||
?ShortUrlMeta $meta = null
|
||||
) {
|
||||
public function __construct(string $longUrl, array $tags = [], ?ShortUrlMeta $meta = null)
|
||||
{
|
||||
$this->longUrl = $longUrl;
|
||||
$this->tags = $tags;
|
||||
$this->meta = $meta ?? ShortUrlMeta::createEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
*/
|
||||
public function getLongUrl(): UriInterface
|
||||
public function getLongUrl(): string
|
||||
{
|
||||
return $this->longUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array
|
||||
* @return string[]
|
||||
*/
|
||||
public function getTags(): array
|
||||
{
|
||||
return $this->tags;
|
||||
}
|
||||
|
||||
/**
|
||||
*/
|
||||
public function getMeta(): ShortUrlMeta
|
||||
{
|
||||
return $this->meta;
|
||||
|
@ -12,11 +12,14 @@ use function Shlinkio\Shlink\Core\parseDateField;
|
||||
|
||||
final class ShortUrlsParams
|
||||
{
|
||||
public const DEFAULT_ITEMS_PER_PAGE = 10;
|
||||
|
||||
private int $page;
|
||||
private ?string $searchTerm;
|
||||
private array $tags;
|
||||
private ShortUrlsOrdering $orderBy;
|
||||
private ?DateRange $dateRange;
|
||||
private ?int $itemsPerPage = null;
|
||||
|
||||
private function __construct()
|
||||
{
|
||||
@ -56,6 +59,9 @@ final class ShortUrlsParams
|
||||
parseDateField($inputFilter->getValue(ShortUrlsParamsInputFilter::END_DATE)),
|
||||
);
|
||||
$this->orderBy = ShortUrlsOrdering::fromRawData($query);
|
||||
$this->itemsPerPage = (int) (
|
||||
$inputFilter->getValue(ShortUrlsParamsInputFilter::ITEMS_PER_PAGE) ?? self::DEFAULT_ITEMS_PER_PAGE
|
||||
);
|
||||
}
|
||||
|
||||
public function page(): int
|
||||
@ -63,6 +69,11 @@ final class ShortUrlsParams
|
||||
return $this->page;
|
||||
}
|
||||
|
||||
public function itemsPerPage(): int
|
||||
{
|
||||
return $this->itemsPerPage;
|
||||
}
|
||||
|
||||
public function searchTerm(): ?string
|
||||
{
|
||||
return $this->searchTerm;
|
||||
|
@ -6,9 +6,11 @@ namespace Shlinkio\Shlink\Core\Options;
|
||||
|
||||
use Laminas\Stdlib\AbstractOptions;
|
||||
|
||||
use const Shlinkio\Shlink\Core\DEFAULT_DELETE_SHORT_URL_THRESHOLD;
|
||||
|
||||
class DeleteShortUrlsOptions extends AbstractOptions
|
||||
{
|
||||
private int $visitsThreshold = 15;
|
||||
private int $visitsThreshold = DEFAULT_DELETE_SHORT_URL_THRESHOLD;
|
||||
private bool $checkVisitsThreshold = true;
|
||||
|
||||
public function getVisitsThreshold(): int
|
||||
|
@ -6,20 +6,53 @@ namespace Shlinkio\Shlink\Core\Options;
|
||||
|
||||
use Laminas\Stdlib\AbstractOptions;
|
||||
|
||||
use function Functional\contains;
|
||||
|
||||
use const Shlinkio\Shlink\Core\DEFAULT_REDIRECT_CACHE_LIFETIME;
|
||||
use const Shlinkio\Shlink\Core\DEFAULT_REDIRECT_STATUS_CODE;
|
||||
|
||||
class UrlShortenerOptions extends AbstractOptions
|
||||
{
|
||||
protected $__strictMode__ = false; // phpcs:ignore
|
||||
|
||||
private bool $validateUrl = true;
|
||||
private int $redirectStatusCode = DEFAULT_REDIRECT_STATUS_CODE;
|
||||
private int $redirectCacheLifetime = DEFAULT_REDIRECT_CACHE_LIFETIME;
|
||||
|
||||
public function isUrlValidationEnabled(): bool
|
||||
{
|
||||
return $this->validateUrl;
|
||||
}
|
||||
|
||||
protected function setValidateUrl(bool $validateUrl): self
|
||||
protected function setValidateUrl(bool $validateUrl): void
|
||||
{
|
||||
$this->validateUrl = $validateUrl;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function redirectStatusCode(): int
|
||||
{
|
||||
return $this->redirectStatusCode;
|
||||
}
|
||||
|
||||
protected function setRedirectStatusCode(int $redirectStatusCode): void
|
||||
{
|
||||
$this->redirectStatusCode = $this->normalizeRedirectStatusCode($redirectStatusCode);
|
||||
}
|
||||
|
||||
private function normalizeRedirectStatusCode(int $statusCode): int
|
||||
{
|
||||
return contains([301, 302], $statusCode) ? $statusCode : DEFAULT_REDIRECT_STATUS_CODE;
|
||||
}
|
||||
|
||||
public function redirectCacheLifetime(): int
|
||||
{
|
||||
return $this->redirectCacheLifetime;
|
||||
}
|
||||
|
||||
protected function setRedirectCacheLifetime(int $redirectCacheLifetime): void
|
||||
{
|
||||
$this->redirectCacheLifetime = $redirectCacheLifetime > 0
|
||||
? $redirectCacheLifetime
|
||||
: DEFAULT_REDIRECT_CACHE_LIFETIME;
|
||||
}
|
||||
}
|
||||
|
@ -10,8 +10,6 @@ use Shlinkio\Shlink\Core\Repository\ShortUrlRepositoryInterface;
|
||||
|
||||
class ShortUrlRepositoryAdapter implements AdapterInterface
|
||||
{
|
||||
public const ITEMS_PER_PAGE = 10;
|
||||
|
||||
private ShortUrlRepositoryInterface $repository;
|
||||
private ShortUrlsParams $params;
|
||||
|
||||
|
@ -44,7 +44,7 @@ class ShortUrlService implements ShortUrlServiceInterface
|
||||
/** @var ShortUrlRepository $repo */
|
||||
$repo = $this->em->getRepository(ShortUrl::class);
|
||||
$paginator = new Paginator(new ShortUrlRepositoryAdapter($repo, $params));
|
||||
$paginator->setItemCountPerPage(ShortUrlRepositoryAdapter::ITEMS_PER_PAGE)
|
||||
$paginator->setItemCountPerPage($params->itemsPerPage())
|
||||
->setCurrentPageNumber($params->page());
|
||||
|
||||
return $paginator;
|
||||
|
@ -5,7 +5,6 @@ declare(strict_types=1);
|
||||
namespace Shlinkio\Shlink\Core\Service;
|
||||
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Psr\Http\Message\UriInterface;
|
||||
use Shlinkio\Shlink\Core\Domain\Resolver\DomainResolverInterface;
|
||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
|
||||
@ -42,10 +41,8 @@ class UrlShortener implements UrlShortenerInterface
|
||||
* @throws InvalidUrlException
|
||||
* @throws Throwable
|
||||
*/
|
||||
public function urlToShortCode(UriInterface $url, array $tags, ShortUrlMeta $meta): ShortUrl
|
||||
public function urlToShortCode(string $url, array $tags, ShortUrlMeta $meta): ShortUrl
|
||||
{
|
||||
$url = (string) $url;
|
||||
|
||||
// First, check if a short URL exists for all provided params
|
||||
$existingShortUrl = $this->findExistingShortUrlIfExists($url, $tags, $meta);
|
||||
if ($existingShortUrl !== null) {
|
||||
|
@ -4,7 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Service;
|
||||
|
||||
use Psr\Http\Message\UriInterface;
|
||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
|
||||
use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException;
|
||||
@ -17,5 +16,5 @@ interface UrlShortenerInterface
|
||||
* @throws NonUniqueSlugException
|
||||
* @throws InvalidUrlException
|
||||
*/
|
||||
public function urlToShortCode(UriInterface $url, array $tags, ShortUrlMeta $meta): ShortUrl;
|
||||
public function urlToShortCode(string $url, array $tags, ShortUrlMeta $meta): ShortUrl;
|
||||
}
|
||||
|
@ -37,6 +37,7 @@ class UrlValidator implements UrlValidatorInterface, RequestMethodInterface
|
||||
try {
|
||||
$this->httpClient->request(self::METHOD_GET, $url, [
|
||||
RequestOptions::ALLOW_REDIRECTS => ['max' => self::MAX_REDIRECTS],
|
||||
RequestOptions::IDN_CONVERSION => true,
|
||||
]);
|
||||
} catch (GuzzleException $e) {
|
||||
throw InvalidUrlException::fromUrl($url, $e);
|
||||
|
@ -12,6 +12,7 @@ use Laminas\Validator;
|
||||
use Shlinkio\Shlink\Common\Validation;
|
||||
use Shlinkio\Shlink\Core\Util\CocurSymfonySluggerBridge;
|
||||
|
||||
use const Shlinkio\Shlink\Core\CUSTOM_SLUGS_REGEXP;
|
||||
use const Shlinkio\Shlink\Core\MIN_SHORT_CODES_LENGTH;
|
||||
|
||||
class ShortUrlMetaInputFilter extends InputFilter
|
||||
@ -49,8 +50,8 @@ class ShortUrlMetaInputFilter extends InputFilter
|
||||
// empty, is by using the deprecated setContinueIfEmpty
|
||||
$customSlug = $this->createInput(self::CUSTOM_SLUG, false)->setContinueIfEmpty(true);
|
||||
$customSlug->getFilterChain()->attach(new Validation\SluggerFilter(new CocurSymfonySluggerBridge(new Slugify([
|
||||
'regexp' => '/[^A-Za-z0-9._~]+/',
|
||||
'lowercase' => false,
|
||||
'regexp' => CUSTOM_SLUGS_REGEXP,
|
||||
'lowercase' => false, // We want to keep it case sensitive
|
||||
]))));
|
||||
$customSlug->getValidatorChain()->attach(new Validator\NotEmpty([
|
||||
Validator\NotEmpty::STRING,
|
||||
|
@ -5,10 +5,13 @@ declare(strict_types=1);
|
||||
namespace Shlinkio\Shlink\Core\Validation;
|
||||
|
||||
use Laminas\Filter;
|
||||
use Laminas\InputFilter\Input;
|
||||
use Laminas\InputFilter\InputFilter;
|
||||
use Laminas\Validator;
|
||||
use Shlinkio\Shlink\Common\Validation;
|
||||
|
||||
use function is_numeric;
|
||||
|
||||
class ShortUrlsParamsInputFilter extends InputFilter
|
||||
{
|
||||
use Validation\InputFactoryTrait;
|
||||
@ -18,6 +21,7 @@ class ShortUrlsParamsInputFilter extends InputFilter
|
||||
public const TAGS = 'tags';
|
||||
public const START_DATE = 'startDate';
|
||||
public const END_DATE = 'endDate';
|
||||
public const ITEMS_PER_PAGE = 'itemsPerPage';
|
||||
|
||||
public function __construct(array $data)
|
||||
{
|
||||
@ -32,14 +36,22 @@ class ShortUrlsParamsInputFilter extends InputFilter
|
||||
|
||||
$this->add($this->createInput(self::SEARCH_TERM, false));
|
||||
|
||||
$page = $this->createInput(self::PAGE, false);
|
||||
$page->getValidatorChain()->attach(new Validator\Digits())
|
||||
->attach(new Validator\GreaterThan(['min' => 1, 'inclusive' => true]));
|
||||
$this->add($page);
|
||||
$this->add($this->createNumericInput(self::PAGE, 1));
|
||||
|
||||
$tags = $this->createArrayInput(self::TAGS, false);
|
||||
$tags->getFilterChain()->attach(new Filter\StringToLower())
|
||||
->attach(new Filter\PregReplace(['pattern' => '/ /', 'replacement' => '-']));
|
||||
$this->add($tags);
|
||||
|
||||
$this->add($this->createNumericInput(self::ITEMS_PER_PAGE, -1));
|
||||
}
|
||||
|
||||
private function createNumericInput(string $name, int $min): Input
|
||||
{
|
||||
$input = $this->createInput($name, false);
|
||||
$input->getValidatorChain()->attach(new Validator\Callback(fn ($value) => is_numeric($value)))
|
||||
->attach(new Validator\GreaterThan(['min' => $min, 'inclusive' => true]));
|
||||
|
||||
return $input;
|
||||
}
|
||||
}
|
||||
|
@ -1,7 +1,7 @@
|
||||
<?php $this->layout('ShlinkCore::layout/default') ?>
|
||||
|
||||
<?php $this->start('title') ?>
|
||||
URL Not Found
|
||||
Not Found
|
||||
<?php $this->end() ?>
|
||||
|
||||
<?php $this->start('stylesheets') ?>
|
||||
|
@ -1,7 +1,7 @@
|
||||
<?php $this->layout('ShlinkCore::layout/default') ?>
|
||||
|
||||
<?php $this->start('title') ?>
|
||||
Invalid URL
|
||||
Invalid Short URL
|
||||
<?php $this->end() ?>
|
||||
|
||||
<?php $this->start('stylesheets') ?>
|
||||
|
@ -1,7 +1,7 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title><?= $this->section('title', '') ?> | URL shortener</title>
|
||||
<title><?= $this->section('title', '') ?> | Shlink</title>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
|
@ -27,16 +27,19 @@ class RedirectActionTest extends TestCase
|
||||
private RedirectAction $action;
|
||||
private ObjectProphecy $urlResolver;
|
||||
private ObjectProphecy $visitTracker;
|
||||
private Options\UrlShortenerOptions $shortenerOpts;
|
||||
|
||||
public function setUp(): void
|
||||
{
|
||||
$this->urlResolver = $this->prophesize(ShortUrlResolverInterface::class);
|
||||
$this->visitTracker = $this->prophesize(VisitsTrackerInterface::class);
|
||||
$this->shortenerOpts = new Options\UrlShortenerOptions();
|
||||
|
||||
$this->action = new RedirectAction(
|
||||
$this->urlResolver->reveal(),
|
||||
$this->visitTracker->reveal(),
|
||||
new Options\AppOptions(['disableTrackParam' => 'foobar']),
|
||||
$this->shortenerOpts,
|
||||
);
|
||||
}
|
||||
|
||||
@ -48,8 +51,9 @@ class RedirectActionTest extends TestCase
|
||||
{
|
||||
$shortCode = 'abc123';
|
||||
$shortUrl = new ShortUrl('http://domain.com/foo/bar?some=thing');
|
||||
$shortCodeToUrl = $this->urlResolver->resolveEnabledShortUrl(new ShortUrlIdentifier($shortCode, ''))
|
||||
->willReturn($shortUrl);
|
||||
$shortCodeToUrl = $this->urlResolver->resolveEnabledShortUrl(
|
||||
new ShortUrlIdentifier($shortCode, ''),
|
||||
)->willReturn($shortUrl);
|
||||
$track = $this->visitTracker->track(Argument::cetera())->will(function (): void {
|
||||
});
|
||||
|
||||
@ -110,4 +114,40 @@ class RedirectActionTest extends TestCase
|
||||
|
||||
$track->shouldNotHaveBeenCalled();
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* @dataProvider provideRedirectConfigs
|
||||
*/
|
||||
public function expectedStatusCodeAndCacheIsReturnedBasedOnConfig(
|
||||
int $configuredStatus,
|
||||
int $configuredLifetime,
|
||||
int $expectedStatus,
|
||||
?string $expectedCacheControl
|
||||
): void {
|
||||
$this->shortenerOpts->redirectStatusCode = $configuredStatus;
|
||||
$this->shortenerOpts->redirectCacheLifetime = $configuredLifetime;
|
||||
|
||||
$shortUrl = new ShortUrl('http://domain.com/foo/bar');
|
||||
$shortCode = $shortUrl->getShortCode();
|
||||
$this->urlResolver->resolveEnabledShortUrl(Argument::cetera())->willReturn($shortUrl);
|
||||
|
||||
$request = (new ServerRequest())->withAttribute('shortCode', $shortCode);
|
||||
$response = $this->action->process($request, $this->prophesize(RequestHandlerInterface::class)->reveal());
|
||||
|
||||
$this->assertInstanceOf(Response\RedirectResponse::class, $response);
|
||||
$this->assertEquals($expectedStatus, $response->getStatusCode());
|
||||
$this->assertEquals($response->hasHeader('Cache-Control'), $expectedCacheControl !== null);
|
||||
$this->assertEquals($response->getHeaderLine('Cache-Control'), $expectedCacheControl ?? '');
|
||||
}
|
||||
|
||||
public function provideRedirectConfigs(): iterable
|
||||
{
|
||||
yield 'status 302' => [302, 20, 302, null];
|
||||
yield 'status over 302' => [400, 20, 302, null];
|
||||
yield 'status below 301' => [201, 20, 302, null];
|
||||
yield 'status 301 with valid expiration' => [301, 20, 301, 'private,max-age=20'];
|
||||
yield 'status 301 with zero expiration' => [301, 0, 301, 'private,max-age=30'];
|
||||
yield 'status 301 with negative expiration' => [301, -20, 301, 'private,max-age=30'];
|
||||
}
|
||||
}
|
||||
|
@ -65,6 +65,8 @@ class SimplifiedConfigParserTest extends TestCase
|
||||
'mercure_internal_hub_url' => 'internal_url',
|
||||
'mercure_jwt_secret' => 'super_secret_value',
|
||||
'anonymize_remote_addr' => false,
|
||||
'redirect_status_code' => 301,
|
||||
'redirect_cache_lifetime' => 90,
|
||||
];
|
||||
$expected = [
|
||||
'app_options' => [
|
||||
@ -94,6 +96,8 @@ class SimplifiedConfigParserTest extends TestCase
|
||||
],
|
||||
'default_short_codes_length' => 8,
|
||||
'anonymize_remote_addr' => false,
|
||||
'redirect_status_code' => 301,
|
||||
'redirect_cache_lifetime' => 90,
|
||||
],
|
||||
|
||||
'delete_short_urls' => [
|
||||
|
@ -9,7 +9,6 @@ use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Doctrine\ORM\ORMException;
|
||||
use Laminas\Diactoros\Uri;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Argument;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
@ -65,7 +64,7 @@ class UrlShortenerTest extends TestCase
|
||||
public function urlIsProperlyShortened(): void
|
||||
{
|
||||
$shortUrl = $this->urlShortener->urlToShortCode(
|
||||
new Uri('http://foobar.com/12345/hello?foo=bar'),
|
||||
'http://foobar.com/12345/hello?foo=bar',
|
||||
[],
|
||||
ShortUrlMeta::createEmpty(),
|
||||
);
|
||||
@ -89,7 +88,7 @@ class UrlShortenerTest extends TestCase
|
||||
$getRepo = $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal());
|
||||
|
||||
$shortUrl = $this->urlShortener->urlToShortCode(
|
||||
new Uri('http://foobar.com/12345/hello?foo=bar'),
|
||||
'http://foobar.com/12345/hello?foo=bar',
|
||||
[],
|
||||
ShortUrlMeta::createEmpty(),
|
||||
);
|
||||
@ -112,7 +111,7 @@ class UrlShortenerTest extends TestCase
|
||||
|
||||
$this->expectException(ORMException::class);
|
||||
$this->urlShortener->urlToShortCode(
|
||||
new Uri('http://foobar.com/12345/hello?foo=bar'),
|
||||
'http://foobar.com/12345/hello?foo=bar',
|
||||
[],
|
||||
ShortUrlMeta::createEmpty(),
|
||||
);
|
||||
@ -131,7 +130,7 @@ class UrlShortenerTest extends TestCase
|
||||
$this->expectException(NonUniqueSlugException::class);
|
||||
|
||||
$this->urlShortener->urlToShortCode(
|
||||
new Uri('http://foobar.com/12345/hello?foo=bar'),
|
||||
'http://foobar.com/12345/hello?foo=bar',
|
||||
[],
|
||||
ShortUrlMeta::fromRawData(['customSlug' => 'custom-slug']),
|
||||
);
|
||||
@ -151,7 +150,7 @@ class UrlShortenerTest extends TestCase
|
||||
$findExisting = $repo->findBy(Argument::any())->willReturn([$expected]);
|
||||
$getRepo = $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal());
|
||||
|
||||
$result = $this->urlShortener->urlToShortCode(new Uri($url), $tags, $meta);
|
||||
$result = $this->urlShortener->urlToShortCode($url, $tags, $meta);
|
||||
|
||||
$findExisting->shouldHaveBeenCalledOnce();
|
||||
$getRepo->shouldHaveBeenCalledOnce();
|
||||
@ -235,7 +234,7 @@ class UrlShortenerTest extends TestCase
|
||||
]);
|
||||
$getRepo = $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal());
|
||||
|
||||
$result = $this->urlShortener->urlToShortCode(new Uri($url), $tags, $meta);
|
||||
$result = $this->urlShortener->urlToShortCode($url, $tags, $meta);
|
||||
|
||||
$this->assertSame($expected, $result);
|
||||
$findExisting->shouldHaveBeenCalledOnce();
|
||||
|
@ -48,7 +48,10 @@ class UrlValidatorTest extends TestCase
|
||||
$request = $this->httpClient->request(
|
||||
RequestMethodInterface::METHOD_GET,
|
||||
$expectedUrl,
|
||||
[RequestOptions::ALLOW_REDIRECTS => ['max' => 15]],
|
||||
[
|
||||
RequestOptions::ALLOW_REDIRECTS => ['max' => 15],
|
||||
RequestOptions::IDN_CONVERSION => true,
|
||||
],
|
||||
)->willReturn(new Response());
|
||||
|
||||
$this->urlValidator->validateUrl($expectedUrl);
|
||||
|
@ -4,7 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Rest\Action\ShortUrl;
|
||||
|
||||
use Laminas\Diactoros\Uri;
|
||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||
use Shlinkio\Shlink\Core\Exception\ValidationException;
|
||||
use Shlinkio\Shlink\Core\Model\CreateShortUrlData;
|
||||
@ -28,6 +27,6 @@ class CreateShortUrlAction extends AbstractCreateShortUrlAction
|
||||
}
|
||||
|
||||
$meta = ShortUrlMeta::fromRawData($postData);
|
||||
return new CreateShortUrlData(new Uri($postData['longUrl']), (array) ($postData['tags'] ?? []), $meta);
|
||||
return new CreateShortUrlData($postData['longUrl'], (array) ($postData['tags'] ?? []), $meta);
|
||||
}
|
||||
}
|
||||
|
@ -4,7 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Rest\Action\ShortUrl;
|
||||
|
||||
use Laminas\Diactoros\Uri;
|
||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||
use Shlinkio\Shlink\Core\Exception\ValidationException;
|
||||
use Shlinkio\Shlink\Core\Model\CreateShortUrlData;
|
||||
@ -46,6 +45,6 @@ class SingleStepCreateShortUrlAction extends AbstractCreateShortUrlAction
|
||||
]);
|
||||
}
|
||||
|
||||
return new CreateShortUrlData(new Uri($query['longUrl']));
|
||||
return new CreateShortUrlData($query['longUrl']);
|
||||
}
|
||||
}
|
||||
|
@ -19,12 +19,6 @@ use function trim;
|
||||
|
||||
class BodyParserMiddleware implements MiddlewareInterface, RequestMethodInterface
|
||||
{
|
||||
/**
|
||||
* Process an incoming server request and return a response, optionally delegating
|
||||
* to the next middleware component to create the response.
|
||||
*
|
||||
*
|
||||
*/
|
||||
public function process(Request $request, RequestHandlerInterface $handler): Response
|
||||
{
|
||||
$method = $request->getMethod();
|
||||
@ -51,8 +45,6 @@ class BodyParserMiddleware implements MiddlewareInterface, RequestMethodInterfac
|
||||
return $handler->handle($this->parseFromUrlEncoded($request));
|
||||
}
|
||||
|
||||
/**
|
||||
*/
|
||||
private function getRequestContentType(Request $request): string
|
||||
{
|
||||
$contentType = $request->getHeaderLine('Content-type');
|
||||
@ -60,8 +52,6 @@ class BodyParserMiddleware implements MiddlewareInterface, RequestMethodInterfac
|
||||
return trim(array_shift($contentTypes));
|
||||
}
|
||||
|
||||
/**
|
||||
*/
|
||||
private function parseFromJson(Request $request): Request
|
||||
{
|
||||
$rawBody = (string) $request->getBody();
|
||||
@ -74,6 +64,7 @@ class BodyParserMiddleware implements MiddlewareInterface, RequestMethodInterfac
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated To be removed on Shlink v3.0.0, supporting only JSON requests.
|
||||
*/
|
||||
private function parseFromUrlEncoded(Request $request): Request
|
||||
{
|
||||
|
@ -7,7 +7,6 @@ namespace ShlinkioTest\Shlink\Rest\Action\ShortUrl;
|
||||
use Cake\Chronos\Chronos;
|
||||
use Laminas\Diactoros\ServerRequest;
|
||||
use Laminas\Diactoros\ServerRequestFactory;
|
||||
use Laminas\Diactoros\Uri;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Argument;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
@ -50,7 +49,7 @@ class CreateShortUrlActionTest extends TestCase
|
||||
{
|
||||
$shortUrl = new ShortUrl('');
|
||||
$shorten = $this->urlShortener->urlToShortCode(
|
||||
Argument::type(Uri::class),
|
||||
Argument::type('string'),
|
||||
Argument::type('array'),
|
||||
$expectedMeta,
|
||||
)->willReturn($shortUrl);
|
||||
|
@ -9,7 +9,6 @@ use PHPUnit\Framework\Assert;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Argument;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Psr\Http\Message\UriInterface;
|
||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Exception\ValidationException;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
|
||||
@ -71,8 +70,8 @@ class SingleStepCreateShortUrlActionTest extends TestCase
|
||||
]);
|
||||
$findApiKey = $this->apiKeyService->check('abc123')->willReturn(true);
|
||||
$generateShortCode = $this->urlShortener->urlToShortCode(
|
||||
Argument::that(function (UriInterface $argument) {
|
||||
Assert::assertEquals('http://foobar.com', (string) $argument);
|
||||
Argument::that(function (string $argument): string {
|
||||
Assert::assertEquals('http://foobar.com', $argument);
|
||||
return $argument;
|
||||
}),
|
||||
[],
|
||||
|
@ -2,6 +2,6 @@ parameters:
|
||||
checkMissingIterableValueType: false
|
||||
checkGenericClassInNonGenericObjectType: false
|
||||
ignoreErrors:
|
||||
- '#AbstractQuery::setParameters()#'
|
||||
- '#mustRun()#'
|
||||
- '#AbstractQuery::setParameters\(\)#'
|
||||
- '#mustRun\(\)#'
|
||||
- '#AssociationBuilder::setOrderBy#'
|
||||
|
Loading…
Reference in New Issue
Block a user