1
0
mirror of https://github.com/zws-im/zws.git synced 2025-10-30 23:27:52 +02:00

feat: release API v2

BREAKING CHANGE: API v1 endpoints are no longer totally compatible, you should upgrade to v2
This commit is contained in:
Jonah Snider
2021-02-21 13:12:32 -08:00
committed by GitHub
parent bc360132d5
commit aff7bb8827
67 changed files with 12286 additions and 972 deletions

13
.dockerignore Normal file
View File

@@ -0,0 +1,13 @@
.github
.vscode
node_modules
tsc_output
.editorconfig
*.env
.gitignore
.prettierignore
app.json
openapi.yml
prettier.config.js
renovate.json
*.log

View File

@@ -1,9 +1,16 @@
root = true
[*]
indent_style = space
indent_style = tab
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
insert_final_newline = true
[*.{y,ya}ml]
indent_style = space
[*.sql]
indent_style = space
indent_size = 4

View File

@@ -1,5 +0,0 @@
{
"projects": {
"default": "zero-width-shortener"
}
}

6
.github/FUNDING.yml vendored
View File

@@ -1,12 +1,12 @@
# These are supported funding model platforms
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: pizzafox # Replace with a single Patreon username
open_collective: zws # Replace with a single Open Collective username
patreon: pizzafox
open_collective: zws
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
otechie: # Replace with a single Otechie username
custom: # Replace with a single custom sponsorship URL
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']

142
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,142 @@
name: CI
on: [push, pull_request]
jobs:
build:
name: Build
runs-on: ubuntu-latest
steps:
- name: Checkout git repository
uses: actions/checkout@v2
- name: Get Yarn cache directory path
id: yarn-cache-dir-path
run: echo "::set-output name=dir::$(yarn config get cacheFolder)"
- name: Cache dependencies
uses: actions/cache@v2
with:
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-
- name: Install dependencies with Yarn
run: yarn install --immutable
- name: Compile TypeScript
run: yarn run build
- name: Upload compiled TypeScript
uses: actions/upload-artifact@v2
with:
name: tsc_output
path: tsc_output
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- name: Checkout git repository
uses: actions/checkout@v2
- name: Get Yarn cache directory path
id: yarn-cache-dir-path
run: echo "::set-output name=dir::$(yarn config get cacheFolder)"
- name: Cache dependencies
uses: actions/cache@v2
with:
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-
- name: Install dependencies with Yarn
run: yarn install --immutable
- name: Lint
run: yarn run lint
lint-dockerfile:
name: Lint Dockerfile
runs-on: ubuntu-latest
steps:
- name: Checkout git repository
uses: actions/checkout@v2
- name: Lint Dockerfile
uses: brpaz/hadolint-action@v1.3.1
with:
dockerfile: 'Dockerfile'
style:
name: Check style
runs-on: ubuntu-latest
steps:
- name: Checkout git repository
uses: actions/checkout@v2
- name: Get Yarn cache directory path
id: yarn-cache-dir-path
run: echo "::set-output name=dir::$(yarn config get cacheFolder)"
- name: Cache dependencies
uses: actions/cache@v2
with:
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-
- name: Install dependencies with Yarn
run: yarn install --immutable
- name: Check style
run: yarn run style
style-prisma:
name: Check style of Prisma schema
runs-on: ubuntu-latest
steps:
- name: Checkout git repository
uses: actions/checkout@v2
- name: Get yarn cache
id: yarn-cache
run: echo "::set-output name=dir::$(yarn config get cacheFolder)"
- name: Cache dependencies
uses: actions/cache@v2
with:
path: ${{ steps.yarn-cache.outputs.dir }}
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-
- name: Install dependencies with Yarn
run: yarn install --immutable
- name: Check style with Prisma CLI
run: yarn run prisma format && git diff --exit-code -s prisma/schema.prisma
deploy:
name: Deploy
runs-on: ubuntu-latest
# Don't run this job if we aren't on master branch
# `semantic-release` will do this automatically, but this saves us the time of building the image prior to that
if: ${{ github.ref == 'refs/heads/master' }}
needs: [build, lint, lint-dockerfile]
steps:
- name: Checkout git repository
uses: actions/checkout@v2
- name: Get yarn cache
id: yarn-cache
run: echo "::set-output name=dir::$(yarn config get cacheFolder)"
- name: Cache dependencies
uses: actions/cache@v2
with:
path: ${{ steps.yarn-cache.outputs.dir }}
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-
- name: Install dependencies with Yarn
run: yarn install --immutable
- name: Deploy
run: yarn run deploy
env:
DOCKER_USERNAME: pizzafox
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

67
.github/workflows/codeql-analysis.yml vendored Normal file
View File

@@ -0,0 +1,67 @@
# For most projects, this workflow file will not need changing; you simply need
# to commit it to your repository.
#
# You may wish to alter this file to override the set of languages analyzed,
# or to provide custom queries or build logic.
#
# ******** NOTE ********
# We have attempted to detect the languages in your repository. Please check
# the `language` matrix defined below to confirm you have the correct set of
# supported CodeQL languages.
#
name: 'CodeQL'
on:
push:
branches: [main]
pull_request:
# The branches below must be a subset of the branches above
branches: [main]
schedule:
- cron: '27 15 * * 5'
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
language: ['javascript']
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ]
# Learn more:
# https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed
steps:
- name: Checkout repository
uses: actions/checkout@v2
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v1
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# queries: ./path/to/local/query, your-org/your-repo/queries@main
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v1
# ℹ️ Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
# and modify them (or add more) to build your code if your project
# uses a compiled language
#- run: |
# make bootstrap
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v1

View File

@@ -1,35 +0,0 @@
on: [push, pull_request]
name: CI
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Git checkout
uses: actions/checkout@v1
with:
fetch-depth: 0
- name: Install Node.js
uses: actions/setup-node@v1
with:
version: 10.x
- name: Install functions dependencies
run: npm --prefix ./functions install
- name: Lint functions
run: npm --prefix ./functions run lint
- name: Deploy Firebase Functions
uses: pizzafox/firebase-action@master
if: github.event == 'push' && github.branch == 'master'
env:
FIREBASE_TOKEN: ${{ secrets.FIREBASE_TOKEN }}
PROJECT_ID: zero-width-shortener
with:
args: deploy --only functions
- name: Deploy Firebase Firestore
if: github.event == 'push' && github.branch == 'master'
uses: pizzafox/firebase-action@master
env:
FIREBASE_TOKEN: ${{ secrets.FIREBASE_TOKEN }}
PROJECT_ID: zero-width-shortener
with:
args: deploy --only firestore

87
.gitignore vendored
View File

