Repository: thedevs-network/kutt Branch: main Commit: fc832d7e7243 Files: 209 Total size: 550.6 KB Directory structure: gitextract_5ryo2i8m/ ├── .dockerignore ├── .example.env ├── .github/ │ └── workflows/ │ ├── docker-build-development.yaml │ ├── docker-build-latest.yaml │ └── docker-build-release.yaml ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── custom/ │ └── .gitkeep ├── db/ │ └── .gitkeep ├── docker-compose.mariadb.yml ├── docker-compose.postgres.yml ├── docker-compose.sqlite-redis.yml ├── docker-compose.yml ├── docs/ │ └── api/ │ ├── api.js │ └── generate.js ├── jsconfig.json ├── knexfile.js ├── package.json ├── server/ │ ├── consts.js │ ├── cron.js │ ├── env.js │ ├── handlers/ │ │ ├── auth.handler.js │ │ ├── domains.handler.js │ │ ├── helpers.handler.js │ │ ├── links.handler.js │ │ ├── locals.handler.js │ │ ├── renders.handler.js │ │ ├── users.handler.js │ │ └── validators.handler.js │ ├── knex.js │ ├── mail/ │ │ ├── index.js │ │ ├── mail.js │ │ ├── template-change-email.html │ │ ├── template-reset.html │ │ ├── template-verify.html │ │ └── text.js │ ├── migrations/ │ │ ├── 20200211220920_constraints.js │ │ ├── 20200510140704_domains.js │ │ ├── 20200718124944_description.js │ │ ├── 20200730203154_expire_in.js │ │ ├── 20200810195255_change_email.js │ │ ├── 20241103083933_user-roles.js │ │ ├── 20241223062111_indexes.js │ │ ├── 20241223103044_visits_user_id.js │ │ ├── 20241223155527_visits_user_id_index.js │ │ └── 20250106070444_remove_cooldown.js │ ├── models/ │ │ ├── domain.model.js │ │ ├── host.model.js │ │ ├── index.js │ │ ├── ip.model.js │ │ ├── link.model.js │ │ ├── user.model.js │ │ └── visit.model.js │ ├── passport.js │ ├── queries/ │ │ ├── domain.queries.js │ │ ├── host.queries.js │ │ ├── index.js │ │ ├── link.queries.js │ │ ├── user.queries.js │ │ └── visit.queries.js │ ├── queues/ │ │ ├── index.js │ │ ├── queues.js │ │ └── visit.js │ ├── redis.js │ ├── routes/ │ │ ├── auth.routes.js │ │ ├── domain.routes.js │ │ ├── health.routes.js │ │ ├── index.js │ │ ├── link.routes.js │ │ ├── renders.routes.js │ │ ├── routes.js │ │ └── user.routes.js │ ├── server.js │ ├── utils/ │ │ ├── asyncHandler.js │ │ ├── index.js │ │ ├── knex.js │ │ ├── map.json │ │ └── utils.js │ └── views/ │ ├── 404.hbs │ ├── admin.hbs │ ├── banned.hbs │ ├── create_admin.hbs │ ├── error.hbs │ ├── homepage.hbs │ ├── layout.hbs │ ├── login.hbs │ ├── logout.hbs │ ├── partials/ │ │ ├── admin/ │ │ │ ├── dialog/ │ │ │ │ ├── add_domain.hbs │ │ │ │ ├── add_domain_success.hbs │ │ │ │ ├── ban_domain.hbs │ │ │ │ ├── ban_domain_success.hbs │ │ │ │ ├── ban_user.hbs │ │ │ │ ├── ban_user_success.hbs │ │ │ │ ├── create_user.hbs │ │ │ │ ├── create_user_success.hbs │ │ │ │ ├── delete_domain.hbs │ │ │ │ ├── delete_domain_success.hbs │ │ │ │ ├── delete_user.hbs │ │ │ │ ├── delete_user_success.hbs │ │ │ │ ├── frame.hbs │ │ │ │ └── mesasge.hbs │ │ │ ├── domains/ │ │ │ │ ├── actions.hbs │ │ │ │ ├── loading.hbs │ │ │ │ ├── table.hbs │ │ │ │ ├── tbody.hbs │ │ │ │ ├── tfoot.hbs │ │ │ │ ├── thead.hbs │ │ │ │ └── tr.hbs │ │ │ ├── index.hbs │ │ │ ├── links/ │ │ │ │ ├── actions.hbs │ │ │ │ ├── edit.hbs │ │ │ │ ├── loading.hbs │ │ │ │ ├── table.hbs │ │ │ │ ├── tbody.hbs │ │ │ │ ├── tfoot.hbs │ │ │ │ ├── thead.hbs │ │ │ │ └── tr.hbs │ │ │ ├── table_nav.hbs │ │ │ ├── table_tab.hbs │ │ │ └── users/ │ │ │ ├── actions.hbs │ │ │ ├── loading.hbs │ │ │ ├── table.hbs │ │ │ ├── tbody.hbs │ │ │ ├── tfoot.hbs │ │ │ ├── thead.hbs │ │ │ └── tr.hbs │ │ ├── auth/ │ │ │ ├── form.hbs │ │ │ ├── form_admin.hbs │ │ │ ├── login_disabled.hbs │ │ │ ├── verify.hbs │ │ │ └── welcome.hbs │ │ ├── footer.hbs │ │ ├── header.hbs │ │ ├── icons/ │ │ │ ├── arrow_left.hbs │ │ │ ├── chart.hbs │ │ │ ├── check.hbs │ │ │ ├── chevron_left.hbs │ │ │ ├── chevron_right.hbs │ │ │ ├── cog.hbs │ │ │ ├── copy.hbs │ │ │ ├── eye.hbs │ │ │ ├── heart.hbs │ │ │ ├── key.hbs │ │ │ ├── login.hbs │ │ │ ├── new_user.hbs │ │ │ ├── pencil.hbs │ │ │ ├── plus.hbs │ │ │ ├── qrcode.hbs │ │ │ ├── reload.hbs │ │ │ ├── send.hbs │ │ │ ├── shield.hbs │ │ │ ├── shuffle.hbs │ │ │ ├── spinner.hbs │ │ │ ├── stop.hbs │ │ │ ├── trash.hbs │ │ │ ├── write.hbs │ │ │ ├── x.hbs │ │ │ └── zap.hbs │ │ ├── links/ │ │ │ ├── actions.hbs │ │ │ ├── dialog/ │ │ │ │ ├── ban.hbs │ │ │ │ ├── ban_success.hbs │ │ │ │ ├── delete.hbs │ │ │ │ ├── delete_success.hbs │ │ │ │ ├── frame.hbs │ │ │ │ └── message.hbs │ │ │ ├── edit.hbs │ │ │ ├── loading.hbs │ │ │ ├── nav.hbs │ │ │ ├── table.hbs │ │ │ ├── tbody.hbs │ │ │ ├── tfoot.hbs │ │ │ ├── thead.hbs │ │ │ └── tr.hbs │ │ ├── protected/ │ │ │ └── form.hbs │ │ ├── report/ │ │ │ ├── email.hbs │ │ │ └── form.hbs │ │ ├── reset_password/ │ │ │ ├── new_password_form.hbs │ │ │ ├── new_password_success.hbs │ │ │ └── request_form.hbs │ │ ├── settings/ │ │ │ ├── apikey.hbs │ │ │ ├── change_email.hbs │ │ │ ├── change_password.hbs │ │ │ ├── delete_account.hbs │ │ │ └── domain/ │ │ │ ├── add_form.hbs │ │ │ ├── delete.hbs │ │ │ ├── delete_success.hbs │ │ │ ├── dialog.hbs │ │ │ ├── index.hbs │ │ │ └── table.hbs │ │ ├── shortener.hbs │ │ ├── stats.hbs │ │ └── support_email.hbs │ ├── protected.hbs │ ├── report.hbs │ ├── reset_password.hbs │ ├── reset_password_set_new_password.hbs │ ├── settings.hbs │ ├── stats.hbs │ ├── terms.hbs │ ├── url_info.hbs │ ├── verify.hbs │ └── verify_change_email.hbs └── static/ ├── css/ │ └── styles.css ├── manifest.webmanifest ├── robots.txt └── scripts/ ├── main.js └── stats.js ================================================ FILE CONTENTS ================================================ ================================================ FILE: .dockerignore ================================================ .git node_modules ================================================ FILE: .example.env ================================================ # Optional - App port to run on PORT=3000 # Optional - The name of the site where Kutt is hosted SITE_NAME=Kutt # Optional - The domain that this website is on DEFAULT_DOMAIN=localhost:3000 # Required - A passphrase to encrypt JWT. Use a random long string JWT_SECRET= # Optional - Database client. Available clients for the supported databases: # pg | better-sqlite3 | mysql2 # other supported drivers that you can use but you have to manually install them with npm: # pg-native | sqlite3 | mysql DB_CLIENT=better-sqlite3 # Optional - SQLite database file path # Only if you're using SQLite DB_FILENAME=db/data # Optional - SQL database credential details # Only if you're using Postgres or MySQL DB_HOST=localhost DB_PORT=5432 DB_NAME=kutt DB_USER=postgres DB_PASSWORD= DB_SSL=false DB_POOL_MIN=0 DB_POOL_MAX=10 # Optional - Generated link length LINK_LENGTH=6 # Optional - Alphabet used to generate custom addresses # Default value omits o, O, 0, i, I, l, 1, and j to avoid confusion when reading the URL LINK_CUSTOM_ALPHABET=abcdefghkmnpqrstuvwxyzABCDEFGHKLMNPQRSTUVWXYZ23456789 # Optional - Tells the app that it's running behind a proxy server # and that it should get the IP address from that proxy server # if you're not using a proxy server then set this to false, otherwise users can override their IP address TRUST_PROXY=true # Optional - Redis host and port REDIS_ENABLED=false REDIS_HOST=127.0.0.1 REDIS_PORT=6379 REDIS_PASSWORD= # The number for Redis database, between 0 and 15. Defaults to 0. # If you don't know what this is, then you probably don't need to change it. REDIS_DB=0 # Optional - Disable registration. Default is true. DISALLOW_REGISTRATION=true # Optional - Disable form-based login. Only makes sense when OIDC is enabled. DISALLOW_LOGIN_FORM=false # Optional - Disable anonymous link creation. Default is true. DISALLOW_ANONYMOUS_LINKS=true # Optional - This would be shown to the user on the settings page # It's only for display purposes and has no other use SERVER_IP_ADDRESS= SERVER_CNAME_ADDRESS= # Optional - Use HTTPS for links with custom domain # It's on you to generate SSL certificates for those domains manually, at least on this version for now CUSTOM_DOMAIN_USE_HTTPS=false # Optional - Email is used to verify or change email address, reset password, and send reports. # If it's disabled, all the above functionality would be disabled as well. # MAIL_FROM example: "Kutt ". Leave it empty to use MAIL_USER. # More info on the configuration on http://nodemailer.com/. MAIL_ENABLED=false MAIL_HOST= MAIL_PORT=587 MAIL_SECURE=true MAIL_USER= MAIL_FROM= MAIL_PASSWORD= # Optional - Enable rate limitting for some API routes ENABLE_RATE_LIMIT=false # Optional - The email address that will receive submitted reports REPORT_EMAIL= # Optional - Support email to show on the app CONTACT_EMAIL= # Optional - Login with OIDC OIDC_ENABLED=false OIDC_ISSUER= OIDC_CLIENT_ID= OIDC_CLIENT_SECRET= OIDC_SCOPE= OIDC_EMAIL_CLAIM= ================================================ FILE: .github/workflows/docker-build-development.yaml ================================================ name: docker-build-development env: dockerhub_repository: "kutt/kutt" dockerhub_tag: "development" on: push: branches: - develop jobs: dockerhub-build-push: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v2 - name: Set up QEMU uses: docker/setup-qemu-action@v1 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v1 - name: Login to DockerHub uses: docker/login-action@v1 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Build and push uses: docker/build-push-action@v2 with: platforms: linux/amd64,linux/arm64 context: . push: true tags: ${{ env.dockerhub_repository }}:${{ env.dockerhub_tag }} - name: Update repo description uses: peter-evans/dockerhub-description@v2 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_PASSWORD }} repository: ${{ env.dockerhub_repository }} ================================================ FILE: .github/workflows/docker-build-latest.yaml ================================================ name: docker-build-latest env: dockerhub_repository: "kutt/kutt" dockerhub_tag: "main" on: push: branches: - main jobs: dockerhub-build-push: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v2 - name: Set up QEMU uses: docker/setup-qemu-action@v1 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v1 - name: Login to DockerHub uses: docker/login-action@v1 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Build and push uses: docker/build-push-action@v2 with: platforms: linux/amd64,linux/arm64 context: . push: true tags: ${{ env.dockerhub_repository }}:${{ env.dockerhub_tag }} - name: Update repo description uses: peter-evans/dockerhub-description@v2 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_PASSWORD }} repository: ${{ env.dockerhub_repository }} ================================================ FILE: .github/workflows/docker-build-release.yaml ================================================ name: docker-build-release env: dockerhub_repository: "kutt/kutt" on: release: types: [published] jobs: dockerhub-build-push: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v2 - name: Set up QEMU uses: docker/setup-qemu-action@v1 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v1 - name: Login to DockerHub uses: docker/login-action@v1 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Build and push uses: docker/build-push-action@v2 with: platforms: linux/amd64,linux/arm64 context: . push: true tags: ${{ env.dockerhub_repository }}:${{ github.event.release.tag_name }}, ${{ env.dockerhub_repository }}:latest ================================================ FILE: .gitignore ================================================ .env .vscode/ logs client/.next/ node_modules/ client/config.js client/old.config.js server/config.js server/old.config.js production-server .idea/ dump.rdb docs/api/static **/.DS_Store db/* !db/.gitkeep ================================================ FILE: Dockerfile ================================================ # specify node.js image FROM node:22-alpine # use production node environment by default ENV NODE_ENV=production # set working directory. WORKDIR /kutt # download dependencies while using Docker's caching RUN --mount=type=bind,source=package.json,target=package.json \ --mount=type=bind,source=package-lock.json,target=package-lock.json \ --mount=type=cache,target=/root/.npm \ npm ci --omit=dev RUN mkdir -p /var/lib/kutt # copy the rest of source files into the image COPY . . # expose the port that the app listens on EXPOSE 3000 # intialize database and run the app CMD npm run migrate && npm start ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2020 Kutt Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================

Kutt.it

# Kutt.it **Kutt** is a modern URL shortener with support for custom domains. Create and edit links, view statistics, manage users, and more. [https://kutt.it](https://kutt.it) > [!NOTE] > [kutt.it](https://kutt.it) domain has been deactivated by the Italian TLD registrar due to the lack of identification documents. I'm in contact with the domain registrar to bring it back as soon as possible. > > Meanwhile, please use [kutt.to](https://kutt.to), all the previous and the future links work with this domain as well. [![docker-build-release](https://github.com/thedevs-network/kutt/actions/workflows/docker-build-release.yaml/badge.svg)](https://github.com/thedevs-network/kutt/actions/workflows/docker-build-release.yaml) [![Uptime Status](https://uptime.betterstack.com/status-badges/v2/monitor/1ogaa.svg)](https://status.kutt.it) [![Contributions](https://img.shields.io/badge/contributions-welcome-brightgreen.svg)](https://github.com/thedevs-network/kutt/#contributing) [![GitHub license](https://img.shields.io/github/license/thedevs-network/kutt.svg)](https://github.com/thedevs-network/kutt/blob/develop/LICENSE) ## Table of contents - [Key features](#key-features) - [Donations and sponsors](#donations-and-sponsors) - [Setup](#setup) - [Docker](#docker) - [API](#api) - [Configuration](#configuration) - [Themes and customizations](#themes-and-customizations) - [Browser extensions](#browser-extensions) - [Videos](#videos) - [Integrations](#integrations) - [Contributing](#contributing) ## Key features - Created with self-host in mind: - Zero configuration needed - Easy setup with no build step - Supporting various databases (SQLite, Postgres, MySQL) - Ability to disable registration and anonymous links - OpenID Connect (OIDC) login - Custom domain support - Set custom URLs, password, description, and expiration time for links - View, edit, delete and manage your links - Private statistics for shortened URLs - Admin page to manage users and links - Customizability and themes - RESTful API ## Donations and sponsors Support the development of Kutt by making a donation or becoming an sponsor. [Donate or sponsor →](https://btcpay.kutt.it/apps/L9Gc7PrnLykeRHkhsH2jHivBeEh/crowdfund) ## Setup The only prerequisite is [Node.js](https://nodejs.org/) (version 20 or above). The default database is SQLite. You can optionally install Postgres or MySQL/MariaDB for the database or Redis for the cache. When you first start the app, you're prompted to create an admin account. 1. Clone this repository or [download the latest zip](https://github.com/thedevs-network/kutt/releases) 2. Install dependencies: `npm install` 3. Initialize database: `npm run migrate` 5. Start the app for development `npm run dev` or production `npm start` ## Docker Make sure Docker is installed, then you can start the app from the root directory: ```sh docker compose up ``` Various docker-compose configurations are available. Use `docker compose -f up` to start the one you want: - [`docker-compose.yml`](./docker-compose.yml): Default Kutt setup. Uses SQLite for the database. - [`docker-compose.sqlite-redis.yml`](./docker-compose.sqlite-redis.yml): Starts Kutt with SQLite and Redis. - Required environment variable: `REDIS_ENABLED` - [`docker-compose.postgres.yml`](./docker-compose.postgres.yml): Starts Kutt with Postgres and Redis. - Required environment variables: `REDIS_ENABLED`, `DB_PASSWORD`, `DB_NAME`, `DB_USER` - [`docker-compose.mariadb.yml`](./docker-compose.mariadb.yml): Starts Kutt with MariaDB and Redis. - Required environment variables: `REDIS_ENABLED`, `DB_PASSWORD`, `DB_NAME`, `DB_USER`, `DB_PORT` Official Kutt Docker image is available on [Docker Hub](https://hub.docker.com/r/kutt/kutt). ## API [View API documentation →](https://docs.kutt.it) ## Configuration The app is configured via environment variables. You can pass environment variables directly or create a `.env` file. View [`.example.env`](./.example.env) file for the list of configurations. All variables are optional except `JWT_SECRET` which is required on production. You can use files for each of the variables by appending `_FILE` to the name of the variable. Example: `JWT_SECRET_FILE=/path/to/secret_file`. | Variable | Description | Default | Example | | -------- | ----------- | ------- | ------- | | `JWT_SECRET` | This is used to sign authentication tokens. Use a **long** **random** string. | - | - | | `PORT` | The port to start the app on | `3000` | `8888` | | `SITE_NAME` | Name of the website | `Kutt` | `Your Site` | | `DEFAULT_DOMAIN` | The domain address that this app runs on | `localhost:3000` | `yoursite.com` | | `LINK_LENGTH` | The length of of shortened address | `6` | `5` | | `LINK_CUSTOM_ALPHABET` | Alphabet used to generate custom addresses. Default value omits o, O, 0, i, I, l, 1, and j to avoid confusion when reading the URL. | (abcd..789) | `abcABC^&*()@` | | `DISALLOW_REGISTRATION` | Disable registration. Note that if `MAIL_ENABLED` is set to false, then the registration would still be disabled since it relies on emails to sign up users. | `true` | `false` | | `DISALLOW_LOGIN_FORM` | Disable login with email and password. Only makes sense if OIDC is enabled. | `false` | `true` | | `DISALLOW_ANONYMOUS_LINKS` | Disable anonymous link creation | `true` | `false` | | `TRUST_PROXY` | If the app is running behind a proxy server like NGINX or Cloudflare and that it should get the IP address from that proxy server. If you're not using a proxy server then set this to false, otherwise users can override their IP address. | `true` | `false` | | `DB_CLIENT` | Which database client to use. Supported clients: `pg` or `pg-native` for Postgres, `mysql2` for MySQL or MariaDB, `sqlite3` and `better-sqlite3` for SQLite. NOTE: `pg-native` and `sqlite3` are not installed by default, use `npm` to install them before use. | `better-sqlite3` | `pg` | | `DB_FILENAME` | File path for the SQLite database. Only if you use SQLite. | `db/data` | `/var/lib/data` | | `DB_HOST` | Database connection host. Only if you use Postgres or MySQL. | `localhost` | `your-db-host.com` | | `DB_PORT` | Database port. Only if you use Postgres or MySQL. | `5432` (Postgres) | `3306` (MySQL) | | `DB_NAME` | Database name. Only if you use Postgres or MySQL. | `kutt` | `mydb` | | `DB_USER` | Database user. Only if you use Postgres or MySQL. | `postgres` | `myuser` | | `DB_PASSWORD` | Database password. Only if you use Postgres or MySQL. | - | `mypassword` | | `DB_SSL` | Whether use SSL for the database connection. Only if you use Postgres or MySQL. | `false` | `true` | | `DB_POOL_MIN` | Minimum number of database connection pools. Only if you use Postgres or MySQL. | `0` | `2` | | `DB_POOL_MAX` | Maximum number of database connection pools. Only if you use Postgres or MySQL. | `10` | `5` | | `REDIS_ENABLED` | Whether to use Redis for cache | `false` | `true` | | `REDIS_HOST` | Redis connection host | `127.0.0.1` | `your-redis-host.com` | | `REDIS_PORT` | Redis port | `6379` | `6379` | | `REDIS_PASSWORD` | Redis password | - | `mypassword` | | `REDIS_DB` | Redis database number, between 0 and 15. | `0` | `1` | | `SERVER_IP_ADDRESS` | The IP address shown to the user on the setting's page. It's only for display purposes and has no other use. | - | `1.2.3.4` | | `SERVER_CNAME_ADDRESS` | The subdomain shown to the user on the setting's page. It's only for display purposes and has no other use. | - | `custom.yoursite.com` | | `CUSTOM_DOMAIN_USE_HTTPS` | Use https for links with custom domain. It's on you to generate SSL certificates for those domains manually—at least on this version for now. | `false` | `true` | | `ENABLE_RATE_LIMIT` | Enable rate limiting for some API routes. If Redis is enabled uses Redis, otherwise, uses memory. | `false` | `true` | | `MAIL_ENABLED` | Enable emails, which are used for signup, verifying or changing email address, resetting password, and sending reports. If is disabled, all these functionalities will be disabled too. | `false` | `true` | | `MAIL_HOST` | Email server host | - | `your-mail-server.com` | | `MAIL_PORT` | Email server port | `587` | `465` (SSL) | | `MAIL_USER` | Email server user | - | `myuser` | | `MAIL_PASSWORD` | Email server password for the user | - | `mypassword` | | `MAIL_FROM` | Email address to send the user from | - | `example@yoursite.com` | | `MAIL_SECURE` | Whether use SSL for the email server connection | `false` | `true` | | `OIDC_ENABLED` | Enable OpenID Connect | `false` | `true` | | `OIDC_ISSUER` | OIDC issuer URL | - | `https://example.com/some/path` | | `OIDC_CLIENT_ID` | OIDC client id | - | `example-app` | | `OIDC_CLIENT_SECRET` | OIDC client secret | - | `some-secret` | | `OIDC_SCOPE` | OIDC Scope | `openid profile email` | `openid email` | | `OIDC_EMAIL_CLAIM` | Name of the field to get user's email from | `email` | `userEmail` | | `REPORT_EMAIL` | The email address that will receive submitted reports | - | `example@yoursite.com` | | `CONTACT_EMAIL` | The support email address to show on the app | - | `example@yoursite.com` | ## Themes and customizations You can add styles, change images, or render custom HTML. Place your content inside the [`/custom`](./custom) folder according to below instructions. #### How it works: The structure of the custom folder is like this: ``` custom/ ├─ css/ │ ├─ custom1.css │ ├─ custom2.css │ ├─ ... ├─ images/ │ ├─ logo.png │ ├─ favicon.ico │ ├─ ... ├─ views/ │ ├─ partials/ │ │ ├─ footer.hbs │ ├─ 404.hbs │ ├─ ... ``` - **css**: Put your CSS style files here. ([View example →](https://github.com/thedevs-network/kutt-customizations/tree/main/themes/crimson/css)) - You can put as many style files as you want: `custom1.css`, `custom2.css`, etc. - If you name your style file `styles.css`, it will replace Kutt's original `styles.css` file. - Each file will be accessible by `/css/.css` - **images**: Put your images here. ([View example →](https://github.com/thedevs-network/kutt-customizations/tree/main/themes/crimson/images)) - Name them just like the files inside the [`/static/images/`](./static/images) folder to replace Kutt's original images. - Each image will be accessible by `/images/.` - **views**: Custom HTML templates to render. ([View example →](https://github.com/thedevs-network/kutt-customizations/tree/main/themes/crimson/views)) - It should follow the same file naming and folder structure as [`/server/views`](./server/views) - Although we try to keep the original file names unchanged, be aware that new changes on Kutt might break your custom views. #### Example theme: Crimson This is an example and official theme. Crimson includes custom styles, images, and views. [Get Crimson theme →](https://github.com/thedevs-network/kutt-customizations/tree/main/themes/crimson) [View list of themes and customizations →](https://github.com/thedevs-network/kutt-customizations) | Homepage | Admin page | Login/signup | | -------- | ---------- | ------------ | | ![crimson-homepage](https://github.com/user-attachments/assets/b74fab78-5e80-4f57-8425-f0cc73e9c68d) | ![crimson-admin](https://github.com/user-attachments/assets/a75d2430-8074-4ce4-93ec-d8bdfd75d917) | ![crimson-login-signup ](https://github.com/user-attachments/assets/b915eb77-3d66-4407-8e5d-b556f80ff453) #### Usage with Docker: If you're building the image locally, then the `/custom` folder should already be included in your app. If you're pulling the official image, make sure `/kutt/custom` volume is mounted or you have access to it. [View Docker compose example →](https://github.com/thedevs-network/kutt/blob/main/docker-compose.yml#L7) Then, move your files to that volume. You can do it with this Docker command: ```sh docker cp :/kutt ``` For example: ```sh docker cp custom kutt-server-1:/kutt ``` Make sure to restart the kutt server container after copying files or making changes. ## Browser extensions Download Kutt's extension for web browsers via below links. - [Chrome](https://chrome.google.com/webstore/detail/kutt/pklakpjfiegjacoppcodencchehlfnpd) - [Firefox](https://addons.mozilla.org/en-US/firefox/addon/kutt/) ## Videos **Official videos** - [Next.js to htmx – A Real World Example](https://www.youtube.com/watch?v=8RL4NvYZDT4) ## Integrations - **ShareX** – You can use Kutt as your default URL shortener in [ShareX](https://getsharex.com/). If you host your custom instance of Kutt, refer to [ShareX wiki](https://github.com/thedevs-network/kutt/wiki/ShareX) on how to setup. - **Alfred workflow** – Download Kutt's official workflow for [Alfred](https://www.alfredapp.com/) app from [alfred-kutt](https://github.com/thedevs-network/alfred-kutt) repository. - **iOS shortcut** – [Kutt shortcut](https://www.icloud.com/shortcuts/a829856aea2c420e97c53437e68b752b) for your apple device which works from the iOS sharing context menu or on standalone mode. A courtesy of [@caneeeeee](https://github.com/caneeeeee). **Third-party packages** | Language | Link | Description | | --------------- | --------------------------------------------------------------------------------- | ---------------------------------------------------- | | C# (.NET) | [KuttSharp](https://github.com/0xaryan/KuttSharp) | .NET package for Kutt.it url shortener | | C# (.NET) | [Kutt.NET](https://github.com/AlphaNecron/Kutt.NET) | C# API Wrapper for Kutt | | Python | [kutt-cli](https://github.com/RealAmirali/kutt-cli) | Command-line client for Kutt written in Python | | Ruby | [kutt.rb](https://github.com/RealAmirali/kutt.rb) | Kutt library written in Ruby | | Rust | [urlshortener](https://github.com/vityafx/urlshortener-rs) | URL shortener library written in Rust | | Rust | [kutt-rs](https://github.com/robatipoor/kutt-rs) | Command line tool written in Rust | | Node.js | [node-kutt](https://github.com/ardalanamini/node-kutt) | Node.js client for Kutt.it url shortener | | JavaScript | [kutt-vscode](https://github.com/mehrad77/kutt-vscode) | Visual Studio Code extension for Kutt | | Java | [kutt-desktop](https://github.com/cipher812/kutt-desktop) | A Cross platform Java desktop application for Kutt | | Go | [kutt-go](https://github.com/raahii/kutt-go) | Go client for Kutt.it url shortener | | BASH | [GitHub Gist](https://gist.github.com/hashworks/6d6e4eae8984a5018f7692a796d570b4) | Simple BASH function to access the API | | BASH | [url-shortener](https://git.tim-peters.org/Tim/url-shortener) | Simple BASH script with GUI | | Kubernetes/Helm | [ArtifactHub](https://artifacthub.io/packages/helm/christianhuth/kutt) | A Helm Chart to install Kutt on a Kubernetes cluster | ## Contributing Pull requests are welcome. Open a discussion for feedback, requesting features, or discussing ideas. Special thanks to [Thomas](https://github.com/trgwii) and [Muthu](https://github.com/MKRhere). Logo design by [Muthu](https://github.com/MKRhere). ================================================ FILE: custom/.gitkeep ================================================ # keep this folder in git # put supported customization files for styles and such # if you're using docker make sure to mount this folder ================================================ FILE: db/.gitkeep ================================================ # keep this folder in git # if you use a file-based databases such as sqlite3, the database files would be stored here ================================================ FILE: docker-compose.mariadb.yml ================================================ services: server: build: context: . volumes: - custom:/kutt/custom environment: DB_CLIENT: mysql2 DB_HOST: mariadb DB_PORT: 3306 REDIS_ENABLED: true REDIS_HOST: redis REDIS_PORT: 6379 ports: - 3000:3000 depends_on: mariadb: condition: service_healthy redis: condition: service_started mariadb: image: mariadb:10 restart: always healthcheck: test: ['CMD-SHELL', 'mysql ${DB_NAME} --user=${DB_USER} --password=${DB_PASSWORD} --execute "SELECT 1;"'] interval: 3s retries: 5 start_period: 30s volumes: - db_data_mariadb:/var/lib/mysql environment: MARIADB_DATABASE: ${DB_NAME} MARIADB_USER: ${DB_USER} MARIADB_PASSWORD: ${DB_PASSWORD} MARIADB_ROOT_PASSWORD: ${DB_PASSWORD} expose: - 3306 redis: image: redis:alpine restart: always expose: - 6379 volumes: db_data_mariadb: custom: ================================================ FILE: docker-compose.postgres.yml ================================================ services: server: build: context: . volumes: - custom:/kutt/custom environment: DB_CLIENT: pg DB_HOST: postgres DB_PORT: 5432 REDIS_ENABLED: true REDIS_HOST: redis REDIS_PORT: 6379 ports: - 3000:3000 depends_on: postgres: condition: service_healthy redis: condition: service_started postgres: image: postgres restart: always user: ${DB_USER} volumes: - db_data_pg:/var/lib/postgresql/data environment: POSTGRES_DB: ${DB_NAME} POSTGRES_PASSWORD: ${DB_PASSWORD} expose: - 5432 healthcheck: test: [ "CMD", "pg_isready" ] interval: 10s timeout: 5s retries: 5 redis: image: redis:alpine restart: always expose: - 6379 volumes: db_data_pg: custom: ================================================ FILE: docker-compose.sqlite-redis.yml ================================================ services: server: build: context: . volumes: - db_data_sqlite:/var/lib/kutt - custom:/kutt/custom environment: DB_FILENAME: "/var/lib/kutt/data.sqlite" REDIS_ENABLED: true REDIS_HOST: redis REDIS_PORT: 6379 ports: - 3000:3000 depends_on: redis: condition: service_started redis: image: redis:alpine restart: always expose: - 6379 volumes: db_data_sqlite: custom: ================================================ FILE: docker-compose.yml ================================================ services: server: build: context: . volumes: - db_data_sqlite:/var/lib/kutt - custom:/kutt/custom environment: DB_FILENAME: "/var/lib/kutt/data.sqlite" ports: - 3000:3000 volumes: db_data_sqlite: custom: ================================================ FILE: docs/api/api.js ================================================ const p = require("../../package.json"); module.exports = { openapi: "3.0.0", info: { title: "Kutt.it", description: "API reference for [http://kutt.it](http://kutt.it).\n", version: p.version }, servers: [ { url: "https://kutt.it/api/v2" } ], tags: [ { name: "health" }, { name: "links" }, { name: "domains" }, { name: "users" } ], paths: { "/health": { get: { tags: ["health"], summary: "API health", responses: { "200": { description: "Health", content: { "text/html": { example: "OK" } } } } } }, "/links": { get: { tags: ["links"], description: "Get list of links", parameters: [ { name: "limit", in: "query", description: "Limit", required: false, style: "form", explode: true, schema: { type: "number", example: 10 } }, { name: "skip", in: "query", description: "Skip", required: false, style: "form", explode: true, schema: { type: "number", example: 0 } }, { name: "all", in: "query", description: "All links (ADMIN only)", required: false, style: "form", explode: true, schema: { type: "boolean", example: false } } ], responses: { "200": { description: "List of links", content: { "application/json": { schema: { $ref: "#/components/schemas/inline_response_200" } } } } }, security: [ { APIKeyAuth: [] } ] }, post: { tags: ["links"], description: "Create a short link", requestBody: { content: { "application/json": { schema: { $ref: "#/components/schemas/body" } } } }, responses: { "200": { description: "Created link", content: { "application/json": { schema: { $ref: "#/components/schemas/Link" } } } } }, security: [ { APIKeyAuth: [] } ] } }, "/links/{id}": { delete: { tags: ["links"], description: "Delete a link", parameters: [ { name: "id", in: "path", required: true, style: "simple", explode: false, schema: { type: "string", format: "uuid" } } ], responses: { "200": { description: "Deleted link successfully", content: { "application/json": { schema: { $ref: "#/components/schemas/inline_response_200_1" } } } } }, security: [ { APIKeyAuth: [] } ] }, patch: { tags: ["links"], description: "Update a link", parameters: [ { name: "id", in: "path", required: true, style: "simple", explode: false, schema: { type: "string", format: "uuid" } } ], requestBody: { content: { "application/json": { schema: { $ref: "#/components/schemas/body_1" } } } }, responses: { "200": { description: "Updated link successfully", content: { "application/json": { schema: { $ref: "#/components/schemas/Link" } } } } }, security: [ { APIKeyAuth: [] } ] } }, "/links/{id}/stats": { get: { tags: ["links"], description: "Get link stats", parameters: [ { name: "id", in: "path", required: true, style: "simple", explode: false, schema: { type: "string", format: "uuid" } } ], responses: { "200": { description: "Link stats", content: { "application/json": { schema: { $ref: "#/components/schemas/Stats" } } } } }, security: [ { APIKeyAuth: [] } ] } }, "/domains": { post: { tags: ["domains"], description: "Create a domain", requestBody: { content: { "application/json": { schema: { $ref: "#/components/schemas/body_2" } } } }, responses: { "200": { description: "Created domain", content: { "application/json": { schema: { $ref: "#/components/schemas/Domain" } } } } }, security: [ { APIKeyAuth: [] } ] } }, "/domains/{id}": { delete: { tags: ["domains"], description: "Delete a domain", parameters: [ { name: "id", in: "path", required: true, style: "simple", explode: false, schema: { type: "string", format: "uuid" } } ], responses: { "200": { description: "Deleted domain successfully", content: { "application/json": { schema: { $ref: "#/components/schemas/inline_response_200_1" } } } } }, security: [ { APIKeyAuth: [] } ] } }, "/users": { get: { tags: ["users"], description: "Get user info", responses: { "200": { description: "User info", content: { "application/json": { schema: { $ref: "#/components/schemas/User" } } } } }, security: [ { APIKeyAuth: [] } ] } } }, components: { schemas: { Link: { type: "object", properties: { address: { type: "string" }, banned: { type: "boolean", default: false }, created_at: { type: "string", format: "date-time" }, id: { type: "string", format: "uuid" }, link: { type: "string" }, password: { type: "boolean", default: false }, target: { type: "string" }, description: { type: "string" }, updated_at: { type: "string", format: "date-time" }, visit_count: { type: "number" } } }, Domain: { type: "object", properties: { address: { type: "string" }, banned: { type: "boolean", default: false }, created_at: { type: "string", format: "date-time" }, id: { type: "string", format: "uuid" }, homepage: { type: "string" }, updated_at: { type: "string", format: "date-time" } } }, User: { type: "object", properties: { apikey: { type: "string" }, email: { type: "string" }, domains: { type: "array", items: { $ref: "#/components/schemas/Domain" } } } }, StatsItem: { type: "object", properties: { stats: { $ref: "#/components/schemas/StatsItem_stats" }, views: { type: "array", items: { type: "number" } } } }, Stats: { type: "object", properties: { lastDay: { $ref: "#/components/schemas/StatsItem" }, lastMonth: { $ref: "#/components/schemas/StatsItem" }, lastWeek: { $ref: "#/components/schemas/StatsItem" }, lastYear: { $ref: "#/components/schemas/StatsItem" }, updatedAt: { type: "string" }, address: { type: "string" }, banned: { type: "boolean", default: false }, created_at: { type: "string", format: "date-time" }, id: { type: "string", format: "uuid" }, link: { type: "string" }, password: { type: "boolean", default: false }, target: { type: "string" }, updated_at: { type: "string", format: "date-time" }, visit_count: { type: "number" } } }, inline_response_200: { properties: { limit: { type: "number", default: 10 }, skip: { type: "number", default: 0 }, total: { type: "number", default: 0 }, data: { type: "array", items: { $ref: "#/components/schemas/Link" } } } }, body: { required: ["target"], properties: { target: { type: "string" }, description: { type: "string" }, expire_in: { type: "string", example: "2 minutes/hours/days" }, password: { type: "string" }, customurl: { type: "string" }, reuse: { type: "boolean", default: false }, domain: { type: "string" } } }, inline_response_200_1: { properties: { message: { type: "string" } } }, body_1: { required: ["target", "address"], properties: { target: { type: "string" }, address: { type: "string" }, description: { type: "string" }, expire_in: { type: "string", example: "2 minutes/hours/days" } } }, body_2: { required: ["address"], properties: { address: { type: "string" }, homepage: { type: "string" } } }, StatsItem_stats_browser: { type: "object", properties: { name: { type: "string" }, value: { type: "number" } } }, StatsItem_stats: { type: "object", properties: { browser: { type: "array", items: { $ref: "#/components/schemas/StatsItem_stats_browser" } }, os: { type: "array", items: { $ref: "#/components/schemas/StatsItem_stats_browser" } }, country: { type: "array", items: { $ref: "#/components/schemas/StatsItem_stats_browser" } }, referrer: { type: "array", items: { $ref: "#/components/schemas/StatsItem_stats_browser" } } } } }, securitySchemes: { APIKeyAuth: { type: "apiKey", name: "X-API-KEY", in: "header" } } } }; ================================================ FILE: docs/api/generate.js ================================================ const { join, dirname } = require("node:path"); const { promises: fs } = require("node:fs"); const api = require("./api"); const Template = (output, { api, title, redoc }) => fs.writeFile(output, ` ${title} `); const Api = output => fs.writeFile(output, JSON.stringify(api)); const Redoc = output => fs.copyFile(join( dirname(require.resolve("redoc")), "redoc.standalone.js"), output); module.exports = (async () => { const out = join(__dirname, "static"); const apiFile = "api.json"; const redocFile = "redoc.js"; await fs.mkdir(out, { recursive: true }); return Promise.all([ Api(join(out, apiFile)), Redoc(join(out, redocFile)), Template(join(out, "index.html"), { api: apiFile, title: api.info.title, redoc: redocFile }), ]); })(); ================================================ FILE: jsconfig.json ================================================ { "compilerOptions": { "module": "CommonJS", "allowImportingTsExtensions": false }, "exclude": [ "node_modules", "**/node_modules/*" ] } ================================================ FILE: knexfile.js ================================================ // this configuration is for migrations only // and since jwt secret is not required, it's set to a placehodler string to bypass env validation if (!process.env.JWT_SECRET) { process.env.JWT_SECRET = "securekey"; } const env = require("./server/env"); const isSQLite = env.DB_CLIENT === "sqlite3" || env.DB_CLIENT === "better-sqlite3"; module.exports = { client: env.DB_CLIENT, connection: { ...(isSQLite && { filename: env.DB_FILENAME }), host: env.DB_HOST, database: env.DB_NAME, user: env.DB_USER, port: env.DB_PORT, password: env.DB_PASSWORD, ssl: env.DB_SSL, }, useNullAsDefault: true, migrations: { tableName: "knex_migrations", directory: "server/migrations", disableMigrationsListValidation: true, } }; ================================================ FILE: package.json ================================================ { "name": "kutt", "version": "3.2.3", "description": "Modern URL shortener.", "main": "./server/server.js", "scripts": { "dev": "node --watch-path=./server --watch-path=./custom server/server.js", "start": "node server/server.js --production", "migrate": "knex migrate:latest", "migrate:make": "knex migrate:make", "docs:build": "cd docs/api && node generate && cd ../.." }, "repository": { "type": "git", "url": "git+https://github.com/thedevs-network/kutt.git" }, "keywords": [ "url-shortener" ], "author": "Pouria Ezzati ", "license": "MIT", "bugs": { "url": "https://github.com/thedevs-network/kutt/issues" }, "homepage": "https://github.com/thedevs-network/kutt#readme", "dependencies": { "bcryptjs": "2.4.3", "better-sqlite3": "11.8.1", "bull": "4.16.5", "cookie-parser": "1.4.7", "cookie-session": "^2.1.0", "cors": "2.8.5", "date-fns": "2.30.0", "dotenv": "16.4.7", "envalid": "8.0.0", "express": "4.21.2", "express-rate-limit": "7.5.0", "express-validator": "6.14.2", "geoip-lite": "1.4.10", "hbs": "4.2.0", "helmet": "7.1.0", "ioredis": "5.4.2", "isbot": "5.1.19", "jsonwebtoken": "9.0.2", "knex": "3.1.0", "ms": "2.1.3", "mysql2": "3.12.0", "nanoid": "3.3.8", "nodemailer": "6.9.16", "openid-client": "^5.7.0", "passport": "0.7.0", "passport-jwt": "4.0.1", "passport-local": "1.0.0", "passport-localapikey-update": "0.6.0", "pg": "8.13.1", "pg-query-stream": "4.7.1", "rate-limit-redis": "4.2.0", "useragent": "2.3.0" }, "devDependencies": { "@types/bcryptjs": "2.4.2", "@types/cookie-parser": "1.4.3", "@types/cors": "2.8.12", "@types/express": "4.17.14", "@types/hbs": "4.0.4", "@types/jsonwebtoken": "7.2.8", "@types/ms": "0.7.31", "@types/node": "18.11.9", "@types/nodemailer": "6.4.6", "@types/pg": "8.11.10", "redoc": "2.2.0" } } ================================================ FILE: server/consts.js ================================================ const ROLES = { USER: "USER", ADMIN: "ADMIN" }; module.exports = { ROLES, } ================================================ FILE: server/cron.js ================================================ const query = require("./queries"); const utils = require("./utils"); // check and delete links 30 secoonds setInterval(function () { query.link.batchRemove({ expire_in: ["<", utils.dateToUTC(new Date())] }).catch(); }, 30_000); ================================================ FILE: server/env.js ================================================ require("dotenv").config(); const { cleanEnv, num, str, bool } = require("envalid"); const { readFileSync } = require("node:fs"); const supportedDBClients = [ "pg", "pg-native", "sqlite3", "better-sqlite3", "mysql", "mysql2" ]; // make sure custom alphabet is not empty if (process.env.LINK_CUSTOM_ALPHABET === "") { delete process.env.LINK_CUSTOM_ALPHABET; } // make sure jwt secret is not empty if (process.env.JWT_SECRET === "") { delete process.env.JWT_SECRET; } // if is started with the --production argument, then set NODE_ENV to production if (process.argv.includes("--production")) { process.env.NODE_ENV = "production"; } const spec = { PORT: num({ default: 3000 }), SITE_NAME: str({ example: "Kutt", default: "Kutt" }), DEFAULT_DOMAIN: str({ example: "kutt.it", default: "localhost:3000" }), LINK_LENGTH: num({ default: 6 }), LINK_CUSTOM_ALPHABET: str({ default: "abcdefghkmnpqrstuvwxyzABCDEFGHKLMNPQRSTUVWXYZ23456789" }), TRUST_PROXY: bool({ default: true }), DB_CLIENT: str({ choices: supportedDBClients, default: "better-sqlite3" }), DB_FILENAME: str({ default: "db/data" }), DB_HOST: str({ default: "localhost" }), DB_PORT: num({ default: 5432 }), DB_NAME: str({ default: "kutt" }), DB_USER: str({ default: "postgres" }), DB_PASSWORD: str({ default: "" }), DB_SSL: bool({ default: false }), DB_POOL_MIN: num({ default: 0 }), DB_POOL_MAX: num({ default: 10 }), REDIS_ENABLED: bool({ default: false }), REDIS_HOST: str({ default: "127.0.0.1" }), REDIS_PORT: num({ default: 6379 }), REDIS_PASSWORD: str({ default: "" }), REDIS_DB: num({ default: 0 }), DISALLOW_ANONYMOUS_LINKS: bool({ default: true }), DISALLOW_REGISTRATION: bool({ default: true }), DISALLOW_LOGIN_FORM: bool({ default: false }), SERVER_IP_ADDRESS: str({ default: "" }), SERVER_CNAME_ADDRESS: str({ default: "" }), CUSTOM_DOMAIN_USE_HTTPS: bool({ default: false }), JWT_SECRET: str({ devDefault: "securekey" }), MAIL_ENABLED: bool({ default: false }), MAIL_HOST: str({ default: "" }), MAIL_PORT: num({ default: 587 }), MAIL_SECURE: bool({ default: false }), MAIL_USER: str({ default: "" }), MAIL_FROM: str({ default: "", example: "Kutt " }), MAIL_PASSWORD: str({ default: "" }), OIDC_ENABLED: bool({ default: false }), OIDC_ISSUER: str({ default: "" }), OIDC_CLIENT_ID: str({ default: "" }), OIDC_CLIENT_SECRET: str({ default: "" }), OIDC_SCOPE: str({ default: "openid profile email" }), OIDC_EMAIL_CLAIM: str({ default: "email" }), ENABLE_RATE_LIMIT: bool({ default: false }), REPORT_EMAIL: str({ default: "" }), CONTACT_EMAIL: str({ default: "" }), NODE_APP_INSTANCE: num({ default: 0 }), }; for (const key in spec) { const file_key = key + "_FILE"; if (!(file_key in process.env)) continue; try { process.env[key] = readFileSync(process.env[file_key], "utf8").trim(); } catch { // on error, env_FILE just doesn't get applied. } } const env = cleanEnv(process.env, spec); module.exports = env; ================================================ FILE: server/handlers/auth.handler.js ================================================ const { differenceInDays, addMinutes } = require("date-fns"); const { nanoid } = require("nanoid"); const passport = require("passport"); const { randomUUID } = require("node:crypto"); const bcrypt = require("bcryptjs"); const { ROLES } = require("../consts"); const query = require("../queries"); const utils = require("../utils"); const redis = require("../redis"); const mail = require("../mail"); const env = require("../env"); const CustomError = utils.CustomError; function authenticate(type, error, isStrict, redirect) { return function auth(req, res, next) { if (req.user) return next(); passport.authenticate(type, (err, user, info) => { if ( (err || info instanceof Error) && type === "oidc" ) { return next(new CustomError("OIDC authentication failed.", 401)); }; if (err) return next(err); if ( req.isHTML && redirect && ((!user && isStrict) || (user && isStrict && !user.verified) || (user && user.banned)) ) { if (redirect === "page") { res.redirect("/logout"); return; } if (redirect === "header") { res.setHeader("HX-Redirect", "/logout"); res.send("NOT_AUTHENTICATED"); return; } } if (!user && isStrict) { throw new CustomError(error, 401); } if (user && user.banned) { throw new CustomError("You're banned from using this website.", 403); } if (user && isStrict && !user.verified) { throw new CustomError("Your email address is not verified. " + "Sign up to get the verification link again.", 400); } if (user) { res.locals.isAdmin = utils.isAdmin(user); req.user = { ...user, admin: utils.isAdmin(user) }; // renew token if it's been at least one day since the token has been created // only do it for html page requests not api requests if (info?.exp && req.isHTML && redirect === "page") { const diff = Math.abs(differenceInDays(new Date(info.exp * 1000), new Date())); if (diff < 6) { const token = utils.signToken(user); utils.deleteCurrentToken(res); utils.setToken(res, token); } } } return next(); })(req, res, next); } } const local = authenticate("local", "Login credentials are wrong.", true, null); const jwt = authenticate("jwt", "Unauthorized.", true, "header"); const jwtPage = authenticate("jwt", "Unauthorized.", true, "page"); const jwtLoose = authenticate("jwt", "Unauthorized.", false, "header"); const jwtLoosePage = authenticate("jwt", "Unauthorized.", false, "page"); const apikey = authenticate("localapikey", "API key is not correct.", false, null); const oidc = authenticate("oidc", "Unauthorized", true, "page"); function admin(req, res, next) { if (req.user.admin) return next(); throw new CustomError("Unauthorized", 401); } async function signup(req, res) { const salt = await bcrypt.genSalt(12); const password = await bcrypt.hash(req.body.password, salt); const user = await query.user.add( { email: req.body.email, password }, req.user ); await mail.verification(user); if (req.isHTML) { res.render("partials/auth/verify"); return; } return res.status(201).send({ message: "A verification email has been sent." }); } async function createAdminUser(req, res) { const isThereAUser = await query.user.findAny(); if (isThereAUser) { throw new CustomError("Can not create the admin user because a user already exists.", 400); } const salt = await bcrypt.genSalt(12); const password = await bcrypt.hash(req.body.password, salt); const user = await query.user.add({ email: req.body.email, password, role: ROLES.ADMIN, verified: true }); const token = utils.signToken(user); if (req.isHTML) { utils.setToken(res, token); res.render("partials/auth/welcome"); return; } return res.status(201).send({ token }); } function login(req, res) { const token = utils.signToken(req.user); if (req.isHTML) { utils.setToken(res, token); res.render("partials/auth/welcome"); return; } return res.status(200).send({ token }); } async function verify(req, res, next) { if (!req.params.verificationToken) return next(); const user = await query.user.update( { verification_token: req.params.verificationToken, verification_expires: [">", utils.dateToUTC(new Date())] }, { verified: true, verification_token: null, verification_expires: null } ); if (user) { const token = utils.signToken(user); utils.deleteCurrentToken(res); utils.setToken(res, token); res.locals.token_verified = true; req.cookies.token = token; } return next(); } async function changePassword(req, res) { const isMatch = await bcrypt.compare(req.body.currentpassword, req.user.password); if (!isMatch) { const message = "Current password is not correct."; res.locals.errors = { currentpassword: message }; throw new CustomError(message, 401); } const salt = await bcrypt.genSalt(12); const newpassword = await bcrypt.hash(req.body.newpassword, salt); const user = await query.user.update({ id: req.user.id }, { password: newpassword }); if (!user) { throw new CustomError("Couldn't change the password. Try again later."); } if (req.isHTML) { res.setHeader("HX-Trigger-After-Swap", "resetChangePasswordForm"); res.render("partials/settings/change_password", { success: "Password has been changed." }); return; } return res .status(200) .send({ message: "Your password has been changed successfully." }); } async function generateApiKey(req, res) { const apikey = nanoid(40); if (env.REDIS_ENABLED) { redis.remove.user(req.user); } const user = await query.user.update({ id: req.user.id }, { apikey }); if (!user) { throw new CustomError("Couldn't generate API key. Please try again later."); } if (req.isHTML) { res.render("partials/settings/apikey", { user: { apikey }, }); return; } return res.status(201).send({ apikey }); } async function resetPassword(req, res) { const user = await query.user.update( { email: req.body.email }, { reset_password_token: randomUUID(), reset_password_expires: utils.dateToUTC(addMinutes(new Date(), 30)) } ); if (user) { mail.resetPasswordToken(user).catch(error => { console.error("Send reset-password token email error:\n", error); }); } if (req.isHTML) { res.render("partials/reset_password/request_form", { message: "If the email address exists, a reset password email will be sent to it." }); return; } return res.status(200).send({ message: "If email address exists, a reset password email has been sent." }); } async function newPassword(req, res) { const { new_password, reset_password_token } = req.body; const salt = await bcrypt.genSalt(12); const password = await bcrypt.hash(req.body.new_password, salt); const user = await query.user.update( { reset_password_token, reset_password_expires: [">", utils.dateToUTC(new Date())] }, { reset_password_expires: null, reset_password_token: null, password, } ); if (!user) { throw new CustomError("Could not set the password. Please try again later."); } res.render("partials/reset_password/new_password_success"); } async function changeEmailRequest(req, res) { const { email, password } = req.body; const isMatch = await bcrypt.compare(password, req.user.password); if (!isMatch) { const error = "Password is not correct."; res.locals.errors = { password: error }; throw new CustomError(error, 401); } const user = await query.user.find({ email }); if (user) { const error = "Can't use this email address."; res.locals.errors = { email: error }; throw new CustomError(error, 400); } const updatedUser = await query.user.update( { id: req.user.id }, { change_email_address: email, change_email_token: randomUUID(), change_email_expires: utils.dateToUTC(addMinutes(new Date(), 30)) } ); if (updatedUser) { await mail.changeEmail({ ...updatedUser, email }); } const message = "A verification link has been sent to the requested email address." if (req.isHTML) { res.setHeader("HX-Trigger-After-Swap", "resetChangeEmailForm"); res.render("partials/settings/change_email", { success: message }); return; } return res.status(200).send({ message }); } async function changeEmail(req, res, next) { const changeEmailToken = req.params.changeEmailToken; if (changeEmailToken) { const foundUser = await query.user.find({ change_email_token: changeEmailToken, change_email_expires: [">", utils.dateToUTC(new Date())] }); if (!foundUser) return next(); const user = await query.user.update( { id: foundUser.id }, { change_email_token: null, change_email_expires: null, change_email_address: null, email: foundUser.change_email_address } ); if (user) { const token = utils.signToken(user); utils.deleteCurrentToken(res); utils.setToken(res, token); res.locals.token_verified = true; req.cookies.token = token; } } return next(); } function featureAccess(features, redirect) { return function(req, res, next) { for (let i = 0; i < features.length; ++i) { if (!features[i]) { if (redirect) { return res.redirect("/"); } else { throw new CustomError("Request is not allowed.", 400); } } } next(); } } function featureAccessPage(features) { return featureAccess(features, true); } module.exports = { admin, apikey, changeEmail, changeEmailRequest, changePassword, createAdminUser, featureAccess, featureAccessPage, generateApiKey, jwt, jwtLoose, jwtLoosePage, jwtPage, local, login, newPassword, oidc, resetPassword, signup, verify, } ================================================ FILE: server/handlers/domains.handler.js ================================================ const { Handler } = require("express"); const { CustomError, sanitize } = require("../utils"); const query = require("../queries"); const redis = require("../redis"); const utils = require("../utils"); const env = require("../env"); async function add(req, res) { const { address, homepage } = req.body; const domain = await query.domain.add({ address, homepage, user_id: req.user.id }); if (req.isHTML) { const domains = (await query.domain.get({ user_id: req.user.id })).map(sanitize.domain); res.setHeader("HX-Reswap", "none"); res.render("partials/settings/domain/table", { domains }); return; } return res.status(200).send(sanitize.domain(domain)); }; async function addAdmin(req, res) { const { address, banned, homepage } = req.body; const domain = await query.domain.add({ address, homepage, banned, ...(banned && { banned_by_id: req.user.id }) }); if (req.isHTML) { res.setHeader("HX-Trigger", "reloadMainTable"); res.render("partials/admin/dialog/add_domain_success", { address: domain.address, }); return; } return res.status(200).send({ message: "The domain has been added successfully." }); }; async function remove(req, res) { const domain = await query.domain.find({ uuid: req.params.id, user_id: req.user.id }); if (!domain) { throw new CustomError("Could not delete the domain.", 400); } const [updatedDomain] = await query.domain.update( { id: domain.id }, { user_id: null } ); if (!updatedDomain) { throw new CustomError("Could not delete the domain.", 500); } if (env.REDIS_ENABLED) { redis.remove.domain(updatedDomain); } if (req.isHTML) { const domains = (await query.domain.get({ user_id: req.user.id })).map(sanitize.domain); res.setHeader("HX-Reswap", "outerHTML"); res.render("partials/settings/domain/delete_success", { domains, address: domain.address, }); return; } return res.status(200).send({ message: "Domain deleted successfully" }); }; async function removeAdmin(req, res) { const id = req.params.id; const links = req.query.links const domain = await query.domain.find({ id }); if (!domain) { throw new CustomError("Could not find the domain.", 400); } if (links) { await query.link.batchRemove({ domain_id: id }); } await query.domain.remove(domain); if (req.isHTML) { res.setHeader("HX-Reswap", "outerHTML"); res.setHeader("HX-Trigger", "reloadMainTable"); res.render("partials/admin/dialog/delete_domain_success", { address: domain.address, }); return; } return res.status(200).send({ message: "Domain deleted successfully" }); } async function getAdmin(req, res) { const { limit, skip } = req.context; const search = req.query.search; const user = req.query.user; const banned = utils.parseBooleanQuery(req.query.banned); const owner = utils.parseBooleanQuery(req.query.owner); const links = utils.parseBooleanQuery(req.query.links); const match = { ...(banned !== undefined && { banned }), ...(owner !== undefined && { user_id: [owner ? "is not" : "is", null] }), }; const [data, total] = await Promise.all([ query.domain.getAdmin(match, { limit, search, user, links, skip }), query.domain.totalAdmin(match, { search, user, links }) ]); const domains = data.map(utils.sanitize.domain_admin); if (req.isHTML) { res.render("partials/admin/domains/table", { total, total_formatted: total.toLocaleString("en-US"), limit, skip, table_domains: domains, }) return; } return res.send({ total, limit, skip, data: domains, }); } async function ban(req, res) { const { id } = req.params; const update = { banned_by_id: req.user.id, banned: true }; // 1. check if domain exists const domain = await query.domain.find({ id }); if (!domain) { throw new CustomError("No domain has been found.", 400); } if (domain.banned) { throw new CustomError("Domain has been banned already.", 400); } const tasks = []; // 2. ban domain tasks.push(query.domain.update({ id }, update)); // 3. ban user if (req.body.user && domain.user_id) { tasks.push(query.user.update({ id: domain.user_id }, update)); } // 4. ban links if (req.body.links) { tasks.push(query.link.update({ domain_id: id }, update)); } // 5. wait for all tasks to finish await Promise.all(tasks).catch((err) => { throw new CustomError("Couldn't ban entries."); }); // 6. send response if (req.isHTML) { res.setHeader("HX-Reswap", "outerHTML"); res.setHeader("HX-Trigger", "reloadMainTable"); res.render("partials/admin/dialog/ban_domain_success", { address: domain.address, }); return; } return res.status(200).send({ message: "Banned domain successfully." }); } module.exports = { add, addAdmin, ban, getAdmin, remove, removeAdmin, } ================================================ FILE: server/handlers/helpers.handler.js ================================================ const { RedisStore: RateLimitRedisStore } = require("rate-limit-redis"); const { rateLimit: expressRateLimit } = require("express-rate-limit"); const { validationResult } = require("express-validator"); const { CustomError } = require("../utils"); const query = require("../queries"); const redis = require("../redis"); const env = require("../env"); function error(error, req, res, _next) { if (!(error instanceof CustomError)) { console.error(error); } else if (env.isDev) { console.error(error.message); } const message = error instanceof CustomError ? error.message : "An error occurred."; const statusCode = error.statusCode ?? 500; if (req.isHTML && req.viewTemplate) { res.locals.error = message; res.render(req.viewTemplate); return; } if (req.isHTML) { res.render("error", { message: "An error occurred. Please try again later." }); return; } return res.status(statusCode).json({ error: message }); }; function verify(req, res, next) { const result = validationResult(req); if (result.isEmpty()) return next(); const errors = result.array(); const error = errors[0].msg; res.locals.errors = {}; errors.forEach(e => { if (res.locals.errors[e.param]) return; res.locals.errors[e.param] = e.msg; }); throw new CustomError(error, 400); } function parseQuery(req, res, next) { const { admin } = req.user || {}; if ( typeof req.query.limit !== "undefined" && typeof req.query.limit !== "string" ) { return res.status(400).json({ error: "limit query is not valid." }); } if ( typeof req.query.skip !== "undefined" && typeof req.query.skip !== "string" ) { return res.status(400).json({ error: "skip query is not valid." }); } if ( typeof req.query.search !== "undefined" && typeof req.query.search !== "string" ) { return res.status(400).json({ error: "search query is not valid." }); } const limit = parseInt(req.query.limit) || 10; req.context = { limit: limit > 50 ? 50 : limit, skip: parseInt(req.query.skip) || 0, }; next(); }; function rateLimit(params) { if (!env.ENABLE_RATE_LIMIT) { return function(req, res, next) { return next(); } } let store = undefined; if (env.REDIS_ENABLED) { store = new RateLimitRedisStore({ sendCommand: (...args) => redis.client.call(...args), }) } return expressRateLimit({ windowMs: params.window * 1000, validate: { trustProxy: false }, skipSuccessfulRequests: !!params.skipSuccess, skipFailedRequests: !!params.skipFailed, ...(store && { store }), limit: function (req, res) { if (params.user && req.user) { return params.user; } return params.limit; }, keyGenerator: function(req, res) { return "rl:" + req.method + req.baseUrl + req.path + ":" + req.ip; }, requestWasSuccessful: function(req, res) { return !res.locals.error && res.statusCode < 400; }, handler: function (req, res, next, options) { throw new CustomError(options.message, options.statusCode); }, }); } // redirect to create admin page if the kutt instance is ran for the first time async function adminSetup(req, res, next) { const isThereAUser = req.user || (await query.user.findAny()); if (isThereAUser) { next(); return; } res.redirect("/create-admin"); } module.exports = { adminSetup, error, parseQuery, rateLimit, verify, } ================================================ FILE: server/handlers/links.handler.js ================================================ const { differenceInSeconds } = require("date-fns"); const promisify = require("node:util").promisify; const bcrypt = require("bcryptjs"); const { isbot } = require("isbot"); const URL = require("node:url"); const dns = require("node:dns"); const validators = require("./validators.handler"); const map = require("../utils/map.json"); const transporter = require("../mail"); const query = require("../queries"); const queue = require("../queues"); const utils = require("../utils"); const env = require("../env"); const CustomError = utils.CustomError; const dnsLookup = promisify(dns.lookup); async function get(req, res) { const { limit, skip } = req.context; const search = req.query.search; const userId = req.user.id; const match = { user_id: userId }; const [data, total] = await Promise.all([ query.link.get(match, { limit, search, skip }), query.link.total(match, { search }) ]); if (req.isHTML) { res.render("partials/links/table", { total, limit, skip, links: data.map(utils.sanitize.link_html), }) return; } return res.send({ total, limit, skip, data: data.map(utils.sanitize.link), }); }; async function getAdmin(req, res) { const { limit, skip } = req.context; const search = req.query.search; const user = req.query.user; let domain = req.query.domain; const banned = utils.parseBooleanQuery(req.query.banned); const anonymous = utils.parseBooleanQuery(req.query.anonymous); const has_domain = utils.parseBooleanQuery(req.query.has_domain); const match = { ...(banned !== undefined && { banned }), ...(anonymous !== undefined && { user_id: [anonymous ? "is" : "is not", null] }), ...(has_domain !== undefined && { domain_id: [has_domain ? "is not" : "is", null] }), }; // if domain is equal to the defualt domain, // it means admins is looking for links with the defualt domain (no custom user domain) if (domain === env.DEFAULT_DOMAIN) { domain = undefined; match.domain_id = null; } const [data, total] = await Promise.all([ query.link.getAdmin(match, { limit, search, user, domain, skip }), query.link.totalAdmin(match, { search, user, domain }) ]); const links = data.map(utils.sanitize.link_admin); if (req.isHTML) { res.render("partials/admin/links/table", { total, total_formatted: total.toLocaleString("en-US"), limit, skip, links, }) return; } return res.send({ total, limit, skip, data: links, }); }; async function create(req, res) { const { reuse, password, customurl, description, target, fetched_domain, expire_in } = req.body; const domain_id = fetched_domain ? fetched_domain.id : null; const targetDomain = utils.removeWww(URL.parse(target).hostname); const tasks = await Promise.all([ reuse && query.link.find({ target, user_id: req.user.id, domain_id }), customurl && query.link.find({ address: customurl, domain_id }), !customurl && utils.generateId(query, domain_id), validators.bannedDomain(targetDomain), validators.bannedHost(targetDomain) ]); // if "reuse" is true, try to return // the existent URL without creating one if (tasks[0]) { return res.json(utils.sanitize.link(tasks[0])); } // Check if custom link already exists if (tasks[1]) { const error = "Custom URL is already in use."; res.locals.errors = { customurl: error }; throw new CustomError(error); } // Create new link const address = customurl || tasks[2]; const link = await query.link.create({ password, address, domain_id, description, target, expire_in, user_id: req.user && req.user.id }); link.domain = fetched_domain?.address; if (req.isHTML) { res.setHeader("HX-Trigger", "reloadMainTable"); const shortURL = utils.getShortURL(link.address, link.domain); return res.render("partials/shortener", { link: shortURL.link, url: shortURL.url, }); } return res .status(201) .send(utils.sanitize.link({ ...link })); } async function edit(req, res) { const link = await query.link.find({ uuid: req.params.id, ...(!req.user.admin && { user_id: req.user.id }) }); if (!link) { throw new CustomError("Link was not found."); } let isChanged = false; [ [req.body.address, "address"], [req.body.target, "target"], [req.body.description, "description"], [req.body.expire_in, "expire_in"], [req.body.password, "password"] ].forEach(([value, name]) => { if (!value) { if (name === "password" && link.password) req.body.password = null; else { delete req.body[name]; return; } } if (value === link[name] && name !== "password") { delete req.body[name]; return; } if (name === "expire_in" && link.expire_in) if (Math.abs(differenceInSeconds(utils.parseDatetime(value), utils.parseDatetime(link.expire_in))) < 60) return; if (name === "password") if (value && value.replace(/•/ig, "").length === 0) { delete req.body.password; return; } isChanged = true; }); if (!isChanged) { throw new CustomError("Should at least update one field."); } const { address, target, description, expire_in, password } = req.body; const targetDomain = target && utils.removeWww(URL.parse(target).hostname); const domain_id = link.domain_id || null; const tasks = await Promise.all([ address && query.link.find({ address, domain_id }), target && validators.bannedDomain(targetDomain), target && validators.bannedHost(targetDomain) ]); // Check if custom link already exists if (tasks[0]) { const error = "Custom URL is already in use."; res.locals.errors = { address: error }; throw new CustomError("Custom URL is already in use."); } // Update link const [updatedLink] = await query.link.update( { id: link.id }, { ...(address && { address }), ...(description && { description }), ...(target && { target }), ...(expire_in && { expire_in }), ...((password || password === null) && { password }) } ); if (req.isHTML) { res.render("partials/links/edit", { swap_oob: true, success: "Link has been updated.", ...utils.sanitize.link_html({ ...updatedLink }), }); return; } return res.status(200).send(utils.sanitize.link({ ...updatedLink })); }; async function editAdmin(req, res) { const link = await query.link.find({ uuid: req.params.id, ...(!req.user.admin && { user_id: req.user.id }) }); if (!link) { throw new CustomError("Link was not found."); } let isChanged = false; [ [req.body.address, "address"], [req.body.target, "target"], [req.body.description, "description"], [req.body.expire_in, "expire_in"], [req.body.password, "password"] ].forEach(([value, name]) => { if (!value) { if (name === "password" && link.password) req.body.password = null; else { delete req.body[name]; return; } } if (value === link[name] && name !== "password") { delete req.body[name]; return; } if (name === "expire_in" && link.expire_in) if (Math.abs(differenceInSeconds(utils.parseDatetime(value), utils.parseDatetime(link.expire_in))) < 60) return; if (name === "password") if (value && value.replace(/•/ig, "").length === 0) { delete req.body.password; return; } isChanged = true; }); if (!isChanged) { throw new CustomError("Should at least update one field."); } const { address, target, description, expire_in, password } = req.body; const targetDomain = target && utils.removeWww(URL.parse(target).hostname); const domain_id = link.domain_id || null; const tasks = await Promise.all([ address && query.link.find({ address, domain_id }), target && validators.bannedDomain(targetDomain), target && validators.bannedHost(targetDomain) ]); // Check if custom link already exists if (tasks[0]) { const error = "Custom URL is already in use."; res.locals.errors = { address: error }; throw new CustomError("Custom URL is already in use."); } // Update link const [updatedLink] = await query.link.update( { id: link.id }, { ...(address && { address }), ...(description && { description }), ...(target && { target }), ...(expire_in && { expire_in }), ...((password || password === null) && { password }) } ); if (req.isHTML) { res.render("partials/admin/links/edit", { swap_oob: true, success: "Link has been updated.", ...utils.sanitize.link_admin({ ...updatedLink }), }); return; } return res.status(200).send(utils.sanitize.link({ ...updatedLink })); }; async function remove(req, res) { const { error, isRemoved, link } = await query.link.remove({ uuid: req.params.id, ...(!req.user.admin && { user_id: req.user.id }) }); if (!isRemoved) { const messsage = error || "Could not delete the link."; throw new CustomError(messsage); } if (req.isHTML) { res.setHeader("HX-Reswap", "outerHTML"); res.setHeader("HX-Trigger", "reloadMainTable"); res.render("partials/links/dialog/delete_success", { link: utils.getShortURL(link.address, link.domain).link, }); return; } return res .status(200) .send({ message: "Link has been deleted successfully." }); }; async function report(req, res) { const { link } = req.body; await transporter.sendReportEmail(link); if (req.isHTML) { res.render("partials/report/form", { message: "Report was received. We'll take actions shortly." }); return; } return res .status(200) .send({ message: "Thanks for the report, we'll take actions shortly." }); }; async function ban(req, res) { const { id } = req.params; const update = { banned_by_id: req.user.id, banned: true }; // 1. check if link exists const link = await query.link.find({ uuid: id }); if (!link) { throw new CustomError("No link has been found.", 400); } if (link.banned) { throw new CustomError("Link has been banned already.", 400); } const tasks = []; // 2. ban link tasks.push(query.link.update({ uuid: id }, update)); const domain = utils.removeWww(URL.parse(link.target).hostname); // 3. ban target's domain if (req.body.domain) { tasks.push(query.domain.add({ ...update, address: domain })); } // 4. ban target's host if (req.body.host) { const dnsRes = await dnsLookup(domain).catch(() => { throw new CustomError("Couldn't fetch DNS info."); }); const host = dnsRes?.address; tasks.push(query.host.add({ ...update, address: host })); } // 5. ban link owner if (req.body.user && link.user_id) { tasks.push(query.user.update({ id: link.user_id }, update)); } // 6. ban all of owner's links if (req.body.userLinks && link.user_id) { tasks.push(query.link.update({ user_id: link.user_id }, update)); } // 7. wait for all tasks to finish await Promise.all(tasks).catch((err) => { throw new CustomError("Couldn't ban entries."); }); // 8. send response if (req.isHTML) { res.setHeader("HX-Reswap", "outerHTML"); res.setHeader("HX-Trigger", "reloadMainTable"); res.render("partials/links/dialog/ban_success", { link: utils.getShortURL(link.address, link.domain).link, }); return; } return res.status(200).send({ message: "Banned link successfully." }); }; async function redirect(req, res, next) { const isPreservedUrl = utils.preservedURLs.some( item => item === req.path.replace("/", "") ); if (isPreservedUrl) return next(); // 1. If custom domain, get domain info const host = utils.removeWww(req.headers.host); const domain = host !== env.DEFAULT_DOMAIN ? await query.domain.find({ address: host }) : null; // 2. Get link const address = req.params.id.replace("+", ""); const link = await query.link.find({ address, domain_id: domain ? domain.id : null }); // 3. When no link, if has domain redirect to domain's homepage // otherwise redirect to 404 if (!link) { return res.redirect(domain?.homepage || "/404"); } // 4. If link is banned, redirect to banned page. if (link.banned) { return res.redirect("/banned"); } // 5. If wants to see link info, then redirect const isRequestingInfo = /.*\+$/gi.test(req.params.id); if (isRequestingInfo && !link.password) { if (req.isHTML) { res.render("url_info", { title: "Short link information", target: link.target, link: utils.getShortURL(link.address, link.domain).link }); return; } return res.send({ target: link.target }); } // 6. If link is protected, redirect to password page if (link.password) { if ("authorization" in req.headers) { const auth = req.headers.authorization; const firstSpace = auth.indexOf(" "); if (firstSpace !== -1) { const method = auth.slice(0, firstSpace); const payload = auth.slice(firstSpace + 1); if (method === "Basic") { const decoded = Buffer.from(payload, "base64").toString("utf8"); const colon = decoded.indexOf(":"); if (colon !== -1) { const password = decoded.slice(colon + 1); const matches = await bcrypt.compare(password, link.password); if (matches) return res.redirect(link.target); } } } } res.render("protected", { title: "Protected short link", id: link.uuid }); return; } // 7. Create link visit const isBot = isbot(req.headers["user-agent"]); if (link.user_id && !isBot) { queue.visit.add({ userAgent: req.headers["user-agent"], ip: req.ip, country: req.get("cf-ipcountry"), referrer: req.get("Referrer"), link }); } // 8. Redirect to target return res.redirect(link.target); }; async function redirectProtected(req, res) { // 1. Get link const uuid = req.params.id; const link = await query.link.find({ uuid }); // 2. Throw error if no link if (!link || !link.password) { throw new CustomError("Couldn't find the link.", 400); } // 3. Check if password matches const matches = await bcrypt.compare(req.body.password, link.password); if (!matches) { throw new CustomError("Password is not correct.", 401); } // 4. Create visit if (link.user_id) { queue.visit.add({ userAgent: req.headers["user-agent"], ip: req.ip, country: req.get("cf-ipcountry"), referrer: req.get("Referrer"), link }); } // 5. Send target if (req.isHTML) { res.setHeader("HX-Redirect", link.target); res.render("partials/protected/form", { id: link.uuid, message: "Redirecting...", }); return; } return res.status(200).send({ target: link.target }); }; async function redirectCustomDomainHomepage(req, res, next) { const host = utils.removeWww(req.headers.host); if (host === env.DEFAULT_DOMAIN) { next(); return; } const path = req.path; const pathName = path.replace("/", "").split("/")[0]; if ( path === "/" || utils.preservedURLs.includes(pathName) ) { const domain = await query.domain.find({ address: host }); if (domain?.homepage) { res.redirect(302, domain.homepage); return; } } next(); }; async function stats(req, res) { const { user } = req; const uuid = req.params.id; const link = await query.link.find({ ...(!user.admin && { user_id: user.id }), uuid }); if (!link) { if (req.isHTML) { res.setHeader("HX-Redirect", "/404"); res.status(200).send(""); return; } throw new CustomError("Link could not be found."); } const stats = await query.visit.find({ link_id: link.id }, link.visit_count); if (!stats) { throw new CustomError("Could not get the short link stats. Try again later."); } if (req.isHTML) { res.render("partials/stats", { link: utils.sanitize.link_html(link), stats, map, }); return; } return res.status(200).send({ ...stats, ...utils.sanitize.link(link) }); }; module.exports = { ban, create, edit, editAdmin, get, getAdmin, remove, report, stats, redirect, redirectProtected, redirectCustomDomainHomepage, } ================================================ FILE: server/handlers/locals.handler.js ================================================ const query = require("../queries"); const utils = require("../utils"); const env = require("../env"); function isHTML(req, res, next) { const accepts = req.accepts(["json", "html"]); req.isHTML = accepts === "html"; next(); } function noLayout(req, res, next) { res.locals.layout = null; next(); } function viewTemplate(template) { return function (req, res, next) { req.viewTemplate = template; next(); } } function config(req, res, next) { res.locals.default_domain = env.DEFAULT_DOMAIN; res.locals.site_name = env.SITE_NAME; res.locals.contact_email = env.CONTACT_EMAIL; res.locals.server_ip_address = env.SERVER_IP_ADDRESS; res.locals.server_cname_address = env.SERVER_CNAME_ADDRESS; res.locals.disallow_registration = env.DISALLOW_REGISTRATION; res.locals.disallow_login_form = env.DISALLOW_LOGIN_FORM; res.locals.login_disabled = env.DISALLOW_LOGIN_FORM && !env.OIDC_ENABLED; res.locals.oidc_enabled = env.OIDC_ENABLED; res.locals.mail_enabled = env.MAIL_ENABLED; res.locals.report_email = env.REPORT_EMAIL; res.locals.custom_styles = utils.getCustomCSSFileNames(); next(); } async function user(req, res, next) { const user = req.user; res.locals.user = user; res.locals.domains = user && (await query.domain.get({ user_id: user.id })).map(utils.sanitize.domain); next(); } function newPassword(req, res, next) { res.locals.reset_password_token = req.body.reset_password_token; next(); } function createLink(req, res, next) { res.locals.show_advanced = !!req.body.show_advanced; next(); } function editLink(req, res, next) { res.locals.id = req.params.id; res.locals.class = "no-animation"; next(); } function protected(req, res, next) { res.locals.id = req.params.id; next(); } function adminTable(req, res, next) { res.locals.query = { anonymous: req.query.anonymous, domain: req.query.domain, domains: req.query.domains, links: req.query.links, role: req.query.role, search: req.query.search, user: req.query.user, verified: req.query.verified, }; next(); } module.exports = { adminTable, config, createLink, editLink, isHTML, newPassword, noLayout, protected, user, viewTemplate, } ================================================ FILE: server/handlers/renders.handler.js ================================================ const query = require("../queries"); const utils = require("../utils"); const env = require("../env"); /** * * PAGES * **/ async function homepage(req, res) { if (env.DISALLOW_ANONYMOUS_LINKS && !req.user) { res.redirect("/login"); return; } res.render("homepage", { title: "Free modern URL shortener", }); } async function login(req, res) { if (req.user) { res.redirect("/"); return; } res.render("login", { title: "Log in or sign up" }); } function logout(req, res) { utils.deleteCurrentToken(res); res.render("logout", { title: "Logging out.." }); } async function createAdmin(req, res) { const isThereAUser = await query.user.findAny(); if (isThereAUser) { res.redirect("/login"); return; } res.render("create_admin", { title: "Create admin account" }); } function notFound(req, res) { res.render("404", { title: "404 - Not found" }); } function settings(req, res) { res.render("settings", { title: "Settings" }); } function admin(req, res) { res.render("admin", { title: "Admin" }); } function stats(req, res) { res.render("stats", { title: "Stats" }); } async function banned(req, res) { res.render("banned", { title: "Banned link", }); } async function report(req, res) { if (!env.REPORT_EMAIL) { res.redirect("/"); return; } res.render("report", { title: "Report abuse", }); } async function resetPassword(req, res) { res.render("reset_password", { title: "Reset password", }); } async function resetPasswordSetNewPassword(req, res) { const reset_password_token = req.params.resetPasswordToken; if (reset_password_token) { const user = await query.user.find( { reset_password_token, reset_password_expires: [">", utils.dateToUTC(new Date())] } ); if (user) { res.locals.token_verified = true; } } res.render("reset_password_set_new_password", { title: "Reset password", ...(res.locals.token_verified && { reset_password_token }), }); } async function verifyChangeEmail(req, res) { res.render("verify_change_email", { title: "Verifying email", }); } async function verify(req, res) { res.render("verify", { title: "Verify", }); } async function terms(req, res) { res.render("terms", { title: "Terms of Service", }); } /** * * PARTIALS * **/ async function confirmLinkDelete(req, res) { const link = await query.link.find({ uuid: req.query.id, ...(!req.user.admin && { user_id: req.user.id }) }); if (!link) { return res.render("partials/links/dialog/message", { layout: false, message: "Could not find the link." }); } res.render("partials/links/dialog/delete", { layout: false, link: utils.getShortURL(link.address, link.domain).link, id: link.uuid }); } async function confirmLinkBan(req, res) { const link = await query.link.find({ uuid: req.query.id, ...(!req.user.admin && { user_id: req.user.id }) }); if (!link) { return res.render("partials/links/dialog/message", { message: "Could not find the link." }); } res.render("partials/links/dialog/ban", { link: utils.getShortURL(link.address, link.domain).link, id: link.uuid }); } async function confirmUserDelete(req, res) { const user = await query.user.find({ id: req.query.id }); if (!user) { return res.render("partials/admin/dialog/message", { layout: false, message: "Could not find the user." }); } res.render("partials/admin/dialog/delete_user", { layout: false, email: user.email, id: user.id }); } async function confirmUserBan(req, res) { const user = await query.user.find({ id: req.query.id }); if (!user) { return res.render("partials/admin/dialog/message", { layout: false, message: "Could not find the user." }); } res.render("partials/admin/dialog/ban_user", { layout: false, email: user.email, id: user.id }); } async function createUser(req, res) { res.render("partials/admin/dialog/create_user", { layout: false, }); } async function addDomainAdmin(req, res) { res.render("partials/admin/dialog/add_domain", { layout: false, }); } async function addDomainForm(req, res) { res.render("partials/settings/domain/add_form"); } async function confirmDomainDelete(req, res) { const domain = await query.domain.find({ uuid: req.query.id, user_id: req.user.id }); if (!domain) { throw new utils.CustomError("Could not find the domain.", 400); } res.render("partials/settings/domain/delete", { ...utils.sanitize.domain(domain) }); } async function confirmDomainBan(req, res) { const domain = await query.domain.find({ id: req.query.id }); if (!domain) { throw new utils.CustomError("Could not find the domain.", 400); } const hasUser = !!domain.user_id; const hasLink = await query.link.find({ domain_id: domain.id }); res.render("partials/admin/dialog/ban_domain", { id: domain.id, address: domain.address, hasUser, hasLink, }); } async function confirmDomainDeleteAdmin(req, res) { const domain = await query.domain.find({ id: req.query.id }); if (!domain) { throw new utils.CustomError("Could not find the domain.", 400); } const hasLink = await query.link.find({ domain_id: domain.id }); res.render("partials/admin/dialog/delete_domain", { id: domain.id, address: domain.address, hasLink, }); } async function getReportEmail(req, res) { if (!env.REPORT_EMAIL) { throw new utils.CustomError("No report email is available.", 400); } res.render("partials/report/email", { report_email_address: env.REPORT_EMAIL.replace("@", "[at]") }); } async function getSupportEmail(req, res) { if (!env.CONTACT_EMAIL) { throw new utils.CustomError("No support email is available.", 400); } await utils.sleep(500); res.render("partials/support_email", { email: env.CONTACT_EMAIL, }); } async function linkEdit(req, res) { const link = await query.link.find({ uuid: req.params.id, ...(!req.user.admin && { user_id: req.user.id }) }); res.render("partials/links/edit", { ...(link && utils.sanitize.link_html(link)), domain: link.domain || env.DEFAULT_DOMAIN, }); } async function linkEditAdmin(req, res) { const link = await query.link.find({ uuid: req.params.id, }); res.render("partials/admin/links/edit", { ...(link && utils.sanitize.link_html(link)), domain: link.domain || env.DEFAULT_DOMAIN, }); } module.exports = { addDomainAdmin, addDomainForm, admin, banned, confirmDomainBan, confirmDomainDelete, confirmDomainDeleteAdmin, confirmLinkBan, confirmLinkDelete, confirmUserBan, confirmUserDelete, createAdmin, createUser, getReportEmail, getSupportEmail, homepage, linkEdit, linkEditAdmin, login, logout, notFound, report, resetPassword, resetPasswordSetNewPassword, settings, stats, terms, verifyChangeEmail, verify, } ================================================ FILE: server/handlers/users.handler.js ================================================ const bcrypt = require("bcryptjs"); const query = require("../queries"); const utils = require("../utils"); const mail = require("../mail"); const env = require("../env"); async function get(req, res) { const domains = await query.domain.get({ user_id: req.user.id }); const data = { apikey: req.user.apikey, email: req.user.email, domains: domains.map(utils.sanitize.domain) }; return res.status(200).send(data); }; async function remove(req, res) { await query.user.remove(req.user); if (req.isHTML) { utils.deleteCurrentToken(res); res.setHeader("HX-Trigger-After-Swap", "redirectToHomepage"); res.render("partials/settings/delete_account", { success: "Account has been deleted. Logging out..." }); return; } return res.status(200).send("OK"); }; async function removeByAdmin(req, res) { const user = await query.user.find({ id: req.params.id }); if (!user) { const message = "Could not find the user."; if (req.isHTML) { return res.render("partials/admin/dialog/message", { layout: false, message }); } else { return res.status(400).send({ message }); } } await query.user.remove(user); if (req.isHTML) { res.setHeader("HX-Reswap", "outerHTML"); res.setHeader("HX-Trigger", "reloadMainTable"); res.render("partials/admin/dialog/delete_user_success", { email: user.email, }); return; } return res.status(200).send({ message: "User has been deleted successfully." }); }; async function getAdmin(req, res) { const { limit, skip, all } = req.context; const { role, search } = req.query; const userId = req.user.id; const verified = utils.parseBooleanQuery(req.query.verified); const banned = utils.parseBooleanQuery(req.query.banned); const domains = utils.parseBooleanQuery(req.query.domains); const links = utils.parseBooleanQuery(req.query.links); const match = { ...(role && { role }), ...(verified !== undefined && { verified }), ...(banned !== undefined && { banned }), }; const [data, total] = await Promise.all([ query.user.getAdmin(match, { limit, search, domains, links, skip }), query.user.totalAdmin(match, { search, domains, links }) ]); const users = data.map(utils.sanitize.user_admin); if (req.isHTML) { res.render("partials/admin/users/table", { total, total_formatted: total.toLocaleString("en-US"), limit, skip, users, }) return; } return res.send({ total, limit, skip, data: users, }); }; async function ban(req, res) { const { id } = req.params; const update = { banned_by_id: req.user.id, banned: true }; // 1. check if user exists const user = await query.user.find({ id }); if (!user) { throw new CustomError("No user has been found.", 400); } if (user.banned) { throw new CustomError("User has been banned already.", 400); } const tasks = []; // 2. ban user tasks.push(query.user.update({ id }, update)); // 3. ban user links if (req.body.links) { tasks.push(query.link.update({ user_id: id }, update)); } // 4. ban user domains if (req.body.domains) { tasks.push(query.domain.update({ user_id: id }, update)); } // 5. wait for all tasks to finish await Promise.all(tasks).catch((err) => { throw new CustomError("Couldn't ban entries."); }); // 6. send response if (req.isHTML) { res.setHeader("HX-Reswap", "outerHTML"); res.setHeader("HX-Trigger", "reloadMainTable"); res.render("partials/admin/dialog/ban_user_success", { email: user.email, }); return; } return res.status(200).send({ message: "Banned user successfully." }); } async function create(req, res) { const salt = await bcrypt.genSalt(12); req.body.password = await bcrypt.hash(req.body.password, salt); const user = await query.user.create(req.body); if (req.body.verification_email && !user.banned && !user.verified) { await mail.verification(user); } if (req.isHTML) { res.setHeader("HX-Trigger", "reloadMainTable"); res.render("partials/admin/dialog/create_user_success", { email: user.email, }); return; } return res.status(201).send({ message: "The user has been created successfully." }); } module.exports = { ban, create, get, getAdmin, remove, removeByAdmin, } ================================================ FILE: server/handlers/validators.handler.js ================================================ const { addMilliseconds } = require("date-fns"); const { body, param, query: queryValidator } = require("express-validator"); const promisify = require("node:util").promisify; const bcrypt = require("bcryptjs"); const dns = require("node:dns"); const URL = require("node:url"); const ms = require("ms"); const { ROLES } = require("../consts"); const query = require("../queries"); const utils = require("../utils"); const knex = require("../knex"); const env = require("../env"); const dnsLookup = promisify(dns.lookup); const checkUser = (value, { req }) => !!req.user; const sanitizeCheckbox = value => value === true || value === "on" || value; const createLink = [ body("target") .exists({ checkNull: true, checkFalsy: true }) .withMessage("Target is missing.") .isString() .trim() .isLength({ min: 1, max: 2040 }) .withMessage("Maximum URL length is 2040.") .customSanitizer(utils.addProtocol) .custom(value => utils.urlRegex.test(value) || /^(?!https?|ftp)(\w+:|\/\/)/.test(value)) .withMessage("URL is not valid.") .custom(value => utils.removeWww(URL.parse(value).host) !== env.DEFAULT_DOMAIN) .withMessage(`${env.DEFAULT_DOMAIN} URLs are not allowed.`), body("password") .optional({ nullable: true, checkFalsy: true }) .custom(checkUser) .withMessage("Only users can use this field.") .isString() .isLength({ min: 3, max: 64 }) .withMessage("Password length must be between 3 and 64."), body("customurl") .optional({ nullable: true, checkFalsy: true }) .custom(checkUser) .withMessage("Only users can use this field.") .isString() .trim() .isLength({ min: 1, max: 64 }) .withMessage("Custom URL length must be between 1 and 64.") .custom(value => utils.customAddressRegex.test(value) || utils.customAlphabetRegex.test(value)) .withMessage("Custom URL is not valid.") .custom(value => !utils.preservedURLs.some(url => url.toLowerCase() === value)) .withMessage("You can't use this custom URL."), body("reuse") .optional({ nullable: true }) .custom(checkUser) .withMessage("Only users can use this field.") .isBoolean() .withMessage("Reuse must be boolean."), body("description") .optional({ nullable: true, checkFalsy: true }) .isString() .trim() .isLength({ min: 1, max: 2040 }) .withMessage("Description length must be between 1 and 2040."), body("expire_in") .optional({ nullable: true, checkFalsy: true }) .isString() .trim() .custom(value => { try { return !!ms(value); } catch { return false; } }) .withMessage("Expire format is invalid. Valid examples: 1m, 8h, 42 days.") .customSanitizer(ms) .custom(value => value >= ms("1m")) .withMessage("Expire time should be more than 1 minute.") .customSanitizer(value => utils.dateToUTC(addMilliseconds(new Date(), value))), body("domain") .optional({ nullable: true, checkFalsy: true }) .customSanitizer(value => value === env.DEFAULT_DOMAIN ? null : value) .custom(checkUser) .withMessage("Only users can use this field.") .isString() .withMessage("Domain should be string.") .customSanitizer(value => value.toLowerCase()) .custom(async (address, { req }) => { const domain = await query.domain.find({ address, user_id: req.user.id }); req.body.fetched_domain = domain || null; if (!domain) return Promise.reject(); }) .withMessage("You can't use this domain.") ]; const editLink = [ body("target") .optional({ checkFalsy: true, nullable: true }) .isString() .trim() .isLength({ min: 1, max: 2040 }) .withMessage("Maximum URL length is 2040.") .customSanitizer(utils.addProtocol) .custom(value => utils.urlRegex.test(value) || /^(?!https?|ftp)(\w+:|\/\/)/.test(value)) .withMessage("URL is not valid.") .custom(value => utils.removeWww(URL.parse(value).host) !== env.DEFAULT_DOMAIN) .withMessage(`${env.DEFAULT_DOMAIN} URLs are not allowed.`), body("password") .optional({ nullable: true, checkFalsy: true }) .isString() .isLength({ min: 3, max: 64 }) .withMessage("Password length must be between 3 and 64."), body("address") .optional({ checkFalsy: true, nullable: true }) .isString() .trim() .isLength({ min: 1, max: 64 }) .withMessage("Custom URL length must be between 1 and 64.") .custom(value => utils.customAddressRegex.test(value) || utils.customAlphabetRegex.test(value)) .withMessage("Custom URL is not valid") .custom(value => !utils.preservedURLs.some(url => url.toLowerCase() === value)) .withMessage("You can't use this custom URL."), body("expire_in") .optional({ nullable: true, checkFalsy: true }) .isString() .trim() .custom(value => { try { return !!ms(value); } catch { return false; } }) .withMessage("Expire format is invalid. Valid examples: 1m, 8h, 42 days.") .customSanitizer(ms) .custom(value => value >= ms("1m")) .withMessage("Expire time should be more than 1 minute.") .customSanitizer(value => utils.dateToUTC(addMilliseconds(new Date(), value))), body("description") .optional({ nullable: true, checkFalsy: true }) .isString() .trim() .isLength({ min: 0, max: 2040 }) .withMessage("Description length must be between 0 and 2040."), param("id", "ID is invalid.") .exists({ checkFalsy: true, checkNull: true }) .isLength({ min: 36, max: 36 }) ]; const redirectProtected = [ body("password", "Password is invalid.") .exists({ checkFalsy: true, checkNull: true }) .isString() .isLength({ min: 3, max: 64 }) .withMessage("Password length must be between 3 and 64."), param("id", "ID is invalid.") .exists({ checkFalsy: true, checkNull: true }) .isLength({ min: 36, max: 36 }) ]; const addDomain = [ body("address", "Domain is not valid.") .exists({ checkFalsy: true, checkNull: true }) .isLength({ min: 3, max: 64 }) .withMessage("Domain length must be between 3 and 64.") .trim() .customSanitizer(utils.addProtocol) .custom(value => utils.urlRegex.test(value)) .customSanitizer(value => { const parsed = URL.parse(value); return utils.removeWww(parsed.hostname || parsed.href); }) .custom(value => value !== env.DEFAULT_DOMAIN) .withMessage("You can't use the default domain.") .custom(async value => { const domain = await query.domain.find({ address: value }); if (domain?.user_id || domain?.banned) return Promise.reject(); }) .withMessage("You can't add this domain."), body("homepage") .optional({ checkFalsy: true, nullable: true }) .customSanitizer(utils.addProtocol) .custom(value => utils.urlRegex.test(value) || /^(?!https?|ftp)(\w+:|\/\/)/.test(value)) .withMessage("Homepage is not valid.") ]; const addDomainAdmin = [ body("address", "Domain is not valid.") .exists({ checkFalsy: true, checkNull: true }) .isLength({ min: 3, max: 64 }) .withMessage("Domain length must be between 3 and 64.") .trim() .customSanitizer(utils.addProtocol) .custom(value => utils.urlRegex.test(value)) .customSanitizer(value => { const parsed = URL.parse(value); return utils.removeWww(parsed.hostname || parsed.href); }) .custom(value => value !== env.DEFAULT_DOMAIN) .withMessage("You can't add the default domain.") .custom(async value => { const domain = await query.domain.find({ address: value }); if (domain) return Promise.reject(); }) .withMessage("Domain already exists."), body("homepage") .optional({ checkFalsy: true, nullable: true }) .customSanitizer(utils.addProtocol) .custom(value => utils.urlRegex.test(value) || /^(?!https?|ftp)(\w+:|\/\/)/.test(value)) .withMessage("Homepage is not valid."), body("banned") .optional({ nullable: true }) .customSanitizer(sanitizeCheckbox) .isBoolean(), ] const removeDomain = [ param("id", "ID is invalid.") .exists({ checkFalsy: true, checkNull: true }) .isLength({ min: 36, max: 36 }) ]; const removeDomainAdmin = [ param("id", "ID is invalid.") .exists({ checkFalsy: true, checkNull: true }) .isNumeric(), queryValidator("links") .optional({ nullable: true }) .customSanitizer(sanitizeCheckbox) .isBoolean(), ]; const deleteLink = [ param("id", "ID is invalid.") .exists({ checkFalsy: true, checkNull: true }) .isLength({ min: 36, max: 36 }) ]; const reportLink = [ body("link", "No link has been provided.") .exists({ checkFalsy: true, checkNull: true }) .customSanitizer(utils.addProtocol) .custom( value => utils.removeWww(URL.parse(value).host) === env.DEFAULT_DOMAIN ) .withMessage(`You can only report a ${env.DEFAULT_DOMAIN} link.`) ]; const banLink = [ param("id", "ID is invalid.") .exists({ checkFalsy: true, checkNull: true }) .isLength({ min: 36, max: 36 }), body("host", '"host" should be a boolean.') .optional({ nullable: true }) .customSanitizer(sanitizeCheckbox) .isBoolean(), body("user", '"user" should be a boolean.') .optional({ nullable: true }) .customSanitizer(sanitizeCheckbox) .isBoolean(), body("userLinks", '"userLinks" should be a boolean.') .optional({ nullable: true }) .customSanitizer(sanitizeCheckbox) .isBoolean(), body("domain", '"domain" should be a boolean.') .optional({ nullable: true }) .customSanitizer(sanitizeCheckbox) .isBoolean() ]; const banUser = [ param("id", "ID is invalid.") .exists({ checkFalsy: true, checkNull: true }) .isNumeric(), body("links", '"links" should be a boolean.') .optional({ nullable: true }) .customSanitizer(sanitizeCheckbox) .isBoolean(), body("domains", '"domains" should be a boolean.') .optional({ nullable: true }) .customSanitizer(sanitizeCheckbox) .isBoolean() ]; const banDomain = [ param("id", "ID is invalid.") .exists({ checkFalsy: true, checkNull: true }) .isNumeric(), body("links", '"links" should be a boolean.') .optional({ nullable: true }) .customSanitizer(sanitizeCheckbox) .isBoolean(), body("domains", '"domains" should be a boolean.') .optional({ nullable: true }) .customSanitizer(sanitizeCheckbox) .isBoolean() ]; const createUser = [ body("password", "Password is not valid.") .exists({ checkFalsy: true, checkNull: true }) .isLength({ min: 8, max: 64 }) .withMessage("Password length must be between 8 and 64."), body("email", "Email is not valid.") .exists({ checkFalsy: true, checkNull: true }) .trim() .isLength({ min: 1, max: 255 }) .withMessage("Email length must be max 255.") .isEmail() .custom(async (value, { req }) => { const user = await query.user.find({ email: value }); if (user) return Promise.reject(); }) .withMessage("User already exists."), body("role", "Role is not valid.") .optional({ nullable: true, checkFalsy: true }) .trim() .isIn([ROLES.USER, ROLES.ADMIN]), body("verified") .optional({ nullable: true }) .customSanitizer(sanitizeCheckbox) .isBoolean(), body("banned") .optional({ nullable: true }) .customSanitizer(sanitizeCheckbox) .isBoolean(), body("verification_email") .optional({ nullable: true }) .customSanitizer(sanitizeCheckbox) .isBoolean(), ]; const getStats = [ param("id", "ID is invalid.") .exists({ checkFalsy: true, checkNull: true }) .isLength({ min: 36, max: 36 }) ]; const signup = [ body("password", "Password is not valid.") .exists({ checkFalsy: true, checkNull: true }) .isLength({ min: 8, max: 64 }) .withMessage("Password length must be between 8 and 64."), body("email", "Email is not valid.") .exists({ checkFalsy: true, checkNull: true }) .trim() .isLength({ min: 0, max: 255 }) .withMessage("Email length must be max 255.") .isEmail() ]; const signupEmailTaken = [ body("email", "Email is not valid.") .custom(async (value, { req }) => { const user = await query.user.find({ email: value }); if (user) { req.user = user; } if (user?.verified) { return Promise.reject(); } }) .withMessage("You can't use this email address.") ]; const login = [ body("password", "Password is not valid.") .exists({ checkFalsy: true, checkNull: true }) .isLength({ min: 8, max: 64 }) .withMessage("Password length must be between 8 and 64."), body("email", "Email is not valid.") .exists({ checkFalsy: true, checkNull: true }) .trim() .isLength({ min: 1, max: 255 }) .withMessage("Email length must be max 255.") .isEmail() ]; const createAdmin = [ body("password", "Password is not valid.") .exists({ checkFalsy: true, checkNull: true }) .isLength({ min: 8, max: 64 }) .withMessage("Password length must be between 8 and 64."), body("email", "Email is not valid.") .exists({ checkFalsy: true, checkNull: true }) .trim() .isLength({ min: 0, max: 255 }) .withMessage("Email length must be max 255.") .isEmail() ]; const changePassword = [ body("currentpassword", "Password is not valid.") .exists({ checkFalsy: true, checkNull: true }) .isLength({ min: 8, max: 64 }) .withMessage("Password length must be between 8 and 64."), body("newpassword", "Password is not valid.") .exists({ checkFalsy: true, checkNull: true }) .isLength({ min: 8, max: 64 }) .withMessage("Password length must be between 8 and 64.") ]; const changeEmail = [ body("password", "Password is not valid.") .exists({ checkFalsy: true, checkNull: true }) .isLength({ min: 8, max: 64 }) .withMessage("Password length must be between 8 and 64."), body("email", "Email address is not valid.") .exists({ checkFalsy: true, checkNull: true }) .trim() .isLength({ min: 1, max: 255 }) .withMessage("Email length must be max 255.") .isEmail() ]; const resetPassword = [ body("email", "Email is not valid.") .exists({ checkFalsy: true, checkNull: true }) .trim() .isLength({ min: 0, max: 255 }) .withMessage("Email length must be max 255.") .isEmail() ]; const newPassword = [ body("reset_password_token", "Reset password token is invalid.") .exists({ checkFalsy: true, checkNull: true }) .isLength({ min: 36, max: 36 }), body("new_password", "Password is not valid.") .exists({ checkFalsy: true, checkNull: true }) .isLength({ min: 8, max: 64 }) .withMessage("Password length must be between 8 and 64."), body("repeat_password", "Password is not valid.") .custom((repeat_password, { req }) => { return repeat_password === req.body.new_password; }) .withMessage("Passwords don't match."), ]; const deleteUser = [ body("password", "Password is not valid.") .exists({ checkFalsy: true, checkNull: true }) .isLength({ min: 8, max: 64 }) .custom(async (password, { req }) => { const isMatch = await bcrypt.compare(password, req.user.password); if (!isMatch) return Promise.reject(); }) .withMessage("Password is not correct.") ]; const deleteUserByAdmin = [ param("id", "ID is invalid.") .exists({ checkFalsy: true, checkNull: true }) .isNumeric() ]; async function bannedDomain(domain) { const isBanned = await query.domain.find({ address: domain, banned: true }); if (isBanned) { throw new utils.CustomError("Domain is banned.", 400); } }; async function bannedHost(domain) { let isBanned; try { const dnsRes = await dnsLookup(domain); if (!dnsRes || !dnsRes.address) return; isBanned = await query.host.find({ address: dnsRes.address, banned: true }); } catch (error) { isBanned = null; } if (isBanned) { throw new utils.CustomError("URL is containing malware/scam.", 400); } }; module.exports = { addDomain, addDomainAdmin, banDomain, banLink, banUser, bannedDomain, bannedHost, changeEmail, changePassword, checkUser, createAdmin, createLink, createUser, deleteLink, deleteUser, deleteUserByAdmin, editLink, getStats, login, newPassword, redirectProtected, removeDomain, removeDomainAdmin, reportLink, resetPassword, signup, signupEmailTaken, } ================================================ FILE: server/knex.js ================================================ const knex = require("knex"); const env = require("./env"); const isSQLite = env.DB_CLIENT === "sqlite3" || env.DB_CLIENT === "better-sqlite3"; const isPostgres = env.DB_CLIENT === "pg" || env.DB_CLIENT === "pg-native"; const isMySQL = env.DB_CLIENT === "mysql" || env.DB_CLIENT === "mysql2"; const db = knex({ client: env.DB_CLIENT, connection: { ...(isSQLite && { filename: env.DB_FILENAME }), host: env.DB_HOST, port: env.DB_PORT, database: env.DB_NAME, user: env.DB_USER, password: env.DB_PASSWORD, ssl: env.DB_SSL, pool: { min: env.DB_POOL_MIN, max: env.DB_POOL_MAX } }, useNullAsDefault: true, }); db.isPostgres = isPostgres; db.isSQLite = isSQLite; db.isMySQL = isMySQL; db.compatibleILIKE = isPostgres ? "andWhereILike" : "andWhereLike"; module.exports = db; ================================================ FILE: server/mail/index.js ================================================ module.exports = require("./mail"); ================================================ FILE: server/mail/mail.js ================================================ const nodemailer = require("nodemailer"); const path = require("node:path"); const fs = require("node:fs"); const { resetMailText, verifyMailText, changeEmailText } = require("./text"); const { CustomError } = require("../utils"); const env = require("../env"); const mailConfig = { host: env.MAIL_HOST, port: env.MAIL_PORT, secure: env.MAIL_SECURE, auth: env.MAIL_USER ? { user: env.MAIL_USER, pass: env.MAIL_PASSWORD } : undefined }; const transporter = nodemailer.createTransport(mailConfig); // Read email templates const resetEmailTemplatePath = path.join(__dirname, "template-reset.html"); const verifyEmailTemplatePath = path.join(__dirname, "template-verify.html"); const changeEmailTemplatePath = path.join(__dirname,"template-change-email.html"); let resetEmailTemplate, verifyEmailTemplate, changeEmailTemplate; // only read email templates if email is enabled if (env.MAIL_ENABLED) { resetEmailTemplate = fs .readFileSync(resetEmailTemplatePath, { encoding: "utf-8" }) .replace(/{{domain}}/gm, env.DEFAULT_DOMAIN) .replace(/{{site_name}}/gm, env.SITE_NAME); verifyEmailTemplate = fs .readFileSync(verifyEmailTemplatePath, { encoding: "utf-8" }) .replace(/{{domain}}/gm, env.DEFAULT_DOMAIN) .replace(/{{site_name}}/gm, env.SITE_NAME); changeEmailTemplate = fs .readFileSync(changeEmailTemplatePath, { encoding: "utf-8" }) .replace(/{{domain}}/gm, env.DEFAULT_DOMAIN) .replace(/{{site_name}}/gm, env.SITE_NAME); } async function verification(user) { if (!env.MAIL_ENABLED) { throw new Error("Attempting to send verification email but email is not enabled."); }; const mail = await transporter.sendMail({ from: env.MAIL_FROM || env.MAIL_USER, to: user.email, subject: "Verify your account", text: verifyMailText .replace(/{{verification}}/gim, user.verification_token) .replace(/{{domain}}/gm, env.DEFAULT_DOMAIN) .replace(/{{site_name}}/gm, env.SITE_NAME), html: verifyEmailTemplate .replace(/{{verification}}/gim, user.verification_token) .replace(/{{domain}}/gm, env.DEFAULT_DOMAIN) .replace(/{{site_name}}/gm, env.SITE_NAME) }); if (!mail.accepted.length) { throw new CustomError("Couldn't send verification email. Try again later."); } } async function changeEmail(user) { if (!env.MAIL_ENABLED) { throw new Error("Attempting to send change email token but email is not enabled."); }; const mail = await transporter.sendMail({ from: env.MAIL_FROM || env.MAIL_USER, to: user.change_email_address, subject: "Verify your new email address", text: changeEmailText .replace(/{{verification}}/gim, user.change_email_token) .replace(/{{domain}}/gm, env.DEFAULT_DOMAIN) .replace(/{{site_name}}/gm, env.SITE_NAME), html: changeEmailTemplate .replace(/{{verification}}/gim, user.change_email_token) .replace(/{{domain}}/gm, env.DEFAULT_DOMAIN) .replace(/{{site_name}}/gm, env.SITE_NAME) }); if (!mail.accepted.length) { throw new CustomError("Couldn't send verification email. Try again later."); } } async function resetPasswordToken(user) { if (!env.MAIL_ENABLED) { throw new Error("Attempting to send reset password email but email is not enabled."); }; const mail = await transporter.sendMail({ from: env.MAIL_FROM || env.MAIL_USER, to: user.email, subject: "Reset your password", text: resetMailText .replace(/{{resetpassword}}/gm, user.reset_password_token) .replace(/{{domain}}/gm, env.DEFAULT_DOMAIN), html: resetEmailTemplate .replace(/{{resetpassword}}/gm, user.reset_password_token) .replace(/{{domain}}/gm, env.DEFAULT_DOMAIN) }); if (!mail.accepted.length) { throw new CustomError( "Couldn't send reset password email. Try again later." ); } } async function sendReportEmail(link) { if (!env.MAIL_ENABLED) { throw new Error("Attempting to send report email but email is not enabled."); }; const mail = await transporter.sendMail({ from: env.MAIL_FROM || env.MAIL_USER, to: env.REPORT_EMAIL, subject: "[REPORT]", text: link, html: link }); if (!mail.accepted.length) { throw new CustomError("Couldn't submit the report. Try again later."); } } module.exports = { changeEmail, verification, resetPasswordToken, sendReportEmail, } ================================================ FILE: server/mail/template-change-email.html ================================================

{{site_name}}.

You're attempting to change your email address on {{domain}}.

Please verify your email address using the link below.

================================================ FILE: server/mail/template-reset.html ================================================

{{site_name}}.

A password reset has been requested for your account.

Please click on the button below to reset your password. There's no need to take any action if you didn't request this.

================================================ FILE: server/mail/template-verify.html ================================================

{{site_name}}.

Thanks for creating an account on {{domain}}.

Please verify your email address using the link below.

================================================ FILE: server/mail/text.js ================================================ module.exports.verifyMailText = `You're attempting to change your email address on {{site_name}}. Please verify your email address using the link below. https://{{domain}}/verify/{{verification}}`; module.exports.changeEmailText = `Thanks for creating an account on {{site_name}}. Please verify your email address using the link below. https://{{domain}}/verify-email/{{verification}}`; module.exports.resetMailText = `A password reset has been requested for your account. Please click on the button below to reset your password. There's no need to take any action if you didn't request this. https://{{domain}}/reset-password/{{resetpassword}}`; ================================================ FILE: server/migrations/20200211220920_constraints.js ================================================ const models = require("../models"); async function up(knex) { await models.createUserTable(knex); await models.createIPTable(knex); await models.createDomainTable(knex); await models.createHostTable(knex); await models.createLinkTable(knex); await models.createVisitTable(knex); } async function down() { // do nothing } module.exports = { up, down } ================================================ FILE: server/migrations/20200510140704_domains.js ================================================ const models = require("../models"); async function up(knex) { await models.createUserTable(knex); await models.createIPTable(knex); await models.createDomainTable(knex); await models.createHostTable(knex); await models.createLinkTable(knex); await models.createVisitTable(knex); // drop unique user id constraint only if database is postgres // because other databases use the new version of the app and they start fresh with the correct model // if i use table.dropUnique() method it would throw error on fresh install because the constraint does not exist // and if it throws error, the rest of the transactions fail as well if (knex.client.driverName === "pg") { knex.raw(` ALTER TABLE domains DROP CONSTRAINT IF EXISTS domains_user_id_unique `) } const hasUUID = await knex.schema.hasColumn("domains", "uuid"); if (!hasUUID) { await knex.schema.alterTable("domains", (table) => { table.uuid("uuid").notNullable().defaultTo(knex.fn.uuid()); }); } } async function down() { // do nothing } module.exports = { up, down } ================================================ FILE: server/migrations/20200718124944_description.js ================================================ async function up(knex) { const hasDescription = await knex.schema.hasColumn("links", "description"); if (!hasDescription) { await knex.schema.alterTable("links", table => { table.string("description"); }); } } async function down() { return null; } module.exports = { up, down } ================================================ FILE: server/migrations/20200730203154_expire_in.js ================================================ async function up(knex) { const hasExpireIn = await knex.schema.hasColumn("links", "expire_in"); if (!hasExpireIn) { await knex.schema.alterTable("links", table => { table.dateTime("expire_in"); }); } } async function down() { return null; } module.exports = { up, down } ================================================ FILE: server/migrations/20200810195255_change_email.js ================================================ async function up(knex) { const hasChangeEmail = await knex.schema.hasColumn( "users", "change_email_token" ); if (!hasChangeEmail) { await knex.schema.alterTable("users", table => { table.dateTime("change_email_expires"); table.string("change_email_token"); table.string("change_email_address"); }); } } async function down() { return null; } module.exports = { up, down } ================================================ FILE: server/migrations/20241103083933_user-roles.js ================================================ const { ROLES } = require("../consts"); /** * @param { import("knex").Knex } knex * @returns { Promise } */ async function up(knex) { const hasRole = await knex.schema.hasColumn("users", "role"); if (!hasRole) { await knex.transaction(async function(trx) { await trx.schema.alterTable("users", table => { table .enu("role", [ROLES.USER, ROLES.ADMIN]) .notNullable() .defaultTo(ROLES.USER); }); if (typeof process.env.ADMIN_EMAILS === "string") { const adminEmails = process.env.ADMIN_EMAILS.split(",").map((e) => e.trim()); const adminRoleQuery = trx("users").update("role", ROLES.ADMIN); adminEmails.forEach((adminEmail, index) => { if (index === 0) { adminRoleQuery.where("email", adminEmail); } else { adminRoleQuery.orWhere("email", adminEmail); } }); await adminRoleQuery; } }); } }; /** * @param { import("knex").Knex } knex * @returns { Promise } */ async function down(knex) {}; module.exports = { up, down, } ================================================ FILE: server/migrations/20241223062111_indexes.js ================================================ const env = require("../env"); const isMySQL = env.DB_CLIENT === "mysql" || env.DB_CLIENT === "mysql2"; /** * @param { import("knex").Knex } knex * @returns { Promise } */ async function up(knex) { // make apikey unique await knex.schema.alterTable("users", function(table) { table.unique("apikey"); }); // IF NOT EXISTS is not available on MySQL So if you're // using MySQL you should make sure you don't have these indexes already const ifNotExists = isMySQL ? "" : "IF NOT EXISTS"; // create them separately because one string with break lines didn't work on MySQL await Promise.all([ knex.raw(`CREATE INDEX ${ifNotExists} links_domain_id_index ON links (domain_id);`), knex.raw(`CREATE INDEX ${ifNotExists} links_user_id_index ON links (user_id);`), knex.raw(`CREATE INDEX ${ifNotExists} links_address_index ON links (address);`), knex.raw(`CREATE INDEX ${ifNotExists} links_expire_in_index ON links (expire_in);`), knex.raw(`CREATE INDEX ${ifNotExists} domains_address_index ON domains (address);`), knex.raw(`CREATE INDEX ${ifNotExists} domains_user_id_index ON domains (user_id);`), knex.raw(`CREATE INDEX ${ifNotExists} hosts_address_index ON hosts (address);`), knex.raw(`CREATE INDEX ${ifNotExists} visits_link_id_index ON visits (link_id);`), ]); }; /** * @param { import("knex").Knex } knex * @returns { Promise } */ async function down(knex) { await knex.schema.alterTable("users", function(table) { table.dropUnique(["apikey"]); }); await Promise.all([ knex.raw(`DROP INDEX links_domain_id_index;`), knex.raw(`DROP INDEX links_user_id_index;`), knex.raw(`DROP INDEX links_address_index;`), knex.raw(`DROP INDEX links_expire_in_index;`), knex.raw(`DROP INDEX domains_address_index;`), knex.raw(`DROP INDEX domains_user_id_index;`), knex.raw(`DROP INDEX hosts_address_index;`), knex.raw(`DROP INDEX visits_link_id_index;`), ]); }; module.exports = { up, down, } ================================================ FILE: server/migrations/20241223103044_visits_user_id.js ================================================ /** * @param { import("knex").Knex } knex * @returns { Promise } */ async function up(knex) { const hasUserIDColumn = await knex.schema.hasColumn("visits", "user_id"); if (hasUserIDColumn) return; await knex.schema.alterTable("visits", function(table) { table .integer("user_id") .unsigned(); table .foreign("user_id") .references("id") .inTable("users") .onDelete("CASCADE") .withKeyName("visits_user_id_foreign"); }); const [{ count }] = await knex("visits").count("* as count"); const count_number = parseInt(count); if (Number.isNaN(count_number) || count_number === 0) return; if (count_number < 1_000_000) { const last_visit = await knex("visits").orderBy("id", "desc").first(); const size = 100_000; const loops = Math.floor(last_visit.id / size) + 1; await Promise.all( new Array(loops).fill(null).map((_, i) => { return knex("visits") .fromRaw(knex.raw("visits v")) .update({ user_id: knex.ref("links.user_id") }) .updateFrom("links") .where("links.id", knex.ref("link_id")) .andWhereBetween("v.id", [i * size, (i * size) + size]); }) ); } else { console.warn( "MIGRATION WARN:" + "Skipped adding user_id to visits due to high volume of visits and the potential risk of locking the database.\n" + "Please refer to Kutt's migration guide for more information." ); } }; /** * @param { import("knex").Knex } knex * @returns { Promise } */ async function down(knex) {}; module.exports = { up, down, } ================================================ FILE: server/migrations/20241223155527_visits_user_id_index.js ================================================ const env = require("../env"); const isMySQL = env.DB_CLIENT === "mysql" || env.DB_CLIENT === "mysql2"; /** * @param { import("knex").Knex } knex * @returns { Promise } */ async function up(knex) { // IF NOT EXISTS is not available on MySQL So if you're // using MySQL you should make sure you don't have these indexes already const ifNotExists = isMySQL ? "" : "IF NOT EXISTS"; await knex.raw(` CREATE INDEX ${ifNotExists} visits_user_id_index ON visits (user_id); `); }; /** * @param { import("knex").Knex } knex * @returns { Promise } */ async function down(knex) { await knex.raw(` DROP INDEX visits_user_id_index; `); }; module.exports = { up, down, } ================================================ FILE: server/migrations/20250106070444_remove_cooldown.js ================================================ async function up(knex) { const hasCooldowns = await knex.schema.hasColumn("users", "cooldowns"); if (hasCooldowns) { await knex.schema.alterTable("users", table => { table.dropColumn("cooldowns"); }); } const hasCooldown = await knex.schema.hasColumn("users", "cooldown"); if (hasCooldown) { await knex.schema.alterTable("users", table => { table.dropColumn("cooldown"); }); } const hasMaliciousAttempts = await knex.schema.hasColumn("users", "malicious_attempts"); if (hasMaliciousAttempts) { await knex.schema.alterTable("users", table => { table.dropColumn("malicious_attempts"); }); } } async function down(knex) {} module.exports = { up, down }; ================================================ FILE: server/models/domain.model.js ================================================ async function createDomainTable(knex) { const hasTable = await knex.schema.hasTable("domains"); if (!hasTable) { await knex.schema.createTable("domains", table => { table.increments("id").primary(); table .boolean("banned") .notNullable() .defaultTo(false); table .integer("banned_by_id") .unsigned() .references("id") .inTable("users"); table .string("address") .unique() .notNullable(); table.string("homepage").nullable(); table .integer("user_id") .unsigned(); table .foreign("user_id") .references("id") .inTable("users") .onDelete("SET NULL") .withKeyName("domains_user_id_foreign"); table .uuid("uuid") .notNullable() .defaultTo(knex.fn.uuid()); table.timestamps(false, true); }); } } module.exports = { createDomainTable } ================================================ FILE: server/models/host.model.js ================================================ async function createHostTable(knex) { const hasTable = await knex.schema.hasTable("hosts"); if (!hasTable) { await knex.schema.createTable("hosts", table => { table.increments("id").primary(); table .string("address") .unique() .notNullable(); table .boolean("banned") .notNullable() .defaultTo(false); table .integer("banned_by_id") .unsigned() .references("id") .inTable("users"); table.timestamps(false, true); }); } } module.exports = { createHostTable } ================================================ FILE: server/models/index.js ================================================ module.exports = { ...require("./domain.model"), ...require("./host.model"), ...require("./ip.model"), ...require("./link.model"), ...require("./user.model"), ...require("./visit.model"), } ================================================ FILE: server/models/ip.model.js ================================================ async function createIPTable(knex) { const hasTable = await knex.schema.hasTable("ips"); if (!hasTable) { await knex.schema.createTable("ips", table => { table.increments("id").primary(); table .string("ip") .unique() .notNullable(); table.timestamps(false, true); }); } } module.exports = { createIPTable } ================================================ FILE: server/models/link.model.js ================================================ async function createLinkTable(knex) { const hasTable = await knex.schema.hasTable("links"); if (!hasTable) { await knex.schema.createTable("links", table => { table.increments("id").primary(); table.string("address").notNullable(); table.string("description"); table .boolean("banned") .notNullable() .defaultTo(false); table .integer("banned_by_id") .unsigned() .references("id") .inTable("users"); table .integer("domain_id") .unsigned() .references("id") .inTable("domains"); table.string("password"); table.dateTime("expire_in"); table.string("target", 2040).notNullable(); table .integer("user_id") .unsigned(); table .foreign("user_id") .references("id") .inTable("users") .onDelete("CASCADE") .withKeyName("links_user_id_foreign"); table .integer("visit_count") .notNullable() .defaultTo(0); table .uuid("uuid") .notNullable() .defaultTo(knex.fn.uuid()); table.timestamps(false, true); }); } const hasUUID = await knex.schema.hasColumn("links", "uuid"); if (!hasUUID) { await knex.schema.alterTable("links", table => { table .uuid("uuid") .notNullable() .defaultTo(knex.fn.uuid()); }); } } module.exports = { createLinkTable } ================================================ FILE: server/models/user.model.js ================================================ const { ROLES } = require("../consts"); async function createUserTable(knex) { const hasTable = await knex.schema.hasTable("users"); if (!hasTable) { await knex.schema.createTable("users", table => { table.increments("id").primary(); table.string("apikey"); table .boolean("banned") .notNullable() .defaultTo(false); table .integer("banned_by_id") .unsigned() .references("id") .inTable("users"); table .string("email") .unique() .notNullable(); table .enu("role", [ROLES.USER, ROLES.ADMIN]) .notNullable() .defaultTo(ROLES.USER); table.string("password").notNullable(); table.dateTime("reset_password_expires"); table.string("reset_password_token"); table.dateTime("change_email_expires"); table.string("change_email_token"); table.string("change_email_address"); table.dateTime("verification_expires"); table.string("verification_token"); table .boolean("verified") .notNullable() .defaultTo(false); table.timestamps(false, true); }); } } module.exports = { createUserTable }; ================================================ FILE: server/models/visit.model.js ================================================ async function createVisitTable(knex) { const hasTable = await knex.schema.hasTable("visits"); if (!hasTable) { await knex.schema.createTable("visits", table => { table.increments("id").primary(); table.jsonb("countries"); table .dateTime("created_at") .notNullable() .defaultTo(knex.fn.now()); table.dateTime("updated_at").defaultTo(knex.fn.now()); table .integer("link_id") .unsigned(); table .foreign("link_id") .references("id") .inTable("links") .onDelete("CASCADE") .withKeyName("visits_link_id_foreign"); table .integer("user_id") .unsigned(); table .foreign("user_id") .references("id") .inTable("users") .onDelete("CASCADE") .withKeyName("visits_user_id_foreign"); table.jsonb("referrers"); table .integer("total") .notNullable() .defaultTo(0); table .integer("br_chrome") .notNullable() .defaultTo(0); table .integer("br_edge") .notNullable() .defaultTo(0); table .integer("br_firefox") .notNullable() .defaultTo(0); table .integer("br_ie") .notNullable() .defaultTo(0); table .integer("br_opera") .notNullable() .defaultTo(0); table .integer("br_other") .notNullable() .defaultTo(0); table .integer("br_safari") .notNullable() .defaultTo(0); table .integer("os_android") .notNullable() .defaultTo(0); table .integer("os_ios") .notNullable() .defaultTo(0); table .integer("os_linux") .notNullable() .defaultTo(0); table .integer("os_macos") .notNullable() .defaultTo(0); table .integer("os_other") .notNullable() .defaultTo(0); table .integer("os_windows") .notNullable() .defaultTo(0); }); } const hasUpdatedAt = await knex.schema.hasColumn("visits", "updated_at"); if (!hasUpdatedAt) { await knex.schema.alterTable("visits", table => { table.dateTime("updated_at").defaultTo(knex.fn.now()); }); } } module.exports = { createVisitTable } ================================================ FILE: server/passport.js ================================================ const { Strategy: LocalAPIKeyStrategy } = require("passport-localapikey-update"); const { Strategy: JwtStrategy, ExtractJwt } = require("passport-jwt"); const { Strategy: LocalStrategy } = require("passport-local"); const passport = require("passport"); const bcrypt = require("bcryptjs"); const query = require("./queries"); const env = require("./env"); const utils = require("./utils") const jwtOptions = { jwtFromRequest: req => req.cookies?.token, secretOrKey: env.JWT_SECRET }; passport.use( new JwtStrategy(jwtOptions, async (payload, done) => { try { // 'sub' used to be the email address // this check makes sure to invalidate old JWTs where the sub is still the email address if (typeof payload.sub === "string" || !payload.sub) { return done(null, false); } const user = await query.user.find({ id: payload.sub }); if (!user) return done(null, false); return done(null, user, payload); } catch (err) { return done(err); } }) ); if (!env.DISALLOW_LOGIN_FORM) { const localOptions = { usernameField: "email" }; passport.use( new LocalStrategy(localOptions, async (email, password, done) => { try { const user = await query.user.find({ email }); if (!user) { return done(null, false); } const isMatch = await bcrypt.compare(password, user.password); if (!isMatch) { return done(null, false); } return done(null, user); } catch (err) { return done(err); } }) ); } const localAPIKeyOptions = { apiKeyField: "apikey", apiKeyHeader: "x-api-key" }; passport.use( new LocalAPIKeyStrategy(localAPIKeyOptions, async (apikey, done) => { try { const user = await query.user.find({ apikey }); if (!user) { return done(null, false); } return done(null, user); } catch (err) { return done(err); } }) ); if (env.OIDC_ENABLED) { async function enableOIDC() { const requiredKeys = ["OIDC_ISSUER", "OIDC_CLIENT_ID", "OIDC_CLIENT_SECRET", "OIDC_SCOPE", "OIDC_EMAIL_CLAIM"]; requiredKeys.forEach((key) => { if (!env[key]) { throw new Error(`Missing required env ${key}`); } }); const { Issuer, Strategy: OIDCStrategy, UserinfoResponse } = await import("openid-client"); const issuer = await Issuer.discover(env.OIDC_ISSUER).catch(function (error) { error.info = "Failed connecting to OIDC issuer."; throw error; }); const client = new issuer.Client({ client_id: env.OIDC_CLIENT_ID, client_secret: env.OIDC_CLIENT_SECRET, redirect_uris: [utils.getSiteURL() + "/login/oidc"], response_types: ["code"] }); passport.use( "oidc", new OIDCStrategy( { client, params: { scope: env.OIDC_SCOPE, prompt: "login" }, passReqToCallback: true }, async (req, tokenset, userinfo, done) => { try { const email = userinfo[env.OIDC_EMAIL_CLAIM]; const existingUser = await query.user.find({ email }); // Existing user. if (existingUser) return done(null, existingUser); // New user. // Generate a random password which is not supposed to be used directly. const salt = await bcrypt.genSalt(12); const password = utils.generateRandomPassword(); const newUser = await query.user.add({ email, password, }); const updatedUser = await query.user.update(newUser, { verified: true, verification_token: null, verification_expires: null, }); return done(null, updatedUser); } catch (err) { return done(err); } } ) ); } enableOIDC(); } ================================================ FILE: server/queries/domain.queries.js ================================================ const redis = require("../redis"); const utils = require("../utils"); const knex = require("../knex"); const env = require("../env"); async function find(match) { if (match.address && env.REDIS_ENABLED) { const cachedDomain = await redis.client.get(redis.key.domain(match.address)); if (cachedDomain) return JSON.parse(cachedDomain); } const domain = await knex("domains").where(match).first(); if (domain && env.REDIS_ENABLED) { const key = redis.key.domain(domain.address); redis.client.set(key, JSON.stringify(domain), "EX", 60 * 15); } return domain; } function get(match) { return knex("domains").where(match); } async function add(params) { params.address = params.address.toLowerCase(); const existingDomain = await knex("domains").where("address", params.address).first(); let id = existingDomain?.id; const newDomain = { address: params.address, homepage: params.homepage, user_id: params.user_id, banned: !!params.banned, banned_by_id: params.banned_by_id }; if (id) { await knex("domains").where("id", id).update({ ...newDomain, updated_at: params.updated_at || utils.dateToUTC(new Date()) }); } else { // Mysql and sqlite don't support returning but return the inserted id by default const [createdDomain] = await knex("domains").insert(newDomain, "*"); id = typeof createdDomain === "number" ? createdDomain : createdDomain.id; } // Query domain instead of using returning as sqlite and mysql don't support it const domain = await knex("domains").where("id", id).first(); if (env.REDIS_ENABLED) { redis.remove.domain(existingDomain); redis.remove.domain(domain); } return domain; } async function update(match, update) { // if the domains' adddress is changed, // make sure to delete the original domains from cache let domains = [] if (env.REDIS_ENABLED && update.address) { domains = await knex("domains").select("*").where(match); } await knex("domains") .where(match) .update({ ...update, updated_at: utils.dateToUTC(new Date()) }); const updated_domains = await knex("domains").select("*").where(match); if (env.REDIS_ENABLED) { domains.forEach(redis.remove.domain); updated_domains.forEach(redis.remove.domain); } return updated_domains; } function normalizeMatch(match) { const newMatch = { ...match }; if (newMatch.address) { newMatch["domains.address"] = newMatch.address; delete newMatch.address; } if (newMatch.user_id) { newMatch["domains.user_id"] = newMatch.user_id; delete newMatch.user_id; } if (newMatch.uuid) { newMatch["domains.uuid"] = newMatch.uuid; delete newMatch.uuid; } if (newMatch.banned !== undefined) { newMatch["domains.banned"] = newMatch.banned; delete newMatch.banned; } return newMatch; } const selectable_admin = [ "domains.id", "domains.address", "domains.homepage", "domains.banned", "domains.created_at", "domains.updated_at", "domains.user_id", "domains.uuid", "users.email as email", "links_count" ]; async function getAdmin(match, params) { const query = knex("domains").select(...selectable_admin); Object.entries(normalizeMatch(match)).forEach(([key, value]) => { query.andWhere(key, ...(Array.isArray(value) ? value : [value])); }); query .offset(params.skip) .limit(params.limit) .fromRaw("domains") .orderBy("domains.id", "desc") .groupBy(1) .groupBy("l.links_count") .groupBy("users.email"); if (params?.user) { const id = parseInt(params?.user); if (Number.isNaN(id)) { query[knex.compatibleILIKE]("users.email", "%" + params.user + "%"); } else { query.andWhere("domains.user_id", id); } } if (params?.search) { query[knex.compatibleILIKE]( knex.raw("concat_ws(' ', domains.address, domains.homepage)"), "%" + params.search + "%" ); } if (params?.links !== undefined) { query.andWhere("links_count", params?.links ? "is not" : "is", null); } query.leftJoin( knex("links").select("domain_id").count("* as links_count").groupBy("domain_id").as("l"), "domains.id", "l.domain_id" ); query.leftJoin("users", "domains.user_id", "users.id"); return query; } async function totalAdmin(match, params) { const query = knex("domains"); Object.entries(normalizeMatch(match)).forEach(([key, value]) => { query.andWhere(key, ...(Array.isArray(value) ? value : [value])); }); if (params?.user) { const id = parseInt(params?.user); if (Number.isNaN(id)) { query[knex.compatibleILIKE]("users.email", "%" + params.user + "%"); } else { query.andWhere("domains.user_id", id); } } if (params?.search) { query[knex.compatibleILIKE]( knex.raw("concat_ws(' ', domains.address, domains.homepage)"), "%" + params.search + "%" ); } if (params?.links !== undefined) { query.leftJoin( knex("links").select("domain_id").count("* as links_count").groupBy("domain_id").as("l"), "domains.id", "l.domain_id" ); query.andWhere("links_count", params?.links ? "is not" : "is", null); } query.leftJoin("users", "domains.user_id", "users.id"); query.count("* as count"); const [{ count }] = await query; return typeof count === "number" ? count : parseInt(count); } async function remove(domain) { const deletedDomain = await knex("domains").where("id", domain.id).delete(); if (env.REDIS_ENABLED) { redis.remove.domain(domain); } return !!deletedDomain; } module.exports = { add, find, get, getAdmin, remove, totalAdmin, update, } ================================================ FILE: server/queries/host.queries.js ================================================ const redis = require("../redis"); const utils = require("../utils"); const knex = require("../knex"); const env = require("../env"); async function find(match) { if (match.address && env.REDIS_ENABLED) { const cachedHost = await redis.client.get(redis.key.host(match.address)); if (cachedHost) return JSON.parse(cachedHost); } const host = await knex("hosts") .where(match) .first(); if (host && env.REDIS_ENABLED) { const key = redis.key.host(host.address); redis.client.set(key, JSON.stringify(host), "EX", 60 * 15); } return host; } async function add(params) { params.address = params.address.toLowerCase(); const existingHost = await knex("hosts").where("address", params.address).first(); let id = existingHost?.id; const newHost = { address: params.address, banned: !!params.banned, banned_by_id: params.banned_by_id, }; if (id) { await knex("hosts").where("id", id).update({ ...newHost, updated_at: params.updated_at || utils.dateToUTC(new Date()) }); } else { // Mysql and sqlite don't support returning but return the inserted id by default const [createdHost] = await knex("hosts").insert(newHost, "*"); id = typeof createdHost === "number" ? createdHost : createdHost.id; } // Query domain instead of using returning as sqlite and mysql don't support it const host = await knex("hosts").where("id", id); if (env.REDIS_ENABLED) { redis.remove.host(host); } return host; } module.exports = { add, find, } ================================================ FILE: server/queries/index.js ================================================ const domain = require("./domain.queries"); const visit = require("./visit.queries"); const link = require("./link.queries"); const user = require("./user.queries"); const host = require("./host.queries"); module.exports = { domain, host, link, user, visit }; ================================================ FILE: server/queries/link.queries.js ================================================ const bcrypt = require("bcryptjs"); const utils = require("../utils"); const redis = require("../redis"); const knex = require("../knex"); const env = require("../env"); const CustomError = utils.CustomError; const selectable = [ "links.id", "links.address", "links.banned", "links.created_at", "links.domain_id", "links.updated_at", "links.password", "links.description", "links.expire_in", "links.target", "links.visit_count", "links.user_id", "links.uuid", "domains.address as domain" ]; const selectable_admin = [ ...selectable, "users.email as email" ]; function normalizeMatch(match) { const newMatch = { ...match }; if (newMatch.address) { newMatch["links.address"] = newMatch.address; delete newMatch.address; } if (newMatch.user_id) { newMatch["links.user_id"] = newMatch.user_id; delete newMatch.user_id; } if (newMatch.id) { newMatch["links.id"] = newMatch.id; delete newMatch.id; } if (newMatch.uuid) { newMatch["links.uuid"] = newMatch.uuid; delete newMatch.uuid; } if (newMatch.banned !== undefined) { newMatch["links.banned"] = newMatch.banned; delete newMatch.banned; } return newMatch; } async function total(match, params) { const normalizedMatch = normalizeMatch(match); const query = knex("links"); Object.entries(normalizedMatch).forEach(([key, value]) => { query.andWhere(key, ...(Array.isArray(value) ? value : [value])); }); if (params?.search) { query[knex.compatibleILIKE]( knex.raw("concat_ws(' ', description, links.address, target, domains.address)"), "%" + params.search + "%" ); } query.leftJoin("domains", "links.domain_id", "domains.id"); query.count("* as count"); const [{ count }] = await query; return typeof count === "number" ? count : parseInt(count); } async function totalAdmin(match, params) { const query = knex("links"); Object.entries(normalizeMatch(match)).forEach(([key, value]) => { query.andWhere(key, ...(Array.isArray(value) ? value : [value])); }); if (params?.user) { const id = parseInt(params?.user); if (Number.isNaN(id)) { query[knex.compatibleILIKE]("users.email", "%" + params.user + "%"); } else { query.andWhere("links.user_id", params.user); } } if (params?.search) { query[knex.compatibleILIKE]( knex.raw("concat_ws(' ', description, links.address, target)"), "%" + params.search + "%" ); } if (params?.domain) { query[knex.compatibleILIKE]("domains.address", "%" + params.domain + "%"); } query.leftJoin("domains", "links.domain_id", "domains.id"); query.leftJoin("users", "links.user_id", "users.id"); query.count("* as count"); const [{ count }] = await query; return typeof count === "number" ? count : parseInt(count); } async function get(match, params) { const query = knex("links") .select(...selectable) .where(normalizeMatch(match)) .offset(params.skip) .limit(params.limit) .orderBy("links.id", "desc"); if (params?.search) { query[knex.compatibleILIKE]( knex.raw("concat_ws(' ', description, links.address, target, domains.address)"), "%" + params.search + "%" ); } query.leftJoin("domains", "links.domain_id", "domains.id"); return query; } async function getAdmin(match, params) { const query = knex("links").select(...selectable_admin); Object.entries(normalizeMatch(match)).forEach(([key, value]) => { query.andWhere(key, ...(Array.isArray(value) ? value : [value])); }); query .orderBy("links.id", "desc") .offset(params.skip) .limit(params.limit) if (params?.user) { const id = parseInt(params?.user); if (Number.isNaN(id)) { query[knex.compatibleILIKE]("users.email", "%" + params.user + "%"); } else { query.andWhere("links.user_id", params.user); } } if (params?.search) { query[knex.compatibleILIKE]( knex.raw("concat_ws(' ', description, links.address, target)"), "%" + params.search + "%" ); } if (params?.domain) { query[knex.compatibleILIKE]("domains.address", "%" + params.domain + "%"); } query.leftJoin("domains", "links.domain_id", "domains.id"); query.leftJoin("users", "links.user_id", "users.id"); return query; } async function find(match) { if (match.address && match.domain_id !== undefined && env.REDIS_ENABLED) { const key = redis.key.link(match.address, match.domain_id); const cachedLink = await redis.client.get(key); if (cachedLink) return JSON.parse(cachedLink); } const link = await knex("links") .select(...selectable) .where(normalizeMatch(match)) .leftJoin("domains", "links.domain_id", "domains.id") .first(); if (link && env.REDIS_ENABLED) { const key = redis.key.link(link.address, link.domain_id); redis.client.set(key, JSON.stringify(link), "EX", 60 * 15); } return link; } async function create(params) { let encryptedPassword = null; if (params.password) { const salt = await bcrypt.genSalt(12); encryptedPassword = await bcrypt.hash(params.password, salt); } let [link] = await knex( "links" ).insert( { password: encryptedPassword, domain_id: params.domain_id || null, user_id: params.user_id || null, address: params.address, description: params.description || null, expire_in: params.expire_in || null, target: params.target }, "*" ); // mysql doesn't return the whole link, but rather the id number only // so we need to fetch the link ourselves if (typeof link === "number") { link = await knex("links").where("id", link).first(); } return link; } async function remove(match) { const link = await knex("links").where(match).first(); if (!link) { return { isRemoved: false, error: "Could not find the link.", link: null } } const deletedLink = await knex("links").where("id", link.id).delete(); if (env.REDIS_ENABLED) { redis.remove.link(link); } return { isRemoved: !!deletedLink, link }; } async function batchRemove(match) { const query = knex("links"); Object.entries(match).forEach(([key, value]) => { query.andWhere(key, ...(Array.isArray(value) ? value : [value])); }); const links = await query.clone(); await query.delete(); if (env.REDIS_ENABLED) { links.forEach(redis.remove.link); } } async function update(match, update) { if (update.password) { const salt = await bcrypt.genSalt(12); update.password = await bcrypt.hash(update.password, salt); } // if the links' adddress or domain is changed, // make sure to delete the original links from cache let links = [] if (env.REDIS_ENABLED && (update.address || update.domain_id)) { links = await knex("links").select('*').where(match); } await knex("links") .where(match) .update({ ...update, updated_at: utils.dateToUTC(new Date()) }); const updated_links = await knex("links") .select(selectable) .where(normalizeMatch(match)) .leftJoin("domains", "links.domain_id", "domains.id"); if (env.REDIS_ENABLED) { links.forEach(redis.remove.link); updated_links.forEach(redis.remove.link); } return updated_links; } function incrementVisit(match) { return knex("links").where(match).increment("visit_count", 1); } module.exports = { normalizeMatch, batchRemove, create, find, get, getAdmin, incrementVisit, remove, total, totalAdmin, update, } ================================================ FILE: server/queries/user.queries.js ================================================ const { addMinutes } = require("date-fns"); const { randomUUID } = require("node:crypto"); const { ROLES } = require("../consts"); const utils = require("../utils"); const redis = require("../redis"); const knex = require("../knex"); const env = require("../env"); async function find(match) { if ((match.id || match.apikey) && env.REDIS_ENABLED) { const key = redis.key.user(match.id || match.apikey); const cachedUser = await redis.client.get(key); if (cachedUser) return JSON.parse(cachedUser); } const query = knex("users"); Object.entries(match).forEach(([key, value]) => { query.andWhere(key, ...(Array.isArray(value) ? value : [value])); }); const user = await query.first(); if (user && env.REDIS_ENABLED) { if (match.id) { const idKey = redis.key.user(user.id); redis.client.set(idKey, JSON.stringify(user), "EX", 60 * 15); } if (match.apikey) { const apikeyKey = redis.key.user(user.apikey); redis.client.set(apikeyKey, JSON.stringify(user), "EX", 60 * 15); } } return user; } async function add(params, user) { const data = { email: params.email, password: params.password, ...(params.role && { role: params.role }), ...(params.verified !== undefined && { verified: params.verified }), verification_token: randomUUID(), verification_expires: utils.dateToUTC(addMinutes(new Date(), 60)) }; if (user) { await knex("users") .where("id", user.id) .update({ ...data, updated_at: utils.dateToUTC(new Date()) }); } else { await knex("users").insert(data); } if (env.REDIS_ENABLED) { redis.remove.user(user); } return { ...user, ...data }; } async function update(match, update, methods) { const { user, updated_user } = await knex.transaction(async function(trx) { const query = trx("users"); Object.entries(match).forEach(([key, value]) => { query.andWhere(key, ...(Array.isArray(value) ? value : [value])); }); const user = await query.select("id").first(); if (!user) return {}; const updateQuery = trx("users").where("id", user.id); if (methods?.increments) { methods.increments.forEach(columnName => { updateQuery.increment(columnName); }); } await updateQuery.update({ ...update, updated_at: utils.dateToUTC(new Date()) }); const updated_user = await trx("users").where("id", user.id).first(); return { user, updated_user }; }); if (env.REDIS_ENABLED && user) { redis.remove.user(user); redis.remove.user(updated_user); } return updated_user; } async function remove(user) { const deletedUser = await knex("users").where("id", user.id).delete(); if (env.REDIS_ENABLED) { redis.remove.user(user); } return !!deletedUser; } const selectable_admin = [ "users.id", "users.email", "users.verified", "users.role", "users.banned", "users.banned_by_id", "users.created_at", "users.updated_at" ]; function normalizeMatch(match) { const newMatch = { ...match } if (newMatch.banned !== undefined) { newMatch["users.banned"] = newMatch.banned; delete newMatch.banned; } return newMatch; } async function getAdmin(match, params) { const query = knex("users") .select(...selectable_admin) .select("l.links_count") .select("d.domains") .fromRaw("users") .where(normalizeMatch(match)) .offset(params.skip) .limit(params.limit) .orderBy("users.id", "desc") .groupBy(1) .groupBy("l.links_count") .groupBy("d.domains"); if (params?.search) { const id = parseInt(params?.search); if (Number.isNaN(id)) { query[knex.compatibleILIKE]("users.email", "%" + params?.search + "%"); } else { query.andWhere("users.id", params?.search); } } if (params?.domains !== undefined) { query.andWhere("d.domains", params?.domains ? "is not" : "is", null); } if (params?.links !== undefined) { query.andWhere("links_count", params?.links ? "is not" : "is", null); } query.leftJoin( knex("domains") .select("user_id", knex.isMySQL ? knex.raw("group_concat(address SEPARATOR ', ') AS domains") : knex.raw("string_agg(address, ', ') AS domains") ) .groupBy("user_id").as("d"), "users.id", "d.user_id" ) query.leftJoin( knex("links").select("user_id").count("* as links_count").groupBy("user_id").as("l"), "users.id", "l.user_id" ); return query; } async function totalAdmin(match, params) { const query = knex("users") .count("* as count") .fromRaw("users") .where(normalizeMatch(match)); if (params?.search) { const id = parseInt(params?.search); if (Number.isNaN(id)) { query[knex.compatibleILIKE]("users.email", "%" + params?.search + "%"); } else { query.andWhere("users.id", params?.search); } } if (params?.domains !== undefined) { query.andWhere("domains", params?.domains ? "is not" : "is", null); query.leftJoin( knex("domains") .select("user_id", knex.isMySQL ? knex.raw("group_concat(address SEPARATOR ', ') AS domains") : knex.raw("string_agg(address, ', ') AS domains") ) .groupBy("user_id").as("d"), "users.id", "d.user_id" ); } if (params?.links !== undefined) { query.andWhere("links", params?.links ? "is not" : "is", null); query.leftJoin( knex("links").select("user_id").count("* as links").groupBy("user_id").as("l"), "users.id", "l.user_id" ); } const [{ count }] = await query; return typeof count === "number" ? count : parseInt(count); } async function create(params) { let [user] = await knex("users").insert({ email: params.email, password: params.password, role: params.role ?? ROLES.USER, verified: params.verified ?? false, banned: params.banned ?? false, }, "*"); // mysql doesn't return the whole user, but rather the id number only // so we need to fetch the user ourselves if (typeof user === "number") { user = await knex("users").where("id", user).first(); } return user; } // check if there exists a user async function findAny() { if (env.REDIS_ENABLED) { const anyuser = await redis.client.get("any-user"); if (anyuser) return true; } const anyuser = await knex("users").select("id").first(); if (env.REDIS_ENABLED && anyuser) { redis.client.set("any-user", JSON.stringify(anyuser), "EX", 60 * 5); } return !!anyuser; } module.exports = { add, create, find, findAny, getAdmin, remove, totalAdmin, update, } ================================================ FILE: server/queries/visit.queries.js ================================================ const { isAfter, subDays, subHours, set, format } = require("date-fns"); const utils = require("../utils"); const redis = require("../redis"); const knex = require("../knex"); const env = require("../env"); async function add(params) { const data = { ...params, country: params.country.toLowerCase(), referrer: params.referrer.toLowerCase() }; const nowUTC = new Date().toISOString(); const truncatedNow = nowUTC.substring(0, 10) + " " + nowUTC.substring(11, 14) + "00:00"; return knex.transaction(async (trx) => { // Create a subquery first that truncates the const subquery = trx("visits") .select("visits.*") .select({ created_at_hours: utils.knexUtils(trx).truncatedTimestamp("created_at", "hour") }) .where({ link_id: data.link_id }) .as("subquery"); const visit = await trx .select("*") .from(subquery) .where("created_at_hours", "=", truncatedNow) .forUpdate() .first(); if (visit) { const countries = typeof visit.countries === "string" ? JSON.parse(visit.countries) : visit.countries; const referrers = typeof visit.referrers === "string" ? JSON.parse(visit.referrers) : visit.referrers; await trx("visits") .where({ id: visit.id }) .increment(`br_${data.browser}`, 1) .increment(`os_${data.os}`, 1) .increment("total", 1) .update({ updated_at: utils.dateToUTC(new Date()), countries: JSON.stringify({ ...countries, [data.country]: (countries[data.country] ?? 0) + 1 }), referrers: JSON.stringify({ ...referrers, [data.referrer]: (referrers[data.referrer] ?? 0) + 1 }) }); } else { // This must also happen in the transaction to avoid concurrency await trx("visits").insert({ [`br_${data.browser}`]: 1, countries: { [data.country]: 1 }, referrers: { [data.referrer]: 1 }, [`os_${data.os}`]: 1, total: 1, link_id: data.link_id, user_id: data.user_id, }); } return visit; }); } async function find(match, total) { if (match.link_id && env.REDIS_ENABLED) { const key = redis.key.stats(match.link_id); const cached = await redis.client.get(key); if (cached) return JSON.parse(cached); } const stats = { lastDay: { stats: utils.getInitStats(), views: new Array(24).fill(0), total: 0 }, lastWeek: { stats: utils.getInitStats(), views: new Array(7).fill(0), total: 0 }, lastMonth: { stats: utils.getInitStats(), views: new Array(30).fill(0), total: 0 }, lastYear: { stats: utils.getInitStats(), views: new Array(12).fill(0), total: 0 } }; const visitsStream = knex("visits").where(match).stream(); const now = new Date(); const periods = utils.getStatsPeriods(now); for await (const visit of visitsStream) { periods.forEach(([type, fromDate]) => { const isIncluded = isAfter(utils.parseDatetime(visit.created_at), fromDate); if (!isIncluded) return; const diffFunction = utils.getDifferenceFunction(type); const diff = diffFunction(now, utils.parseDatetime(visit.created_at)); const index = stats[type].views.length - diff - 1; const view = stats[type].views[index]; const period = stats[type].stats; const countries = typeof visit.countries === "string" ? JSON.parse(visit.countries) : visit.countries; const referrers = typeof visit.referrers === "string" ? JSON.parse(visit.referrers) : visit.referrers; stats[type].stats = { browser: { chrome: period.browser.chrome + visit.br_chrome, edge: period.browser.edge + visit.br_edge, firefox: period.browser.firefox + visit.br_firefox, ie: period.browser.ie + visit.br_ie, opera: period.browser.opera + visit.br_opera, other: period.browser.other + visit.br_other, safari: period.browser.safari + visit.br_safari }, os: { android: period.os.android + visit.os_android, ios: period.os.ios + visit.os_ios, linux: period.os.linux + visit.os_linux, macos: period.os.macos + visit.os_macos, other: period.os.other + visit.os_other, windows: period.os.windows + visit.os_windows }, country: { ...period.country, ...Object.entries(countries).reduce( (obj, [country, count]) => ({ ...obj, [country]: (period.country[country] || 0) + count }), {} ) }, referrer: { ...period.referrer, ...Object.entries(referrers).reduce( (obj, [referrer, count]) => ({ ...obj, [referrer]: (period.referrer[referrer] || 0) + count }), {} ) } }; stats[type].views[index] += visit.total; stats[type].total += visit.total; }); } const response = { lastYear: { stats: utils.statsObjectToArray(stats.lastYear.stats), views: stats.lastYear.views, total: stats.lastYear.total }, lastDay: { stats: utils.statsObjectToArray(stats.lastDay.stats), views: stats.lastDay.views, total: stats.lastDay.total }, lastMonth: { stats: utils.statsObjectToArray(stats.lastMonth.stats), views: stats.lastMonth.views, total: stats.lastMonth.total }, lastWeek: { stats: utils.statsObjectToArray(stats.lastWeek.stats), views: stats.lastWeek.views, total: stats.lastWeek.total }, updatedAt: new Date() }; if (match.link_id && env.REDIS_ENABLED) { const key = redis.key.stats(match.link_id); redis.client.set(key, JSON.stringify(response), "EX", 60); } return response; }; module.exports = { add, find }; ================================================ FILE: server/queues/index.js ================================================ const { visit } = require("./queues"); module.exports = { visit, }; ================================================ FILE: server/queues/queues.js ================================================ const Queue = require("bull"); const path = require("node:path"); const env = require("../env"); const redis = { port: env.REDIS_PORT, host: env.REDIS_HOST, db: env.REDIS_DB, ...(env.REDIS_PASSWORD && { password: env.REDIS_PASSWORD }) }; let visit; if (env.REDIS_ENABLED) { visit = new Queue("visit", { redis }); visit.clean(5000, "completed"); visit.process(6, path.resolve(__dirname, "visit.js")); visit.on("completed", job => job.remove()); // TODO: handler error // visit.on("error", function (error) { // console.log("error"); // }); } else { const visitProcessor = require(path.resolve(__dirname, "visit.js")); visit = { add(data) { visitProcessor({ data }).catch(function(error) { console.error("Add visit error: ", error); }); } } } module.exports = { visit, } ================================================ FILE: server/queues/visit.js ================================================ const useragent = require("useragent"); const geoip = require("geoip-lite"); const URL = require("node:url"); const { removeWww } = require("../utils"); const query = require("../queries"); const browsersList = ["IE", "Firefox", "Chrome", "Opera", "Safari", "Edge"]; const osList = ["Windows", "Mac OS", "Linux", "Android", "iOS"]; function filterInBrowser(agent) { return function(item) { return agent.family.toLowerCase().includes(item.toLocaleLowerCase()); } } function filterInOs(agent) { return function(item) { return agent.os.family.toLowerCase().includes(item.toLocaleLowerCase()); } } module.exports = function({ data }) { const tasks = []; tasks.push(query.link.incrementVisit({ id: data.link.id })); // the following line is for backward compatibility // used to send the whole header to get the user agent const userAgent = data.userAgent || data.headers?.["user-agent"]; const agent = useragent.parse(userAgent); const [browser = "Other"] = browsersList.filter(filterInBrowser(agent)); const [os = "Other"] = osList.filter(filterInOs(agent)); const referrer = data.referrer && removeWww(URL.parse(data.referrer).hostname); const country = data.country || geoip.lookup(data.ip)?.country; tasks.push( query.visit.add({ browser: browser.toLowerCase(), country: country || "Unknown", link_id: data.link.id, user_id: data.link.user_id, os: os.toLowerCase().replace(/\s/gi, ""), referrer: (referrer && referrer.replace(/\./gi, "[dot]")) || "Direct" }) ); return Promise.all(tasks); } ================================================ FILE: server/redis.js ================================================ const Redis = require("ioredis"); const env = require("./env"); let client; if (env.REDIS_ENABLED) { client = new Redis({ host: env.REDIS_HOST, port: env.REDIS_PORT, db: env.REDIS_DB, ...(env.REDIS_PASSWORD && { password: env.REDIS_PASSWORD }) }); } const key = { link: (address, domain_id) => `l:${address}:${domain_id || ""}`, domain: (address) => `d:${address}`, stats: (link_id) => `s:${link_id}`, host: (address) => `h:${address}`, user: (idOrKey) => `u:${idOrKey}` }; const remove = { domain: (domain) => { if (!domain) return; return client.del(key.domain(domain.address)); }, host: (host) => { if (!host) return; return client.del(key.host(host.address)); }, link: (link) => { if (!link) return; return client.del(key.link(link.address, link.domain_id)); }, user: (user) => { if (!user) return; return Promise.all([ client.del(key.user(user.id)), client.del(key.user(user.apikey)), ]); } }; module.exports = { client, key, remove, } ================================================ FILE: server/routes/auth.routes.js ================================================ const { Router } = require("express"); const validators = require("../handlers/validators.handler"); const helpers = require("../handlers/helpers.handler"); const asyncHandler = require("../utils/asyncHandler"); const locals = require("../handlers/locals.handler"); const auth = require("../handlers/auth.handler"); const utils = require("../utils"); const env = require("../env"); const router = Router(); router.post( "/login", locals.viewTemplate("partials/auth/form"), auth.featureAccess([!env.DISALLOW_LOGIN_FORM]), validators.login, asyncHandler(helpers.verify), helpers.rateLimit({ window: 60, limit: 5 }), asyncHandler(auth.local), asyncHandler(auth.login) ); router.post( "/signup", locals.viewTemplate("partials/auth/form"), auth.featureAccess([!env.DISALLOW_REGISTRATION, env.MAIL_ENABLED]), validators.signup, asyncHandler(helpers.verify), helpers.rateLimit({ window: 60, limit: 5 }), validators.signupEmailTaken, asyncHandler(helpers.verify), asyncHandler(auth.signup) ); router.post( "/create-admin", locals.viewTemplate("partials/auth/form_admin"), validators.createAdmin, asyncHandler(helpers.verify), helpers.rateLimit({ window: 60, limit: 5 }), asyncHandler(auth.createAdminUser) ); router.post( "/change-password", locals.viewTemplate("partials/settings/change_password"), asyncHandler(auth.jwt), validators.changePassword, asyncHandler(helpers.verify), helpers.rateLimit({ window: 60, limit: 5 }), asyncHandler(auth.changePassword) ); router.post( "/change-email", locals.viewTemplate("partials/settings/change_email"), asyncHandler(auth.jwt), auth.featureAccess([env.MAIL_ENABLED]), validators.changeEmail, asyncHandler(helpers.verify), helpers.rateLimit({ window: 60, limit: 3 }), asyncHandler(auth.changeEmailRequest) ); router.post( "/apikey", locals.viewTemplate("partials/settings/apikey"), asyncHandler(auth.jwt), helpers.rateLimit({ window: 60, limit: 10 }), asyncHandler(auth.generateApiKey) ); router.post( "/reset-password", locals.viewTemplate("partials/reset_password/request_form"), auth.featureAccess([env.MAIL_ENABLED]), validators.resetPassword, asyncHandler(helpers.verify), helpers.rateLimit({ window: 60, limit: 3 }), asyncHandler(auth.resetPassword) ); router.post( "/new-password", locals.viewTemplate("partials/reset_password/new_password_form"), locals.newPassword, validators.newPassword, asyncHandler(helpers.verify), helpers.rateLimit({ window: 60, limit: 5 }), asyncHandler(auth.newPassword) ); module.exports = router; ================================================ FILE: server/routes/domain.routes.js ================================================ const { Router } = require("express"); const validators = require("../handlers/validators.handler"); const helpers = require("../handlers/helpers.handler"); const domains = require("../handlers/domains.handler"); const asyncHandler = require("../utils/asyncHandler"); const locals = require("../handlers/locals.handler"); const auth = require("../handlers/auth.handler"); const router = Router(); router.get( "/admin", locals.viewTemplate("partials/admin/domains/table"), asyncHandler(auth.apikey), asyncHandler(auth.jwt), asyncHandler(auth.admin), helpers.parseQuery, locals.adminTable, asyncHandler(domains.getAdmin) ); router.post( "/", locals.viewTemplate("partials/settings/domain/add_form"), asyncHandler(auth.apikey), asyncHandler(auth.jwt), validators.addDomain, asyncHandler(helpers.verify), asyncHandler(domains.add) ); router.post( "/admin", locals.viewTemplate("partials/admin/dialog/add_domain"), asyncHandler(auth.apikey), asyncHandler(auth.jwt), asyncHandler(auth.admin), validators.addDomainAdmin, asyncHandler(helpers.verify), asyncHandler(domains.addAdmin) ); router.delete( "/:id", locals.viewTemplate("partials/settings/domain/delete"), asyncHandler(auth.apikey), asyncHandler(auth.jwt), validators.removeDomain, asyncHandler(helpers.verify), asyncHandler(domains.remove) ); router.delete( "/admin/:id", locals.viewTemplate("partials/admin/dialog/delete_domain"), asyncHandler(auth.apikey), asyncHandler(auth.jwt), asyncHandler(auth.admin), validators.removeDomainAdmin, asyncHandler(helpers.verify), asyncHandler(domains.removeAdmin) ); router.post( "/admin/ban/:id", locals.viewTemplate("partials/admin/dialog/ban_domain"), asyncHandler(auth.apikey), asyncHandler(auth.jwt), asyncHandler(auth.admin), validators.banDomain, asyncHandler(helpers.verify), asyncHandler(domains.ban) ); module.exports = router; ================================================ FILE: server/routes/health.routes.js ================================================ const { Router } = require("express"); const router = Router(); router.get("/", (_, res) => res.send("OK")); module.exports = router; ================================================ FILE: server/routes/index.js ================================================ module.exports = require("./routes"); ================================================ FILE: server/routes/link.routes.js ================================================ const { Router } = require("express"); const cors = require("cors"); const validators = require("../handlers/validators.handler"); const helpers = require("../handlers/helpers.handler"); const asyncHandler = require("../utils/asyncHandler"); const locals = require("../handlers/locals.handler"); const link = require("../handlers/links.handler"); const auth = require("../handlers/auth.handler"); const env = require("../env"); const router = Router(); router.get( "/", locals.viewTemplate("partials/links/table"), asyncHandler(auth.apikey), asyncHandler(auth.jwt), helpers.parseQuery, asyncHandler(link.get) ); router.get( "/admin", locals.viewTemplate("partials/admin/links/table"), asyncHandler(auth.apikey), asyncHandler(auth.jwt), asyncHandler(auth.admin), helpers.parseQuery, locals.adminTable, asyncHandler(link.getAdmin) ); router.post( "/", cors(), locals.viewTemplate("partials/shortener"), asyncHandler(auth.apikey), asyncHandler(env.DISALLOW_ANONYMOUS_LINKS ? auth.jwt : auth.jwtLoose), locals.createLink, validators.createLink, asyncHandler(helpers.verify), asyncHandler(link.create) ); router.patch( "/:id", locals.viewTemplate("partials/links/edit"), asyncHandler(auth.apikey), asyncHandler(auth.jwt), locals.editLink, validators.editLink, asyncHandler(helpers.verify), asyncHandler(link.edit) ); router.patch( "/admin/:id", locals.viewTemplate("partials/links/edit"), asyncHandler(auth.apikey), asyncHandler(auth.jwt), asyncHandler(auth.admin), locals.editLink, validators.editLink, asyncHandler(helpers.verify), asyncHandler(link.editAdmin) ); router.delete( "/:id", locals.viewTemplate("partials/links/dialog/delete"), asyncHandler(auth.apikey), asyncHandler(auth.jwt), validators.deleteLink, asyncHandler(helpers.verify), asyncHandler(link.remove) ); router.post( "/admin/ban/:id", locals.viewTemplate("partials/links/dialog/ban"), asyncHandler(auth.apikey), asyncHandler(auth.jwt), asyncHandler(auth.admin), validators.banLink, asyncHandler(helpers.verify), asyncHandler(link.ban) ); router.get( "/:id/stats", locals.viewTemplate("partials/stats"), asyncHandler(auth.apikey), asyncHandler(auth.jwt), validators.getStats, asyncHandler(helpers.verify), asyncHandler(link.stats) ); router.post( "/:id/protected", locals.viewTemplate("partials/protected/form"), locals.protected, validators.redirectProtected, asyncHandler(helpers.verify), asyncHandler(link.redirectProtected) ); router.post( "/report", locals.viewTemplate("partials/report/form"), auth.featureAccess([env.MAIL_ENABLED]), validators.reportLink, asyncHandler(helpers.verify), asyncHandler(link.report) ); module.exports = router; ================================================ FILE: server/routes/renders.routes.js ================================================ const { Router } = require("express"); const helpers = require("../handlers/helpers.handler"); const renders = require("../handlers/renders.handler"); const asyncHandler = require("../utils/asyncHandler"); const locals = require("../handlers/locals.handler"); const auth = require("../handlers/auth.handler"); const env = require("../env"); const router = Router(); // pages router.get( "/", asyncHandler(auth.jwtLoosePage), asyncHandler(helpers.adminSetup), asyncHandler(locals.user), asyncHandler(renders.homepage) ); router.get( "/login", asyncHandler(auth.jwtLoosePage), asyncHandler(helpers.adminSetup), asyncHandler(renders.login) ); router.get( "/login/oidc", locals.viewTemplate("login"), auth.featureAccess([env.OIDC_ENABLED]), asyncHandler(auth.jwtLoosePage), asyncHandler(auth.oidc), asyncHandler(auth.login) ); router.get( "/logout", asyncHandler(renders.logout) ); router.get( "/create-admin", asyncHandler(renders.createAdmin) ); router.get( "/404", asyncHandler(auth.jwtLoosePage), asyncHandler(locals.user), asyncHandler(renders.notFound) ); router.get( "/settings", asyncHandler(auth.jwtPage), asyncHandler(locals.user), asyncHandler(renders.settings) ); router.get( "/admin", asyncHandler(auth.jwtPage), asyncHandler(auth.admin), asyncHandler(locals.user), asyncHandler(renders.admin) ); router.get( "/stats", asyncHandler(auth.jwtPage), asyncHandler(locals.user), asyncHandler(renders.stats) ); router.get( "/banned", asyncHandler(auth.jwtLoosePage), asyncHandler(locals.user), asyncHandler(renders.banned) ); router.get( "/report", asyncHandler(auth.jwtLoosePage), asyncHandler(locals.user), asyncHandler(renders.report) ); router.get( "/reset-password", auth.featureAccessPage([env.MAIL_ENABLED]), asyncHandler(auth.jwtLoosePage), asyncHandler(locals.user), asyncHandler(renders.resetPassword) ); router.get( "/reset-password/:resetPasswordToken", asyncHandler(auth.jwtLoosePage), asyncHandler(locals.user), asyncHandler(renders.resetPasswordSetNewPassword) ); router.get( "/verify-email/:changeEmailToken", asyncHandler(auth.changeEmail), asyncHandler(auth.jwtLoosePage), asyncHandler(locals.user), asyncHandler(renders.verifyChangeEmail) ); router.get( "/verify/:verificationToken", asyncHandler(auth.verify), asyncHandler(auth.jwtLoosePage), asyncHandler(locals.user), asyncHandler(renders.verify) ); router.get( "/terms", asyncHandler(auth.jwtLoosePage), asyncHandler(locals.user), asyncHandler(renders.terms) ); // partial renders router.get( "/confirm-link-delete", locals.noLayout, asyncHandler(auth.jwt), asyncHandler(renders.confirmLinkDelete) ); router.get( "/confirm-link-ban", locals.noLayout, locals.viewTemplate("partials/links/dialog/message"), asyncHandler(auth.jwt), asyncHandler(auth.admin), asyncHandler(renders.confirmLinkBan) ); router.get( "/confirm-user-delete", locals.noLayout, asyncHandler(auth.jwt), asyncHandler(auth.admin), asyncHandler(renders.confirmUserDelete) ); router.get( "/confirm-user-ban", locals.noLayout, asyncHandler(auth.jwt), asyncHandler(auth.admin), asyncHandler(renders.confirmUserBan) ); router.get( "/create-user", locals.noLayout, asyncHandler(auth.jwt), asyncHandler(auth.admin), asyncHandler(renders.createUser) ); router.get( "/add-domain", locals.noLayout, asyncHandler(auth.jwt), asyncHandler(auth.admin), asyncHandler(renders.addDomainAdmin) ); router.get( "/confirm-domain-ban", locals.noLayout, asyncHandler(auth.jwt), asyncHandler(auth.admin), asyncHandler(renders.confirmDomainBan) ); router.get( "/confirm-domain-delete-admin", locals.noLayout, asyncHandler(auth.jwt), asyncHandler(auth.admin), asyncHandler(renders.confirmDomainDeleteAdmin) ); router.get( "/link/edit/:id", locals.noLayout, asyncHandler(auth.jwt), asyncHandler(renders.linkEdit) ); router.get( "/admin/link/edit/:id", locals.noLayout, asyncHandler(auth.jwt), asyncHandler(auth.admin), asyncHandler(renders.linkEditAdmin) ); router.get( "/add-domain-form", locals.noLayout, asyncHandler(auth.jwt), asyncHandler(renders.addDomainForm) ); router.get( "/confirm-domain-delete", locals.noLayout, locals.viewTemplate("partials/settings/domain/delete"), asyncHandler(auth.jwt), asyncHandler(renders.confirmDomainDelete) ); router.get( "/get-report-email", locals.noLayout, locals.viewTemplate("partials/report/email"), asyncHandler(renders.getReportEmail) ); router.get( "/get-support-email", locals.noLayout, locals.viewTemplate("partials/support_email"), asyncHandler(renders.getSupportEmail) ); module.exports = router; ================================================ FILE: server/routes/routes.js ================================================ const { Router } = require("express"); const helpers = require("./../handlers/helpers.handler"); const locals = require("./../handlers/locals.handler"); const renders = require("./renders.routes"); const domains = require("./domain.routes"); const health = require("./health.routes"); const link = require("./link.routes"); const user = require("./user.routes"); const auth = require("./auth.routes"); const renderRouter = Router(); renderRouter.use(renders); const apiRouter = Router(); apiRouter.use(locals.noLayout); apiRouter.use("/domains", domains); apiRouter.use("/health", health); apiRouter.use("/links", link); apiRouter.use("/users", user); apiRouter.use("/auth", auth); module.exports = { api: apiRouter, render: renderRouter, }; ================================================ FILE: server/routes/user.routes.js ================================================ const { Router } = require("express"); const validators = require("../handlers/validators.handler"); const helpers = require("../handlers/helpers.handler"); const asyncHandler = require("../utils/asyncHandler"); const locals = require("../handlers/locals.handler"); const user = require("../handlers/users.handler"); const auth = require("../handlers/auth.handler"); const router = Router(); router.get( "/", asyncHandler(auth.apikey), asyncHandler(auth.jwt), asyncHandler(user.get) ); router.get( "/admin", locals.viewTemplate("partials/admin/users/table"), asyncHandler(auth.apikey), asyncHandler(auth.jwt), asyncHandler(auth.admin), helpers.parseQuery, locals.adminTable, asyncHandler(user.getAdmin) ); router.post( "/admin", locals.viewTemplate("partials/admin/dialog/create_user"), asyncHandler(auth.apikey), asyncHandler(auth.jwt), asyncHandler(auth.admin), validators.createUser, asyncHandler(helpers.verify), asyncHandler(user.create) ); router.post( "/delete", locals.viewTemplate("partials/settings/delete_account"), asyncHandler(auth.apikey), asyncHandler(auth.jwt), validators.deleteUser, asyncHandler(helpers.verify), asyncHandler(user.remove) ); router.delete( "/admin/:id", locals.viewTemplate("partials/admin/dialog/delete_user"), asyncHandler(auth.apikey), asyncHandler(auth.jwt), asyncHandler(auth.admin), validators.deleteUserByAdmin, asyncHandler(helpers.verify), asyncHandler(user.removeByAdmin) ); router.post( "/admin/ban/:id", locals.viewTemplate("partials/admin/dialog/ban_user"), asyncHandler(auth.apikey), asyncHandler(auth.jwt), asyncHandler(auth.admin), validators.banUser, asyncHandler(helpers.verify), asyncHandler(user.ban) ); module.exports = router; ================================================ FILE: server/server.js ================================================ const env = require("./env"); const cookieParser = require("cookie-parser"); const passport = require("passport"); const express = require("express"); const session = require("cookie-session"); const helmet = require("helmet"); const path = require("node:path"); const hbs = require("hbs"); const helpers = require("./handlers/helpers.handler"); const renders = require("./handlers/renders.handler"); const asyncHandler = require("./utils/asyncHandler"); const locals = require("./handlers/locals.handler"); const links = require("./handlers/links.handler"); const routes = require("./routes"); const utils = require("./utils"); // run the cron jobs // the app might be running in cluster mode (multiple instances) so run the cron job only on one cluster (the first one) // NODE_APP_INSTANCE variable is added by pm2 automatically, if you're using something else to cluster your app, then make sure to set this variable if (env.NODE_APP_INSTANCE === 0) { require("./cron"); } // intialize passport authentication library require("./passport"); // create express app const app = express(); // this tells the express app that it's running behind a proxy server // and thus it should get the IP address from the proxy server if (env.TRUST_PROXY) { app.set("trust proxy", true); } app.use(helmet({ contentSecurityPolicy: false })); app.use(cookieParser()); app.use(express.json()); app.use(express.urlencoded({ extended: true })); // use cookie sessions only when OIDC is enabled // because only OIDC is using it if (env.OIDC_ENABLED) { app.use(session({ keys: [env.JWT_SECRET], maxAge: 1000 * 60 * 60 * 24 * 7, // expire after seven days })); } // serve static app.use("/images", express.static("custom/images")); app.use("/css", express.static("custom/css", { extensions: ["css"] })); app.use(express.static("static")); app.use(passport.initialize()); app.use(locals.isHTML); app.use(locals.config); // template engine / serve html app.set("view engine", "hbs"); app.set("views", [ path.join(__dirname, "../custom/views"), path.join(__dirname, "views"), ]); utils.registerHandlebarsHelpers(); // if is custom domain, redirect to the set homepage app.use(asyncHandler(links.redirectCustomDomainHomepage)); // render html pages app.use("/", routes.render); // handle api requests app.use("/api/v2", routes.api); app.use("/api", routes.api); // finally, redirect the short link to the target app.get("/:id", asyncHandler(links.redirect)); // 404 pages that don't exist app.get("*", renders.notFound); // handle errors coming from above routes app.use(helpers.error); app.listen(env.PORT, () => { console.log(`> Ready on http://localhost:${env.PORT}`); }); ================================================ FILE: server/utils/asyncHandler.js ================================================ function asyncHandler(fn) { return function asyncUtilWrap(...args) { const fnReturn = fn(...args); const next = args[args.length - 1]; return Promise.resolve(fnReturn).catch(next); } } module.exports = asyncHandler; ================================================ FILE: server/utils/index.js ================================================ module.exports = require("./utils"); ================================================ FILE: server/utils/knex.js ================================================ function knexUtils(knex) { function truncatedTimestamp(columnName, precision = "hour") { switch (knex.client.driverName) { case "sqlite3": case "better-sqlite3": // SQLite uses strftime for date truncation const sqliteFormats = { second: "%Y-%m-%d %H:%M:%S", minute: "%Y-%m-%d %H:%M:00", hour: "%Y-%m-%d %H:00:00", day: "%Y-%m-%d 00:00:00", }; return knex.raw(`strftime('${sqliteFormats[precision]}', ${columnName})`); // Default to 'hour' case "mssql": // For MSSQL, we can use FORMAT or CONVERT to truncate the timestamp const mssqlFormats = { second: "yyyy-MM-dd HH:mm:ss", minute: "yyyy-MM-dd HH:mm:00", hour: "yyyy-MM-dd HH:00:00", day: "yyyy-MM-dd 00:00:00", }; return knex.raw(`FORMAT(${columnName}, '${mssqlFormats[precision]}'`); case "pg": case "pgnative": case "cockroachdb": // PostgreSQL has the `date_trunc` function, which is ideal for this task return knex.raw(`date_trunc(?, ${columnName} at time zone 'Z')`, [precision]); case "oracle": case "oracledb": // Oracle truncates dates using the `TRUNC` function return knex.raw(`TRUNC(${columnName}, ?)`, [precision]); case "mysql": case "mysql2": // MySQL can use the DATE_FORMAT function to truncate const mysqlFormats = { second: "%Y-%m-%d %H:%i:%s", minute: "%Y-%m-%d %H:%i:00", hour: "%Y-%m-%d %H:00:00", day: "%Y-%m-%d 00:00:00", }; return knex.raw(`DATE_FORMAT(${columnName}, '${mysqlFormats[precision]}')`); default: throw new Error(`${this.client.driverName} does not support timestamp truncation with precision`); } } return { truncatedTimestamp } } module.exports = { knexUtils } ================================================ FILE: server/utils/map.json ================================================ { "id": "world-low-res", "name": "World Low Res", "viewBox": "0 0 1008.8549 651.45282", "layers": [ { "id": "mt", "name": "Malta", "d": "m 515.52523,355.75974 -0.168,0.053 -0.167,-0.104 -0.039,-0.063 0.232,-0.053 0.114,0.047 0.048,0.09 -0.02,0.03 z m 0.709,0.604 -0.094,0.112 -0.27,-0.005 -0.236,-0.175 -0.002,-0.366 0.272,0.072 0.249,0.245 0.081,0.117 z" }, { "id": "ae", "name": "United Arab Emirates", "d": "m 620.12,393.97425 0.5,-0.15 0.11,0.84 2.19,-0.48 2.32,0.08 1.69,0.09 1.92,-2.07 2.1,-1.98 1.77,-1.9 0.53,1.05 0.38,2.44 -1.43,0.01 -0.23,2 0.5,0.42 -1.27,0.6 -0.01,1.25 -0.82,1.26 -0.07,1.21 -0.57,0.64 -8.42,-1.52 -1.08,-3.08 z" }, { "id": "af", "name": "Afghanistan", "d": "m 647.13,357.15425 2.86,1.3 2.11,-0.46 0.59,-1.55 2.21,-0.52 1.58,-1.05 0.56,-2.79 2.36,-0.68 0.44,-1.25 1.33,0.94 0.84,0.11 1.56,0.03 2.12,0.74 0.85,0.42 2.03,-1.12 0.95,0.67 0.9,-1.6 1.68,0.07 0.43,-0.52 0.3,-1.43 1.21,-1.23 1.51,0.8 -0.3,1.09 0.85,0.17 -0.27,2.95 1.11,1.15 0.98,-0.74 1.25,-0.34 1.74,-1.57 1.93,0.26 2.9,0 0.5,1.01 -1.64,0.39 -1.42,0.65 -3.22,0.4 -3.01,0.73 -1.64,1.51 0.66,1.46 0.33,1.7 -1.4,1.43 0.12,1.3 -0.77,1.22 -2.67,-0.11 1.1,2.22 -1.78,0.85 -1.19,2 0.15,1.98 -1.1,0.92 -1.03,-0.3 -2.15,0.43 -0.3,0.91 -2.09,0 -1.56,1.84 -0.1,2.75 -3.65,1.33 -1.95,-0.28 -0.57,0.7 -1.67,-0.4 -2.81,0.48 -4.69,-1.64 2.54,-2.93 -0.23,-2.1 -2.12,-0.55 -0.22,-2.09 -0.92,-2.64 1.2,-1.83 -1.22,-0.49 0.77,-2.45 z" }, { "id": "al", "name": "Albania", "d": "m 533.23,334.91425 -0.35,1.27 0.4,1.59 1.16,0.9 -0.06,0.97 -0.91,0.54 -0.17,1.19 -1.3,1.76 -0.48,-0.25 -0.05,-0.8 -1.56,-1.23 -0.24,-1.75 0.24,-2.53 0.38,-1.16 -0.47,-0.59 -0.19,-1.19 1.22,-1.87 0.17,0.72 0.76,-0.34 0.6,1.02 0.67,0.38 z" }, { "id": "am", "name": "Armenia", "d": "m 597.7,337.75425 3.9,-0.58 0.58,0.98 1.07,0.64 -0.57,0.92 1.5,1.26 -0.79,1.16 1.19,0.99 1.26,0.59 0.06,2.5 -1.02,0.1 -1.14,-2.08 0.01,-0.55 -1.24,0.01 -0.83,-0.98 -0.58,0.1 -1.11,-1.06 -2.08,-0.91 0.27,-1.79 z" }, { "id": "ao", "name": "Angola", "d": "m 521.28,480.03425 0.69,2.09 0.8,1.68 0.64,0.91 1.07,1.47 1.85,-0.23 0.93,-0.4 1.55,0.4 0.42,-0.7 0.7,-1.64 1.74,-0.11 0.15,-0.49 1.43,-0.01 -0.24,1.01 3.4,-0.02 0.05,1.77 0.57,1.09 -0.41,1.7 0.21,1.74 0.94,1.05 -0.15,3.37 0.69,-0.26 1.22,0.07 1.74,-0.42 1.28,0.17 0.3,0.88 -0.32,1.38 0.49,1.34 -0.42,1.07 0.24,0.99 -5.84,-0.04 -0.13,9.16 1.89,2.38 1.83,1.82 -5.15,1.19 -6.79,-0.41 -1.94,-1.4 -11.37,0.13 -0.42,0.21 -1.67,-1.32 -1.82,-0.09 -1.68,0.5 -1.35,0.56 -0.26,-1.83 0.39,-2.55 0.97,-2.65 0.15,-1.24 0.91,-2.59 0.67,-1.17 1.61,-1.87 0.9,-1.27 0.29,-2.11 -0.15,-1.61 -0.84,-1.01 -0.75,-1.72 -0.69,-1.69 0.15,-0.59 0.86,-1.12 -0.85,-2.72 -0.57,-1.88 -1.4,-1.77 0.27,-0.54 1.16,-0.38 0.81,0.05 0.98,-0.34 8.27,0.01 z m -10.91,-0.54 -0.71,0.3 -0.75,-2.1 1.13,-1.21 0.85,-0.47 1.05,0.96 -1.02,0.59 -0.46,0.72 -0.09,1.21 z" }, { "id": "ar", "name": "Argentina", "d": "m 291.85,649.16425 -2.66,0.25 -1.43,-1.73 -1.69,-0.13 -3,0 0,-10.57 1.08,2.15 1.4,3.53 3.65,2.87 3.93,1.21 -1.28,2.42 z m 1.5,-122.44 1.65,2.18 1.09,-2.43 3.2,0.12 0.45,0.64 5.15,4.94 2.29,0.46 3.43,2.26 2.89,1.2 0.4,1.36 -2.76,4.73 2.83,0.85 3.15,0.48 2.22,-0.5 2.54,-2.4 0.46,-2.74 1.39,-0.59 1.41,1.79 -0.06,2.49 -2.36,1.73 -1.88,1.28 -3.16,3.08 -3.74,4.37 -0.7,2.59 -0.75,3.37 0.03,3.3 -0.61,0.74 -0.22,2.17 -0.19,1.76 3.56,2.91 -0.38,2.37 1.75,1.51 -0.14,1.7 -2.69,4.52 -4.16,1.91 -5.62,0.75 -3.08,-0.36 0.59,2.15 -0.57,2.72 0.52,1.85 -1.68,1.3 -2.87,0.51 -2.7,-1.35 -1.08,0.97 0.39,3.71 1.89,1.14 1.54,-1.19 0.84,1.96 -2.58,1.18 -2.25,2.38 -0.41,3.91 -0.66,2.11 -2.65,0.01 -2.2,2.04 -0.8,3.01 2.76,2.98 2.68,0.83 -0.96,3.73 -3.31,2.38 -1.82,5.03 -2.56,1.72 -1.15,2.06 0.91,4.64 1.87,2.63 -1.18,-0.23 -2.6,-0.71 -6.78,-0.61 -1.16,-2.63 0.05,-3.33 -1.87,0.28 -0.99,-1.6 -0.25,-4.6 2.15,-1.88 0.89,-2.68 -0.33,-2.11 1.49,-3.52 1.02,-5.35 -0.3,-2.33 1.22,-0.75 -0.3,-1.48 -1.3,-0.78 0.92,-1.63 -1.27,-1.46 -0.65,-4.4 1.13,-0.77 -0.47,-4.54 0.66,-3.75 0.75,-3.22 1.68,-1.3 -0.85,-3.46 -0.01,-3.22 2.12,-2.26 -0.06,-2.87 1.6,-3.31 0.01,-3.09 -0.73,-0.61 -1.29,-5.69 1.73,-3.34 -0.27,-3.11 1,-2.9 1.84,-2.96 1.98,-1.95 -0.84,-1.23 0.59,-1 -0.09,-5.14 3.05,-1.51 0.96,-3.16 -0.34,-0.76 2.34,-2.72 3.62,0.72 z" }, { "id": "at", "name": "Austria", "d": "m 523.11,310.10425 -0.21,1.71 -1.58,0.01 0.54,0.89 -0.93,2.65 -0.53,0.69 -2.45,0.1 -1.42,0.92 -2.32,-0.31 -4.01,-1.05 -0.62,-1.43 -2.77,0.72 -0.33,0.77 -1.7,-0.58 -1.43,-0.11 -1.27,-0.74 0.43,-1.01 -0.11,-0.74 0.85,-0.22 1.42,1.14 0.4,-1.09 2.47,0.18 2.01,-0.74 1.34,0.12 0.87,0.85 0.27,-0.7 -0.4,-2.72 1.01,-0.54 0.98,-1.95 2.09,1.37 1.57,-1.74 0.99,-0.32 2.18,1.3 1.31,-0.22 1.3,0.8 -0.23,0.54 z" }, { "id": "au", "name": "Australia", "d": "m 883.18,588.41425 2.71,1.28 1.53,-0.51 2.19,-0.71 1.68,0.25 0.2,4.43 -0.96,1.3 -0.29,3.06 -0.98,-1.05 -1.95,2.67 -0.58,-0.21 -1.72,-0.12 -1.73,-3.28 -0.38,-2.5 -1.62,-3.25 0.07,-1.7 1.83,0.34 z m -5.15,-86.06 1.01,2.25 1.8,-1.08 0.93,1.22 1.35,1.13 -0.29,1.28 0.6,2.48 0.43,1.45 0.71,0.35 0.76,2.5 -0.27,1.52 0.91,1.99 3.04,1.54 1.98,1.41 1.88,1.29 -0.37,0.72 1.6,1.87 1.09,3.25 1.12,-0.66 1.14,1.31 0.69,-0.46 0.48,3.21 1.99,1.87 1.3,1.17 2.19,2.49 0.79,2.49 0.07,1.77 -0.19,1.94 1.34,2.68 -0.16,2.81 -0.49,1.48 -0.76,2.87 0.06,1.86 -0.55,2.34 -1.24,3 -2.08,1.63 -1.02,2.59 -0.94,1.67 -0.83,2.93 -1.08,1.71 -0.71,2.58 -0.36,2.4 0.14,1.11 -1.61,1.22 -3.14,0.13 -2.59,1.45 -1.29,1.38 -1.69,1.54 -2.32,-1.58 -1.72,-0.63 0.44,-1.85 -1.53,0.67 -2.46,2.58 -2.42,-0.97 -1.59,-0.56 -1.6,-0.25 -2.71,-1.03 -1.81,-2.18 -0.52,-2.66 -0.65,-1.75 -1.38,-1.4 -2.7,-0.41 0.92,-1.66 -0.68,-2.52 -1.37,2.35 -2.5,0.63 1.47,-1.88 0.42,-1.95 1.08,-1.65 -0.22,-2.47 -2.28,2.85 -1.75,1.15 -1.07,2.69 -2.19,-1.4 0.09,-1.79 -1.75,-2.43 -1.48,-1.25 0.53,-0.77 -3.6,-2 -1.97,-0.09 -2.7,-1.6 -5.02,0.31 -3.63,1.18 -3.19,1.1 -2.68,-0.22 -2.97,1.7 -2.43,0.77 -0.54,1.75 -1.04,1.36 -2.38,0.08 -1.76,0.3 -2.48,-0.61 -2.02,0.37 -1.92,0.15 -1.67,1.8 -0.82,-0.15 -1.41,0.96 -1.35,1.08 -2.05,-0.13 -1.88,0 -2.97,-2.17 -1.51,-0.64 0.06,-1.93 1.39,-0.46 0.48,-0.76 -0.1,-1.2 0.34,-2.3 -0.31,-1.95 -1.48,-3.29 -0.46,-1.85 0.12,-1.83 -1.12,-2.08 -0.07,-0.93 -1.24,-1.26 -0.35,-2.47 -1.6,-2.48 -0.39,-1.33 1.23,1.35 -0.95,-2.88 1.39,0.9 0.83,1.2 -0.05,-1.59 -1.39,-2.43 -0.27,-0.97 -0.65,-0.92 0.3,-1.77 0.57,-0.75 0.38,-1.52 -0.3,-1.77 1.16,-2.17 0.21,2.29 1.18,-2.07 2.28,-1 1.37,-1.28 2.14,-1.1 1.27,-0.23 0.77,0.37 2.21,-1.11 1.7,-0.33 0.42,-0.65 0.74,-0.27 1.55,0.07 2.95,-0.87 1.52,-1.31 0.72,-1.58 1.64,-1.49 0.13,-1.17 0.07,-1.59 1.96,-2.47 1.18,2.51 1.19,-0.58 -1,-1.38 0.88,-1.41 1.24,0.63 0.34,-2.21 1.53,-1.42 0.68,-1.14 1.41,-0.49 0.04,-0.8 1.23,0.34 0.05,-0.72 1.23,-0.41 1.36,-0.39 2.07,1.32 1.56,1.71 1.75,0.02 1.78,0.27 -0.59,-1.58 1.34,-2.3 1.26,-0.75 -0.44,-0.71 1.22,-1.63 1.7,-1.01 1.43,0.34 2.36,-0.54 -0.05,-1.45 -2.05,-0.94 1.49,-0.41 1.86,0.7 1.49,1.17 2.36,0.73 0.8,-0.29 1.74,0.88 1.64,-0.82 1.05,0.25 0.66,-0.55 1.29,1.41 -0.75,1.53 -1.06,1.16 -0.96,0.1 0.33,1.15 -0.82,1.43 -1,1.41 0.2,0.81 2.23,1.6 2.16,0.93 1.44,1 2.03,1.72 0.79,0 1.47,0.75 0.43,0.9 2.68,0.99 1.85,-1 0.55,-1.57 0.57,-1.29 0.35,-1.59 0.85,-2.3 -0.39,-1.39 0.2,-0.84 -0.32,-1.64 0.37,-2.16 0.54,-0.58 -0.44,-0.95 0.68,-1.51 0.53,-1.56 0.07,-0.81 1.04,-1.06 0.79,1.39 0.19,1.78 0.7,0.34 0.12,1.2 1.02,1.45 0.21,1.62 -0.08,1.01 z" }, { "id": "az", "name": "Azerbaijan", "d": "m 601.68,342.71425 0.83,0.97 1.24,-0.01 -0.01,0.56 1.14,2.08 -1.92,-0.48 -1.42,-1.66 -0.44,-1.37 0.58,-0.09 z m 6.65,-5.43 1.24,0.25 0.48,-0.95 1.67,-1.51 1.47,1.97 1.43,2.62 1.31,0.17 0.86,0.99 -2.31,0.29 -0.49,2.82 -0.48,1.26 -1.03,0.84 0.08,1.77 -0.7,0.18 -1.75,-1.87 0.97,-1.78 -0.83,-1.06 -1.05,0.27 -3.31,2.66 -0.06,-2.5 -1.26,-0.59 -1.19,-0.99 0.79,-1.16 -1.49,-1.26 0.56,-0.92 -1.07,-0.64 -0.58,-0.97 0.69,-0.61 2.09,1.07 1.51,0.22 0.38,-0.43 -1.38,-2.02 0.73,-0.52 0.79,0.13 1.93,2.27 z" }, { "id": "ba", "name": "Bosnia and Herzegovina", "d": "m 528.79,323.36425 1.02,-0.01 -0.7,1.72 1.35,1.5 -0.41,1.82 -0.66,0.17 -0.53,0.36 -0.91,0.89 -0.41,2.1 -2.48,-1.44 -1.06,-1.61 -1.07,-0.85 -1.29,-1.45 -0.6,-1.21 -1.38,-1.83 0.59,-1.64 1.01,0.91 0.6,-0.82 1.31,-0.09 2.41,0.66 1.94,-0.06 z" }, { "id": "bd", "name": "Bangladesh", "d": "m 735.34,400.66425 -0.05,2.15 -0.98,-0.46 0.18,2.41 -0.8,-1.56 -0.16,-1.52 -0.54,-1.45 -1.17,-1.76 -2.58,-0.12 0.26,1.25 -0.88,1.67 -1.2,-0.61 -0.41,0.55 -0.79,-0.33 -1.08,-0.27 -0.44,-2.48 -0.97,-2.28 0.47,-1.84 -1.72,-0.82 0.62,-1.12 1.75,-1.15 -2.02,-1.63 0.99,-2.11 2.22,1.34 1.34,0.16 0.25,2.15 2.66,0.42 2.61,-0.05 1.61,0.53 -1.29,2.59 -1.26,0.18 -0.86,1.73 1.53,1.58 0.46,-1.94 0.78,-0.01 z" }, { "id": "be", "name": "Belgium", "d": "m 484.8,296.16425 2.05,0.35 2.6,-0.93 1.77,1.95 1.55,1.04 -0.32,2.97 -0.73,0.16 -0.31,2.43 -2.45,-1.97 -1.44,0.34 -1.96,-2.06 -1.3,-1.77 -1.3,-0.07 -0.41,-1.56 z" }, { "id": "bf", "name": "Burkina Faso", "d": "m 467.58,436.65425 -1.92,-0.73 -1.32,0.11 -0.98,0.71 -1.26,-0.6 -0.49,-0.93 -1.26,-0.62 -0.19,-1.64 0.77,-1.21 -0.07,-0.96 2.23,-2.36 0.41,-1.96 0.77,-0.7 1.36,0.38 1.17,-0.58 0.38,-0.74 2.18,-1.28 0.53,-0.9 2.62,-1.2 1.55,-0.41 0.7,0.55 1.79,-0.01 -0.22,1.4 0.38,1.31 1.58,1.87 0.08,1.38 3.24,0.65 -0.07,1.95 -0.61,0.86 -1.37,0.26 -0.57,1.24 -0.96,0.32 -2.46,-0.06 -1.3,-0.22 -0.9,0.46 -1.24,-0.21 -4.87,0.13 -0.07,1.61 z" }, { "id": "bg", "name": "Bulgaria", "d": "m 539.03,325.81425 0.81,1.6 1.08,-0.29 2.16,0.61 4.12,0.2 1.39,-0.99 3.3,-0.9 2.04,1.41 1.65,0.41 -1.46,1.59 -1.02,2.73 0.9,2.16 -2.41,-0.51 -2.86,1.18 -0.03,1.86 -2.55,0.35 -1.97,-1.3 -2.25,1.03 -2.07,-0.11 -0.2,-2.47 -1.41,-1.21 0.47,-0.54 -0.31,-0.45 0.47,-1.21 1.07,-1.19 -1.36,-1.66 -0.25,-1.42 z" }, { "id": "bi", "name": "Burundi", "d": "m 557.77,476.18425 -0.18,-3.37 -0.71,-1.26 1.71,0.22 0.86,-1.59 1.49,0.18 0.16,1.1 0.6,0.63 0.03,0.91 -0.69,0.58 -1.1,1.46 -1.01,1.01 z" }, { "id": "bj", "name": "Benin", "d": "m 483.05,446.17425 -2.32,0.33 -0.69,-1.94 0.13,-6.46 -0.57,-0.58 -0.1,-1.39 -0.98,-0.99 -0.85,-0.83 0.36,-1.5 0.96,-0.32 0.57,-1.24 1.37,-0.26 0.61,-0.86 0.94,-0.83 1.01,-0.01 2.14,1.64 -0.11,0.95 0.63,1.68 -0.55,1.14 0.29,0.76 -1.36,1.75 -0.86,0.87 -0.53,1.77 0.07,1.79 z" }, { "id": "bn", "name": "Brunei Darussalam", "d": "m 795.71,451.02425 1.11,-1.05 2.39,-1.53 -0.13,1.38 -0.16,1.78 -1.34,-0.09 -0.59,0.95 z" }, { "id": "bo", "name": "Bolivia", "d": "m 299.29,526.60425 -3.2,-0.13 -1.09,2.43 -1.65,-2.18 -3.67,-0.73 -2.33,2.72 -2.03,0.41 -1.1,-4.15 -1.5,-3.34 0.88,-2.87 -1.47,-1.25 -0.37,-2.12 -1.38,-2 1.77,-3.14 -1.21,-2.44 0.65,-0.97 -0.51,-1.07 1.1,-1.44 0.06,-2.44 0.13,-2.02 0.61,-0.96 -2.43,-4.58 2.09,0.24 1.44,-0.07 0.63,-0.85 2.45,-1.15 1.47,-1.06 3.67,-0.48 -0.29,2.12 0.34,1.09 -0.23,1.9 3.05,2.55 3.14,0.47 1.1,1.07 1.9,0.57 1.16,0.83 1.76,-0.03 1.63,0.85 0.12,1.66 0.55,0.84 0.04,1.25 -0.82,0.04 1.08,3.37 5.37,0.12 -0.41,1.68 0.3,1.15 1.53,0.82 0.67,1.82 -0.5,2.32 -0.77,1.29 0.27,1.69 -0.88,0.61 -0.04,-0.91 -2.62,-1.51 -2.6,-0.05 -4.89,0.86 -1.34,2.62 -0.07,1.6 -1.11,3.59 z" }, { "id": "br", "name": "Brazil", "d": "m 313.93,552.04425 3.74,-4.37 3.17,-3.08 1.88,-1.28 2.36,-1.73 0.06,-2.49 -1.41,-1.79 -1.39,0.59 0.55,-1.78 0.38,-1.82 0,-1.68 -1.01,-0.55 -1.05,0.49 -1.04,-0.13 -0.33,-1.18 -0.26,-2.77 -0.53,-0.9 -1.89,-0.82 -1.14,0.59 -2.96,-0.58 0.18,-4.06 -0.83,-1.66 0.88,-0.61 -0.27,-1.69 0.77,-1.29 0.5,-2.32 -0.67,-1.82 -1.53,-0.82 -0.3,-1.15 0.41,-1.68 -5.37,-0.12 -1.08,-3.37 0.82,-0.04 -0.04,-1.25 -0.55,-0.84 -0.12,-1.66 -1.63,-0.85 -1.76,0.03 -1.16,-0.83 -1.9,-0.57 -1.1,-1.07 -3.14,-0.47 -3.05,-2.55 0.23,-1.9 -0.34,-1.09 0.29,-2.12 -3.67,0.48 -1.47,1.06 -2.45,1.15 -0.63,0.85 -1.44,0.07 -2.09,-0.24 -1.58,0.49 -1.28,-0.33 0.19,-4.3 -2.3,1.66 -2.47,-0.07 -1.06,-1.51 -1.86,-0.16 0.59,-1.21 -1.56,-1.72 -1.17,-2.53 0.74,-0.51 0,-1.19 1.7,-0.81 -0.28,-1.51 0.71,-0.98 0.21,-1.3 3.2,-1.91 2.3,-0.53 0.37,-0.42 2.53,0.13 1.26,-7.65 0.07,-1.21 -0.44,-1.59 -1.24,-1.02 0.01,-2.02 1.58,-0.46 0.56,0.29 0.09,-1.07 -1.64,-0.29 -0.03,-1.74 5.46,0.06 0.93,-0.96 0.78,0.88 0.54,1.65 0.53,-0.35 1.55,1.48 2.18,-0.18 0.54,-0.86 2.08,-0.65 1.16,-0.45 0.32,-1.18 2.01,-0.8 -0.16,-0.58 -2.37,-0.24 -0.39,-1.76 0.11,-1.87 -1.25,-0.72 0.52,-0.26 2.08,0.36 2.23,0.7 0.81,-0.66 2.01,-0.44 3.14,-1.04 1.03,-1.07 -0.38,-0.79 1.46,-0.12 0.66,0.64 -0.37,1.23 0.96,0.42 0.65,1.3 -0.78,0.98 -0.45,2.38 0.72,1.41 0.2,1.29 1.73,1.3 1.38,0.14 0.31,-0.54 0.88,-0.12 1.27,-0.49 0.91,-0.74 1.55,0.23 0.68,-0.1 1.53,0.23 0.25,-0.57 -0.47,-0.55 0.28,-0.81 1.13,0.25 1.33,-0.29 1.6,0.59 1.23,0.58 0.87,-0.76 0.62,0.12 0.39,0.79 1.34,-0.2 1.07,-1.06 0.86,-2.06 1.66,-2.55 0.96,-0.13 0.69,1.54 1.57,4.88 1.5,0.46 0.08,1.92 -2.11,2.29 0.87,0.84 4.96,0.44 0.1,2.79 2.13,-1.83 3.53,1.01 4.65,1.7 1.37,1.63 -0.46,1.54 3.26,-0.86 5.46,1.48 4.19,-0.11 4.14,2.31 3.58,3.13 2.16,0.8 2.4,0.12 1.02,0.88 0.95,3.57 0.47,1.69 -1.12,4.66 -1.43,1.84 -3.95,3.94 -1.79,3.21 -2.07,2.48 -0.7,0.06 -0.79,2.1 0.2,5.4 -0.78,4.48 -0.3,1.93 -0.88,1.15 -0.5,3.94 -2.84,3.88 -0.48,3.09 -2.27,1.31 -0.66,1.81 -3.04,-0.01 -4.41,1.17 -1.98,1.35 -3.14,0.89 -3.3,2.44 -2.37,3.06 -0.41,2.32 0.47,1.73 -0.53,3.18 -0.63,1.55 -1.96,1.75 -3.11,5.68 -2.47,2.59 -1.91,1.54 -1.27,3.16 -1.86,1.91 -0.78,-1.9 1.24,-1.57 -1.62,-2.25 -2.2,-1.82 -2.89,-2.08 -1.04,0.09 -2.81,-2.5 z" }, { "id": "bs", "name": "Bahamas", "d": "m 258.11,395.45425 -0.69,0.15 -0.71,-1.76 -1.05,-0.89 0.61,-1.95 0.84,0.12 0.98,2.55 0.02,1.78 z m -0.8,-8.69 -3.06,0.5 -0.2,-1.15 1.32,-0.25 1.85,0.09 0.09,0.81 z m 2.3,-0.03 -0.48,2.21 -0.52,-0.4 0.05,-1.63 -1.26,-1.23 -0.01,-0.36 2.22,1.41 z" }, { "id": "bt", "name": "Bhutan", "d": "m 732.61,383.03425 1.14,1 -0.2,1.93 -2.29,0.09 -2.36,-0.21 -1.77,0.49 -2.55,-1.19 -0.05,-0.63 1.85,-2.34 1.51,-0.8 2.01,0.73 1.48,0.08 z" }, { "id": "bw", "name": "Botswana", "d": "m 547.42,516.20425 0.56,0.52 0.89,1.71 3.17,3.25 1.2,0.32 0.01,1.05 0.82,1.9 2.17,0.46 1.79,1.36 -3.97,2.22 -2.52,2.26 -0.93,2.03 -0.84,1.15 -1.53,0.25 -0.49,1.47 -0.29,0.96 -1.79,0.72 -2.28,-0.15 -1.34,-0.86 -1.18,-0.38 -1.37,0.72 -0.69,1.48 -1.33,0.93 -1.4,1.39 -2.01,0.32 -0.62,-1.09 0.26,-1.9 -1.67,-2.93 -0.75,-0.46 0,-8.86 2.76,-0.11 0.08,-10.57 2.09,-0.09 4.32,-1.03 1.08,1.21 1.78,-1.15 0.86,-0.01 1.58,-0.66 0.5,0.22 z" }, { "id": "by", "name": "Belarus", "d": "m 541.35,284.32425 2.71,0.04 3.04,-1.8 0.65,-2.72 2.3,-1.57 -0.26,-2.2 1.7,-0.84 3.02,-1.93 2.95,1.26 0.4,1.23 1.47,-0.59 2.74,1.18 0.27,2.31 -0.6,1.32 1.76,3.15 1.14,0.87 -0.17,0.86 1.89,0.83 0.81,1.25 -1.09,1.02 -2.26,-0.16 -0.54,0.44 0.66,1.54 0.69,2.93 -2.41,0.27 -0.86,1 -0.19,2.26 -1.11,-0.43 -2.53,0.22 -0.74,-1.05 -1.05,0.78 -1.05,-0.65 -2.21,-0.09 -3.13,-1.08 -2.83,-0.36 -2.17,0.1 -1.54,1.23 -1.34,0.17 -0.05,-2.01 -0.87,-2.12 1.68,-0.94 0.02,-1.85 -0.78,-1.78 z" }, { "id": "bz", "name": "Belize", "d": "m 225.56,413.21425 -0.02,-0.43 0.34,-0.14 0.51,0.35 1,-1.77 0.53,-0.04 0.01,0.43 0.53,0.01 -0.04,0.8 -0.46,1.27 0.25,0.45 -0.29,1.05 0.17,0.27 -0.32,1.47 -0.55,0.78 -0.51,0.09 -0.56,1 -0.83,0 0.22,-3.28 z" }, { "id": "ca", "name": "Canada", "d": "m 199.18,96.484245 -0.22,-5.9 3.63,0.58 1.63,0.96 3.35,4.92 -0.76,4.970005 -4.15,2.77 -2.28,-3.12 -1.2,-5.180005 z m 13.21,12.650005 0.33,-1.49 -1.97,-2.45 -5.65,-0.19 0.75,3.68 5.25,0.83 1.29,-0.38 z m 36.35,46.95 3.08,5.1 0.81,0.57 3.07,-1.27 3.02,0.2 2.98,0.28 -0.25,-2.64 -4.84,-5.38 -6.42,-1.08 -1.35,0.67 -0.1,3.55 z m -65.43,-62.700005 -2.71,4.19 6.24,0.52 4.61,4.440005 4.58,1.5 -1.09,-5.680005 -2.14,-6.73 -7.58,-5.35 -5.5,-2.04 0.2,5.69 3.39,3.46 z m 25.9,-10.24 5.13,-0.12 -2.22,4 -0.04,5.3 3.01,5.76 5.81,1.77 4.96,-0.99 5.18,-10.73 3.85,-4.45 -3.38,-4.97 -2.21,-10.65 -4.6,-3.19 -4.72,-3.68 -3.58,-9.56 -6.52,0.94 1.23,4.15 -2.87,1.25 -1.94,5.32 -1.94,7.46 1.78,7.26 3.07,5.13 z m -63.75,53.380005 3.92,1.95 12.67,-1.3 -5.82,4.77 0.36,3.43 4.26,-0.24 7.07,-4.58 9.5,-1.67 1.71,-5.22 -0.49,-5.57 -2.94,-0.5 -2.5,1.93 -1.1,-4.13 -0.95,-5.7 -2.9,-1.42 -2.57,4.41 4.01,11.05 -4.9,-0.85 -4.98,-6.79 -7.89,-4 -2.64,3.32 -3.82,11.11 z m 22.56,-42.060005 -3.65,-2.9 -1.5,-0.66 -2.88,4.28 -0.05,2 4.66,0.01 3.42,-2.73 z m -1.46,12.350005 0.93,-3.99 -3.95,-2.12 -4.09,1.39 -2.27,4.26 4.16,4.21 5.22,-3.75 z m 29.09,33.24 4.62,-1.11 1.28,-8.25 -0.09,-5.95 -2.14,-5.56 -0.22,1.6 -3.94,-0.7 -4.22,4.09 -3.02,-0.37 0.18,8.92 4.6,-0.87 -0.06,6.47 3.01,1.73 z m -3.28,45.61 -5.06,-3.93 -4.71,-4.21 -0.87,-6.18 -1.76,-8.92 -3.14,-3.84 -2.79,-1.55 -2.47,1.42 1.99,9.59 -1.41,3.73 -2.29,-8.98 -2.56,-3.11 -3.17,4.81 -3.9,-4.76 -6.24,2.87 1.4,-4.46 -2.87,-1.87 -7.51,5.84 -1.95,3.71 -2.35,6.77 4.9,2.32 4.33,-0.12 -6.5,3.46 1.48,3.13 3.98,0.17 5.99,-0.67 5.42,1.96 -3.66,1.44 -3.95,-0.37 -4.33,1.41 -1.87,0.87 3.45,6.35 2.49,-0.88 3.83,2.15 1.52,3.65 4.99,-0.73 7.1,-1.16 5.26,-2.65 3.26,-0.48 4.82,2.12 5.07,1.22 0.94,-2.86 -1.79,-3.05 4.6,-0.64 0.33,-3.57 z m 7.74,-0.98 -1.96,3.54 -2.47,2.49 3.83,3.54 2.28,-0.85 3.78,2.36 1.74,-2.73 -1.71,-3.03 -0.84,-1.53 -1.68,-1.46 -2.97,-2.33 z m -17.61,-29.45 -2.13,-2.17 -3.76,0.4 -0.95,1.38 4.37,6.75 2.47,-6.36 z m 28.69,13.17 3.01,-6.93 3.34,-1.85 4.19,-8.74 -5.36,-2.47 -5.84,-0.36 -2.78,2.77 -1.47,4.23 -0.04,4.82 1.75,8.19 3.2,0.34 z m 17.15,-23 5.76,-0.18 8.04,-1.61 3.59,1.28 4.18,-2.26 1.75,-2.84 -0.63,-4.52 -3,-4.23 -4.56,-0.8 -5.71,0.97 -4.46,2.44 -4.09,-0.94 -3.78,-0.5 -1.78,-2.7 -3.22,-2.61 0.64,-4.43 -2.42,-3.98 -5.52,0.03 -3.11,-3.99 -5.78,-0.8 -1.06,5.1 3.25,3.74 5.8,1.45 2.81,5.09 0.34,5.6 0.97,5.99 7.45,3.42 4.54,1.28 z m -89.02,-18.27 5.21,-5.05 2.62,-0.59 2.16,-4.23 0.38,-9.77 -3.85,1.91 -4.3,-0.18 -5.76,8.19 -4.76,8.98 3.8,2.51 4.5,-1.77 z m 72.18,16.17 1.53,-4.14 -1.02,-3.46 -2.45,-3.92 -4.03,3.02 -1.49,4.92 3.4,2.79 4.06,0.79 z m -8.31,11.44 -0.73,-2.88 -5,1.26 -3.34,-2.11 -3.32,4.8 3.09,6.28 -5.72,-1.17 -0.06,3.01 6.97,7.05 1.94,3.38 2.7,0.73 4.6,-3.41 0.5,-8.21 -4.24,-4.07 2.61,-4.66 z m -73.99,153.74 -1.16,-2.34 -2.8,-1.77 -1.39,-2.05 -0.95,-1.5 -2.64,-0.46 -1.72,-0.67 -2.94,-0.96 -0.24,1.02 1.08,2.38 2.89,0.78 0.5,1.23 2.51,1.5 0.84,1.51 4.6,1.92 1.42,-0.59 z m 121.7,-77.63 -2,-2.11 -2.06,0.5 -0.25,-3.06 -3.21,-2.04 -3.07,-2.27 -1.63,-1.75 -1.43,1.03 -0.52,-2.96 -2.03,-0.55 -0.96,6.13 -0.36,5.11 -2.44,3.14 3.8,-0.6 0.96,3.65 3.99,-3.23 2.78,-3.38 1.57,2.86 4.36,1.51 2.5,-1.98 z m -120.53,-52.55 7.38,-4.18 0,-3.87 3.48,-6.41 6.88,-6.69 3.52,-2.47 -3.01,-4.2 -2.72,-2.95 -7.16,-0.57 -4,-2.16 -9.48,1.63 2.74,6.23 -2.43,6.43 -1.94,6.87 -1.2,3.86 6.47,4.69 1.47,3.79 z m 134.24,27.31 0.32,-1.01 -0.03,-3.17 -2.19,-2.08 -2.57,1.05 -1.19,4.17 0.7,3.56 3.14,-0.36 1.82,-2.16 z m 23.82,7.54 4.41,6.6 3.45,2.85 4.92,-7.87 0.87,-4.93 -4.41,-0.47 -4.03,-6.7 -4.45,-1.64 -6.6,-4.97 5.15,-3.63 -2.65,-7.54 -2.44,-3.35 -6.77,-3.35 -2.92,-5.55 -5.21,1.99 -0.36,-3.86 -3.86,-4.32 -6.22,-4.71 -2.65,3.71 -5.55,2.66 0.42,-6.06 -4.81,-10.05 -7.11,4.06 -2.59,7.7 -2.21,-5.92 2.06,-6.37 -7.24,2.65 -2.88,3.99 -2.15,8.42 0.89,9.05 3.98,0.04 -2.93,3.92 2.33,2.96 4.55,1.25 5.93,2.42 10.2,1.82 5.08,-1.04 1.5,-2.42 2.21,2.79 2.47,0.46 2.97,4.96 -1.8,1.98 5.68,2.63 4.29,3.68 1.08,2.55 0.77,3.24 -3.63,6.93 -0.98,3.44 0.94,2.42 -5.77,0.86 -5.27,0.12 -1.85,4.87 2.37,2.23 8.11,-1.03 -0.04,-1.89 4.08,3.15 4.18,3.28 -0.98,1.77 3.4,3.02 6.02,3.53 7.6,2.39 -0.46,-2.09 -2.92,-3.67 -3.96,-5.37 7.03,5 3.54,1.66 0.97,-4.44 -1.82,-6.3 -1.16,-1.73 -3.81,-3.03 -2.95,-3.91 0.35,-3.94 3.64,-0.9 z M 222.6,51.594245 l 2.34,7.29 4.96,5.88 9.81,-1.09 6.31,1.97 -4.38,6.05 -2.21,-1.78 -7.66,-0.71 1.19,8.31 3.96,6.04 -0.8,5.2 -4.97,3.46 -2.27,5.47 4.55,2.650005 3.82,8.55 -7.5,-5.7 -1.71,0.94 1.38,9.38 -5.18,2.83 0.35,5.85 5.3,0.63 4.17,1.44 8.24,-1.84 7.33,3.27 7.49,-7.19 -0.06,-3.02 -4.79,0.48 -0.39,-2.84 3.92,-3.83 1.33,-5.15 4.33,-3.83 2.66,-4.760005 -2.32,-7.1 1.94,-2.65 -3.86,-1.89 8.49,-1.63 1.79,-3.15 5.78,-2.6 4.8,-13.47 4.57,-4.94 6.62,-11.12 -6.1,0.1 2.54,-4.3 6.78,-3.99 6.84,-8.9 0.12,-5.73 -5.13,-6.04 -6.02,-2.93 -7.49,-1.82 -6.07,-1.49 -6.07,-1.5 -8.1,3.98 -1.49,-2.53 -8.57,0.98 -5.03,2.57 -3.7,3.65 -2.13,11.74 -3.06,-6.01 -3.48,-1.14 -4.12,7.97 -5.5,3.35 -3.27,0.66 -4.17,3.84 0.61,6.65 3.28,5.49 z m 74.4,265.000005 -0.98,-1.98 -1.06,1.26 0.7,1.36 3.56,1.71 1.04,-0.26 1.38,-1.66 -2.6,0.11 -2.04,-0.54 z m -57,-77.86 0.61,1.63 1.98,0.14 3.28,-3.34 0.06,-1.19 -3.85,-0.06 -2.08,2.82 z m 62.13,66.44 -2.87,-1.8 -3.69,-1.09 -0.97,0.37 2.61,2.04 3.63,1.34 1.36,-0.08 -0.07,-0.78 z m 24.88,4.79 -0.36,-2.24 -1.96,0.72 0.87,-3.11 -2.8,-1.32 -1.29,1.05 -2.49,-1.18 0.98,-1.51 -1.88,-0.93 -1.83,1.47 1.86,-3.82 1.5,-2.8 0.54,-1.22 -1.3,-0.2 -2.43,1.55 -1.74,2.53 -2.9,6.92 -2.35,2.56 1.22,1.14 -1.75,1.47 0.43,1.23 5.44,0.13 3.01,-0.25 2.69,1.01 -1.98,1.93 1.67,0.14 3.25,-3.58 0.78,0.53 -0.61,3.37 1.84,0.77 1.27,-0.15 1.18,-3.61 -0.86,-2.6 z m -21.19,4.76 -2.81,4.56 -4.63,0.58 -3.64,-2.01 -0.92,-3.07 -0.89,-4.46 2.65,-2.83 -2.48,-2.09 -4.19,0.43 -5.88,3.53 -4.5,5.45 -2.38,0.67 3.23,-3.8 4.04,-5.57 3.57,-1.9 2.35,-3.11 2.9,-0.3 4.21,0.03 6,0.92 4.74,-0.71 3.53,-3.62 4.62,-1.59 2.01,-1.58 2.04,-1.71 -0.2,-5.19 -1.13,-1.77 -2.18,-0.63 -1.11,-4.05 -1.8,-1.55 -4.47,-1.26 -2.52,-2.82 -3.73,-2.83 1.13,-3.2 -3.1,-6.26 -3.65,-6.89 -2.18,-4.98 -1.86,2.61 -2.68,6.05 -4.06,2.97 -2.03,-3.16 -2.56,-0.85 -0.93,-6.99 0.08,-4.8 -5,-0.44 -0.85,-2.27 -3.45,-3.44 -2.61,-2.04 -2.32,1.58 -2.88,-0.58 -4.81,-1.65 -1.95,1.4 0.94,9.18 1.22,5.12 -3.31,5.75 3.41,4.02 1.9,4.44 0.23,3.42 -1.55,3.5 -3.18,3.46 -4.49,2.28 1.98,2.53 1.46,7.4 -1.52,4.68 -2.16,1.46 -4.17,-4.28 -2.03,-5.17 -0.87,-4.76 0.46,-4.19 -3.05,-0.47 -4.63,-0.28 -2.97,-2.08 -3.51,-1.37 -2.01,-2.38 -2.8,-1.94 -5.21,-2.23 -3.92,1.02 -1.31,-3.95 -1.26,-4.99 -4.12,-0.9 0.15,-6.41 1.09,-4.48 3.04,-6.6 3.43,-4.9 3.26,-0.77 0.19,-4.05 2.21,-2.68 4.01,-0.42 3.25,-4.39 0.82,-2.9 2.7,-5.73 0.84,-3.5 2.9,2.11 3.9,-1.08 5.49,-4.96 0.36,-3.54 -1.98,-3.98 2.09,-4.06 -0.17,-3.87 -3.76,-3.95 -4.14,-1.19 -3.98,-0.62 -0.15,8.71 -2.04,6.56 -2.93,5.3 -2.71,-4.95 0.84,-5.61 -3.35,-5.02 -3.75,6.09 0.01,-7.99 -5.21,-1.63 2.49,-4.01 -3.81,-9.59 -2.84,-3.91 -3.7,-1.44 -3.32,6.43 -0.22,9.34 3.27,3.29 3,4.91 -1.27,7.71 -2.26,-0.2 -1.78,5.88 0.02,-7 -4.34,-2.58 -2.49,1.33 0.32,4.67 -4.09,-0.18 -4.35,1.17 -4.95,-3.35 -3.13,0.6 -2.82,-4.11 -2.26,-1.84 -2.24,0.77 -3.41,0.35 -1.81,2.61 2.86,3.19 -3.05,3.72 -2.99,-4.42 -2.39,1.3 -7.57,0.87 -5.07,-1.59 3.94,-3.74 -3.78,-3.9 -2.75,0.5 -3.86,-1.32 -6.56,-2.89 -4.29,-3.37 -3.4,-0.47 -1.06,2.36 -3.44,1.31 -0.38,-6.15 -3.73,5.5 -4.74,-7.32 -1.94,-0.89 -0.63,3.91 -2.09,1.9 -1.93,-3.39 -4.59,2.05 -4.2,3.55 -4.17,-0.98 -3.4,2.5 -2.46,3.28 -2.92,-0.72 -4.41,-3.8 -5.23,-1.94 -0.02,27.65 -0.01,35.43 2.76,0.17 2.73,1.56 1.96,2.44 2.49,3.6 2.73,-3.05 2.81,-1.79 1.49,2.85 1.89,2.23 2.57,2.42 1.75,3.79 2.87,5.88 4.77,3.2 0.08,3.12 -1.56,2.35 0.06,2.48 3.39,3.45 0.49,3.76 3.59,1.96 -0.4,2.79 1.56,3.96 5.08,1.82 2,1.89 5.43,4.23 0.38,0.01 7.96,0 8.32,0 2.76,0 8.55,0 8.27,0 8.41,0 8.42,0 9.53,0 9.59,0 5.8,0 0.01,-1.64 0.95,-0.02 0.5,2.35 0.87,0.72 1.96,0.26 2.86,0.67 2.72,1.3 2.27,-0.55 3.45,1.09 1.14,-1.66 1.59,-0.66 0.62,-1.03 0.63,-0.55 2.61,0.86 1.93,0.1 0.67,0.57 0.94,2.38 3.15,0.63 -0.49,1.18 1.11,1.21 -0.48,1.56 1.18,0.51 -0.59,1.37 0.75,0.13 0.53,-0.6 0.55,0.9 2.1,0.5 2.13,0.04 2.27,0.41 2.51,0.78 0.91,1.26 1.82,3.04 -0.9,1.3 -2.28,-0.54 -1.42,-2.44 0.36,2.49 -1.34,2.17 0.15,1.84 -0.23,1.07 -1.81,1.27 -1.32,2.09 -0.62,1.32 1.54,0.24 2.08,-1.2 1.23,-1.06 0.83,-0.17 1.54,0.38 0.75,-0.59 1.37,-0.48 2.44,-0.47 0,0 0,0 -0.25,-1.15 -0.13,0.04 -0.86,0.2 -1.12,-0.36 0.84,-1.32 0.85,-0.46 1.98,-0.56 2.37,-0.53 1.24,0.73 0.78,-0.85 0.89,-0.54 0.6,0.29 0.03,0.06 2.87,-2.73 1.27,-0.73 4.26,-0.03 5.17,0 0.28,-0.98 0.9,-0.2 1.19,-0.62 1,-1.82 0.86,-3.15 2.14,-3.1 0.93,1.08 1.88,-0.7 1.25,1.19 0,5.52 1.83,2.25 3.12,-0.48 4.49,-0.13 -4.87,3.26 0.11,3.29 2.13,0.28 3.13,-2.79 2.78,-1.58 6.21,-2.35 3.47,-2.62 -1.81,-1.46 -0.29,-2.92 z m -53.66,-71.1 1.1,-3.12 -0.71,-1.23 -1.15,-0.13 -1.08,1.8 -0.13,0.41 0.74,1.77 1.23,0.5 z m -142.66,36.43 0,0 1.56,-2.35 -1.56,2.35 z m -3.4,3.29 -2.69,0.38 -1.32,-0.62 -0.17,1.52 0.52,2.07 1.42,1.46 1.04,2.13 1.69,2.1 1.12,0.01 -2.44,-3.7 0.83,-5.35 z" }, { "id": "cd", "name": "Democratic Republic of Congo", "d": "m 561.96,453.86425 -0.17,3.26 1.12,0.37 -0.9,0.99 -1.08,0.74 -1.07,1.46 -0.59,1.29 -0.16,2.24 -0.65,1.06 -0.02,2.1 -0.81,0.78 -0.1,1.66 -0.39,0.21 -0.26,1.53 0.71,1.26 0.18,3.37 0.5,2.57 -0.28,1.46 0.56,1.62 1.63,1.57 1.51,3.55 -1.1,-0.29 -3.77,0.48 -0.75,0.33 -0.8,1.8 0.63,1.25 -0.5,3.35 -0.35,2.85 0.76,0.51 1.96,1.1 0.77,-0.51 0.24,3.08 -2.15,-0.03 -1.15,-1.57 -1.03,-1.22 -2.15,-0.4 -0.63,-1.49 -1.72,0.9 -2.24,-0.4 -0.94,-1.29 -1.78,-0.26 -1.31,0.07 -0.16,-0.88 -0.97,-0.07 -1.28,-0.17 -1.73,0.42 -1.22,-0.07 -0.7,0.26 0.15,-3.37 -0.93,-1.05 -0.21,-1.73 0.41,-1.7 -0.56,-1.09 -0.05,-1.76 -3.41,0.02 0.25,-1.01 -1.43,0.01 -0.15,0.49 -1.74,0.11 -0.71,1.63 -0.42,0.71 -1.55,-0.4 -0.92,0.4 -1.86,0.22 -1.07,-1.47 -0.64,-0.91 -0.81,-1.68 -0.69,-2.09 -8.27,-0.03 -0.99,0.33 -0.81,-0.05 -1.16,0.38 -0.39,-0.87 0.71,-0.3 0.09,-1.22 0.46,-0.72 1.02,-0.58 0.74,0.28 0.96,-1.07 1.52,0.03 0.18,0.79 1.05,0.5 1.65,-1.76 1.63,-1.36 0.71,-0.89 -0.09,-2.3 1.22,-2.71 1.28,-1.43 1.85,-1.34 0.32,-0.89 0.07,-1.02 0.46,-0.97 -0.15,-1.58 0.35,-2.47 0.55,-1.74 0.84,-1.49 0.16,-1.68 0.25,-1.95 1.1,-1.42 1.5,-0.9 2.31,0.95 1.78,1.03 2.05,0.28 2.09,0.54 0.84,-1.68 0.39,-0.22 1.27,0.28 3.13,-1.39 1.1,0.59 0.91,-0.08 0.42,-0.68 1.04,-0.24 2.11,0.29 1.8,0.06 0.93,-0.29 1.69,2.31 1.26,0.33 0.75,-0.47 1.3,0.19 1.56,-0.59 0.67,1.19 z" }, { "id": "cf", "name": "Central African Republic", "d": "m 518.34,442.91425 2.32,-0.22 0.52,-0.72 0.46,0.06 0.7,0.63 3.53,-1.07 1.19,-1.1 1.47,-0.99 -0.28,-0.99 0.79,-0.26 2.71,0.18 2.64,-1.31 2.02,-3.09 1.43,-1.14 1.77,-0.49 0.32,1.22 1.62,1.77 0,1.15 -0.45,1.18 0.18,0.87 0.97,0.81 2.14,1.24 1.53,1.13 0.03,0.92 1.88,1.46 1.17,1.21 0.71,1.68 2.1,1.11 0.45,0.89 -0.93,0.29 -1.8,-0.06 -2.11,-0.29 -1.04,0.24 -0.42,0.68 -0.91,0.08 -1.1,-0.59 -3.13,1.39 -1.27,-0.28 -0.39,0.22 -0.84,1.68 -2.09,-0.54 -2.05,-0.28 -1.78,-1.03 -2.31,-0.95 -1.5,0.9 -1.1,1.42 -0.25,1.95 -1.8,-0.16 -1.9,-0.47 -1.67,1.48 -1.47,2.6 -0.3,-0.81 -0.12,-1.27 -1.28,-0.9 -1.04,-1.44 -0.24,-1 -1.32,-1.46 0.22,-0.83 -0.28,-1.18 0.22,-2.17 0.67,-0.51 z" }, { "id": "cg", "name": "Republic of Congo", "d": "m 511.94,476.97425 -1.05,-0.96 -0.85,0.47 -1.13,1.2 -2.3,-2.95 2.13,-1.54 -1.05,-1.85 0.96,-0.7 1.89,-0.34 0.22,-1.24 1.5,1.34 2.48,0.12 0.86,-1.32 0.35,-1.85 -0.31,-2.18 -1.32,-1.64 1.21,-3.23 -0.7,-0.55 -2.08,0.22 -0.79,-1.43 0.21,-1.22 3.53,0.11 2.27,0.73 2.23,0.66 0.2,-1.5 1.47,-2.6 1.67,-1.48 1.9,0.47 1.8,0.16 -0.16,1.68 -0.84,1.49 -0.55,1.74 -0.35,2.47 0.15,1.58 -0.46,0.97 -0.07,1.02 -0.32,0.89 -1.85,1.34 -1.28,1.43 -1.22,2.71 0.09,2.3 -0.71,0.89 -1.63,1.36 -1.65,1.76 -1.05,-0.5 -0.18,-0.79 -1.52,-0.03 -0.96,1.07 z" }, { "id": "ch", "name": "Switzerland", "d": "m 502.4,312.59425 0.11,0.74 -0.43,1.01 1.27,0.74 1.43,0.11 -0.22,1.67 -1.23,0.69 -2.08,-0.51 -0.61,1.63 -1.33,0.13 -0.49,-0.64 -1.57,1.36 -1.35,0.19 -1.21,-0.86 -0.96,-1.77 -1.34,0.64 0.04,-1.84 2.05,-2.31 -0.09,-1.05 1.28,0.39 0.77,-0.71 2.38,0.03 0.58,-0.9 z" }, { "id": "ci", "name": "Côte d'Ivoire", "d": "m 467.49,449.71425 -1.27,0.03 -1.96,-0.55 -1.79,0.03 -3.33,0.49 -1.94,0.81 -2.78,1.02 -0.54,-0.07 0.21,-2.3 0.27,-0.35 -0.08,-1.11 -1.19,-1.17 -0.89,-0.19 -0.82,-0.77 0.61,-1.24 -0.28,-1.36 0.13,-0.82 0.45,0 0.16,-1.23 -0.22,-0.54 0.27,-0.39 1.04,-0.34 -0.69,-2.26 -0.65,-1.16 0.23,-0.97 0.56,-0.21 0.36,-0.26 0.78,0.42 2.16,0.03 0.52,-0.83 0.48,0.06 0.81,-0.32 0.44,1.21 0.65,-0.36 1.16,-0.42 1.26,0.62 0.49,0.93 1.26,0.6 0.98,-0.71 1.32,-0.11 1.92,0.73 0.74,4.01 -1.18,2.36 -0.73,3.17 1.21,2.41 z" }, { "id": "cl", "name": "Chile", "d": "m 283.06,636.98425 0,10.57 3,0 1.69,0.13 -0.93,1.98 -2.4,1.53 -1.38,-0.16 -1.66,-0.4 -2.04,-1.48 -2.94,-0.71 -3.53,-2.71 -2.86,-2.57 -3.86,-5.25 2.31,0.97 3.94,3.13 3.72,1.7 1.45,-2.17 0.91,-3.2 2.58,-1.91 2,0.55 z m 1.16,-112.01 1.1,4.15 2.02,-0.41 0.34,0.76 -0.96,3.16 -3.05,1.51 0.09,5.14 -0.59,1 0.84,1.23 -1.98,1.95 -1.84,2.96 -1,2.9 0.27,3.11 -1.73,3.34 1.29,5.69 0.73,0.61 -0.01,3.09 -1.6,3.31 0.06,2.87 -2.12,2.26 0.01,3.22 0.85,3.46 -1.68,1.3 -0.75,3.22 -0.66,3.75 0.47,4.54 -1.13,0.77 0.65,4.4 1.27,1.46 -0.92,1.63 1.3,0.78 0.3,1.48 -1.22,0.75 0.3,2.33 -1.02,5.35 -1.49,3.52 0.33,2.11 -0.89,2.68 -2.15,1.88 0.25,4.6 0.99,1.6 1.87,-0.28 -0.05,3.33 1.16,2.63 6.78,0.61 2.6,0.71 -2.49,-0.03 -1.35,1.13 -2.53,1.67 -0.45,4.38 -1.19,0.11 -3.16,-1.54 -3.21,-3.25 0,0 -3.49,-2.63 -0.88,-2.87 0.79,-2.62 -1.41,-2.94 -0.36,-7.34 1.19,-4.03 2.96,-3.19 -4.26,-1.19 2.67,-3.57 0.95,-6.56 3.12,1.37 1.46,-7.97 -1.88,-1 -0.88,4.75 -1.77,-0.54 0.88,-5.42 0.96,-6.84 1.29,-2.48 -0.81,-3.5 -0.23,-3.98 1.18,-0.11 1.72,-5.6 1.94,-5.43 1.19,-4.97 -0.65,-4.91 0.84,-2.67 -0.34,-3.96 1.64,-3.87 0.51,-6.04 0.9,-6.37 0.88,-6.75 -0.21,-4.87 -0.58,-4.15 1.44,-0.75 0.75,-1.5 1.37,1.99 0.37,2.12 1.47,1.25 -0.88,2.87 1.51,3.34 z" }, { "id": "cm", "name": "Cameroon", "d": "m 512.17,457.32425 -0.35,-0.15 -1.66,0.36 -1.71,-0.38 -1.33,0.19 -4.56,-0.07 0.41,-2.2 -1.1,-1.84 -1.28,-0.48 -0.57,-1.25 -0.72,-0.4 0.04,-0.77 0.72,-1.98 1.33,-2.7 0.81,-0.03 1.67,-1.64 1.07,-0.04 1.57,1.15 1.93,-0.95 0.26,-1.16 0.63,-1.14 0.43,-1.42 1.5,-1.16 0.57,-1.97 0.59,-0.63 0.4,-1.47 0.74,-1.81 2.36,-2.2 0.15,-0.95 0.31,-0.51 -1.11,-1.14 0.09,-0.9 0.79,-0.17 1.11,1.83 0.19,1.89 -0.1,1.89 1.52,2.57 -1.56,-0.03 -0.79,0.2 -1.28,-0.28 -0.61,1.33 1.65,1.65 1.22,0.48 0.4,1.17 0.88,1.93 -0.44,0.77 -1.41,2.84 -0.67,0.51 -0.22,2.17 0.28,1.18 -0.22,0.83 1.32,1.46 0.24,1 1.04,1.44 1.28,0.9 0.12,1.27 0.3,0.81 -0.2,1.5 -2.23,-0.66 -2.27,-0.73 z" }, { "id": "cn", "name": "China", "d": "m 784.88,410.66425 -2.42,1.41 -2.3,-0.91 -0.08,-2.53 1.38,-1.34 3.06,-0.83 1.61,0.07 0.63,1.13 -1.23,1.3 -0.65,1.7 z m 48.56,-107.52 4.88,1.38 3.32,3.03 1.13,3.95 4.26,0 2.43,-1.65 4.63,-1.24 -1.47,3.76 -1.09,1.51 -0.96,4.46 -1.89,3.89 -3.4,-0.7 -2.41,1.4 0.74,3.36 -0.4,4.55 -1.43,0.1 0.02,1.93 -1.81,-2.24 -1.11,2.13 -4.33,1.62 0.44,1.97 -2.42,-0.14 -1.33,-1.17 -1.93,2.64 -3.09,1.98 -2.28,2.35 -3.92,1.06 -2.06,1.69 -3.02,0.98 1.49,-1.67 -0.59,-1.41 2.22,-2.45 -1.48,-1.93 -2.44,1.3 -3.17,2.54 -1.73,2.34 -2.75,0.17 -1.43,1.68 1.48,2.41 2.29,0.58 0.09,1.58 2.22,1.02 3.14,-2.51 2.49,1.37 1.81,0.09 0.46,1.84 -3.97,0.97 -1.31,1.87 -2.73,1.73 -1.44,2.39 3.02,1.86 1.1,3.31 1.71,3.05 1.9,2.53 -0.05,2.43 -1.76,0.89 0.67,1.73 1.65,1 -0.43,2.61 -0.71,2.52 -1.57,0.28 -2.05,3.41 -2.27,4.09 -2.6,3.68 -3.86,2.82 -3.9,2.55 -3.16,0.35 -1.71,1.34 -0.97,-0.98 -1.59,1.5 -3.92,1.5 -2.97,0.46 -0.96,3.15 -1.55,0.17 -0.74,-2.16 0.66,-1.16 -3.76,-0.96 -1.33,0.49 -2.82,-0.78 -1.33,-1.22 0.44,-1.74 -2.56,-0.55 -1.35,-1.14 -2.39,1.62 -2.73,0.35 -2.24,-0.02 -1.5,0.74 -1.45,0.44 0.42,3.43 -1.5,-0.08 -0.25,-0.7 -0.08,-1.24 -2.06,0.87 -1.21,-0.55 -2.08,-1.13 0.82,-2.51 -1.78,-0.59 -0.67,-2.8 -2.96,0.51 0.34,-3.63 2.66,-2.58 0.11,-2.57 -0.08,-2.4 -1.22,-0.75 -0.94,-1.86 -1.64,0.24 -3.02,-0.47 0.95,-1.33 -1.31,-1.99 -2,1.35 -2.36,-0.78 -3.23,2.03 -2.55,2.36 -2.26,0.39 -1.23,-0.85 -1.48,-0.08 -2,-0.73 -1.51,0.8 -1.85,2.34 -0.24,-2.48 -1.71,0.66 -3.27,-0.31 -3.17,-0.73 -2.28,-1.39 -2.18,-0.63 -0.94,-1.53 -1.58,-0.46 -2.83,-2.09 -2.25,-0.99 -1.16,0.77 -3.9,-2.26 -2.75,-2.07 -0.79,-3.63 2.01,0.44 0.09,-1.69 -1.12,-1.71 0.28,-2.74 -3.01,-3.99 -4.61,-1.39 -0.83,-2.66 -2.07,-1.63 -0.5,-1.01 -0.42,-2.01 0.1,-1.38 -1.7,-0.81 -0.92,0.36 -0.71,-3.32 0.8,-0.83 -0.39,-0.85 2.68,-1.73 1.94,-0.72 2.97,0.49 1.06,-2.35 3.6,-0.44 1,-1.48 4.42,-2.03 0.39,-0.85 -0.22,-2.17 1.92,-1 -2.52,-6.75 5.55,-1.58 1.44,-0.89 2.02,-7.26 5.56,1.35 1.56,-1.86 0.13,-4.19 2.33,-0.39 2.13,-2.83 1.1,-0.35 0.74,2.97 2.36,2.23 4,1.57 1.93,3.32 -1.08,4.73 1.01,1.73 3.33,0.68 3.78,0.55 3.39,2.45 1.73,0.43 1.28,3.57 1.65,2.27 3.09,-0.09 5.79,0.85 3.73,-0.53 2.77,0.57 4.15,2.29 3.39,0 1.24,1.16 3.26,-2.01 4.53,-1.31 4.2,-0.14 3.28,-1.34 2.01,-2.05 1.96,-1.3 -0.45,-1.28 -0.9,-1.5 1.47,-2.54 1.58,0.36 2.88,0.8 2.79,-2.1 4.28,-1.55 2.05,-2.66 1.97,-1.16 4.07,-0.54 2.21,0.46 0.31,-1.45 -2.54,-2.89 -2.25,-1.33 -2.16,1.54 -2.77,-0.65 -1.59,0.53 -0.72,-1.71 1.98,-4.23 1.37,-3.25 3.37,1.63 3.95,-2.74 -0.03,-1.93 2.53,-4.73 1.56,-1.45 -0.04,-2.52 -1.54,-1.1 2.32,-2.31 3.48,-0.84 3.72,-0.13 4.2,1.39 2.46,1.71 1.73,4.61 1.05,1.94 0.98,2.73 1.05,4.31 z" }, { "id": "co", "name": "Colombia", "d": "m 264.17,464.06425 -1.2,-0.66 -1.38,-0.92 -0.8,0.44 -2.38,-0.39 -0.68,-1.2 -0.52,0.05 -2.81,-1.59 -0.38,-0.87 1.05,-0.21 -0.12,-1.39 0.65,-1.01 1.39,-0.19 1.19,-1.75 1.07,-1.46 -1.04,-0.67 0.53,-1.62 -0.63,-2.56 0.6,-0.73 -0.44,-2.37 -1.14,-1.5 0.36,-1.36 0.91,0.2 0.53,-0.84 -0.65,-1.65 0.34,-0.42 1.44,0.09 2.11,-1.97 1.15,-0.3 0.03,-0.93 0.52,-2.39 1.61,-1.32 1.76,-0.05 0.22,-0.59 2.2,0.23 2.21,-1.43 1.09,-0.64 1.35,-1.37 1,0.17 0.73,0.75 -0.54,0.96 -1.8,0.48 -0.71,1.42 -1.09,0.81 -0.81,1.06 -0.35,2.01 -0.77,1.66 1.44,0.18 0.36,1.3 0.62,0.62 0.22,1.13 -0.33,1.04 0.1,0.59 0.69,0.23 0.67,0.98 3.6,-0.27 1.63,0.36 1.98,2.41 1.13,-0.3 2.02,0.15 1.6,-0.32 0.99,0.49 -0.51,1.5 -0.62,0.94 -0.22,2.01 0.56,1.85 0.8,0.83 0.09,0.63 -1.42,1.39 1.02,0.61 0.75,0.98 0.85,2.77 -0.53,0.35 -0.54,-1.65 -0.78,-0.88 -0.93,0.96 -5.46,-0.06 0.03,1.74 1.64,0.29 -0.09,1.07 -0.56,-0.29 -1.58,0.46 -0.01,2.02 1.24,1.02 0.44,1.59 -0.07,1.21 -1.26,7.65 -1.4,-1.49 -0.84,-0.06 1.81,-2.84 -2.15,-1.31 -1.68,0.24 -1.01,-0.48 -1.55,0.74 -2.09,-0.35 -1.65,-2.92 -1.3,-0.72 -0.89,-1.32 -1.86,-1.32 z" }, { "id": "cr", "name": "Costa Rica", "d": "m 242.88,440.65425 -1.52,-0.63 -0.57,-0.59 0.32,-0.49 -0.1,-0.62 -0.78,-0.68 -1.1,-0.55 -0.97,-0.36 -0.18,-0.83 -0.74,-0.51 0.18,0.83 -0.56,0.67 -0.64,-0.78 -0.9,-0.28 -0.38,-0.57 0.02,-0.86 0.37,-0.9 -0.79,-0.4 0.64,-0.54 0.42,-0.37 1.85,0.75 0.64,-0.37 0.89,0.24 0.47,0.58 0.82,0.19 0.67,-0.6 0.72,1.54 1.08,1.14 1.32,1.21 -1.09,0.25 0.02,1.13 0.58,0.42 -0.42,0.34 0.11,0.51 -0.23,0.57 z" }, { "id": "cu", "name": "Cuba", "d": "m 244.83,397.19425 2.43,0.22 2.2,0.03 2.63,1.03 1.12,1.11 2.62,-0.34 0.99,0.7 2.38,1.87 1.74,1.35 0.92,-0.04 1.68,0.61 -0.21,0.84 2.07,0.12 2.12,1.22 -0.33,0.69 -1.87,0.38 -1.89,0.15 -1.93,-0.24 -4.01,0.29 1.88,-1.66 -1.14,-0.77 -1.81,-0.2 -0.97,-0.86 -0.67,-1.7 -1.58,0.11 -2.62,-0.8 -0.84,-0.63 -3.65,-0.47 -0.98,-0.59 1.05,-0.75 -2.75,-0.15 -2.01,1.56 -1.17,0.04 -0.4,0.74 -1.38,0.33 -1.2,-0.29 1.48,-0.93 0.6,-1.09 1.27,-0.67 1.43,-0.59 2.13,-0.29 z" }, { "id": "cy", "name": "Cyprus", "d": "m 570.56,358.54425 1.89,-1.46 -2.55,1.02 -2.02,-0.05 -0.4,0.83 -0.2,0.02 -1.33,0.12 0.65,1.37 1.37,0.44 2.88,-1.38 -0.09,-0.27 z" }, { "id": "cz", "name": "Czech Republic", "d": "m 523.06,308.11425 -1.3,-0.8 -1.31,0.22 -2.18,-1.3 -0.99,0.32 -1.57,1.74 -2.09,-1.37 -1.58,-1.83 -1.43,-1.04 -0.3,-1.82 -0.49,-1.3 2.04,-0.95 1.04,-1.1 2.01,-0.86 0.71,-0.84 0.74,0.51 1.25,-0.47 1.33,1.43 2.09,0.39 -0.17,1.21 1.52,0.9 0.42,-1.13 1.92,0.49 0.27,1.37 2.08,0.26 1.29,2.13 -0.83,0.01 -0.44,0.77 -0.64,0.19 -0.18,0.97 -0.54,0.21 -0.08,0.39 -0.95,0.44 -1.25,-0.07 z" }, { "id": "de", "name": "Germany", "d": "m 503.32,279.17425 0.05,1.88 2.84,1.12 -0.03,1.7 2.85,-0.9 1.57,-1.31 3.17,1.89 1.32,1.51 0.66,2.39 -0.78,1.25 1.01,1.65 0.7,2.45 -0.22,1.56 1.15,2.86 -1.25,0.47 -0.74,-0.51 -0.71,0.84 -2.01,0.86 -1.04,1.1 -2.04,0.95 0.49,1.3 0.3,1.82 1.43,1.04 1.58,1.83 -0.98,1.95 -1.01,0.54 0.4,2.72 -0.27,0.7 -0.87,-0.85 -1.34,-0.12 -2.01,0.74 -2.47,-0.18 -0.4,1.09 -1.42,-1.14 -0.85,0.22 -3,-1.26 -0.58,0.9 -2.38,-0.03 0.35,-2.98 1.42,-2.9 -4.04,-0.78 -1.32,-1.13 0.16,-1.89 -0.56,-0.98 0.32,-2.97 -0.48,-4.69 1.69,0 0.71,-1.71 0.7,-4.23 -0.53,-1.58 0.55,-1 2.34,-0.26 0.52,1.04 1.91,-2.33 -0.64,-1.79 -0.13,-2.75 2.12,0.64 z" }, { "id": "dj", "name": "Djibouti", "d": "m 596.3,427.97425 0.66,0.88 -0.09,1.19 -1.6,0.68 1.21,0.77 -1.04,1.52 -0.62,-0.5 -0.67,0.2 -1.57,-0.05 -0.05,-0.86 -0.21,-0.79 0.94,-1.33 0.99,-1.26 1.2,0.25 z" }, { "id": "dk", "name": "Denmark", "d": "m 511.08,276.09425 -1.68,3.97 -2.93,-2.76 -0.39,-2.05 4.11,-1.66 0.89,2.5 z m -4.98,-4.25 -0.69,1.9 -0.83,-0.55 -2.02,3.59 0.76,2.39 -1.79,0.74 -2.12,-0.64 -1.14,-2.72 -0.08,-5.12 0.47,-1.38 0.8,-1.54 2.47,-0.32 0.98,-1.43 2.26,-1.47 -0.1,2.68 -0.83,1.68 0.34,1.43 1.52,0.76 z" }, { "id": "do", "name": "Dominican Republic", "d": "m 274.43,407.60425 0.35,-0.51 2.19,0.02 1.66,0.76 0.74,-0.08 0.51,1.05 1.53,-0.06 -0.09,0.88 1.25,0.11 1.38,1.08 -1.04,1.2 -1.34,-0.64 -1.28,0.12 -0.92,-0.14 -0.51,0.54 -1.08,0.18 -0.42,-0.72 -0.93,0.43 -1.12,2 -0.72,-0.46 -0.15,-0.84 0.06,-0.8 -0.72,-0.88 0.68,-0.5 0.22,-1.13 z" }, { "id": "dz", "name": "Algeria", "d": "m 509.15,396.33425 -9.61,5.75 -8.12,5.85 -3.95,1.32 -3.11,0.29 -0.03,-1.88 -1.3,-0.48 -1.75,-0.85 -0.66,-1.39 -9.46,-6.55 -9.46,-6.65 -10.55,-7.53 0.06,-0.61 0,-0.21 -0.03,-3.75 4.53,-2.36 2.8,-0.49 2.29,-0.86 1.08,-1.62 3.28,-1.29 0.12,-2.41 1.62,-0.29 1.27,-1.21 3.67,-0.56 0.51,-1.28 -0.74,-0.71 -0.97,-3.53 -0.16,-2.05 -1.06,-2.18 2.69,-1.87 3.04,-0.6 1.77,-1.43 2.7,-1.05 4.75,-0.62 4.64,-0.29 1.41,0.52 2.64,-1.37 3,-0.03 1.14,0.81 1.91,-0.21 -0.57,1.79 0.45,3.28 -0.66,2.82 -1.73,1.88 0.25,2.53 2.29,1.98 0.03,0.81 1.72,1.33 1.2,5.86 0.91,2.84 0.15,1.48 -0.49,2.59 0.2,1.44 -0.36,1.72 0.25,1.97 -1.12,1.29 1.66,2.26 0.11,1.32 0.99,1.71 1.31,-0.56 2.22,1.42 z" }, { "id": "ec", "name": "Ecuador", "d": "m 250.35,473.12425 1.49,-2.08 -0.61,-1.22 -1.07,1.3 -1.68,-1.23 0.57,-0.78 -0.47,-2.53 0.98,-0.42 0.52,-1.73 1.06,-1.8 -0.2,-1.13 1.54,-0.6 1.92,-1.11 2.81,1.59 0.52,-0.05 0.68,1.2 2.38,0.39 0.8,-0.44 1.38,0.92 1.2,0.66 0.39,2.11 -0.87,1.81 -3.06,2.92 -3.37,1.1 -1.72,2.43 -0.53,1.88 -1.59,1.15 -1.17,-1.41 -1.14,-0.3 -1.16,0.22 -0.07,-1.02 0.8,-0.66 z" }, { "id": "ee", "name": "Estonia", "d": "m 543.67,264.96425 0.33,-3.12 -1.03,0.67 -1.78,-1.9 -0.25,-3.11 3.55,-1.53 3.53,-0.81 3.04,0.92 2.9,-0.17 0.42,0.96 -1.99,3.14 0.83,4.96 -1.2,1.66 -2.32,-0.01 -2.41,-1.94 -1.23,-0.65 z" }, { "id": "eg", "name": "Egypt", "d": "m 573.42,377.53425 -0.79,1.29 -0.6,2.4 -0.76,1.64 -0.66,0.56 -0.93,-1.02 -1.27,-1.42 -2,-4.57 -0.28,0.29 1.16,3.37 1.72,3.18 2.12,4.88 1.03,1.68 0.9,1.74 2.52,3.4 -0.56,0.53 0.09,1.97 3.27,2.71 0.49,0.62 -11.12,0 -10.88,0 -11.27,0 0,-11.23 0,-11.18 -0.84,-2.58 0.72,-2 -0.43,-1.39 1.01,-1.57 3.73,-0.05 2.7,0.86 2.78,0.97 1.3,0.5 2.16,-1.03 1.15,-0.93 2.48,-0.27 1.99,0.41 0.77,1.62 0.65,-1.07 2.24,0.77 2.19,0.19 1.38,-0.82 z" }, { "id": "eh", "name": "Western Sahara", "d": "m 438.82,383.31425 3.62,0.01 8.75,0.03 0,0 0,0 -8.75,-0.03 -3.62,-0.01 -0.11,0.09 -0.05,0.04 -1.78,3.2 -1.86,1.14 -1.02,1.91 -0.06,1.65 -0.75,1.79 -0.94,0.49 -1.56,1.94 -0.96,2.15 0.18,1.02 -0.92,1.57 -1.08,0.82 -0.13,1.39 -0.12,1.27 0.61,-1 10.98,0.02 -0.53,-4.35 0.69,-1.55 2.62,-0.27 -0.09,-7.86 9.21,0.17 0,-4.73 0.06,-0.61 0,-0.21 z" }, { "id": "er", "name": "Eritrea", "d": "m 594.25,428.42425 -0.96,-0.93 -1.15,-1.67 -1.24,-0.92 -0.73,-1 -2.44,-1.15 -1.92,-0.03 -0.68,-0.61 -1.64,0.68 -1.7,-1.31 -0.88,2.15 -3.26,-0.6 -0.3,-1.15 1.21,-4.25 0.27,-1.93 0.88,-0.9 2.07,-0.48 1.42,-1.67 1.63,3.38 0.77,2.67 1.54,1.41 3.82,2.72 1.56,1.64 1.52,1.66 0.88,0.98 1.38,0.86 -0.85,0.7 z" }, { "id": "es", "name": "Spain", "d": "m 450.17,334.81425 0.14,-2.68 -1.14,-1.66 3.96,-2.77 3.43,0.7 3.77,-0.03 2.98,0.66 2.33,-0.2 4.53,0.12 1.12,1.49 5.16,1.73 1.02,-0.82 3.16,1.72 3.25,-0.49 0.15,2.19 -2.66,2.49 -3.59,0.78 -0.25,1.24 -1.73,2.03 -1.08,2.96 1.09,2.05 -1.62,1.6 -0.6,2.3 -2.12,0.7 -1.99,2.69 -3.55,0.05 -2.68,-0.06 -1.75,1.22 -1.07,1.31 -1.38,-0.29 -1.03,-1.17 -0.8,-2 -2.62,-0.54 -0.23,-1.16 1.04,-1.32 0.38,-0.96 -0.96,-1.06 0.77,-2.35 -1.12,-2.17 1.21,-0.3 0.11,-1.72 0.46,-0.53 0.03,-2.88 1.3,-1 -0.78,-1.88 -1.64,-0.13 -0.48,0.47 -1.65,0.01 -0.71,-1.84 -1.14,0.55 z" }, { "id": "et", "name": "Ethiopia", "d": "m 581.79,421.48425 1.7,1.31 1.64,-0.68 0.68,0.61 1.92,0.03 2.44,1.15 0.73,1 1.24,0.92 1.15,1.67 0.96,0.93 -0.99,1.26 -0.94,1.33 0.21,0.79 0.05,0.86 1.57,0.05 0.67,-0.2 0.62,0.5 -0.61,1.01 1.04,1.56 1.03,1.36 1.07,1.01 9.17,3.34 2.36,-0.02 -7.93,8.42 -3.65,0.12 -2.5,1.97 -1.79,0.05 -0.77,0.88 -1.92,0 -1.13,-0.94 -2.56,1.17 -0.83,1.16 -1.87,-0.22 -0.62,-0.32 -0.66,0.07 -0.88,-0.02 -3.55,-2.38 -1.95,0 -0.96,-0.91 0,-1.57 -1.46,-0.47 -1.65,-3.05 -1.28,-0.65 -0.5,-1.12 -1.42,-1.37 -1.72,-0.2 0.96,-1.61 1.48,-0.07 0.42,-0.86 -0.03,-2.53 0.83,-2.96 1.32,-0.8 0.29,-1.16 1.2,-2.17 1.69,-1.42 1.14,-2.81 0.45,-2.47 3.26,0.6 z" }, { "id": "fk", "name": "Falkland Islands", "d": "m 303.91,633.38425 3.36,-2.69 2.39,1.12 1.68,-1.79 2.24,2.01 -0.84,1.58 -3.79,1.36 -1.26,-1.59 -2.38,2.05 z" }, { "id": "fi", "name": "Finland", "d": "m 555.67,193.35425 -0.41,5.4 4.3,4.99 -2.59,5.48 3.26,7.96 -1.89,5.76 2.53,4.86 -1.15,4.14 4.15,4.26 -1.06,3.1 -2.6,3.45 -6,7.41 -5.09,0.45 -4.93,2.07 -4.56,1.18 -1.63,-3.07 -2.71,-1.87 0.62,-5.72 -1.36,-5.41 1.34,-3.58 2.54,-3.94 6.41,-7.02 1.88,-1.39 -0.3,-2.84 -3.9,-3.22 -0.94,-2.7 -0.08,-11.12 -4.37,-5.15 -3.74,-3.81 1.68,-2.08 3.12,4.15 3.66,-0.39 3.01,1.87 2.67,-3.44 1.38,-5.85 4.35,-2.78 3.6,3.26 z" }, { "id": "fj", "name": "Fiji", "d": "m 980.78,508.86425 -0.35,1.4 -0.23,0.16 -1.78,0.72 -1.79,0.61 -0.36,-1.09 1.4,-0.6 0.89,-0.16 1.64,-0.91 0.58,-0.13 z m -5.84,4.31 -1.27,-0.36 -1.08,1 0.27,1.29 1.55,0.36 1.74,-0.4 0.46,-1.53 -0.96,-0.84 -0.71,0.48 z" }, { "id": "fr", "name": "France", "d": "m 502.31,333.79425 -0.93,2.89 -1.27,-0.76 -0.65,-2.53 0.57,-1.41 1.81,-1.45 0.47,3.26 z m -16.75,-33.35 1.96,2.06 1.44,-0.34 2.45,1.97 0.63,0.37 0.81,-0.09 1.32,1.12 4.04,0.79 -1.42,2.9 -0.36,2.98 -0.77,0.71 -1.28,-0.38 0.09,1.05 -2.05,2.3 -0.04,1.84 1.34,-0.63 0.96,1.77 -0.12,1.13 0.83,1.5 -0.97,1.21 0.72,3.04 1.52,0.49 -0.32,1.68 -2.54,2.17 -5.53,-1.04 -4.08,1.24 -0.32,2.29 -3.25,0.49 -3.15,-1.72 -1.02,0.82 -5.16,-1.73 -1.12,-1.49 1.45,-2.32 0.53,-7.88 -2.89,-4.26 -2.07,-2.09 -4.29,-1.6 -0.28,-3.07 3.64,-0.92 4.71,1.09 -0.89,-4.84 2.65,1.85 6.53,-3.37 0.84,-3.61 2.45,-0.9 0.41,1.56 1.3,0.07 1.3,1.79 z" }, { "id": "ga", "name": "Gabon", "d": "m 506.61,474.73425 -2.88,-2.82 -1.86,-2.3 -1.7,-2.88 0.09,-0.92 0.61,-0.9 0.68,-2.02 0.57,-2.07 0.95,-0.16 4.07,0.03 -0.02,-3.35 1.33,-0.19 1.71,0.38 1.66,-0.36 0.35,0.15 -0.21,1.22 0.79,1.43 2.08,-0.22 0.7,0.55 -1.21,3.23 1.32,1.64 0.31,2.18 -0.35,1.85 -0.86,1.32 -2.48,-0.12 -1.5,-1.34 -0.22,1.24 -1.89,0.34 -0.96,0.7 1.05,1.85 z" }, { "id": "gb", "name": "United Kingdom", "d": "m 459.63,281.25425 -1.5,3.29 -2.12,-0.98 -1.73,0.07 0.58,-2.57 -0.58,-2.6 2.35,-0.2 3,2.99 z m 7.45,-20.76 -3,5.73 2.86,-0.72 3.07,0.03 -0.73,4.22 -2.52,4.53 2.9,0.32 0.22,0.52 2.5,5.79 1.92,0.77 1.73,5.41 0.8,1.84 3.4,0.88 -0.34,2.93 -1.43,1.33 1.12,2.33 -2.52,2.33 -3.75,-0.04 -4.77,1.21 -1.31,-0.87 -1.85,2.06 -2.59,-0.5 -1.97,1.67 -1.49,-0.87 4.11,-4.64 2.51,-0.97 -0.02,0 -4.38,-0.75 -0.79,-1.8 2.93,-1.41 -1.54,-2.48 0.53,-3.06 4.17,0.42 0,0 0.41,-2.74 -1.88,-2.95 -0.04,-0.07 -3.4,-0.85 -0.67,-1.32 1.02,-2.2 -0.92,-1.37 -1.51,2.34 -0.16,-4.8 -1.42,-2.59 1.02,-5.36 2.18,-4.31 2.24,0.42 3.36,-0.41 z" }, { "id": "ge", "name": "Georgia", "d": "m 592.01,336.10425 0.42,-1.6 -0.7,-2.57 -1.62,-1.41 -1.55,-0.44 -1.03,-1.17 0.34,-0.46 2.37,0.66 4.13,0.62 3.82,1.83 0.49,0.71 1.7,-0.6 2.62,0.8 0.85,1.55 1.77,0.87 -0.73,0.51 1.38,2.02 -0.38,0.43 -1.51,-0.22 -2.09,-1.06 -0.69,0.6 -3.9,0.58 -2.7,-1.82 z" }, { "id": "gf", "name": "French Guiana", "d": "m 328.14,456.66425 -1.07,1.06 -1.34,0.2 -0.38,-0.78 -0.63,-0.12 -0.87,0.76 -1.22,-0.57 0.71,-1.19 0.24,-1.27 0.48,-1.2 -1.09,-1.65 -0.22,-1.91 1.46,-2.41 0.95,0.31 2.06,0.66 2.97,2.36 0.46,1.14 -1.66,2.55 -0.85,2.06 z" }, { "id": "gh", "name": "Ghana", "d": "m 478.48,447.09425 -4.4,1.64 -1.56,0.96 -2.53,0.81 -2.5,-0.79 0.13,-1.11 -1.21,-2.41 0.73,-3.17 1.18,-2.36 -0.74,-4.01 -0.39,-2.13 0.07,-1.61 4.87,-0.13 1.24,0.21 0.9,-0.46 1.3,0.22 -0.21,0.89 1.17,1.46 0,2.05 0.27,2.22 0.7,1.03 -0.62,2.53 0.22,1.4 0.75,1.78 z" }, { "id": "gl", "name": "Greenland", "d": "m 344.38,24.164245 9.42,-13.61 9.84,1.07 3.57,-8.9499996 9.91,-2.42 22.4,3.15 17.54,18.5899996 -5.18,8.3 -10.73,0.93 -15.09,2.03 1.41,3.64 9.93,-2.24 8.44,6.91 5.45,-6.12 2.33,7.15 -3.08,10.97 7.14,-6.93 13.61,-7.55 8.41,3.81 1.57,8.12 -11.43,12.66 -1.58,3.9 -8.96,2.86 6.49,0.79 -3.28,11.51 -2.26,9.59 0.09,15.260005 3.37,8.34 -4.38,0.51 -4.61,3.88 5.17,6.3 0.66,9.62 -3,1.02 3.63,9.15 -6.22,0.75 3.25,4.14 -0.92,3.51 -3.95,1.51 -3.91,0.03 3.51,6.48 0.04,4.13 -5.55,-3.83 -1.44,2.49 3.78,2.29 3.68,5.48 1.06,6.95 -5,1.62 -2.16,-3.26 -3.47,-4.98 0.96,5.87 -3.25,4.41 7.38,0.35 3.87,0.45 -7.52,7.03 -7.62,6.13 -8.2,2.61 -3.09,0.04 -2.9,2.87 -3.9,7.63 -6.03,4.89 -1.94,0.29 -3.74,1.67 -4.02,1.59 -2.41,4.12 -0.04,4.56 -1.41,4.16 -4.58,4.95 1.13,4.71 -1.26,4.85 -1.43,5.56 -3.95,0.34 -4.14,-4.63 -5.61,-0.03 -2.72,-3.18 -1.87,-5.8 -4.86,-7.68 -1.42,-4.15 -0.38,-5.89 -3.89,-6.27 1.01,-5.17 -1.87,-2.53 2.77,-8.65 4.22,-2.85 1.11,-3.26 0.59,-6.26 -3.21,2.86 -1.52,1.19 -2.52,1.14 -3.44,-2.61 -0.19,-5.55 1.1,-4.48 2.6,-0.12 5.72,2.25 -4.82,-5.43 -2.51,-3.01 -2.79,1.24 -2.34,-2.19 3.13,-8.5 -1.7,-3.53 -2.23,-6.74 -3.37,-10.91 -3.57,-4.17 0.03,-4.63 -7.53,-6.69 -5.95,-0.85 -7.49,0.47 -6.84,0.86 -3.26,-3.75 -4.87,-7.66 7.36,-3.97 5.65,-0.68 -12,-3.36 -6.32,-5.44 0.39,-5.340005 10.61,-6.87 10.27,-7.16 1.08,-5.64 -7.56,-5.76 2.44,-6.64 9.71,-12.33 4.08,-1.98 -1.17,-8.57 6.64,-5.24 8.62,-3.21 8.62,-0.18 3.06,6.3 7.44,-11.32 6.69,7.78 3.93,1.59 5.83,6.41 -6.67,-10.77 z" }, { "id": "gm", "name": "Gambia", "d": "m 428.28,426.68425 0.36,-1.27 3.05,-0.09 0.64,-0.67 0.89,-0.05 1.1,0.71 0.87,0.01 0.93,-0.48 0.56,0.82 -1.21,0.65 -1.22,-0.05 -1.2,-0.61 -1.04,0.66 -0.5,0.03 -0.68,0.4 z" }, { "id": "gn", "name": "Guinea", "d": "m 451.84,442.16425 -0.79,-0.07 -0.57,1.13 -0.8,-0.01 -0.54,-0.6 0.18,-1.13 -1.17,-1.72 -0.73,0.31 -0.6,0.07 -0.77,0.16 0.03,-1.03 -0.45,-0.74 0.09,-0.82 -0.61,-1.19 -0.78,-1.01 -2.24,0 -0.65,0.53 -0.78,0.06 -0.48,0.61 -0.32,0.79 -1.5,1.24 -1.23,-1.67 -1.09,-1.11 -0.72,-0.36 -0.7,-0.57 -0.31,-1.25 -0.41,-0.62 -0.82,-0.47 1.25,-1.38 0.85,0.05 0.73,-0.48 0.62,0 0.44,-0.38 -0.24,-0.94 0.31,-0.3 0.05,-0.97 1.35,0.03 2.02,0.7 0.62,-0.07 0.21,-0.31 1.52,0.22 0.41,-0.16 0.16,1.05 0.45,-0.01 0.73,-0.38 0.46,0.1 0.78,0.72 1.2,0.23 0.77,-0.62 0.91,-0.38 0.67,-0.4 0.56,0.08 0.62,0.62 0.34,0.79 1.15,1.19 -0.58,0.73 -0.11,0.92 0.6,-0.28 0.35,0.34 -0.15,0.84 0.86,0.82 -0.56,0.21 -0.23,0.97 0.65,1.16 0.69,2.26 -1.04,0.34 -0.27,0.39 0.22,0.54 -0.16,1.23 z" }, { "id": "gq", "name": "Equatorial Guinea", "d": "m 502.12,460.82425 -0.53,-0.42 0.97,-3.13 4.56,0.07 0.02,3.35 -4.07,-0.03 z" }, { "id": "gr", "name": "Greece", "d": "m 541.95,356.96425 1.53,1.16 2.18,-0.19 2.09,0.24 -0.07,0.6 1.53,-0.41 -0.35,1.01 -4.04,0.29 0.03,-0.56 -3.42,-0.67 0.52,-1.47 z m 8.15,-20.96 -0.87,2.33 -0.67,0.41 -1.71,-0.1 -1.46,-0.35 -3.4,0.96 1.94,2.06 -1.42,0.59 -1.56,0 -1.48,-1.88 -0.53,0.8 0.63,2.18 1.4,1.7 -1.06,0.79 1.56,1.65 1.39,1.03 0.04,2 -1.36,-1.15 -1.24,0.21 0.83,1.8 -0.92,0.19 -1,-0.69 1.2,3.95 -0.58,0 -0.45,-1.25 -0.57,-0.02 -0.26,1.32 -0.45,-0.3 0.1,-0.74 -0.56,-1.04 -0.64,0 0.12,0.84 -0.25,0.27 -0.62,-0.54 -0.38,-1.01 0.52,-0.57 -0.36,-0.74 -0.41,-0.38 -0.42,-0.09 -0.49,-0.94 0.58,-0.52 0.36,-0.48 0.56,0.1 0.25,-0.41 0.59,-0.16 0.68,0.46 0.55,0.17 0.39,-0.62 -0.94,-0.08 -0.56,-0.19 -1.25,0.28 -1.22,0.05 -1.09,-1.64 -0.18,-0.25 0.17,-0.64 -1.42,-1.15 -0.19,-1.03 1.3,-1.76 0.17,-1.19 0.91,-0.53 0.06,-0.97 1.83,-0.33 1.07,-0.81 1.52,0.07 0.46,-0.65 0.53,-0.12 2.07,0.11 2.25,-1.02 1.98,1.3 2.55,-0.35 0.03,-1.86 1.38,0.93 z" }, { "id": "gt", "name": "Guatemala", "d": "m 222.89,425.00425 -1.44,-0.5 -1.75,-0.05 -1.28,-0.57 -1.51,-1.18 0.07,-0.84 0.32,-0.68 -0.39,-0.54 1.35,-2.36 3.59,-0.01 0.08,-0.98 -0.46,-0.18 -0.31,-0.63 -1.04,-0.67 -1.04,-0.98 1.27,0 0,-1.65 2.62,0 2.59,0.03 -0.02,2.31 -0.22,3.28 0.83,0 0.92,0.53 0.24,-0.44 0.82,0.37 -1.27,1.11 -1.33,0.81 -0.2,0.55 0.22,0.56 -0.58,0.74 -0.66,0.17 0.15,0.34 -0.52,0.32 -0.96,0.72 z" }, { "id": "gw", "name": "Guinea-Bissau", "d": "m 433.08,432.69425 -1.5,-1.19 -1.18,-0.18 -0.64,-0.81 0.01,-0.43 -0.85,-0.6 -0.18,-0.61 1.49,-0.47 0.93,0.09 0.75,-0.32 5.18,0.12 -0.05,0.97 -0.31,0.3 0.24,0.94 -0.44,0.38 -0.62,0 -0.73,0.48 -0.85,-0.05 z" }, { "id": "gy", "name": "Guyana", "d": "m 307.95,440.25425 1.84,1.03 1.74,1.83 0.07,1.45 1.06,0.07 1.5,1.36 1.11,0.98 -0.45,2.52 -1.7,0.73 0.15,0.65 -0.52,1.45 1.25,2.02 0.89,0.01 0.37,1.57 1.71,2.42 -0.68,0.1 -1.55,-0.23 -0.91,0.74 -1.27,0.49 -0.88,0.12 -0.31,0.54 -1.38,-0.14 -1.73,-1.3 -0.2,-1.29 -0.72,-1.41 0.45,-2.38 0.78,-0.98 -0.65,-1.3 -0.96,-0.42 0.37,-1.23 -0.66,-0.64 -1.46,0.12 -1.89,-2.12 0.76,-0.77 -0.06,-1.3 1.73,-0.45 0.69,-0.52 -0.96,-1.04 0.25,-1.03 z" }, { "id": "hn", "name": "Honduras", "d": "m 230.68,427.15425 -0.48,-0.89 -0.86,-0.25 0.2,-1.15 -0.38,-0.31 -0.58,-0.2 -1.23,0.34 -0.1,-0.39 -0.85,-0.46 -0.6,-0.57 -0.83,-0.24 0.58,-0.74 -0.22,-0.56 0.2,-0.55 1.33,-0.81 1.27,-1.11 0.29,0.12 0.62,-0.51 0.8,-0.04 0.26,0.23 0.44,-0.14 1.3,0.26 1.3,-0.08 0.9,-0.32 0.33,-0.32 0.89,0.15 0.67,0.2 0.73,-0.07 0.56,-0.25 1.28,0.4 0.45,0.06 0.85,0.54 0.81,0.65 1.02,0.44 0.74,0.8 -0.96,-0.06 -0.39,0.39 -0.97,0.38 -0.71,0 -0.62,0.37 -0.56,-0.13 -0.48,-0.44 -0.29,0.08 -0.36,0.69 -0.27,-0.03 -0.05,0.6 -0.98,0.79 -0.51,0.34 -0.29,0.36 -0.83,-0.58 -0.6,0.76 -0.59,-0.02 -0.66,0.07 0.06,1.41 -0.41,0.02 -0.35,0.66 z" }, { "id": "hr", "name": "Croatia", "d": "m 528.3,319.18425 0.68,1.55 0.89,1.14 -1.08,1.49 -1.27,-0.88 -1.94,0.06 -2.41,-0.66 -1.31,0.09 -0.6,0.82 -1.01,-0.91 -0.59,1.64 1.38,1.83 0.6,1.21 1.29,1.45 1.07,0.85 1.06,1.61 2.48,1.44 -0.31,0.64 -2.63,-1.4 -1.63,-1.38 -2.56,-1.14 -2.36,-2.85 0.57,-0.3 -1.28,-1.64 -0.06,-1.34 -1.8,-0.62 -0.86,1.71 -0.83,-1.33 0.07,-1.38 0.1,-0.06 1.95,0.14 0.52,-0.68 0.95,0.65 1.1,0.08 -0.01,-1.12 0.97,-0.41 0.28,-1.62 2.23,-1.08 0.89,0.5 2.1,1.73 2.31,0.77 z" }, { "id": "ht", "name": "Haiti", "d": "m 270.29,407.00425 1.71,0.13 2.43,0.47 0.25,1.61 -0.22,1.13 -0.68,0.5 0.72,0.88 -0.06,0.8 -1.86,-0.5 -1.32,0.2 -1.71,-0.21 -1.31,0.55 -1.51,-0.92 0.25,-0.95 2.58,0.41 2.12,0.24 1.01,-0.66 -1.28,-1.27 0.02,-1.13 -1.77,-0.46 z" }, { "id": "hu", "name": "Hungary", "d": "m 520.93,315.36425 0.93,-2.65 -0.54,-0.89 1.58,-0.01 0.21,-1.71 1.43,1.07 1.03,0.46 2.36,-0.51 0.22,-0.84 1.12,-0.13 1.36,-0.65 0.3,0.27 1.32,-0.52 0.66,-1 0.92,-0.25 3,1.28 0.6,-0.43 1.55,1.14 0.2,1.12 -1.71,0.87 -1.33,2.8 -1.69,2.76 -2.25,0.76 -1.75,-0.17 -2.15,1.05 -1.05,0.6 -2.31,-0.77 -2.1,-1.73 -0.89,-0.5 -0.55,-1.37 z" }, { "id": "id", "name": "Indonesia", "d": "m 813.97,492.31425 -1.18,0.05 -3.72,-1.98 2.61,-0.56 1.47,0.86 0.98,0.86 -0.16,0.77 z m 10.43,-0.28 -2.4,0.62 -0.34,-0.34 0.25,-0.96 1.21,-1.72 2.77,-1.12 0.28,0.56 0.05,0.86 -1.82,2.1 z m -18.32,-5.77 1.01,0.75 1.73,-0.23 0.7,1.2 -3.24,0.57 -1.94,0.38 -1.51,-0.02 0.96,-1.62 1.54,-0.02 0.75,-1.01 z m 14.03,-0.01 -0.41,1.56 -4.21,0.8 -3.73,-0.35 -0.01,-1.03 2.23,-0.59 1.76,0.84 1.87,-0.21 2.5,-1.02 z m -40.04,-3.69 5.37,0.28 0.62,-1.16 5.2,1.35 1.02,1.82 4.21,0.51 3.44,1.67 -3.2,1.07 -3.08,-1.13 -2.54,0.08 -2.91,-0.21 -2.62,-0.51 -3.25,-1.07 -2.06,-0.28 -1.17,0.35 -5.11,-1.16 -0.49,-1.21 -2.57,-0.21 1.92,-2.68 3.4,0.17 2.26,1.09 1.16,0.21 0.4,1.02 z m 73.18,-1.58 -1.44,1.91 -0.27,-2.11 0.5,-1.01 0.59,-0.95 0.64,0.82 -0.02,1.34 z m -20.96,-7.71 -1.05,0.93 -1.94,-0.51 -0.55,-1.2 2.84,-0.13 0.7,0.91 z m 9.04,-1.01 1.02,2.13 -2.37,-1.15 -2.34,-0.23 -1.58,0.18 -1.94,-0.1 0.67,-1.53 3.46,-0.12 3.08,0.82 z m 10.29,-5.42 0.78,4.51 2.9,1.67 2.34,-2.96 3.22,-1.68 2.49,0 2.4,0.97 2.08,1 3.01,0.53 0.05,9.1 0.05,9.16 -2.5,-2.31 -2.85,-0.57 -0.69,0.8 -3.55,0.09 1.19,-2.29 1.77,-0.78 -0.73,-3.05 -1.35,-2.35 -5.44,-2.37 -2.31,-0.23 -4.21,-2.58 -0.83,1.36 -1.08,0.25 -0.64,-1.02 -0.01,-1.21 -2.14,-1.37 3.02,-1 2,0.05 -0.24,-0.74 -4.1,-0.01 -1.11,-1.66 -2.5,-0.51 -1.19,-1.38 3.78,-0.67 1.44,-0.91 4.5,1.14 0.45,1.02 z m -24.96,-7.16 -2.25,2.76 -2.11,0.54 -2.7,-0.54 -4.67,0.14 -2.45,0.4 -0.4,2.11 2.51,2.48 1.51,-1.26 5.23,-0.95 -0.23,1.28 -1.22,-0.4 -1.22,1.63 -2.47,1.08 2.65,3.57 -0.51,0.96 2.52,3.22 -0.02,1.84 -1.5,0.82 -1.1,-0.98 1.36,-2.29 -2.75,1.08 -0.7,-0.77 0.36,-1.08 -2.02,-1.64 0.21,-2.72 -1.87,0.85 0.24,3.25 0.11,4 -1.78,0.41 -1.2,-0.82 0.8,-2.57 -0.43,-2.69 -1.18,-0.02 -0.87,-1.91 1.16,-1.83 0.4,-2.21 1.41,-4.2 0.59,-1.15 2.38,-2.07 2.19,0.82 3.54,0.39 3.22,-0.12 2.77,-2.02 0.49,0.61 z m 9.67,0.8 -0.15,2.43 -1.45,-0.27 -0.43,1.69 1.16,1.47 -0.79,0.33 -1.13,-1.76 -0.83,-3.56 0.56,-2.23 0.93,-1.01 0.2,1.52 1.66,0.24 0.27,1.15 z m -30.32,-1.94 3.14,2.58 -3.32,0.33 -0.94,1.9 0.12,2.52 -2.7,1.91 -0.06,2.77 -1.08,4.27 -0.41,-0.99 -3.19,1.26 -1.11,-1.71 -2,-0.16 -1.4,-0.89 -3.33,1 -1.02,-1.35 -1.84,0.15 -2.31,-0.32 -0.43,-3.74 -1.4,-0.77 -1.35,-2.38 -0.39,-2.44 0.33,-2.58 1.67,-1.85 0.47,1.86 1.92,1.57 1.81,-0.57 1.79,0.2 1.63,-1.41 1.34,-0.24 2.65,0.78 2.29,-0.59 1.44,-3.88 1.08,-0.97 0.97,-3.17 3.22,0 2.43,0.47 -1.59,2.52 2.06,2.64 -0.49,1.28 z m -33.81,21.42 -3.1,0.06 -2.36,-2.34 -3.6,-2.28 -1.2,-1.69 -2.12,-2.27 -1.39,-2.09 -2.13,-3.9 -2.46,-2.32 -0.82,-2.39 -1.03,-2.17 -2.53,-1.75 -1.47,-2.39 -2.11,-1.56 -2.92,-3.08 -0.25,-1.42 1.81,0.11 4.34,0.54 2.48,2.73 2.17,1.89 1.55,1.16 2.66,3 2.85,0.04 2.36,1.91 1.62,2.33 2.13,1.27 -1.12,2.27 1.61,0.97 1.01,0.07 0.48,1.94 0.98,1.56 2.06,0.25 1.36,1.76 -0.7,3.47 -0.16,4.32 z" }, { "id": "ie", "name": "Ireland", "d": "m 458.13,284.54425 0.46,3.36 -2.12,4.12 -4.97,2.68 -3.97,-0.68 2.27,-4.78 -1.46,-4.77 3.81,-3.75 2.12,-2.27 0.58,2.6 -0.58,2.57 1.74,-0.06 z" }, { "id": "il", "name": "Israel", "d": "m 575.66,367.07425 -0.49,1.05 -1.02,-0.46 -0.58,2.2 0.7,0.36 -0.71,0.46 -0.13,0.86 1.32,-0.45 0.07,1.27 -1.4,5.17 -1.84,-5.55 0.81,-1.08 -0.19,-0.19 0.74,-1.53 0.57,-2.5 0.4,-0.84 0.08,-0.03 0.94,0 0.26,-0.58 0.75,-0.05 0.04,1.37 -0.38,0.5 z" }, { "id": "in", "name": "India", "d": "m 693.75,357.69425 3.01,3.99 -0.28,2.74 1.11,1.71 -0.09,1.69 -2.01,-0.44 0.79,3.63 2.75,2.06 3.9,2.27 -1.78,1.46 -1.09,2.99 2.72,1.2 2.64,1.55 3.66,1.77 3.84,0.41 1.62,1.59 2.16,0.29 3.38,0.73 2.33,-0.05 0.32,-1.24 -0.37,-1.99 0.22,-1.35 1.71,-0.67 0.24,2.48 0.05,0.63 2.55,1.19 1.77,-0.49 2.36,0.21 2.29,-0.09 0.2,-1.93 -1.14,-1 2.26,-0.4 2.55,-2.35 3.23,-2.03 2.35,0.78 2,-1.34 1.32,1.98 -0.95,1.34 3.02,0.47 0.22,1.2 -0.99,0.58 0.23,1.93 -2,-0.57 -3.63,2.16 0.08,1.78 -1.54,2.6 -0.15,1.5 -1.25,2.52 -2.19,-0.7 -0.11,3.15 -0.63,1.03 0.3,1.28 -1.39,0.72 -1.47,-4.8 -0.78,0.01 -0.46,1.94 -1.53,-1.58 0.86,-1.73 1.26,-0.18 1.29,-2.59 -1.61,-0.53 -2.61,0.05 -2.66,-0.42 -0.25,-2.15 -1.34,-0.16 -2.22,-1.34 -0.99,2.11 2.02,1.63 -1.75,1.15 -0.62,1.12 1.72,0.82 -0.47,1.84 0.97,2.28 0.44,2.48 -0.41,1.1 -1.9,-0.04 -3.46,0.62 0.16,2.25 -1.5,1.76 -4.03,2 -3.14,3.46 -2.11,1.85 -2.79,1.91 0,1.34 -1.4,0.72 -2.53,1.03 -1.31,0.16 -0.84,2.2 0.58,3.75 0.15,2.37 -1.18,2.71 -0.02,4.83 -1.45,0.14 -1.27,2.15 0.85,0.93 -2.56,0.8 -0.94,1.92 -1.13,0.81 -2.65,-2.63 -1.3,-3.96 -1.08,-2.86 -0.98,-1.34 -1.49,-2.74 -0.69,-3.58 -0.49,-1.8 -2.55,-3.97 -1.16,-5.64 -0.84,-3.77 0.01,-3.58 -0.54,-2.8 -4.08,1.79 -1.98,-0.36 -3.66,-3.63 1.35,-1.09 -0.83,-1.18 -3.29,-2.58 1.87,-2.04 6.17,0.01 -0.56,-2.64 -1.57,-1.56 -0.32,-2.39 -1.84,-1.4 3.09,-3.3 3.26,0.24 2.93,-3.32 1.76,-3.26 2.72,-3.24 -0.04,-2.33 2.39,-1.91 -2.27,-1.63 -0.97,-2.25 -0.99,-2.95 1.37,-1.46 4.26,0.83 3.12,-0.51 z" }, { "id": "iq", "name": "Iraq", "d": "m 602.86,356.02425 1.83,1.04 0.22,2 -1.42,1.17 -0.65,2.64 1.95,3.18 3.43,1.82 1.45,2.5 -0.46,2.37 0.89,0 0.03,1.73 1.55,1.69 -1.66,-0.15 -1.89,-0.27 -2.06,3.08 -5.21,-0.26 -7.9,-6.49 -4.18,-2.29 -3.38,-0.89 -1.13,-4.04 6.21,-3.5 1.06,-4.12 -0.27,-2.52 1.54,-0.86 1.44,-2.18 1.2,-0.55 3.26,0.46 0.99,0.89 1.34,-0.59 z" }, { "id": "ir", "name": "Iran", "d": "m 626.69,351.78425 2.47,-0.68 1.99,-2.02 1.87,0.1 1.23,-0.66 2,0.33 3.1,1.79 2.24,0.39 3.2,3.09 2.09,0.12 0.25,2.91 -1.14,4.25 -0.77,2.45 1.22,0.49 -1.2,1.83 0.92,2.64 0.22,2.09 2.12,0.55 0.23,2.1 -2.54,2.93 1.38,1.68 1.13,1.93 2.68,1.4 0.08,2.77 1.34,0.51 0.23,1.44 -4.04,1.61 -1.06,3.6 -5.27,-0.93 -3.06,-0.71 -3.16,-0.41 -1.2,-3.81 -1.34,-0.56 -2.16,0.56 -2.82,1.51 -3.43,-1.03 -2.83,-2.41 -2.7,-0.9 -1.87,-3.01 -2.07,-4.27 -1.51,0.52 -1.78,-1.07 -1.05,1.26 -1.55,-1.69 -0.03,-1.73 -0.89,0 0.46,-2.37 -1.45,-2.5 -3.43,-1.82 -1.95,-3.18 0.65,-2.64 1.42,-1.17 -0.22,-2 -1.83,-1.04 -1.82,-4.14 -1.53,-2.83 0.54,-1.09 -0.87,-4.12 1.92,-1.03 0.44,1.37 1.42,1.66 1.92,0.47 1.02,-0.1 3.31,-2.66 1.05,-0.27 0.82,1.07 -0.96,1.78 1.75,1.86 0.69,-0.17 0.89,2.61 2.66,0.73 1.95,1.76 3.98,0.6 4.38,-0.92 z" }, { "id": "is", "name": "Iceland", "d": "m 434.82,212.68425 -0.64,4.48 3.16,4.6 -3.64,5.01 -8.09,4.38 -2.42,1.15 -3.69,-0.93 -7.82,-2.01 2.76,-2.84 -6.1,-3.2 4.96,-1.28 -0.12,-1.97 -5.88,-1.57 1.89,-4.47 4.25,-1.03 4.37,4.68 4.26,-3.75 3.53,1.96 4.57,-3.71 z" }, { "id": "it", "name": "Italy", "d": "m 519.02,348.13425 -1.01,2.78 0.42,1.09 -0.59,1.79 -2.14,-1.31 -1.43,-0.38 -3.91,-1.79 0.39,-1.82 3.28,0.32 2.86,-0.39 2.13,-0.29 z m -17.69,-10.82 1.68,2.62 -0.39,4.81 -1.27,-0.23 -1.14,1.2 -1.06,-0.95 -0.11,-4.38 -0.64,-2.1 1.54,0.19 1.39,-1.16 z m 8.87,-21.6 4.01,1.05 -0.3,1.99 0.67,1.71 -2.23,-0.58 -2.28,1.42 0.16,1.97 -0.34,1.12 0.92,1.99 2.63,1.95 1.41,3.17 3.12,3.05 2.2,-0.02 0.68,0.83 -0.79,0.74 2.51,1.35 2.06,1.12 2.4,1.92 0.29,0.68 -0.52,1.31 -1.56,-1.7 -2.44,-0.6 -1.18,2.36 2.03,1.34 -0.33,1.88 -1.17,0.21 -1.5,3.06 -1.17,0.27 0.01,-1.08 0.57,-1.91 0.61,-0.77 -1.09,-2.09 -0.86,-1.83 -1.16,-0.46 -0.83,-1.58 -1.8,-0.67 -1.21,-1.49 -2.07,-0.24 -2.19,-1.68 -2.56,-2.45 -1.91,-2.19 -0.87,-3.8 -1.4,-0.45 -2.28,-1.29 -1.29,0.53 -1.62,1.8 -1.17,0.28 0.32,-1.68 -1.52,-0.49 -0.72,-3.04 0.97,-1.21 -0.83,-1.5 0.12,-1.13 1.21,0.86 1.35,-0.19 1.57,-1.36 0.49,0.64 1.34,-0.13 0.61,-1.63 2.07,0.51 1.24,-0.68 0.22,-1.67 1.7,0.58 0.33,-0.78 2.77,-0.71 0.6,1.39 z" }, { "id": "jm", "name": "Jamaica", "d": "m 258.01,411.21425 1.89,0.26 1.49,0.71 0.46,0.8 -1.97,0.05 -0.85,0.49 -1.57,-0.47 -1.6,-1.07 0.33,-0.67 1.18,-0.2 z" }, { "id": "jo", "name": "Jordan", "d": "m 575.17,368.12425 0.49,-1.05 3.12,1.32 5.49,-3.54 1.13,4.04 -0.53,0.49 -5.62,1.65 2.8,3.26 -0.93,0.54 -0.46,1.09 -2.14,0.44 -0.67,1.16 -1.22,0.98 -3.12,-0.51 -0.09,-0.46 1.4,-5.17 -0.07,-1.27 0.42,-0.96 z" }, { "id": "jp", "name": "Japan", "d": "m 853.01,362.26425 0.36,1.15 -1.58,2.03 -1.15,-1.07 -1.44,0.78 -0.74,1.95 -1.83,-0.95 0.02,-1.58 1.55,-2 1.59,0.39 1.15,-1.42 2.07,0.72 z m 17.77,-10.28 -1.06,2.78 0.49,1.73 -1.46,2.42 -3.58,1.6 -4.93,0.21 -4,3.84 -1.88,-1.29 -0.11,-2.52 -4.88,0.75 -3.32,1.59 -3.28,0.06 2.84,2.46 -1.87,5.61 -1.81,1.37 -1.36,-1.27 0.69,-2.96 -1.77,-0.96 -1.14,-2.28 2.65,-1.03 1.47,-2.11 2.82,-1.75 2.06,-2.33 5.58,-1.02 3,0.7 2.93,-6.17 1.87,1.67 4.11,-3.51 1.59,-1.38 1.76,-4.38 -0.48,-4.1 1.18,-2.33 2.98,-0.68 1.53,5.11 -0.08,2.94 -2.59,3.6 0.05,3.63 z m 8.23,-25.93 1.97,0.83 1.98,-1.65 0.62,4.35 -4.16,1.05 -2.46,3.76 -4.41,-2.58 -1.53,4.12 -3.12,0.06 -0.39,-3.74 1.39,-2.94 3,-0.21 0.82,-5.38 0.83,-3.09 3.29,4.12 2.17,1.3 z" }, { "id": "ke", "name": "Kenya", "d": "m 590.44,466.03425 1.66,2.29 -1.96,1.12 -0.69,1.16 -1.06,0.2 -0.39,1.97 -0.9,1.12 -0.55,1.86 -1.13,0.92 -4.02,-2.79 -0.2,-1.62 -10.16,-5.67 -0.48,-0.31 -0.02,-2.95 0.8,-1.13 1.38,-1.84 1.02,-2.03 -1.23,-3.2 -0.33,-1.4 -1.33,-1.94 1.72,-1.67 1.9,-1.84 1.46,0.47 0,1.57 0.96,0.91 1.95,0 3.55,2.38 0.88,0.02 0.66,-0.07 0.62,0.32 1.87,0.22 0.83,-1.16 2.56,-1.17 1.13,0.94 1.92,0 -2.45,3.17 z" }, { "id": "kg", "name": "Kyrgyzstan", "d": "m 674.47,333.36425 0.63,-1.66 1.84,-0.54 4.62,1.31 0.43,-2.24 1.59,-0.8 4,1.61 1.02,-0.42 4.65,0.1 4.16,0.4 1.4,1.37 1.73,0.55 -0.39,0.86 -4.42,2.03 -1,1.48 -3.6,0.44 -1.06,2.35 -2.97,-0.49 -1.93,0.72 -2.68,1.72 0.39,0.85 -0.8,0.83 -5.3,0.55 -3.47,-1.17 -3.04,0.28 0.27,-2.1 3.05,0.61 1.03,-1.13 2.13,0.36 3.59,-2.64 -3.32,-1.96 -2,0.93 -2.07,-1.4 2.35,-2.43 z" }, { "id": "kh", "name": "Cambodia", "d": "m 765.69,433.85425 -1.14,-1.48 -1.41,-2.94 -0.67,-3.45 1.8,-2.38 3.62,-0.55 2.63,0.41 2.31,1.13 1.27,-1.99 2.49,1.06 0.65,1.92 -0.35,3.42 -4.71,2.19 1.23,1.73 -2.94,0.2 -2.43,1.14 z" }, { "id": "kp", "name": "North Korea", "d": "m 841.8,332.87425 0.39,0.67 -1.06,-0.23 -1.22,1.27 -0.84,1.28 0.11,2.67 -1.45,0.81 -0.5,0.65 -1.06,1.08 -1.87,0.6 -1.21,0.98 -0.09,1.57 -0.33,0.4 1.12,0.58 1.59,1.58 -0.41,0.86 -1.19,0.23 -1.98,0.17 -1.09,1.6 -1.26,-0.12 -0.17,0.32 -1.36,-0.67 -0.34,0.66 -0.82,0.29 -0.1,-0.66 -0.73,-0.32 -0.75,-0.57 0.77,-1.57 0.66,-0.42 -0.25,-0.65 0.71,-1.94 -0.19,-0.59 -1.63,-0.4 -1.32,-0.97 2.28,-2.35 3.09,-1.98 1.93,-2.65 1.33,1.17 2.42,0.14 -0.44,-1.97 4.33,-1.63 1.12,-2.13 z" }, { "id": "kr", "name": "South Korea", "d": "m 835.38,346.78425 2.42,4.18 0.69,2.27 0.02,3.98 -1.05,1.88 -2.54,0.66 -2.24,1.41 -2.53,0.29 -0.31,-1.85 0.52,-2.57 -1.24,-3.6 2.08,-0.59 -1.92,-3 0.17,-0.32 1.26,0.12 1.09,-1.6 1.98,-0.17 1.19,-0.23 z" }, { "id": "xk", "name": "Kosovo", "d": "m 533.72,334.17425 -0.13,0.77 -0.36,-0.03 -0.18,-1.37 -0.67,-0.38 -0.6,-1.02 0.52,-0.85 0.67,-0.28 0.39,-1.26 0.5,-0.22 0.4,0.54 0.53,0.24 0.36,0.61 0.46,0.18 0.55,0.7 0.4,-0.02 -0.32,0.93 -0.33,0.45 0.09,0.28 -0.63,0.14 z" }, { "id": "kw", "name": "Kuwait", "d": "m 610.02,376.01425 0.58,1.41 -0.25,0.73 0.9,2.41 -1.98,0.08 -0.7,-1.51 -2.5,-0.31 2.06,-3.08 z" }, { "id": "kz", "name": "Kazakhstan", "d": "m 674.47,333.36425 -1.61,0.7 -3.69,2.61 -1.23,2.65 -1.05,0.02 -0.76,-1.75 -3.57,-0.12 -0.57,-3.06 -1.37,-0.03 0.21,-3.8 -3.35,-2.8 -4.81,0.3 -3.29,0.56 -2.68,-3.5 -2.29,-1.48 -4.35,-2.84 -0.52,-0.35 -7.22,2.35 0.11,14.13 -1.44,0.18 -1.96,-2.93 -1.9,-1.06 -3.18,0.79 -1.24,1.25 -0.16,-0.92 0.69,-1.57 -0.53,-1.32 -3.25,-1.3 -1.27,-3.47 -1.54,-0.98 -0.1,-1.28 2.73,0.37 0.11,-2.88 2.38,-0.64 2.45,0.59 0.51,-3.91 -0.5,-2.51 -2.81,0.2 -2.38,-1 -3.25,1.79 -2.61,0.86 -1.43,-0.66 0.29,-2.1 -1.79,-2.76 -2.08,0.11 -2.38,-2.83 1.62,-3.22 -0.82,-0.87 2.23,-4.77 2.89,2.53 0.35,-3.19 5.78,-4.85 4.38,-0.12 6.19,3.1 3.31,1.79 2.98,-1.87 4.44,-0.08 3.59,2.29 0.82,-1.31 3.93,0.19 0.71,-2.11 -4.55,-3.09 2.69,-2.23 -0.52,-1.25 2.69,-1.21 -2.02,-3.2 1.28,-1.62 10.49,-1.66 1.37,-1.19 7.02,-1.79 2.52,-2.04 5.04,1.06 0.88,5.01 2.93,-1.16 3.6,1.63 -0.23,2.58 2.69,-0.27 7.02,-4.49 -1.02,1.5 3.58,3.66 6.26,11.58 1.5,-2.33 3.86,2.56 4.03,-1.14 1.54,0.8 1.35,2.55 1.96,0.84 1.2,1.85 3.61,-0.58 1.49,2.63 -2.14,2.83 -2.33,0.4 -0.13,4.18 -1.56,1.86 -5.56,-1.35 -2.02,7.26 -1.44,0.89 -5.55,1.58 2.52,6.75 -1.92,1 0.22,2.16 -1.73,-0.55 -1.4,-1.37 -4.16,-0.4 -4.65,-0.1 -1.02,0.42 -4,-1.61 -1.59,0.8 -0.43,2.24 -4.62,-1.31 -1.84,0.54 z" }, { "id": "la", "name": "Lao People's Democratic Republic", "d": "m 770.52,423.46425 0.91,-1.3 0.13,-2.44 -2.27,-2.53 -0.18,-2.87 -2.13,-2.38 -2.12,-0.2 -0.56,1.02 -1.65,0.08 -0.84,-0.51 -2.95,1.74 -0.07,-2.62 0.69,-3.11 -1.89,-0.13 -0.16,-1.78 -1.22,-0.92 0.6,-1.1 2.39,-1.94 0.25,0.7 1.49,0.08 -0.42,-3.43 1.45,-0.44 1.64,2.37 1.26,2.72 3.45,0.03 1.09,2.59 -1.79,0.77 -0.81,1.07 3.36,1.76 2.33,3.46 1.77,2.57 2.12,2.02 0.71,2.04 -0.51,2.88 -2.49,-1.06 -1.27,1.99 z" }, { "id": "lb", "name": "Lebanon", "d": "m 575.94,365.18425 -0.75,0.05 -0.26,0.58 -0.94,0 1,-2.73 1.39,-2.38 0.06,-0.12 1.26,0.18 0.46,1.32 -1.53,1.27 z" }, { "id": "lk", "name": "Sri Lanka", "d": "m 704.82,442.62425 -0.42,2.92 -1.17,0.8 -2.44,0.64 -1.34,-2.23 -0.49,-4.03 1.27,-4.58 1.93,1.57 1.31,1.98 z" }, { "id": "lr", "name": "Liberia", "d": "m 453.88,451.47425 -0.74,0.02 -2.89,-1.33 -2.54,-2.13 -2.39,-1.53 -1.89,-1.81 0.67,-0.9 0.15,-0.81 1.26,-1.53 1.31,-1.31 0.6,-0.07 0.73,-0.31 1.17,1.72 -0.18,1.13 0.54,0.6 0.8,0.01 0.57,-1.13 0.79,0.07 -0.13,0.82 0.28,1.36 -0.61,1.24 0.82,0.77 0.89,0.19 1.19,1.17 0.08,1.11 -0.27,0.35 z" }, { "id": "ls", "name": "Lesotho", "d": "m 556.75,548.00425 0.98,0.96 -0.86,1.56 -0.48,1.05 -1.56,0.5 -0.52,1.04 -1,0.32 -2.1,-2.49 1.49,-2.03 1.52,-1.25 1.31,-0.64 z" }, { "id": "lt", "name": "Lithuania", "d": "m 539.24,282.34425 -0.23,-1.22 0.3,-1.33 -1.24,-0.77 -2.93,-0.86 -0.6,-4.16 3.21,-1.55 4.7,0.33 2.76,-0.5 0.39,1.05 1.49,0.32 2.7,2.42 0.26,2.2 -2.3,1.57 -0.65,2.72 -3.04,1.8 -2.71,-0.04 -0.67,-1.46 z" }, { "id": "lu", "name": "Luxembourg", "d": "m 492.45,301.54425 0.56,0.98 -0.16,1.89 -0.81,0.1 -0.63,-0.38 0.31,-2.43 z" }, { "id": "lv", "name": "Latvia", "d": "m 534.54,274.00425 0.1,-3.81 1.38,-3.24 2.64,-1.78 2.22,3.88 2.25,-0.1 0.54,-3.99 2.39,-0.93 1.23,0.65 2.41,1.94 2.32,0.01 1.35,1.19 0.23,2.49 0.91,2.99 -3.02,1.93 -1.7,0.84 -2.7,-2.42 -1.49,-0.32 -0.39,-1.05 -2.76,0.5 -4.7,-0.33 z" }, { "id": "ly", "name": "Libya", "d": "m 517.14,398.18425 -1.98,1.12 -1.58,-1.66 -4.43,-1.31 -1.23,-1.91 -2.22,-1.42 -1.31,0.56 -0.99,-1.71 -0.11,-1.32 -1.66,-2.26 1.12,-1.29 -0.25,-1.97 0.36,-1.72 -0.2,-1.44 0.49,-2.59 -0.15,-1.48 -0.91,-2.84 1.37,-0.75 0.24,-1.38 -0.3,-1.35 1.93,-1.26 0.86,-1.05 1.37,-0.95 0.16,-2.55 3.29,1.15 1.18,-0.29 2.34,0.56 3.72,1.47 1.31,2.92 2.52,0.64 3.95,1.36 2.99,1.61 1.37,-0.84 1.34,-1.49 -0.65,-2.51 0.88,-1.6 2.02,-1.55 1.93,-0.45 3.79,0.68 0.96,1.48 1.04,0.01 0.89,0.56 2.79,0.39 0.68,1.08 -1.01,1.57 0.43,1.39 -0.72,2 0.84,2.58 0,11.18 0,11.23 0,5.96 -3.22,0.01 -0.04,1.24 -11.18,-5.7 -11.19,-5.77 z" }, { "id": "ma", "name": "Morocco", "d": "m 451.21,383.39425 -0.03,-3.75 4.53,-2.36 2.8,-0.49 2.29,-0.86 1.08,-1.62 3.28,-1.29 0.12,-2.41 1.62,-0.29 1.27,-1.21 3.67,-0.56 0.51,-1.28 -0.74,-0.71 -0.97,-3.53 -0.16,-2.05 -1.06,-2.18 -1.22,-0.04 -2.9,-0.75 -2.67,0.24 -1.69,-1.46 -2.06,-0.02 -0.89,2.11 -1.87,3.51 -2.08,1.39 -2.81,1.53 -1.8,2.24 -0.38,1.74 -1.07,2.82 0.7,4.03 -2.34,2.68 -1.4,0.85 -2.21,2.17 -2.61,0.35 -1.3,1.12 3.62,0.01 8.75,0.03 0,0 0,0 -8.75,-0.03 -3.62,-0.01 z" }, { "id": "md", "name": "Moldova", "d": "m 550.14,309.70425 0.67,-0.62 1.86,-0.42 2.07,1.31 1.15,0.16 1.27,1.12 -0.2,1.41 1.02,0.67 0.4,1.72 0.98,1.04 -0.19,0.6 0.52,0.42 -0.74,0.29 -1.66,-0.11 -0.27,-0.57 -0.59,0.33 0.2,0.72 -0.77,1.29 -0.49,1.37 -0.7,0.44 -0.51,-1.83 0.3,-1.72 -0.09,-1.79 -1.62,-2.44 -0.89,-1.75 -0.87,-1.24 z" }, { "id": "me", "name": "Montenegro", "d": "m 531.02,332.48425 -0.17,-0.72 -1.22,1.87 0.19,1.19 -0.59,-0.29 -0.78,-1.23 -1.22,-0.75 0.31,-0.64 0.41,-2.1 0.91,-0.89 0.53,-0.36 0.74,0.66 0.41,0.54 0.92,0.41 1.07,0.79 -0.23,0.33 -0.52,0.85 z" }, { "id": "mg", "name": "Madagascar", "d": "m 614.42,498.65425 0.74,1.21 0.69,1.89 0.46,3.46 0.72,1.35 -0.28,1.38 -0.49,0.86 -0.96,-1.7 -0.52,0.86 0.53,2.14 -0.25,1.24 -0.77,0.67 -0.18,2.48 -1.1,3.42 -1.38,4.09 -1.74,5.67 -1.07,4.21 -1.27,3.55 -2.28,0.73 -2.45,1.31 -1.61,-0.79 -2.23,-1.1 -0.77,-1.62 -0.19,-2.71 -0.98,-2.42 -0.26,-2.17 0.5,-2.16 1.29,-0.52 0.01,-0.99 1.34,-2.26 0.25,-1.88 -0.65,-1.4 -0.53,-1.85 -0.22,-2.7 0.98,-1.63 0.37,-1.85 1.4,-0.1 1.57,-0.6 1.03,-0.52 1.24,-0.04 1.59,-1.65 2.31,-1.78 0.84,-1.44 -0.38,-1.23 1.19,0.35 1.55,-1.99 0.05,-1.72 0.93,-1.28 z" }, { "id": "mk", "name": "Macedonia", "d": "m 533.23,334.91425 0.36,0.03 0.13,-0.77 1.65,-0.59 0.63,-0.14 0.96,-0.22 1.29,-0.06 1.41,1.21 0.2,2.47 -0.54,0.12 -0.46,0.65 -1.52,-0.07 -1.07,0.81 -1.83,0.32 -1.16,-0.9 -0.4,-1.59 z" }, { "id": "ml", "name": "Mali", "d": "m 441.38,422.47425 0.94,-0.52 0.47,-1.7 0.89,-0.07 1.96,0.8 1.58,-0.57 1.08,0.19 0.43,-0.64 11.25,-0.04 0.62,-2.03 -0.49,-0.36 -1.35,-12.68 -1.35,-13.06 4.29,-0.05 9.46,6.65 9.46,6.55 0.66,1.39 1.75,0.85 1.3,0.48 0.03,1.88 3.11,-0.29 0.01,6.75 -1.54,1.94 -0.24,1.79 -2.49,0.45 -3.82,0.25 -1.04,1.03 -1.8,0.11 -1.79,0.01 -0.7,-0.55 -1.55,0.41 -2.62,1.2 -0.53,0.9 -2.18,1.28 -0.38,0.74 -1.17,0.58 -1.36,-0.38 -0.77,0.7 -0.41,1.96 -2.23,2.36 0.07,0.96 -0.77,1.21 0.19,1.64 -1.16,0.42 -0.65,0.36 -0.44,-1.21 -0.81,0.32 -0.48,-0.06 -0.52,0.83 -2.16,-0.03 -0.78,-0.42 -0.36,0.26 -0.86,-0.82 0.15,-0.84 -0.35,-0.34 -0.6,0.28 0.11,-0.92 0.58,-0.73 -1.15,-1.19 -0.34,-0.79 -0.62,-0.62 -0.56,-0.08 -0.67,0.4 -0.91,0.38 -0.77,0.62 -1.2,-0.23 -0.78,-0.72 -0.46,-0.1 -0.73,0.38 -0.45,0.01 -0.16,-1.05 0.13,-0.89 -0.24,-1.1 -1.05,-0.81 -0.55,-1.64 z" }, { "id": "mm", "name": "Myanmar", "d": "m 754.61,406.20425 -1.64,1.28 -1.98,0.14 -1.28,3.19 -1.18,0.53 1.36,2.57 1.78,2.13 1.14,1.92 -1.02,2.52 -0.97,0.53 0.67,1.45 1.87,2.28 0.32,1.6 -0.05,1.33 1.1,2.6 -1.54,2.65 -1.36,2.91 -0.27,-2.1 0.86,-2.18 -0.94,-1.68 0.23,-3.11 -1.14,-1.48 -0.91,-3.44 -0.51,-3.66 -1.21,-2.4 -1.85,1.46 -3.19,2.06 -1.57,-0.26 -1.74,-0.67 0.97,-3.61 -0.58,-2.74 -2.2,-3.39 0.34,-1.07 -1.64,-0.38 -1.99,-2.42 -0.18,-2.41 0.98,0.46 0.05,-2.15 1.39,-0.72 -0.3,-1.28 0.63,-1.03 0.11,-3.15 2.19,0.7 1.25,-2.52 0.15,-1.5 1.54,-2.6 -0.08,-1.78 3.63,-2.16 2,0.57 -0.23,-1.93 0.99,-0.58 -0.22,-1.2 1.64,-0.24 0.94,1.86 1.22,0.75 0.09,2.4 -0.12,2.57 -2.65,2.58 -0.34,3.63 2.96,-0.5 0.67,2.8 1.78,0.59 -0.82,2.5 2.08,1.13 1.22,0.55 2.05,-0.87 0.09,1.24 -2.39,1.94 -0.6,1.1 z" }, { "id": "mn", "name": "Mongolia", "d": "m 721.54,305.13425 2.96,-0.74 5.35,-3.74 4.27,-2.07 2.43,1.35 2.93,0.06 1.87,2.05 2.8,0.15 4.06,1.09 2.72,-3.03 -1.14,-2.6 2.91,-4.66 3.14,1.87 2.54,0.53 3.3,1.15 0.53,3.32 3.99,1.84 2.65,-0.81 3.54,-0.57 2.81,0.58 2.75,2.09 1.7,2.2 2.6,-0.04 3.53,0.69 2.58,-1.06 3.69,-0.71 4.11,-3.06 1.68,0.47 1.47,1.46 3.34,-0.36 -1.36,3.25 -1.98,4.22 0.72,1.71 1.59,-0.53 2.76,0.65 2.16,-1.54 2.25,1.33 2.54,2.89 -0.31,1.45 -2.21,-0.46 -4.07,0.54 -1.98,1.16 -2.05,2.66 -4.28,1.55 -2.79,2.1 -2.88,-0.8 -1.58,-0.36 -1.47,2.54 0.89,1.5 0.46,1.28 -1.97,1.3 -2.01,2.05 -3.27,1.33 -4.21,0.15 -4.53,1.31 -3.26,2.01 -1.24,-1.16 -3.39,0 -4.15,-2.29 -2.77,-0.57 -3.73,0.53 -5.79,-0.85 -3.09,0.09 -1.64,-2.27 -1.28,-3.57 -1.73,-0.43 -3.39,-2.45 -3.78,-0.55 -3.33,-0.68 -1.01,-1.73 1.08,-4.73 -1.93,-3.31 -4,-1.57 -2.36,-2.23 z" }, { "id": "mr", "name": "Mauritania", "d": "m 441.38,422.47425 -1.85,-1.98 -1.7,-2.13 -1.86,-0.77 -1.34,-0.85 -1.57,0.03 -1.36,0.63 -1.4,-0.25 -0.96,0.93 -0.24,-1.56 0.78,-1.44 0.35,-2.75 -0.31,-2.91 -0.34,-1.47 0.28,-1.47 -0.72,-1.42 -1.48,-1.28 0.61,-1 10.98,0.02 -0.53,-4.35 0.69,-1.55 2.62,-0.27 -0.09,-7.86 9.21,0.17 0,-4.73 10.55,7.53 -4.29,0.05 1.35,13.06 1.35,12.68 0.49,0.36 -0.62,2.03 -11.25,0.04 -0.43,0.64 -1.08,-0.19 -1.58,0.57 -1.96,-0.8 -0.89,0.07 -0.47,1.7 z" }, { "id": "mw", "name": "Malawi", "d": "m 572.4,495.94425 -0.78,2.16 0.78,3.72 0.98,-0.04 1.01,0.92 1.17,2.08 0.24,3.72 -1.21,0.61 -0.86,2.01 -1.83,-1.79 -0.2,-2.04 0.59,-1.35 -0.17,-1.15 -1.1,-0.73 -0.78,0.26 -1.61,-1.38 -1.47,-0.74 0.85,-2.66 0.88,-0.99 -0.54,-2.36 0.56,-2.3 0.48,-0.77 -0.71,-2.4 -1.32,-1.26 2.74,0.52 0.57,0.78 0.95,1.32 z" }, { "id": "mx", "name": "Mexico", "d": "m 203.14,388.97425 -1.09,2.71 -0.49,2.21 -0.21,4.08 -0.27,1.47 0.49,1.64 0.87,1.47 0.56,2.31 1.86,2.21 0.65,1.69 1.1,1.45 2.98,0.79 1.16,1.22 2.46,-0.82 2.13,-0.29 2.1,-0.53 1.77,-0.51 1.78,-1.2 0.67,-1.73 0.23,-2.49 0.49,-0.87 1.89,-0.79 2.97,-0.69 2.48,0.1 1.7,-0.25 0.67,0.63 -0.09,1.44 -1.51,1.77 -0.66,1.81 0.51,0.51 -0.42,1.28 -0.7,2.29 -0.71,-0.75 -0.59,0.05 -0.53,0.04 -1,1.77 -0.51,-0.35 -0.34,0.14 0.02,0.43 -2.59,-0.03 -2.62,0 0,1.65 -1.27,0 1.04,0.98 1.04,0.67 0.31,0.63 0.46,0.18 -0.08,0.98 -3.59,0.01 -1.35,2.36 0.39,0.54 -0.32,0.68 -0.07,0.84 -3.17,-3.11 -1.45,-0.94 -2.29,-0.76 -1.56,0.21 -2.26,1.09 -1.41,0.29 -1.98,-0.76 -2.1,-0.56 -2.62,-1.33 -2.1,-0.41 -3.18,-1.35 -2.34,-1.4 -0.71,-0.78 -1.57,-0.17 -2.87,-0.93 -1.17,-1.34 -3.01,-1.67 -1.4,-1.87 -0.67,-1.45 0.93,-0.29 -0.29,-0.85 0.65,-0.77 0.01,-1.04 -0.94,-1.34 -0.26,-1.2 -0.94,-1.52 -2.47,-3.02 -2.82,-2.39 -1.36,-1.91 -2.41,-1.26 -0.51,-0.76 0.43,-1.92 -1.43,-0.73 -1.66,-1.52 -0.7,-2.19 -1.51,-0.26 -1.62,-1.66 -1.32,-1.55 -0.12,-1 -1.51,-2.42 -0.99,-2.48 0.04,-1.25 -2.03,-1.29 -0.93,0.14 -1.6,-0.9 -0.45,1.33 0.46,1.56 0.27,2.43 0.97,1.33 2.08,2.21 0.46,0.75 0.43,0.22 0.36,1.1 0.5,-0.05 0.57,2.04 0.85,0.8 0.59,1.11 1.77,1.6 0.93,2.89 0.83,1.35 0.78,1.44 0.15,1.62 1.35,0.1 1.13,1.39 1.02,1.36 -0.07,0.54 -1.18,1.11 -0.5,-0.01 -0.74,-1.85 -1.83,-1.73 -2.02,-1.48 -1.44,-0.78 0.09,-2.25 -0.42,-1.68 -1.34,-0.96 -1.93,-1.39 -0.37,0.4 -0.7,-0.82 -1.73,-0.75 -1.65,-1.83 0.2,-0.24 1.15,0.18 1.04,-1.18 0.11,-1.43 -2.16,-2.27 -1.64,-0.89 -1.04,-2.01 -1.04,-2.12 -1.3,-2.61 -1.14,-2.96 3.19,-0.25 3.56,-0.36 -0.26,0.64 4.23,1.61 6.4,2.31 5.58,-0.03 2.22,0 0,-1.35 4.86,0 1.02,1.17 1.44,1.03 1.66,1.43 0.93,1.69 0.7,1.76 1.45,0.97 2.33,0.96 1.76,-2.53 2.3,-0.06 1.97,1.28 1.41,2.18 0.97,1.86 1.65,1.8 0.62,2.19 0.79,1.47 2.18,0.96 1.99,0.68 z" }, { "id": "my", "name": "Malaysia", "d": "m 758.9,446.32425 0.22,1.44 1.85,-0.33 0.92,-1.15 0.64,0.26 1.66,1.69 1.18,1.87 0.16,1.88 -0.3,1.27 0.27,0.96 0.21,1.65 0.99,0.77 1.1,2.46 -0.05,0.94 -1.99,0.19 -2.65,-2.06 -3.32,-2.21 -0.33,-1.42 -1.62,-1.87 -0.39,-2.31 -1.01,-1.52 0.31,-2.04 -0.62,-1.19 0.49,-0.5 2.28,1.22 z m 49.19,4.83 -2.06,0.95 -2.43,-0.47 -3.22,0 -0.97,3.17 -1.08,0.97 -1.44,3.88 -2.29,0.59 -2.65,-0.78 -1.34,0.24 -1.63,1.41 -1.79,-0.2 -1.81,0.57 -1.92,-1.57 -0.47,-1.86 2.05,0.96 2.17,-0.52 0.56,-2.36 1.2,-0.53 3.36,-0.6 2.01,-2.21 1.38,-1.77 1.28,1.45 0.59,-0.95 1.34,0.09 0.16,-1.78 0.13,-1.38 2.16,-1.95 1.41,-2.19 1.13,-0.01 1.44,1.42 0.13,1.22 1.85,0.78 2.34,0.84 -0.2,1.1 -1.88,0.14 0.49,1.35 z" }, { "id": "mz", "name": "Mozambique", "d": "m 572.4,495.94425 2.11,-0.23 3.37,0.8 0.74,-0.36 1.95,-0.07 1,-0.85 1.68,0.04 3.06,-1.1 2.23,-1.64 0.46,1.27 -0.12,2.83 0.35,2.5 0.11,4.48 0.49,1.4 -0.83,2.07 -1.09,2.01 -1.79,1.8 -2.56,1.11 -3.16,1.41 -3.17,3.15 -1.08,0.54 -1.96,2.09 -1.15,0.69 -0.24,2.12 1.33,2.25 0.55,1.76 0.04,0.9 0.49,-0.15 -0.08,2.96 -0.45,1.41 0.66,0.52 -0.42,1.27 -1.17,1.09 -2.31,1.04 -3.37,1.66 -1.23,1.15 0.24,1.3 0.71,0.21 -0.24,1.64 -2.12,-0.02 -0.24,-1.38 -0.42,-1.39 -0.24,-1.11 0.5,-3.43 -0.73,-2.17 -1.34,-4.26 2.95,-3.41 0.74,-2.15 0.43,-0.27 0.31,-1.74 -0.45,-0.88 0.12,-2.2 0.55,-2.04 -0.01,-3.69 -1.45,-0.94 -1.34,-0.21 -0.6,-0.72 -1.3,-0.61 -2.34,0.06 -0.18,-1.08 -0.27,-2.05 8.51,-2.38 1.61,1.38 0.78,-0.26 1.1,0.73 0.17,1.15 -0.59,1.35 0.2,2.04 1.83,1.79 0.86,-2.01 1.21,-0.61 -0.24,-3.72 -1.17,-2.08 -1.01,-0.92 -0.98,0.04 -0.78,-3.72 z" }, { "id": "na", "name": "Namibia", "d": "m 521.33,546.79425 -2.08,-2.39 -1.1,-2.3 -0.62,-3.03 -0.69,-2.25 -0.94,-4.72 -0.06,-3.63 -0.36,-1.64 -1.09,-1.24 -1.45,-2.47 -1.47,-3.57 -0.61,-1.85 -2.29,-2.87 -0.17,-2.25 1.35,-0.55 1.68,-0.5 1.82,0.09 1.67,1.32 0.42,-0.21 11.37,-0.12 1.94,1.4 6.79,0.41 5.15,-1.19 2.3,-0.67 1.82,0.17 1.1,0.66 0.03,0.24 -1.58,0.66 -0.86,0.01 -1.78,1.15 -1.08,-1.21 -4.32,1.03 -2.09,0.09 -0.08,10.57 -2.76,0.11 0,8.86 -0.01,11.52 -2.5,1.63 -1.5,0.23 -1.77,-0.6 -1.26,-0.23 -0.47,-1.36 -1.11,-0.87 z" }, { "id": "nc", "name": "New Caledonia", "d": "m 940.33,523.73425 2.3,1.86 1.45,1.38 -1.06,0.73 -1.55,-0.82 -2,-1.35 -1.81,-1.59 -1.85,-2.1 -0.39,-1.01 1.2,0.05 1.58,1.01 1.23,1.01 z" }, { "id": "ne", "name": "Niger", "d": "m 481.54,430.13425 0.07,-1.95 -3.24,-0.65 -0.08,-1.38 -1.58,-1.87 -0.38,-1.31 0.22,-1.4 1.8,-0.11 1.04,-1.03 3.82,-0.25 2.49,-0.45 0.24,-1.79 1.54,-1.94 -0.01,-6.75 3.95,-1.32 8.12,-5.85 9.61,-5.75 4.43,1.31 1.58,1.66 1.98,-1.12 0.69,4.67 1.05,0.78 0.05,0.95 1.16,1.02 -0.61,1.28 -1.08,5.98 -0.14,3.79 -3.58,2.74 -1.21,3.8 1.17,1.06 -0.01,1.85 1.8,0.07 -0.28,1.34 -0.79,0.17 -0.09,0.9 -0.53,0.07 -1.89,-3.13 -0.66,-0.12 -2.19,1.6 -2.17,-0.83 -1.51,-0.17 -0.81,0.4 -1.65,-0.08 -1.65,1.22 -1.43,0.07 -3.39,-1.48 -1.33,0.7 -1.43,-0.05 -1.05,-1.08 -2.82,-1.07 -3.01,0.34 -0.73,0.62 -0.39,1.65 -0.81,1.15 -0.19,2.54 -2.14,-1.64 -1.01,0.01 z" }, { "id": "ng", "name": "Nigeria", "d": "m 499.34,450.33425 -2.91,1 -1.07,-0.14 -1.08,0.62 -2.24,-0.06 -1.5,-1.75 -0.92,-2.02 -1.99,-1.84 -2.11,0.03 -2.47,0 0.16,-4.53 -0.07,-1.79 0.53,-1.77 0.86,-0.87 1.36,-1.75 -0.29,-0.76 0.55,-1.14 -0.63,-1.68 0.11,-0.95 0.19,-2.54 0.81,-1.15 0.39,-1.65 0.73,-0.62 3.01,-0.34 2.82,1.07 1.05,1.08 1.43,0.05 1.33,-0.7 3.39,1.48 1.43,-0.07 1.65,-1.22 1.65,0.08 0.81,-0.4 1.51,0.17 2.17,0.83 2.19,-1.6 0.66,0.12 1.89,3.13 0.53,-0.07 1.11,1.14 -0.31,0.51 -0.15,0.95 -2.36,2.2 -0.74,1.81 -0.4,1.47 -0.59,0.63 -0.57,1.97 -1.5,1.16 -0.43,1.42 -0.63,1.14 -0.26,1.16 -1.93,0.95 -1.57,-1.15 -1.07,0.04 -1.67,1.64 -0.81,0.03 -1.33,2.7 z" }, { "id": "ni", "name": "Nicaragua", "d": "m 235.18,432.56425 -0.97,-0.9 -1.31,-1.15 -0.62,-0.96 -1.18,-0.89 -1.41,-1.29 0.31,-0.44 0.47,0.43 0.21,-0.21 0.87,-0.11 0.35,-0.66 0.41,-0.02 -0.06,-1.41 0.66,-0.07 0.59,0.02 0.6,-0.76 0.83,0.58 0.29,-0.36 0.51,-0.34 0.98,-0.79 0.05,-0.6 0.27,0.03 0.36,-0.69 0.29,-0.08 0.48,0.44 0.56,0.13 0.62,-0.37 0.71,0 0.97,-0.38 0.39,-0.39 0.96,0.06 -0.24,0.28 -0.14,0.64 0.28,1.05 -0.64,0.98 -0.3,1.15 -0.1,1.27 0.16,0.73 0.07,1.29 -0.43,0.28 -0.26,1.22 0.19,0.75 -0.58,0.73 0.14,0.76 0.42,0.47 -0.67,0.6 -0.82,-0.19 -0.47,-0.58 -0.89,-0.24 -0.64,0.37 -1.85,-0.75 z" }, { "id": "nl", "name": "Netherlands", "d": "m 492.53,286.23425 2.33,0.13 0.53,1.58 -0.7,4.23 -0.71,1.71 -1.69,0 0.48,4.69 -1.55,-1.04 -1.77,-1.95 -2.6,0.93 -2.05,-0.35 1.44,-1.24 2.46,-6.74 z" }, { "id": "no", "name": "Norway", "d": "m 554.48,175.86425 8.77,6.24 -3.61,2.23 3.07,5.11 -4.77,3.19 -2.26,0.72 1.19,-5.59 -3.6,-3.25 -4.35,2.78 -1.38,5.85 -2.67,3.44 -3.01,-1.87 -3.66,0.38 -3.12,-4.15 -1.68,2.09 -1.74,0.32 -0.41,5.08 -5.28,-1.22 -0.74,4.22 -2.69,-0.03 -1.85,5.24 -2.8,7.87 -4.35,9.5 1.02,2.23 -0.98,2.55 -2.78,-0.11 -1.82,5.91 0.17,8.04 1.79,2.98 -0.93,6.73 -2.33,3.81 -1.24,3.15 -1.88,-3.35 -5.54,6.27 -3.74,1.24 -3.88,-2.71 -1,-5.86 -0.89,-13.26 2.58,-3.88 7.4,-5.18 5.54,-6.59 5.13,-9.3 6.74,-13.76 4.7,-5.67 7.71,-9.89 6.15,-3.59 4.61,0.44 4.27,-6.99 5.11,0.38 5.03,-1.74 z" }, { "id": "np", "name": "Nepal", "d": "m 722.58,382.70425 -0.22,1.35 0.37,1.99 -0.32,1.24 -2.33,0.05 -3.38,-0.73 -2.16,-0.29 -1.62,-1.59 -3.84,-0.41 -3.66,-1.77 -2.64,-1.55 -2.72,-1.2 1.09,-2.99 1.78,-1.46 1.16,-0.78 2.25,1 2.83,2.09 1.57,0.46 0.94,1.53 2.18,0.63 2.28,1.39 3.17,0.73 z" }, { "id": "nz", "name": "New Zealand", "d": "m 960.63,588.88425 0.64,1.53 1.99,-1.5 0.81,1.57 0,1.57 -1.04,1.74 -1.83,2.8 -1.43,1.54 1.03,1.86 -2.16,0.05 -2.4,1.46 -0.75,2.57 -1.59,4.03 -2.2,1.8 -1.4,1.16 -2.58,-0.09 -1.82,-1.34 -3.05,-0.28 -0.47,-1.48 1.51,-2.96 3.53,-3.87 1.81,-0.73 2.01,-1.47 2.4,-2.01 1.68,-1.98 1.25,-2.81 1.06,-0.95 0.42,-2.07 1.97,-1.7 0.61,1.56 z m 4.46,-17.02 2.03,3.67 0.06,-2.38 1.27,0.95 0.42,2.65 2.26,1.15 1.89,0.28 1.6,-1.35 1.42,0.41 -0.68,3.15 -0.85,2.09 -2.14,-0.07 -0.75,1.1 0.26,1.56 -0.41,0.68 -1.06,1.97 -1.39,2.53 -2.17,1.49 -0.48,-0.98 -1.17,-0.54 1.62,-3.04 -0.92,-2.01 -3.02,-1.45 0.08,-1.31 2.03,-1.25 0.47,-2.74 -0.13,-2.28 -1.14,-2.34 0.08,-0.61 -1.34,-1.43 -2.21,-3.04 -1.17,-2.41 1.04,-0.27 1.53,1.89 2.18,0.89 0.79,3.04 z" }, { "id": "om", "name": "Oman", "d": "m 640.54,403.43425 -1.05,2.04 -1.27,-0.16 -0.58,0.71 -0.45,1.5 0.34,1.98 -0.26,0.36 -1.29,-0.01 -1.75,1.1 -0.27,1.43 -0.64,0.62 -1.74,-0.02 -1.1,0.74 0.01,1.18 -1.36,0.81 -1.55,-0.27 -1.88,0.98 -1.3,0.16 -0.92,-2.04 -2.19,-4.84 8.41,-2.96 1.87,-5.97 -1.29,-2.14 0.07,-1.22 0.82,-1.26 0.01,-1.25 1.27,-0.6 -0.5,-0.42 0.23,-2 1.43,-0.01 1.26,2.09 1.57,1.11 2.06,0.4 1.66,0.55 1.27,1.74 0.76,1 1,0.38 -0.01,0.67 -1.02,1.79 -0.45,0.84 -1.17,0.99 z m -6.92,-14.54 -0.37,0.56 -0.53,-1.06 0.82,-1.06 0.35,0.27 -0.27,1.29 z" }, { "id": "pa", "name": "Panama", "d": "m 257.13,443.46425 -0.93,-0.81 -0.6,-1.52 0.69,-0.75 -0.71,-0.19 -0.52,-0.93 -1.4,-0.78 -1.23,0.18 -0.56,0.98 -1.14,0.7 -0.61,0.1 -0.27,0.59 1.33,1.52 -0.76,0.36 -0.41,0.42 -1.3,0.14 -0.49,-1.68 -0.36,0.48 -0.93,-0.16 -0.56,-1.14 -1.15,-0.18 -0.73,-0.33 -1.2,0 -0.09,0.61 -0.32,-0.42 0.15,-0.56 0.23,-0.57 -0.11,-0.51 0.42,-0.34 -0.58,-0.42 -0.02,-1.13 1.09,-0.25 1,1.01 -0.06,0.6 1.12,0.12 0.27,-0.23 0.77,0.7 1.38,-0.21 1.19,-0.71 1.7,-0.57 0.96,-0.84 1.55,0.16 -0.11,0.28 1.57,0.1 1.25,0.49 0.91,0.84 1.06,0.78 -0.34,0.42 0.65,1.65 -0.53,0.84 -0.91,-0.2 z" }, { "id": "pe", "name": "Peru", "d": "m 280.38,513.39425 -0.75,1.51 -1.44,0.74 -2.81,-1.68 -0.25,-1.2 -5.55,-2.92 -5.03,-3.17 -2.17,-1.78 -1.16,-2.37 0.46,-0.83 -2.37,-3.75 -2.77,-5.24 -2.64,-5.62 -1.15,-1.29 -0.88,-2.06 -2.18,-1.84 -2,-1.13 0.91,-1.25 -1.36,-2.67 0.87,-1.95 2.24,-1.77 0.33,1.17 -0.8,0.66 0.07,1.02 1.16,-0.22 1.14,0.3 1.17,1.41 1.59,-1.15 0.53,-1.88 1.72,-2.43 3.37,-1.1 3.06,-2.92 0.87,-1.81 -0.39,-2.11 0.75,-0.27 1.86,1.32 0.89,1.32 1.3,0.72 1.65,2.92 2.09,0.35 1.55,-0.74 1.01,0.48 1.68,-0.24 2.15,1.31 -1.81,2.84 0.84,0.06 1.4,1.49 -2.53,-0.13 -0.37,0.42 -2.3,0.53 -3.2,1.91 -0.21,1.3 -0.71,0.98 0.28,1.51 -1.7,0.81 0,1.19 -0.74,0.51 1.17,2.53 1.56,1.72 -0.59,1.21 1.86,0.16 1.06,1.51 2.47,0.07 2.3,-1.66 -0.19,4.3 1.28,0.33 1.58,-0.49 2.43,4.58 -0.61,0.96 -0.13,2.02 -0.06,2.44 -1.1,1.44 0.51,1.07 -0.65,0.97 1.21,2.44 z" }, { "id": "pg", "name": "Papua New Guinea", "d": "m 912.57,482.67425 -0.79,0.28 -1.21,-1.08 -1.23,-1.78 -0.6,-2.13 0.39,-0.27 0.3,0.83 0.85,0.63 1.36,1.77 1.32,0.95 -0.39,0.8 z m -10.93,-3.75 -1.47,0.23 -0.44,0.79 -1.53,0.68 -1.44,0.66 -1.49,0 -2.3,-0.81 -1.6,-0.78 0.23,-0.87 2.51,0.41 1.53,-0.22 0.42,-1.34 0.4,-0.07 0.27,1.49 1.6,-0.21 0.79,-0.96 1.57,-1 -0.31,-1.65 1.68,-0.05 0.57,0.46 -0.06,1.55 -0.93,1.69 z m -13.43,5.35 2.5,1.84 1.82,2.99 1.61,-0.09 -0.11,1.25 2.17,0.48 -0.84,0.53 2.98,1.19 -0.31,0.82 -1.86,0.2 -0.69,-0.73 -2.41,-0.32 -2.83,-0.43 -2.18,-1.8 -1.59,-1.55 -1.46,-2.46 -3.66,-1.23 -2.38,0.8 -1.71,0.93 0.36,2.08 -2.2,0.97 -1.57,-0.47 -2.9,-0.12 -0.05,-9.16 -0.05,-9.1 4.87,1.92 5.18,1.6 1.93,1.43 1.56,1.41 0.43,1.65 4.67,1.73 0.68,1.49 -2.58,0.3 0.62,1.85 z m 16.67,-8.09 -0.88,0.74 -0.53,-1.65 -0.65,-1.08 -1.27,-0.91 -1.6,-1.19 -2.02,-0.82 0.78,-0.67 1.51,0.78 0.95,0.61 1.18,0.67 1.12,1.17 1.07,0.89 0.34,1.46 z" }, { "id": "ph", "name": "Philippines", "d": "m 829.84,440.11425 0.29,1.87 0.17,1.58 -0.96,2.57 -1.02,-2.86 -1.31,1.42 0.9,2.06 -0.8,1.31 -3.3,-1.63 -0.79,-2.03 0.86,-1.33 -1.78,-1.33 -0.88,1.17 -1.32,-0.11 -2.08,1.57 -0.46,-0.82 1.1,-2.37 1.77,-0.79 1.53,-1.06 0.99,1.27 2.13,-0.77 0.46,-1.26 1.98,-0.08 -0.17,-2.18 2.27,1.34 0.24,1.42 0.18,1.04 z m -6.71,-5.26 -1.01,0.93 -0.88,1.79 -0.88,0.84 -1.73,-1.95 0.58,-0.76 0.7,-0.79 0.31,-1.76 1.55,-0.17 -0.45,1.91 2.08,-2.74 -0.27,2.7 z m -15.36,2.72 -3.73,2.67 1.38,-1.97 2.03,-1.74 1.68,-1.96 1.47,-2.82 0.5,2.31 -1.85,1.56 -1.48,1.95 z m 9.48,-7.3 1.68,0.88 1.78,0 -0.05,1.19 -1.3,1.2 -1.78,0.85 -0.1,-1.32 0.2,-1.45 -0.43,-1.35 z m 10.14,-0.77 0.79,3.18 -2.16,-0.75 0.06,0.95 0.69,1.75 -1.33,0.63 -0.12,-1.99 -0.84,-0.15 -0.44,-1.72 1.65,0.23 -0.04,-1.08 -1.71,-2.18 2.69,0.06 0.76,1.07 z m -11.14,-2.59 -0.74,2.47 -1.2,-1.42 -1.43,-2.18 2.4,0.1 0.97,1.03 z m -0.58,-15.74 1.73,0.84 0.86,-0.76 0.25,0.75 -0.46,1.22 0.96,2.09 -0.74,2.42 -1.65,0.96 -0.44,2.33 0.63,2.29 1.49,0.32 1.24,-0.34 3.5,1.59 -0.27,1.56 0.92,0.69 -0.29,1.32 -2.18,-1.4 -1.04,-1.5 -0.72,1.05 -1.79,-1.72 -2.55,0.42 -1.4,-0.63 0.14,-1.19 0.88,-0.73 -0.84,-0.67 -0.36,1.04 -1.38,-1.65 -0.42,-1.26 -0.1,-2.77 1.13,0.96 0.29,-4.55 0.91,-2.66 1.7,-0.02 z" }, { "id": "pl", "name": "Poland", "d": "m 517.61,297.22425 -1.15,-2.86 0.22,-1.56 -0.7,-2.45 -1.01,-1.65 0.78,-1.25 -0.66,-2.39 1.92,-1.39 4.37,-2.22 3.54,-1.64 2.79,0.82 0.21,1.18 2.71,0.06 3.45,0.55 5.16,-0.08 1.44,0.52 0.67,1.46 0.12,2.09 0.78,1.78 -0.02,1.85 -1.68,0.94 0.87,2.12 0.05,2.01 1.41,3.89 -0.3,1.24 -1.39,0.51 -2.55,3.61 0.72,1.92 -0.61,-0.25 -2.66,-1.64 -2.02,0.6 -1.32,-0.44 -1.66,0.92 -1.41,-1.52 -1.16,0.58 -0.16,-0.26 -1.29,-2.13 -2.08,-0.26 -0.27,-1.37 -1.92,-0.49 -0.42,1.13 -1.52,-0.9 0.17,-1.21 -2.09,-0.39 z" }, { "id": "pk", "name": "Pakistan", "d": "m 686.24,352.01425 2.07,1.63 0.83,2.66 4.61,1.39 -2.71,2.86 -3.12,0.51 -4.26,-0.83 -1.37,1.46 0.99,2.95 0.97,2.25 2.27,1.63 -2.39,1.91 0.04,2.33 -2.72,3.24 -1.76,3.26 -2.93,3.32 -3.26,-0.24 -3.09,3.3 1.84,1.4 0.32,2.39 1.57,1.56 0.56,2.64 -6.17,-0.01 -1.87,2.04 -2.05,-0.77 -0.84,-2.2 -2.17,-2.34 -5.16,0.58 -4.56,0.05 -3.95,0.44 1.06,-3.6 4.04,-1.61 -0.23,-1.44 -1.34,-0.51 -0.08,-2.77 -2.68,-1.4 -1.13,-1.93 -1.38,-1.68 4.69,1.64 2.81,-0.48 1.67,0.4 0.57,-0.7 1.95,0.28 3.65,-1.33 0.1,-2.75 1.56,-1.84 2.09,0 0.3,-0.91 2.15,-0.43 1.03,0.3 1.1,-0.92 -0.15,-1.98 1.19,-2 1.78,-0.85 -1.1,-2.22 2.67,0.11 0.77,-1.22 -0.12,-1.3 1.4,-1.43 -0.33,-1.7 -0.66,-1.46 1.64,-1.51 3.01,-0.73 3.22,-0.4 1.42,-0.65 z" }, { "id": "pr", "name": "Puerto Rico", "d": "m 289.66,411.14425 1.43,0.26 0.51,0.58 -0.72,0.74 -2.11,-0.02 -1.64,0.1 -0.16,-1.25 0.39,-0.43 z" }, { "id": "ps", "name": "Palestinian Territories", "d": "m 575.17,368.12425 0,2.01 -0.42,0.96 -1.32,0.45 0.13,-0.86 0.71,-0.46 -0.7,-0.36 0.58,-2.2 z" }, { "id": "pt", "name": "Portugal", "d": "m 450.17,334.81425 1.02,-0.95 1.14,-0.55 0.71,1.84 1.65,-0.01 0.48,-0.47 1.64,0.13 0.78,1.88 -1.3,1 -0.03,2.88 -0.46,0.53 -0.11,1.72 -1.21,0.3 1.12,2.17 -0.77,2.35 0.96,1.06 -0.38,0.96 -1.04,1.32 0.23,1.16 -1.12,0.91 -1.48,-0.49 -1.45,0.38 0.43,-2.74 -0.26,-2.18 -1.26,-0.33 -0.67,-1.35 0.23,-2.36 1.11,-1.31 0.2,-1.47 0.59,-2.21 -0.07,-1.57 -0.56,-1.34 z" }, { "id": "py", "name": "Paraguay", "d": "m 299.74,527.24425 1.11,-3.59 0.07,-1.6 1.34,-2.62 4.89,-0.86 2.6,0.05 2.62,1.51 0.04,0.91 0.83,1.66 -0.18,4.06 2.96,0.58 1.14,-0.59 1.89,0.82 0.53,0.9 0.26,2.77 0.33,1.18 1.04,0.13 1.05,-0.49 1.01,0.55 0,1.68 -0.38,1.82 -0.55,1.78 -0.46,2.75 -2.54,2.4 -2.22,0.5 -3.15,-0.48 -2.82,-0.85 2.76,-4.73 -0.41,-1.37 -2.88,-1.2 -3.43,-2.26 -2.29,-0.46 z" }, { "id": "qa", "name": "Qatar", "d": "m 617.97,392.41425 -0.19,-2.24 0.76,-1.62 0.76,-0.34 0.85,0.97 0.05,1.81 -0.61,1.81 -0.78,0.22 z" }, { "id": "ro", "name": "Romania", "d": "m 539.18,311.11425 1.21,-0.89 1.74,0.46 1.79,0.02 1.3,1.01 0.96,-0.64 2.07,-0.4 0.71,-0.98 1.18,0.01 0.85,0.4 0.87,1.24 0.89,1.75 1.62,2.44 0.09,1.79 -0.3,1.72 0.51,1.83 1.25,0.73 1.31,-0.64 1.28,0.68 0.06,1.03 -1.36,0.84 -0.85,-0.36 -0.78,4.71 -1.65,-0.41 -2.04,-1.41 -3.3,0.9 -1.39,0.99 -4.12,-0.2 -2.16,-0.61 -1.08,0.29 -0.81,-1.6 -0.51,-0.68 0.65,-0.66 -0.7,-0.49 -0.88,0.88 -1.63,-1.14 -0.22,-1.63 -1.71,-0.94 -0.31,-1.27 -1.52,-1.58 2.25,-0.76 1.69,-2.76 1.33,-2.8 z" }, { "id": "rs", "name": "Serbia", "d": "m 534.03,321.15425 1.71,0.94 0.22,1.63 1.63,1.14 0.88,-0.88 0.7,0.49 -0.65,0.66 0.51,0.68 -0.69,0.88 0.25,1.42 1.36,1.66 -1.07,1.19 -0.47,1.21 0.31,0.45 -0.47,0.54 -1.29,0.06 -0.96,0.22 -0.09,-0.28 0.33,-0.45 0.32,-0.93 -0.4,0.02 -0.55,-0.7 -0.46,-0.18 -0.36,-0.61 -0.53,-0.24 -0.4,-0.54 -0.5,0.22 -0.39,1.26 -0.67,0.28 0.23,-0.33 -1.07,-0.79 -0.92,-0.41 -0.41,-0.54 -0.74,-0.66 0.66,-0.17 0.41,-1.82 -1.35,-1.5 0.7,-1.72 -1.02,0.01 1.08,-1.49 -0.89,-1.14 -0.68,-1.55 2.15,-1.05 1.75,0.17 1.52,1.58 z" }, { "id": "ru", "name": "Russia", "d": "m 1008.52,216.00425 -2.78,2.97 -4.6,0.7 -0.07,6.46 -1.12,1.35 -2.63,-0.19 -2.14,-2.26 -3.73,-1.92 -0.63,-2.89 -2.85,-1.1 -3.19,0.87 -1.52,-2.37 0.61,-2.55 -3.36,1.64 1.26,3.19 -1.59,2.83 -0.02,0.04 -3.6,2.89 -3.63,-0.48 2.53,3.44 1.67,5.2 1.29,1.67 0.33,2.53 -0.72,1.6 -5.23,-1.32 -7.84,4.51 -2.49,0.69 -4.29,4.1 -4.07,3.5 -1.03,2.55 -4.01,-3.9 -7.31,4.42 -1.28,-2.08 -2.7,2.39 -3.75,-0.76 -0.9,3.63 -3.36,5.22 0.1,2.14 3.19,1.17 -0.38,7.46 -2.6,0.19 -1.2,4.15 1.17,2.1 -4.9,2.47 -0.97,5.4 -4.18,1.14 -0.84,4.66 -4.04,4.18 -1.04,-3.08 -1.2,-6.69 -1.56,-10.65 1.35,-6.95 2.37,-3.07 0.15,-2.44 4.36,-1.18 5.01,-6.78 4.83,-5.73 5.04,-4.57 2.25,-8.37 -3.41,0.51 -1.68,4.92 -7.11,6.36 -2.3,-7.14 -7.24,2 -7.02,9.56 2.32,3.38 -6.26,1.42 -4.33,0.56 0.2,-3.95 -4.36,-0.84 -3.47,2.7 -8.57,-0.94 -9.22,1.62 -9.08,10.33 -10.75,11.78 4.42,0.61 1.38,3 2.72,1.05 1.79,-2.38 3.08,0.31 4.05,5.19 0.09,3.92 -2.19,4.51 -0.24,5.27 -1.26,6.85 -4.23,6.01 -0.94,2.82 -3.81,4.66 -3.78,4.53 -1.81,2.28 -3.74,2.25 -1.77,0.05 -1.76,-1.86 -3.76,2.79 -0.44,1.26 -0.39,-0.66 -0.02,-1.93 1.43,-0.1 0.4,-4.55 -0.74,-3.36 2.41,-1.4 3.4,0.7 1.89,-3.89 0.96,-4.46 1.09,-1.51 1.47,-3.76 -4.63,1.24 -2.43,1.65 -4.26,0 -1.13,-3.95 -3.32,-3.03 -4.88,-1.38 -1.04,-4.28 -0.98,-2.73 -1.05,-1.94 -1.73,-4.61 -2.46,-1.71 -4.2,-1.39 -3.72,0.13 -3.48,0.84 -2.32,2.31 1.54,1.1 0.04,2.52 -1.56,1.45 -2.53,4.72 0.03,1.93 -3.95,2.74 -3.37,-1.63 -3.35,0.36 -1.47,-1.46 -1.68,-0.47 -4.11,3.06 -3.69,0.71 -2.58,1.06 -3.53,-0.7 -2.6,0.04 -1.7,-2.2 -2.75,-2.09 -2.81,-0.58 -3.55,0.57 -2.65,0.81 -3.98,-1.84 -0.53,-3.32 -3.3,-1.15 -2.54,-0.53 -3.14,-1.87 -2.9,4.66 1.14,2.6 -2.73,3.03 -4.05,-1.09 -2.8,-0.16 -1.87,-2.04 -2.92,-0.06 -2.44,-1.35 -4.26,2.07 -5.35,3.74 -2.96,0.74 -1.1,0.35 -1.49,-2.63 -3.61,0.58 -1.19,-1.84 -1.96,-0.85 -1.35,-2.55 -1.55,-0.8 -4.03,1.14 -3.86,-2.57 -1.49,2.33 -6.27,-11.58 -3.58,-3.66 1.03,-1.5 -7.03,4.49 -2.69,0.27 0.23,-2.58 -3.6,-1.63 -2.93,1.17 -0.88,-5.01 -5.04,-1.06 -2.52,2.03 -7.02,1.79 -1.37,1.19 -10.49,1.66 -1.29,1.62 2.02,3.21 -2.69,1.2 0.53,1.25 -2.69,2.22 4.54,3.1 -0.7,2.11 -3.94,-0.19 -0.81,1.31 -3.59,-2.29 -4.45,0.09 -2.98,1.87 -3.32,-1.79 -6.18,-3.1 -4.38,0.12 -5.79,4.85 -0.35,3.19 -2.88,-2.53 -2.24,4.77 0.82,0.87 -1.62,3.21 2.38,2.84 2.08,-0.12 1.79,2.76 -0.28,2.1 1.42,0.66 -1.28,2.39 -2.72,0.66 -2.79,4.09 2.55,3.7 -0.28,2.59 3.06,4.46 -1.67,1.51 -0.48,0.95 -1.24,-0.25 -1.93,-2.27 -0.79,-0.13 -1.76,-0.87 -0.86,-1.55 -2.62,-0.79 -1.7,0.6 -0.49,-0.71 -3.82,-1.83 -4.13,-0.62 -2.37,-0.66 -0.34,0.45 -3.57,-3.27 -3.2,-1.48 -2.42,-2.32 2.04,-0.64 2.33,-3.35 -1.57,-1.6 4.13,-1.67 -0.07,-0.9 -2.52,0.66 0.09,-1.83 1.45,-1.16 2.71,-0.31 0.44,-1.4 -0.62,-2.33 1.14,-2.23 -0.03,-1.26 -4.13,-1.41 -1.64,0.05 -1.73,-2.04 -2.15,0.69 -3.56,-1.54 0.06,-0.87 -1,-1.93 -2.24,-0.22 -0.23,-1.39 0.7,-0.91 -1.79,-2.58 -2.91,0.44 -0.85,-0.23 -0.71,1.04 -1.05,-0.18 -0.69,-2.94 -0.66,-1.54 0.54,-0.44 2.26,0.16 1.09,-1.02 -0.81,-1.25 -1.89,-0.83 0.17,-0.86 -1.14,-0.87 -1.76,-3.15 0.6,-1.31 -0.27,-2.31 -2.74,-1.18 -1.47,0.59 -0.4,-1.24 -2.95,-1.26 -0.9,-2.99 -0.24,-2.49 -1.35,-1.19 1.2,-1.66 -0.83,-4.96 2,-3.13 -0.42,-0.96 3.19,-3.07 -2.94,-2.68 6,-7.41 2.6,-3.45 1.05,-3.1 -4.15,-4.26 1.15,-4.15 -2.52,-4.85 1.89,-5.76 -3.26,-7.96 2.59,-5.48 -4.29,-4.99 0.41,-5.4 2.26,-0.72 4.77,-3.19 2.89,-2.81 4.61,4.86 7.68,1.88 10.59,8.65 2.15,3.51 0.19,4.8 -3.11,3.69 -4.58,1.85 -12.52,-5.31 -2.06,0.9 4.57,5.1 0.18,3.15 0.18,6.75 3.61,1.97 2.19,1.66 0.36,-3.11 -1.69,-2.8 1.78,-2.51 6.78,4.1 2.36,-1.59 -1.89,-4.88 6.53,-6.74 2.59,0.4 2.62,2.43 1.63,-4.81 -2.34,-4.28 1.37,-4.41 -2.06,-4.69 7.84,2.44 1.6,4.18 -3.55,0.91 0.02,4.04 2.21,2.44 4.33,-1.54 0.69,-4.61 5.86,-3.52 9.79,-6.54 2.11,0.38 -2.76,4.64 3.48,0.78 2.01,-2.58 5.25,-0.21 4.16,-3.19 3.2,4.62 3.19,-5.09 -2.94,-4.58 1.46,-2.66 8.28,2.44 3.88,2.49 10.16,8.8 1.88,-3.97 -2.85,-4.11 -0.08,-1.68 -3.38,-0.78 0.92,-3.83 -1.5,-6.49 -0.08,-2.74 5.17,-7.99 1.84,-8.42 2.08,-1.88 7.42,2.51 0.58,5.18 -2.66,7.28 1.74,2.78 0.9,5.94 -0.64,11.07 3.09,4.73 -1.2,5.01 -5.49,10.2 3.21,1.02 1.12,-2.51 3.08,-1.82 0.74,-3.55 2.43,-3.49 -1.63,-4.26 1.31,-5.08 -3.07,-0.64 -0.67,-4.42 2.24,-8.28 -3.64,-7.03 5.02,-6.04 -0.65,-6.62 1.4,-0.22 1.47,5.19 -1.11,8.67 3,1.59 -1.28,-6.37 4.69,-3.58 5.82,-0.49 5.18,5.18 -2.49,-7.62 -0.28,-10.28 4.88,-2.02 6.74,0.44 6.08,-1.32 -2.28,-5.38 3.25,-7.02 3.22,-0.3 5.45,-5.51 7.4,-1.51 0.94,-3.15 7.36,-1.08 2.29,2.61 6.29,-6.24 5.15,0.2 0.77,-5.24 2.68,-5.33 6.62,-5.31 4.81,4.21 -3.82,3.13 6.35,1.92 0.76,6.03 2.56,-2.94 8.2,0.16 6.32,5.84 2.25,4.35 -0.7,5.85 -3.1,3.24 -7.37,5.92 -2.11,3.08 3.48,1.43 4.15,2.55 2.52,-1.91 1.43,6.39 1.23,-2.56 4.48,-1.57 9,1.65 0.68,4.58 11.72,1.43 0.16,-7.47 5.95,1.74 4.48,-0.05 4.53,5.14 1.29,6.04 -1.66,3.84 3.52,6.98 4.41,3.49 2.71,-9.18 4.5,4 4.78,-2.38 5.43,2.72 2.07,-2.47 4.59,1.24 -2.02,-8.4 3.7,-4.07 25.32,6.06 2.39,5.35 7.34,6.65 11.32,-1.62 5.58,1.41 2.33,3.5 -0.34,6.02 3.45,2.29 3.75,-1.64 4.97,-0.21 5.29,1.57 5.31,-0.89 4.88,6.99 3.47,-2.48 -2.27,-5.07 1.25,-3.62 8.95,2.29 5.83,-0.49 8.06,3.84 3.92,3.44 6.87,5.86 7.35,7.34 -0.24,4.44 1.89,1.74 -0.65,-5.15 7.61,1.07 5.55,6.53 z m -127.43,90.5 -2.82,-7.68 -1.16,-4.51 0.07,-4.5 -0.97,-4.5 -0.73,-3.15 -1.25,0.67 1.11,2.21 -2.59,2.17 -0.25,6.3 1.64,4.41 -0.12,5.85 -0.65,3.24 0.32,4.54 -0.31,4.01 0.52,3.4 1.84,-3.13 2.13,2.44 0.08,-2.84 -2.73,-4.23 1.72,-6.11 4.15,1.41 z m -343.02,-27.48 -2.94,-0.86 -3.87,1.58 -0.64,2.13 3.45,0.55 5.16,-0.07 -0.22,-1.23 0.3,-1.33 -1.24,-0.77 z m 442.13,-100.12 3.66,-0.52 2.89,-2.06 0.24,-1.19 -4.06,-2.51 -2.38,-0.02 -0.36,0.37 -3.57,3.64 0.5,2.73 3.08,-0.44 z m -109.88,-27.09 -2.66,3.92 0.49,0.52 5.75,1.08 4.25,-0.07 -0.34,-2.57 -3.98,-3.81 -3.51,0.93 z m 24.57,-9.53 3.24,-4.25 -7.04,-2.88 -5.23,-1.68 -0.67,3.59 5.21,4.27 4.49,0.95 z m -25.13,-1.69 10.33,0.3 2.21,-8.14 -10.13,-6.07 -7.4,-0.51 -3.7,2.18 -1.51,7.75 5.55,7.01 4.65,-2.52 z m -247.12,25.94 -2.87,1.96 0.41,4.83 5.08,2.35 0.74,3.82 9.16,1.1 1.66,-0.74 -5.36,-7.11 -0.57,-7.52 4.39,-9.14 4.18,-9.82 8.71,-10.17 8.56,-5.34 9.93,-5.74 1.88,-3.71 -1.95,-4.83 -5.46,1.6 -4.8,4.49 -9.33,2.22 -9.26,7.41 -6.27,5.85 0.76,4.87 -6.71,9.03 2.58,1.22 -5.56,8.27 0.1,5.1 z m 147.48,-67.940005 0.83,-5.72 -7.11,-8.34 -2.11,-0.98 -2.3,1.7 -5.12,18.600005 15.81,-5.260005 z m -164.23,-29.31 3.04,3.88 3.28,-2.69 0.39,-2.72 2.52,-1.27 3.76,-2.23 1.08,-2.62 -4.16,-3.85 -2.64,2.9 -1.61,4.12 -0.57,-4.65 -4.26,0.21 -5.47,3.14 6.24,0.52 -1.6,5.26 z m 131.25,13.04 4.65,5.73 7.81,4.2 6.12,-1.8 0.69,-13.62 -6.46,-16.04 -5.45,-9.02 -6.07,4.11 -7.28,11.83 3.83,3.27 2.16,11.34 z" }, { "id": "rw", "name": "Rwanda", "d": "m 560.79,466.80425 1.12,1.57 -0.17,1.64 -0.8,0.35 -1.49,-0.18 -0.86,1.59 -1.71,-0.22 0.26,-1.53 0.39,-0.21 0.1,-1.66 0.81,-0.78 0.68,0.29 z" }, { "id": "sa", "name": "Saudi Arabia", "d": "m 595.45,417.47425 -0.36,-1.24 -0.85,-0.88 -0.22,-1.17 -1.44,-1.04 -1.5,-2.46 -0.79,-2.41 -1.94,-2.04 -1.25,-0.48 -1.86,-2.85 -0.32,-2.08 0.12,-1.79 -1.61,-3.36 -1.31,-1.19 -1.52,-0.63 -0.92,-1.76 0.15,-0.69 -0.78,-1.6 -0.82,-0.69 -1.09,-2.32 -1.71,-2.52 -1.43,-2.16 -1.39,0.01 0.43,-1.74 0.13,-1.11 0.34,-1.28 3.12,0.51 1.22,-0.98 0.67,-1.16 2.14,-0.44 0.46,-1.09 0.93,-0.54 -2.8,-3.26 5.62,-1.65 0.53,-0.49 3.38,0.89 4.18,2.29 7.9,6.49 5.21,0.26 2.5,0.31 0.7,1.51 1.98,-0.08 1.1,2.73 1.38,0.71 0.48,1.11 1.91,1.31 0.17,1.29 -0.28,1.03 0.36,1.04 0.8,0.87 0.38,1.01 0.42,0.75 0.84,0.61 0.78,-0.22 0.53,1.17 0.11,0.71 1.08,3.08 8.42,1.52 0.57,-0.64 1.28,2.14 -1.87,5.97 -8.41,2.96 -8.08,1.13 -2.62,1.32 -2.01,3.07 -1.31,0.48 -0.7,-0.97 -1.07,0.15 -2.71,-0.29 -0.52,-0.3 -3.23,0.07 -0.76,0.27 -1.15,-0.76 -0.75,1.43 0.29,1.23 z" }, { "id": "sb", "name": "Solomon Islands", "d": "m 930.06,493.00425 0.78,0.97 -1.96,-0.02 -1.07,-1.74 1.67,0.69 0.58,0.1 z m -3.55,-1.73 -1.09,0.06 -1.72,-0.29 -0.59,-0.44 0.18,-1.12 1.85,0.44 0.91,0.59 0.46,0.76 z m 2.32,-0.77 -0.42,0.52 -2.08,-2.45 -0.58,-1.68 0.95,0 1.01,2.25 1.12,1.36 z m -5.06,-3.56 0.12,0.57 -2.2,-1.19 -1.54,-1.01 -1.05,-0.94 0.42,-0.29 1.29,0.67 2.3,1.29 0.66,0.9 z m -6.55,-2.78 -0.56,0.16 -1.23,-0.64 -1.15,-1.15 0.14,-0.47 1.67,1.18 1.13,0.92 z" }, { "id": "sd", "name": "Sudan", "d": "m 570.73,437.15425 -0.39,-0.05 0.05,-1.41 -0.34,-0.97 -1.44,-1.12 -0.34,-2.05 0.34,-2.1 -1.3,-0.19 -0.19,0.63 -1.69,0.15 0.68,0.83 0.24,1.71 -1.54,1.56 -1.4,2.04 -1.44,0.29 -2.36,-1.65 -1.06,0.58 -0.29,0.83 -1.44,0.53 -0.1,0.58 -2.79,0 -0.39,-0.58 -2.02,-0.1 -1.01,0.49 -0.77,-0.25 -1.44,-1.65 -0.48,-0.77 -2.03,0.39 -0.77,1.31 -0.72,2.52 -0.96,0.53 -0.86,0.31 -0.23,-0.14 -0.97,-0.81 -0.18,-0.87 0.45,-1.18 0,-1.15 -1.62,-1.77 -0.32,-1.22 0.03,-0.69 -1.03,-0.83 -0.03,-1.66 -0.58,-1.1 -0.99,0.17 0.28,-1.05 0.73,-1.2 -0.32,-1.18 0.92,-0.88 -0.58,-0.67 0.74,-1.78 1.28,-2.13 2.42,0.2 -0.14,-11.61 0.04,-1.24 3.22,-0.01 0,-5.96 11.27,0 10.88,0 11.12,0 0.9,2.94 -0.61,0.54 0.41,3.06 1.03,3.52 1.06,0.73 1.54,1.08 -1.42,1.67 -2.07,0.48 -0.88,0.9 -0.27,1.93 -1.21,4.25 0.3,1.15 -0.45,2.47 -1.14,2.81 -1.69,1.42 -1.2,2.17 -0.29,1.16 -1.32,0.8 -0.83,2.96 z" }, { "id": "se", "name": "Sweden", "d": "m 537.7,217.74425 -2.72,4.69 0.44,4.02 -4.46,5.13 -5.41,5.34 -2.05,8.41 2,4.07 2.68,3.14 -2.57,6.23 -2.92,1.26 -1.07,8.84 -1.59,4.76 -3.4,-0.49 -1.59,3.95 -3.25,0.23 -0.89,-4.71 -2.35,-5.81 -2.13,-7.5 1.24,-3.15 2.33,-3.81 0.93,-6.73 -1.79,-2.98 -0.18,-8.04 1.83,-5.91 2.78,0.11 0.97,-2.55 -1.02,-2.23 4.35,-9.5 2.81,-7.87 1.85,-5.24 2.69,0.02 0.75,-4.21 5.28,1.22 0.41,-5.08 1.74,-0.33 3.74,3.81 4.37,5.15 0.08,11.12 0.94,2.7 z" }, { "id": "si", "name": "Slovenia", "d": "m 514.21,316.76425 2.32,0.31 1.42,-0.92 2.45,-0.1 0.53,-0.69 0.47,0.05 0.55,1.37 -2.23,1.08 -0.28,1.62 -0.97,0.41 0.01,1.12 -1.1,-0.08 -0.95,-0.65 -0.52,0.68 -1.95,-0.14 0.62,-0.36 -0.67,-1.71 z" }, { "id": "sj", "name": "Svalbard and Jan Mayen", "d": "m 544.83,104.74425 -6.26,5.36 -4.95,-3.02 1.94,-3.42 -1.69,-4.340005 5.81,-2.78 1.11,5.180005 4.04,3.02 z m -18.15,-26.680005 9.23,11.29 -7.06,5.66 -1.56,10.090005 -2.46,2.49 -1.33,10.51 -3.38,0.48 -6.03,-7.64 2.54,-4.62 -4.2,-3.86 -5.46,-11.820005 -2.18,-11.79 7.64,-5.69 1.54,5.56 3.99,-0.22 1.06,-5.43 4.12,-0.56 3.54,5.55 z m 20.17,-11.46 5.5,5.8 -4.16,8.52 -8.13,1.81 -8.27,-2.56 -0.5,-4.32 -4.02,-0.28 -3.07,-7.48 8.66,-4.72 4.07,4.08 2.84,-5.09 7.08,4.24 z" }, { "id": "sk", "name": "Slovakia", "d": "m 528.36,304.27425 0.16,0.26 1.16,-0.58 1.41,1.52 1.66,-0.92 1.32,0.44 2.02,-0.6 2.66,1.64 -0.77,1.11 -0.55,1.71 -0.6,0.43 -3,-1.28 -0.92,0.25 -0.66,1 -1.32,0.52 -0.3,-0.27 -1.36,0.65 -1.12,0.13 -0.22,0.84 -2.36,0.51 -1.03,-0.46 -1.43,-1.07 -0.28,-1.45 0.23,-0.54 0.39,-0.93 1.25,0.07 0.95,-0.44 0.08,-0.39 0.54,-0.21 0.18,-0.97 0.64,-0.19 0.44,-0.77 z" }, { "id": "sl", "name": "Sierra Leone", "d": "m 443.43,444.69425 -0.76,-0.21 -2.01,-1.13 -1.46,-1.5 -0.49,-1.03 -0.35,-2.08 1.5,-1.24 0.32,-0.79 0.48,-0.61 0.78,-0.06 0.65,-0.53 2.24,0 0.78,1.01 0.61,1.19 -0.09,0.82 0.45,0.74 -0.03,1.03 0.77,-0.16 -1.31,1.31 -1.26,1.53 -0.15,0.81 z" }, { "id": "sn", "name": "Senegal", "d": "m 428.64,425.41425 -1.16,-2.24 -1.4,-1.02 1.24,-0.55 1.36,-2.03 0.66,-1.49 0.96,-0.93 1.4,0.25 1.36,-0.63 1.57,-0.03 1.34,0.85 1.86,0.77 1.7,2.13 1.85,1.98 0.13,1.79 0.55,1.64 1.05,0.81 0.24,1.1 -0.13,0.89 -0.41,0.16 -1.52,-0.22 -0.21,0.31 -0.62,0.07 -2.02,-0.7 -1.35,-0.03 -5.18,-0.12 -0.75,0.32 -0.93,-0.09 -1.49,0.47 -0.46,-2.19 2.55,0.06 0.68,-0.4 0.5,-0.03 1.04,-0.66 1.2,0.61 1.22,0.05 1.21,-0.65 -0.56,-0.82 -0.93,0.48 -0.87,-0.01 -1.1,-0.71 -0.89,0.05 -0.64,0.67 z" }, { "id": "so", "name": "Somalia", "d": "m 618.88,430.68425 -0.07,-0.79 -1.06,0.01 -1.33,0.98 -1.49,0.28 -1.29,0.42 -0.89,0.06 -1.6,0.1 -1,0.52 -1.39,0.19 -2.47,0.88 -3.05,0.33 -2.65,0.73 -1.39,-0.01 -1.26,-1.19 -0.55,-1.17 -0.91,-0.53 -1.04,1.52 -0.61,1.01 1.04,1.56 1.03,1.36 1.07,1.01 9.17,3.34 2.36,-0.02 -7.93,8.42 -3.65,0.12 -2.5,1.97 -1.79,0.05 -0.77,0.88 -2.45,3.17 0.03,10.15 1.66,2.29 0.63,-0.66 0.65,-1.46 3.07,-3.38 2.61,-2.12 4.2,-2.76 2.8,-2.26 3.3,-3.81 2.39,-3.13 2.41,-4.1 1.73,-3.59 1.35,-3.15 0.79,-3.05 0.6,-1.02 -0.01,-1.5 z" }, { "id": "sr", "name": "Suriname", "d": "m 315.27,446.97425 3.36,0.56 0.3,-0.51 2.27,-0.2 3.01,0.76 -1.46,2.4 0.22,1.91 1.11,1.66 -0.49,1.2 -0.25,1.27 -0.72,1.17 -1.6,-0.59 -1.33,0.29 -1.13,-0.25 -0.28,0.81 0.47,0.55 -0.25,0.57 -1.53,-0.23 -1.71,-2.42 -0.37,-1.57 -0.89,-0.01 -1.25,-2.02 0.52,-1.45 -0.15,-0.65 1.7,-0.73 z" }, { "id": "ss", "name": "South Sudan", "d": "m 570.73,437.15425 0.03,2.2 -0.42,0.86 -1.48,0.07 -0.96,1.61 1.72,0.2 1.42,1.37 0.5,1.12 1.28,0.65 1.65,3.05 -1.9,1.84 -1.72,1.67 -1.73,1.28 -1.97,0 -2.26,0.65 -1.78,-0.63 -1.15,0.77 -2.47,-1.86 -0.67,-1.19 -1.56,0.59 -1.3,-0.19 -0.75,0.47 -1.26,-0.33 -1.69,-2.31 -0.45,-0.89 -2.1,-1.11 -0.71,-1.68 -1.17,-1.21 -1.88,-1.46 -0.03,-0.92 -1.53,-1.13 -1.91,-1.1 0.86,-0.31 0.96,-0.53 0.72,-2.52 0.77,-1.31 2.03,-0.39 0.48,0.77 1.44,1.65 0.77,0.25 1.01,-0.49 2.02,0.1 0.39,0.58 2.79,0 0.1,-0.58 1.44,-0.53 0.29,-0.83 1.06,-0.58 2.36,1.65 1.44,-0.29 1.4,-2.04 1.54,-1.56 -0.24,-1.71 -0.68,-0.83 1.69,-0.15 0.19,-0.63 1.3,0.19 -0.34,2.1 0.34,2.05 1.44,1.12 0.34,0.97 -0.05,1.41 z" }, { "id": "sv", "name": "El Salvador", "d": "m 229.34,426.01425 -0.31,0.67 -1.62,-0.04 -1.01,-0.27 -1.16,-0.57 -1.56,-0.18 -0.79,-0.62 0.09,-0.42 0.96,-0.72 0.52,-0.32 -0.15,-0.34 0.66,-0.17 0.83,0.24 0.6,0.57 0.85,0.46 0.1,0.39 1.23,-0.34 0.58,0.2 0.38,0.31 z" }, { "id": "sy", "name": "Syria", "d": "m 584.27,364.85425 -5.49,3.54 -3.12,-1.32 -0.06,-0.02 0.38,-0.5 -0.04,-1.37 0.69,-1.83 1.53,-1.27 -0.46,-1.32 -1.26,-0.18 -0.26,-2.61 0.68,-1.41 0.75,-0.75 0.75,-0.76 0.16,-1.94 0.91,0.68 3.09,-0.97 1.49,0.65 2.31,-0.01 3.22,-1.31 1.52,0.06 3.19,-0.54 -1.44,2.18 -1.54,0.86 0.27,2.52 -1.06,4.12 z" }, { "id": "sz", "name": "Swaziland", "d": "m 565.43,540.99425 -0.57,1.39 -1.64,0.33 -1.68,-1.69 -0.02,-1.08 0.76,-1.17 0.27,-0.9 0.81,-0.22 1.41,0.57 0.42,1.39 z" }, { "id": "td", "name": "Chad", "d": "m 516.15,427.51425 0.28,-1.34 -1.8,-0.07 0.01,-1.85 -1.17,-1.06 1.21,-3.8 3.58,-2.74 0.14,-3.79 1.08,-5.98 0.61,-1.28 -1.16,-1.02 -0.05,-0.95 -1.05,-0.78 -0.69,-4.67 2.83,-1.66 11.19,5.77 11.18,5.7 0.14,11.61 -2.42,-0.2 -1.28,2.13 -0.74,1.78 0.58,0.67 -0.92,0.88 0.32,1.18 -0.73,1.2 -0.28,1.05 0.99,-0.17 0.58,1.1 0.03,1.66 1.03,0.83 -0.03,0.69 -1.77,0.49 -1.43,1.14 -2.02,3.09 -2.64,1.31 -2.71,-0.18 -0.79,0.26 0.28,0.99 -1.47,0.99 -1.19,1.1 -3.53,1.07 -0.7,-0.63 -0.46,-0.06 -0.52,0.72 -2.32,0.22 0.44,-0.77 -0.88,-1.93 -0.4,-1.17 -1.22,-0.48 -1.65,-1.65 0.61,-1.33 1.28,0.28 0.79,-0.2 1.56,0.03 -1.52,-2.57 0.1,-1.89 -0.19,-1.89 z" }, { "id": "tf", "name": "French Southern and Antarctic Lands", "d": "m 668.79,619.28425 1.8,1.33 2.65,0.54 0.1,0.81 -0.78,1.96 -4.31,0.28 -0.07,-2.29 0.42,-1.76 z" }, { "id": "tg", "name": "Togo", "d": "m 480.73,446.50425 -2.25,0.59 -0.63,-0.98 -0.75,-1.78 -0.22,-1.4 0.62,-2.53 -0.7,-1.03 -0.27,-2.22 0,-2.05 -1.17,-1.46 0.21,-0.89 2.46,0.06 -0.36,1.5 0.85,0.83 0.98,0.99 0.1,1.39 0.57,0.58 -0.13,6.46 z" }, { "id": "th", "name": "Thailand", "d": "m 763.14,429.43425 -2.52,-1.31 -2.4,0.06 0.41,-2.25 -2.47,0.02 -0.22,3.14 -1.51,4.15 -0.91,2.5 0.19,2.05 1.82,0.09 1.14,2.57 0.51,2.43 1.56,1.61 1.7,0.33 1.45,1.45 -0.91,1.15 -1.86,0.34 -0.22,-1.44 -2.28,-1.23 -0.49,0.5 -1.11,-1.07 -0.48,-1.39 -1.49,-1.59 -1.36,-1.33 -0.46,1.65 -0.53,-1.56 0.31,-1.76 0.82,-2.71 1.36,-2.91 1.54,-2.65 -1.1,-2.6 0.05,-1.33 -0.32,-1.6 -1.87,-2.28 -0.67,-1.45 0.97,-0.53 1.02,-2.52 -1.14,-1.92 -1.78,-2.13 -1.36,-2.57 1.18,-0.53 1.28,-3.19 1.98,-0.14 1.64,-1.28 1.6,-0.69 1.22,0.92 0.16,1.78 1.89,0.13 -0.69,3.11 0.07,2.62 2.95,-1.74 0.84,0.51 1.65,-0.08 0.56,-1.02 2.12,0.2 2.13,2.38 0.18,2.87 2.27,2.53 -0.13,2.44 -0.91,1.3 -2.63,-0.41 -3.62,0.55 -1.8,2.38 z" }, { "id": "tj", "name": "Tajikistan", "d": "m 674.62,340.87425 -1.03,1.13 -3.05,-0.61 -0.27,2.1 3.04,-0.28 3.47,1.17 5.3,-0.55 0.71,3.33 0.92,-0.36 1.7,0.81 -0.09,1.38 0.42,2.01 -2.9,0 -1.93,-0.26 -1.74,1.57 -1.25,0.34 -0.98,0.74 -1.11,-1.15 0.27,-2.95 -0.85,-0.17 0.3,-1.09 -1.51,-0.8 -1.21,1.23 -0.3,1.43 -0.43,0.52 -1.68,-0.07 -0.9,1.6 -0.95,-0.67 -2.03,1.12 -0.85,-0.42 1.57,-3.57 -0.6,-2.66 -2.06,-0.86 0.73,-1.59 2.34,0.17 1.33,-2.01 0.89,-2.35 3.75,-0.86 -0.58,1.71 0.4,1.02 z" }, { "id": "tl", "name": "Timor-Leste", "d": "m 825.9,488.50425 0.33,-0.66 2.41,-0.63 1.96,-0.1 0.87,-0.35 1.06,0.35 -1.03,0.76 -2.92,1.23 -2.35,0.82 -0.05,-0.86 z" }, { "id": "tm", "name": "Turkmenistan", "d": "m 647.13,357.15425 -0.25,-2.91 -2.09,-0.12 -3.2,-3.09 -2.24,-0.39 -3.1,-1.79 -2,-0.33 -1.23,0.66 -1.87,-0.1 -1.99,2.02 -2.47,0.68 -0.52,-2.49 0.41,-3.73 -2.19,-1.22 0.72,-2.48 -1.86,-0.22 0.62,-3.09 2.64,0.91 2.47,-1.19 -2.05,-2.23 -0.8,-2.14 -2.26,0.96 -0.28,2.73 -0.88,-2.41 1.24,-1.25 3.18,-0.79 1.9,1.06 1.96,2.93 1.44,-0.18 3.16,-0.05 -0.46,-1.88 2.4,-1.3 2.36,-2.2 3.78,2 0.3,2.99 1.07,0.77 3.03,-0.17 0.94,0.67 1.38,3.79 3.21,2.51 1.83,1.69 2.93,1.75 3.73,1.52 -0.08,2.16 -0.84,-0.11 -1.33,-0.94 -0.44,1.25 -2.36,0.68 -0.56,2.79 -1.58,1.05 -2.21,0.52 -0.59,1.55 -2.11,0.46 z" }, { "id": "tn", "name": "Tunisia", "d": "m 502.09,374.94425 -1.2,-5.86 -1.72,-1.33 -0.03,-0.81 -2.29,-1.98 -0.25,-2.53 1.73,-1.88 0.66,-2.82 -0.45,-3.28 0.57,-1.79 3.06,-1.41 1.96,0.42 -0.08,1.77 2.38,-1.29 0.2,0.67 -1.41,1.71 -0.01,1.6 0.97,0.85 -0.37,2.96 -1.85,1.71 0.53,1.83 1.45,0.06 0.71,1.59 1.07,0.52 -0.16,2.55 -1.37,0.95 -0.86,1.05 -1.93,1.26 0.3,1.35 -0.24,1.38 z" }, { "id": "tr", "name": "Turkey", "d": "m 579,336.85425 4.02,1.43 3.27,-0.57 2.41,0.33 3.31,-1.94 2.99,-0.18 2.7,1.83 0.48,1.3 -0.27,1.79 2.08,0.91 1.1,1.06 -1.92,1.03 0.88,4.11 -0.55,1.1 1.53,2.82 -1.34,0.59 -0.98,-0.89 -3.26,-0.45 -1.2,0.55 -3.19,0.54 -1.51,-0.06 -3.23,1.31 -2.31,0.01 -1.49,-0.66 -3.09,0.97 -0.92,-0.68 -0.15,1.94 -0.75,0.76 -0.75,0.76 -1.03,-1.57 1.06,-1.3 -1.71,0.3 -2.35,-0.8 -1.93,2 -4.26,0.39 -2.27,-1.86 -3.02,-0.12 -0.65,1.44 -1.94,0.41 -2.71,-1.85 -3.06,0.06 -1.66,-3.48 -2.05,-1.96 1.36,-2.78 -1.78,-1.72 3.11,-3.48 4.32,-0.15 1.18,-2.81 5.34,0.49 3.37,-2.42 3.27,-1.06 4.64,-0.08 4.91,2.64 z m -27.25,2.39 -2.34,1.98 -0.88,-1.71 0.04,-0.76 0.67,-0.41 0.87,-2.33 -1.37,-0.99 2.86,-1.18 2.41,0.5 0.33,1.44 2.45,1.2 -0.51,0.91 -3.33,0.2 -1.2,1.15 z" }, { "id": "tt", "name": "Trinidad and Tobago", "d": "m 302.56,433.49425 1.61,-0.37 0.59,0.1 -0.11,2.11 -2.34,0.31 -0.51,-0.25 0.82,-0.78 z" }, { "id": "tw", "name": "Taiwan", "d": "m 816.95,393.52425 -1.69,4.87 -1.2,2.48 -1.48,-2.55 -0.32,-2.25 1.65,-3 2.25,-2.32 1.28,0.91 z" }, { "id": "tz", "name": "Tanzania", "d": "m 570.56,466.28425 0.48,0.31 10.16,5.67 0.2,1.62 4.02,2.79 -1.29,3.45 0.16,1.59 1.8,1.02 0.08,0.73 -0.77,1.7 0.16,0.85 -0.18,1.35 0.98,1.76 1.16,2.79 1.02,0.62 -2.23,1.64 -3.06,1.1 -1.68,-0.04 -1,0.85 -1.95,0.07 -0.74,0.36 -3.37,-0.8 -2.11,0.23 -0.78,-3.86 -0.95,-1.32 -0.57,-0.78 -2.74,-0.52 -1.6,-0.85 -1.78,-0.47 -1.12,-0.48 -1.17,-0.71 -1.51,-3.55 -1.63,-1.57 -0.56,-1.62 0.28,-1.46 -0.5,-2.57 1.16,-0.13 1.01,-1.01 1.1,-1.46 0.69,-0.58 -0.03,-0.91 -0.6,-0.63 -0.16,-1.1 0.8,-0.35 0.17,-1.64 -1.12,-1.57 0.99,-0.34 3.07,0.04 z" }, { "id": "ua", "name": "Ukraine", "d": "m 564.63,292.74425 1.04,0.19 0.71,-1.04 0.85,0.23 2.91,-0.44 1.79,2.57 -0.7,0.92 0.23,1.39 2.24,0.21 1,1.93 -0.06,0.87 3.56,1.54 2.15,-0.69 1.73,2.04 1.64,-0.04 4.13,1.4 0.03,1.27 -1.13,2.23 0.61,2.33 -0.44,1.39 -2.71,0.31 -1.44,1.16 -0.09,1.83 -2.24,0.33 -1.87,1.32 -2.62,0.21 -2.42,1.52 -1.32,1.03 1.49,1.47 1.37,0.96 2.86,-0.24 -0.55,1.42 -3.07,0.68 -3.81,2.27 -1.55,-0.79 0.61,-1.85 -3.06,-1.16 0.5,-0.77 3.16,-1.63 -0.4,-0.81 -0.45,0.41 -0.44,-0.22 -4.36,-1.02 -0.19,-1.51 -2.6,0.5 -1.04,2.23 -2.17,2.95 -1.28,-0.68 -1.31,0.64 -1.25,-0.73 0.7,-0.44 0.49,-1.37 0.77,-1.29 -0.2,-0.72 0.59,-0.32 0.27,0.56 1.66,0.11 0.74,-0.29 -0.52,-0.42 0.19,-0.6 -0.98,-1.04 -0.4,-1.72 -1.02,-0.67 0.2,-1.41 -1.27,-1.12 -1.15,-0.16 -2.07,-1.31 -1.86,0.42 -0.67,0.62 -1.18,-0.01 -0.71,0.98 -2.07,0.4 -0.95,0.64 -1.31,-1.01 -1.79,-0.02 -1.74,-0.46 -1.21,0.89 -0.2,-1.12 -1.55,-1.14 0.55,-1.71 0.77,-1.1 0.62,0.24 -0.73,-1.92 2.55,-3.61 1.39,-0.51 0.3,-1.24 -1.41,-3.89 1.34,-0.17 1.54,-1.23 2.17,-0.1 2.83,0.36 3.13,1.08 2.21,0.09 1.05,0.65 1.05,-0.78 0.74,1.05 2.53,-0.22 1.11,0.43 0.19,-2.26 0.86,-1 z" }, { "id": "ug", "name": "Uganda", "d": "m 564.85,466.50425 -3.07,-0.04 -0.99,0.34 -1.67,0.86 -0.68,-0.29 0.02,-2.1 0.65,-1.06 0.16,-2.24 0.59,-1.29 1.07,-1.46 1.08,-0.74 0.9,-0.99 -1.12,-0.37 0.17,-3.26 1.15,-0.77 1.78,0.63 2.26,-0.65 1.97,0 1.73,-1.28 1.33,1.94 0.33,1.4 1.23,3.2 -1.02,2.03 -1.38,1.84 -0.8,1.13 0.02,2.95 z" }, { "id": "us", "name": "United States", "d": "m 109.5,280.05425 0,0 -1.54,-1.83 -2.47,-1.57 -0.79,-4.36 -3.61,-4.13 -1.51,-4.94 -2.69,-0.34 -4.46,-0.13 -3.29,-1.54 -5.8,-5.64 -2.68,-1.05 -4.9,-1.99 -3.88,0.48 -5.51,-2.59 -3.33,-2.43 -3.11,1.21 0.58,3.93 -1.55,0.36 -3.24,1.16 -2.47,1.86 -3.11,1.16 -0.4,-3.24 1.26,-5.53 2.98,-1.77 -0.77,-1.46 -3.57,3.22 -1.91,3.77 -4.04,3.95 2.05,2.65 -2.65,3.85 -3.01,2.21 -2.81,1.59 -0.69,2.29 -4.38,2.63 -0.89,2.36 -3.28,2.13 -1.92,-0.38 -2.62,1.38 -2.85,1.67 -2.33,1.63 -4.81,1.38 -0.44,-0.81 3.07,-2.27 2.74,-1.51 2.99,-2.71 3.48,-0.56 1.38,-2.06 3.89,-3.05 0.63,-1.03 2.07,-1.83 0.48,-4 1.43,-3.17 -3.23,1.64 -0.9,-0.93 -1.52,1.95 -1.83,-2.73 -0.76,1.94 -1.05,-2.7 -2.8,2.17 -1.72,0 -0.24,-3.23 0.51,-2.02 -1.81,-1.98 -3.65,1.07 -2.37,-2.63 -1.92,-1.36 -0.01,-3.25 -2.16,-2.48 1.08,-3.41 2.29,-3.37 1,-3.15 2.27,-0.45 1.92,0.99 2.26,-3.01 2.04,0.54 2.14,-1.96 -0.52,-2.92 -1.57,-1.16 2.08,-2.52 -1.72,0.07 -2.98,1.43 -0.85,1.43 -2.21,-1.43 -3.97,0.73 -4.11,-1.56 -1.18,-2.65 -3.55,-3.91 3.94,-2.87 6.25,-3.41 2.31,0 -0.38,3.48 5.92,-0.27 -2.28,-4.34 -3.45,-2.72 -1.99,-3.64 -2.69,-3.17 -3.85,-2.38 1.57,-4.03 4.97,-0.25 3.54,-3.58 0.67,-3.92 2.86,-3.91 2.73,-0.95 5.31,-3.76 2.58,0.57 4.31,-4.61 4.24,1.83 2.03,3.87 1.25,-1.65 4.74,0.51 -0.17,1.95 4.29,1.43 2.86,-0.84 5.91,2.64 5.39,0.78 2.16,1.07 3.73,-1.34 4.25,2.46 3.05,1.13 -0.02,27.65 -0.01,35.43 2.76,0.17 2.73,1.56 1.96,2.44 2.49,3.6 2.73,-3.05 2.81,-1.79 1.49,2.85 1.89,2.23 2.57,2.42 1.75,3.79 2.87,5.88 4.77,3.2 0.08,3.12 -1.6,2.32 z m 175.93,34.43 -1.25,-1.19 -1.88,0.7 -0.93,-1.08 -2.14,3.1 -0.86,3.15 -1,1.82 -1.19,0.62 -0.9,0.2 -0.28,0.98 -5.17,0 -4.26,0.03 -1.27,0.73 -2.87,2.73 0.29,0.54 0.17,1.51 -2.1,1.27 -2.3,-0.32 -2.2,-0.14 -1.33,0.44 0.25,1.15 0,0 0.05,0.37 -2.42,2.27 -2.11,1.09 -1.44,0.51 -1.66,1.03 -2.03,0.5 -1.4,-0.19 -1.73,-0.77 0.96,-1.45 0.62,-1.32 1.32,-2.09 -0.14,-1.57 -0.5,-2.24 -1.04,-0.39 -1.74,1.7 -0.56,-0.03 -0.14,-0.97 1.54,-1.56 0.26,-1.79 -0.23,-1.79 -2.08,-1.55 -2.38,-0.8 -0.39,1.52 -0.62,0.4 -0.5,1.95 -0.26,-1.33 -1.12,0.95 -0.7,1.32 -0.73,1.92 -0.14,1.64 0.93,2.38 -0.08,2.51 -1.14,1.84 -0.57,0.52 -0.76,0.41 -0.95,0.02 -0.26,-0.25 -0.76,-1.98 -0.02,-0.98 0.08,-0.94 -0.35,-1.87 0.53,-2.18 0.63,-2.71 1.46,-3.03 -0.42,0.01 -2.06,2.54 -0.38,-0.46 1.1,-1.42 1.67,-2.57 1.91,-0.36 2.19,-0.8 2.21,0.42 0.09,0.02 2.47,-0.36 -1.4,-1.61 -0.75,-0.13 -0.86,-0.16 -0.59,-1.14 -2.75,0.36 -2.49,0.9 -1.97,-1.55 -1.59,-0.52 0.9,-2.17 -2.48,1.37 -2.25,1.33 -2.16,1.04 -1.72,-1.4 -2.81,0.85 0.01,-0.6 1.9,-1.73 1.99,-1.65 2.86,-1.37 -3.45,-1.09 -2.27,0.55 -2.72,-1.3 -2.86,-0.67 -1.96,-0.26 -0.87,-0.72 -0.5,-2.35 -0.95,0.02 -0.01,1.64 -5.8,0 -9.59,0 -9.53,0 -8.42,0 -8.41,0 -8.27,0 -8.55,0 -2.76,0 -8.32,0 -7.96,0 0.95,3.47 0.45,3.41 -0.69,1.09 -1.49,-3.91 -4.05,-1.42 -0.34,0.82 0.82,1.94 0.89,3.53 0.51,5.42 -0.34,3.59 -0.34,3.54 -1.1,3.61 0.9,2.9 0.1,3.2 -0.61,3.05 1.49,1.99 0.39,2.95 2.17,2.99 1.24,1.17 -0.1,0.82 2.34,4.85 2.72,3.45 0.34,1.87 0.71,0.55 2.6,0.33 1,0.91 1.57,0.17 0.31,0.96 1.31,0.4 1.82,1.92 0.47,1.7 3.19,-0.25 3.56,-0.36 -0.26,0.65 4.23,1.6 6.4,2.31 5.58,-0.02 2.22,0 0.01,-1.35 4.86,0 1.02,1.16 1.43,1.03 1.67,1.43 0.93,1.69 0.7,1.77 1.45,0.97 2.33,0.96 1.77,-2.53 2.29,-0.06 1.98,1.28 1.41,2.18 0.97,1.86 1.65,1.8 0.62,2.19 0.79,1.47 2.19,0.96 1.99,0.68 1.09,-0.09 -0.53,-1.06 -0.14,-1.5 0.03,-2.16 0.65,-1.42 1.53,-1.51 2.79,-1.37 2.55,-2.37 2.36,-0.75 1.74,-0.23 2.04,0.74 2.45,-0.4 2.09,1.69 2.03,0.1 1.05,-0.61 1.04,0.47 0.53,-0.42 -0.6,-0.63 0.05,-1.3 -0.5,-0.86 1.16,-0.5 2.14,-0.22 2.49,0.36 3.17,-0.41 1.76,0.8 1.36,1.5 0.5,0.16 2.83,-1.46 1.09,0.49 2.19,2.68 0.79,1.75 -0.58,2.1 0.42,1.23 1.3,2.4 1.49,2.68 1.07,0.71 0.44,1.35 1.38,0.37 0.84,-0.39 0.7,-1.89 0.12,-1.21 0.09,-2.1 -1.33,-3.65 -0.02,-1.37 -1.25,-2.25 -0.94,-2.75 -0.5,-2.25 0.43,-2.31 1.32,-1.94 1.58,-1.57 3.08,-2.16 0.4,-1.12 1.42,-1.23 1.4,-0.22 1.84,-1.98 2.9,-1.01 1.78,-2.53 -0.39,-3.46 -0.29,-1.21 -0.8,-0.24 -0.12,-3.35 -1.93,-1.14 1.85,0.56 -0.6,-2.26 0.54,-1.55 0.33,2.97 1.43,1.36 -0.87,2.4 0.26,0.14 1.58,-2.81 0.9,-1.38 -0.04,-1.35 -0.7,-0.64 -0.58,-1.94 0.92,0.9 0.62,0.19 0.21,0.92 2.04,-2.78 0.61,-2.62 -0.83,-0.17 0.85,-1.02 -0.08,0.45 1.79,-0.01 3.93,-1.11 -0.83,-0.7 -4.12,0.7 2.34,-1.07 1.63,-0.18 1.22,-0.19 2.07,-0.65 1.35,0.07 1.89,-0.61 0.22,-1.07 -0.84,-0.84 0.29,1.37 -1.16,-0.09 -0.93,-1.99 0.03,-2.01 0.48,-0.86 1.48,-2.28 2.96,-1.15 2.88,-1.34 2.99,-1.9 -0.48,-1.29 -1.83,-2.25 -0.03,-5.56 z m -239.56,-50.44 -1.5,0.8 -2.55,1.86 0.43,2.42 1.43,1.32 2.8,-1.95 2.43,-2.47 -1.19,-1.63 -1.85,-0.35 z m -45.62,-28.57 2.04,-1.26 0.23,-0.68 -2.27,-0.67 0,2.61 z m 8.5,15.37 -2.77,0.97 1.7,1.52 1.84,1.04 1.72,-0.87 -0.27,-2.15 -2.22,-0.51 z m 97.35,32.5 -2.69,0.38 -1.32,-0.62 -0.17,1.52 0.52,2.07 1.42,1.46 1.04,2.13 1.69,2.1 1.12,0.01 -2.44,-3.7 0.83,-5.35 z m -68.72,120.68 -1,-0.28 -0.27,0.26 0.02,0.19 0.32,0.24 0.48,0.63 0.94,-0.21 0.23,-0.36 -0.72,-0.47 z m -2.99,-0.54 1.5,0.09 0.09,-0.32 -1.38,-0.13 -0.21,0.36 z m 5.89,3.29 -0.5,-0.26 -1.07,-0.5 -0.21,-0.06 -0.16,0.28 0.19,0.58 -0.49,0.48 -0.14,0.33 0.46,1.08 -0.08,0.83 0.7,0.42 0.41,-0.49 0.9,-0.46 1.1,-0.63 0.07,-0.16 -0.71,-1.04 -0.47,-0.4 z m -7.86,-5.14 -0.75,0.41 0.11,0.12 0.36,0.68 0.98,0.11 0.2,0.04 0.15,-0.17 -0.81,-0.99 -0.24,-0.2 z m -4.4,-1.56 -0.43,0.3 -0.15,0.22 0.94,0.55 0.33,-0.3 -0.06,-0.7 -0.63,-0.07 z" }, { "id": "uy", "name": "Uruguay", "d": "m 313.93,552.04425 1.82,-0.34 2.81,2.5 1.04,-0.09 2.89,2.08 2.2,1.82 1.62,2.25 -1.24,1.57 0.78,1.9 -1.21,2.12 -3.17,1.88 -2.07,-0.68 -1.52,0.37 -2.59,-1.46 -1.9,0.11 -1.71,-1.87 0.22,-2.16 0.61,-0.74 -0.03,-3.3 0.75,-3.37 z" }, { "id": "uz", "name": "Uzbekistan", "d": "m 662.01,351.20425 0.08,-2.16 -3.73,-1.52 -2.93,-1.75 -1.83,-1.69 -3.21,-2.51 -1.38,-3.79 -0.94,-0.67 -3.03,0.17 -1.07,-0.77 -0.3,-2.99 -3.78,-2 -2.36,2.2 -2.4,1.3 0.46,1.88 -3.16,0.05 -0.11,-14.13 7.22,-2.35 0.52,0.35 4.35,2.84 2.29,1.48 2.68,3.5 3.29,-0.56 4.81,-0.3 3.35,2.8 -0.21,3.8 1.37,0.03 0.57,3.06 3.57,0.12 0.76,1.75 1.05,-0.02 1.23,-2.65 3.69,-2.61 1.61,-0.7 0.83,0.37 -2.35,2.43 2.07,1.4 2,-0.93 3.32,1.96 -3.59,2.64 -2.13,-0.36 -1.16,0.1 -0.4,-1.02 0.58,-1.71 -3.75,0.86 -0.89,2.35 -1.33,2.01 -2.34,-0.17 -0.73,1.59 2.06,0.86 0.6,2.66 -1.57,3.57 -2.12,-0.74 z" }, { "id": "ve", "name": "Venezuela", "d": "m 275.5,430.60425 -0.08,0.67 -1.65,0.33 0.92,1.29 -0.04,1.49 -1.23,1.64 1.06,2.24 1.21,-0.18 0.63,-2.04 -0.87,-1 -0.14,-2.14 3.49,-1.16 -0.39,-1.34 0.98,-0.9 1.01,2 1.97,0.05 1.82,1.58 0.11,0.94 2.51,0.02 3,-0.29 1.61,1.27 2.14,0.35 1.57,-0.88 0.03,-0.72 3.48,-0.17 3.36,-0.04 -2.38,0.84 0.95,1.34 2.25,0.21 2.12,1.39 0.45,2.26 1.46,-0.07 1.1,0.67 -2.22,1.65 -0.25,1.03 0.96,1.04 -0.69,0.52 -1.73,0.45 0.06,1.3 -0.76,0.77 1.89,2.12 0.38,0.79 -1.03,1.07 -3.14,1.04 -2.01,0.44 -0.81,0.66 -2.23,-0.7 -2.08,-0.36 -0.52,0.26 1.25,0.72 -0.11,1.87 0.39,1.76 2.37,0.24 0.16,0.58 -2.01,0.8 -0.32,1.18 -1.16,0.45 -2.08,0.65 -0.54,0.86 -2.18,0.18 -1.55,-1.48 -0.85,-2.77 -0.75,-0.98 -1.02,-0.61 1.42,-1.39 -0.09,-0.63 -0.8,-0.83 -0.56,-1.85 0.22,-2.01 0.62,-0.94 0.51,-1.5 -0.99,-0.49 -1.6,0.32 -2.02,-0.15 -1.13,0.3 -1.98,-2.41 -1.63,-0.36 -3.6,0.27 -0.67,-0.98 -0.69,-0.23 -0.1,-0.59 0.33,-1.04 -0.22,-1.13 -0.62,-0.62 -0.36,-1.3 -1.44,-0.18 0.77,-1.66 0.35,-2.01 0.81,-1.06 1.09,-0.81 0.71,-1.42 z" }, { "id": "vn", "name": "Vietnam", "d": "m 778.46,402.12425 -3.74,2.56 -2.34,2.81 -0.62,2.05 2.15,3.09 2.62,3.82 2.54,1.79 1.71,2.33 1.28,5.32 -0.38,5.02 -2.33,1.87 -3.22,1.83 -2.28,2.36 -3.5,2.62 -1.02,-1.81 0.79,-1.91 -2.08,-1.61 2.43,-1.14 2.94,-0.2 -1.23,-1.73 4.71,-2.19 0.35,-3.42 -0.65,-1.92 0.51,-2.88 -0.71,-2.04 -2.12,-2.02 -1.77,-2.57 -2.33,-3.46 -3.36,-1.76 0.81,-1.07 1.79,-0.77 -1.09,-2.59 -3.45,-0.03 -1.26,-2.72 -1.64,-2.37 1.51,-0.74 2.23,0.02 2.73,-0.35 2.39,-1.62 1.35,1.14 2.57,0.55 -0.45,1.74 1.34,1.22 z" }, { "id": "vu", "name": "Vanuatu", "d": "m 946.12,510.15425 -0.92,0.38 -0.94,-1.27 0.1,-0.78 1.76,1.67 z m -2.07,-4.44 0.46,2.33 -0.75,-0.36 -0.58,0.16 -0.4,-0.8 -0.06,-2.21 1.33,0.88 z" }, { "id": "ye", "name": "Yemen", "d": "m 624.41,416.58425 -2.03,0.79 -0.54,1.28 -0.07,0.99 -2.79,1.22 -4.48,1.35 -2.51,2.03 -1.23,0.15 -0.84,-0.17 -1.64,1.2 -1.79,0.55 -2.35,0.15 -0.71,0.16 -0.61,0.75 -0.74,0.21 -0.43,0.73 -1.39,-0.06 -0.9,0.38 -1.94,-0.14 -0.73,-1.67 0.08,-1.57 -0.45,-0.85 -0.55,-2.12 -0.81,-1.19 0.56,-0.14 -0.29,-1.32 0.34,-0.56 -0.12,-1.26 1.23,-0.93 -0.29,-1.23 0.75,-1.43 1.15,0.76 0.76,-0.27 3.23,-0.07 0.52,0.3 2.71,0.29 1.07,-0.15 0.7,0.97 1.31,-0.48 2.01,-3.07 2.62,-1.32 8.08,-1.13 2.2,4.84 z" }, { "id": "za", "name": "South Africa", "d": "m 563.88,548.96425 -0.55,0.46 -1.19,1.63 -0.78,1.66 -1.59,2.33 -3.17,3.38 -1.98,1.98 -2.12,1.51 -2.93,1.3 -1.43,0.17 -0.36,0.93 -1.7,-0.5 -1.39,0.64 -3.04,-0.65 -1.7,0.41 -1.16,-0.18 -2.89,1.33 -2.39,0.54 -1.73,1.28 -1.28,0.08 -1.19,-1.21 -0.95,-0.06 -1.21,-1.51 -0.13,0.47 -0.37,-0.91 0.02,-1.96 -0.91,-2.23 0.9,-0.6 -0.07,-2.53 -1.84,-3.05 -1.41,-2.74 0,-0.01 -2.01,-4.15 1.34,-1.57 1.11,0.87 0.47,1.36 1.26,0.23 1.76,0.6 1.51,-0.23 2.5,-1.63 0,-11.52 0.76,0.46 1.66,2.93 -0.26,1.89 0.63,1.1 2.01,-0.32 1.4,-1.39 1.33,-0.93 0.69,-1.48 1.37,-0.72 1.18,0.38 1.34,0.87 2.28,0.15 1.79,-0.72 0.28,-0.96 0.49,-1.47 1.53,-0.25 0.84,-1.15 0.93,-2.03 2.52,-2.26 3.97,-2.22 1.14,0.03 1.36,0.51 0.94,-0.36 1.49,0.3 1.34,4.26 0.73,2.17 -0.5,3.43 0.24,1.11 -1.42,-0.57 -0.81,0.22 -0.26,0.9 -0.77,1.17 0.03,1.08 1.67,1.7 1.64,-0.34 0.57,-1.39 2.13,0.03 -0.7,2.28 -0.33,2.62 -0.73,1.43 -1.9,1.62 z m -7.13,-0.96 -1.22,-0.98 -1.31,0.65 -1.52,1.25 -1.5,2.03 2.1,2.48 1,-0.32 0.52,-1.03 1.56,-0.5 0.48,-1.05 0.86,-1.56 -0.97,-0.97 z" }, { "id": "zm", "name": "Zambia", "d": "m 567.36,489.46425 1.32,1.26 0.71,2.4 -0.48,0.77 -0.56,2.3 0.54,2.36 -0.88,0.99 -0.85,2.66 1.47,0.74 -8.51,2.38 0.27,2.05 -2.13,0.4 -1.59,1.15 -0.34,1.01 -1.01,0.22 -2.44,2.4 -1.55,1.89 -0.95,0.07 -0.91,-0.34 -3.13,-0.32 -0.5,-0.22 -0.03,-0.24 -1.1,-0.66 -1.82,-0.17 -2.3,0.67 -1.83,-1.82 -1.89,-2.38 0.13,-9.16 5.84,0.04 -0.24,-0.99 0.42,-1.07 -0.49,-1.33 0.32,-1.38 -0.3,-0.88 0.97,0.07 0.16,0.88 1.31,-0.07 1.78,0.26 0.94,1.29 2.24,0.4 1.72,-0.9 0.63,1.49 2.15,0.4 1.03,1.22 1.15,1.57 2.15,0.03 -0.24,-3.08 -0.77,0.51 -1.96,-1.1 -0.76,-0.51 0.35,-2.85 0.5,-3.35 -0.63,-1.25 0.8,-1.8 0.75,-0.33 3.77,-0.48 1.1,0.29 1.17,0.71 1.12,0.48 1.78,0.47 z" }, { "id": "zw", "name": "Zimbabwe", "d": "m 562.96,527.25425 -1.49,-0.3 -0.95,0.36 -1.35,-0.51 -1.14,-0.03 -1.79,-1.36 -2.17,-0.46 -0.82,-1.9 -0.01,-1.05 -1.2,-0.32 -3.17,-3.25 -0.89,-1.71 -0.56,-0.52 -1.08,-2.35 3.13,0.32 0.91,0.34 0.95,-0.07 1.55,-1.89 2.44,-2.4 1.01,-0.22 0.34,-1.01 1.59,-1.15 2.13,-0.4 0.18,1.08 2.34,-0.06 1.3,0.61 0.6,0.72 1.34,0.21 1.45,0.94 0.01,3.69 -0.55,2.04 -0.12,2.2 0.45,0.88 -0.31,1.74 -0.43,0.27 -0.74,2.15 z" } ] } ================================================ FILE: server/utils/utils.js ================================================ const { differenceInDays, differenceInHours, differenceInMonths, differenceInMilliseconds, addDays, subHours, subDays, subMonths, subYears, format } = require("date-fns"); const { customAlphabet } = require("nanoid"); const crypto = require("node:crypto"); const JWT = require("jsonwebtoken"); const path = require("node:path"); const fs = require("node:fs"); const hbs = require("hbs"); const ms = require("ms"); const { ROLES } = require("../consts"); const knexUtils = require("./knex"); const knex = require("../knex"); const env = require("../env"); const nanoid = customAlphabet(env.LINK_CUSTOM_ALPHABET, env.LINK_LENGTH); class CustomError extends Error { constructor(message, statusCode, data) { super(message); this.name = this.constructor.name; this.statusCode = statusCode ?? 500; this.data = data; } } const urlRegex = /^(?:(?:(?:https?|ftp):)?\/\/)(?:\S+(?::\S*)?@)?(?:(?!(?:10|127)(?:\.\d{1,3}){3})(?!(?:169\.254|192\.168)(?:\.\d{1,3}){2})(?!172\.(?:1[6-9]|2\d|3[0-1])(?:\.\d{1,3}){2})(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z0-9\u00a1-\uffff][a-z0-9\u00a1-\uffff_-]{0,62})?[a-z0-9\u00a1-\uffff]\.)+(?:[a-z\u00a1-\uffff]{2,}\.?))(?::\d{2,5})?(?:[/?#]\S*)?$/i; const charsNeedEscapeInRegExp = ".$*+?()[]{}|^-"; const customAlphabetEscaped = env.LINK_CUSTOM_ALPHABET .split("").map(c => charsNeedEscapeInRegExp.includes(c) ? "\\" + c : c).join(""); const customAlphabetRegex = new RegExp(`^[${customAlphabetEscaped}_-]+$`); const customAddressRegex = new RegExp("^[a-zA-Z0-9-_]+$"); function isAdmin(user) { return user.role === ROLES.ADMIN; } function signToken(user) { return JWT.sign( { iss: "ApiAuth", sub: user.id, iat: parseInt((new Date().getTime() / 1000).toFixed(0)), exp: parseInt((addDays(new Date(), 7).getTime() / 1000).toFixed(0)) }, env.JWT_SECRET ) } function setToken(res, token) { res.cookie("token", token, { maxAge: 1000 * 60 * 60 * 24 * 7, // expire after seven days httpOnly: true, secure: env.isProd }); } function deleteCurrentToken(res) { res.clearCookie("token", { httpOnly: true, secure: env.isProd }); } function generateRandomPassword() { // 24-64 characters. const length = Math.floor(Math.random() * 41 ) + 24; return [...crypto.randomBytes(length)].map(byte => String.fromCharCode((byte % 93) + 33)).join(""); } async function generateId(query, domain_id) { const address = nanoid(); const link = await query.link.find({ address, domain_id }); if (link) { return generateId(query, domain_id) }; return address; } function addProtocol(url) { const hasProtocol = /^(\w+:|\/\/)/.test(url); return hasProtocol ? url : "http://" + url; } function getSiteURL() { const protocol = !env.isDev ? "https://" : "http://"; return `${protocol}${env.DEFAULT_DOMAIN}`; } function getShortURL(address, domain) { const protocol = (env.CUSTOM_DOMAIN_USE_HTTPS || !domain) && !env.isDev ? "https://" : "http://"; const link = `${domain || env.DEFAULT_DOMAIN}/${address}`; const url = `${protocol}${link}`; return { address, link, url }; } function statsObjectToArray(obj) { const objToArr = (key) => Array.from(Object.keys(obj[key])) .map((name) => ({ name, value: obj[key][name] })) .sort((a, b) => b.value - a.value); return { browser: objToArr("browser"), os: objToArr("os"), country: objToArr("country"), referrer: objToArr("referrer") }; } function getDifferenceFunction(type) { if (type === "lastDay") return differenceInHours; if (type === "lastWeek") return differenceInDays; if (type === "lastMonth") return differenceInDays; if (type === "lastYear") return differenceInMonths; throw new Error("Unknown type."); } function parseDatetime(date) { // because postgres and mysql return date, sqlite returns formatted iso 8601 string in utc return date instanceof Date ? date : new Date(date + "Z"); } function parseTimestamps(item) { return { created_at: parseDatetime(item.created_at), updated_at: parseDatetime(item.updated_at), } } function dateToUTC(date) { const dateUTC = date instanceof Date ? date.toISOString() : new Date(date).toISOString(); // format the utc date in 'YYYY-MM-DD hh:mm:ss' for SQLite if (knex.isSQLite) { return dateUTC.substring(0, 10) + " " + dateUTC.substring(11, 19); } // mysql doesn't save time in utc, so format the date in local timezone instead if (knex.isMySQL) { return format(new Date(date), "yyyy-MM-dd HH:mm:ss"); } // return unformatted utc string for postgres return dateUTC; } function getStatsPeriods(now) { return [ ["lastDay", subHours(now, 24)], ["lastWeek", subDays(now, 7)], ["lastMonth", subDays(now, 30)], ["lastYear", subMonths(now, 12)], ] } const preservedURLs = [ "login", "logout", "create-admin", "404", "settings", "admin", "stats", "signup", "banned", "report", "reset-password", "resetpassword", "verify-email", "verifyemail", "verify", "terms", "confirm-link-delete", "confirm-link-ban", "confirm-user-delete", "confirm-user-ban", "create-user", "confirm-domain-delete-admin", "confirm-domain-ban", "add-domain-form", "confirm-domain-delete", "get-report-email", "get-support-email", "link", "admin", "url-password", "url-info", "api", "static", "images", "privacy", "protected", "css", "fonts", "libs", "pricing" ]; function parseBooleanQuery(query) { if (query === "true" || query === true) return true; if (query === "false" || query === false) return false; return undefined; } function getInitStats() { return Object.create({ browser: { chrome: 0, edge: 0, firefox: 0, ie: 0, opera: 0, other: 0, safari: 0 }, os: { android: 0, ios: 0, linux: 0, macos: 0, other: 0, windows: 0 }, country: {}, referrer: {} }); } // format date to relative date const MINUTE = 60, HOUR = MINUTE * 60, DAY = HOUR * 24, WEEK = DAY * 7, MONTH = DAY * 30, YEAR = DAY * 365; function getTimeAgo(dateString) { const date = new Date(dateString); const secondsAgo = Math.round((Date.now() - Number(date)) / 1000); if (secondsAgo < MINUTE) { return `${secondsAgo} second${secondsAgo !== 1 ? "s" : ""} ago`; } let divisor; let unit = ""; if (secondsAgo < HOUR) { [divisor, unit] = [MINUTE, "minute"]; } else if (secondsAgo < DAY) { [divisor, unit] = [HOUR, "hour"]; } else if (secondsAgo < WEEK) { [divisor, unit] = [DAY, "day"]; } else if (secondsAgo < MONTH) { [divisor, unit] = [WEEK, "week"]; } else if (secondsAgo < YEAR) { [divisor, unit] = [MONTH, "month"]; } else { [divisor, unit] = [YEAR, "year"]; } const count = Math.floor(secondsAgo / divisor); return `${count} ${unit}${count > 1 ? "s" : ""} ago`; } const sanitize = { domain: domain => ({ ...domain, ...parseTimestamps(domain), id: domain.uuid, banned: !!domain.banned, homepage: domain.homepage || env.DEFAULT_DOMAIN, uuid: undefined, user_id: undefined, banned_by_id: undefined }), link: link => { const timestamps = parseTimestamps(link); return { ...link, ...timestamps, banned_by_id: undefined, domain_id: undefined, user_id: undefined, uuid: undefined, banned: !!link.banned, id: link.uuid, password: !!link.password, link: getShortURL(link.address, link.domain).url, } }, link_html: link => { const timestamps = parseTimestamps(link); return { ...link, ...timestamps, banned_by_id: undefined, domain_id: undefined, user_id: undefined, uuid: undefined, banned: !!link.banned, id: link.uuid, relative_created_at: getTimeAgo(timestamps.created_at), relative_expire_in: link.expire_in && ms(differenceInMilliseconds(parseDatetime(link.expire_in), new Date()), { long: true }), password: !!link.password, visit_count: link.visit_count.toLocaleString("en-US"), link: getShortURL(link.address, link.domain), } }, link_admin: link => { const timestamps = parseTimestamps(link); return { ...link, ...timestamps, domain: link.domain || env.DEFAULT_DOMAIN, id: link.uuid, relative_created_at: getTimeAgo(timestamps.created_at), relative_expire_in: link.expire_in && ms(differenceInMilliseconds(parseDatetime(link.expire_in), new Date()), { long: true }), password: !!link.password, visit_count: link.visit_count.toLocaleString("en-US"), link: getShortURL(link.address, link.domain) } }, user_admin: user => { const timestamps = parseTimestamps(user); return { ...user, ...timestamps, links_count: (user.links_count ?? 0).toLocaleString("en-US"), relative_created_at: getTimeAgo(timestamps.created_at), relative_updated_at: getTimeAgo(timestamps.updated_at), } }, domain_admin: domain => { const timestamps = parseTimestamps(domain); return { ...domain, ...timestamps, links_count: (domain.links_count ?? 0).toLocaleString("en-US"), relative_created_at: getTimeAgo(timestamps.created_at), relative_updated_at: getTimeAgo(timestamps.updated_at), } } }; function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } function removeWww(host) { return host.replace("www.", ""); }; function registerHandlebarsHelpers() { hbs.registerHelper("ifEquals", function(arg1, arg2, options) { return (arg1 === arg2) ? options.fn(this) : options.inverse(this); }); hbs.registerHelper("json", function(context) { return JSON.stringify(context); }); const blocks = {}; hbs.registerHelper("extend", function(name, context) { let block = blocks[name]; if (!block) { block = blocks[name] = []; } block.push(context.fn(this)); }); hbs.registerHelper("block", function(name) { const val = (blocks[name] || []).join("\n"); blocks[name] = []; return val; }); hbs.registerPartials(path.join(__dirname, "../views/partials"), function (err) {}); const customPartialsPath = path.join(__dirname, "../../custom/views/partials"); const customPartialsExist = fs.existsSync(customPartialsPath); if (customPartialsExist) { hbs.registerPartials(customPartialsPath, function (err) {}); } } // grab custom styles file name from the custom/css folder const custom_css_file_names = []; const customCSSPath = path.join(__dirname, "../../custom/css"); const customCSSExists = fs.existsSync(customCSSPath); if (customCSSExists) { fs.readdir(customCSSPath, function(error, files) { if (error) { console.warn("Could not read the custom CSS folder:", error); } else { files.forEach(function(file_name) { custom_css_file_names.push(file_name); }); } }) } function getCustomCSSFileNames() { return custom_css_file_names; } module.exports = { addProtocol, customAddressRegex, customAlphabetRegex, CustomError, dateToUTC, deleteCurrentToken, generateId, generateRandomPassword, getCustomCSSFileNames, getDifferenceFunction, getInitStats, getSiteURL, getShortURL, getStatsPeriods, isAdmin, parseBooleanQuery, parseDatetime, parseTimestamps, preservedURLs, registerHandlebarsHelpers, removeWww, sanitize, setToken, signToken, sleep, statsObjectToArray, urlRegex, ...knexUtils, } ================================================ FILE: server/views/404.hbs ================================================ {{> header}}

404 | Link could not be found.

← Back to homepage
{{> footer}} ================================================ FILE: server/views/admin.hbs ================================================ {{> header}} {{> admin/index}} {{> footer}} ================================================ FILE: server/views/banned.hbs ================================================ {{> header}}

Link has been banned and removed because of malware or scam.

If you noticed a malware/scam link shortened by {{default_domain}}, send us a report .

{{> footer}} ================================================ FILE: server/views/create_admin.hbs ================================================ {{> header}} {{> auth/form_admin}} {{> footer}} ================================================ FILE: server/views/error.hbs ================================================ {{> header}}

Error!

{{message}}

← Back to homepage
{{> footer}} ================================================ FILE: server/views/homepage.hbs ================================================ {{> header}} {{> shortener}} {{#if user}} {{> links/table}} {{/if}} {{> footer}} ================================================ FILE: server/views/layout.hbs ================================================ {{site_name}} | {{title}} {{#each custom_styles}} {{/each}} {{{block "stylesheets"}}}
{{{body}}}
{{{block "scripts"}}} ================================================ FILE: server/views/login.hbs ================================================ {{> header}} {{#if login_disabled}} {{> auth/login_disabled}} {{else}} {{> auth/form}} {{/if}} {{> footer}} ================================================ FILE: server/views/logout.hbs ================================================ {{> header}} {{> footer}} ================================================ FILE: server/views/partials/admin/dialog/add_domain.hbs ================================================

Add domain

{{> icons/spinner}}
{{#if error}}

{{error}}

{{/if}}
================================================ FILE: server/views/partials/admin/dialog/add_domain_success.hbs ================================================
{{> icons/check}}

The domain "{{address}}" has been created successfully.

================================================ FILE: server/views/partials/admin/dialog/ban_domain.hbs ================================================

Ban domain?

Are you sure do you want to ban the domain "{{address}}"?

{{#if hasUser}} {{/if}} {{#if hasLink}} {{/if}}
{{> icons/spinner}}
{{#if error}}

{{error}}

{{/if}}
================================================ FILE: server/views/partials/admin/dialog/ban_domain_success.hbs ================================================
{{> icons/check}}

The domain "{{address}}" is banned.

================================================ FILE: server/views/partials/admin/dialog/ban_user.hbs ================================================

Ban user?

Are you sure do you want to ban the user "{{email}}"?

{{> icons/spinner}}
{{#if error}}

{{error}}

{{/if}}
================================================ FILE: server/views/partials/admin/dialog/ban_user_success.hbs ================================================
{{> icons/check}}

The user "{{email}}" is banned.

================================================ FILE: server/views/partials/admin/dialog/create_user.hbs ================================================

Create user

{{> icons/spinner}}
{{#if error}}

{{error}}

{{/if}}
================================================ FILE: server/views/partials/admin/dialog/create_user_success.hbs ================================================
{{> icons/check}}

The user "{{email}}" has been created successfully.

================================================ FILE: server/views/partials/admin/dialog/delete_domain.hbs ================================================

Delete domain?

Are you sure do you want to delete the domain "{{address}}"?

{{#if hasLink}}
{{/if }}
{{> icons/spinner}}
{{#if error}}

{{error}}

{{/if}}
================================================ FILE: server/views/partials/admin/dialog/delete_domain_success.hbs ================================================
{{> icons/check}}

The domain "{{address}}" has been deleted.

================================================ FILE: server/views/partials/admin/dialog/delete_user.hbs ================================================

Delete user?

Are you sure do you want to delete the user "{{email}}"?
All their data including their links will be deleted.

{{> icons/spinner}}
{{#if error}}

{{error}}

{{/if}}
================================================ FILE: server/views/partials/admin/dialog/delete_user_success.hbs ================================================
{{> icons/check}}

The user "{{email}}" has been deleted.

================================================ FILE: server/views/partials/admin/dialog/frame.hbs ================================================
{{> icons/spinner}}
================================================ FILE: server/views/partials/admin/dialog/mesasge.hbs ================================================
{{#if error}}

{{error}}

{{else}}

{{message}}

{{/if}}
================================================ FILE: server/views/partials/admin/domains/actions.hbs ================================================ {{#if banned}} {{/if}} {{#unless banned}} {{/unless}} ================================================ FILE: server/views/partials/admin/domains/loading.hbs ================================================ {{#unless table_domains}} {{#ifEquals table_domains.length 0}} No domains. {{else}} {{> icons/spinner}} Loading domains... {{/ifEquals}} {{/unless}} ================================================ FILE: server/views/partials/admin/domains/table.hbs ================================================ {{> admin/domains/thead}} {{> admin/domains/tbody}} {{> admin/domains/tfoot}}
================================================ FILE: server/views/partials/admin/domains/tbody.hbs ================================================ {{> admin/domains/loading}} {{#each table_domains}} {{> admin/domains/tr}} {{/each}} ================================================ FILE: server/views/partials/admin/domains/tfoot.hbs ================================================ {{> admin/table_nav}} ================================================ FILE: server/views/partials/admin/domains/thead.hbs ================================================ {{> admin/table_tab title='domains'}}
{{> admin/table_nav}} ID Address Homepage Created at Total links ================================================ FILE: server/views/partials/admin/domains/tr.hbs ================================================ {{id}} {{address}}

by  {{~#if user_id~}} {{email}} {{#ifEquals @root.query.user email}} {{else}}  ( view domains ) {{/ifEquals}} {{~else~}} System {{~/if~}}  {{~#if description~}}· {{description}}{{~/if}}

{{#if homepage}} {{homepage}} {{else}} No homepage {{/if}} {{relative_created_at}} {{#ifEquals links_count '0'}} {{links_count}} {{else}} {{links_count}} {{/ifEquals}} {{> admin/domains/actions}} {{> icons/spinner}} ================================================ FILE: server/views/partials/admin/index.hbs ================================================

Recent shortened links.

{{> admin/links/table onload=true}} {{> admin/dialog/frame}}
================================================ FILE: server/views/partials/admin/links/actions.hbs ================================================ {{#if password}} {{/if}} {{#if banned}} {{/if}} {{> icons/chart}} {{#unless banned}} {{/unless}} ================================================ FILE: server/views/partials/admin/links/edit.hbs ================================================ {{#if id}}
{{#if error}} {{#unless errors}}

{{error}}

{{/unless}} {{else if success}}

{{success}}

{{/if}}
{{else}}

No link was found.

{{/if}} ================================================ FILE: server/views/partials/admin/links/loading.hbs ================================================ {{#unless links}} {{#ifEquals links.length 0}} No links. {{else}} {{> icons/spinner}} Loading links... {{/ifEquals}} {{/unless}} ================================================ FILE: server/views/partials/admin/links/table.hbs ================================================ {{> admin/links/thead}} {{> admin/links/tbody}} {{> admin/links/tfoot}}
================================================ FILE: server/views/partials/admin/links/tbody.hbs ================================================ {{> admin/links/loading}} {{#each links}} {{> admin/links/tr}} {{/each}} ================================================ FILE: server/views/partials/admin/links/tfoot.hbs ================================================ {{> admin/table_nav}} ================================================ FILE: server/views/partials/admin/links/thead.hbs ================================================ {{> admin/table_tab title='links'}}
{{> admin/table_nav}} Original URL Created at Short link Views ================================================ FILE: server/views/partials/admin/links/tr.hbs ================================================ {{target}}

by  {{~#if user_id~}} {{email}} {{#ifEquals @root.query.user email}} {{else}}  ( view links ) {{/ifEquals}} {{~else~}} Anonymous {{~/if~}}  {{~#if description~}}· {{description}}{{~/if}}

{{relative_created_at}} {{#if relative_expire_in}}

Expires in {{relative_expire_in}}

{{/if}}

{{domain}}

{{visit_count}} {{> admin/links/actions}} {{> icons/spinner}} ================================================ FILE: server/views/partials/admin/table_nav.hbs ================================================
================================================ FILE: server/views/partials/admin/table_tab.hbs ================================================

Total {{title}}: {{#if total includeZero=true}}{{total_formatted}}{{else}}-{{/if}}

================================================ FILE: server/views/partials/admin/users/actions.hbs ================================================ {{#if banned}} {{/if}} {{#unless banned}} {{/unless}} ================================================ FILE: server/views/partials/admin/users/loading.hbs ================================================ {{#unless users}} {{#ifEquals users.length 0}} No users. {{else}} {{> icons/spinner}} Loading users... {{/ifEquals}} {{/unless}} ================================================ FILE: server/views/partials/admin/users/table.hbs ================================================ {{> admin/users/thead}} {{> admin/users/tbody}} {{> admin/users/tfoot}}
================================================ FILE: server/views/partials/admin/users/tbody.hbs ================================================ {{> admin/users/loading}} {{#each users}} {{> admin/users/tr}} {{/each}} ================================================ FILE: server/views/partials/admin/users/tfoot.hbs ================================================ {{> admin/table_nav}} ================================================ FILE: server/views/partials/admin/users/thead.hbs ================================================ {{> admin/table_tab title='users'}}
{{> admin/table_nav}} ID Email Created at Verified Role Total links ================================================ FILE: server/views/partials/admin/users/tr.hbs ================================================ {{id}} {{email}}

{{#if domains}} {{domains}} {{else}} No domains {{/if}}

{{relative_created_at}} {{#if verified}} VERIFIED {{else}} NOT VERIFIED {{/if}} {{#ifEquals role "ADMIN"}} ADMIN {{else}} USER {{/ifEquals}} {{#ifEquals links_count '0'}} {{links_count}} {{else}} {{links_count}} {{/ifEquals}} {{> admin/users/actions}} {{> icons/spinner}} ================================================ FILE: server/views/partials/auth/form.hbs ================================================
{{#unless disallow_login_form}}
{{#unless disallow_registration}} {{#if mail_enabled}} {{/if}} {{/unless}}
{{/unless}} {{#if oidc_enabled}} {{/if}} {{#unless disallow_login_form}} {{#if mail_enabled}} Forgot your password? {{/if}} {{/unless}} {{#unless errors}} {{#if error}}

{{error}}

{{/if}} {{/unless}}
================================================ FILE: server/views/partials/auth/form_admin.hbs ================================================

Create an Admin account first:

{{#unless errors}} {{#if error}}

{{error}}

{{/if}} {{/unless}}
================================================ FILE: server/views/partials/auth/login_disabled.hbs ================================================ ================================================ FILE: server/views/partials/auth/verify.hbs ================================================ ================================================ FILE: server/views/partials/auth/welcome.hbs ================================================ ================================================ FILE: server/views/partials/footer.hbs ================================================ ================================================ FILE: server/views/partials/header.hbs ================================================
================================================ FILE: server/views/partials/icons/arrow_left.hbs ================================================ ================================================ FILE: server/views/partials/icons/chart.hbs ================================================ ================================================ FILE: server/views/partials/icons/check.hbs ================================================ ================================================ FILE: server/views/partials/icons/chevron_left.hbs ================================================ ================================================ FILE: server/views/partials/icons/chevron_right.hbs ================================================ ================================================ FILE: server/views/partials/icons/cog.hbs ================================================ ================================================ FILE: server/views/partials/icons/copy.hbs ================================================ ================================================ FILE: server/views/partials/icons/eye.hbs ================================================ ================================================ FILE: server/views/partials/icons/heart.hbs ================================================ ================================================ FILE: server/views/partials/icons/key.hbs ================================================ ================================================ FILE: server/views/partials/icons/login.hbs ================================================ ================================================ FILE: server/views/partials/icons/new_user.hbs ================================================ ================================================ FILE: server/views/partials/icons/pencil.hbs ================================================ ================================================ FILE: server/views/partials/icons/plus.hbs ================================================ ================================================ FILE: server/views/partials/icons/qrcode.hbs ================================================ ================================================ FILE: server/views/partials/icons/reload.hbs ================================================ ================================================ FILE: server/views/partials/icons/send.hbs ================================================ ================================================ FILE: server/views/partials/icons/shield.hbs ================================================ ================================================ FILE: server/views/partials/icons/shuffle.hbs ================================================ ================================================ FILE: server/views/partials/icons/spinner.hbs ================================================ ================================================ FILE: server/views/partials/icons/stop.hbs ================================================ ================================================ FILE: server/views/partials/icons/trash.hbs ================================================ ================================================ FILE: server/views/partials/icons/write.hbs ================================================ ================================================ FILE: server/views/partials/icons/x.hbs ================================================ ================================================ FILE: server/views/partials/icons/zap.hbs ================================================ ================================================ FILE: server/views/partials/links/actions.hbs ================================================ {{#if password}} {{/if}} {{#if banned}} {{/if}} {{> icons/chart}} ================================================ FILE: server/views/partials/links/dialog/ban.hbs ================================================

Ban link?

Are you sure do you want to ban the link "{{link}}"?

{{> icons/spinner}}
{{#if error}}

{{error}}

{{/if}}
================================================ FILE: server/views/partials/links/dialog/ban_success.hbs ================================================
{{> icons/check}}

The link "{{link}}" is banned.

================================================ FILE: server/views/partials/links/dialog/delete.hbs ================================================

Delete link?

Are you sure do you want to delete the link "{{link}}"?

{{> icons/spinner}}
{{#if error}}

{{error}}

{{/if}}
================================================ FILE: server/views/partials/links/dialog/delete_success.hbs ================================================
{{> icons/check}}

Your link "{{link}}" has been deleted.

================================================ FILE: server/views/partials/links/dialog/frame.hbs ================================================ ================================================ FILE: server/views/partials/links/dialog/message.hbs ================================================
{{#if error}}

{{error}}

{{else}}

{{message}}

{{/if}}
================================================ FILE: server/views/partials/links/edit.hbs ================================================ {{#if id}}
{{#if error}} {{#unless errors}}

{{error}}

{{/unless}} {{else if success}}

{{success}}

{{/if}}
{{else}}

No link was found.

{{/if}} ================================================ FILE: server/views/partials/links/loading.hbs ================================================ {{#unless links}} {{#ifEquals links.length 0}} No links. {{else}} {{> icons/spinner}} Loading links... {{/ifEquals}} {{/unless}} ================================================ FILE: server/views/partials/links/nav.hbs ================================================
================================================ FILE: server/views/partials/links/table.hbs ================================================

Recent shortened links.

{{> links/thead}} {{> links/tbody}} {{> links/tfoot}}
{{> links/dialog/frame}}
================================================ FILE: server/views/partials/links/tbody.hbs ================================================ {{> links/loading}} {{#each links}} {{> links/tr}} {{/each}} ================================================ FILE: server/views/partials/links/tfoot.hbs ================================================ {{> links/nav}} ================================================ FILE: server/views/partials/links/thead.hbs ================================================ {{> links/nav}} Original URL Created at Short link Views ================================================ FILE: server/views/partials/links/tr.hbs ================================================ {{target}} {{#if description}}

{{description}}

{{/if}} {{relative_created_at}} {{#if relative_expire_in}}

Expires in {{relative_expire_in}}

{{/if}}
{{> icons/check}}
{{link.link}} {{visit_count}} {{> links/actions}} {{> icons/spinner}} ================================================ FILE: server/views/partials/protected/form.hbs ================================================
{{#if message}}

{{message}}

{{else}}
{{#if error}}

{{error}}

{{/if}} {{/if}}
================================================ FILE: server/views/partials/report/email.hbs ================================================
{{#unless report_email_address}} {{else}} {{report_email_address}} {{/unless}}
================================================ FILE: server/views/partials/report/form.hbs ================================================
{{#if message}}

{{message}}

{{else}}
{{#if error}}

{{error}}

{{/if}} {{/if}}
================================================ FILE: server/views/partials/reset_password/new_password_form.hbs ================================================
{{#unless errors}} {{#if error}}

{{error}}

{{/if}} {{/unless}}
================================================ FILE: server/views/partials/reset_password/new_password_success.hbs ================================================

Your password is updated successfully. You can now log in with your new password.

Log in → ================================================ FILE: server/views/partials/reset_password/request_form.hbs ================================================
{{#if message}}

{{message}}

{{else}}
{{#if error}}

{{error}}

{{/if}} {{/if}}
================================================ FILE: server/views/partials/settings/apikey.hbs ================================================

API

In additional to this website, you can use the API to create, delete and get shortened URLs. If you're not familiar with API, don't generate the key. DO NOT share this key on the client side of your website. Read API docs.

{{#if user.apikey}}
{{> icons/check}}

{{user.apikey}}

{{/if}} {{#if error}}

{{error}}

{{/if}}
================================================ FILE: server/views/partials/settings/change_email.hbs ================================================

Change email

Enter your password and a new email address to change your email address.

{{#if error}} {{#unless errors}}

{{error}}

{{/unless}} {{else if success}}

{{success}}

{{/if}}
================================================ FILE: server/views/partials/settings/change_password.hbs ================================================

Change password

Enter your current password and a new password to change it to.

{{#if error}} {{#unless errors}}

{{error}}

{{/unless}} {{else if success}}

{{success}}

{{/if}}
================================================ FILE: server/views/partials/settings/delete_account.hbs ================================================

Delete account

Delete your account from {{default_domain}}.

{{#if success}}

{{success}}

{{else}}
{{#if error}} {{#unless errors}}

{{error}}

{{/unless}} {{/if}} {{/if}}
================================================ FILE: server/views/partials/settings/domain/add_form.hbs ================================================

If you leave homepage empty, yoursite.com will be redirected to {{default_domain}}.

{{> icons/spinner}} {{#unless errors}} {{#if error}}

{{error}}

{{/if}} {{/unless}}
================================================ FILE: server/views/partials/settings/domain/delete.hbs ================================================

Delete domain?

Are you sure do you want to delete the domain "{{address}}"?

{{> icons/spinner}}
{{#if error}}

{{error}}

{{/if}}
================================================ FILE: server/views/partials/settings/domain/delete_success.hbs ================================================
{{> icons/check}}

Your domain "{{address}}" has been deleted.

{{> settings/domain/table}} ================================================ FILE: server/views/partials/settings/domain/dialog.hbs ================================================
{{> icons/spinner}}
================================================ FILE: server/views/partials/settings/domain/index.hbs ================================================

Custom domain

You can set a custom domain for your short URLs, so instead of {{default_domain}}/shorturl you can have yoursite.com/shorturl.

{{#if server_cname_address}}

Point your domain's A record to {{#if server_ip_address}} {{server_ip_address}} {{else}} our IP address {{/if}} or your subdomain's CNAME record to {{server_cname_address}}. If you're using Cloudflare, make sure to use DNS only mode for your subdomain.

Then, add the domain via the form below:

{{else}}

Point your domain's A record to {{#if server_ip_address}} {{server_ip_address}} {{else}} our IP address {{/if}} then add the domain via the form below:

{{/if}} {{> settings/domain/table}}
{{> icons/spinner}}
{{> settings/domain/dialog}} ================================================ FILE: server/views/partials/settings/domain/table.hbs ================================================ {{#if domains}} {{#each domains}} {{/each}} {{else}} {{/if}}
Domain Homepage
{{address}} {{homepage}}
No domains yet.
================================================ FILE: server/views/partials/shortener.hbs ================================================
{{#if link}}
{{> icons/check}}

{{link}}

{{/if}} {{#unless link}}

Cut your links shorter.

{{/unless}}
{{#if errors.target}}

{{errors.target}}

{{/if}} {{#unless errors}} {{#if error}}

{{error}}

{{/if}} {{/unless}}
================================================ FILE: server/views/partials/stats.hbs ================================================ {{#if error}}

{{> icons/x}} {{error}}

{{else}}

Stats for: {{link.link.link}}

{{link.target}}

Total views: {{link.visit_count}}

{{stats.lastDay.total}} tracked visits in the last day.

Last update at .


Referrers.

Browsers.


Countries.

{{#each map.layers}} {{/each}}

Operating systems.

{{/if}} ================================================ FILE: server/views/partials/support_email.hbs ================================================ {{email}} ================================================ FILE: server/views/protected.hbs ================================================ {{> header}}

Protected link.

Enter the password to be redirected to the link.

{{> protected/form}}
{{> footer}} ================================================ FILE: server/views/report.hbs ================================================ {{> header}}

Report abuse.

Report abuses, malware and phishing links to the email address below {{#if mail_enabled}}or use the form{{/if}}. We will review as soon as we can.

{{> report/email}} {{#if mail_enabled}} {{> report/form}} {{/if}}
{{> footer}} ================================================ FILE: server/views/reset_password.hbs ================================================ {{> header}}

Reset password.

If you forgot you password you can use the form below to get a reset password link.

{{> reset_password/request_form}}
{{> footer}} ================================================ FILE: server/views/reset_password_set_new_password.hbs ================================================ {{> header}}
{{#if token_verified}}

Reset password.

Set your new password.

{{> reset_password/new_password_form}} {{else}}

{{> icons/x}} Password token is invalid. Please try again.

Reset password → {{/if}}
{{> footer}} ================================================ FILE: server/views/settings.hbs ================================================ {{> header}}

Welcome, {{user.email}}.


{{> settings/domain/index}}
{{> settings/apikey}}
{{> settings/change_password}}
{{#if mail_enabled}} {{> settings/change_email}}
{{/if}} {{> settings/delete_account}}
{{> footer}} ================================================ FILE: server/views/stats.hbs ================================================ {{> header}}
{{> icons/spinner}} Loading stats...
{{> footer}} {{#extend "scripts"}} {{/extend}} ================================================ FILE: server/views/terms.hbs ================================================ {{> header}}

{{default_domain}} Terms of Service

By accessing the website at https://{{default_domain}}, you are agreeing to be bound by these terms of service, all applicable laws and regulations, and agree that you are responsible for compliance with any applicable local laws. If you do not agree with any of these terms, you are prohibited from using or accessing this site. The materials contained in this website are protected by applicable copyright and trademark law.

In no event shall {{site_name}} or its suppliers be liable for any damages (including, without limitation, damages for loss of data or profit, or due to business interruption) arising out of the use or inability to use the materials on {{default_domain}} website, even if {{site_name}} or a {{site_name}} authorized representative has been notified orally or in writing of the possibility of such damage. Because some jurisdictions do not allow limitations on implied warranties, or limitations of liability for consequential or incidental damages, these limitations may not apply to you.

The materials appearing on {{site_name}} website could include technical, typographical, or photographic errors. {{site_name}} does not warrant that any of the materials on its website are accurate, complete or current. {{site_name}} may make changes to the materials contained on its website at any time without notice. However {{site_name}} does not make any commitment to update the materials.

{{site_name}} has not reviewed all of the sites linked to its website and is not responsible for the contents of any such linked site. The inclusion of any link does not imply endorsement by {{site_name}} of the site. Use of any such linked website is at the "user's" own risk.

{{site_name}} may revise these terms of service for its website at any time without notice. By using this website you are agreeing to be bound by the then current version of these terms of service.

{{> footer}} ================================================ FILE: server/views/url_info.hbs ================================================ {{> header}}

Target for {{link}}:

{{target}}

{{> footer}} ================================================ FILE: server/views/verify.hbs ================================================ {{> header}}
{{#if token_verified}}

Your account has been verified. Redirecting to homepage...

{{else}}

{{> icons/x}} Invalid verification. Please try again.

Log in / sign up → {{/if}}
{{> footer}} ================================================ FILE: server/views/verify_change_email.hbs ================================================ {{> header}}
{{#if token_verified}}

Email address is verified. Redirecting to homepage...

{{else}}

{{> icons/x}} Couldn't verify the email address. Please try again.

{{#if user}} Settings → {{else}} Log in / sign up → {{/if}} {{/if}}
{{> footer}} ================================================ FILE: static/css/styles.css ================================================ @font-face { font-family: 'Nunito'; font-style: normal; font-weight: 200 1000; src: url(/fonts/nunito-variable.woff2) format('woff2'); } :root { --bg-color: hsl(206, 12%, 95%); --text-color: hsl(200, 35%, 25%); --color-primary: hsl(207, 90%, 54%); --outline-color: hsl(188, 100%, 54%); --button-bg: linear-gradient(to right, #e0e0e0, #bdbdbd); --button-bg-box-shadow-color: rgba(160, 160, 160, 0.5); --button-bg-primary: linear-gradient(to right, hsl(207, 90%, 61%), hsl(218, 100%, 58%)); --button-bg-primary-box-shadow-color: hsla(207, 90%, 61%, 0.5); --button-bg-secondary: linear-gradient(to right, hsl(262, 47%, 55%), hsl(265, 100%, 46%)); --button-bg-secondary-box-shadow-color: hsla(258, 58%, 42%, 0.5); --button-bg-danger: linear-gradient(to right, hsl(0, 84%, 58%), hsl(0, 78%, 50%)); --button-bg-danger-box-shadow-color: hsla(0, 58%, 42%, 0.5); --button-bg-success: linear-gradient(to right, hsl(130, 58%, 45%), hsl(130, 67%, 45%)); --button-bg-success-box-shadow-color: hsla(128, 80%, 48%, 0.5); --button-action-shadow-color: hsla(200, 15%, 60%, 0.12); --underline-color: hsl(200, 35%, 65%); --secondary-text-color: hsl(200, 14%, 60%); --send-icon-hover-color: hsl(262, 52%, 47%); --send-spinner-icon-color: hsl(200, 15%, 70%); --success-icon-color: hsl(144, 40%, 57%); --error-icon-color: hsl(0, 86%, 63%); --copy-icon-color: hsl(144, 40%, 57%); --copy-icon-bg-color: hsl(144, 100%, 96%); --copy-icon-shadow-color: hsla(200, 15%, 60%, 0.12); --focus-outline-color: hsla(207, 90%, 61%, 0.5); --checkbox-bg-color: hsl(262, 47%, 63%); --input-shadow-color: hsla(200, 15%, 70%, 0.2); --input-hover-shadow-color: hsla(200, 15%, 70%, 0.4); --input-label-color: hsl(200, 35%, 25%); --table-bg-color: hsl(200, 12%, 95%); --table-shadow-color: hsla(200, 20%, 70%, 0.3); --table-tr-border-color: hsl(200, 14%, 94%); --table-tr-hover-bg-color: hsl(200, 14%, 98%); --table-head-tr-border-color: hsl(200, 14%, 90%); --table-status-gray-bg-color: hsl(200, 12%, 95%); --keyframe-slidey-offset: 0; } /* ANIMATIONS */ @keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } @keyframes fadein { from { opacity: 0 } to { opacity: 1 } } @keyframes slidey { from { transform: translateY(var(--keyframe-slidey-offset)) } to { transform: translateY(0) } } @keyframes tooltip { to { opacity: 0.9; transform: translate(-50%, 0); } } /* GENERAL */ body { margin: 0; padding: 0; background-color: var(--bg-color); font: 16px/1.45 'Nunito', sans-serif; overflow-x: hidden; color: var(--text-color); } * { box-sizing: border-box; outline-color: var(--outline-color); } *::-moz-focus-inner { border: none; } .hidden { display: none; } hr { width: 100%; height: 2px; outline: none; border: none; background-color: hsl(200, 20%, 92%); } span.bold { font-weight: bold; } span.underline { border-bottom: 2px dotted #999; } .space-between { display: flex; justify-content: space-between; align-items: center; } .align-center { display: flex; align-items: center; } a, button.link { color: var(--color-primary); border-bottom: 1px dotted transparent; text-decoration: none; transition: all 0.2s ease-out; cursor: pointer; } a:hover, button.link:hover { border-bottom-color: var(--color-primary); } a.wrapper-only { color: inherit; } a.nav { color: inherit; padding-bottom: 2px; } a.nav:hover { color: var(--color-primary); } a.button, button { position: relative; width: auto; height: 40px; display: flex; align-items: center; justify-content: center; padding: 0 32px; font-size: 13px; font-weight: normal; text-align: center; line-height: 1; word-break: keep-all; color: #444; border: none; border-radius: 100px; transition: all 0.4s ease-out; cursor: pointer; overflow: hidden; background: var(--button-bg); box-shadow: 0 5px 6px var(--button-bg-box-shadow-color); } a.button.primary, button.primary { color: white; background: var(--button-bg-primary); box-shadow: 0 5px 6px var(--button-bg-primary-box-shadow-color); } a.button.secondary, button.secondary { color: white; background: var(--button-bg-secondary); box-shadow: 0 5px 6px var(--button-bg-secondary-box-shadow-color); } a.button.danger, button.danger { color: white; background: var(--button-bg-danger); box-shadow: 0 5px 6px var(--button-bg-danger-box-shadow-color); } a.button.success, button.success { color: white; background: var(--button-bg-success); box-shadow: 0 5px 6px var(--button-bg-success-box-shadow-color); } a.button:focus, a.button:hover, button:focus, button:hover { box-shadow: 0 6px 15px var(--button-bg-box-shadow-color); transform: translateY(-2px) scale(1.02, 1.02); } a.button.primary:focus, a.button.primary:hover, button.primary:focus, button.primary:hover { box-shadow: 0 6px 15px var(--button-bg-primary-box-shadow-color); } a.button.secondary:focus, a.button.secondary:hover, button.secondary:focus, button.secondary:hover { box-shadow: 0 6px 15px var(--button-bg-secondary-box-shadow-color); } a.button.danger:focus, a.button.danger:hover, button.danger:focus, button.danger:hover { box-shadow: 0 6px 15px var(--button-bg-danger-box-shadow-color); } a.button.success:focus, a.button.success:hover, button.success:focus, button.success:hover { box-shadow: 0 6px 15px var(--button-bg-success-box-shadow-color); } a.button:disabled, button:disabled { cursor: default; } a.button:disabled:hover, button:disabled:hover { transform: none; } a.button svg.with-text, a.button span svg, button svg.with-text, button span svg { width: 1.1em; height: auto; margin-right: 0.5rem; stroke: white; stroke-width: 2; } a.button.action, button.action { padding: 5px; width: 24px; height: 24px; box-shadow: 0 2px 1px var(--button-action-shadow-color); } a.button.action:disabled, button.action:disabled { background: none; box-shadow: none; } a.button.action svg, button.action svg { width: 100%; margin-right: 0; } a.button.action.delete, button.action.delete { background: hsl(0, 100%, 96%); } a.button.action.delete svg, button.action.delete svg { stroke-width: 2; stroke: hsl(0, 100%, 69%); } a.button.action.edit, button.action.edit { background: hsl(46, 100%, 94%); } a.button.action.edit svg, button.action.edit svg { stroke-width: 2.5; stroke: hsl(46, 90%, 50%); } a.button.action.qrcode, button.action.qrcode { background: hsl(0, 0%, 94%); } a.button.action.qrcode svg, button.action.qrcode svg { fill: hsl(0, 0%, 35%); stroke: none; } a.button.action.stats, button.action.stats { background: hsl(260, 100%, 96%); } a.button.action.stats svg, button.action.stats svg { stroke-width: 2.5; stroke: hsl(260, 100%, 69%); } a.button.action.ban, button.action.ban { background: hsl(10, 100%, 96%); } a.button.action.ban svg, button.action.ban svg { stroke-width: 2; stroke: hsl(10, 100%, 40%); } a.button.action.password sv, button.action.password svg, a.button.action.banned svg, button.action.banned svg { stroke-width: 2.5; stroke: #bbb; } button.nav { box-sizing: border-box; width: auto; height: 28px; display: flex; flex: 0 0 auto; align-items: center; justify-content: center; padding: 0 8px; border: none; border-radius: 4px; box-shadow: 0 0px 10px rgba(100, 100, 100, 0.1); background: none; background-color: white; transition: all 0.2s ease-in-out; font-size: 12px; cursor: pointer; } button.nav:disabled { background-color: #f6f6f6; box-shadow: 0 0px 5px rgba(150, 150, 150, 0.1); opacity: 0.9; color: #bbb; cursor: default; } button.nav svg { width: 14px; height: auto; } button.nav svg { stroke-width: 2.5; } button.nav:hover { transform: translateY(-2px); } button.nav:disabled:hover { transform: none; } button.table { height: 32px; padding: 0 1rem; font-size: 12px; border-radius: 3px; transition: all 0.2s ease-in-out; box-shadow: 0 1px 2px var(--button-bg-box-shadow-color); } button.table:hover { transform: translateY(-2px); box-shadow: 0 1px 2px var(--button-bg-box-shadow-color); } button.table.primary, button.primary:focus, button.primary:hover { box-shadow: 0 1px 2px var(--button-bg-primary-box-shadow-color); } button.table.secondary, button.secondary:focus, button.secondary:hover { box-shadow: 0 1px 2px var(--button-bg-secondary-box-shadow-color); } button.table.danger, button.danger:focus, button.danger:hover { box-shadow: 0 1px 2px var(--button-bg-danger-box-shadow-color); } button.table.success, button.success:focus, button.success:hover { box-shadow: 0 1px 2px var(--button-bg-success-box-shadow-color); } button.link { position: relative; width: auto; height: auto; display: flex; align-items: center; justify-content: flex-start; padding: 0 0 2px 0; font-size: 1rem; font-weight: normal; border-radius: 0; text-align: left; line-height: normal; word-break: normal; cursor: pointer; background: none; box-shadow: none; } button.link:hover { box-shadow: none; transform: none; } button.link span { height: 1rem; } button.link svg { stroke: var(--color-primary); } svg.spinner { animation: spin 1s linear infinite, fadein 0.3s ease-in-out; } input { filter: none; } input[type="text"], input[type="email"], input[type="password"] { box-sizing: border-box; width: 240px; height: 44px; padding: 0 24px; font-size: 15px; letter-spacing: 0.05em; color: #444; background-color: white; border: none; border-radius: 100px; border-bottom: 5px solid #f5f5f5; border-bottom-width: 5px; box-shadow: 0 10px 35px var(--input-shadow-color); transition: all 0.5s ease-out; } input[type="text"]:focus, input[type="email"]:focus, input[type="password"]:focus { outline: none; box-shadow: 0 20px 35px var(--input-hover-shadow-color); } input[type="text"]::placeholder, input[type="email"]::placeholder, input[type="password"]::placeholder { font-size: 14px; letter-spacing: 0.05em; color: #888; } .error input[type="text"], .error input[type="email"], .error input[type="password"] { border-bottom-color: rgba(250, 10, 10, 0.8); box-shadow: 0 10px 15px hsla(0, 100%, 75%, 0.2); } select { position: relative; width: 240px; height: 44px; padding: 0 24px; font-size: 15px; box-sizing: border-box; letter-spacing: 0.05em; color: #444; background-color: white; box-shadow: 0 10px 35px var(--input-shadow-color); border: none; border-radius: 100px; border-bottom: 5px solid #f5f5f5; border-bottom-width: 5px; transition: all 0.5s ease-out; appearance: none; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='48' height='48' viewBox='0 0 24 24' fill='none' stroke='%235c666b' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E"); background-repeat: no-repeat, repeat; background-position: right 1.2em top 50%, 0 0; background-size: 1em auto, 100%; } select:focus { outline: none; box-shadow: 0 20px 35px var(--input-hover-shadow-color) } .error select { border-bottom-color: rgba(250, 10, 10, 0.8); box-shadow: 0 10px 15px hsla(0, 100%, 75%, 0.2); } input[type="checkbox"] { position: relative; width: 1rem; height: 1rem; display: flex; align-items: center; justify-content: center; border-radius: 4px; background-color: white; box-shadow: 0 2px 4px rgba(50, 50, 50, 0.2); margin: 0; -webkit-appearance: none; appearance: none; cursor: pointer; } input[type="checkbox"]:focus { outline: 3px solid var(--focus-outline-color); } input[type="checkbox"]::after { content: ""; position: absolute; top: 50%; left: 50%; width: 80%; height: 80%; display: block; border-radius: 2px; background-color: var(--checkbox-bg-color); box-shadow: 0 2px 4px rgba(50, 50, 50, 0.2); cursor: pointer; opacity: 0; transform: translate(-50%, -50%) scale(0); transition: all 0.1s ease-in-out; } input[type="checkbox"]:checked:after { opacity: 1; transform: translate(-50%, -50%) scale(1); } input.table-input, select.table-input { width: auto; height: 32px; font-size: 13px; padding: 0 1.5rem; border-radius: 3px; border-bottom-width: 2px; } select.table-input { width: 150px; } input.table-input::placeholder { font-size: 13px; } select:has(option[value=""]:checked) { letter-spacing: 0.05em; color: #888; } label { display: flex; color: var(--input-label-color); font-size: 1rem; flex-direction: column; align-items: flex-start; font-weight: bold; } label input { margin-top: 0.5rem; } label.checkbox { flex-direction: row; align-items: center; cursor: pointer; font-weight: normal; } label.checkbox input[type="checkbox"] { margin: 0 0.75rem 2px 0; } p.error, p.success { display: flex; align-items: center; font-weight: normal; animation: fadein 0.3s ease-in-out; } p.error { color: red; } p.success { color: #0ea30e; } table { width: 100%; display: flex; flex-direction: column; background-color: white; border-radius: 12px; box-shadow: 0 6px 15px var(--table-shadow-color); text-align: center; overflow: auto; } table tr { flex: 1 1 auto; } table tr, table th, table td, table thead, table tfoot { display: flex; overflow: hidden; } table tbody, table tr { overflow: visible; } table tbody, table thead, table tfoot { flex-direction: column; } table tr { padding: 0 0.5rem; border-bottom: 1px solid var(--table-tr-border-color); } table th, table td { flex-basis: 0; padding: 0.75rem; } table td { position: relative; white-space: nowrap; font-size: 15px; align-items: center; } table tbody { border-bottom-right-radius: 12px; border-bottom-left-radius: 12px; animation: fadein 0.3s ease-in-out; } table tbody + tfoot { border: none; } table thead { background-color: var(--table-bg-color); border-top-right-radius: 12px; border-top-left-radius: 12px; font-weight: bold; } table thead tr { border-bottom: 1px solid var(--table-head-tr-border-color); } table tfoot { background-color: var(--table-bg-color); border-bottom-right-radius: 12px; border-bottom-left-radius: 12px; } table tr.loading-placeholder { flex: 1 1 auto; justify-content: center; animation: fadein 0.3s ease-in-out; } table tr.loading-placeholder td { flex: 0 0 auto; font-size: 18px; font-weight: 300; } table select { margin-right: 1rem; } table .tab { display: flex; align-items: center; } table .tab a { position: relative; display: flex; align-items: center; justify-content: center; padding: 0.4rem 1rem; margin: 0 0.5rem; font-size: 12px; color: var(--text-color); border: none; border-radius: 4px; background-color: white; cursor: pointer; box-shadow: 0 0px 10px rgba(100, 100, 100, 0.1); font-weight: normal; transition: all 0.2s ease-in-out; } table .tab a:first-child { margin-left: 0} table .tab a.active { background-color: #f6f6f6; box-shadow: 0 0px 5px rgba(150, 150, 150, 0.1); color: #aaa; font-weight: bold; opacity: 0.9; cursor: default; } table .tab a:not(.active):hover { transform: translateY(-2px); } .dialog { position: fixed; width: 100%; height: 100%; top: 0; left: 0; display: none; justify-content: center; align-items: center; background-color: rgba(50, 50, 50, 0.8); z-index: 1000; animation: fadein 0.2s ease-in-out; } .dialog.open { display: flex; } .dialog .box { min-width: 450px; max-width: 90%; display: flex; flex-direction: column; align-items: center; text-align: center; padding: 3rem 2rem; background-color: white; border-radius: 8px; --keyframe-slidey-offset: -30px; animation: slidey 0.2s ease-in-out; } .dialog.qrcode .box { min-width: auto; padding: 2rem; } .dialog .content-wrapper { display: flex; flex-direction: column; } .dialog .loading { display: none; width: 24px; height: 24px; margin: 3rem 0; animation: fadein 0.2s ease-in-out; } .dialog.htmx-request .loading { display: block; } .dialog.htmx-request .content-wrapper { display: none; } .dialog .loading svg { animation: spin 1s linear infinite; } .dialog .content { display: flex; flex-direction: column; animation: fadein 0.2s ease-in-out; } .dialog .content h2 { font-weight: bold !important; margin-bottom: 0.5rem !important; margin-top: 0; } .dialog .content .buttons { display: flex; align-items: center; margin-top: 1.5rem; } .dialog .content .buttons button { margin-right: 2rem; } .dialog .content .buttons button:last-of-type { margin-right: 0; } .dialog .content { align-items: center; } .dialog .content #dialog-error { margin-top: 1rem; margin-bottom: -1rem; } .dialog .content .icon { width: 48px; height: 48px; border-radius: 100%; padding: 5px; margin-bottom: 1.5rem; border: 2px solid; } .dialog .content .icon svg { width: 100%; height: auto; } .dialog .content .icon.success { border-color: var(--success-icon-color); } .dialog .content .icon.success svg { stroke-width: 2; stroke: var(--success-icon-color); } .dialog .content .icon.error { border-color: var(--error-icon-color); } .dialog .content .icon.error svg { stroke-width: 1.5; stroke: var(--error-icon-color); } .dialog .content svg.spinner { display: none; width: 24px; margin: 0.5rem 0; } .dialog .content.htmx-request svg.spinner { display: block; } .dialog .content.htmx-request button { display: none; } .dialog .content label { margin: 0.5rem 0; } .dialog .content input[type="text"], .dialog .content input[type="password"], .dialog .content input[type="email"], .dialog .content select { width: 320px; height: 48px; } .inputs { display: flex; align-items: flex-start; margin-bottom: 1rem; } .inputs label { flex: 0 0 0; margin-right: 1rem; } .inputs label:last-child { margin-right: 0; } .search-input-wrapper { position: relative; } .search-input-wrapper button { position: absolute; display: none; right: 0; top: 50%; width: auto; height: auto; padding: 3px; margin: 0; background-color: transparent; background: none; box-shadow: none; transform: translateY(-50%); cursor: pointer; margin-right: 0.25rem; transition: all 0.2s ease-in-out; } .search-input-wrapper button:hover { transform: translateY(-55%); } .search-input-wrapper svg { width: 0.9rem; height: auto; stroke-width: 2; stroke: #888; } [data-tooltip] { position: relative; overflow: visible; } [data-tooltip]:before, [data-tooltip]:after { position: absolute; left: 50%; display: none; font-size: 11px; line-height: 1; opacity: 0; transform: translate(-50%, -0.5rem); } [data-tooltip]:before { content: ""; border: 4px solid transparent; top: -4px; border-bottom-width: 0; border-top-color: #333; z-index: 1001; } [data-tooltip]:after { content: attr(data-tooltip); top: -25px; text-align: center; min-width: 1rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; padding: 5px 7px; border-radius: 4px; box-shadow: 0 1em 2em -0.5em rgba(0, 0, 0, 0.35); background: #333; color: #fff; z-index: 1000; } [data-tooltip]:hover:before, [data-tooltip]:hover:after { display: block; } [data-tooltip]:before, [data-tooltip]:after, [data-tooltip]:hover:before, [data-tooltip]:hover:after { animation: tooltip 300ms ease-out forwards; } /* DISTINCT */ .main-wrapper { min-height: 100vh; width: 100%; display: flex; flex: 0 0 auto; align-items: center; flex-direction: column; } .section-container { max-width: 90%; flex: 1 1 auto; display: flex; flex-direction: column; align-items: flex-start; margin-top: 1rem; } .htmx-spinner .spinner { display: none; } .htmx-spinner.htmx-request button svg { display: none; } .htmx-spinner.htmx-request .spinner { display: block; } /* LOGIN & SIGNUP */ form#login-signup { width: 420px; max-width: 100%; flex: 1 1 auto; display: flex; padding: 0 16px; flex-direction: column; margin: 3rem 0 0; } form#login-signup label { margin-bottom: 2rem; } form#login-signup input { width: 100%; height: 72px; margin-top: 1rem; padding: 0 3rem; font-size: 16px; } form#login-signup .buttons-wrapper { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.5rem; } form#login-signup .buttons-wrapper button, form#login-signup .buttons-wrapper a.button { height: 56px; flex: 0 0 48%; padding: 0 1rem 2px; margin: 0; } form#login-signup .buttons-wrapper button.full, form#login-signup .buttons-wrapper a.button { flex-basis: 100%; } form#login-signup a.forgot-password { align-self: flex-start; font-size: 14px; } form#login-signup svg.spinner { display: none; } form#login-signup.htmx-request:not(.signup) .login svg { display: none; } form#login-signup.htmx-request:not(.signup) .login svg.spinner { display: block; } form#login-signup.htmx-request.signup .signup svg { display: none; } form#login-signup.htmx-request.signup .signup svg.spinner { display: block; } form#login-signup.htmx-request .error { opacity: 0; } form#login-signup p.error { margin-bottom: 0; } .admin-form-title { font-size: 26px; font-weight: 300; margin: 0 0 3rem; text-align: center; } .login-signup-message { flex: 1 1 auto; margin-top: 3rem; } .login-signup-message h1 { font-weight: 300; font-size: 24px; } /* HEADER */ header { box-sizing: border-box; margin: 0; width: 1232px; max-width: 100%; padding: 0 32px; height: 102px; justify-content: space-between; align-items: center; display: flex; } header .logo-wrapper { display: flex; align-items: center; } header a.logo { position: relative; display: flex; align-items: center; font-size: 22px; font-weight: bold; text-decoration: none; border: none; margin: 0; padding: 0; } header a.logo:hover { border: none; color: inherit; } header .logo img { margin: 0 12px 0 0; padding: 0; } header ul.logo-links { list-style: none; display: flex; align-items: flex-end; margin: 0 0 0 0.5rem; padding: 0; } header ul.logo-links li { padding: 2px 0 0; margin: 0 0 0 32px; } header ul.logo-links li a { font-size: 1rem; } header nav ul { display: flex; flex-direction: row-reverse; align-items: center; list-style: none; margin: 0; padding: 0; } header nav ul li { margin: 0 0 0 2rem; padding: 0; } header nav ul li:last-child { margin-left: 0; } /* SHORTENER */ main { width: 800px; max-width: 100%; flex: 1 1 auto; display: flex; flex-direction: column; align-items: center; padding: 0 1rem; margin-top: 1rem; } main #shorturl { display: flex; align-items: center; margin: 1rem 0 3rem; } main #shorturl h1 { margin: 0; border-bottom: 2px dotted transparent; font-weight: 300; font-size: 2rem; } main #shorturl h1.link { cursor: pointer; border-bottom-color: var(--underline-color); transition: opacity 0.3s ease-in-out; --keyframe-slidey-offset: -10px; animation: fadein 0.2s ease-in-out, slidey 0.2s ease-in-out; } main #shorturl h1.link:hover { opacity: 0.8; } .clipboard { width: 35px; height: 35px; display: flex; margin-right: 1rem; } .clipboard button { width: 100%; height: 100%; display: flex; margin: 0; padding: 7px; box-shadow: none; outline: none; border: none; background: none; border-radius: 100%; background-color: var(--copy-icon-bg-color); transition: transform 0.4s ease-out; box-shadow: 0 2px 1px var(--copy-icon-shadow-color); cursor: pointer; --keyframe-slidey-offset: -10px; animation: slidey 0.2s ease-in-out; } .clipboard.small { width: 24px; height: 24px; } .clipboard.small button { width: 24px; height: 24px; padding: 5px; } .clipboard button:hover, .clipboard button:focus { transform: translateY(-2px) scale(1.02, 1.02); } .clipboard button:focus { outline: 3px solid var(--focus-outline-color); } .clipboard svg { stroke: var(--copy-icon-color); width: 100%; height: auto; } .clipboard svg.copy { stroke-width: 2.5; } .clipboard svg.check { display: none; padding: 3px; stroke-width: 3; --keyframe-slidey-offset: -10px; animation: slidey 0.2s ease-in-out; } .clipboard.copied button { background-color: transparent; box-shadow: none; } .clipboard.copied button { display: none; } .clipboard.copied svg.check { display: block; } main #shorturl h1 span { border-bottom: 1px dotted #999; } main form { position: relative; width: 100%; display: flex; flex-direction: column; } main form input#target { position: relative; width: 100%; height: 72px; display: flex; padding: 0 84px 0 40px; font-size: 20px; } main form input#target::placeholder { font-size: 17px; } main form p.error { font-size: 13px; margin-left: 0.5rem; } main form .target-wrapper p.error { font-size: 15px; margin-left: 1rem; margin-bottom: 0; } main form .target-wrapper { position: relative; width: 100%; height: auto; } main form button.submit { box-sizing: content-box; position: absolute; cursor: pointer; width: 28px; height: auto; right: 0; top: 16px; padding: 4px; margin: 0 2rem 0; background: none; box-shadow: none; outline: none; border: none; } main form button.submit:focus, main form button.submit:hover { outline: none; } main form button.submit svg.send { width: 100%; fill: #aaa; animation: fadein 0.3s ease-in-out; transition: fill 0.2s ease-in-out; } main form button.submit:hover svg.send { fill: var(--send-icon-hover-color); } main form button.submit svg.spinner { display: none; fill: none; stroke: var(--send-spinner-icon-color); stroke-width: 2; } main form.htmx-request button.submit svg.send { display: none; } main form.htmx-request button.submit svg.spinner { display: block; } main form label#advanced { margin-top: 2rem; align-self: flex-start; } main form label#advanced input { width: 1.1rem; height: 1.1rem; margin-bottom: 2px; } #advanced-options { display: flex; flex-direction: column; margin-top: 1.5rem; } #advanced-options.hidden { display: none; } .advanced-input-wrapper { width: 100%; display: flex; align-items: flex-start; margin-bottom: 1rem; } .advanced-input-wrapper label { flex: 1 1 0; padding-right: 1rem; } .advanced-input-wrapper label.expire-in { flex: 1 1 34%; } .advanced-input-wrapper label.description { flex: 1 1 65%; } .advanced-input-wrapper label:last-child { padding-right: 0; } .advanced-input-wrapper label input, .advanced-input-wrapper label select { width: 100%; margin-top: 0.5rem; } /* MAIN TABLE */ #main-table-wrapper { width: 1200px; max-width: 100%; display: flex; flex-direction: column; flex: 1 1 auto; align-items: flex-start; padding: 0 1rem; margin: 7rem 0 7.5rem; } #main-table-wrapper h2 { font-weight: 300; margin-bottom: 1rem; } #main-table-wrapper table thead, #main-table-wrapper table tbody, #main-table-wrapper table tfoot { min-width: 1000px; } #main-table-wrapper tr { padding: 0 0.5rem; } #main-table-wrapper th, #main-table-wrapper td { padding: 1rem; } #main-table-wrapper td { font-size: 1rem; } #main-table-wrapper table .original-url { flex: 7 7 0; } #main-table-wrapper table .created-at { flex: 2.5 2.5 0; } #main-table-wrapper table .short-link { flex: 3 3 0; } #main-table-wrapper.admin-table-wrapper table .short-link { overflow: visible; } #main-table-wrapper table .views { flex: 1 1 0; justify-content: flex-end; } #main-table-wrapper table .actions { flex: 3 3 0; justify-content: flex-end; overflow: visible; } #main-table-wrapper table .actions a.button, #main-table-wrapper table .actions button { margin-right: 0.5rem; } #main-table-wrapper table .actions a.button:last-child, #main-table-wrapper table .actions button:last-child { margin-right: 0; } #main-table-wrapper table .users-id { flex: 3 3 0; justify-content: flex-end; } #main-table-wrapper table .users-email { flex: 9 9 0; } #main-table-wrapper table .users-created-at { flex: 4 4 0; } #main-table-wrapper table .users-updated-at { flex: 4 4 0; } #main-table-wrapper table .users-verified { flex: 3 3 0; overflow: visible; } #main-table-wrapper table .users-role { flex: 2 2 0; overflow: visible; } #main-table-wrapper table .users-links-count { flex: 3 3 0; justify-content: flex-end; overflow: visible; } #main-table-wrapper table .users-actions { flex: 2 2 0; } #main-table-wrapper table .domains-id { flex: 2 2 0; justify-content: flex-end; } #main-table-wrapper table .domains-address { flex: 7 7 0; } #main-table-wrapper table .domains-homepage { flex: 5 5 0; } #main-table-wrapper table .domains-created-at { flex: 3 3 0; } #main-table-wrapper table .domains-links-count { flex: 3 3 0; justify-content: flex-end; overflow: visible; } #main-table-wrapper table .domains-actions { flex: 2 2 0; } #main-table-wrapper table td.original-url, #main-table-wrapper table td.created-at, #main-table-wrapper.admin-table-wrapper table td.short-link, #main-table-wrapper table td.users-email, #main-table-wrapper table td.domains-address, #main-table-wrapper table td.users-created-at, #main-table-wrapper table td.users-verified { flex-direction: column; align-items: flex-start; justify-content: center; } table .short-link-wrapper { display: flex; align-items: center; } #main-table-wrapper table td .description { display: flex; align-items: center; margin: 0; font-size: 14px; color: #888; } #main-table-wrapper table td .description a { color: #aaa; border-bottom-color: #aaa; } #main-table-wrapper table td .description svg { stroke: #aaa; stroke-width: 2; width: 0.85em; margin-right: 0.25rem; } #main-table-wrapper table td .description span { color: #aaa; } #main-table-wrapper table td .description a:hover { border-bottom-color: transparent; } #main-table-wrapper table .status { font-size: 11px; font-weight: bold; padding: 4px 12px; border-radius: 12px; margin-top: 0.25rem; } #main-table-wrapper table .status:first-child { margin-top: 0; } #main-table-wrapper table .status.gray { background-color: var(--table-status-gray-bg-color); } #main-table-wrapper table .status.green { background-color: hsl(102.4, 100%, 93.3%); } #main-table-wrapper table .status.red { background-color: hsl(0, 100%, 96.7%); } #main-table-wrapper table tr.no-data { flex: 1 1 auto; justify-content: center; animation: fadein 0.3s ease-in-out; } #main-table-wrapper table.htmx-request tbody tr { opacity: 0.5; } #main-table-wrapper table tr.loading-placeholder { opacity: 0.6 !important; } #main-table-wrapper table tr.loading-placeholder td, #main-table-wrapper table tr.no-data td { flex: 0 0 auto; font-size: 18px; font-weight: 300; } #main-table-wrapper table tr.loading-placeholder svg.spinner { width: 1rem; height: auto; margin-right: 0.5rem; stroke-width: 1.5; } #main-table-wrapper table thead tr.controls { justify-content: space-between; } #main-table-wrapper table thead tr.controls.with-filters { align-items: flex-end; } #main-table-wrapper table tfoot tr.controls { justify-content: flex-end; } #main-table-wrapper table th.search { flex: 1 1 auto; align-items: center; } #main-table-wrapper table th.filters { flex: 1 1 auto; flex-direction: column; align-items: start; } #main-table-wrapper table th.filters > div { display: flex; align-items: center; margin-bottom: 1rem; } #main-table-wrapper table th.filters > div:last-child { margin-bottom: 0; } #main-table-wrapper table th.nav { flex: 0 0 auto; align-items: center; } #main-table-wrapper table tr.controls .checkbox { margin-left: 1rem; font-size: 15px; } #main-table-wrapper table .limit, #main-table-wrapper table .pagination { display: flex; align-items: center; } #main-table-wrapper table button.nav { margin-right: 0.75rem; } #main-table-wrapper table button.nav:last-child { margin-right: 0; } #main-table-wrapper table .nav-divider { height: 20px; width: 1px; opacity: 0.4; background-color: #888; margin: 0 1.5rem; } #main-table-wrapper table tbody tr:hover { background-color: var(--table-tr-hover-bg-color); } #main-table-wrapper table tbody td.right-fade:after { content: ""; position: absolute; right: 0; top: 0; height: 100%; width: 16px; background: linear-gradient(to left, white, rgba(255, 255, 255, 0.001)); } #main-table-wrapper table tbody tr:hover td.right-fade:after { background: linear-gradient(to left, var(--table-tr-hover-bg-color), rgba(255, 255, 255, 0.001)); } #main-table-wrapper table .clipboard { margin-right: 0.5rem; } #main-table-wrapper table .clipboard svg.check { width: 24px; } #main-table-wrapper table tr.edit { background-color: #fafafa; } #main-table-wrapper table tr.edit td { width: 100%; padding: 2rem 1.5rem; flex-basis: auto; } #main-table-wrapper table tr.edit td form { width: 100; display: flex; flex-direction: column; align-items: flex-start; } #main-table-wrapper table tr.edit td form > div { width: 100%; display: flex; align-items: start; } #main-table-wrapper table tr.edit label { margin: 0 0.5rem 1rem; } #main-table-wrapper table tr.edit label:first-child { margin-left: 0; } #main-table-wrapper table tr.edit label:last-child { margin-right: 0; } #main-table-wrapper table tr.edit input { height: 44px; padding: 0 1.5rem; font-size: 15px; } #main-table-wrapper table tr.edit input, #main-table-wrapper table tr.edit input + p { width: 240px; max-width: 100%; font-size: 14px; text-wrap: wrap; text-align: left; } #main-table-wrapper table tr.edit input[name="target"], #main-table-wrapper table tr.edit input[name="description"], #main-table-wrapper table tr.edit input[name="target"] + p, #main-table-wrapper table tr.edit input[name="description"] + p { width: 420px; } #main-table-wrapper table tr.edit button { height: 38px; margin-right: 1rem; } #main-table-wrapper table tr.edit button:last-child { margin-right: 0; } #main-table-wrapper table tr.edit form { --keyframe-slidey-offset: -5px; animation: fadein 0.3s ease-in-out, slidey 0.32s ease-in-out; } #main-table-wrapper table tr.edit form.no-animation { animation: none; } #main-table-wrapper table tr.edit { display: none; } #main-table-wrapper table tr.edit.show { display: flex; } #main-table-wrapper table tr.edit td.loading { display: none; } #main-table-wrapper table tr.edit.htmx-request td.loading { display: block; } #main-table-wrapper table tr.edit td.loading svg { width: 16px; height: 16px; } #main-table-wrapper table tr.edit form.htmx-request button .reload { display: none; } #main-table-wrapper table tr.edit form button .loader { display: none; } #main-table-wrapper table tr.edit form.htmx-request button .loader { display: inline-block; } #main-table-wrapper table tr.edit form .response p { margin: 2rem 0 0; } #main-table-wrapper table tr.edit p.no-data { width: 100%; text-align: center; } .dialog .ban-checklist { display: flex; align-items: center; } .dialog .ban-checklist label { margin: 1rem 1.5rem 1rem 0; } .dialog .ban-checklist label:last-child { margin-right: 0; } #main-table-wrapper tr.category { justify-content: space-between; align-items: center; } #main-table-wrapper th.category-total { flex: 1 1 auto; } #main-table-wrapper th.category-total p { margin: 0; font-size: 15px; font-weight: normal } #main-table-wrapper th.category-tab { flex: 2 2 auto; justify-content: flex-end; } /* ADMIN */ table .search-input-wrapper { margin-right: 1rem; } input.search.admin { max-width: 200px; } .content.admin-create form { display: flex; flex-direction: column; } .content.admin-create .checkbox-wrapper { display: flex; align-items: center; } .content.admin-create .checkbox-wrapper label { margin-right: 1rem; } .content.admin-create .buttons { justify-content: center; } .content.admin-create .buttons button { flex: 1 1 auto; } /* FOOTER */ footer { width: 100%; display: flex; justify-content: center; padding: 1rem 0; font-size: 13px; text-align: center; } footer button.link { display: inline-block; font-size: 13px; } footer button.link .spinner { display: none; width: 1em; margin: 0 0 -2px; } footer button.link.htmx-request .spinner { display: inline; } /* SETTINGS */ #settings { width: 600px; max-width: 100%; padding: 0 16px; overflow: hidden; } h1.settings-welcome { font-size: 28px; font-weight: 300; } h1.settings-welcome span { border-bottom: 2px dotted #999; padding-bottom: 2px; font-weight: normal; } /* SETTINGS - DOMAIN */ #domains-table { margin-top: 1rem; } #domains-table .domain { flex: 2 2 0; } #domains-table .homepage { flex: 2 2 0; } #domains-table .actions { flex: 1 1 0; justify-content: flex-end; padding-right: 1rem; } #domains-table .no-entry { flex: 1 1 0; opacity: 0.8; } .add-domain-wrapper { width: 100%; display: flex; flex-direction: column; align-items: flex-start; margin: 1.5rem 0 2rem; } .add-domain-wrapper > .spinner { width: 20px; display: none; margin: 1rem 0 0 1rem; } .add-domain-wrapper.htmx-request > button { display: none; } .add-domain-wrapper.htmx-request > .spinner { display: block; } form#add-domain { margin-top: 1rem; } form#add-domain .buttons-wrapper { display: flex; } form#add-domain button { margin-right: 1rem } form#add-domain .spinner { width: 20px; display: none; } form#add-domain.htmx-request .buttons-wrapper { display: none; } form#add-domain.htmx-request .spinner { display: block; } form#add-domain .error { font-size: 0.85rem; } /* SETTINGS - API */ #apikey-wrapper { margin-bottom: 1.5rem; } #apikey { display: flex; align-items: center; margin-bottom: 1rem; } #apikey p { font-weight: bold; border-bottom: 1px dotted #999; transition: opacity 0.2s ease-in-out; cursor: pointer; line-break: anywhere; } #apikey p:hover { opacity: 0.8; } form#generate-apikey .spinner { display: none; } form#generate-apikey.htmx-request svg { display: none; } form#generate-apikey.htmx-request .spinner { display: block; } /* SETTINGS - CHANGE PASSWORD */ #change-password-wrapper { margin-bottom: 1.5rem; } form#change-password { margin-top: 1.5rem; } form#change-password button { margin-top: 1rem; } form#change-password .spinner { display: none; } form#change-password.htmx-request svg { display: none; } form#change-password.htmx-request .spinner { display: block; } /* SETTINGS - CHANGE EMAIL */ #change-email-wrapper { margin-bottom: 1.5rem; } form#change-email { margin-top: 1.5rem; } form#change-email button { margin-top: 1rem; } form#change-email .spinner { display: none; } form#change-email.htmx-request svg { display: none; } form#change-email.htmx-request .spinner { display: block; } /* SETTINGS - DELETE ACCOUNT */ #delete-account-wrapper { margin-bottom: 1.5rem; } form#delete-account { margin-top: 1.5rem; } form#delete-account button { margin-top: 1rem; } form#delete-account .spinner { display: none; } form#delete-account.htmx-request svg { display: none; } form#delete-account.htmx-request .spinner { display: block; } /* STATS */ #stats-section { width: 1200px; max-width: 100%; padding: 0 16px; } .loading-stats { width: 100%; flex: 1 1 0; margin-top: -5rem; display: flex; align-items: center; justify-content: center; } .loading-stats .spinner { width: 1.25rem; margin-right: 0.5rem; } .stats-info { width: 100%; display: flex; align-items: flex-end; justify-content: space-between; } .stats-info h2 { font-weight: 300; font-size: 24px; } .stats-info p { font-size: 14px; } .stats-info h2, .stats-info p { margin: 0 } #stats { width: 100%; display: flex; flex-direction: column; align-items: stretch; background-color: white; border-radius: 12px; box-shadow: 0 6px 15px var(--table-shadow-color); overflow: hidden; padding: 0; } .stats-head { width: 100%; display: flex; align-items: center; background-color: var(--table-bg-color); justify-content: space-between; padding: 0.75rem 1.5rem; } .total-number { font-weight: bold; } .stats-nav { display: flex; align-items: center; } .stats-nav button { margin-right: 0.75rem; } .stats-nav button:last-child { margin-right: 0; } .stats-period { display: flex; flex-direction: column; align-items: stretch; padding: 0.75rem 1.5rem; } .stats-period h2 { font-size: 24px; font-weight: 300; margin: 1rem 0 0; } .stats-period span.total-in-period { font-weight: bold; border-bottom: 1px dotted var(--underline-color); } p.last-update { font-size: 14px; color: var(--secondary-text-color); margin: 0.75rem 0 0; } #stats canvas { width: 100%; margin: 2rem 0; } .stats-columns-wrapper { display: flex; align-items: flex-start; } .stats-columns-wrapper > div { flex: 1 1 50%; } svg.map { width: 100%; } svg.map path { fill: hsl(200, 15%, 92%); stroke: #fff; transition: all 0.1s ease-in-out; } svg.map path.color-1 { fill: hsl(261, 46%, 90%); } svg.map path.color-2 { fill: hsl(261, 46%, 86%); } svg.map path.color-3 { fill: hsl(261, 46%, 82%); } svg.map path.color-4 { fill: hsl(261, 46%, 76%); } svg.map path.color-5 { fill: hsl(261, 46%, 72%); } svg.map path.color-6 { fill: hsl(261, 46%, 68%); } svg.map path.active { stroke: hsl(261, 46%, 50%); stroke-width: 1.5; } #map-tooltip { position: fixed; } #map-tooltip.visible::before, #map-tooltip.visible::after { display: block !important; } #map-tooltip:before { border-top-color: rgba(255, 255, 255, 0.95); } #map-tooltip:after { box-shadow: 0 1em 2em -0.5em rgba(0, 0, 0, 0.15); background: rgba(255, 255, 255, 0.95); color: #333; } .stats-back-to-home { width: 100%; display: flex; justify-content: center; margin: 2rem 0; } .stats-error { width: 100%; flex: 1 1 auto; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; } .stats-error p { margin-top: -3rem; display: flex; align-items: center; } .stats-error p svg { width: 1.2rem; margin: 0 0.5rem 0.1rem 0; } .stats-error .stats-back-to-home { margin-top: 0 } /* 404 - NOT FOUND */ #notfound { width: 800px; align-items: center; } #notfound h2 { font-size: 28px; font-weight: 300; text-align: center; } /* BANNED */ #banned { width: 1200px; align-items: center; text-align: center } #banned h2 { font-weight: normal; } #banned h4 { font-weight: normal; margin: 0; } /* REPORT */ #report { width: 600px; } #report form { display: flex; flex-direction: column; margin-top: 2rem; } #report form .inputs-wrapper { display: flex; align-items: flex-end; } #report form button { margin: 0 0 0.2rem 1rem; } #report form .spinner { display: none; } #report form.htmx-request svg { display: none; } #report form.htmx-request .spinner { display: block; } #report-email .spinner { display: none; } #report-email .htmx-request svg { display: none; } #report-email .htmx-request .spinner { display: block; } .eye-icon svg { stroke-width: 0.5; } /* RESET PASSWORD */ #reset-password form { width: 100%; display: flex; flex-direction: column; } #reset-password form .inputs-wrapper { display: flex; align-items: flex-end; margin-top: 2rem; } #reset-password form label { flex: 0 0 280px; } #reset-password form label input { width: 100%; } #reset-password form button { margin: 0 0 0.2rem 1rem; } #new-password h2 { margin-bottom: 0.5rem; } #new-password p { margin-bottom: 1.5rem; } #new-password-form label { margin-bottom: 1.5rem; } #new-password-form label input { width: 280px; } #new-password form { width: 420px; max-width: 100%; flex: 1 1 auto; display: flex; padding: 0 16px; flex-direction: column; } #new-password form label { margin-bottom: 2rem; } #new-password form input { width: 100%; height: 72px; margin-top: 1rem; padding: 0 3rem; font-size: 16px; } #new-password form button { height: 56px; padding: 0 1rem 2px; margin: 0; } /* VERIFY USER */ /* VERIFY CHANGE EMAIL */ /* RESET PASSWORD TOKEN */ .verify-page { width: 600px; align-items: center; } .verify-page h2, .verify-page h3 { display: flex; align-items: center; text-align: center; font-weight: normal; } .verify-page h2 svg, .verify-page h3 svg { width: 1.15em; height: auto; margin-right: 0.5rem; } /* URL INFO */ #url-info { width: 1200px; align-items: center; text-align: center; padding: 0 16px; } #url-info h3 { font-weight: normal; margin: 0; } #url-info p { line-break: anywhere; } /* PROTECTED */ #protected { width: 600px; } #protected form { width: 100%; margin-top: 1rem; } #protected form .inputs-wrapper { width: 100%; display: flex; align-items: flex-end; } #protected form label { flex: 0 0 280px; } #protected form label input { width: 100%; } #protected form button { margin: 0 0 0.2rem 1rem; } #protected form .spinner { display: none; } #protected form.htmx-request svg { display: none; } #protected form.htmx-request .spinner { display: block; } /* TERMS */ #terms { width: 600px; } /* ERROR PAGE */ #error-page { align-items: center; text-align: center; } #error-page h2 { margin: 0; } #error-page .back-to-home { margin-top: 2rem; } /* RESPONSIVE STYLES */ @media only screen and (max-width: 768px) { html, body { font-size: 14px; } input[type="text"], input[type="email"], input[type="password"], select { font-size: 14px; padding: 0 16px; height: 38px; letter-spacing: 0.04em; border-bottom-width: 4px; } label input { margin-top: 0.25rem; } input[type="text"]::placeholder, input[type="email"]::placeholder, input[type="password"]::placeholder { font-size: 13px; letter-spacing: 0.04em; } table tr { padding: 0 0.25rem; } table th, table td { padding: 0.5rem; } table td { font-size: 14px; } table tr.loading-placeholder td { font-size: 16px; } a.button, button { height: 32px; padding: 0 22px; font-size: 12px; } a.button.action, button.action { padding: 4px; width: 20px; height: 20px; } button.nav { height: 26px; padding: 0 7px; font-size: 11px; } .dialog .box { min-width: 300px; padding: 2rem 1.25rem; } .dialog.qrcode .box { padding: 1.5rem; } .dialog .loading { width: 20px; height: 20px; margin: 2rem 0; } .dialog .content .buttons { margin-top: 1rem; } header { padding: 16px 16px 0; height: 72px; } header a.logo { font-size: 20px; } header ul.logo-links { display: none; } header .logo img { margin-right: 8px; } header nav ul li { margin-left: 0.75rem } header nav ul li a.button { height: 28px; padding: 0 1rem; font-size: 11px; } form#login-signup label { margin-bottom: 1.5rem; } form#login-signup input { height: 58px; margin-top: 0.75rem; padding: 0 2rem; font-size: 15px; } form#login-signup .buttons-wrapper { margin-bottom: 1rem; } form#login-signup .buttons-wrapper button { height: 44px; } form#login-signup a.forgot-password { font-size: 13px; } .login-signup-message { margin-top: 1.5rem; } .login-signup-message h1 { font-size: 20px; } main #shorturl { margin-bottom: 1.5rem; } main #shorturl h1 { font-size: 1.6rem; } .clipboard { width: 30px; height: 30px; margin-right: 0.5rem; } .clipboard svg.check { padding: 2px; } main form input#target { height: 58px; padding: 0 58px 0 26px; font-size: 15px; } main form input#target::placeholder { font-size: 14px; } main form p.error { font-size: 12px; margin-left: 0.25rem; } main form .target-wrapper p.error { font-size: 13px; margin-left: 0.5rem; } main form button.submit { width: 22px; top: 13px; margin: 0 1rem 0; } main form label#advanced { margin-top: 1.5rem; } main form label#advanced input { margin-bottom: 3px; } #main-table-wrapper { margin: 4rem 0 4.5rem;} #main-table-wrapper h2 { margin-bottom: 0.5rem; } #main-table-wrapper table thead, #main-table-wrapper table tbody, #main-table-wrapper table tfoot { min-width: 800px; } #main-table-wrapper tr { padding: 0 0.25rem; } #main-table-wrapper th, #main-table-wrapper td { padding: 0.75rem; } #main-table-wrapper table .actions a.button, #main-table-wrapper table .actions button { margin-right: 0.3rem; } #main-table-wrapper table td p.description { font-size: 12px; } #main-table-wrapper table tr.no-data td { font-size: 16px; } #main-table-wrapper.admin-table-wrapper table th.nav { flex-direction: column; align-items: flex-end; } #main-table-wrapper.admin-table-wrapper table th .nav-divider { display: none; } #main-table-wrapper.admin-table-wrapper table th .limit { margin-bottom: 1rem; } table .tab a { padding: 0.3rem 0.9rem; } #main-table-wrapper th.category-total p { font-size: 13px; } #main-table-wrapper table thead tr.controls.with-filters { align-items: flex-start; } #main-table-wrapper table th select, input.table-input { height: 28px; font-size: 12px; padding: 0 1rem; } #main-table-wrapper table th select { background-position: right 0.7em top 50%, 0 0; } .search-input-wrapper button { padding: 2px; margin-right: 0.15rem; } #main-table-wrapper table th input.search.admin { max-width: 150px; padding: 0 1.5rem 0 1rem; } #main-table-wrapper table th select.table-input { max-width: 120px; } #main-table-wrapper table th button.table { height: 28px; } #main-table-wrapper table th input::placeholder { font-size: 12px; } #main-table-wrapper table tr.controls .checkbox { font-size: 13px; } #main-table-wrapper table button.nav { margin-right: 0.5rem; } #main-table-wrapper table .nav-divider { height: 18px; margin: 0 1rem; } #main-table-wrapper table tbody td.right-fade:after { width: 14px; } #main-table-wrapper table tr.edit td { padding: 1.25rem 1rem; } #main-table-wrapper table tr.edit label { margin: 0 0.25rem 0.5rem; } #main-table-wrapper table tr.edit input { height: 38px; padding: 0 1rem; font-size: 13px; } #main-table-wrapper table tr.edit input, #main-table-wrapper table tr.edit input + p { width: 200px; } #main-table-wrapper table tr.edit input[name="target"], #main-table-wrapper table tr.edit input[name="description"], #main-table-wrapper table tr.edit input[name="target"] + p, #main-table-wrapper table tr.edit input[name="description"] + p { width: 320px; } #main-table-wrapper table tr.edit button { height: 32px; margin-right: 0.5rem; } #main-table-wrapper table tr.edit td.loading svg { width: 14px; height: 14px; } #main-table-wrapper table tr.edit form .response p { margin: 1rem 0 0; } .dialog .ban-checklist label { margin: 0.5rem 1rem 0.5rem 0; } footer { padding: 0.75rem 0; font-size: 12px; } footer button.link { font-size: 12px; } h1.settings-welcome { font-size: 18px; } .add-domain-wrapper { margin: 1rem 0 1rem; } .add-domain-wrapper > .spinner { width: 18px; margin: 0.5rem 0 0 0.5rem; } form#add-domain { margin-top: 0.75rem; } form#add-domain button { margin-right: 0.5rem } .stats-info { flex-direction: column; align-items: flex-start; justify-content: flex-start; } .stats-info h2 { font-size: 18px; margin-bottom: 0.25rem; } .stats-info p { font-size: 11px; line-break: anywhere; } .stats-head { padding: 0rem 1rem; } .stats-head p { font-size: 0.9rem; } .stats-nav button { margin-right: 0.5rem; } .stats-period { padding: 0.5rem 1rem; } .stats-period h2 { font-size: 18px; margin: 0.5rem 0 0; } p.last-update { font-size: 12px; } #stats canvas { margin: 1rem 0; } .stats-columns-wrapper { flex-direction: column; } .stats-columns-wrapper > div { flex-basis: 100%; } #notfound h2 { font-size: 20px; } #report form { margin-top: 1.5rem; } #report form .inputs-wrapper { flex-direction: column; align-items: flex-start; } #report form button { margin: 0.75rem 0 0.2rem 0; } #reset-password form .inputs-wrapper { flex-direction: column; align-items: flex-start; margin-top: 1rem; } #reset-password form label { flex-basis: 0; width: 280px; } #reset-password form button { margin: 0.75rem 0 0.2rem 0; } #new-password form label { margin-bottom: 1.5rem; } #new-password form input { height: 58px; margin-top: 0.75rem; padding: 0 2rem; font-size: 15px; } #new-password form button { height: 44px; } .verify-page h2, .verify-page h3 { display: flex; flex-direction: column; } #protected form { margin-top: 0.5rem; } #protected form .inputs-wrapper { flex-direction: column; align-items: flex-start; } #protected form label { flex-basis: 0; width: 280px; } #protected form button { margin: 0.75rem 0 0.2rem 0; } } @media only screen and (max-width: 640px) { table tr.loading-placeholder { justify-content: flex-start; } .inputs { flex-direction: column; margin-bottom: 0.75rem; } .inputs label { margin: 0 0 0.75rem; } .inputs label:last-child { margin: 0; } .advanced-input-wrapper { flex-direction: column; margin-bottom: 0; } .advanced-input-wrapper label { width: 100%; margin-bottom: 0.75rem; padding-right: 0; } .advanced-input-wrapper label input, .advanced-input-wrapper label select { margin-top: 0.5rem; } form#add-domain .spinner { width: 18px; } #apikey-wrapper { max-width: 100%; } #apikey p { font-size: 0.85rem; } #apikey .clipboard { width: 22px; height: 22px; } } ================================================ FILE: static/manifest.webmanifest ================================================ { "name": "Kutt", "short_name": "Kutt", "theme_color": "#f3f3f3", "background_color": "#f3f3f3", "display": "standalone", "description": "Kutt.it is a free and open source URL shortener with custom domains and stats.", "Scope": "/", "start_url": "/", "icons": [ { "src": "images/icons/icon-72x72.png", "sizes": "72x72", "type": "image/png" }, { "src": "images/icons/icon-96x96.png", "sizes": "96x96", "type": "image/png" }, { "src": "images/icons/icon-128x128.png", "sizes": "128x128", "type": "image/png" }, { "src": "images/icons/icon-144x144.png", "sizes": "144x144", "type": "image/png" }, { "src": "images/icons/icon-152x152.png", "sizes": "152x152", "type": "image/png" }, { "src": "images/icons/icon-192x192.png", "sizes": "192x192", "type": "image/png" }, { "src": "images/icons/icon-384x384.png", "sizes": "384x384", "type": "image/png" }, { "src": "images/icons/icon-512x512.png", "sizes": "512x512", "type": "image/png" } ], "splash_pages": null } ================================================ FILE: static/robots.txt ================================================ User-agent: * Disallow: / ================================================ FILE: static/scripts/main.js ================================================ // log htmx on dev // htmx.logAll(); // add text/html accept header to receive html instead of json for the requests document.body.addEventListener("htmx:configRequest", function(evt) { evt.detail.headers["Accept"] = "text/html,*/*"; }); // redirect to homepage document.body.addEventListener("redirectToHomepage", function() { setTimeout(() => { window.location.replace("/"); }, 1500); }); // reset form if event is sent from the backend function resetForm(id) { return function() { const form = document.getElementById(id); if (!form) return; form.reset(); } } document.body.addEventListener("resetChangePasswordForm", resetForm("change-password")); document.body.addEventListener("resetChangeEmailForm", resetForm("change-email")); // an htmx extension to use the specifed params in the path instead of the query or body htmx.defineExtension("path-params", { onEvent: function(name, evt) { if (name === "htmx:configRequest") { evt.detail.path = evt.detail.path.replace(/{([^}]+)}/g, function(_, param) { var val = evt.detail.parameters[param] delete evt.detail.parameters[param] return val === undefined ? "{" + param + "}" : encodeURIComponent(val) }) } } }) // find closest element function closest(selector, elm) { let element = elm || this; while (element && element.nodeType === 1) { if (element.matches(selector)) { return element; } element = element.parentNode; } return null; }; // get url query param function getQueryParams() { const search = window.location.search.replace("?", ""); const query = {}; search.split("&").map(q => { const keyvalue = q.split("="); query[keyvalue[0]] = keyvalue[1]; }); return query; } // trim text function trimText(selector, length) { const element = document.querySelector(selector); if (!element) return; let text = element.textContent; if (typeof text !== "string") return; text = text.trim(); if (text.length > length) { element.textContent = text.split("").slice(0, length).join("") + "..."; } } function formatDateHour(selector) { const element = document.querySelector(selector); if (!element) return; const dateString = element.dataset.date; if (!dateString) return; const date = new Date(dateString); element.textContent = date.getHours() + ":" + date.getMinutes(); } // show QR code function handleQRCode(element, id) { const dialog = document.getElementById(id); const dialogContent = dialog.querySelector(".content-wrapper"); if (!dialogContent) return; openDialog(id, "qrcode"); dialogContent.textContent = ""; const qrcode = new QRCode(dialogContent, { text: element.dataset.url, width: 200, height: 200, colorDark : "#000000", colorLight : "#ffffff", correctLevel : QRCode.CorrectLevel.H }); } // copy the link to clipboard function handleCopyLink(element) { navigator.clipboard.writeText(element.dataset.url); } // copy the link and toggle copy button style function handleShortURLCopyLink(element) { handleCopyLink(element); const clipboard = element.parentNode.querySelector(".clipboard") || closest(".clipboard", element); if (!clipboard || clipboard.classList.contains("copied")) return; clipboard.classList.add("copied"); setTimeout(function() { clipboard.classList.remove("copied"); }, 1000); } // open and close dialog function openDialog(id, name) { const dialog = document.getElementById(id); if (!dialog) return; dialog.classList.add("open"); if (name) { dialog.classList.add(name); } } function closeDialog() { const dialog = document.querySelector(".dialog"); if (!dialog) return; while (dialog.classList.length > 0) { dialog.classList.remove(dialog.classList[0]); } dialog.classList.add("dialog"); } window.addEventListener("click", function(event) { const dialog = document.querySelector(".dialog"); if (dialog && event.target === dialog) { closeDialog(); } }); // handle navigation in the table of links function setLinksLimit(event) { const buttons = Array.from(document.querySelectorAll("table .nav .limit button")); const limitInput = document.querySelector("#limit"); if (!limitInput || !buttons || !buttons.length) return; limitInput.value = event.target.textContent; buttons.forEach(b => { b.disabled = b.textContent === event.target.textContent; }); } function setLinksSkip(event, action) { const buttons = Array.from(document.querySelectorAll("table .nav .pagination button")); const limitElm = document.querySelector("#limit"); const totalElm = document.querySelector("#total"); const skipElm = document.querySelector("#skip"); if (!buttons || !limitElm || !totalElm || !skipElm) return; const skip = parseInt(skipElm.value); const limit = parseInt(limitElm.value); const total = parseInt(totalElm.value); skipElm.value = action === "next" ? skip + limit : Math.max(skip - limit, 0); document.querySelectorAll(".pagination .next").forEach(elm => { elm.disabled = total <= parseInt(skipElm.value) + limit; }); document.querySelectorAll(".pagination .prev").forEach(elm => { elm.disabled = parseInt(skipElm.value) <= 0; }); } function updateLinksNav() { const totalElm = document.querySelector("#total"); const skipElm = document.querySelector("#skip"); const limitElm = document.querySelector("#limit"); if (!totalElm || !skipElm || !limitElm) return; const total = parseInt(totalElm.value); const skip = parseInt(skipElm.value); const limit = parseInt(limitElm.value); document.querySelectorAll(".pagination .next").forEach(elm => { elm.disabled = total <= skip + limit; }); document.querySelectorAll(".pagination .prev").forEach(elm => { elm.disabled = skip <= 0; }); } function resetTableNav() { const totalElm = document.querySelector("#total"); const skipElm = document.querySelector("#skip"); const limitElm = document.querySelector("#limit"); if (!totalElm || !skipElm || !limitElm) return; skipElm.value = 0; limitElm.value = 10; const total = parseInt(totalElm.value); const skip = parseInt(skipElm.value); const limit = parseInt(limitElm.value); document.querySelectorAll(".pagination .next").forEach(elm => { elm.disabled = total <= skip + limit; }); document.querySelectorAll(".pagination .prev").forEach(elm => { elm.disabled = skip <= 0; }); document.querySelectorAll("table .nav .limit button").forEach(b => { b.disabled = b.textContent === limit.toString(); }); } // tab click function setTab(event, targetId) { const tabs = Array.from(closest("nav", event.target).children); tabs.forEach(function (tab) { tab.classList.remove("active"); }); if (targetId) { document.getElementById(targetId).classList.add("active"); } else { event.target.classList.add("active"); } } // show clear search button function onSearchChange(event) { const clearButton = event.target.parentElement.querySelector("button.clear"); if (!clearButton) return; clearButton.style.display = event.target.value.length > 0 ? "block" : "none"; } function clearSeachInput(event) { event.preventDefault(); const button = closest("button", event.target); const input = button.parentElement.querySelector("input"); if (!input) return; input.value = ""; button.style.display = "none"; htmx.trigger("body", "reloadMainTable"); } // detect if search inputs have value on load to show clear button function onSearchInputLoad() { const linkSearchInput = document.getElementById("search"); if (!linkSearchInput) return; const linkClearButton = linkSearchInput.parentElement.querySelector("button.clear") linkClearButton.style.display = linkSearchInput.value.length > 0 ? "block" : "none"; const userSearchInput = document.getElementById("search_user"); if (!userSearchInput) return; const userClearButton = userSearchInput.parentElement.querySelector("button.clear") userClearButton.style.display = userSearchInput.value.length > 0 ? "block" : "none"; const domainSearchInput = document.getElementById("search_domain"); if (!domainSearchInput) return; const domainClearButton = domainSearchInput.parentElement.querySelector("button.clear") domainClearButton.style.display = domainSearchInput.value.length > 0 ? "block" : "none"; } onSearchInputLoad(); // create user checkbox control function canSendVerificationEmail() { const canSendVerificationEmail = !document.getElementById("create-user-verified").checked && !document.getElementById("create-user-banned").checked; const checkbox = document.getElementById("send-email-label"); if (canSendVerificationEmail) checkbox.classList.remove("hidden"); if (!canSendVerificationEmail && !checkbox.classList.contains("hidden")) checkbox.classList.add("hidden"); } // htmx prefetch extension // https://github.com/bigskysoftware/htmx-extensions/blob/main/src/preload/README.md htmx.defineExtension("preload", { onEvent: function(name, event) { if (name !== "htmx:afterProcessNode") { return } var attr = function(node, property) { if (node == undefined) { return undefined } return node.getAttribute(property) || node.getAttribute("data-" + property) || attr(node.parentElement, property) } var load = function(node) { var done = function(html) { if (!node.preloadAlways) { node.preloadState = "DONE" } if (attr(node, "preload-images") == "true") { document.createElement("div").innerHTML = html } } return function() { if (node.preloadState !== "READY") { return } var hxGet = node.getAttribute("hx-get") || node.getAttribute("data-hx-get") if (hxGet) { htmx.ajax("GET", hxGet, { source: node, handler: function(elt, info) { done(info.xhr.responseText) } }) return } if (node.getAttribute("href")) { var r = new XMLHttpRequest() r.open("GET", node.getAttribute("href")) r.onload = function() { done(r.responseText) } r.send() } } } var init = function(node) { if (node.getAttribute("href") + node.getAttribute("hx-get") + node.getAttribute("data-hx-get") == "") { return } if (node.preloadState !== undefined) { return } var on = attr(node, "preload") || "mousedown" const always = on.indexOf("always") !== -1 if (always) { on = on.replace("always", "").trim() } node.addEventListener(on, function(evt) { if (node.preloadState === "PAUSE") { node.preloadState = "READY" if (on === "mouseover") { window.setTimeout(load(node), 100) } else { load(node)() } } }) switch (on) { case "mouseover": node.addEventListener("touchstart", load(node)) node.addEventListener("mouseout", function(evt) { if ((evt.target === node) && (node.preloadState === "READY")) { node.preloadState = "PAUSE" } }) break case "mousedown": node.addEventListener("touchstart", load(node)) break } node.preloadState = "PAUSE" node.preloadAlways = always htmx.trigger(node, "preload:init") } const parent = event.target || event.detail.elt; parent.querySelectorAll("[preload]").forEach(function(node) { init(node) node.querySelectorAll("a,[hx-get],[data-hx-get]").forEach(init) }) } }) ================================================ FILE: static/scripts/stats.js ================================================ // create views chart label function createViewsChartLabel(ctx) { const period = ctx.dataset.period; let labels = []; if (period === "day") { const nowHour = new Date().getHours(); for (let i = 23; i >= 0; --i) { let h = nowHour - i; if (h < 0) h = 24 + h; labels.push(`${Math.floor(h)}:00`); } } if (period === "week") { const nowDay = new Date().getDate(); for (let i = 6; i >= 0; --i) { const date = new Date(new Date().setDate(nowDay - i)); labels.push(`${date.getDate()} ${date.toLocaleString("default",{month:"short"})}`); } } if (period === "month") { const nowDay = new Date().getDate(); for (let i = 29; i >= 0; --i) { const date = new Date(new Date().setDate(nowDay - i)); labels.push(`${date.getDate()} ${date.toLocaleString("default",{month:"short"})}`); } } if (period === "year") { const nowMonth = new Date().getMonth(); for (let i = 11; i >= 0; --i) { const date = new Date(new Date().setMonth(nowMonth - i)); labels.push(`${date.toLocaleString("default",{month:"short"})} ${date.toLocaleString("default",{year:"numeric"})}`); } } return labels; } // change stats period for showing charts and data function changeStatsPeriod(event) { const period = event.target.dataset.period; if (!period) return; const canvases = document.querySelector("#stats").querySelectorAll("[data-period]"); const buttons = document.querySelector("#stats").querySelectorAll(".nav"); if (!buttons || !canvases) return; buttons.forEach(b => b.disabled = false); event.target.disabled = true; canvases.forEach(canvas => { if (canvas.dataset.period === period) { canvas.classList.remove("hidden"); } else { canvas.classList.add("hidden"); } }); feedMapData(period); } // beautify browser lables function beautifyBrowserName(name) { if (name === "firefox") return "Firefox"; if (name === "chrome") return "Chrome"; if (name === "edge") return "Edge"; if (name === "opera") return "Opera"; if (name === "safari") return "Safari"; if (name === "other") return "Other"; if (name === "ie") return "IE"; return name; } // create views chart function createViewsChart() { const canvases = document.querySelectorAll("canvas.visits"); if (!canvases || !canvases.length) return; canvases.forEach(ctx => { const data = JSON.parse(ctx.dataset.data); const period = ctx.dataset.period; const labels = createViewsChartLabel(ctx); const maxTicksLimitX = period === "year" ? 6 : period === "month" ? 15 : 12; const gradient = ctx.getContext("2d").createLinearGradient(0, 0, 0, 300); gradient.addColorStop(0, "rgba(179, 157, 219, 0.95)"); gradient.addColorStop(1, "rgba(179, 157, 219, 0.05)"); new Chart(ctx, { type: "line", data: { labels: labels, datasets: [{ label: "Views", data, tension: 0.3, elements: { point: { pointRadius: 0, pointHoverRadius: 4 } }, fill: { target: "start", }, backgroundColor: gradient, borderColor: "rgb(179, 157, 219)", borderWidth: 1, }] }, options: { plugins: { legend: { display: false, }, tooltip: { backgroundColor: "rgba(255, 255, 255, 0.95)", titleColor: "#333", titleFont: { weight: "normal", size: 15 }, bodyFont: { weight: "normal", size: 16 }, bodyColor: "rgb(179, 157, 219)", padding: 12, cornerRadius: 2, borderColor: "rgba(0, 0, 0, 0.1)", borderWidth: 1, displayColors: false, } }, responsive: true, interaction: { intersect: false, usePointStyle: true, mode: "index", }, scales: { y: { grace: "10%", beginAtZero: true, ticks: { maxTicksLimit: 5 } }, x: { ticks: { maxTicksLimit: maxTicksLimitX, } } } } }); // reset the display: block style that chart.js applies automatically ctx.style.display = ""; }); } // create browsers chart function createBrowsersChart() { const canvases = document.querySelectorAll("canvas.browsers"); if (!canvases || !canvases.length) return; canvases.forEach(ctx => { const data = JSON.parse(ctx.dataset.data); const period = ctx.dataset.period; const gradient = ctx.getContext("2d").createLinearGradient(500, 0, 0, 0); const gradientHover = ctx.getContext("2d").createLinearGradient(500, 0, 0, 0); gradient.addColorStop(0, "rgba(179, 157, 219, 0.95)"); gradient.addColorStop(1, "rgba(179, 157, 219, 0.05)"); gradientHover.addColorStop(0, "rgba(179, 157, 219, 0.9)"); gradientHover.addColorStop(1, "rgba(179, 157, 219, 0.4)"); new Chart(ctx, { type: "bar", data: { labels: data.map(d => beautifyBrowserName(d.name)), datasets: [{ label: "Views", data: data.map(d => d.value), backgroundColor: gradient, borderColor: "rgba(179, 157, 219, 1)", borderWidth: 1, hoverBackgroundColor: gradientHover, hoverBorderWidth: 2 }] }, options: { indexAxis: "y", plugins: { legend: { display: false, }, tooltip: { backgroundColor: "rgba(255, 255, 255, 0.95)", titleColor: "#333", titleFont: { weight: "normal", size: 15 }, bodyFont: { weight: "normal", size: 16 }, bodyColor: "rgb(179, 157, 219)", padding: 12, cornerRadius: 2, borderColor: "rgba(0, 0, 0, 0.1)", borderWidth: 1, displayColors: false, } }, responsive: true, interaction: { intersect: false, mode: "index", axis: "y" }, scales: { x: { grace: "5%", beginAtZero: true, ticks: { maxTicksLimit: 6, } } } } }); // reset the display: block style that chart.js applies automatically ctx.style.display = ""; }); } // create referrers chart function createReferrersChart() { const canvases = document.querySelectorAll("canvas.referrers"); if (!canvases || !canvases.length) return; canvases.forEach(ctx => { const data = JSON.parse(ctx.dataset.data); const period = ctx.dataset.period; let max = Array.from(data).sort((a, b) => a.value > b.value ? -1 : 1)[0]; let tooltipEnabled = true; let hoverBackgroundColor = "rgba(179, 157, 219, 1)"; let hoverBorderWidth = 2; let borderColor = "rgba(179, 157, 219, 1)"; if (data.length === 0) { data.push({ name: "No views.", value: 1 }); max = { value: 1000 }; tooltipEnabled = false; hoverBackgroundColor = "rgba(179, 157, 219, 0.1)"; hoverBorderWidth = 1; borderColor = "rgba(179, 157, 219, 0.2)"; } new Chart(ctx, { type: "doughnut", data: { labels: data.map(d => d.name.replace(/\[dot\]/g, ".")), datasets: [{ label: "Views", data: data.map(d => d.value), backgroundColor: data.map(d => `rgba(179, 157, 219, ${Math.max((d.value / max.value) - 0.2, 0.1).toFixed(2)})`), borderWidth: 1, borderColor, hoverBackgroundColor, hoverBorderWidth, }] }, options: { plugins: { legend: { position: "left", labels: { boxWidth: 25, font: { size: 11 } } }, tooltip: { enabled: tooltipEnabled, backgroundColor: "rgba(255, 255, 255, 0.95)", titleColor: "#333", titleFont: { weight: "normal", size: 15 }, bodyFont: { weight: "normal", size: 16 }, bodyColor: "rgb(179, 157, 219)", padding: 12, cornerRadius: 2, borderColor: "rgba(0, 0, 0, 0.1)", borderWidth: 1, displayColors: false, } }, responsive: false, } }); // reset the display: block style that chart.js applies automatically ctx.style.display = ""; }); } // beautify browser lables function beautifyOsName(name) { if (name === "android") return "Android"; if (name === "ios") return "iOS"; if (name === "linux") return "Linux"; if (name === "macos") return "macOS"; if (name === "windows") return "Windows"; if (name === "other") return "Other"; return name; } // create operating systems chart function createOsChart() { const canvases = document.querySelectorAll("canvas.os"); if (!canvases || !canvases.length) return; canvases.forEach(ctx => { const data = JSON.parse(ctx.dataset.data); const period = ctx.dataset.period; const gradient = ctx.getContext("2d").createLinearGradient(500, 0, 0, 0); const gradientHover = ctx.getContext("2d").createLinearGradient(500, 0, 0, 0); gradient.addColorStop(0, "rgba(179, 157, 219, 0.95)"); gradient.addColorStop(1, "rgba(179, 157, 219, 0.05)"); gradientHover.addColorStop(0, "rgba(179, 157, 219, 0.9)"); gradientHover.addColorStop(1, "rgba(179, 157, 219, 0.4)"); new Chart(ctx, { type: "bar", data: { labels: data.map(d => beautifyOsName(d.name)), datasets: [{ label: "Views", data: data.map(d => d.value), backgroundColor: gradient, borderColor: "rgba(179, 157, 219, 1)", borderWidth: 1, hoverBackgroundColor: gradientHover, hoverBorderWidth: 2 }] }, options: { indexAxis: "y", plugins: { legend: { display: false, }, tooltip: { backgroundColor: "rgba(255, 255, 255, 0.95)", titleColor: "#333", titleFont: { weight: "normal", size: 15 }, bodyFont: { weight: "normal", size: 16 }, bodyColor: "rgb(179, 157, 219)", padding: 12, cornerRadius: 2, borderColor: "rgba(0, 0, 0, 0.1)", borderWidth: 1, displayColors: false, } }, responsive: true, interaction: { intersect: false, mode: "index", axis: "y" }, scales: { x: { grace:"5%", beginAtZero: true, ticks: { maxTicksLimit: 6, } } } } }); // reset the display: block style that chart.js applies automatically ctx.style.display = ""; }); } // add data to the map function feedMapData(period) { const map = document.querySelector("svg.map"); const paths = map.querySelectorAll("path"); if (!map || !paths || !paths.length) return; let data = JSON.parse(map.dataset[period || "day"]); if (!data) return; let max = data.sort((a, b) => a.value > b.value ? -1 : 1)[0]; if (!max) max = { value: 1 } data = data.reduce((a, c) => ({ ...a, [c.name]: c.value }), {}); for (let i = 0; i < paths.length; ++i) { const id = paths[i].dataset.id; const views = data[id] || 0; paths[i].dataset.views = views; const colorLevel = Math.ceil((views / max.value) * 6); const classList = paths[i].classList; for (let j = 1; j < 7; j++) { paths[i].classList.remove(`color-${j}`); } paths[i].classList.add(`color-${colorLevel}`) paths[i].dataset.views = views; } } // handle map tooltip hover function mapTooltipHoverOver() { const tooltip = document.querySelector("#map-tooltip"); if (!tooltip) return; if (!event.target.dataset.id) return mapTooltipHoverOut(); if (!tooltip.classList.contains("active")) { tooltip.classList.add("visible"); } tooltip.dataset.tooltip = `${event.target.ariaLabel}: ${event.target.dataset.views || 0}`; const rect = event.target.getBoundingClientRect(); tooltip.style.top = rect.top + (rect.height / 2) + "px"; tooltip.style.left = rect.left + (rect.width / 2) + "px"; event.target.classList.add("active"); } function mapTooltipHoverOut() { const tooltip = document.querySelector("#map-tooltip"); const map = document.querySelector("svg.map"); const paths = map.querySelectorAll("path"); if (!tooltip || !map) return; tooltip.classList.remove("visible"); for (let i = 0; i < paths.length; ++i) { paths[i].classList.remove("active"); } } // create stats charts function createCharts() { if (Chart === undefined) { setTimeout(function() { createCharts() }, 100); return; } createViewsChart(); createBrowsersChart(); createReferrersChart(); createOsChart(); feedMapData(); }