@@ -1,15 +1,5 @@
# Created by https://www.gitignore.io/api/node,linux,windows,firebase,visualstudiocode
# Edit at https://www.gitignore.io/?templates=node,linux,windows,firebase,visualstudiocode
### Firebase ###
.idea
**/node_modules/*
**/.firebaserc
### Firebase Patch ###
.runtimeconfig.json
.firebase/
# Created by https://www.gitignore.io/api/node,linux,macos,windows,visualstudiocode
# Edit at https://www.gitignore.io/?templates=node,linux,macos,windows,visualstudiocode
### Linux ###
*~
@@ -26,6 +16,34 @@
# .nfs files are created when an open file is removed but is still being accessed
.nfs*
### macOS ###
# General
.DS_Store
.AppleDouble
.LSOverride
# Icon must end with two \r
Icon
# Thumbnails
._*
# Files that might appear in the root of a volume
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent
# Directories potentially created on remote AFP share
.AppleDB
.AppleDesktop
Network Trash Folder
Temporary Items
.apdisk
### Node ###
# Logs
logs
@@ -49,6 +67,7 @@ lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
@@ -72,6 +91,9 @@ jspm_packages/
# TypeScript v1 declaration files
typings/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
@@ -100,6 +122,18 @@ typings/
# nuxt.js build output
.nuxt
# rollup.js default build output
dist/
# Uncomment the public line if your project uses Gatsby
# https://nextjs.org/blog/next-9-1#public-directory-support
# https://create-react-app.dev/docs/using-the-public-folder/#docsNav
# public
# Storybook build outputs
.out
.storybook-out
# vuepress build output
.vuepress/dist
@@ -112,6 +146,10 @@ typings/
# DynamoDB Local files
.dynamodb/
# Temporary folders
tmp/
temp/
### VisualStudioCode ###
.vscode/*
!.vscode/settings.json
@@ -126,6 +164,7 @@ typings/
### Windows ###
# Windows thumbnail cache files
Thumbs.db
Thumbs.db:encryptable
ehthumbs.db
ehthumbs_vista.db
@@ -148,12 +187,22 @@ $RECYCLE.BIN/
# Windows shortcuts
*.lnk
# End of https://www.gitignore.io/api/node,linux,windows,firebase,visualstudiocode
# End of https://www.gitignore.io/api/node,linux,macos,windows,visualstudiocode
# Package manager lockfiles
package-lock.json
pnpm-lock.yaml
yarn.lock
# TypeScript compiler output
tsc_output
# Firebase authentication file
serviceAccount.json
# Yarn
.yarn/*
!.yarn/releases
!.yarn/plugins
!.yarn/sdks
!.yarn/versions
.pnp.*
# Dotenv files
*.env
!*example.env
# Google Cloud Service account
service-account-key.json

1
.node-version Normal file
View File

@@ -0,0 +1 @@
15

197
.prettierignore Normal file
View File

@@ -0,0 +1,197 @@
# Created by https://www.gitignore.io/api/node,linux,macos,windows,visualstudiocode
# Edit at https://www.gitignore.io/?templates=node,linux,macos,windows,visualstudiocode
### Linux ###
*~
# temporary files which can be created if a process still has a handle open of a deleted file
.fuse_hidden*
# KDE directory preferences
.directory
# Linux trash folder which might appear on any partition or disk
.Trash-*
# .nfs files are created when an open file is removed but is still being accessed
.nfs*
### macOS ###
# General
.DS_Store
.AppleDouble
.LSOverride
# Icon must end with two \r
Icon
# Thumbnails
._*
# Files that might appear in the root of a volume
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent
# Directories potentially created on remote AFP share
.AppleDB
.AppleDesktop
Network Trash Folder
Temporary Items
.apdisk
### Node ###
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# TypeScript v1 declaration files
typings/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
.env.test
# parcel-bundler cache (https://parceljs.org/)
.cache
# next.js build output
.next
# nuxt.js build output
.nuxt
# rollup.js default build output
dist/
# Uncomment the public line if your project uses Gatsby
# https://nextjs.org/blog/next-9-1#public-directory-support
# https://create-react-app.dev/docs/using-the-public-folder/#docsNav
# public
# Storybook build outputs
.out
.storybook-out
# vuepress build output
.vuepress/dist
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# Temporary folders
tmp/
temp/
### VisualStudioCode ###
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
### VisualStudioCode Patch ###
# Ignore all local history of files
.history
### Windows ###
# Windows thumbnail cache files
Thumbs.db
Thumbs.db:encryptable
ehthumbs.db
ehthumbs_vista.db
# Dump file
*.stackdump
# Folder config file
[Dd]esktop.ini
# Recycle Bin used on file shares
$RECYCLE.BIN/
# Windows Installer files
*.cab
*.msi
*.msix
*.msm
*.msp
# Windows shortcuts
*.lnk
# End of https://www.gitignore.io/api/node,linux,macos,windows,visualstudiocode
# TypeScript compiler output
tsc_output
# Yarn
.yarn/*
.pnp.*

25
.releaserc.json Normal file
View File

@@ -0,0 +1,25 @@
{
"plugins": [
[
"@semantic-release/commit-analyzer",
{
"preset": "angular"
}
],
"@semantic-release/release-notes-generator",
"@semantic-release/github",
["@semantic-release/exec", {"prepareCmd": "docker pull zwsim/zws"}],
[
"@semantic-release/exec",
{
"prepareCmd": "docker build -t zwsim/zws ."
}
],
[
"semantic-release-docker",
{
"name": "zwsim/zws"
}
]
]
}

6
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,6 @@
{
"editor.codeActionsOnSave": {
"source.organizeImports": false
},
"typescript.tsdk": "node_modules\\typescript\\lib"
}

55
.yarn/releases/yarn-2.4.0.cjs vendored Executable file

File diff suppressed because one or more lines are too long

2
.yarnrc.yml Normal file
View File

@@ -0,0 +1,2 @@
yarnPath: .yarn/releases/yarn-2.4.0.cjs
nodeLinker: node-modules

View File

@@ -1,76 +0,0 @@
# Contributor Covenant Code of Conduct
## Our Pledge
In the interest of fostering an open and welcoming environment, we as
contributors and maintainers pledge to making participation in our project and
our community a harassment-free experience for everyone, regardless of age, body
size, disability, ethnicity, sex characteristics, gender identity and expression,
level of experience, education, socio-economic status, nationality, personal
appearance, race, religion, or sexual identity and orientation.
## Our Standards
Examples of behavior that contributes to creating a positive environment
include:
* Using welcoming and inclusive language
* Being respectful of differing viewpoints and experiences
* Gracefully accepting constructive criticism
* Focusing on what is best for the community
* Showing empathy towards other community members
Examples of unacceptable behavior by participants include:
* The use of sexualized language or imagery and unwelcome sexual attention or
advances
* Trolling, insulting/derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or electronic
address, without explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Our Responsibilities
Project maintainers are responsible for clarifying the standards of acceptable
behavior and are expected to take appropriate and fair corrective action in
response to any instances of unacceptable behavior.
Project maintainers have the right and responsibility to remove, edit, or
reject comments, commits, code, wiki edits, issues, and other contributions
that are not aligned to this Code of Conduct, or to ban temporarily or
permanently any contributor for other behaviors that they deem inappropriate,
threatening, offensive, or harmful.
## Scope
This Code of Conduct applies both within project spaces and in public spaces
when an individual is representing the project or its community. Examples of
representing a project or community include using an official project e-mail
address, posting via an official social media account, or acting as an appointed
representative at an online or offline event. Representation of a project may be
further defined and clarified by project maintainers.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported by contacting the project manager at jonah@jonah.pw. All
complaints will be reviewed and investigated and will result in a response that
is deemed necessary and appropriate to the circumstances. The project team is
obligated to maintain confidentiality with regard to the reporter of an incident.
Further details of specific enforcement policies may be posted separately.
Project maintainers who do not follow or enforce the Code of Conduct in good
faith may face temporary or permanent repercussions as determined by other
members of the project's leadership.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see
https://www.contributor-covenant.org/faq

22
Dockerfile Normal file
View File

@@ -0,0 +1,22 @@
FROM node:15.9.0-alpine3.10
WORKDIR /usr/src/app
ENV PORT=3000
EXPOSE 3000
COPY package.json yarn.lock .yarnrc.yml tsconfig.json ./
COPY prisma ./prisma
COPY .yarn ./.yarn
COPY src ./src
RUN yarn install --immutable
RUN yarn prisma generate
RUN yarn build
# Remove devDependencies manually, Yarn 2 doesn't support skipping them (see https://yarnpkg.com/configuration/manifest#devDependencies)
RUN yarn remove @semantic-release/exec @tsconfig/node14 @types/node @types/supports-color eslint-plugin-prettier prettier prettier-config-xo semantic-release ts-node type-fest typescript xo
RUN yarn install --immutable
RUN rm -rf .yarn/cache src tsconfig.json
CMD ["node", "."]

View File

@@ -1,4 +1,3 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/

101
README.md
View File

@@ -1,22 +1,93 @@
---
description: Shorten URLs with invisible spaces
---
# [Zero Width Shortener (ZWS)](https://zws.im)
# Zero Width Shortener
Shorten URLs with invisible spaces.
Zero Width Shortener \(abbreviated as ZWS\) is a URL shortener that shortens URLs using spaces that have zero width, making them invisible to humans.
Or, [configure your own instance](#Self-hosting) to use any other kinds of characters.
### Characters
## Contributors
We've done a bit of research on what characters work on different platforms
### Code Contributors
| Character | In use | [Twitter](https://twitter.com/) | [iMessage](https://support.apple.com/explore/messages)\* | [Discord](https://discordapp.com/) | [Slack](https://slack.com) | [Telegram](https://telegram.org/) | Notes |
| :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- |
| `U+200B` | ✔️ | ❌ | ❌ | ✔️ | ❌ | ✔️ | Used in URLs since initial release, blacklisted space character on [Twitter](https://twitter.com/) |
| `U+200D` | ✔️ | ❌ | | ✔️ | | ✔️ | [Discord](https://discordapp.com/) prompts you with a "spoopy URL" popup when clicked |
| `U+200C` | ❌ | ❌ | | ✔️ | | ❌ | Blacklisted space on [Twitter](https://twitter.com/), discontinued \(previously used, replaced with `U+200D`\) |
| `U+180E` | ❌ | ❌ | ❌ | ✔️ | | ❌ | Visible on iOS, discontinued in b39897e \(previously used, replaced with `U+200C`\) |
| `U+061C` | ❌ | ❌ | | ✔️ | | ✔️ | |
This project exists thanks to all the people who contribute.
<a href="https://github.com/zws-im/zws/graphs/contributors"><img src="https://opencollective.com/zws/contributors.svg?width=890&button=false" /></a>
* [iMessage](https://support.apple.com/explore/messages) note: Tested on latest beta of iOS
### Financial Contributors
Become a financial contributor and help us sustain our community. [[Contribute][open-collective]]
#### Individuals
<a href="https://opencollective.com/zws"><img src="https://opencollective.com/zws/individuals.svg?width=890"></a>
#### Organizations
Support this project with your organization. Your logo will show up here with a link to your website. [[Contribute][open-collective]]
<a href="https://opencollective.com/zws/organization/0/website"><img src="https://opencollective.com/zws/organization/0/avatar.svg"></a>
<a href="https://opencollective.com/zws/organization/1/website"><img src="https://opencollective.com/zws/organization/1/avatar.svg"></a>
<a href="https://opencollective.com/zws/organization/2/website"><img src="https://opencollective.com/zws/organization/2/avatar.svg"></a>
<a href="https://opencollective.com/zws/organization/3/website"><img src="https://opencollective.com/zws/organization/3/avatar.svg"></a>
<a href="https://opencollective.com/zws/organization/4/website"><img src="https://opencollective.com/zws/organization/4/avatar.svg"></a>
<a href="https://opencollective.com/zws/organization/5/website"><img src="https://opencollective.com/zws/organization/5/avatar.svg"></a>
<a href="https://opencollective.com/zws/organization/6/website"><img src="https://opencollective.com/zws/organization/6/avatar.svg"></a>
<a href="https://opencollective.com/zws/organization/7/website"><img src="https://opencollective.com/zws/organization/7/avatar.svg"></a>
<a href="https://opencollective.com/zws/organization/8/website"><img src="https://opencollective.com/zws/organization/8/avatar.svg"></a>
<a href="https://opencollective.com/zws/organization/9/website"><img src="https://opencollective.com/zws/organization/9/avatar.svg"></a>
## Self-hosting
### Heroku
[![Deploy to Heroku][deploy-to-heroku-image]][deploy-to-heroku]
Running an instance of ZWS on Heroku is the easiest way to self-host.
You can also stay totally within the free limits of both the [`web` process](https://devcenter.heroku.com/articles/procfile) and the [Heroku Postgres][heroku-postgres] database.
Note that the Hobby Dev (free) plan of [Heroku Postgres][heroku-postgres] has a row limit of 10,000, which might not be enough for your use case.
### [Docker Compose][docker-compose]
1. [Clone the repository](https://docs.github.com/en/github/creating-cloning-and-archiving-repositories/cloning-a-repository)
2. Copy `db.example.env` to `db.env` and fill in the values
3. Copy `example.env` to `.env` and update the `DATABASE_URL` environment variable to match the values in `db.env`
4. Run [`docker volume create --name=zws-postgres-storage`](https://docs.docker.com/engine/reference/commandline/volume_create/)
5. Run [`docker-compose up -d`](https://docs.docker.com/compose/reference/up/) (this will automatically apply database migrations)
### Database migrations
After you create an app using the above button you'll need to run the database migrations before shortening any URLs.
**These are done automatically, but manual usage may be required when upgrading versions**.
This can be done easily through [Docker Compose][docker-compose] by running the following commands:
```sh
docker volume create --name=zws-postgres-storage
docker-compose up migration
docker-compose down
```
Even if your database isn't being run through [Docker Compose][docker-compose] you'll still need to create the volume and start the `db` service.
You can delete the volume right after.
If you know a better way to do this, please open a pull request!
#### [Heroku Postgres][heroku-postgres]
If you are using a Heroku database migrations are automatically applied, but to manually do so you'll need the credentials for your database:
1. Get the [Heroku Postgres][heroku-postgres] connection URI from
- [the web interface](https://data.heroku.com/) (select your datastore, "Settings", "Database Credentials", "URI")
- [the Heroku CLI](https://devcenter.heroku.com/articles/heroku-postgresql#external-connections-ingress)
2. Create a `.env` file and enter in the connection URI
Example:
```env
DATABASE_URL=postgresql://johndoe:randompassword@localhost:5432/mydb?schema=public
```
Afterward you can run the migration commands shown above.
[deploy-to-heroku]: https://dashboard.heroku.com/new?template=https://github.com/zws-im/zws/tree/v2
[deploy-to-heroku-image]: https://www.herokucdn.com/deploy/button.svg
[heroku-postgres]: https://www.heroku.com/postgres
[docker-compose]: https://docs.docker.com/compose/
[open-collective]: https://opencollective.com/zws/contribute

View File

@@ -1,10 +0,0 @@
# Table of contents
* [Zero Width Shortener](README.md)
## REST API <a id="api"></a>
* [Get URL](api/get-shortened-url.md)
* [Shorten URL](api/shorten-url.md)
* [Get URL stats](api/get-url-stats.md)

View File

@@ -1,72 +0,0 @@
---
description: Redirect to the long URL from a shortened URL
---
# Get URL
{% api-method method="get" host="https://zws.im/api" path="/getURL/:short" %}
{% api-method-summary %}
Get URL
{% endapi-method-summary %}
{% api-method-description %}
This endpoint redirects you to the long URL corresponding to a short ID.
{% endapi-method-description %}
{% api-method-spec %}
{% api-method-request %}
{% api-method-path-parameters %}
{% api-method-parameter name="short" type="string" required=true %}
Short ID of the URL to redirect to.
{% endapi-method-parameter %}
{% endapi-method-path-parameters %}
{% endapi-method-request %}
{% api-method-response %}
{% api-method-response-example httpCode=301 %}
{% api-method-response-example-description %}
Redirects you to the long URL corresponding to the provided short ID.
{% endapi-method-response-example-description %}
```text
```
{% endapi-method-response-example %}
{% api-method-response-example httpCode=400 %}
{% api-method-response-example-description %}
The short ID wasn't specified or wasn't a string type.
{% endapi-method-response-example-description %}
{% tabs %}
{% tab title="no short ID" %}
```javascript
{
"error": "You must specify a short ID"
}
```
{% endtab %}
{% tab title="invalid ID type" %}
```javascript
{
"error": "Short ID must be string type"
}
```
{% endtab %}
{% endtabs %}
{% endapi-method-response-example %}
{% api-method-response-example httpCode=404 %}
{% api-method-response-example-description %}
The requested short ID couldn't be found.
{% endapi-method-response-example-description %}
```text
```
{% endapi-method-response-example %}
{% endapi-method-response %}
{% endapi-method-spec %}
{% endapi-method %}

View File

@@ -1,117 +0,0 @@
---
description: Get usage stats for a URL
---
# Get URL stats
{% api-method method="get" host="https://zws.im/api" path="/:short" %}
{% api-method-summary %}
Get URL Stats
{% endapi-method-summary %}
{% api-method-description %}
This endpoint allows you to see stats for a URL that was shortened.
{% endapi-method-description %}
{% api-method-spec %}
{% api-method-request %}
{% api-method-path-parameters %}
{% api-method-parameter name="short" type="string" %}
Short ID of the URL to get stats for.
{% endapi-method-parameter %}
{% endapi-method-path-parameters %}
{% api-method-query-parameters %}
{% api-method-parameter type="string" name="short" %}
Short ID of the URL to get stats for.
{% endapi-method-parameter %}
{% api-method-parameter name="url" type="string" %}
Long URL to get stats for.
{% endapi-method-parameter %}
{% endapi-method-query-parameters %}
{% endapi-method-request %}
{% api-method-response %}
{% api-method-response-example httpCode=200 %}
{% api-method-response-example-description %}
`get` is the number of times the shortened URL was visited and `shorten` is the number of times it was shortened. `usage` contains arrays of UNIX timestamps when the URL was visited or shortened.
{% endapi-method-response-example-description %}
```javascript
{
"get": 123,
"shorten": 123,
"usage": {
"get": [1565561037]
"shorten": [1564561037]
}
}
```
{% endapi-method-response-example %}
{% api-method-response-example httpCode=400 %}
{% api-method-response-example-description %}
{% endapi-method-response-example-description %}
{% tabs %}
{% tab title="URL was not specified" %}
```javascript
{
"error": "You must specify a short ID or a URL"
}
```
{% endtab %}
{% tab title="invalid URL type" %}
```javascript
{
"error": "URL must be string type"
}
```
{% endtab %}
{% tab title="invalid short ID type" %}
```javascript
{
"error": "Short ID must be string type"
}
```
{% endtab %}
{% tab title="URL is invalid" %}
```javascript
{
"error": "Not a valid URL"
}
```
{% endtab %}
{% endtabs %}
{% endapi-method-response-example %}
{% api-method-response-example httpCode=404 %}
{% api-method-response-example-description %}
The URL or short ID couldn't be found.
{% endapi-method-response-example-description %}
```text
```
{% endapi-method-response-example %}
{% api-method-response-example httpCode=413 %}
{% api-method-response-example-description %}
The URL exceed 500 characters.
{% endapi-method-response-example-description %}
```javascript
{
"error": "URL can not exceed 500 characters"
}
```
{% endapi-method-response-example %}
{% endapi-method-response %}
{% endapi-method-spec %}
{% endapi-method %}

View File

@@ -1,104 +0,0 @@
---
description: Shorten a long URL
---
# Shorten URL
{% api-method method="get" host="https://zws.im/api" path="/shortenURL" %}
{% api-method-summary %}
Shorten URL
{% endapi-method-summary %}
{% api-method-description %}
This endpoint returns the short ID corresponding to the specified long URL.
{% endapi-method-description %}
{% api-method-spec %}
{% api-method-request %}
{% api-method-query-parameters %}
{% api-method-parameter name="url" type="string" required=true %}
The long URL to shorten. Must not contain the URL shortener's hostname or exceed 500 characters in length.
{% endapi-method-parameter %}
{% endapi-method-query-parameters %}
{% endapi-method-request %}
{% api-method-response %}
{% api-method-response-example httpCode=200 %}
{% api-method-response-example-description %}
The URL has already been shortened and was fetched from the database.
{% endapi-method-response-example-description %}
```javascript
{
"short": "Short ID"
}
```
{% endapi-method-response-example %}
{% api-method-response-example httpCode=201 %}
{% api-method-response-example-description %}
The URL was shortened and added to the database.
{% endapi-method-response-example-description %}
```javascript
{
"short": "Short ID"
}
```
{% endapi-method-response-example %}
{% api-method-response-example httpCode=400 %}
{% api-method-response-example-description %}
The URL wasn't specified, wasn't string type, was invalid, or contained the URL shortener's hostname.
{% endapi-method-response-example-description %}
{% tabs %}
{% tab title="URL was not specified" %}
```javascript
{
"error": "You must specify a URL"
}
```
{% endtab %}
{% tab title="URL is invalid" %}
```javascript
{
"error": "Not a valid URL"
}
```
{% endtab %}
{% tab title="URL contains hostname" %}
```javascript
{
"error": "Shortening a URL containing the URL shortener's hostname is disallowed"
}
```
{% endtab %}
{% tab title="invalid URL type" %}
```javascript
{
"error": "URL must be string type"
}
```
{% endtab %}
{% endtabs %}
{% endapi-method-response-example %}
{% api-method-response-example httpCode=413 %}
{% api-method-response-example-description %}
The URL exceeded 500 characters.
{% endapi-method-response-example-description %}
```javascript
{
"error": "URL can not exceed 500 characters"
}
```
{% endapi-method-response-example %}
{% endapi-method-response %}
{% endapi-method-spec %}
{% endapi-method %}

45
app.json Normal file
View File

@@ -0,0 +1,45 @@
{
"addons": [
{
"plan": "heroku-postgresql",
"options": {
"version": "13"
}
}
],
"buildpacks": [
{
"url": "heroku/nodejs"
}
],
"description": "A configurable URL shortener",
"env": {
"DATABASE_URL": {
"description": "URL or path to database. Leave this blank if you are using a Heroku add-on for your database.",
"required": false
},
"SHORT_LENGTH": {
"description": "Length of shortened IDs. Default is calculated based on length of SHORT_CHARS.",
"required": false
},
"SHORT_CHARS": {
"description": "JSON array of characters to used in shortened IDs. Default is alphanumeric (a-z, A-Z, 0-9).",
"required": false
},
"SHORT_REWRITES": {
"description": "JSON object of rewrites to normalize shortened IDs. Advanced use only.",
"required": false
}
},
"formation": {
"web": {
"quantity": 1,
"size": "free"
}
},
"keywords": ["URL Shortener", "Node.js"],
"logo": "https://avatars.githubusercontent.com/u/53232036",
"name": "Custom ZWS Instance",
"repository": "https://github.com/zws-im/zws",
"website": "https://docs.zws.im"
}

3
db.example.env Normal file
View File

@@ -0,0 +1,3 @@
POSTGRES_USER=johndoe
POSTGRES_PASSWORD=randompassword
POSTGRES_DB=mydb

30
docker-compose.yml Normal file
View File

@@ -0,0 +1,30 @@
services:
web:
image: zwsim/zws
ports:
- '3000:3000'
links:
- db
depends_on:
- migration
env_file: ./.env
migration:
image: zwsim/zws
links:
- db
env_file: ./.env
command: ['yarn', 'run', 'migrations']
db:
image: postgres:13
env_file: ./db.env
volumes:
- zws-postgres-storage:/var/lib/postgresql/data
expose:
- '5432'
watchtower:
image: containrrr/watchtower
volumes:
- /var/run/docker.sock:/var/run/docker.sock
volumes:
zws-postgres-storage:
external: true

17
example.env Normal file
View File

@@ -0,0 +1,17 @@
# URL or path to database
# Supports the native connection string format for PostgreSQL, MySQL and SQLite.
# See the documentation for all the connection string options: https://pris.ly/d/connection-strings
DATABASE_URL=postgresql://johndoe:randompassword@localhost:5432/mydb?schema=public
# Length of shortened IDs
# Default is calculated based on length of SHORT_CHARS
SHORT_LENGTH=12
# JSON array of characters to used in shortened IDs
# Default is alphanumeric (a-z, A-Z, 0-9)
SHORT_CHARS=["a", "b", "c"]
# JSON object of rewrites to normalize shortened IDs
# Advanced use only
SHORT_REWRITES={"d": "a", "e": "b", "f": "c"}
# HTTP port to listen on
PORT=3000

View File

@@ -1,11 +0,0 @@
{
"firestore": {
"indexes": "firestore.indexes.json",
"rules": "firestore.rules"
},
"functions": {
"predeploy": [
"npm --prefix \"$RESOURCE_DIR\" run lint --fix"
]
}
}

View File

@@ -1,26 +0,0 @@
{
// Example:
//
// "indexes": [
// {
// "collectionGroup": "widgets",
// "queryScope": "COLLECTION",
// "fields": [
// { "fieldPath": "foo", "arrayConfig": "CONTAINS" },
// { "fieldPath": "bar", "mode": "DESCENDING" }
// ]
// },
//
// "fieldOverrides": [
// {
// "collectionGroup": "widgets",
// "fieldPath": "baz",
// "indexes": [
// { "order": "ASCENDING", "queryScope": "COLLECTION" }
// ]
// },
// ]
// ]
"indexes": [],
"fieldOverrides": []
}

View File

@@ -1,7 +0,0 @@
service cloud.firestore {
match /databases/{database}/documents {
match /{document=**} {
allow read, write: if false;
}
}
}

View File

@@ -1,3 +0,0 @@
module.exports = {
"extends": "@dice-discord"
}

View File

@@ -1,5 +0,0 @@
{
"$schema": "http://json.schemastore.org/prettierrc",
"endOfLine": "lf",
"printWidth": 120
}

View File

@@ -1,60 +0,0 @@
{
"author": "Jonah Snider <jonah@jonah.pw> (jonah.pw)",
"bugs": {
"url": "https://github.com/zws-im/zws/issues"
},
"contributors": [
"Yousef Sultan <yousef.su.2000@gmail.com>"
],
"dependencies": {
"@google-cloud/debug-agent": "^4.0.1",
"@google-cloud/profiler": "^2.0.2",
"cors": "^2.8.5",
"firebase-admin": "~8.3.0",
"firebase-functions": "^3.2.0"
},
"description": "Firebase Cloud Functions for Zero Width Shortener",
"devDependencies": {
"@dice-discord/eslint-config": "^3.0.0",
"eslint": "^6.1.0",
"eslint-config-prettier": "^6.0.0",
"eslint-plugin-prettier": "^3.1.0",
"eslint-plugin-promise": "^4.2.1",
"firebase-functions-test": "^0.1.6",
"firebase-tools": "^7.2.2",
"prettier": "^1.18.2"
},
"engines": {
"node": "10"
},
"homepage": "https://zws.im",
"keywords": [
"zero",
"width",
"shortener",
"url",
"zero-width-shortener",
"zws"
],
"license": "Apache-2.0",
"main": "./src/index.js",
"name": "zero-width-shortener-functions",
"peerDependencies": {
"sqreen": "^1.30.3"
},
"private": true,
"repository": {
"type": "git",
"url": "git+https://github.com/zws-im/zws.git"
},
"scripts": {
"deploy": "firebase deploy --only functions",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"logs": "firebase functions:log",
"serve": "firebase serve --only functions",
"shell": "firebase functions:shell",
"start": "npm run shell"
},
"version": "1.11.14"
}

View File

@@ -1,42 +0,0 @@
/**
* @typedef {Object} CharacterConfig Configuration object for a specific type of short character.
* @property {string} preferred The preferred character to use for this type of character
* @property {Array<string>} [compatible] Array of compatible other compatible characters, not including the preferred character
*/
/**
* Space characters that are used in shortened URLs.
* @enum {CharacterConfig} Array of character configs, array index is their numerical ID
*/
module.exports.characters = [
{
preferred: "\u200c",
compatible: ["\u180e"]
},
{
preferred: "\u200d",
compatible: ["\u200b"]
}
];
/**
* Hostnames of ZWS instances.
* Used to prevent shortening a link that's already shortened.
*/
module.exports.hostnames = [
"zws.im",
"zero-width-shortener.firebaseapp.com",
"zero-width-shortener.web.app",
"zws.jonahsnider.ninja",
"zws.jonah.pw",
"zerowidthshortener.netlify.com"
];
/**
* RegEx for space characters.
*/
module.exports.spacesRegex = new RegExp(
`^(${module.exports.characters
.map(spaceConfig => `${spaceConfig.preferred}|${spaceConfig.compatible.join("|")}`)
.join("|")})+$`
);

View File

@@ -1,55 +0,0 @@
const spacesToBinary = require("../util/spacesToBinary");
const { spacesRegex } = require("../constants");
const functions = require("firebase-functions");
const admin = require("firebase-admin");
const firestore = admin.firestore();
const urls = firestore.collection("urls");
const cors = require("cors")({ origin: true });
module.exports = functions.https.onRequest(async (req, res) => {
cors(req, res, () => {});
// Remove any trailing slashes
const short = req.params["0"].replace(/\//g, "");
if (short) {
if (typeof short === "string") {
if (spacesRegex.test(short)) {
const binary = spacesToBinary(short);
const ref = urls.doc(binary);
const doc = await ref.get();
if (doc && doc.exists) {
// Increment the counter for this URL and record the timestamp in the background
ref.update({
"stats.get": admin.firestore.FieldValue.increment(1),
"usage.get": admin.firestore.FieldValue.arrayUnion(new Date())
});
const data = doc.data();
return res.redirect(301, data.url);
} else {
return res.status(404).end();
}
} else {
return res
.status(400)
.json({ error: "Short ID contained invalid characters" })
.end();
}
} else {
return res
.status(400)
.json({ error: "Short ID must be string type" })
.end();
}
} else {
return res
.status(400)
.json({ error: "You must specify a short ID" })
.end();
}
});

View File

@@ -1,93 +0,0 @@
const functions = require("firebase-functions");
const { characters, spacesRegex } = require("../constants");
const dataToResponse = require("../util/dataToResponse");
const admin = require("firebase-admin");
const firestore = admin.firestore();
const urls = firestore.collection("urls");
const cors = require("cors")({ origin: true });
module.exports = functions.https.onRequest(async (req, res) => {
cors(req, res, () => {});
const short = req.params["0"].split("/")[0] || req.query.short;
const { url } = req.query;
if (short) {
if (typeof short === "string") {
if (spacesRegex.test(short)) {
const binary = short
// Convert one type of space to zeroes
.replace(new RegExp(characters[0], "g"), "0")
// Convert the other type of space to ones
.replace(new RegExp(characters[1], "g"), "1");
const ref = urls.doc(binary);
const doc = await ref.get();
if (doc && doc.exists) {
return res
.status(200)
.json(dataToResponse(doc.data()))
.end();
} else {
return res.status(404).end();
}
} else {
return res
.status(400)
.json({ error: "Short ID contained invalid characters" })
.end();
}
} else {
return res
.status(400)
.json({ error: "Short ID must be string type" })
.end();
}
} else if (url) {
if (typeof url === "string") {
try {
new URL(url);
} catch (error) {
return res
.status(400)
.json({ error: "Not a valid URL" })
.end();
}
if (url.length > 500) {
return res
.status(413)
.json({ error: "URL can not exceed 500 characters" })
.end();
}
// Find documents that have the same long URL (duplicates)
const query = urls.where("url", "==", url);
const snapshot = await query.get();
const { docs } = snapshot;
const [doc] = docs;
if (doc && doc.exists) {
return res
.status(200)
.json(dataToResponse(doc.data()))
.end();
} else {
return res.status(404).end();
}
} else {
return res
.status(400)
.json({ error: "URL must be string type" })
.end();
}
} else {
return res
.status(400)
.json({ error: "You must specify a short ID or a URL" })
.end();
}
});

View File

@@ -1,74 +0,0 @@
const { hostnames } = require("../constants");
const binaryToSpaces = require("../util/binaryToSpaces");
const functions = require("firebase-functions");
const admin = require("firebase-admin");
const firestore = admin.firestore();
const urls = firestore.collection("urls");
const cors = require("cors")({ origin: true });
module.exports = functions.https.onRequest(async (req, res) => {
cors(req, res, () => {});
const { url } = req.query;
if (url) {
let urlInstance;
if (typeof url === "string") {
try {
urlInstance = new URL(url);
} catch (error) {
return res
.status(400)
.json({ error: "Not a valid URL" })
.end();
}
if (hostnames.includes(urlInstance.hostname)) {
return res.status(400).json({
error: "Shortening a URL containing the URL shortener's hostname is disallowed"
});
}
if (url.length > 500) {
return res
.status(413)
.json({ error: "URL can not exceed 500 characters" })
.end();
}
// Count is a number used for generating the short ID
const countRef = firestore.collection("settings").doc("short");
const countDoc = await countRef.get();
const { count } = countDoc.data();
// The math here converts the number to binary (decimal => binary string => binary number)
const short = binaryToSpaces(count.toString(2));
await Promise.all([
// Set the shortened URL document
urls
.doc(Number(count).toString(2))
.set({ url, stats: { get: 0, shorten: 1 }, usage: { get: [], shorten: [new Date()] } }),
// Set the count to be one higher
countRef.update({ count: admin.firestore.FieldValue.increment(1) })
]);
return res
.status(201)
.json({ short })
.end();
} else {
return res
.status(400)
.json({ error: "URL must be string type" })
.end();
}
} else {
return res
.status(400)
.json({ error: "You must specify a URL" })
.end();
}
});

View File

@@ -1,58 +0,0 @@
const functions = require("firebase-functions");
const debugAgent = require("@google-cloud/debug-agent");
const { version } = require("../package.json");
const serviceContext = {
service: "functions",
version
};
if (
functions.config().sqreen &&
functions.config().sqreen.app &&
functions.config().sqreen.app.name &&
functions.config().sqreen.token
) {
process.env.SQREEN_APP_NAME = functions.config().sqreen.app.name;
process.env.SQREEN_TOKEN = functions.config().sqreen.token;
require("sqreen");
}
const admin = require("firebase-admin");
if (process.env.NODE_ENV === "development") {
const serviceAccount = require("../../serviceAccount.json");
admin.initializeApp({
credential: admin.credential.cert(serviceAccount),
databaseURL: "https://zero-width-shortener.firebaseio.com"
});
debugAgent.start({
projectId: "zero-width-shortener",
keyFilename: "../../serviceAccount.json",
allowExpressions: true,
serviceContext
});
} else {
const profiler = require("@google-cloud/profiler");
admin.initializeApp();
profiler.start({
serviceContext
});
debugAgent.start({ serviceContext });
}
// Lots of Firebase stuff must be required after the app is initialized, including endpoints
const getURL = require("./endpoints/getURL");
const shortenURL = require("./endpoints/shortenURL");
const getURLStats = require("./endpoints/getURLStats");
module.exports = {
getURL,
shortenURL,
getURLStats
};

View File

@@ -1,14 +0,0 @@
const { characters } = require("../constants");
/**
* Convert binary numbers in string form to zero-width spaces
* @param {string} binaryString Binary values to convert
*/
module.exports = binaryString => {
characters.forEach(
(spaceConfig, index) =>
(binaryString = binaryString.replace(new RegExp(index.toString(), "g"), spaceConfig.preferred))
);
return binaryString;
};

View File

@@ -1,30 +0,0 @@
const mergeDefault = require("./mergeDefault");
/**
* Helper function for URL stats.
* @param {Object} data
*/
module.exports = data => {
mergeDefault(
{
stats: {
shorten: 0,
get: 0
},
usage: {
shorten: [],
get: []
}
},
data
);
return {
shorten: data.stats.shorten,
get: data.stats.get,
usage: {
get: data.usage.get.map(firestoreTimestamp => firestoreTimestamp.toMillis()),
shorten: data.usage.shorten.map(firestoreTimestamp => firestoreTimestamp.toMillis())
}
};
};

View File

@@ -1,18 +0,0 @@
/**
* Sets default properties on an object that aren't already specified.
* @param {Object} def Default properties
* @param {Object} given Object to assign defaults to
* @returns {Object}
*/
module.exports = (def, given) => {
if (!given) return def;
for (const key in def) {
if (!Object.prototype.hasOwnProperty.call(given, key) || given[key] === undefined) {
given[key] = def[key];
} else if (given[key] === Object(given[key])) {
given[key] = module.exports(def[key], given[key]);
}
}
return given;
};

View File

@@ -1,17 +0,0 @@
const { characters } = require("../constants");
/**
* Replace space characters with binary code IDs.
* @param {string} spaceCharacters String with space characters in it to be replaced with binary
*/
module.exports = spaceCharacters => {
characters.forEach(
(spaceConfig, index) =>
(spaceCharacters = spaceCharacters.replace(
new RegExp(`${spaceConfig.compatible.join("|")}|${spaceConfig.preferred}`, "g"),
index
))
);
return spaceCharacters;
};

219
openapi.yml Normal file
View File

@@ -0,0 +1,219 @@
openapi: '3.0.3'
info:
title: Zero Width Shortener
version: 2.0.0
contact:
name: Jonah Snider
email: jonah@jonah.pw
url: https://jonah.pw
license:
name: Apache 2.0
url: https://www.apache.org/licenses/LICENSE-2.0.html
description: Shorten URLs with invisible spaces.
servers:
- url: https://api.zws.im/
description: Production API.
paths:
/{short}:
get:
tags:
- urls
description: Retrieve a shortened URL.
parameters:
- $ref: '#/components/parameters/ShortenedUrl'
- in: query
name: visit
schema:
type: boolean
default: true
required: false
description: If you should be redirected to the URL instead of returning it as JSON.
responses:
'301':
description: Redirected to the long URL.
'404':
$ref: '#/components/responses/UrlNotFoundError'
'500':
$ref: '#/components/responses/Error'
'200':
content:
application/json:
schema:
type: object
properties:
url:
$ref: '#/components/schemas/Url'
description: Long URL returned.
/{short}/stats:
get:
tags:
- urls
description: Retrieve usage statistics for a shortened URL.
parameters:
- $ref: '#/components/parameters/ShortenedUrl'
responses:
'404':
$ref: '#/components/responses/UrlNotFoundError'
'500':
$ref: '#/components/responses/Error'
'200':
content:
application/json:
schema:
type: object
properties:
url:
$ref: '#/components/schemas/Url'
visits:
type: array
items:
type: string
format: date-time
example: 2021-02-21T03:58:25.794Z
description: Long URL returned.
/:
post:
tags:
- urls
description: Shorten a URL.
requestBody:
content:
application/json:
schema:
type: object
properties:
url:
$ref: '#/components/schemas/Url'
required:
- url
responses:
'500':
$ref: '#/components/responses/Error'
'422':
$ref: '#/components/responses/AttemptedShortenHostnameError'
'503':
$ref: '#/components/responses/UniqueShortIdTimeoutError'
'201':
content:
application/json:
schema:
type: object
properties:
short:
type: string
description: The shortened ID for the URL.
description: URL shortened.
components:
schemas:
Url:
type: string
example: https://jonah.pw
maxLength: 500
responses:
UrlNotFoundError:
description: The shortened URL couldn't be found.
content:
application/json:
schema:
type: object
properties:
statusCode:
type: integer
enum:
- 404
message:
type: string
error:
type: string
enum:
- Not Found
code:
type: string
enum:
- E_URL_NOT_FOUND
required:
- statusCode
- message
- error
- code
AttemptedShortenHostnameError:
description: You attempted to shorten a URL with the same hostname as this instance.
content:
application/json:
schema:
type: object
properties:
statusCode:
type: integer
enum:
- 422
message:
type: string
error:
type: string
enum:
- Unprocessable Entity
code:
type: string
enum:
- E_SHORTEN_HOSTNAME
required:
- statusCode
- message
- error
- code
UniqueShortIdTimeoutError:
description: A unique short ID couldn't be generated in enough time.
content:
application/json:
schema:
type: object
properties:
statusCode:
type: integer
enum:
- 503
message:
type: string
error:
type: string
enum:
- Service Unavailable
code:
type: string
enum:
- E_UNIQUE_SHORT_ID_TIMEOUT
required:
- statusCode
- message
- error
- code
Error:
description: An error occurred while processing the request.
content:
application/json:
schema:
type: object
properties:
statusCode:
type: integer
enum:
- 500
message:
type: string
error:
type: string
enum:
- Internal Server Error
required:
- statusCode
- message
- error
parameters:
ShortenedUrl:
in: path
name: short
schema:
type: string
required: true
description: The shortened ID for the URL.

78
package.json Normal file
View File

@@ -0,0 +1,78 @@
{
"author": {
"email": "jonah@jonah.pw",
"name": "Jonah Snider",
"url": "https://jonah.pw"
},
"bugs": {
"url": "https://github.com/zws-im/zws/issues"
},
"devDependencies": {
"@semantic-release/exec": "5.0.0",
"@tsconfig/node14": "1.0.0",
"@types/node": "14.14.31",
"@types/supports-color": "7.2.0",
"eslint-plugin-prettier": "3.3.1",
"prettier": "2.2.1",
"prettier-config-xo": "1.0.3",
"prisma": "2.17.0",
"semantic-release": "17.3.9",
"semantic-release-docker": "2.2.0",
"ts-node": "9.1.1",
"type-fest": "0.21.1",
"typescript": "4.1.5",
"xo": "0.38.1"
},
"license": "Apache-2.0",
"main": "./tsc_output/index.js",
"name": "@zws.im/zws",
"engines": {
"node": "15.x"
},
"nyc": {
"all": true,
"extends": "@istanbuljs/nyc-config-typescript",
"include": [
"src/**/*.ts"
],
"reporter": [
"lcov",
"cobertura"
]
},
"private": true,
"repository": {
"type": "git",
"url": "git+https://github.com/zws-im/zws.git"
},
"scripts": {
"build": "tsc",
"deploy": "semantic-release",
"lint": "xo",
"prebuild": "rm -rf tsc_output",
"style": "prettier --check .",
"start": "node .",
"migrations": "prisma migrate deploy --preview-feature"
},
"version": "2.0.0",
"xo": {
"prettier": true,
"rules": {
"prettier/prettier": "off"
}
},
"dependencies": {
"@pizzafox/util": "2.5.0",
"@prisma/client": "2.17.0",
"convert": "1.6.2",
"dotenv": "8.2.0",
"execa": "5.0.0",
"fastify": "3.12.0",
"fastify-cors": "5.2.0",
"fastify-error": "0.3.0",
"fluent-json-schema": "2.0.4",
"ow": "0.23.0",
"supports-color": "8.1.1",
"tslog": "3.1.1"
}
}

4
prettier.config.js Normal file
View File

@@ -0,0 +1,4 @@
module.exports = {
...require('prettier-config-xo'),
printWidth: 160
};

View File

@@ -0,0 +1,22 @@
-- CreateTable
CREATE TABLE "shortened_urls" (
"short_base64" TEXT NOT NULL,
"url" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY ("short_base64")
);
-- CreateTable
CREATE TABLE "visits" (
"timestamp" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"shortened_url_base64" TEXT,
PRIMARY KEY ("timestamp")
);
-- CreateIndex
CREATE INDEX "visits.shortened_url_base64_index" ON "visits"("shortened_url_base64");
-- AddForeignKey
ALTER TABLE "visits" ADD FOREIGN KEY ("shortened_url_base64") REFERENCES "shortened_urls"("short_base64") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -0,0 +1,14 @@
/*
Warnings:
- The migration will change the primary key for the `visits` table. If it partially fails, the table could be left without primary key constraint.
- Made the column `shortened_url_base64` on table `visits` required. The migration will fail if there are existing NULL values in that column.
*/
-- DropIndex
DROP INDEX "visits.shortened_url_base64_index";
-- AlterTable
ALTER TABLE "visits" DROP CONSTRAINT "visits_pkey",
ALTER COLUMN "shortened_url_base64" SET NOT NULL,
ADD PRIMARY KEY ("shortened_url_base64", "timestamp");

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"

35
prisma/schema.prisma Normal file
View File

@@ -0,0 +1,35 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
}
model ShortenedUrl {
/// The base64 encoded shortened ID
shortBase64 String @id @map("short_base64")
/// The long URL
url String
/// Statistics of when this URL was visited
visits Visit[]
/// The datetime the URL was shortened
createdAt DateTime @default(now()) @map("created_at")
@@map("shortened_urls")
}
model Visit {
/// When the visit occurred
timestamp DateTime @default(now())
shortenedUrl ShortenedUrl @relation(fields: [shortenedUrlId], references: [shortBase64])
shortenedUrlId String @map("shortened_url_base64")
@@id([shortenedUrlId, timestamp])
@@map("visits")
}

12
renovate.json Normal file
View File

@@ -0,0 +1,12 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:base", ":semanticCommitTypeAll(build)"],
"labels": ["dependencies"],
"packageRules": [
{
"automerge": true,
"updateTypes": ["patch"]
}
],
"semanticCommits": true
}

102
src/config/characters.ts Normal file
View File

@@ -0,0 +1,102 @@
import ow from 'ow';
import {JsonValue} from 'type-fest';
/**
* Every character matching this regular expression:
* ```js
* /[a-z\d]/i;
* ```
*/
const alphaNumeric: string[] = [
// #region alphanumeric
'a',
'b',
'c',
'd',
'e',
'f',
'g',
'h',
'i',
'j',
'k',
'l',
'm',
'n',
'o',
'p',
'q',
'r',
'z',
't',
'u',
'v',
'x',
'w',
'y',
'z',
'A',
'B',
'C',
'D',
'E',
'F',
'G',
'H',
'I',
'J',
'K',
'L',
'M',
'N',
'O',
'P',
'Q',
'R',
'Z',
'T',
'U',
'V',
'X',
'W',
'Y',
'Z',
'1',
'2',
'3',
'4',
'5',
'6',
'7',
'8',
'9',
'0'
// #endregion
];
enum EnvVarNames {
ShortChars = 'SHORT_CHARS',
ShortLength = 'SHORT_LENGTH',
ShortRewrites = 'SHORT_REWRITES'
}
const parsedCharacters: JsonValue = JSON.parse(process.env[EnvVarNames.ShortChars] ?? 'null');
ow(parsedCharacters, EnvVarNames.ShortChars, ow.any(ow.array.ofType(ow.string.nonEmpty).minLength(1)));
/** Characters to use in the shortened ID for a URL. */
export const characters = parsedCharacters === null ? alphaNumeric : [...new Set<string>(parsedCharacters)];
const defaultShortLength = Math.round(8.5 / (2 * Math.log10(characters.length)) + 4);
/** The length of the shortened ID for a URL. */
export const length = process.env[EnvVarNames.ShortLength] === undefined ? defaultShortLength : Number(process.env[EnvVarNames.ShortLength]);
ow(length, EnvVarNames.ShortLength, ow.number.integer.positive);
const parsedRewrites: JsonValue = JSON.parse(process.env[EnvVarNames.ShortRewrites] ?? '{}');
ow(parsedRewrites, EnvVarNames.ShortRewrites, ow.object.valuesOfType(ow.string.nonEmpty));
ow(parsedRewrites, EnvVarNames.ShortRewrites, ow.object.not.instanceOf(Array));
/** Rewrites applied to a URL before redirecting. */
export const rewrites = parsedRewrites as Record<string, string>;

9
src/config/env.ts Normal file
View File

@@ -0,0 +1,9 @@
export enum Env {
Prod,
Dev
}
/** If the application is running on Heroku. */
export const heroku = 'DYNO' in process.env && process.env.NODE_HOME?.includes('heroku');
export const env = process.env.NODE_ENV === 'development' ? Env.Dev : Env.Prod;

10
src/config/index.ts Normal file
View File

@@ -0,0 +1,10 @@
import * as dotenv from 'dotenv';
import * as env from './env';
if (env.env === env.Env.Dev) {
dotenv.config();
}
export * as characters from './characters';
export * as env from './env';
export * as server from './server';

10
src/config/server.ts Normal file
View File

@@ -0,0 +1,10 @@
import ow from 'ow';
import {JsonValue} from 'type-fest';
export const port = process.env.PORT === undefined ? 3000 : Number(process.env.PORT);
const rawHostname: JsonValue = process.env.HOSTNAME ?? 'localhost';
ow(rawHostname, 'HOSTNAME', ow.string);
export const hostname: string = rawHostname;

23
src/db.ts Normal file
View File

@@ -0,0 +1,23 @@
import {PrismaClient} from '@prisma/client';
import execa from 'execa';
import baseLogger from './logger';
const logger = baseLogger.getChildLogger({name: 'db'});
export async function applyMigrations(): Promise<void> {
await execa('yarn', ['run', 'migrations'], {stderr: 'inherit', stdout: 'inherit'});
}
const db = new PrismaClient({
log: [
{emit: 'event', level: 'error'},
{emit: 'event', level: 'info'},
{emit: 'event', level: 'warn'}
]
});
db.$on('error', error => logger.error(error.message));
db.$on('info', info => logger.info(info.message));
db.$on('warn', warning => logger.warn(warning.message));
export default db;

20
src/index.ts Normal file
View File

@@ -0,0 +1,20 @@
import {server} from './config';
import fastify from './server';
import baseLogger from './logger';
const logger = baseLogger.getChildLogger({name: 'http'});
(async () => {
try {
const address = await fastify.listen({port: server.port, host: '0.0.0.0'});
logger.info(`Listening at ${address}`);
} catch (error: unknown) {
logger.error(error);
throw error;
}
})().catch(error => {
process.nextTick(() => {
throw error;
});
});

13
src/logger.ts Normal file
View File

@@ -0,0 +1,13 @@
import supportsColor from 'supports-color';
import {Logger} from 'tslog';
import {heroku} from './config/env';
const logger = new Logger({
displayFilePath: 'hidden',
displayFunctionName: false,
colorizePrettyLogs: supportsColor.stdout && supportsColor.stderr,
// Heroku logs display timestamps next to each line
displayDateTime: !heroku
});
export default logger;

9
src/server/errors.ts Normal file
View File

@@ -0,0 +1,9 @@
import createError from 'fastify-error';
/** When a user attempted to access a shortened URL that couldn't be found. */
export const UrlNotFound = createError('E_URL_NOT_FOUND', 'Shortened URL not found in database', 404);
/** When a user attempted to shorten a URL that had the same hostname as the app does. */
export const AttemptedShortenHostname = createError('E_SHORTEN_HOSTNAME', 'Shortening a URL with the same hostname as the server is disallowed', 422);
export const UniqueShortIdTimeout = createError('E_UNIQUE_SHORT_ID_TIMEOUT', `Couldn't generate a new shortened ID in %s attempts`, 503);

48
src/server/hooks.ts Normal file
View File

@@ -0,0 +1,48 @@
import {FastifyInstance} from 'fastify';
import {env} from '../config';
import db, {applyMigrations} from '../db';
import baseLogger from '../logger';
let requestId: string | undefined;
const fastifyLogger = baseLogger.getChildLogger({
name: 'http',
requestId: () => requestId!
});
export default function addHooks(fastify: FastifyInstance): void {
fastify.addHook('onReady', async () => {
if (env.heroku) {
const dbLogger = baseLogger.getChildLogger({name: 'db'});
dbLogger.info('Heroku environment detected, running migrations');
try {
await applyMigrations();
} catch (error: unknown) {
dbLogger.error('Migrations failed', error);
throw error;
}
dbLogger.info('Migrations completed');
}
await db.$connect();
});
fastify.addHook('onClose', async () => db.$disconnect());
fastify.addHook('onRequest', async (request, reply) => {
requestId = request.id;
fastifyLogger.info(`${request.routerMethod} ${request.routerPath}`);
});
fastify.addHook('onError', async (request, reply, error) => {
if (reply.statusCode >= 500 && reply.statusCode < 600) {
requestId = request.id;
fastifyLogger.error(error);
}
});
}

24
src/server/index.ts Normal file
View File

@@ -0,0 +1,24 @@
import convert from 'convert';
import createServer, {RouteOptions} from 'fastify';
import cors from 'fastify-cors';
import {heroku} from '../config/env';
import logger from '../logger';
import registerHooks from './hooks';
import * as routes from './routes';
const fastify = createServer({
maxParamLength: 1024,
// Migrations are applied when running in Heroku which can take a while
pluginTimeout: heroku ? convert(30).from('s').to('ms') : undefined
});
registerHooks(fastify);
for (const route of Object.values(routes)) {
fastify.route(route as RouteOptions);
}
// eslint-disable-next-line @typescript-eslint/no-empty-function
fastify.register(cors).then(() => {}, logger.getChildLogger({name: 'http'}).error);
export default fastify;

View File

@@ -0,0 +1,3 @@
export {default as visit} from './urls/visit';
export {default as stats} from './urls/stats';
export {default as shorten} from './urls/shorten';

View File

@@ -0,0 +1,32 @@
import {RawReplyDefaultExpression, RawRequestDefaultExpression, RawServerDefault, RouteOptions} from 'fastify';
import S from 'fluent-json-schema';
// eslint-disable-next-line node/prefer-global/url
import {URL} from 'url';
import {server} from '../../../config';
import {urls} from '../../../services';
import {AttemptedShortenHostname} from '../../errors';
const route: RouteOptions<RawServerDefault, RawRequestDefaultExpression, RawReplyDefaultExpression, {Body: {url: string}; Reply: {short: string}}> = {
method: 'POST',
url: '/',
schema: {
body: S.object().prop('url', S.string().format(S.FORMATS.URL).maxLength(500))
},
handler: async (request, reply) => {
const {
body: {url}
} = request;
if (new URL(url).hostname === server.hostname) {
throw new AttemptedShortenHostname();
}
const id = await urls.shorten(url);
void reply.code(201);
return {short: id};
}
};
export default route;

View File

@@ -0,0 +1,32 @@
import {RawReplyDefaultExpression, RawRequestDefaultExpression, RawServerDefault, RouteOptions} from 'fastify';
import S from 'fluent-json-schema';
import {urls} from '../../../services';
import {UrlNotFound} from '../../errors';
const route: RouteOptions<
RawServerDefault,
RawRequestDefaultExpression,
RawReplyDefaultExpression,
{Params: {short: string}; Reply: {url: string; visits: Date[]}}
> = {
method: 'GET',
url: '/:short/stats',
schema: {
params: S.object().prop('short', S.string())
},
handler: async (request, reply) => {
const {
params: {short}
} = request;
const stats = await urls.stats(urls.normalizeShortId(short));
if (stats === null) {
throw new UrlNotFound();
}
return stats;
}
};
export default route;

View File

@@ -0,0 +1,38 @@
import {RawReplyDefaultExpression, RawRequestDefaultExpression, RawServerDefault, RouteOptions} from 'fastify';
import S from 'fluent-json-schema';
import {urls} from '../../../services';
import {UrlNotFound} from '../../errors';
const route: RouteOptions<
RawServerDefault,
RawRequestDefaultExpression,
RawReplyDefaultExpression,
{Params: {short: string}; Querystring: {visit: boolean}; Reply: {url: string}}
> = {
method: 'GET',
url: '/:short',
schema: {
params: S.object().prop('short', S.string()),
querystring: S.object().prop('visit', S.boolean().default(true))
},
handler: async (request, reply) => {
const {
query: {visit},
params: {short}
} = request;
const url = await urls.visit(urls.normalizeShortId(short), true);
if (url === null) {
throw new UrlNotFound();
}
if (visit) {
void reply.redirect(url);
} else {
return {url};
}
}
};
export default route;

1
src/services/index.ts Normal file
View File

@@ -0,0 +1 @@
export * as urls from './urls';

120
src/services/urls.ts Normal file
View File

@@ -0,0 +1,120 @@
import {sample} from '@pizzafox/util';
import {Opaque} from 'type-fest';
import {characters} from '../config';
import db from '../db';
import {UniqueShortIdTimeout} from '../server/errors';
/** A base64 encoded string. */
type Base64 = Opaque<string, 'Base64'>;
/** Maximum number of attempts to generate a unique ID. */
const maxGenerationAttempts = 10;
function encode(value: string): Base64 {
return Buffer.from(value).toString('base64') as Base64;
}
export function debugInfo(id: string, encodedId: Base64) {
return {short: id, encodedShort: encodedId, shortCodepoints: id.split('').map(char => char.charCodeAt(0).toString(16))};
}
// Reused to avoid expensive iteration
const rewritesEntries = Object.entries(characters.rewrites);
export function normalizeShortId(id: string): string {
for (const [original, rewrite] of rewritesEntries) {
id = id.replaceAll(original, rewrite);
}
return id;
}
/**
* Generate a short ID.
* Not guaranteed to be unique.
*
* @returns A short ID
*/
function generateShortId(): string {
let shortId = '';
// eslint-disable-next-line @typescript-eslint/prefer-for-of
for (let i = 0; i < characters.length; i++) {
shortId += sample(characters.characters);
}
return shortId;
}
/**
* Visit a shortened URL.
*
* @param id - The ID of the shortened URL to visit
* @param track - If the visit should be tracked
*
* @returns The long URL, or `null` if it couldn't be found
*/
export async function visit(id: string, track: boolean): Promise<string | null> {
const encodedId = encode(id);
const url = (await db.shortenedUrl.findUnique({where: {shortBase64: encodedId}, select: {url: true}}))?.url;
if (url === undefined) {
return null;
}
if (track) {
// TODO: Remove new Date()` when Prisma is patched (see https://github.com/prisma/prisma/issues/5762)
db.visit.create({data: {shortenedUrl: {connect: {shortBase64: encodedId}}, timestamp: new Date()}}).catch(error => {
throw error;
});
}
return url;
}
/**
* Retrieve usage statistics for a shortened URL
*
* @param id - The ID of the shortened URL
*
* @returns Shortened URL information and statistics, or `null` if it couldn't be found
*/
export async function stats(id: string): Promise<null | {url: string; visits: Date[]}> {
const encodedId = encode(id);
const shortenedUrl = await db.shortenedUrl.findUnique({where: {shortBase64: encodedId}, select: {url: true, visits: {select: {timestamp: true}}}});
if (!shortenedUrl) {
return null;
}
return {visits: shortenedUrl.visits.map(visit => visit.timestamp), url: shortenedUrl.url};
}
/**
* Shorten a long URL
*
* @param url - The long URL to shorten
*
* @returns The ID of the shortened URL
*/
export async function shorten(url: string): Promise<string> {
let id: string;
let shortBase64: Base64;
let attempts = 0;
do {
if (attempts++ > maxGenerationAttempts) {
throw new UniqueShortIdTimeout(maxGenerationAttempts);
}
id = generateShortId();
shortBase64 = encode(id);
// eslint-disable-next-line no-await-in-loop
} while ((await db.shortenedUrl.count({where: {shortBase64}})) > 0);
await db.shortenedUrl.create({data: {url, shortBase64}});
return id;
}

11
tsconfig.json Normal file
View File

@@ -0,0 +1,11 @@
{
"compilerOptions": {
"moduleResolution": "node",
"outDir": "tsc_output",
"resolveJsonModule": true,
"sourceMap": true
},
"extends": "@tsconfig/node14/tsconfig.json",
"exclude": ["node_modules", "nyc_output", "coverage"],
"include": ["src"]
}

10603
yarn.lock Normal file

File diff suppressed because it is too large Load Diff