├── static ├── img │ └── favicon.ico ├── fonts │ ├── FontAwesome.otf │ ├── fontawesome-webfont.eot │ ├── fontawesome-webfont.ttf │ ├── fontawesome-webfont.woff │ └── fontawesome-webfont.woff2 ├── js │ ├── main.js │ ├── clipboard.min.js │ └── notie.min.js ├── partials │ ├── card_footer.html │ └── base.html ├── tmpl │ ├── auth.html │ ├── abuse.html │ ├── index.html │ └── safebrowsing.html └── css │ ├── main.css │ ├── notie.min.css │ └── font-awesome.min.css ├── .github ├── CODEOWNERS ├── ci-config.yml ├── workflows │ ├── docker-prune.yml │ ├── generate-readme.yml │ ├── renovate.yml │ ├── release.yml │ ├── test.yml │ └── tag-semver.yml ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── feature_request.yml │ └── bug_report.yml ├── PULL_REQUEST_TEMPLATE.md ├── SUPPORT.md ├── SECURITY.md ├── CODE_OF_CONDUCT.md └── CONTRIBUTING.md ├── .gitignore ├── .dockerignore ├── Dockerfile ├── .editorconfig ├── LICENSE ├── Makefile ├── go.mod ├── client └── api.go ├── safebrowsing.go ├── manage.go ├── metrics.go ├── main.go ├── database.go ├── http.go ├── go.sum ├── .golangci.yml └── README.md /static/img/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lrstanley/links/HEAD/static/img/favicon.ico -------------------------------------------------------------------------------- /static/fonts/FontAwesome.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lrstanley/links/HEAD/static/fonts/FontAwesome.otf -------------------------------------------------------------------------------- /static/fonts/fontawesome-webfont.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lrstanley/links/HEAD/static/fonts/fontawesome-webfont.eot -------------------------------------------------------------------------------- /static/fonts/fontawesome-webfont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lrstanley/links/HEAD/static/fonts/fontawesome-webfont.ttf -------------------------------------------------------------------------------- /static/fonts/fontawesome-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lrstanley/links/HEAD/static/fonts/fontawesome-webfont.woff -------------------------------------------------------------------------------- /static/fonts/fontawesome-webfont.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lrstanley/links/HEAD/static/fonts/fontawesome-webfont.woff2 -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # THIS FILE IS GENERATED! DO NOT EDIT! Maintained by Terraform. 2 | .github/* @lrstanley 3 | LICENSE @lrstanley 4 | -------------------------------------------------------------------------------- /.github/ci-config.yml: -------------------------------------------------------------------------------- 1 | goreleaser: 2 | post: 3 | nfpms: 4 | - homepage: https://github.com/lrstanley/links 5 | license: MIT 6 | formats: [deb, rpm] 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | *.db 3 | *journal 4 | ~* 5 | *.tmp 6 | *.txt 7 | *.export 8 | dist 9 | bin 10 | rice-box* 11 | *.test 12 | *.prof 13 | vendor/* 14 | links 15 | links-* 16 | .env -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | *.log 2 | ~* 3 | *.tmp 4 | *.txt 5 | dist 6 | *.test 7 | *.prof 8 | *.conf 9 | *.toml 10 | *.db 11 | links 12 | vendor 13 | rice*.go 14 | .git 15 | bin 16 | .env 17 | **/*.md -------------------------------------------------------------------------------- /.github/workflows/docker-prune.yml: -------------------------------------------------------------------------------- 1 | name: docker-prune 2 | 3 | on: 4 | workflow_dispatch: {} 5 | schedule: 6 | - cron: "0 2 * * *" 7 | 8 | jobs: 9 | docker-prune: 10 | uses: lrstanley/.github/.github/workflows/docker-prune.yml@master 11 | secrets: 12 | token: ${{ secrets.USER_PAT }} 13 | -------------------------------------------------------------------------------- /.github/workflows/generate-readme.yml: -------------------------------------------------------------------------------- 1 | name: generate-readme 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | tags: [v*] 7 | schedule: 8 | - cron: "0 13 * * *" 9 | 10 | jobs: 11 | generate: 12 | uses: lrstanley/.github/.github/workflows/generate-readme.yml@master 13 | secrets: 14 | token: ${{ secrets.USER_PAT }} 15 | -------------------------------------------------------------------------------- /.github/workflows/renovate.yml: -------------------------------------------------------------------------------- 1 | name: renovate 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: [master] 7 | schedule: 8 | - cron: "* 1 * * *" 9 | 10 | jobs: 11 | renovate: 12 | uses: lrstanley/.github/.github/workflows/renovate.yml@master 13 | secrets: 14 | app-id: ${{ secrets.BOT_APP_ID }} 15 | app-private-key: ${{ secrets.BOT_PRIVATE_KEY }} 16 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | push: 5 | tags: [v*] 6 | 7 | jobs: 8 | docker-release: 9 | uses: lrstanley/.github/.github/workflows/docker-release.yml@master 10 | secrets: 11 | SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} 12 | with: 13 | scan: false 14 | go-release: 15 | uses: lrstanley/.github/.github/workflows/lang-go-release.yml@master 16 | with: 17 | has-ghcr: true 18 | upload-artifacts: true 19 | -------------------------------------------------------------------------------- /static/js/main.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Liam Stanley . All rights reserved. Use 3 | * of this source code is governed by the MIT license that can be found in 4 | * the LICENSE file. 5 | */ 6 | 7 | // Initialize all callbacks. 8 | $(function () { 9 | $(".input-focus").focus(); 10 | var clipboard = new Clipboard('.clip'); 11 | 12 | clipboard.on('success', function (e) { 13 | notie.alert({time: 1, text: "Copied to clipboard"}) 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax = docker/dockerfile:1.4 2 | 3 | # build image 4 | FROM golang:latest as build 5 | WORKDIR /build 6 | COPY go.sum go.mod Makefile /build/ 7 | RUN make go-fetch 8 | COPY . /build/ 9 | RUN make 10 | 11 | # runtime image 12 | FROM alpine:3.21 13 | RUN apk add --no-cache ca-certificates 14 | COPY --from=build /build/links /usr/local/bin/links 15 | 16 | # runtime params 17 | VOLUME /data 18 | EXPOSE 80 19 | WORKDIR / 20 | ENV PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin 21 | CMD ["/usr/local/bin/links", "--http", "0.0.0.0:80", "--behind-proxy", "--db", "/data/store.db"] 22 | -------------------------------------------------------------------------------- /static/partials/card_footer.html: -------------------------------------------------------------------------------- 1 | 14 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: 4 | pull_request: 5 | branches: [master] 6 | paths-ignore: [".gitignore", "**/*.md", ".github/ISSUE_TEMPLATE/**"] 7 | types: [opened, edited, reopened, synchronize, unlocked] 8 | push: 9 | branches: [master] 10 | paths-ignore: [".gitignore", "**/*.md", ".github/ISSUE_TEMPLATE/**"] 11 | 12 | jobs: 13 | go-test: 14 | uses: lrstanley/.github/.github/workflows/lang-go-test-matrix.yml@master 15 | with: 16 | go-version: latest 17 | go-lint: 18 | uses: lrstanley/.github/.github/workflows/lang-go-lint.yml@master 19 | secrets: 20 | SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} 21 | docker-test: 22 | needs: [go-test, go-lint] 23 | uses: lrstanley/.github/.github/workflows/docker-release.yml@master 24 | secrets: 25 | SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} 26 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | # THIS FILE IS GENERATED! DO NOT EDIT! Maintained by Terraform. 2 | blank_issues_enabled: false 3 | contact_links: 4 | - name: "🙋‍♂️ Ask the community a question!" 5 | about: Have a question, that might not be a bug? Wondering how to solve a problem? Ask away! 6 | url: "https://github.com/lrstanley/links/discussions/new?category=q-a" 7 | - name: "🎉 Show us what you've made!" 8 | about: Have you built something using links, and want to show others? Post here! 9 | url: "https://github.com/lrstanley/links/discussions/new?category=show-and-tell" 10 | - name: "✋ Additional support information" 11 | about: Looking for something else? Check here. 12 | url: "https://github.com/lrstanley/links/blob/master/.github/SUPPORT.md" 13 | - name: "💬 Discord chat" 14 | about: On-topic and off-topic discussions. 15 | url: "https://liam.sh/chat" 16 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # THIS FILE IS GENERATED! DO NOT EDIT! Maintained by Terraform. 2 | # 3 | # editorconfig: https://editorconfig.org/ 4 | # actual source: https://github.com/lrstanley/.github/blob/master/terraform/github-common-files/templates/.editorconfig 5 | # 6 | 7 | root = true 8 | 9 | [*] 10 | charset = utf-8 11 | end_of_line = lf 12 | indent_size = 4 13 | indent_style = space 14 | insert_final_newline = true 15 | trim_trailing_whitespace = true 16 | max_line_length = 100 17 | 18 | [*.tf] 19 | indent_size = 2 20 | 21 | [*.go] 22 | indent_style = tab 23 | indent_size = 4 24 | 25 | [*.md] 26 | trim_trailing_whitespace = false 27 | 28 | [*.{md,py,sh,yml,yaml,cjs,js,ts,vue,css}] 29 | max_line_length = 105 30 | 31 | [*.{yml,yaml,toml}] 32 | indent_size = 2 33 | 34 | [*.json] 35 | indent_size = 2 36 | insert_final_newline = ignore 37 | 38 | [*.html] 39 | max_line_length = 140 40 | indent_size = 2 41 | 42 | [*.{cjs,js,ts,vue,css}] 43 | indent_size = 2 44 | 45 | [Makefile] 46 | indent_style = tab 47 | 48 | [**.min.js] 49 | indent_style = ignore 50 | insert_final_newline = ignore 51 | 52 | [*.bat] 53 | indent_style = tab 54 | -------------------------------------------------------------------------------- /.github/workflows/tag-semver.yml: -------------------------------------------------------------------------------- 1 | name: tag-semver 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | method: 7 | description: "Tagging method to use" 8 | required: true 9 | type: choice 10 | options: [major, minor, patch, alpha, rc, custom] 11 | custom: 12 | description: "Custom tag, if the default doesn't suffice. Must also use method 'custom'." 13 | required: false 14 | type: string 15 | ref: 16 | description: "Git ref to apply tag to (will use default branch if unspecified)." 17 | required: false 18 | type: string 19 | annotation: 20 | description: "Optional annotation to add to the commit." 21 | required: false 22 | type: string 23 | 24 | jobs: 25 | tag-semver: 26 | uses: lrstanley/.github/.github/workflows/tag-semver.yml@master 27 | with: 28 | method: ${{ github.event.inputs.method }} 29 | ref: ${{ github.event.inputs.ref }} 30 | custom: ${{ github.event.inputs.custom }} 31 | annotation: ${{ github.event.inputs.annotation }} 32 | secrets: 33 | token: ${{ secrets.USER_PAT }} 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2014 Liam Stanley 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /static/tmpl/auth.html: -------------------------------------------------------------------------------- 1 | {% extends "../partials/base.html" %} 2 | {% block title %}Decrypt link{% endblock title %} 3 | {% block content %} 4 |
5 |
6 | {% if message %}{% endif %} 7 | 8 |
9 |
Authentication Required
10 |
11 |
12 |
13 | 14 | 15 | 16 | 17 |
18 |
19 |
20 | 21 | {% include "../partials/card_footer.html" %} 22 |
23 |
24 |
25 | {% endblock content %} 26 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .DEFAULT_GOAL := build-all 2 | 3 | export PROJECT := "links" 4 | export PACKAGE := "github.com/lrstanley/links" 5 | 6 | license: 7 | curl -sL https://liam.sh/-/gh/g/license-header.sh | bash -s 8 | 9 | build-all: clean go-fetch go-build 10 | @echo 11 | 12 | up: go-upgrade-deps 13 | @echo 14 | 15 | clean: 16 | /bin/rm -rfv "dist/" "${PROJECT}" 17 | 18 | go-prepare: license go-fetch 19 | go generate -x ./... 20 | 21 | go-fetch: 22 | go mod download 23 | go mod tidy 24 | 25 | go-upgrade-deps: 26 | go get -u ./... 27 | go mod tidy 28 | 29 | go-upgrade-deps-patch: 30 | go get -u=patch ./... 31 | go mod tidy 32 | 33 | go-dlv: go-prepare 34 | dlv debug \ 35 | --headless --listen=:2345 \ 36 | --api-version=2 --log \ 37 | --allow-non-terminal-interactive \ 38 | ${PACKAGE} -- --site-name "http://localhost:8080" --debug --http ":8080" --prom.enabled 39 | 40 | go-debug: go-prepare 41 | go run ${PACKAGE} --site-name "http://localhost:8080" --debug --http ":8080" --prom.enabled 42 | 43 | go-build: go-prepare go-fetch 44 | CGO_ENABLED=0 \ 45 | go build \ 46 | -ldflags '-d -s -w -extldflags=-static' \ 47 | -tags=netgo,osusergo,static_build \ 48 | -installsuffix netgo \ 49 | -buildvcs=false \ 50 | -trimpath \ 51 | -o ${PROJECT} \ 52 | ${PACKAGE} 53 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/lrstanley/links 2 | 3 | go 1.23 4 | 5 | toolchain go1.24.1 6 | 7 | require ( 8 | github.com/flosch/pongo2 v0.0.0-20200913210552-0d938eb266f3 9 | github.com/go-chi/chi/v5 v5.2.1 10 | github.com/google/safebrowsing v0.0.0-20190624211811-bbf0d20d26b3 11 | github.com/jessevdk/go-flags v1.6.1 12 | github.com/joho/godotenv v1.5.1 13 | github.com/lrstanley/go-sempool v0.0.0-20230116155332-a03d0c109854 14 | github.com/lrstanley/pt v0.0.0-20250101082311-aa14889de735 15 | github.com/prometheus/client_golang v1.21.1 16 | github.com/timshannon/bolthold v0.0.0-20240314194003-30aac6950928 17 | go.etcd.io/bbolt v1.4.0 18 | ) 19 | 20 | require ( 21 | github.com/beorn7/perks v1.0.1 // indirect 22 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 23 | github.com/flosch/pongo2/v6 v6.0.0 // indirect 24 | github.com/golang/protobuf v1.5.4 // indirect 25 | github.com/klauspost/compress v1.17.11 // indirect 26 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 27 | github.com/prometheus/client_model v0.6.1 // indirect 28 | github.com/prometheus/common v0.62.0 // indirect 29 | github.com/prometheus/procfs v0.15.1 // indirect 30 | golang.org/x/net v0.34.0 // indirect 31 | golang.org/x/sys v0.29.0 // indirect 32 | golang.org/x/text v0.21.0 // indirect 33 | google.golang.org/protobuf v1.36.2 // indirect 34 | ) 35 | -------------------------------------------------------------------------------- /static/css/main.css: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Liam Stanley . All rights reserved. Use 3 | * of this source code is governed by the MIT license that can be found in 4 | * the LICENSE file. 5 | */ 6 | 7 | html, body, .main { 8 | height: 100%; 9 | width: 100%; 10 | margin: 0; 11 | padding: 0; 12 | } 13 | 14 | .flex-ct { 15 | display: -ms-flexbox; 16 | display: -webkit-flex; 17 | display: flex; 18 | -ms-flex-align: center; 19 | -webkit-align-items: center; 20 | -webkit-box-align: center; 21 | align-items: center; 22 | } 23 | 24 | .wrap { 25 | width: 1000px; 26 | margin-left: auto; 27 | margin-right: auto; 28 | } 29 | 30 | .wrap-sm { 31 | max-width: 500px; 32 | margin-left: auto; 33 | margin-right: auto; 34 | } 35 | 36 | .wrap-md { 37 | max-width: 700px; 38 | margin-left: auto; 39 | margin-right: auto; 40 | } 41 | 42 | @media (max-width: 1050px) { 43 | .wrap { 44 | max-width: 900px; 45 | margin-left: auto; 46 | margin-right: auto; 47 | } 48 | } 49 | 50 | .main .card-header { 51 | padding: 7px 13px; 52 | } 53 | 54 | .main .card-footer { 55 | padding: 6px 13px 9px; 56 | } 57 | 58 | .main .card-footer a, .main .card-footer a:link, .main .card-footer a:visited, .main .card-footer a:hover { 59 | color: #383838; 60 | } 61 | -------------------------------------------------------------------------------- /static/partials/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | {% block title %}{% endblock title %} · Links 10 | 11 | 12 | 13 | 14 | {% if http_pre_include %} 15 | 16 | {{ http_pre_include|safe }} 17 | 18 | {% endif %} 19 | 20 | 21 | 22 | {% block content %}{% endblock content %} 23 | 24 | 25 | 26 | 27 | {% if http_post_include %} 28 | 29 | {{ http_post_include|safe }} 30 | 31 | {% endif %} 32 | 33 | 34 | -------------------------------------------------------------------------------- /static/tmpl/abuse.html: -------------------------------------------------------------------------------- 1 | {% extends "../partials/base.html" %} 2 | {% block title %}Abuse{% endblock title %} 3 | {% block content %} 4 |
5 |
6 |
7 |
Abuse Information & Contact
8 |
9 | This site is a hosted version of Links, 10 | a link shortening http service. This service does not host any user-provided 11 | content. 12 | 13 | 17 | 18 | {% if safebrowsing %} 19 | 24 | {% endif %} 25 |
26 | 27 | {% include "../partials/card_footer.html" %} 28 |
29 |
30 |
31 | {% endblock content %} 32 | -------------------------------------------------------------------------------- /static/css/notie.min.css: -------------------------------------------------------------------------------- 1 | .notie-container{font-size:1.6rem;height:auto;left:0;position:fixed;text-align:center;width:100%;z-index:2;box-sizing:border-box;-o-box-shadow:0 0 5px 0 rgba(0,0,0,.5);-ms-box-shadow:0 0 5px 0 rgba(0,0,0,.5);box-shadow:0 0 5px 0 rgba(0,0,0,.5)}@media screen and (max-width:900px){.notie-container{font-size:1.4rem}}@media screen and (max-width:750px){.notie-container{font-size:1.2rem}}@media screen and (max-width:400px){.notie-container{font-size:1rem}}.notie-background-success{background-color:#57bf57}.notie-background-warning{background-color:#d6a14d}.notie-background-error{background-color:#e1715b}.notie-background-info{background-color:#4d82d6}.notie-background-neutral{background-color:#a0a0a0}.notie-background-overlay{background-color:#fff}.notie-textbox{color:#fff;padding:20px}.notie-textbox-inner{margin:0 auto;max-width:900px}.notie-overlay{height:100%;left:0;opacity:0;position:fixed;top:0;width:100%;z-index:1}.notie-button{cursor:pointer}.notie-button,.notie-element{color:#fff;padding:10px}.notie-element-half{width:50%}.notie-element-half,.notie-element-third{display:inline-block;box-sizing:border-box}.notie-element-third{width:33.3333%}.notie-alert{cursor:pointer}.notie-input-field{background-color:#fff;border:0;font-family:inherit;font-size:inherit;outline:0;padding:10px;text-align:center;width:100%;box-sizing:border-box}.notie-select-choice-repeated{border-bottom:1px solid hsla(0,0%,100%,.2);box-sizing:border-box}.notie-date-selector-inner{margin:0 auto;max-width:900px;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;-o-user-select:none;user-select:none}.notie-date-selector-inner [contenteditable],.notie-date-selector-inner [contenteditable]:focus{outline:0 solid transparent}.notie-date-selector-up{transform:rotate(180deg)} -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 7 | 8 | ## 🚀 Changes proposed by this PR 9 | 10 | 14 | 15 | 16 | ### 🔗 Related bug reports/feature requests 17 | 18 | 22 | - fixes #(issue) 23 | - closes #(issue) 24 | - relates to #(issue) 25 | - implements #(feature) 26 | 27 | ### 🧰 Type of change 28 | 29 | 30 | - [ ] Bug fix (non-breaking change which fixes an issue). 31 | - [ ] New feature (non-breaking change which adds functionality). 32 | - [ ] Breaking change (fix or feature that causes existing functionality to not work as expected). 33 | - [ ] This change requires (or is) a documentation update. 34 | 35 | ### 📝 Notes to reviewer 36 | 37 | 41 | 42 | ### 🤝 Requirements 43 | 44 | - [ ] ✍ I have read and agree to this projects [Code of Conduct](../../blob/master/.github/CODE_OF_CONDUCT.md). 45 | - [ ] ✍ I have read and agree to this projects [Contribution Guidelines](../../blob/master/.github/CONTRIBUTING.md). 46 | - [ ] ✍ I have read and agree to the [Developer Certificate of Origin](https://developercertificate.org/). 47 | - [ ] 🔎 I have performed a self-review of my own changes. 48 | - [ ] 🎨 My changes follow the style guidelines of this project. 49 | 50 | - [ ] 💬 My changes as properly commented, primarily for hard-to-understand areas. 51 | - [ ] 📝 I have made corresponding changes to the documentation. 52 | - [ ] 🧪 I have included tests (if necessary) for this change. 53 | -------------------------------------------------------------------------------- /client/api.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) Liam Stanley . All rights reserved. Use 2 | // of this source code is governed by the MIT license that can be found in 3 | // the LICENSE file. 4 | 5 | // Package links provides a higher level interface to shortening links using 6 | // the links.wtf shortener service (with support for using your custom links.wtf 7 | // service as well.) 8 | package links 9 | 10 | import ( 11 | "encoding/json" 12 | "errors" 13 | "fmt" 14 | "io" 15 | "net/http" 16 | "net/url" 17 | "time" 18 | ) 19 | 20 | // URI is the endpoint which we use to shorten the link. Must be stripped of 21 | // trailing slashes. Defaults to "https://links.wtf". 22 | var URI = "https://links.wtf" 23 | 24 | type apiResponse struct { 25 | URL string `json:"url"` 26 | Success bool `json:"success"` 27 | Message string `json:"message"` 28 | } 29 | 30 | // Shorten shortens a supplied URL with the given links.wtf service, optionally 31 | // with a given encryption string. If you run a custom links.wtf service, 32 | // make sure to update the links.URL variable with the URI of where that 33 | // service is located. 34 | // 35 | // If passwd is blank, no encryption is used. Supply your own httpClient 36 | // to utilize a proxy, or change the timeout (defaults to 4 seconds if no 37 | // config is supplied.) 38 | func Shorten(link, passwd string, httpClient *http.Client) (uri *url.URL, err error) { 39 | if httpClient == nil { 40 | httpClient = &http.Client{Timeout: 4 * time.Second} 41 | } 42 | 43 | params := url.Values{} 44 | 45 | params.Set("url", link) 46 | if passwd != "" { 47 | params.Set("encrypt", passwd) 48 | } 49 | 50 | resp, err := httpClient.PostForm(URI+"/add", params) 51 | if err != nil { 52 | return nil, err 53 | } 54 | if resp.Body != nil { 55 | defer resp.Body.Close() 56 | } 57 | 58 | raw, err := io.ReadAll(resp.Body) 59 | if err != nil { 60 | return nil, err 61 | } 62 | 63 | result := &apiResponse{} 64 | err = json.Unmarshal(raw, &result) 65 | if err != nil { 66 | return nil, err 67 | } 68 | 69 | if !result.Success { 70 | if result.Message == "" { 71 | return nil, errors.New("api returned unknown unsuccessful response") 72 | } 73 | 74 | return nil, fmt.Errorf("api returned error: %s", result.Message) 75 | } 76 | 77 | if uri, err = url.Parse(result.URL); err != nil { 78 | return nil, fmt.Errorf("api returned invalid uri: %s", result.URL) 79 | } 80 | 81 | return uri, nil 82 | } 83 | -------------------------------------------------------------------------------- /safebrowsing.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) Liam Stanley . All rights reserved. Use 2 | // of this source code is governed by the MIT license that can be found in 3 | // the LICENSE file. 4 | 5 | package main 6 | 7 | import ( 8 | "context" 9 | "os" 10 | "time" 11 | 12 | "github.com/flosch/pongo2" 13 | "github.com/google/safebrowsing" 14 | ) 15 | 16 | var safeBrowser *safebrowsing.SafeBrowser 17 | 18 | func init() { 19 | pongo2.RegisterFilter("threatdefinition", safeTypeToStringFilter) 20 | } 21 | 22 | func initSafeBrowsing() { 23 | if conf.SafeBrowsing.APIKey == "" { 24 | return 25 | } 26 | 27 | debug.Println("safebrowsing support enabled, initializing") 28 | 29 | // Validate the part of the config that we can. 30 | if conf.SafeBrowsing.UpdatePeriod < 30*time.Minute { 31 | // Minimum 30m. 32 | conf.SafeBrowsing.UpdatePeriod = 30 * time.Minute 33 | } 34 | if conf.SafeBrowsing.UpdatePeriod > 168*time.Hour { 35 | // Maximum 7 days. 36 | conf.SafeBrowsing.UpdatePeriod = 168 * time.Minute 37 | } 38 | 39 | var err error 40 | safeBrowser, err = safebrowsing.NewSafeBrowser(safebrowsing.Config{ 41 | APIKey: conf.SafeBrowsing.APIKey, 42 | DBPath: conf.SafeBrowsing.DBPath, 43 | UpdatePeriod: conf.SafeBrowsing.UpdatePeriod, 44 | RequestTimeout: 15 * time.Second, 45 | Logger: os.Stdout, 46 | }) 47 | if err != nil { 48 | debug.Fatalf("error initializing google safebrowsing: %v", err) 49 | } 50 | 51 | ctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute) 52 | defer cancel() 53 | 54 | if err = safeBrowser.WaitUntilReady(ctx); err != nil { 55 | debug.Fatalf("error initializing google safebrowsing: %v", err) 56 | } 57 | } 58 | 59 | func safeTypeToString(t safebrowsing.ThreatType) string { 60 | switch t { 61 | case safebrowsing.ThreatType_Malware: 62 | return "Site is known for hosting malware" 63 | case safebrowsing.ThreatType_PotentiallyHarmfulApplication: 64 | return "Site provides potentially harmful applications" 65 | case safebrowsing.ThreatType_SocialEngineering: 66 | return "Site is known for social engineering" 67 | case safebrowsing.ThreatType_UnwantedSoftware: 68 | return "Site provides unwanted software" 69 | } 70 | 71 | return "Unknown threat" 72 | } 73 | 74 | func safeTypeToStringFilter(in, param *pongo2.Value) (*pongo2.Value, *pongo2.Error) { 75 | input := in.Integer() 76 | t := safebrowsing.ThreatType(input) 77 | return pongo2.AsValue(safeTypeToString(t)), nil 78 | } 79 | -------------------------------------------------------------------------------- /.github/SUPPORT.md: -------------------------------------------------------------------------------- 1 | # :raising_hand_man: Support 2 | 3 | This document explains where and how to get help with most of my projects. 4 | Please ensure you read through it thoroughly. 5 | 6 | > :point_right: **Note**: before participating in the community, please read our 7 | > [Code of Conduct][coc]. 8 | > By interacting with this repository, organization, or community you agree to 9 | > abide by its terms. 10 | 11 | ## :grey_question: Asking quality questions 12 | 13 | Questions can go to [Github Discussions][discussions] or feel free to join 14 | the Discord [here][chat]. 15 | 16 | Help me help you! Spend time framing questions and add links and resources. 17 | Spending the extra time up front can help save everyone time in the long run. 18 | Here are some tips: 19 | 20 | * Don't fall for the [XY problem][xy]. 21 | * Search to find out if a similar question has been asked or if a similar 22 | issue/bug has been reported. 23 | * Try to define what you need help with: 24 | * Is there something in particular you want to do? 25 | * What problem are you encountering and what steps have you taken to try 26 | and fix it? 27 | * Is there a concept you don't understand? 28 | * Provide sample code, such as a [CodeSandbox][cs] or a simple snippet, if 29 | possible. 30 | * Screenshots can help, but if there's important text such as code or error 31 | messages in them, please also provide those. 32 | * The more time you put into asking your question, the better I and others 33 | can help you. 34 | 35 | ## :old_key: Security 36 | 37 | For any security or vulnerability related disclosure, please follow the 38 | guidelines outlined in our [security policy][security]. 39 | 40 | ## :handshake: Contributions 41 | 42 | See [`CONTRIBUTING.md`][contributing] on how to contribute. 43 | 44 | 45 | [coc]: https://github.com/lrstanley/links/blob/master/.github/CODE_OF_CONDUCT.md 46 | [contributing]: https://github.com/lrstanley/links/blob/master/.github/CONTRIBUTING.md 47 | [discussions]: https://github.com/lrstanley/links/discussions/categories/q-a 48 | [issues]: https://github.com/lrstanley/links/issues/new/choose 49 | [license]: https://github.com/lrstanley/links/blob/master/LICENSE 50 | [pull-requests]: https://github.com/lrstanley/links/issues/new/choose 51 | [security]: https://github.com/lrstanley/links/security/policy 52 | [support]: https://github.com/lrstanley/links/blob/master/.github/SUPPORT.md 53 | 54 | [xy]: https://meta.stackexchange.com/questions/66377/what-is-the-xy-problem/66378#66378 55 | [chat]: https://liam.sh/chat 56 | [cs]: https://codesandbox.io 57 | -------------------------------------------------------------------------------- /static/tmpl/index.html: -------------------------------------------------------------------------------- 1 | {% extends "../partials/base.html" %} 2 | {% block title %}Shorten a link{% endblock title %} 3 | {% block content %} 4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 | 14 |
15 |
16 |
17 |
18 | 19 |
20 | 21 |
22 |
23 |
24 |
25 | 26 |
27 |
28 |
29 |
30 | 31 | {% include "../partials/card_footer.html" %} 32 |
33 | 34 | {% if link %} 35 | 39 | {% endif %} 40 | 41 | {% if message %}{% endif %} 42 |
43 |
44 | {% endblock content %} 45 | -------------------------------------------------------------------------------- /static/tmpl/safebrowsing.html: -------------------------------------------------------------------------------- 1 | {% extends "../partials/base.html" %} 2 | {% block title %}Dangerous Link{% endblock title %} 3 | {% block content %} 4 |
5 |
6 |
7 |
Google Safe Browsing Warning for {{ link | truncatechars:35 }}
8 |
9 |

10 | Links scans and reviews all shortened links, 11 | checking them against Google Safe Browsing, to 12 | protect users from malware, phishing, and other forms of dangerous websites. Upon review, the link in mention 13 | has been flagged as potentially dangerous. Please review the below information and continue 14 | at your own risk. 15 |

16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | {% for threat in threats %} 27 | 28 | 29 | 30 | 31 | 32 | {% endfor %} 33 | 34 |
Pattern matchThreat typeDescription
{{ threat.Pattern }}{{ threat.ThreatDescriptor.ThreatType }}{{ threat.ThreatDescriptor.ThreatType | threatdefinition }}
35 | 36 |
37 | 41 | 44 |
45 |
46 | {% include "../partials/card_footer.html" %} 47 |
48 |
49 |
50 | {% endblock content %} 51 | -------------------------------------------------------------------------------- /.github/SECURITY.md: -------------------------------------------------------------------------------- 1 | 2 | # :old_key: Security Policy 3 | 4 | ## :heavy_check_mark: Supported Versions 5 | 6 | The following restrictions apply for versions that are still supported in terms of security and bug fixes: 7 | 8 | * :grey_question: Must be using the latest major/minor version. 9 | * :grey_question: Must be using a supported platform for the repository (e.g. OS, browser, etc), and that platform must 10 | be within its supported versions (for example: don't use a legacy or unsupported version of Ubuntu or 11 | Google Chrome). 12 | * :grey_question: Repository must not be archived (unless the vulnerability is critical, and the repository moderately 13 | popular). 14 | * :heavy_check_mark: 15 | 16 | If one of the above doesn't apply to you, feel free to submit an issue and we can discuss the 17 | issue/vulnerability further. 18 | 19 | 20 | ## :lady_beetle: Reporting a Vulnerability 21 | 22 | Best method of contact: [GPG :key:](https://github.com/lrstanley.gpg) 23 | 24 | * :speech_balloon: [Discord][chat]: message `lrstanley` (`/home/liam#0000`). 25 | * :email: Email: `security@liam.sh` 26 | 27 | Backup contacts (if I am unresponsive after **48h**): [GPG :key:](https://github.com/FM1337.gpg) 28 | * :speech_balloon: [Discord][chat]: message `Allen#7440`. 29 | * :email: Email: `security@allenlydiard.ca` 30 | 31 | If you feel that this disclosure doesn't include a critical vulnerability and there is no sensitive 32 | information in the disclosure, you don't have to use the GPG key. For all other situations, please 33 | use it. 34 | 35 | ### :stopwatch: Vulnerability disclosure expectations 36 | 37 | * :no_bell: We expect you to not share this information with others, unless: 38 | * The maximum timeline for initial response has been exceeded (shown below). 39 | * The maximum resolution time has been exceeded (shown below). 40 | * :mag_right: We expect you to responsibly investigate this vulnerability -- please do not utilize the 41 | vulnerability beyond the initial findings. 42 | * :stopwatch: Initial response within 48h, however, if the primary contact shown above is unavailable, please 43 | use the backup contacts provided. The maximum timeline for an initial response should be within 44 | 7 days. 45 | * :stopwatch: Depending on the severity of the disclosure, resolution time may be anywhere from 24h to 2 46 | weeks after initial response, though in most cases it will likely be closer to the former. 47 | * If the vulnerability is very low/low in terms of risk, the above timelines **will not apply**. 48 | * :toolbox: Before the release of resolved versions, a [GitHub Security Advisory][advisory-docs]. 49 | will be released on the respective repository. [Browser all advisories here][advisory]. 50 | 51 | 52 | [chat]: https://liam.sh/chat 53 | [advisory]: https://github.com/advisories?query=type%3Areviewed+ecosystem%3Ago 54 | [advisory-docs]: https://docs.github.com/en/code-security/repository-security-advisories/creating-a-repository-security-advisory 55 | -------------------------------------------------------------------------------- /manage.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) Liam Stanley . All rights reserved. Use 2 | // of this source code is governed by the MIT license that can be found in 3 | // the LICENSE file. 4 | 5 | package main 6 | 7 | import ( 8 | "errors" 9 | "fmt" 10 | "os" 11 | "regexp" 12 | 13 | sempool "github.com/lrstanley/go-sempool" 14 | "github.com/timshannon/bolthold" 15 | ) 16 | 17 | var reCustomID = regexp.MustCompile(`^[a-zA-Z0-9_-]{3,25}$`) 18 | 19 | type CommandAdd struct { 20 | ID string `short:"i" long:"id" description:"custom id to use for shortened link (a-z, A-Z, 0-9, '-' and '_' allowed, 3-25 chars)"` 21 | } 22 | 23 | func (*CommandAdd) Usage() string { return " [link]..." } 24 | 25 | func (cli *CommandAdd) Execute(args []string) error { 26 | if len(args) < 1 { 27 | return errors.New("invalid usage: see 'add --help'") 28 | } 29 | 30 | if cli.ID != "" { 31 | if len(args) > 1 { 32 | return errors.New("invalid usage: can only specify one link to shortened with '--id'") 33 | } 34 | 35 | if !reCustomID.MatchString(cli.ID) { 36 | return fmt.Errorf("invalid custom id specified %q: see 'add --help'", cli.ID) 37 | } 38 | } 39 | 40 | db := newDB(false) 41 | defer db.Close() 42 | 43 | pool := sempool.New(3) 44 | 45 | for i := 0; i < len(args); i++ { 46 | pool.Slot() 47 | go func(url string) { 48 | defer pool.Free() 49 | link := &Link{ 50 | UID: cli.ID, 51 | URL: url, 52 | Author: "localhost", 53 | } 54 | 55 | if err := link.Create(db); err != nil { 56 | fmt.Fprintf(os.Stderr, "error adding %q: %v\n", url, err) 57 | if len(args) == 1 { 58 | os.Exit(1) 59 | } 60 | } 61 | 62 | fmt.Println(link.Short()) 63 | }(args[i]) 64 | } 65 | 66 | pool.Wait() 67 | return nil 68 | } 69 | 70 | type CommandDelete struct{} 71 | 72 | func (*CommandDelete) Usage() string { return " [ids or links]..." } 73 | 74 | func (cli *CommandDelete) Execute(args []string) error { 75 | if len(args) < 1 { 76 | return errors.New("invalid usage: see 'delete --help'") 77 | } 78 | 79 | db := newDB(false) 80 | defer db.Close() 81 | 82 | pool := sempool.New(3) 83 | 84 | for i := 0; i < len(args); i++ { 85 | pool.Slot() 86 | go func(param string) { 87 | defer pool.Free() 88 | 89 | var result []Link 90 | 91 | err := db.Find( 92 | &result, 93 | bolthold.Where("URL").Eq(param).Or( 94 | bolthold.Where("UID").Eq(param).Or( 95 | bolthold.Where("Author").Eq(param), 96 | ), 97 | ), 98 | ) 99 | if err != nil { 100 | fmt.Fprintf(os.Stderr, "error searching for %q: %v\n", param, err) 101 | if len(args) == 1 { 102 | os.Exit(1) 103 | } 104 | return 105 | } 106 | 107 | for j := 0; j < len(result); j++ { 108 | err = db.Delete(result[j].UID, &Link{}) 109 | if err != nil { 110 | fmt.Fprintf(os.Stderr, "error searching for %q: %v\n", param, err) 111 | if len(args) == 1 { 112 | os.Exit(1) 113 | } 114 | continue 115 | } 116 | } 117 | }(args[i]) 118 | } 119 | 120 | pool.Wait() 121 | return nil 122 | } 123 | -------------------------------------------------------------------------------- /metrics.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) Liam Stanley . All rights reserved. Use 2 | // of this source code is governed by the MIT license that can be found in 3 | // the LICENSE file. 4 | 5 | package main 6 | 7 | import ( 8 | "context" 9 | "fmt" 10 | "net/http" 11 | "sync" 12 | "time" 13 | 14 | "github.com/go-chi/chi/v5" 15 | "github.com/go-chi/chi/v5/middleware" 16 | "github.com/prometheus/client_golang/prometheus" 17 | "github.com/prometheus/client_golang/prometheus/promhttp" 18 | ) 19 | 20 | const promNamespace = "links" 21 | 22 | var ( 23 | metricShortened = prometheus.NewGauge(prometheus.GaugeOpts{ 24 | Namespace: promNamespace, 25 | Name: "shortened_total", 26 | Help: "Total amount of shortened links", 27 | }) 28 | metricRedirected = prometheus.NewGauge(prometheus.GaugeOpts{ 29 | Namespace: promNamespace, 30 | Name: "redirected_total", 31 | Help: "Total amount of redirections", 32 | }) 33 | ) 34 | 35 | func init() { 36 | // Register metrics here. 37 | prometheus.MustRegister( 38 | metricShortened, 39 | metricRedirected, 40 | ) 41 | } 42 | 43 | func initMetrics(ctx context.Context, wg *sync.WaitGroup, errors chan<- error, r *chi.Mux) { 44 | if !conf.Prometheus.Enabled { 45 | return 46 | } 47 | 48 | // Bind to existing http address router. 49 | if conf.Prometheus.Addr == "" { 50 | debug.Printf("binding metrics endpoint to '%s'", conf.Prometheus.Endpoint) 51 | debug.Println("WARNING: binding prometheus to existing address/port means it's much easier to accidentally expose to the public!") 52 | r.Handle(conf.Prometheus.Endpoint, promhttp.Handler()) 53 | return 54 | } 55 | 56 | // Custom http server specifically for metrics (makes it easier to firewall off). 57 | mux := chi.NewRouter() 58 | if conf.Proxy { 59 | mux.Use(middleware.RealIP) 60 | } 61 | 62 | mux.Use(middleware.Compress(5)) 63 | mux.Use(middleware.DefaultLogger) 64 | mux.Use(middleware.Timeout(10 * time.Second)) 65 | mux.Use(middleware.Throttle(5)) 66 | mux.Use(middleware.Recoverer) 67 | 68 | mux.Get("/", func(w http.ResponseWriter, r *http.Request) { 69 | w.WriteHeader(http.StatusOK) 70 | fmt.Fprintf(w, `see metrics`, conf.Prometheus.Endpoint) 71 | }) 72 | 73 | mux.Handle(conf.Prometheus.Endpoint, promhttp.Handler()) 74 | 75 | srv := &http.Server{ 76 | Addr: conf.Prometheus.Addr, 77 | Handler: mux, 78 | ReadTimeout: 10 * time.Second, 79 | WriteTimeout: 10 * time.Second, 80 | } 81 | 82 | go func() { 83 | wg.Add(1) 84 | defer wg.Done() 85 | 86 | debug.Printf("initializing metrics server on %s", conf.Prometheus.Addr) 87 | 88 | if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { 89 | errors <- fmt.Errorf("metrics error: %v", err) 90 | } 91 | }() 92 | 93 | go func() { 94 | wg.Add(1) 95 | defer wg.Done() 96 | 97 | <-ctx.Done() 98 | 99 | debug.Printf("requesting metrics server to shutdown") 100 | if err := srv.Shutdown(context.Background()); err != nil && err != http.ErrServerClosed { 101 | errors <- fmt.Errorf("unable to shutdown metrics server: %v", err) 102 | } 103 | }() 104 | } 105 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | # THIS FILE IS GENERATED! DO NOT EDIT! Maintained by Terraform. 2 | name: "💡 Submit a feature request" 3 | description: Suggest an awesome feature for this project! 4 | title: "feature: [REPLACE ME]" 5 | labels: 6 | - enhancement 7 | body: 8 | - type: markdown 9 | attributes: 10 | value: | 11 | ### Thanks for submitting a feature request! 📋 12 | 13 | - 💬 Make sure to check out the [**discussions**](../discussions) section of this repository. Do you have an idea for an improvement, but want to brainstorm it with others first? [Start a discussion here](../discussions/new?category=ideas) first. 14 | - 🔎 Please [**search**](../labels/enhancement) to see if someone else has submitted a similar feature request, before making a new request. 15 | 16 | --------------------------------------------- 17 | - type: textarea 18 | id: describe 19 | attributes: 20 | label: "✨ Describe the feature you'd like" 21 | description: >- 22 | A clear and concise description of what you want to happen, or what 23 | feature you'd like added. 24 | placeholder: 'Example: "It would be cool if X had support for Y"' 25 | validations: 26 | required: true 27 | - type: textarea 28 | id: related 29 | attributes: 30 | label: "🌧 Is your feature request related to a problem?" 31 | description: >- 32 | A clear and concise description of what the problem is. 33 | placeholder: >- 34 | Example: "I'd like to see X feature added, as I frequently have to do Y, 35 | and I think Z would solve that problem" 36 | - type: textarea 37 | id: alternatives 38 | attributes: 39 | label: "🔎 Describe alternatives you've considered" 40 | description: >- 41 | A clear and concise description of any alternative solutions or features 42 | you've considered. 43 | placeholder: >- 44 | Example: "I've considered X and Y, however the potential problems with 45 | those solutions would be [...]" 46 | validations: 47 | required: true 48 | - type: dropdown 49 | id: breaking 50 | attributes: 51 | label: "⚠ If implemented, do you think this feature will be a breaking change to users?" 52 | description: >- 53 | To the best of your ability, do you think implementing this change 54 | would impact users in a way during an upgrade process? 55 | options: 56 | - "Yes" 57 | - "No" 58 | - "Not sure" 59 | validations: 60 | required: true 61 | - type: textarea 62 | id: context 63 | attributes: 64 | label: "⚙ Additional context" 65 | description: >- 66 | Add any other context or screenshots about the feature request here 67 | (attach if necessary). 68 | placeholder: "Examples: logs, screenshots, etc" 69 | - type: checkboxes 70 | id: requirements 71 | attributes: 72 | label: "🤝 Requirements" 73 | description: "Please confirm the following:" 74 | options: 75 | - label: >- 76 | I have confirmed that someone else has not 77 | [submitted a similar feature request](../labels/enhancement). 78 | required: true 79 | - label: >- 80 | If implemented, I believe this feature will help others, in 81 | addition to solving my problems. 82 | required: true 83 | - label: I have looked into alternative solutions to the best of my ability. 84 | required: true 85 | - label: >- 86 | (optional) I would be willing to contribute to testing this 87 | feature if implemented, or making a PR to implement this 88 | functionality. 89 | required: false 90 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | # THIS FILE IS GENERATED! DO NOT EDIT! Maintained by Terraform. 2 | name: "🐞 Submit a bug report" 3 | description: Create a report to help us improve! 4 | title: "bug: [REPLACE ME]" 5 | labels: 6 | - bug 7 | body: 8 | - type: markdown 9 | attributes: 10 | value: | 11 | ### Thanks for submitting a bug report to **links**! 📋 12 | 13 | - 💬 Make sure to check out the [**discussions**](../discussions) section. If your issue isn't a bug (or you're not sure), and you're looking for help to solve it, please [start a discussion here](../discussions/new?category=q-a) first. 14 | - 🔎 Please [**search**](../labels/bug) to see if someone else has submitted a similar bug report, before making a new report. 15 | 16 | ---------------------------------------- 17 | - type: textarea 18 | id: description 19 | attributes: 20 | label: "🌧 Describe the problem" 21 | description: A clear and concise description of what the problem is. 22 | placeholder: 'Example: "When I attempted to do X, I got X error"' 23 | validations: 24 | required: true 25 | - type: textarea 26 | id: expected 27 | attributes: 28 | label: "⛅ Expected behavior" 29 | description: A clear and concise description of what you expected to happen. 30 | placeholder: 'Example: "I expected X to let me add Y component, and be successful"' 31 | validations: 32 | required: true 33 | - type: textarea 34 | id: reproduce 35 | attributes: 36 | label: "🔄 Minimal reproduction" 37 | description: >- 38 | Steps to reproduce the behavior (including code examples and/or 39 | configuration files if necessary) 40 | placeholder: >- 41 | Example: "1. Click on '....' | 2. Run command with flags --foo --bar, 42 | etc | 3. See error" 43 | - type: input 44 | id: version 45 | attributes: 46 | label: "💠 Version: links" 47 | description: What version of links is being used? 48 | placeholder: 'Examples: "v1.2.3, master branch, commit 1a2b3c"' 49 | validations: 50 | required: true 51 | - type: input 52 | id: browser 53 | attributes: 54 | label: "🌐 Version: Browser" 55 | description: What browser (and browser version) did this problem occur on? 56 | placeholder: 'Example: "Google Chrome 99.0.4844.82"' 57 | validations: 58 | required: true 59 | - type: dropdown 60 | id: os 61 | attributes: 62 | label: "🖥 Version: Operating system" 63 | description: >- 64 | What operating system did this issue occur on (if other, specify in 65 | "Additional context" section)? 66 | options: 67 | - linux/ubuntu 68 | - linux/debian 69 | - linux/centos 70 | - linux/alpine 71 | - linux/other 72 | - windows/10 73 | - windows/11 74 | - windows/other 75 | - macos 76 | - other 77 | validations: 78 | required: true 79 | - type: textarea 80 | id: context 81 | attributes: 82 | label: "⚙ Additional context" 83 | description: >- 84 | Add any other context about the problem here. This includes things 85 | like logs, screenshots, code examples, what was the state when the 86 | bug occurred? 87 | placeholder: > 88 | Examples: "logs, code snippets, screenshots, os/browser version info, 89 | etc" 90 | - type: checkboxes 91 | id: requirements 92 | attributes: 93 | label: "🤝 Requirements" 94 | description: "Please confirm the following:" 95 | options: 96 | - label: >- 97 | I believe the problem I'm facing is a bug, and is not intended 98 | behavior. [Post here if you're not sure](../discussions/new?category=q-a). 99 | required: true 100 | - label: >- 101 | I have confirmed that someone else has not 102 | [submitted a similar bug report](../labels/bug). 103 | required: true 104 | -------------------------------------------------------------------------------- /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | 2 | # Code of Conduct 3 | 4 | ## Our Pledge :purple_heart: 5 | 6 | We as members, contributors, and leaders pledge to make participation in our 7 | community a harassment-free experience for everyone, regardless of age, body 8 | size, visible or invisible disability, ethnicity, sex characteristics, gender 9 | identity and expression, level of experience, education, socio-economic status, 10 | nationality, personal appearance, race, caste, color, religion, or sexual 11 | identity and orientation. 12 | 13 | We pledge to act and interact in ways that contribute to an open, welcoming, 14 | diverse, inclusive, and healthy community. 15 | 16 | ## Our Standards 17 | 18 | Examples of behavior that contributes to a positive environment for our 19 | community include: 20 | 21 | * Demonstrating empathy and kindness toward other people 22 | * Being respectful of differing opinions, viewpoints, and experiences 23 | * Giving and gracefully accepting constructive feedback 24 | * Accepting responsibility and apologizing to those affected by our mistakes, 25 | and learning from the experience 26 | * Focusing on what is best not just for us as individuals, but for the overall 27 | community 28 | 29 | Examples of unacceptable behavior include: 30 | 31 | * The use of sexualized language or imagery, and sexual attention or advances of 32 | any kind 33 | * Trolling, insulting or derogatory comments, and personal or political attacks 34 | * Public or private harassment 35 | * Publishing others' private information, such as a physical or email address, 36 | without their explicit permission 37 | * Other conduct which could reasonably be considered inappropriate in a 38 | professional setting 39 | 40 | ## Enforcement Responsibilities 41 | 42 | Community leaders are responsible for clarifying and enforcing our standards of 43 | acceptable behavior and will take appropriate and fair corrective action in 44 | response to any behavior that they deem inappropriate, threatening, offensive, 45 | or harmful. 46 | 47 | Community leaders have the right and responsibility to remove, edit, or reject 48 | comments, commits, code, wiki edits, issues, and other contributions that are 49 | not aligned to this Code of Conduct, and will communicate reasons for moderation 50 | decisions when appropriate. 51 | 52 | ## Scope 53 | 54 | This Code of Conduct applies within all community spaces, and also applies when 55 | an individual is officially representing the community in public spaces. 56 | Examples of representing our community include using an official e-mail address, 57 | posting via an official social media account, or acting as an appointed 58 | representative at an online or offline event. 59 | 60 | ## Enforcement 61 | 62 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 63 | reported to the community leaders responsible for enforcement at 64 | disclosure@liam.sh. All complaints will be reviewed and investigated 65 | promptly and fairly. 66 | 67 | All community leaders are obligated to respect the privacy and security of the 68 | reporter of any incident. 69 | 70 | ## Enforcement Guidelines 71 | 72 | Community leaders will follow these Community Impact Guidelines in determining 73 | the consequences for any action they deem in violation of this Code of Conduct: 74 | 75 | ### 1. Correction 76 | 77 | **Community Impact**: Use of inappropriate language or other behavior deemed 78 | unprofessional or unwelcome in the community. 79 | 80 | **Consequence**: A private, written warning from community leaders, providing 81 | clarity around the nature of the violation and an explanation of why the 82 | behavior was inappropriate. A public apology may be requested. 83 | 84 | ### 2. Warning 85 | 86 | **Community Impact**: A violation through a single incident or series of 87 | actions. 88 | 89 | **Consequence**: A warning with consequences for continued behavior. No 90 | interaction with the people involved, including unsolicited interaction with 91 | those enforcing the Code of Conduct, for a specified period of time. This 92 | includes avoiding interactions in community spaces as well as external channels 93 | like social media. Violating these terms may lead to a temporary or permanent 94 | ban. 95 | 96 | ### 3. Temporary Ban 97 | 98 | **Community Impact**: A serious violation of community standards, including 99 | sustained inappropriate behavior. 100 | 101 | **Consequence**: A temporary ban from any sort of interaction or public 102 | communication with the community for a specified period of time. No public or 103 | private interaction with the people involved, including unsolicited interaction 104 | with those enforcing the Code of Conduct, is allowed during this period. 105 | Violating these terms may lead to a permanent ban. 106 | 107 | ### 4. Permanent Ban 108 | 109 | **Community Impact**: Demonstrating a pattern of violation of community 110 | standards, including sustained inappropriate behavior, harassment of an 111 | individual, or aggression toward or disparagement of classes of individuals. 112 | 113 | **Consequence**: A permanent ban from any sort of public interaction within the 114 | community. 115 | 116 | ## Attribution 117 | 118 | This Code of Conduct is adapted from the Contributor Covenant, 119 | version 2.1, available [here](https://www.contributor-covenant.org/version/2/1/code_of_conduct.html). 120 | 121 | For answers to common questions about this code of conduct, see the [FAQ](https://www.contributor-covenant.org/faq). 122 | Translations are available at [translations](https://www.contributor-covenant.org/translations). 123 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | 2 | # :handshake: Contributing 3 | 4 | This document outlines some of the guidelines that we try and adhere to while 5 | working on this project. 6 | 7 | > :point_right: **Note**: before participating in the community, please read our 8 | > [Code of Conduct][coc]. 9 | > By interacting with this repository, organization, or community you agree to 10 | > abide by our Code of Conduct. 11 | > 12 | > Additionally, if you contribute **any source code** to this repository, you 13 | > agree to the terms of the [Developer Certificate of Origin][dco]. This helps 14 | > ensure that contributions aren't in violation of 3rd party license terms. 15 | 16 | ## :lady_beetle: Issue submission 17 | 18 | When [submitting an issue][issues] or bug report, 19 | please follow these guidelines: 20 | 21 | * Provide as much information as possible (logs, metrics, screenshots, 22 | runtime environment, etc). 23 | * Ensure that you are running on the latest stable version (tagged), or 24 | when using `master`, provide the specific commit being used. 25 | * Provide the minimum needed viable source to replicate the problem. 26 | 27 | ## :bulb: Feature requests 28 | 29 | When [submitting a feature request][issues], please 30 | follow these guidelines: 31 | 32 | * Does this feature benefit others? or just your usecase? If the latter, 33 | it will likely be declined, unless it has a more broad benefit to others. 34 | * Please include the pros and cons of the feature. 35 | * If possible, describe how the feature would work, and any diagrams/mock 36 | examples of what the feature would look like. 37 | 38 | ## :rocket: Pull requests 39 | 40 | To review what is currently being worked on, or looked into, feel free to head 41 | over to the [open pull requests][pull-requests] or [issues list][issues]. 42 | 43 | ## :raised_back_of_hand: Assistance with discussions 44 | 45 | * Take a look at the [open discussions][discussions], and if you feel like 46 | you'd like to help out other members of the community, it would be much 47 | appreciated! 48 | 49 | ## :pushpin: Guidelines 50 | 51 | ### :test_tube: Language agnostic 52 | 53 | Below are a few guidelines if you would like to contribute: 54 | 55 | * If the feature is large or the bugfix has potential breaking changes, 56 | please open an issue first to ensure the changes go down the best path. 57 | * If possible, break the changes into smaller PRs. Pull requests should be 58 | focused on a specific feature/fix. 59 | * Pull requests will only be accepted with sufficient documentation 60 | describing the new functionality/fixes. 61 | * Keep the code simple where possible. Code that is smaller/more compact 62 | does not mean better. Don't do magic behind the scenes. 63 | * Use the same formatting/styling/structure as existing code. 64 | * Follow idioms and community-best-practices of the related language, 65 | unless the previous above guidelines override what the community 66 | recommends. 67 | * Always test your changes, both the features/fixes being implemented, but 68 | also in the standard way that a user would use the project (not just 69 | your configuration that fixes your issue). 70 | * Only use 3rd party libraries when necessary. If only a small portion of 71 | the library is needed, simply rewrite it within the library to prevent 72 | useless imports. 73 | 74 | ### :hamster: Golang 75 | 76 | * See [golang/go/wiki/CodeReviewComments](https://github.com/golang/go/wiki/CodeReviewComments) 77 | * This project uses [golangci-lint](https://golangci-lint.run/) for 78 | Go-related files. This should be available for any editor that supports 79 | `gopls`, however you can also run it locally with `golangci-lint run` 80 | after installing it. 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | ### :whale: Dockerfile 96 | 97 | * Follow container best practices, like [keeping images as small as possible](https://phoenixnap.com/kb/docker-image-size). 98 | * This project uses [shellcheck](https://github.com/koalaman/shellcheck) 99 | for linting `Dockerfile`'s. You can also use native docker extensions. 100 | This is available in VSCode [here](https://marketplace.visualstudio.com/items?itemName=timonwong.shellcheck). 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | ## :clipboard: References 109 | 110 | * [Open Source: How to Contribute](https://opensource.guide/how-to-contribute/) 111 | * [About pull requests](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/about-pull-requests) 112 | * [GitHub Docs](https://docs.github.com/) 113 | 114 | ## :speech_balloon: What to do next? 115 | 116 | * :old_key: Find a vulnerability? Check out our [Security and Disclosure][security] policy. 117 | * :link: Repository [License][license]. 118 | * [Support][support] 119 | * [Code of Conduct][coc]. 120 | 121 | 122 | [coc]: https://github.com/lrstanley/links/blob/master/.github/CODE_OF_CONDUCT.md 123 | [dco]: https://developercertificate.org/ 124 | [discussions]: https://github.com/lrstanley/links/discussions 125 | [issues]: https://github.com/lrstanley/links/issues/new/choose 126 | [license]: https://github.com/lrstanley/links/blob/master/LICENSE 127 | [pull-requests]: https://github.com/lrstanley/links/pulls?q=is%3Aopen+is%3Apr 128 | [security]: https://github.com/lrstanley/links/security/policy 129 | [support]: https://github.com/lrstanley/links/blob/master/.github/SUPPORT.md 130 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) Liam Stanley . All rights reserved. Use 2 | // of this source code is governed by the MIT license that can be found in 3 | // the LICENSE file. 4 | 5 | package main 6 | 7 | import ( 8 | "context" 9 | "fmt" 10 | "log" 11 | "os" 12 | "os/signal" 13 | "runtime" 14 | "strings" 15 | "sync" 16 | "syscall" 17 | "time" 18 | 19 | flags "github.com/jessevdk/go-flags" 20 | _ "github.com/joho/godotenv/autoload" 21 | ) 22 | 23 | var ( 24 | version = "master" 25 | commit = "latest" 26 | date = "-" 27 | ) 28 | 29 | type Config struct { 30 | Site string `env:"SITE_URL" short:"s" long:"site-name" default:"https://links.wtf" description:"site url, used for url generation"` 31 | SessionDir string `env:"SESSION_DIR" long:"session-dir" description:"optional location to store temporary sessions"` 32 | Quiet bool `env:"QUIET" short:"q" long:"quiet" description:"don't log to stdout"` 33 | Debug bool `env:"DEBUG" long:"debug" description:"enable debugging (pprof endpoints)"` 34 | HTTP string `env:"HTTP" short:"b" long:"http" default:":8080" description:"ip:port pair to bind to"` 35 | Proxy bool `env:"PROXY" short:"p" long:"behind-proxy" description:"if X-Forwarded-For headers should be trusted"` 36 | TLS struct { 37 | Enable bool `env:"TLS_ENABLE" long:"enable" description:"run tls server rather than standard http"` 38 | Cert string `env:"TLS_CERT_PATH" short:"c" long:"cert" description:"path to ssl cert file"` 39 | Key string `env:"TLS_KEY_PATH" short:"k" long:"key" description:"path to ssl key file"` 40 | } `group:"TLS Options" namespace:"tls"` 41 | DBPath string `env:"DB_PATH" short:"d" long:"db" default:"store.db" description:"path to database file"` 42 | KeyLength int64 `env:"KEY_LENGTH" long:"key-length" default:"4" description:"default length of key (uuid) for generated urls"` 43 | HTTPPreInclude string `env:"HTTP_PRE_INCLUDE" long:"http-pre-include" description:"HTTP include which is included directly after css is included (near top of the page)"` 44 | HTTPPostInclude string `env:"HTTP_POST_INCLUDE" long:"http-post-include" description:"HTTP include which is included directly after js is included (near bottom of the page)"` 45 | 46 | ExportFile string `short:"e" long:"export-file" default:"links.export" description:"file to export db to"` 47 | ExportJSON bool `long:"export-json" description:"export db to json elements"` 48 | 49 | SafeBrowsing struct { 50 | APIKey string `env:"SAFEBROWSING_API_KEY" long:"api-key" description:"Google API Key used for querying SafeBrowsing, disabled if not provided (see: https://github.com/lrstanley/links#google-safebrowsing)"` 51 | DBPath string `env:"SAFEBROWSING_DB_PATH" long:"db" default:"safebrowsing.db" description:"path to SafeBrowsing database file"` 52 | UpdatePeriod time.Duration `env:"SAFEBROWSING_UPDATE_PERIOD" long:"update-period" default:"1h" description:"duration between updates to the SafeBrowsing API local database"` 53 | RedirectFallback bool `env:"SAFEBROWSING_REDIRECT_FALLBACK" long:"redirect-fallback" description:"if the SafeBrowsing request fails (local cache, and remote hit), this still lets the redirect happen"` 54 | } `group:"Safe Browsing Support" namespace:"safebrowsing"` 55 | 56 | Prometheus struct { 57 | Enabled bool `env:"PROM_ENABLED" long:"enabled" description:"enable exposing of prometheus metrics (on std port, or --prometheus.addr)"` 58 | Addr string `env:"PROM_ADDR" long:"addr" description:"expose on custom address/port, e.g. ':9001' (all ips) or 'localhost:9001' (local only)"` 59 | Endpoint string `env:"PROM_ENDPOINT" long:"endpoint" default:"/metrics" description:"endpoint to expose metrics on"` 60 | } `group:"Prometheus Metrics" namespace:"prom"` 61 | 62 | VersionFlag bool `short:"v" long:"version" description:"display the version of links.wtf and exit"` 63 | 64 | CommandAdd CommandAdd `command:"add" description:"add a link"` 65 | CommandDelete CommandDelete `command:"delete" description:"delete a link, id, or link matching an author"` 66 | } 67 | 68 | var conf Config 69 | 70 | var debug *log.Logger 71 | 72 | func initLogger() { 73 | debug = log.New(os.Stdout, "", log.Lshortfile|log.LstdFlags) 74 | debug.Print("initialized logger") 75 | } 76 | 77 | func main() { 78 | parser := flags.NewParser(&conf, flags.HelpFlag) 79 | parser.SubcommandsOptional = true 80 | _, err := parser.Parse() 81 | if err != nil { 82 | fmt.Fprintln(os.Stderr, err) 83 | os.Exit(1) 84 | } 85 | 86 | if parser.Active != nil { 87 | os.Exit(0) 88 | } 89 | 90 | if conf.VersionFlag { 91 | fmt.Printf("links version: %s [%s] (%s, %s), compiled %s\n", version, commit, runtime.GOOS, runtime.GOARCH, date) 92 | os.Exit(0) 93 | } 94 | 95 | // Do some configuration validation. 96 | if conf.HTTP == "" { 97 | debug.Fatalf("invalid http flag supplied: %s", conf.HTTP) 98 | } 99 | if conf.KeyLength < 4 { 100 | conf.KeyLength = 4 101 | } 102 | 103 | conf.Site = strings.TrimRight(conf.Site, "/") 104 | 105 | initLogger() 106 | 107 | // Verify db is accessible. 108 | verifyDB() 109 | 110 | if conf.ExportJSON { 111 | dbExportJSON(conf.ExportFile) 112 | debug.Print("export complete") 113 | return 114 | } 115 | 116 | // Google SafeBrowsing. 117 | initSafeBrowsing() 118 | 119 | // Setup methods to allow signaling to all children methods that we're stopping. 120 | ctx, closer := context.WithCancel(context.Background()) 121 | errors := make(chan error) 122 | wg := &sync.WaitGroup{} 123 | 124 | signals := make(chan os.Signal, 1) 125 | signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM, os.Interrupt) 126 | 127 | // Initialize the http/https server. 128 | go httpServer(ctx, wg, errors) 129 | 130 | fmt.Println("listening for signal. CTRL+C to quit.") 131 | 132 | go func() { 133 | for { 134 | select { 135 | case <-signals: 136 | fmt.Println("\nsignal received, shutting down") 137 | case <-errors: 138 | debug.Println(err) 139 | } 140 | 141 | // Signal to exit. 142 | closer() 143 | } 144 | }() 145 | 146 | // Wait for the context to close, and wait for all goroutines/processes to exit. 147 | <-ctx.Done() 148 | wg.Wait() 149 | 150 | if safeBrowser != nil { 151 | if err = safeBrowser.Close(); err != nil { 152 | debug.Fatalf("error closing google safebrowsing: %v", err) 153 | } 154 | } 155 | 156 | debug.Println("shutdown complete") 157 | } 158 | -------------------------------------------------------------------------------- /database.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) Liam Stanley . All rights reserved. Use 2 | // of this source code is governed by the MIT license that can be found in 3 | // the LICENSE file. 4 | 5 | package main 6 | 7 | import ( 8 | "bytes" 9 | "crypto/sha256" 10 | "encoding/gob" 11 | "encoding/hex" 12 | "encoding/json" 13 | "errors" 14 | "fmt" 15 | "math/rand" 16 | "net/url" 17 | "os" 18 | "reflect" 19 | "strings" 20 | "sync" 21 | "sync/atomic" 22 | "time" 23 | 24 | "github.com/timshannon/bolthold" 25 | bolt "go.etcd.io/bbolt" 26 | ) 27 | 28 | const collisionMax = 20 29 | 30 | func init() { 31 | rand.Seed(time.Now().UnixNano()) 32 | } 33 | 34 | const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" 35 | 36 | func uuid(n int64) string { 37 | b := make([]byte, n) 38 | for i := range b { 39 | b[i] = letterBytes[rand.Intn(len(letterBytes))] 40 | } 41 | return string(b) 42 | } 43 | 44 | type GlobalStats struct { 45 | mu sync.RWMutex 46 | Shortened int 47 | Redirects int 48 | } 49 | 50 | func incGlobalStats(db *bolthold.Store, hits, created int) { 51 | tmp := GlobalStats{} 52 | err := db.Get("global_stats", &tmp) 53 | 54 | if err != nil && err != bolthold.ErrNotFound { 55 | debug.Printf("unable to read global stats: %s", err) 56 | return 57 | } 58 | 59 | tmp.Redirects += hits 60 | tmp.Shortened += created 61 | 62 | err = db.Upsert("global_stats", &tmp) 63 | if err != nil { 64 | debug.Printf("unable to save global stats: %s", err) 65 | } 66 | 67 | updateGlobalStats(db) 68 | } 69 | 70 | var cachedGlobalStats = GlobalStats{} 71 | 72 | func updateGlobalStats(db *bolthold.Store) { 73 | if db == nil { 74 | db = newDB(true) 75 | defer db.Close() 76 | } 77 | 78 | tmp := GlobalStats{} 79 | 80 | err := db.Get("global_stats", &tmp) 81 | 82 | if err != nil && err != bolthold.ErrNotFound { 83 | debug.Printf("unable to read global stats: %s", err) 84 | return 85 | } 86 | 87 | cachedGlobalStats.mu.Lock() 88 | cachedGlobalStats.Redirects = tmp.Redirects 89 | cachedGlobalStats.Shortened = tmp.Shortened 90 | cachedGlobalStats.mu.Unlock() 91 | 92 | metricRedirected.Set(float64(tmp.Redirects)) 93 | metricShortened.Set(float64(tmp.Shortened)) 94 | } 95 | 96 | // Link represents a url that we are shortening. 97 | type Link struct { 98 | UID string `boltholdKey:"UID"` 99 | URL string // The URL we're expanding. 100 | Created time.Time // When the link was submitted. 101 | Hits int // How many times we've expanded for users. 102 | Author string // IP address of request. May be blank. 103 | EncryptionHash string // Used to password protect (sha256). 104 | } 105 | 106 | func (l *Link) Create(db *bolthold.Store) error { 107 | if len(l.URL) < 1 { 108 | return errors.New("please supply a url to shorten") 109 | } 110 | 111 | uri, err := url.Parse(l.URL) 112 | if err != nil || uri.Hostname() == "" { 113 | return errors.New("unable to parse url: " + l.URL) 114 | } 115 | 116 | if !isValidScheme(uri.Scheme) { 117 | return errors.New("invalid url scheme. allowed schemes: " + strings.Join(validSchemes, ", ")) 118 | } 119 | 120 | if strings.Contains(strings.ToLower(conf.Site), strings.ToLower(uri.Hostname())) { 121 | return errors.New("can't shorten a link for " + conf.Site) 122 | } 123 | 124 | l.URL = uri.String() 125 | l.Created = time.Now() 126 | 127 | if db == nil { 128 | db = newDB(false) 129 | defer db.Close() 130 | } 131 | 132 | // Store it. If the UID was pre-defined, don't generate one. 133 | if l.UID != "" { 134 | return db.Insert(l.UID, l) 135 | } 136 | 137 | var collisionCount int 138 | for { 139 | l.UID = uuid(atomic.LoadInt64(&conf.KeyLength)) 140 | err = db.Insert(l.UID, l) 141 | if err != nil { 142 | if err == bolthold.ErrKeyExists { 143 | // Keep looping through until we're able to store one which 144 | // doesn't collide with a pre-existing key. 145 | collisionCount++ 146 | if collisionCount >= collisionMax { 147 | // If we continue to get collisions in a row, bump the global 148 | // key value to one higher. Worst case if the collision max is 149 | // hit, it will only be computationally difficult on the first 150 | // shortened link upon restart. 151 | collisionCount = 0 152 | newKeyLength := atomic.AddInt64(&conf.KeyLength, 1) 153 | debug.Printf("collision maximum hit (count: %d); increasing global key length from %d to %d", collisionMax, newKeyLength-1, newKeyLength) 154 | } 155 | continue 156 | } 157 | 158 | panic(err) 159 | } 160 | 161 | break 162 | } 163 | 164 | incGlobalStats(db, 0, 1) 165 | 166 | return nil 167 | } 168 | 169 | func (l *Link) AddHit() { 170 | l.Hits++ 171 | 172 | db := newDB(false) 173 | if err := db.Update(l.UID, l); err != nil { 174 | debug.Printf("unable to increment hits on %s: %s", l.UID, err) 175 | } 176 | incGlobalStats(db, 1, 0) 177 | db.Close() 178 | } 179 | 180 | func (l *Link) Short() string { 181 | return conf.Site + "/" + l.UID 182 | } 183 | 184 | func (l *Link) CheckHash(input string) bool { 185 | return hash(input) == l.EncryptionHash 186 | } 187 | 188 | func hash(input string) string { 189 | if input == "" { 190 | return "" 191 | } 192 | 193 | out := sha256.Sum256([]byte(input)) 194 | 195 | return hex.EncodeToString(out[:]) 196 | } 197 | 198 | func newDB(readOnly bool) *bolthold.Store { 199 | store, err := bolthold.Open(conf.DBPath, 0o660, &bolthold.Options{Options: &bolt.Options{ 200 | FreelistType: bolt.FreelistMapType, 201 | ReadOnly: readOnly, 202 | Timeout: 25 * time.Second, 203 | }}) 204 | if err != nil { 205 | panic(fmt.Sprintf("unable to open db: %s", err)) 206 | } 207 | 208 | return store 209 | } 210 | 211 | func verifyDB() { 212 | debug.Printf("verifying access to db: %s", conf.DBPath) 213 | db := newDB(false) 214 | db.Close() 215 | debug.Print("successfully verified access to db") 216 | } 217 | 218 | func getType(myvar interface{}) string { 219 | if t := reflect.TypeOf(myvar); t.Kind() == reflect.Ptr { 220 | return "*" + t.Elem().Name() 221 | } else { 222 | return t.Name() 223 | } 224 | } 225 | 226 | func dbExportJSON(path string) { 227 | f, err := os.Create(path) 228 | if err != nil { 229 | debug.Fatalf("error occurred while trying to open %q: %s", path, err) 230 | } 231 | defer f.Close() 232 | 233 | gob.Register(Link{}) 234 | 235 | db := newDB(true) 236 | defer db.Close() 237 | 238 | var n int 239 | 240 | db.Bolt().View(func(tx *bolt.Tx) error { 241 | // Assume bucket exists and has keys 242 | b := tx.Bucket([]byte(getType(Link{}))) 243 | 244 | b.ForEach(func(k, v []byte) error { 245 | l := Link{} 246 | 247 | dec := gob.NewDecoder(bytes.NewBuffer(v)) 248 | err = dec.Decode(&l) 249 | if err != nil { 250 | debug.Printf("failure: decode %s: %s", k, err) 251 | return err 252 | } 253 | 254 | out, _ := json.Marshal(&l) 255 | out = append(out, 0x0a) 256 | n, err = f.Write(out) 257 | if err != nil { 258 | debug.Printf("failure: unable to write %s to %s: %s", k, path, err) 259 | return err 260 | } 261 | 262 | debug.Printf("success: exported %s (%d bytes)", l.Short(), n) 263 | return nil 264 | }) 265 | return nil 266 | }) 267 | } 268 | -------------------------------------------------------------------------------- /http.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) Liam Stanley . All rights reserved. Use 2 | // of this source code is governed by the MIT license that can be found in 3 | // the LICENSE file. 4 | 5 | package main 6 | 7 | import ( 8 | "context" 9 | "embed" 10 | "encoding/gob" 11 | "fmt" 12 | "io/fs" 13 | "net" 14 | "net/http" 15 | "os" 16 | "strings" 17 | "sync" 18 | "time" 19 | 20 | "github.com/go-chi/chi/v5" 21 | "github.com/go-chi/chi/v5/middleware" 22 | "github.com/lrstanley/pt" 23 | "github.com/timshannon/bolthold" 24 | ) 25 | 26 | var ( 27 | tmpl *pt.Loader 28 | 29 | //go:embed all:static 30 | staticEmbed embed.FS 31 | ) 32 | 33 | func httpServer(ctx context.Context, wg *sync.WaitGroup, errors chan<- error) { 34 | var static fs.FS 35 | var err error 36 | 37 | if conf.Debug { 38 | static = os.DirFS("static") 39 | } else { 40 | static, err = fs.Sub(staticEmbed, "static") 41 | if err != nil { 42 | panic(err) 43 | } 44 | } 45 | 46 | tmpl = pt.New("", pt.Config{ 47 | CacheParsed: !conf.Debug, 48 | FS: static, 49 | ErrorLogger: os.Stderr, 50 | DefaultCtx: tmplDefaultCtx, 51 | NotFoundHandler: http.NotFound, 52 | }) 53 | 54 | gob.Register(flashMessage{}) 55 | updateGlobalStats(nil) 56 | 57 | r := chi.NewRouter() 58 | 59 | if conf.Proxy { 60 | r.Use(middleware.RealIP) 61 | } 62 | 63 | r.Use(middleware.SetHeader("Content-Security-Policy", "script-src 'self'")) 64 | r.Use(middleware.SetHeader("X-Frame-Options", "DENY")) 65 | r.Use(middleware.SetHeader("X-Content-Type-Options", "nosniff")) 66 | r.Use(middleware.SetHeader("Referrer-Policy", "same-origin")) 67 | 68 | r.Use(middleware.Compress(5)) 69 | r.Use(middleware.DefaultLogger) 70 | r.Use(middleware.Timeout(30 * time.Second)) 71 | r.Use(middleware.Recoverer) 72 | r.Use(middleware.GetHead) 73 | 74 | // Mount the static directory (in-memory and disk) to the /static route. 75 | pt.FileServer(r, "/static", http.FS(static)) 76 | 77 | if conf.Debug { 78 | r.Mount("/debug", middleware.Profiler()) 79 | } 80 | 81 | r.Get("/", func(w http.ResponseWriter, r *http.Request) { 82 | tmpl.Render(w, r, "tmpl/index.html", nil) 83 | }) 84 | r.Get("/-/abuse", func(w http.ResponseWriter, r *http.Request) { 85 | tmpl.Render(w, r, "tmpl/abuse.html", pt.M{"safebrowsing": safeBrowser != nil}) 86 | }) 87 | 88 | r.Get("/{uid}", expand) 89 | r.Post("/{uid}", expand) 90 | r.Post("/", addForm) 91 | r.Post("/add", addAPI) 92 | 93 | initMetrics(ctx, wg, errors, r) 94 | 95 | srv := &http.Server{ 96 | Addr: conf.HTTP, 97 | Handler: r, 98 | ReadTimeout: 10 * time.Second, 99 | WriteTimeout: 10 * time.Second, 100 | } 101 | 102 | go func() { 103 | wg.Add(1) 104 | defer wg.Done() 105 | 106 | debug.Printf("initializing http server on %s", conf.HTTP) 107 | 108 | var err error 109 | if conf.TLS.Enable { 110 | err = srv.ListenAndServeTLS(conf.TLS.Cert, conf.TLS.Key) 111 | } else { 112 | err = srv.ListenAndServe() 113 | } 114 | 115 | if err != nil && err != http.ErrServerClosed { 116 | errors <- fmt.Errorf("http error: %v", err) 117 | } 118 | }() 119 | 120 | // Wait for parent to ask to shutdown. 121 | <-ctx.Done() 122 | 123 | debug.Printf("requesting http server to shutdown") 124 | if err := srv.Shutdown(context.Background()); err != nil && err != http.ErrServerClosed { 125 | errors <- fmt.Errorf("unable to shutdown http server: %v", err) 126 | } 127 | } 128 | 129 | func tmplDefaultCtx(w http.ResponseWriter, r *http.Request) (ctx map[string]interface{}) { 130 | if ctx == nil { 131 | ctx = make(map[string]interface{}) 132 | } 133 | 134 | cachedGlobalStats.mu.RLock() 135 | shortened := cachedGlobalStats.Shortened 136 | redirected := cachedGlobalStats.Redirects 137 | cachedGlobalStats.mu.RUnlock() 138 | 139 | ctx = pt.M{ 140 | "full_url": r.URL.String(), 141 | "url": r.URL, 142 | "commit": commit, 143 | "version": version, 144 | "stats_shortened": shortened, 145 | "stats_redirects": redirected, 146 | "http_pre_include": conf.HTTPPreInclude, 147 | "http_post_include": conf.HTTPPostInclude, 148 | } 149 | 150 | return ctx 151 | } 152 | 153 | type flashMessage struct { 154 | Type string 155 | Text string 156 | } 157 | 158 | type httpResp struct { 159 | Success bool `json:"success"` 160 | Message string `json:"message,omitempty"` 161 | URL string `json:"url,omitempty"` 162 | } 163 | 164 | func expand(w http.ResponseWriter, r *http.Request) { 165 | db := newDB(true) 166 | 167 | var link Link 168 | err := db.Get(chi.URLParam(r, "uid"), &link) 169 | db.Close() 170 | 171 | if err != nil { 172 | if err == bolthold.ErrNotFound { 173 | http.Redirect(w, r, "/", http.StatusFound) 174 | return 175 | } 176 | 177 | panic(err) 178 | } 179 | 180 | if link.EncryptionHash != "" { 181 | if r.Method == http.MethodGet { 182 | w.WriteHeader(http.StatusForbidden) 183 | tmpl.Render(w, r, "tmpl/auth.html", nil) 184 | return 185 | } 186 | 187 | decrypt := r.PostFormValue("decrypt") 188 | 189 | if hash(decrypt) != link.EncryptionHash { 190 | w.WriteHeader(http.StatusForbidden) 191 | tmpl.Render(w, r, "tmpl/auth.html", pt.M{"message": flashMessage{"danger", "invalid decryption string provided"}}) 192 | return 193 | } 194 | 195 | // Assume at this point they have provided authentication for the 196 | // link, and we can redirect them. 197 | } 198 | 199 | link.AddHit() 200 | 201 | if safeBrowser != nil { 202 | threats, err := safeBrowser.LookupURLs([]string{link.URL}) 203 | if err != nil { 204 | debug.Printf("safebrowsing error: %v", err) 205 | 206 | if conf.SafeBrowsing.RedirectFallback { 207 | http.Redirect(w, r, link.URL, http.StatusFound) 208 | return 209 | } 210 | 211 | http.Error(w, http.StatusText(http.StatusServiceUnavailable), http.StatusServiceUnavailable) 212 | return 213 | } 214 | 215 | if len(threats) > 0 && threats[0] != nil { 216 | tmpl.Render(w, r, "tmpl/safebrowsing.html", pt.M{"threats": threats[0], "link": link.URL}) 217 | return 218 | } 219 | } 220 | 221 | http.Redirect(w, r, link.URL, http.StatusFound) 222 | } 223 | 224 | func addForm(w http.ResponseWriter, r *http.Request) { 225 | link := &Link{ 226 | URL: r.PostFormValue("url"), 227 | EncryptionHash: hash(r.PostFormValue("encrypt")), 228 | } 229 | 230 | link.Author, _, _ = net.SplitHostPort(r.RemoteAddr) 231 | if link.Author == "" { 232 | link.Author = r.RemoteAddr 233 | } 234 | 235 | if err := link.Create(nil); err != nil { 236 | w.WriteHeader(http.StatusNotAcceptable) 237 | tmpl.Render(w, r, "tmpl/index.html", pt.M{"message": flashMessage{"danger", err.Error()}}) 238 | return 239 | } 240 | 241 | tmpl.Render(w, r, "tmpl/index.html", pt.M{"link": link}) 242 | } 243 | 244 | func addAPI(w http.ResponseWriter, r *http.Request) { 245 | link := Link{ 246 | URL: r.PostFormValue("url"), 247 | EncryptionHash: hash(r.PostFormValue("encrypt")), 248 | } 249 | 250 | // Check for old password supplying method. 251 | if link.EncryptionHash == "" { 252 | link.EncryptionHash = hash(r.PostFormValue("password")) 253 | } 254 | 255 | link.Author, _, _ = net.SplitHostPort(r.RemoteAddr) 256 | if link.Author == "" { 257 | link.Author = r.RemoteAddr 258 | } 259 | 260 | if err := link.Create(nil); err != nil { 261 | w.WriteHeader(http.StatusNotAcceptable) 262 | pt.JSON(w, r, httpResp{Success: false, Message: err.Error()}) 263 | return 264 | } 265 | 266 | w.WriteHeader(http.StatusOK) 267 | pt.JSON(w, r, httpResp{Success: true, URL: link.Short()}) 268 | } 269 | 270 | var validSchemes = []string{ 271 | "http", 272 | "https", 273 | "ftp", 274 | } 275 | 276 | func isValidScheme(scheme string) bool { 277 | scheme = strings.ToLower(scheme) 278 | 279 | for i := 0; i < len(validSchemes); i++ { 280 | if validSchemes[i] == scheme { 281 | return true 282 | } 283 | } 284 | 285 | return false 286 | } 287 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 2 | github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 3 | github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 4 | github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 5 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 6 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 7 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 8 | github.com/flosch/pongo2 v0.0.0-20200913210552-0d938eb266f3 h1:fmFk0Wt3bBxxwZnu48jqMdaOR/IZ4vdtJFuaFV8MpIE= 9 | github.com/flosch/pongo2 v0.0.0-20200913210552-0d938eb266f3/go.mod h1:bJWSKrZyQvfTnb2OudyUjurSG4/edverV7n82+K3JiM= 10 | github.com/flosch/pongo2/v6 v6.0.0 h1:lsGru8IAzHgIAw6H2m4PCyleO58I40ow6apih0WprMU= 11 | github.com/flosch/pongo2/v6 v6.0.0/go.mod h1:CuDpFm47R0uGGE7z13/tTlt1Y6zdxvr2RLT5LJhsHEU= 12 | github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8= 13 | github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= 14 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 15 | github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= 16 | github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= 17 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 18 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 19 | github.com/google/safebrowsing v0.0.0-20190624211811-bbf0d20d26b3 h1:4SV2fLwScO6iAgUKNqXwIrz9Fq2ykQxbSV4ObXtNCWY= 20 | github.com/google/safebrowsing v0.0.0-20190624211811-bbf0d20d26b3/go.mod h1:hT4r/grkURkgVSWJaWd6PyS4xfAb+vb34DyMDYiOGa8= 21 | github.com/jessevdk/go-flags v1.6.1 h1:Cvu5U8UGrLay1rZfv/zP7iLpSHGUZ/Ou68T0iX1bBK4= 22 | github.com/jessevdk/go-flags v1.6.1/go.mod h1:Mk8T1hIAWpOiJiHa9rJASDK2UGWji0EuPGBnNLMooyc= 23 | github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= 24 | github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 25 | github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= 26 | github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= 27 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 28 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 29 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 30 | github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= 31 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 32 | github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= 33 | github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 34 | github.com/lrstanley/go-sempool v0.0.0-20230116155332-a03d0c109854 h1:JZ3xNW+iBFtlB/SYh0YvdKkdTCiFcwkSq9opGOfKizw= 35 | github.com/lrstanley/go-sempool v0.0.0-20230116155332-a03d0c109854/go.mod h1:P02r0Kf+TlgNvBMPfFQglsEPJ13ooIIKR+747c/nlCo= 36 | github.com/lrstanley/pt v0.0.0-20250101082311-aa14889de735 h1:6mz731HBYwjVgmqbfkDe41XpMN17/t1VoX4NLo/lhH0= 37 | github.com/lrstanley/pt v0.0.0-20250101082311-aa14889de735/go.mod h1:2Kos4MYSAR176in5ymjyN3HRaBNJdL7svHjIbMWYPQo= 38 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= 39 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= 40 | github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= 41 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 42 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 43 | github.com/prometheus/client_golang v1.21.1 h1:DOvXXTqVzvkIewV/CDPFdejpMCGeMcbGCQ8YOmu+Ibk= 44 | github.com/prometheus/client_golang v1.21.1/go.mod h1:U9NM32ykUErtVBxdvD3zfi+EuFkkaBvMb09mIfe0Zgg= 45 | github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= 46 | github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= 47 | github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io= 48 | github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I= 49 | github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= 50 | github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= 51 | github.com/rakyll/statik v0.1.5/go.mod h1:OEi9wJV/fMUAGx1eNjq75DKDsJVuEv1U0oYdX6GX8Zs= 52 | github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= 53 | github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= 54 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 55 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 56 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 57 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 58 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 59 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 60 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 61 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 62 | github.com/timshannon/bolthold v0.0.0-20240314194003-30aac6950928 h1:zjNCuOOhh1TKRU0Ru3PPPJt80z7eReswCao91gBLk00= 63 | github.com/timshannon/bolthold v0.0.0-20240314194003-30aac6950928/go.mod h1:PCFYfAEfKT+Nd6zWvUpsXduMR1bXFLf0uGSlEF05MCI= 64 | go.etcd.io/bbolt v1.3.8/go.mod h1:N9Mkw9X8x5fupy0IKsmuqVtoGDyxsaDlbk4Rd05IAQw= 65 | go.etcd.io/bbolt v1.4.0 h1:TU77id3TnN/zKr7CO/uk+fBCwF2jGcMuw2B/FMAzYIk= 66 | go.etcd.io/bbolt v1.4.0/go.mod h1:AsD+OCi/qPN1giOX1aiLAha3o1U8rAz65bvN4j0sRuk= 67 | go.etcd.io/gofail v0.1.0/go.mod h1:VZBCXYGZhHAinaBiiqYvuDynvahNsAyLFwB3kEHKz1M= 68 | golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 69 | golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= 70 | golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= 71 | golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 72 | golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= 73 | golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 74 | golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 75 | golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 76 | golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= 77 | golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 78 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 79 | golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= 80 | golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= 81 | google.golang.org/protobuf v1.36.2 h1:R8FeyR1/eLmkutZOM5CWghmo5itiG9z0ktFlTVLuTmU= 82 | google.golang.org/protobuf v1.36.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= 83 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 84 | gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 85 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 86 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 87 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 88 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 89 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 90 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | # THIS FILE IS GENERATED! DO NOT EDIT! Maintained by Terraform. 2 | # 3 | # golangci-lint: https://golangci-lint.run/ 4 | # false-positives: https://golangci-lint.run/usage/false-positives/ 5 | # actual source: https://github.com/lrstanley/.github/blob/master/terraform/github-common-files/templates/.golangci.yml 6 | # modified variant of: https://gist.github.com/maratori/47a4d00457a92aa426dbd48a18776322 7 | # 8 | 9 | run: 10 | timeout: 3m 11 | 12 | issues: 13 | max-issues-per-linter: 0 14 | max-same-issues: 50 15 | 16 | exclude-rules: 17 | - source: "(noinspection|TODO)" 18 | linters: [godot] 19 | - source: "//noinspection" 20 | linters: [gocritic] 21 | - path: "_test\\.go" 22 | linters: 23 | - bodyclose 24 | - dupl 25 | - funlen 26 | - goconst 27 | - gosec 28 | - noctx 29 | - wrapcheck 30 | 31 | severity: 32 | default-severity: error 33 | rules: 34 | - linters: 35 | - errcheck 36 | - gocritic 37 | severity: warning 38 | 39 | linters: 40 | disable-all: true 41 | enable: 42 | - asasalint # checks for pass []any as any in variadic func(...any) 43 | - asciicheck # checks that your code does not contain non-ASCII identifiers 44 | - bidichk # checks for dangerous unicode character sequences 45 | - bodyclose # checks whether HTTP response body is closed successfully 46 | - canonicalheader # checks whether net/http.Header uses canonical header 47 | - copyloopvar # detects places where loop variables are copied 48 | - cyclop # checks function and package cyclomatic complexity 49 | - dupl # tool for code clone detection 50 | - durationcheck # checks for two durations multiplied together 51 | - errcheck # checking for unchecked errors, these unchecked errors can be critical bugs in some cases 52 | - errorlint # finds code that will cause problems with the error wrapping scheme introduced in Go 1.13 53 | - fatcontext # detects nested contexts in loops 54 | - forbidigo # forbids identifiers 55 | - funlen # tool for detection of long functions 56 | - gci # controls golang package import order and makes it always deterministic 57 | - gocheckcompilerdirectives # validates go compiler directive comments (//go:) 58 | - gochecknoinits # checks that no init functions are present in Go code 59 | - gochecksumtype # checks exhaustiveness on Go "sum types" 60 | - goconst # finds repeated strings that could be replaced by a constant 61 | - gocritic # provides diagnostics that check for bugs, performance and style issues 62 | - gocyclo # computes and checks the cyclomatic complexity of functions 63 | - godot # checks if comments end in a period 64 | - godox # detects FIXME, TODO and other comment keywords 65 | - goimports # in addition to fixing imports, goimports also formats your code in the same style as gofmt 66 | - gomoddirectives # manages the use of 'replace', 'retract', and 'excludes' directives in go.mod 67 | - gomodguard # allow and block lists linter for direct Go module dependencies. This is different from depguard where there are different block types for example version constraints and module recommendations 68 | - goprintffuncname # checks that printf-like functions are named with f at the end 69 | - gosec # inspects source code for security problems 70 | - gosimple # specializes in simplifying a code 71 | - govet # reports suspicious constructs, such as Printf calls whose arguments do not align with the format string 72 | - ineffassign # detects when assignments to existing variables are not used 73 | - intrange # finds places where for loops could make use of an integer range 74 | - loggercheck # checks key value pairs for common logger libraries (kitlog,klog,logr,zap) 75 | - makezero # finds slice declarations with non-zero initial length 76 | - misspell # finds commonly misspelled words 77 | - musttag # enforces field tags in (un)marshaled structs 78 | - nakedret # finds naked returns in functions greater than a specified function length 79 | - nilerr # finds the code that returns nil even if it checks that the error is not nil 80 | - nilnil # checks that there is no simultaneous return of nil error and an invalid value 81 | - noctx # finds sending http request without context.Context 82 | - nosprintfhostport # checks for misuse of Sprintf to construct a host with port in a URL 83 | - perfsprint # checks that fmt.Sprintf can be replaced with a faster alternative 84 | - predeclared # finds code that shadows one of Go's predeclared identifiers 85 | - promlinter # checks Prometheus metrics naming via promlint 86 | - reassign # checks that package variables are not reassigned 87 | - revive # fast, configurable, extensible, flexible, and beautiful linter for Go, drop-in replacement of golint 88 | - rowserrcheck # checks whether Err of rows is checked successfully 89 | - sloglint # ensure consistent code style when using log/slog 90 | - sqlclosecheck # checks that sql.Rows and sql.Stmt are closed 91 | - staticcheck # is a go vet on steroids, applying a ton of static analysis checks 92 | - stylecheck # is a replacement for golint 93 | # - tagalign # aligns struct tags -- disable for now (https://github.com/4meepo/tagalign/issues/13) 94 | - testableexamples # checks if examples are testable (have an expected output) 95 | - testifylint # checks usage of github.com/stretchr/testify 96 | - tparallel # detects inappropriate usage of t.Parallel() method in your Go test codes 97 | - typecheck # like the front-end of a Go compiler, parses and type-checks Go code 98 | - unconvert # removes unnecessary type conversions 99 | - unparam # reports unused function parameters 100 | - unused # checks for unused constants, variables, functions and types 101 | - usestdlibvars # detects the possibility to use variables/constants from the Go standard library 102 | - wastedassign # finds wasted assignment statements 103 | - whitespace # detects leading and trailing whitespace 104 | 105 | linters-settings: 106 | cyclop: 107 | # The maximal code complexity to report. 108 | max-complexity: 30 109 | # The maximal average package complexity. 110 | # If it's higher than 0.0 (float) the check is enabled 111 | package-average: 10.0 112 | 113 | errcheck: 114 | # Report about not checking of errors in type assertions: `a := b.(MyStruct)`. 115 | # Such cases aren't reported by default. 116 | check-type-assertions: true 117 | 118 | funlen: 119 | # Checks the number of lines in a function. 120 | lines: 150 121 | # Checks the number of statements in a function. 122 | statements: 75 123 | # Ignore comments. 124 | ignore-comments: true 125 | 126 | gocritic: 127 | disabled-checks: 128 | - whyNoLint 129 | - hugeParam 130 | - ifElseChain 131 | enabled-tags: 132 | - diagnostic 133 | - opinionated 134 | - performance 135 | - style 136 | # https://go-critic.github.io/overview. 137 | settings: 138 | captLocal: 139 | # Whether to restrict checker to params only. 140 | paramsOnly: false 141 | underef: 142 | # Whether to skip (*x).method() calls where x is a pointer receiver. 143 | skipRecvDeref: false 144 | 145 | gomodguard: 146 | blocked: 147 | # List of blocked modules. 148 | modules: 149 | - github.com/golang/protobuf: 150 | recommendations: 151 | - google.golang.org/protobuf 152 | reason: "see https://developers.google.com/protocol-buffers/docs/reference/go/faq#modules" 153 | - github.com/satori/go.uuid: 154 | recommendations: 155 | - github.com/google/uuid 156 | reason: "satori's package is not maintained" 157 | - github.com/gofrs/uuid: 158 | recommendations: 159 | - github.com/google/uuid 160 | reason: "gofrs' package is not go module" 161 | 162 | govet: 163 | enable-all: true 164 | # Run `go tool vet help` to see all analyzers. 165 | disable: 166 | - fieldalignment # too strict 167 | settings: 168 | shadow: 169 | # Whether to be strict about shadowing; can be noisy. 170 | strict: true 171 | 172 | nakedret: 173 | # Make an issue if func has more lines of code than this setting, and it has naked returns. 174 | max-func-lines: 0 175 | 176 | rowserrcheck: 177 | # database/sql is always checked 178 | packages: 179 | - github.com/jmoiron/sqlx 180 | 181 | stylecheck: 182 | checks: 183 | - all 184 | - -ST1008 # handled by revive already. 185 | 186 | sloglint: 187 | # Enforce not using global loggers. 188 | no-global: "all" 189 | # Enforce using methods that accept a context. 190 | context: "scope" 191 | 192 | tagalign: 193 | align: true 194 | sort: true 195 | order: 196 | # go-flags items 197 | - command 198 | - alias 199 | - group 200 | - namespace 201 | - env-namespace 202 | - subcommands-optional 203 | - env 204 | - env-delim 205 | - short 206 | - long 207 | - no-flag 208 | - hidden 209 | - required 210 | - value-name 211 | - default 212 | - choice 213 | - description 214 | - long-description 215 | # everything else 216 | - json 217 | - yaml 218 | - yml 219 | - toml 220 | - validate 221 | strict: false 222 | 223 | tenv: 224 | # The option `all` will run against whole test files (`_test.go`) regardless of method/function signatures. 225 | # Otherwise, only methods that take `*testing.T`, `*testing.B`, and `testing.TB` as arguments are checked. 226 | all: true 227 | -------------------------------------------------------------------------------- /static/js/clipboard.min.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * clipboard.js v1.6.0 3 | * https://zenorocha.github.io/clipboard.js 4 | * 5 | * Licensed MIT © Zeno Rocha 6 | */ 7 | !function(e){if("object"==typeof exports&&"undefined"!=typeof module)module.exports=e();else if("function"==typeof define&&define.amd)define([],e);else{var t;t="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof self?self:this,t.Clipboard=e()}}(function(){var e,t,n;return function e(t,n,o){function i(a,c){if(!n[a]){if(!t[a]){var l="function"==typeof require&&require;if(!c&&l)return l(a,!0);if(r)return r(a,!0);var u=new Error("Cannot find module '"+a+"'");throw u.code="MODULE_NOT_FOUND",u}var s=n[a]={exports:{}};t[a][0].call(s.exports,function(e){var n=t[a][1][e];return i(n?n:e)},s,s.exports,e,t,n,o)}return n[a].exports}for(var r="function"==typeof require&&require,a=0;a0&&void 0!==arguments[0]?arguments[0]:{};this.action=t.action,this.emitter=t.emitter,this.target=t.target,this.text=t.text,this.trigger=t.trigger,this.selectedText=""}},{key:"initSelection",value:function e(){this.text?this.selectFake():this.target&&this.selectTarget()}},{key:"selectFake",value:function e(){var t=this,n="rtl"==document.documentElement.getAttribute("dir");this.removeFake(),this.fakeHandlerCallback=function(){return t.removeFake()},this.fakeHandler=document.body.addEventListener("click",this.fakeHandlerCallback)||!0,this.fakeElem=document.createElement("textarea"),this.fakeElem.style.fontSize="12pt",this.fakeElem.style.border="0",this.fakeElem.style.padding="0",this.fakeElem.style.margin="0",this.fakeElem.style.position="absolute",this.fakeElem.style[n?"right":"left"]="-9999px";var o=window.pageYOffset||document.documentElement.scrollTop;this.fakeElem.style.top=o+"px",this.fakeElem.setAttribute("readonly",""),this.fakeElem.value=this.text,document.body.appendChild(this.fakeElem),this.selectedText=(0,i.default)(this.fakeElem),this.copyText()}},{key:"removeFake",value:function e(){this.fakeHandler&&(document.body.removeEventListener("click",this.fakeHandlerCallback),this.fakeHandler=null,this.fakeHandlerCallback=null),this.fakeElem&&(document.body.removeChild(this.fakeElem),this.fakeElem=null)}},{key:"selectTarget",value:function e(){this.selectedText=(0,i.default)(this.target),this.copyText()}},{key:"copyText",value:function e(){var t=void 0;try{t=document.execCommand(this.action)}catch(e){t=!1}this.handleResult(t)}},{key:"handleResult",value:function e(t){this.emitter.emit(t?"success":"error",{action:this.action,text:this.selectedText,trigger:this.trigger,clearSelection:this.clearSelection.bind(this)})}},{key:"clearSelection",value:function e(){this.target&&this.target.blur(),window.getSelection().removeAllRanges()}},{key:"destroy",value:function e(){this.removeFake()}},{key:"action",set:function e(){var t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:"copy";if(this._action=t,"copy"!==this._action&&"cut"!==this._action)throw new Error('Invalid "action" value, use either "copy" or "cut"')},get:function e(){return this._action}},{key:"target",set:function e(t){if(void 0!==t){if(!t||"object"!==("undefined"==typeof t?"undefined":r(t))||1!==t.nodeType)throw new Error('Invalid "target" value, use a valid Element');if("copy"===this.action&&t.hasAttribute("disabled"))throw new Error('Invalid "target" attribute. Please use "readonly" instead of "disabled" attribute');if("cut"===this.action&&(t.hasAttribute("readonly")||t.hasAttribute("disabled")))throw new Error('Invalid "target" attribute. You can\'t cut text from elements with "readonly" or "disabled" attributes');this._target=t}},get:function e(){return this._target}}]),e}();e.exports=c})},{select:5}],8:[function(t,n,o){!function(i,r){if("function"==typeof e&&e.amd)e(["module","./clipboard-action","tiny-emitter","good-listener"],r);else if("undefined"!=typeof o)r(n,t("./clipboard-action"),t("tiny-emitter"),t("good-listener"));else{var a={exports:{}};r(a,i.clipboardAction,i.tinyEmitter,i.goodListener),i.clipboard=a.exports}}(this,function(e,t,n,o){"use strict";function i(e){return e&&e.__esModule?e:{default:e}}function r(e,t){if(!(e instanceof t))throw new TypeError("Cannot call a class as a function")}function a(e,t){if(!e)throw new ReferenceError("this hasn't been initialised - super() hasn't been called");return!t||"object"!=typeof t&&"function"!=typeof t?e:t}function c(e,t){if("function"!=typeof t&&null!==t)throw new TypeError("Super expression must either be null or a function, not "+typeof t);e.prototype=Object.create(t&&t.prototype,{constructor:{value:e,enumerable:!1,writable:!0,configurable:!0}}),t&&(Object.setPrototypeOf?Object.setPrototypeOf(e,t):e.__proto__=t)}function l(e,t){var n="data-clipboard-"+e;if(t.hasAttribute(n))return t.getAttribute(n)}var u=i(t),s=i(n),f=i(o),d=function(){function e(e,t){for(var n=0;n0&&void 0!==arguments[0]?arguments[0]:{};this.action="function"==typeof t.action?t.action:this.defaultAction,this.target="function"==typeof t.target?t.target:this.defaultTarget,this.text="function"==typeof t.text?t.text:this.defaultText}},{key:"listenClick",value:function e(t){var n=this;this.listener=(0,f.default)(t,"click",function(e){return n.onClick(e)})}},{key:"onClick",value:function e(t){var n=t.delegateTarget||t.currentTarget;this.clipboardAction&&(this.clipboardAction=null),this.clipboardAction=new u.default({action:this.action(n),target:this.target(n),text:this.text(n),trigger:n,emitter:this})}},{key:"defaultAction",value:function e(t){return l("action",t)}},{key:"defaultTarget",value:function e(t){var n=l("target",t);if(n)return document.querySelector(n)}},{key:"defaultText",value:function e(t){return l("text",t)}},{key:"destroy",value:function e(){this.listener.destroy(),this.clipboardAction&&(this.clipboardAction.destroy(),this.clipboardAction=null)}}],[{key:"isSupported",value:function e(){var t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:["copy","cut"],n="string"==typeof t?[t]:t,o=!!document.queryCommandSupported;return n.forEach(function(e){o=o&&!!document.queryCommandSupported(e)}),o}}]),t}(s.default);e.exports=h})},{"./clipboard-action":7,"good-listener":4,"tiny-emitter":6}]},{},[8])(8)}); 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 6 | ![logo](https://liam.sh/-/gh/svg/lrstanley/links?icon=ph%3Alink-simple&icon.height=65&layout=left&icon.color=rgba%280%2C+184%2C+126%2C+1%29&font=1.3) 7 | 8 | 9 | 10 | 11 |

12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 |

43 |

44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 |

62 | 63 | 64 | 65 | 66 | ## :link: Table of Contents 67 | 68 | - [Installation](#computer-installation) 69 | - [Container Images (ghcr)](#whale-container-images-ghcr) 70 | - [Source](#toolbox-source) 71 | - [Usage](#gear-usage) 72 | - [Dot-env](#dot-env) 73 | - [Example](#example) 74 | - [Google SafeBrowsing](#google-safebrowsing) 75 | - [Using as a library](#using-as-a-library) 76 | - [Example](#example-1) 77 | - [API](#api) 78 | - [Password protection](#password-protection) 79 | - [Support & Assistance](#raising_hand_man-support--assistance) 80 | - [Contributing](#handshake-contributing) 81 | - [License](#balance_scale-license) 82 | 83 | 84 | ## :computer: Installation 85 | 86 | Check out the [releases](https://github.com/lrstanley/links/releases) 87 | page for prebuilt versions. 88 | 89 | 90 | 91 | ### :whale: Container Images (ghcr) 92 | 93 | ```console 94 | $ docker run -it --rm ghcr.io/lrstanley/links:master 95 | ``` 96 | 97 | 98 | ### :toolbox: Source 99 | 100 | Note that you must have [Go](https://golang.org/doc/install) installed (latest is usually best). 101 | 102 | ```console 103 | $ git clone https://github.com/lrstanley/links.git && cd links 104 | $ make 105 | $ ./links --help 106 | ``` 107 | 108 | ## :gear: Usage 109 | 110 | ``` 111 | $ links --help 112 | Usage: 113 | links [OPTIONS] [add | delete] 114 | 115 | Application Options: 116 | -s, --site-name= site url, used for url generation (default: https://links.wtf) [$SITE_URL] 117 | --session-dir= optional location to store temporary sessions [$SESSION_DIR] 118 | -q, --quiet don't log to stdout [$QUIET] 119 | --debug enable debugging (pprof endpoints) [$DEBUG] 120 | -b, --http= ip:port pair to bind to (default: :8080) [$HTTP] 121 | -p, --behind-proxy if X-Forwarded-For headers should be trusted [$PROXY] 122 | -d, --db= path to database file (default: store.db) [$DB_PATH] 123 | --key-length= default length of key (uuid) for generated urls (default: 4) [$KEY_LENGTH] 124 | --http-pre-include= HTTP include which is included directly after css is included (near top of the page) 125 | [$HTTP_PRE_INCLUDE] 126 | --http-post-include= HTTP include which is included directly after js is included (near bottom of the page) 127 | [$HTTP_POST_INCLUDE] 128 | -e, --export-file= file to export db to (default: links.export) 129 | --export-json export db to json elements 130 | -v, --version display the version of links.wtf and exit 131 | 132 | TLS Options: 133 | --tls.enable run tls server rather than standard http [$TLS_ENABLE] 134 | -c, --tls.cert= path to ssl cert file [$TLS_CERT_PATH] 135 | -k, --tls.key= path to ssl key file [$TLS_KEY_PATH] 136 | 137 | Safe Browsing Support: 138 | --safebrowsing.api-key= Google API Key used for querying SafeBrowsing, disabled if not provided (see: 139 | https://github.com/lrstanley/links#google-safebrowsing) [$SAFEBROWSING_API_KEY] 140 | --safebrowsing.db= path to SafeBrowsing database file (default: safebrowsing.db) [$SAFEBROWSING_DB_PATH] 141 | --safebrowsing.update-period= duration between updates to the SafeBrowsing API local database (default: 1h) 142 | [$SAFEBROWSING_UPDATE_PERIOD] 143 | --safebrowsing.redirect-fallback if the SafeBrowsing request fails (local cache, and remote hit), this still lets the 144 | redirect happen [$SAFEBROWSING_REDIRECT_FALLBACK] 145 | 146 | Prometheus Metrics: 147 | --prom.enabled enable exposing of prometheus metrics (on std port, or --prometheus.addr) [$PROM_ENABLED] 148 | --prom.addr= expose on custom address/port, e.g. ':9001' (all ips) or 'localhost:9001' (local only) 149 | [$PROM_ADDR] 150 | --prom.endpoint= endpoint to expose metrics on (default: /metrics) [$PROM_ENDPOINT] 151 | 152 | Help Options: 153 | -h, --help Show this help message 154 | 155 | Available commands: 156 | add add a link 157 | delete delete a link, id, or link matching an author 158 | 159 | ``` 160 | 161 | ### Dot-env 162 | 163 | Links also supports a `.env` file for loading environment variables. Example: 164 | 165 | ``` 166 | SAFEBROWSING_API_KEY= 167 | FOO=BAR 168 | ``` 169 | 170 | ### Example 171 | 172 | ``` 173 | $ links -s "http://your-domain.com" -b "0.0.0.0:80" -d links.db 174 | ``` 175 | 176 | ### Google SafeBrowsing 177 | 178 | Links supports utilizing [Google SafeBrowsing](https://safebrowsing.google.com/), 179 | (see `Safe Browsing Support` under ##Usage). This helps prevent users being 180 | redirected to malicious or otherwise harmful websites. It does require a Google 181 | Developer account (free). 182 | 183 | 1. Go to the [Google Developer Console](https://console.developers.google.com/) 184 | 2. Create a new project (dropdown top left, click `NEW PROJECT`) 185 | 3. Enable the "Safe Browsing" API 186 | 4. Create a new credential (API key) 187 | 188 | Screenshot example: 189 | ![](https://i.imgur.com/VHyPYqi.png) 190 | 191 | ## Using as a library 192 | 193 | Links also has a Go client library which you can use, which adds a simple 194 | wrapper around an http call, to make shortening links simpler. Download it 195 | using the following `go get` command: 196 | 197 | ``` 198 | $ go get -u github.com/lrstanley/links/client 199 | ``` 200 | 201 | View the documentation [here](https://godoc.org/github.com/lrstanley/links/client) 202 | 203 | ### Example 204 | 205 | ```go 206 | package main 207 | 208 | import ( 209 | "fmt" 210 | "log" 211 | 212 | links "github.com/lrstanley/links/client" 213 | ) 214 | 215 | func main() { 216 | uri, err := links.Shorten("https://your-long-link.com/longer/link", "", nil) 217 | if err != nil { 218 | log.Fatalf("unable to shorten link: %s", err) 219 | } 220 | 221 | fmt.Printf("shortened: %s 222 | ", uri.String()) 223 | } 224 | ``` 225 | 226 | ## API 227 | 228 | Shortening a link is quite easy. simply send a `POST` request to `https://example.com/add`, 229 | which will return JSON-safe information as shown below: 230 | 231 | ``` 232 | $ curl --data "url=http://google.com" https://example.com/add 233 | {"url": "https://example.com/27f4", "success": true} 234 | ``` 235 | 236 | #### Password protection 237 | 238 | You can also password protect a link, simply by adding a `password` variable to the payload: 239 | 240 | ``` 241 | $ curl --data 'url=https://google.com/example&encrypt=y0urp4$$w0rd' https://example.com/add 242 | {"url": "https://example.com/abc123", "success": true} 243 | ``` 244 | 245 | 246 | 247 | ## :raising_hand_man: Support & Assistance 248 | 249 | * :heart: Please review the [Code of Conduct](.github/CODE_OF_CONDUCT.md) for 250 | guidelines on ensuring everyone has the best experience interacting with 251 | the community. 252 | * :raising_hand_man: Take a look at the [support](.github/SUPPORT.md) document on 253 | guidelines for tips on how to ask the right questions. 254 | * :lady_beetle: For all features/bugs/issues/questions/etc, [head over here](https://github.com/lrstanley/links/issues/new/choose). 255 | 256 | 257 | 258 | 259 | ## :handshake: Contributing 260 | 261 | * :heart: Please review the [Code of Conduct](.github/CODE_OF_CONDUCT.md) for guidelines 262 | on ensuring everyone has the best experience interacting with the 263 | community. 264 | * :clipboard: Please review the [contributing](.github/CONTRIBUTING.md) doc for submitting 265 | issues/a guide on submitting pull requests and helping out. 266 | * :old_key: For anything security related, please review this repositories [security policy](https://github.com/lrstanley/links/security/policy). 267 | 268 | 269 | 270 | 271 | ## :balance_scale: License 272 | 273 | ``` 274 | MIT License 275 | 276 | Copyright (c) 2014 Liam Stanley 277 | 278 | Permission is hereby granted, free of charge, to any person obtaining a copy 279 | of this software and associated documentation files (the "Software"), to deal 280 | in the Software without restriction, including without limitation the rights 281 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 282 | copies of the Software, and to permit persons to whom the Software is 283 | furnished to do so, subject to the following conditions: 284 | 285 | The above copyright notice and this permission notice shall be included in all 286 | copies or substantial portions of the Software. 287 | 288 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 289 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 290 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 291 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 292 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 293 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 294 | SOFTWARE. 295 | ``` 296 | 297 | _Also located [here](LICENSE)_ 298 | 299 | -------------------------------------------------------------------------------- /static/js/notie.min.js: -------------------------------------------------------------------------------- 1 | !function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.notie=t():e.notie=t()}(this,function(){return function(e){function t(s){if(n[s])return n[s].exports;var a=n[s]={i:s,l:!1,exports:{}};return e[s].call(a.exports,a,a.exports,t),a.l=!0,a.exports}var n={};return t.m=e,t.c=n,t.i=function(e){return e},t.d=function(e,n,s){t.o(e,n)||Object.defineProperty(e,n,{configurable:!1,enumerable:!0,get:s})},t.n=function(e){var n=e&&e.__esModule?function(){return e.default}:function(){return e};return t.d(n,"a",n),n},t.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},t.p="",t(t.s=1)}([function(e,t){e.exports=function(e){return e.webpackPolyfill||(e.deprecate=function(){},e.paths=[],e.children||(e.children=[]),Object.defineProperty(e,"loaded",{enumerable:!0,get:function(){return e.l}}),Object.defineProperty(e,"id",{enumerable:!0,get:function(){return e.i}}),e.webpackPolyfill=1),e}},function(e,t,n){"use strict";(function(e){var n,s,a,c="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e};!function(i,r){"object"===c(t)&&"object"===c(e)?e.exports=r():(s=[],n=r,a="function"==typeof n?n.apply(t,s):n,!(void 0!==a&&(e.exports=a)))}(void 0,function(){return function(e){function t(s){if(n[s])return n[s].exports;var a=n[s]={i:s,l:!1,exports:{}};return e[s].call(a.exports,a,a.exports,t),a.l=!0,a.exports}var n={};return t.m=e,t.c=n,t.i=function(e){return e},t.d=function(e,n,s){t.o(e,n)||Object.defineProperty(e,n,{configurable:!1,enumerable:!0,get:s})},t.n=function(e){var n=e&&e.__esModule?function(){return e.default}:function(){return e};return t.d(n,"a",n),n},t.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},t.p="",t(t.s=0)}([function(e,t,n){function s(e,t){var n={};for(var s in e)t.indexOf(s)>=0||Object.prototype.hasOwnProperty.call(e,s)&&(n[s]=e[s]);return n}Object.defineProperty(t,"__esModule",{value:!0});var a="function"==typeof Symbol&&"symbol"===c(Symbol.iterator)?function(e){return"undefined"==typeof e?"undefined":c(e)}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":"undefined"==typeof e?"undefined":c(e)},i=Object.assign||function(e){for(var t=1;t1&&void 0!==arguments[1]?arguments[1]:"top";e.classList.add(r.classes.container),e.style[t]="-10000px",document.body.appendChild(e),e.style[t]="-"+e.offsetHeight+"px",e.listener&&window.addEventListener("keydown",e.listener),o().then(function(){e.style.transition=m(),e.style[t]=0})},y=function(e){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:"top",n=document.getElementById(e);n&&(n.style[t]="-"+n.offsetHeight+"px",n.listener&&window.removeEventListener("keydown",n.listener),d(r.transitionDuration).then(function(){n.parentNode&&n.parentNode.removeChild(n)}))},L=function(e,t){var n=document.createElement("div");n.id=r.ids.overlay,n.classList.add(r.classes.overlay),n.classList.add(r.classes.backgroundOverlay),n.style.opacity=0,e&&r.overlayClickDismiss&&(n.onclick=function(){y(e.id,t),g()}),document.body.appendChild(n),o().then(function(){n.style.transition=m(),n.style.opacity=r.overlayOpacity})},g=function(){var e=document.getElementById(r.ids.overlay);e.style.opacity=0,d(r.transitionDuration).then(function(){e.parentNode&&e.parentNode.removeChild(e)})},h=t.hideAlerts=function(e){var t=document.getElementsByClassName(r.classes.alert);if(t.length){for(var n=0;n'+s+"",o.onclick=function(){return y(m)},o.listener=function(e){(v(e)||b(e))&&h()},x(o),c&&c<1&&(c=1),!l&&c&&d(c).then(function(){return y(m)})},C=t.force=function(e,t){var n=e.type,s=void 0===n?5:n,a=e.text,c=e.buttonText,i=void 0===c?"OK":c,l=e.callback;u(),h();var o=document.createElement("div"),d=f();o.id=d;var m=document.createElement("div");m.classList.add(r.classes.textbox),m.classList.add(r.classes.backgroundInfo),m.innerHTML='
'+a+"
";var b=document.createElement("div");b.classList.add(r.classes.button),b.classList.add(p[s]),b.innerHTML=i,b.onclick=function(){y(d),g(),l?l():t&&t()},o.appendChild(m),o.appendChild(b),o.listener=function(e){v(e)&&b.click()},x(o),L()},E=t.confirm=function(e,t,n){var s=e.text,a=e.submitText,c=void 0===a?"Yes":a,i=e.cancelText,l=void 0===i?"Cancel":i,o=e.submitCallback,d=e.cancelCallback;u(),h();var p=document.createElement("div"),m=f();p.id=m;var k=document.createElement("div");k.classList.add(r.classes.textbox),k.classList.add(r.classes.backgroundInfo),k.innerHTML='
'+s+"
";var C=document.createElement("div");C.classList.add(r.classes.button),C.classList.add(r.classes.elementHalf),C.classList.add(r.classes.backgroundSuccess),C.innerHTML=c,C.onclick=function(){y(m),g(),o?o():t&&t()};var E=document.createElement("div");E.classList.add(r.classes.button),E.classList.add(r.classes.elementHalf),E.classList.add(r.classes.backgroundError),E.innerHTML=l,E.onclick=function(){y(m),g(),d?d():n&&n()},p.appendChild(k),p.appendChild(C),p.appendChild(E),p.listener=function(e){v(e)?C.click():b(e)&&E.click()},x(p),L(p)},T=function(e,t,n){var c=e.text,i=e.submitText,l=void 0===i?"Submit":i,o=e.cancelText,d=void 0===o?"Cancel":o,p=e.submitCallback,m=e.cancelCallback,k=s(e,["text","submitText","cancelText","submitCallback","cancelCallback"]);u(),h();var C=document.createElement("div"),E=f();C.id=E;var T=document.createElement("div");T.classList.add(r.classes.textbox),T.classList.add(r.classes.backgroundInfo),T.innerHTML='
'+c+"
";var M=document.createElement("input");M.classList.add(r.classes.inputField),M.setAttribute("autocapitalize",k.autocapitalize||"none"),M.setAttribute("autocomplete",k.autocomplete||"off"),M.setAttribute("autocorrect",k.autocorrect||"off"),M.setAttribute("autofocus",k.autofocus||"true"),M.setAttribute("inputmode",k.inputmode||"verbatim"),M.setAttribute("max",k.max||""),M.setAttribute("maxlength",k.maxlength||""),M.setAttribute("min",k.min||""),M.setAttribute("minlength",k.minlength||""),M.setAttribute("placeholder",k.placeholder||""),M.setAttribute("spellcheck",k.spellcheck||"default"),M.setAttribute("step",k.step||"any"),M.setAttribute("type",k.type||"text"),M.value=k.value||"",k.allowed&&(M.oninput=function(){var e=void 0;if(Array.isArray(k.allowed)){for(var t="",n=k.allowed,s=0;s'+n+"",o.appendChild(m),i.forEach(function(e,t){var n=e.type,s=void 0===n?1:n,a=e.text,c=e.handler,u=document.createElement("div");u.classList.add(p[s]),u.classList.add(r.classes.button),u.classList.add(r.classes.selectChoice);var f=i[t+1];f&&!f.type&&(f.type=1),f&&f.type===s&&u.classList.add(r.classes.selectChoiceRepeated),u.innerHTML=a,u.onclick=function(){y(d,l),g(),c()},o.appendChild(u)});var v=document.createElement("div");v.classList.add(r.classes.backgroundNeutral),v.classList.add(r.classes.button),v.innerHTML=a,v.onclick=function(){y(d,l),g(),c?c():t&&t()},o.appendChild(v),o.listener=function(e){b(e)&&v.click()},x(o,l),L(o,l)},H=t.date=function(e,t,n){var s=e.value,a=void 0===s?new Date:s,c=e.submitText,i=void 0===c?"OK":c,l=e.cancelText,o=void 0===l?"Cancel":l,d=e.submitCallback,p=e.cancelCallback;u(),h();var m="▾",k=document.createElement("div"),C=document.createElement("div"),E=document.createElement("div"),T=function(e){k.innerHTML=r.dateMonths[e.getMonth()],C.innerHTML=e.getDate(),E.innerHTML=e.getFullYear()},M=function(e){var t=new Date(a.getFullYear(),a.getMonth()+1,0).getDate(),n=e.target.textContent.replace(/^0+/,"").replace(/[^\d]/g,"").slice(0,2);Number(n)>t&&(n=t.toString()),e.target.textContent=n,Number(n)<1&&(n="1"),a.setDate(Number(n))},H=function(e){var t=e.target.textContent.replace(/^0+/,"").replace(/[^\d]/g,"").slice(0,4);e.target.textContent=t,a.setFullYear(Number(t))},S=function(e){T(a)},w=function(e){var t=new Date(a.getFullYear(),a.getMonth()+e+1,0).getDate();a.getDate()>t&&a.setDate(t),a.setMonth(a.getMonth()+e),T(a)},O=function(e){a.setDate(a.getDate()+e),T(a)},A=function(e){var t=a.getFullYear()+e;t<0?a.setFullYear(0):a.setFullYear(a.getFullYear()+e),T(a)},D=document.createElement("div"),I=f();D.id=I;var j=document.createElement("div");j.classList.add(r.classes.backgroundInfo);var N=document.createElement("div");N.classList.add(r.classes.dateSelectorInner);var P=document.createElement("div");P.classList.add(r.classes.button),P.classList.add(r.classes.elementThird),P.classList.add(r.classes.dateSelectorUp),P.innerHTML=m;var F=document.createElement("div");F.classList.add(r.classes.button),F.classList.add(r.classes.elementThird),F.classList.add(r.classes.dateSelectorUp),F.innerHTML=m;var Y=document.createElement("div");Y.classList.add(r.classes.button),Y.classList.add(r.classes.elementThird),Y.classList.add(r.classes.dateSelectorUp),Y.innerHTML=m,k.classList.add(r.classes.element),k.classList.add(r.classes.elementThird),k.innerHTML=r.dateMonths[a.getMonth()],C.classList.add(r.classes.element),C.classList.add(r.classes.elementThird),C.setAttribute("contentEditable",!0),C.addEventListener("input",M),C.addEventListener("blur",S),C.innerHTML=a.getDate(),E.classList.add(r.classes.element),E.classList.add(r.classes.elementThird),E.setAttribute("contentEditable",!0),E.addEventListener("input",H),E.addEventListener("blur",S),E.innerHTML=a.getFullYear();var _=document.createElement("div");_.classList.add(r.classes.button),_.classList.add(r.classes.elementThird),_.innerHTML=m;var z=document.createElement("div");z.classList.add(r.classes.button),z.classList.add(r.classes.elementThird),z.innerHTML=m;var U=document.createElement("div");U.classList.add(r.classes.button),U.classList.add(r.classes.elementThird),U.innerHTML=m,P.onclick=function(){return w(1)},F.onclick=function(){return O(1)},Y.onclick=function(){return A(1)},_.onclick=function(){return w(-1)},z.onclick=function(){return O(-1)},U.onclick=function(){return A(-1)};var B=document.createElement("div");B.classList.add(r.classes.button),B.classList.add(r.classes.elementHalf),B.classList.add(r.classes.backgroundSuccess),B.innerHTML=i,B.onclick=function(){y(I),g(),d?d(a):t&&t(a)};var J=document.createElement("div");J.classList.add(r.classes.button),J.classList.add(r.classes.elementHalf),J.classList.add(r.classes.backgroundError),J.innerHTML=o,J.onclick=function(){y(I),g(),p?p(a):n&&n(a)},N.appendChild(P),N.appendChild(F),N.appendChild(Y),N.appendChild(k),N.appendChild(C),N.appendChild(E),N.appendChild(_),N.appendChild(z),N.appendChild(U),j.appendChild(N),D.appendChild(j),D.appendChild(B),D.appendChild(J),D.listener=function(e){v(e)?B.click():b(e)&&J.click()},x(D),L(D)};t.default={alert:k,force:C,confirm:E,input:T,select:M,date:H,setOptions:l,hideAlerts:h}}])})}).call(t,n(0)(e))}])}); 2 | -------------------------------------------------------------------------------- /static/css/font-awesome.min.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * Font Awesome 4.7.0 by @davegandy - http://fontawesome.io - @fontawesome 3 | * License - http://fontawesome.io/license (Font: SIL OFL 1.1, CSS: MIT License) 4 | */@font-face{font-family:'FontAwesome';src:url('../fonts/fontawesome-webfont.eot?v=4.7.0');src:url('../fonts/fontawesome-webfont.eot?#iefix&v=4.7.0') format('embedded-opentype'),url('../fonts/fontawesome-webfont.woff2?v=4.7.0') format('woff2'),url('../fonts/fontawesome-webfont.woff?v=4.7.0') format('woff'),url('../fonts/fontawesome-webfont.ttf?v=4.7.0') format('truetype'),url('../fonts/fontawesome-webfont.svg?v=4.7.0#fontawesomeregular') format('svg');font-weight:normal;font-style:normal}.fa{display:inline-block;font:normal normal normal 14px/1 FontAwesome;font-size:inherit;text-rendering:auto;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.fa-lg{font-size:1.33333333em;line-height:.75em;vertical-align:-15%}.fa-2x{font-size:2em}.fa-3x{font-size:3em}.fa-4x{font-size:4em}.fa-5x{font-size:5em}.fa-fw{width:1.28571429em;text-align:center}.fa-ul{padding-left:0;margin-left:2.14285714em;list-style-type:none}.fa-ul>li{position:relative}.fa-li{position:absolute;left:-2.14285714em;width:2.14285714em;top:.14285714em;text-align:center}.fa-li.fa-lg{left:-1.85714286em}.fa-border{padding:.2em .25em .15em;border:solid .08em #eee;border-radius:.1em}.fa-pull-left{float:left}.fa-pull-right{float:right}.fa.fa-pull-left{margin-right:.3em}.fa.fa-pull-right{margin-left:.3em}.pull-right{float:right}.pull-left{float:left}.fa.pull-left{margin-right:.3em}.fa.pull-right{margin-left:.3em}.fa-spin{-webkit-animation:fa-spin 2s infinite linear;animation:fa-spin 2s infinite linear}.fa-pulse{-webkit-animation:fa-spin 1s infinite steps(8);animation:fa-spin 1s infinite steps(8)}@-webkit-keyframes fa-spin{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}100%{-webkit-transform:rotate(359deg);transform:rotate(359deg)}}@keyframes fa-spin{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}100%{-webkit-transform:rotate(359deg);transform:rotate(359deg)}}.fa-rotate-90{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=1)";-webkit-transform:rotate(90deg);-ms-transform:rotate(90deg);transform:rotate(90deg)}.fa-rotate-180{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=2)";-webkit-transform:rotate(180deg);-ms-transform:rotate(180deg);transform:rotate(180deg)}.fa-rotate-270{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=3)";-webkit-transform:rotate(270deg);-ms-transform:rotate(270deg);transform:rotate(270deg)}.fa-flip-horizontal{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=0, mirror=1)";-webkit-transform:scale(-1, 1);-ms-transform:scale(-1, 1);transform:scale(-1, 1)}.fa-flip-vertical{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=2, mirror=1)";-webkit-transform:scale(1, -1);-ms-transform:scale(1, -1);transform:scale(1, -1)}:root .fa-rotate-90,:root .fa-rotate-180,:root .fa-rotate-270,:root .fa-flip-horizontal,:root .fa-flip-vertical{filter:none}.fa-stack{position:relative;display:inline-block;width:2em;height:2em;line-height:2em;vertical-align:middle}.fa-stack-1x,.fa-stack-2x{position:absolute;left:0;width:100%;text-align:center}.fa-stack-1x{line-height:inherit}.fa-stack-2x{font-size:2em}.fa-inverse{color:#fff}.fa-glass:before{content:"\f000"}.fa-music:before{content:"\f001"}.fa-search:before{content:"\f002"}.fa-envelope-o:before{content:"\f003"}.fa-heart:before{content:"\f004"}.fa-star:before{content:"\f005"}.fa-star-o:before{content:"\f006"}.fa-user:before{content:"\f007"}.fa-film:before{content:"\f008"}.fa-th-large:before{content:"\f009"}.fa-th:before{content:"\f00a"}.fa-th-list:before{content:"\f00b"}.fa-check:before{content:"\f00c"}.fa-remove:before,.fa-close:before,.fa-times:before{content:"\f00d"}.fa-search-plus:before{content:"\f00e"}.fa-search-minus:before{content:"\f010"}.fa-power-off:before{content:"\f011"}.fa-signal:before{content:"\f012"}.fa-gear:before,.fa-cog:before{content:"\f013"}.fa-trash-o:before{content:"\f014"}.fa-home:before{content:"\f015"}.fa-file-o:before{content:"\f016"}.fa-clock-o:before{content:"\f017"}.fa-road:before{content:"\f018"}.fa-download:before{content:"\f019"}.fa-arrow-circle-o-down:before{content:"\f01a"}.fa-arrow-circle-o-up:before{content:"\f01b"}.fa-inbox:before{content:"\f01c"}.fa-play-circle-o:before{content:"\f01d"}.fa-rotate-right:before,.fa-repeat:before{content:"\f01e"}.fa-refresh:before{content:"\f021"}.fa-list-alt:before{content:"\f022"}.fa-lock:before{content:"\f023"}.fa-flag:before{content:"\f024"}.fa-headphones:before{content:"\f025"}.fa-volume-off:before{content:"\f026"}.fa-volume-down:before{content:"\f027"}.fa-volume-up:before{content:"\f028"}.fa-qrcode:before{content:"\f029"}.fa-barcode:before{content:"\f02a"}.fa-tag:before{content:"\f02b"}.fa-tags:before{content:"\f02c"}.fa-book:before{content:"\f02d"}.fa-bookmark:before{content:"\f02e"}.fa-print:before{content:"\f02f"}.fa-camera:before{content:"\f030"}.fa-font:before{content:"\f031"}.fa-bold:before{content:"\f032"}.fa-italic:before{content:"\f033"}.fa-text-height:before{content:"\f034"}.fa-text-width:before{content:"\f035"}.fa-align-left:before{content:"\f036"}.fa-align-center:before{content:"\f037"}.fa-align-right:before{content:"\f038"}.fa-align-justify:before{content:"\f039"}.fa-list:before{content:"\f03a"}.fa-dedent:before,.fa-outdent:before{content:"\f03b"}.fa-indent:before{content:"\f03c"}.fa-video-camera:before{content:"\f03d"}.fa-photo:before,.fa-image:before,.fa-picture-o:before{content:"\f03e"}.fa-pencil:before{content:"\f040"}.fa-map-marker:before{content:"\f041"}.fa-adjust:before{content:"\f042"}.fa-tint:before{content:"\f043"}.fa-edit:before,.fa-pencil-square-o:before{content:"\f044"}.fa-share-square-o:before{content:"\f045"}.fa-check-square-o:before{content:"\f046"}.fa-arrows:before{content:"\f047"}.fa-step-backward:before{content:"\f048"}.fa-fast-backward:before{content:"\f049"}.fa-backward:before{content:"\f04a"}.fa-play:before{content:"\f04b"}.fa-pause:before{content:"\f04c"}.fa-stop:before{content:"\f04d"}.fa-forward:before{content:"\f04e"}.fa-fast-forward:before{content:"\f050"}.fa-step-forward:before{content:"\f051"}.fa-eject:before{content:"\f052"}.fa-chevron-left:before{content:"\f053"}.fa-chevron-right:before{content:"\f054"}.fa-plus-circle:before{content:"\f055"}.fa-minus-circle:before{content:"\f056"}.fa-times-circle:before{content:"\f057"}.fa-check-circle:before{content:"\f058"}.fa-question-circle:before{content:"\f059"}.fa-info-circle:before{content:"\f05a"}.fa-crosshairs:before{content:"\f05b"}.fa-times-circle-o:before{content:"\f05c"}.fa-check-circle-o:before{content:"\f05d"}.fa-ban:before{content:"\f05e"}.fa-arrow-left:before{content:"\f060"}.fa-arrow-right:before{content:"\f061"}.fa-arrow-up:before{content:"\f062"}.fa-arrow-down:before{content:"\f063"}.fa-mail-forward:before,.fa-share:before{content:"\f064"}.fa-expand:before{content:"\f065"}.fa-compress:before{content:"\f066"}.fa-plus:before{content:"\f067"}.fa-minus:before{content:"\f068"}.fa-asterisk:before{content:"\f069"}.fa-exclamation-circle:before{content:"\f06a"}.fa-gift:before{content:"\f06b"}.fa-leaf:before{content:"\f06c"}.fa-fire:before{content:"\f06d"}.fa-eye:before{content:"\f06e"}.fa-eye-slash:before{content:"\f070"}.fa-warning:before,.fa-exclamation-triangle:before{content:"\f071"}.fa-plane:before{content:"\f072"}.fa-calendar:before{content:"\f073"}.fa-random:before{content:"\f074"}.fa-comment:before{content:"\f075"}.fa-magnet:before{content:"\f076"}.fa-chevron-up:before{content:"\f077"}.fa-chevron-down:before{content:"\f078"}.fa-retweet:before{content:"\f079"}.fa-shopping-cart:before{content:"\f07a"}.fa-folder:before{content:"\f07b"}.fa-folder-open:before{content:"\f07c"}.fa-arrows-v:before{content:"\f07d"}.fa-arrows-h:before{content:"\f07e"}.fa-bar-chart-o:before,.fa-bar-chart:before{content:"\f080"}.fa-twitter-square:before{content:"\f081"}.fa-facebook-square:before{content:"\f082"}.fa-camera-retro:before{content:"\f083"}.fa-key:before{content:"\f084"}.fa-gears:before,.fa-cogs:before{content:"\f085"}.fa-comments:before{content:"\f086"}.fa-thumbs-o-up:before{content:"\f087"}.fa-thumbs-o-down:before{content:"\f088"}.fa-star-half:before{content:"\f089"}.fa-heart-o:before{content:"\f08a"}.fa-sign-out:before{content:"\f08b"}.fa-linkedin-square:before{content:"\f08c"}.fa-thumb-tack:before{content:"\f08d"}.fa-external-link:before{content:"\f08e"}.fa-sign-in:before{content:"\f090"}.fa-trophy:before{content:"\f091"}.fa-github-square:before{content:"\f092"}.fa-upload:before{content:"\f093"}.fa-lemon-o:before{content:"\f094"}.fa-phone:before{content:"\f095"}.fa-square-o:before{content:"\f096"}.fa-bookmark-o:before{content:"\f097"}.fa-phone-square:before{content:"\f098"}.fa-twitter:before{content:"\f099"}.fa-facebook-f:before,.fa-facebook:before{content:"\f09a"}.fa-github:before{content:"\f09b"}.fa-unlock:before{content:"\f09c"}.fa-credit-card:before{content:"\f09d"}.fa-feed:before,.fa-rss:before{content:"\f09e"}.fa-hdd-o:before{content:"\f0a0"}.fa-bullhorn:before{content:"\f0a1"}.fa-bell:before{content:"\f0f3"}.fa-certificate:before{content:"\f0a3"}.fa-hand-o-right:before{content:"\f0a4"}.fa-hand-o-left:before{content:"\f0a5"}.fa-hand-o-up:before{content:"\f0a6"}.fa-hand-o-down:before{content:"\f0a7"}.fa-arrow-circle-left:before{content:"\f0a8"}.fa-arrow-circle-right:before{content:"\f0a9"}.fa-arrow-circle-up:before{content:"\f0aa"}.fa-arrow-circle-down:before{content:"\f0ab"}.fa-globe:before{content:"\f0ac"}.fa-wrench:before{content:"\f0ad"}.fa-tasks:before{content:"\f0ae"}.fa-filter:before{content:"\f0b0"}.fa-briefcase:before{content:"\f0b1"}.fa-arrows-alt:before{content:"\f0b2"}.fa-group:before,.fa-users:before{content:"\f0c0"}.fa-chain:before,.fa-link:before{content:"\f0c1"}.fa-cloud:before{content:"\f0c2"}.fa-flask:before{content:"\f0c3"}.fa-cut:before,.fa-scissors:before{content:"\f0c4"}.fa-copy:before,.fa-files-o:before{content:"\f0c5"}.fa-paperclip:before{content:"\f0c6"}.fa-save:before,.fa-floppy-o:before{content:"\f0c7"}.fa-square:before{content:"\f0c8"}.fa-navicon:before,.fa-reorder:before,.fa-bars:before{content:"\f0c9"}.fa-list-ul:before{content:"\f0ca"}.fa-list-ol:before{content:"\f0cb"}.fa-strikethrough:before{content:"\f0cc"}.fa-underline:before{content:"\f0cd"}.fa-table:before{content:"\f0ce"}.fa-magic:before{content:"\f0d0"}.fa-truck:before{content:"\f0d1"}.fa-pinterest:before{content:"\f0d2"}.fa-pinterest-square:before{content:"\f0d3"}.fa-google-plus-square:before{content:"\f0d4"}.fa-google-plus:before{content:"\f0d5"}.fa-money:before{content:"\f0d6"}.fa-caret-down:before{content:"\f0d7"}.fa-caret-up:before{content:"\f0d8"}.fa-caret-left:before{content:"\f0d9"}.fa-caret-right:before{content:"\f0da"}.fa-columns:before{content:"\f0db"}.fa-unsorted:before,.fa-sort:before{content:"\f0dc"}.fa-sort-down:before,.fa-sort-desc:before{content:"\f0dd"}.fa-sort-up:before,.fa-sort-asc:before{content:"\f0de"}.fa-envelope:before{content:"\f0e0"}.fa-linkedin:before{content:"\f0e1"}.fa-rotate-left:before,.fa-undo:before{content:"\f0e2"}.fa-legal:before,.fa-gavel:before{content:"\f0e3"}.fa-dashboard:before,.fa-tachometer:before{content:"\f0e4"}.fa-comment-o:before{content:"\f0e5"}.fa-comments-o:before{content:"\f0e6"}.fa-flash:before,.fa-bolt:before{content:"\f0e7"}.fa-sitemap:before{content:"\f0e8"}.fa-umbrella:before{content:"\f0e9"}.fa-paste:before,.fa-clipboard:before{content:"\f0ea"}.fa-lightbulb-o:before{content:"\f0eb"}.fa-exchange:before{content:"\f0ec"}.fa-cloud-download:before{content:"\f0ed"}.fa-cloud-upload:before{content:"\f0ee"}.fa-user-md:before{content:"\f0f0"}.fa-stethoscope:before{content:"\f0f1"}.fa-suitcase:before{content:"\f0f2"}.fa-bell-o:before{content:"\f0a2"}.fa-coffee:before{content:"\f0f4"}.fa-cutlery:before{content:"\f0f5"}.fa-file-text-o:before{content:"\f0f6"}.fa-building-o:before{content:"\f0f7"}.fa-hospital-o:before{content:"\f0f8"}.fa-ambulance:before{content:"\f0f9"}.fa-medkit:before{content:"\f0fa"}.fa-fighter-jet:before{content:"\f0fb"}.fa-beer:before{content:"\f0fc"}.fa-h-square:before{content:"\f0fd"}.fa-plus-square:before{content:"\f0fe"}.fa-angle-double-left:before{content:"\f100"}.fa-angle-double-right:before{content:"\f101"}.fa-angle-double-up:before{content:"\f102"}.fa-angle-double-down:before{content:"\f103"}.fa-angle-left:before{content:"\f104"}.fa-angle-right:before{content:"\f105"}.fa-angle-up:before{content:"\f106"}.fa-angle-down:before{content:"\f107"}.fa-desktop:before{content:"\f108"}.fa-laptop:before{content:"\f109"}.fa-tablet:before{content:"\f10a"}.fa-mobile-phone:before,.fa-mobile:before{content:"\f10b"}.fa-circle-o:before{content:"\f10c"}.fa-quote-left:before{content:"\f10d"}.fa-quote-right:before{content:"\f10e"}.fa-spinner:before{content:"\f110"}.fa-circle:before{content:"\f111"}.fa-mail-reply:before,.fa-reply:before{content:"\f112"}.fa-github-alt:before{content:"\f113"}.fa-folder-o:before{content:"\f114"}.fa-folder-open-o:before{content:"\f115"}.fa-smile-o:before{content:"\f118"}.fa-frown-o:before{content:"\f119"}.fa-meh-o:before{content:"\f11a"}.fa-gamepad:before{content:"\f11b"}.fa-keyboard-o:before{content:"\f11c"}.fa-flag-o:before{content:"\f11d"}.fa-flag-checkered:before{content:"\f11e"}.fa-terminal:before{content:"\f120"}.fa-code:before{content:"\f121"}.fa-mail-reply-all:before,.fa-reply-all:before{content:"\f122"}.fa-star-half-empty:before,.fa-star-half-full:before,.fa-star-half-o:before{content:"\f123"}.fa-location-arrow:before{content:"\f124"}.fa-crop:before{content:"\f125"}.fa-code-fork:before{content:"\f126"}.fa-unlink:before,.fa-chain-broken:before{content:"\f127"}.fa-question:before{content:"\f128"}.fa-info:before{content:"\f129"}.fa-exclamation:before{content:"\f12a"}.fa-superscript:before{content:"\f12b"}.fa-subscript:before{content:"\f12c"}.fa-eraser:before{content:"\f12d"}.fa-puzzle-piece:before{content:"\f12e"}.fa-microphone:before{content:"\f130"}.fa-microphone-slash:before{content:"\f131"}.fa-shield:before{content:"\f132"}.fa-calendar-o:before{content:"\f133"}.fa-fire-extinguisher:before{content:"\f134"}.fa-rocket:before{content:"\f135"}.fa-maxcdn:before{content:"\f136"}.fa-chevron-circle-left:before{content:"\f137"}.fa-chevron-circle-right:before{content:"\f138"}.fa-chevron-circle-up:before{content:"\f139"}.fa-chevron-circle-down:before{content:"\f13a"}.fa-html5:before{content:"\f13b"}.fa-css3:before{content:"\f13c"}.fa-anchor:before{content:"\f13d"}.fa-unlock-alt:before{content:"\f13e"}.fa-bullseye:before{content:"\f140"}.fa-ellipsis-h:before{content:"\f141"}.fa-ellipsis-v:before{content:"\f142"}.fa-rss-square:before{content:"\f143"}.fa-play-circle:before{content:"\f144"}.fa-ticket:before{content:"\f145"}.fa-minus-square:before{content:"\f146"}.fa-minus-square-o:before{content:"\f147"}.fa-level-up:before{content:"\f148"}.fa-level-down:before{content:"\f149"}.fa-check-square:before{content:"\f14a"}.fa-pencil-square:before{content:"\f14b"}.fa-external-link-square:before{content:"\f14c"}.fa-share-square:before{content:"\f14d"}.fa-compass:before{content:"\f14e"}.fa-toggle-down:before,.fa-caret-square-o-down:before{content:"\f150"}.fa-toggle-up:before,.fa-caret-square-o-up:before{content:"\f151"}.fa-toggle-right:before,.fa-caret-square-o-right:before{content:"\f152"}.fa-euro:before,.fa-eur:before{content:"\f153"}.fa-gbp:before{content:"\f154"}.fa-dollar:before,.fa-usd:before{content:"\f155"}.fa-rupee:before,.fa-inr:before{content:"\f156"}.fa-cny:before,.fa-rmb:before,.fa-yen:before,.fa-jpy:before{content:"\f157"}.fa-ruble:before,.fa-rouble:before,.fa-rub:before{content:"\f158"}.fa-won:before,.fa-krw:before{content:"\f159"}.fa-bitcoin:before,.fa-btc:before{content:"\f15a"}.fa-file:before{content:"\f15b"}.fa-file-text:before{content:"\f15c"}.fa-sort-alpha-asc:before{content:"\f15d"}.fa-sort-alpha-desc:before{content:"\f15e"}.fa-sort-amount-asc:before{content:"\f160"}.fa-sort-amount-desc:before{content:"\f161"}.fa-sort-numeric-asc:before{content:"\f162"}.fa-sort-numeric-desc:before{content:"\f163"}.fa-thumbs-up:before{content:"\f164"}.fa-thumbs-down:before{content:"\f165"}.fa-youtube-square:before{content:"\f166"}.fa-youtube:before{content:"\f167"}.fa-xing:before{content:"\f168"}.fa-xing-square:before{content:"\f169"}.fa-youtube-play:before{content:"\f16a"}.fa-dropbox:before{content:"\f16b"}.fa-stack-overflow:before{content:"\f16c"}.fa-instagram:before{content:"\f16d"}.fa-flickr:before{content:"\f16e"}.fa-adn:before{content:"\f170"}.fa-bitbucket:before{content:"\f171"}.fa-bitbucket-square:before{content:"\f172"}.fa-tumblr:before{content:"\f173"}.fa-tumblr-square:before{content:"\f174"}.fa-long-arrow-down:before{content:"\f175"}.fa-long-arrow-up:before{content:"\f176"}.fa-long-arrow-left:before{content:"\f177"}.fa-long-arrow-right:before{content:"\f178"}.fa-apple:before{content:"\f179"}.fa-windows:before{content:"\f17a"}.fa-android:before{content:"\f17b"}.fa-linux:before{content:"\f17c"}.fa-dribbble:before{content:"\f17d"}.fa-skype:before{content:"\f17e"}.fa-foursquare:before{content:"\f180"}.fa-trello:before{content:"\f181"}.fa-female:before{content:"\f182"}.fa-male:before{content:"\f183"}.fa-gittip:before,.fa-gratipay:before{content:"\f184"}.fa-sun-o:before{content:"\f185"}.fa-moon-o:before{content:"\f186"}.fa-archive:before{content:"\f187"}.fa-bug:before{content:"\f188"}.fa-vk:before{content:"\f189"}.fa-weibo:before{content:"\f18a"}.fa-renren:before{content:"\f18b"}.fa-pagelines:before{content:"\f18c"}.fa-stack-exchange:before{content:"\f18d"}.fa-arrow-circle-o-right:before{content:"\f18e"}.fa-arrow-circle-o-left:before{content:"\f190"}.fa-toggle-left:before,.fa-caret-square-o-left:before{content:"\f191"}.fa-dot-circle-o:before{content:"\f192"}.fa-wheelchair:before{content:"\f193"}.fa-vimeo-square:before{content:"\f194"}.fa-turkish-lira:before,.fa-try:before{content:"\f195"}.fa-plus-square-o:before{content:"\f196"}.fa-space-shuttle:before{content:"\f197"}.fa-slack:before{content:"\f198"}.fa-envelope-square:before{content:"\f199"}.fa-wordpress:before{content:"\f19a"}.fa-openid:before{content:"\f19b"}.fa-institution:before,.fa-bank:before,.fa-university:before{content:"\f19c"}.fa-mortar-board:before,.fa-graduation-cap:before{content:"\f19d"}.fa-yahoo:before{content:"\f19e"}.fa-google:before{content:"\f1a0"}.fa-reddit:before{content:"\f1a1"}.fa-reddit-square:before{content:"\f1a2"}.fa-stumbleupon-circle:before{content:"\f1a3"}.fa-stumbleupon:before{content:"\f1a4"}.fa-delicious:before{content:"\f1a5"}.fa-digg:before{content:"\f1a6"}.fa-pied-piper-pp:before{content:"\f1a7"}.fa-pied-piper-alt:before{content:"\f1a8"}.fa-drupal:before{content:"\f1a9"}.fa-joomla:before{content:"\f1aa"}.fa-language:before{content:"\f1ab"}.fa-fax:before{content:"\f1ac"}.fa-building:before{content:"\f1ad"}.fa-child:before{content:"\f1ae"}.fa-paw:before{content:"\f1b0"}.fa-spoon:before{content:"\f1b1"}.fa-cube:before{content:"\f1b2"}.fa-cubes:before{content:"\f1b3"}.fa-behance:before{content:"\f1b4"}.fa-behance-square:before{content:"\f1b5"}.fa-steam:before{content:"\f1b6"}.fa-steam-square:before{content:"\f1b7"}.fa-recycle:before{content:"\f1b8"}.fa-automobile:before,.fa-car:before{content:"\f1b9"}.fa-cab:before,.fa-taxi:before{content:"\f1ba"}.fa-tree:before{content:"\f1bb"}.fa-spotify:before{content:"\f1bc"}.fa-deviantart:before{content:"\f1bd"}.fa-soundcloud:before{content:"\f1be"}.fa-database:before{content:"\f1c0"}.fa-file-pdf-o:before{content:"\f1c1"}.fa-file-word-o:before{content:"\f1c2"}.fa-file-excel-o:before{content:"\f1c3"}.fa-file-powerpoint-o:before{content:"\f1c4"}.fa-file-photo-o:before,.fa-file-picture-o:before,.fa-file-image-o:before{content:"\f1c5"}.fa-file-zip-o:before,.fa-file-archive-o:before{content:"\f1c6"}.fa-file-sound-o:before,.fa-file-audio-o:before{content:"\f1c7"}.fa-file-movie-o:before,.fa-file-video-o:before{content:"\f1c8"}.fa-file-code-o:before{content:"\f1c9"}.fa-vine:before{content:"\f1ca"}.fa-codepen:before{content:"\f1cb"}.fa-jsfiddle:before{content:"\f1cc"}.fa-life-bouy:before,.fa-life-buoy:before,.fa-life-saver:before,.fa-support:before,.fa-life-ring:before{content:"\f1cd"}.fa-circle-o-notch:before{content:"\f1ce"}.fa-ra:before,.fa-resistance:before,.fa-rebel:before{content:"\f1d0"}.fa-ge:before,.fa-empire:before{content:"\f1d1"}.fa-git-square:before{content:"\f1d2"}.fa-git:before{content:"\f1d3"}.fa-y-combinator-square:before,.fa-yc-square:before,.fa-hacker-news:before{content:"\f1d4"}.fa-tencent-weibo:before{content:"\f1d5"}.fa-qq:before{content:"\f1d6"}.fa-wechat:before,.fa-weixin:before{content:"\f1d7"}.fa-send:before,.fa-paper-plane:before{content:"\f1d8"}.fa-send-o:before,.fa-paper-plane-o:before{content:"\f1d9"}.fa-history:before{content:"\f1da"}.fa-circle-thin:before{content:"\f1db"}.fa-header:before{content:"\f1dc"}.fa-paragraph:before{content:"\f1dd"}.fa-sliders:before{content:"\f1de"}.fa-share-alt:before{content:"\f1e0"}.fa-share-alt-square:before{content:"\f1e1"}.fa-bomb:before{content:"\f1e2"}.fa-soccer-ball-o:before,.fa-futbol-o:before{content:"\f1e3"}.fa-tty:before{content:"\f1e4"}.fa-binoculars:before{content:"\f1e5"}.fa-plug:before{content:"\f1e6"}.fa-slideshare:before{content:"\f1e7"}.fa-twitch:before{content:"\f1e8"}.fa-yelp:before{content:"\f1e9"}.fa-newspaper-o:before{content:"\f1ea"}.fa-wifi:before{content:"\f1eb"}.fa-calculator:before{content:"\f1ec"}.fa-paypal:before{content:"\f1ed"}.fa-google-wallet:before{content:"\f1ee"}.fa-cc-visa:before{content:"\f1f0"}.fa-cc-mastercard:before{content:"\f1f1"}.fa-cc-discover:before{content:"\f1f2"}.fa-cc-amex:before{content:"\f1f3"}.fa-cc-paypal:before{content:"\f1f4"}.fa-cc-stripe:before{content:"\f1f5"}.fa-bell-slash:before{content:"\f1f6"}.fa-bell-slash-o:before{content:"\f1f7"}.fa-trash:before{content:"\f1f8"}.fa-copyright:before{content:"\f1f9"}.fa-at:before{content:"\f1fa"}.fa-eyedropper:before{content:"\f1fb"}.fa-paint-brush:before{content:"\f1fc"}.fa-birthday-cake:before{content:"\f1fd"}.fa-area-chart:before{content:"\f1fe"}.fa-pie-chart:before{content:"\f200"}.fa-line-chart:before{content:"\f201"}.fa-lastfm:before{content:"\f202"}.fa-lastfm-square:before{content:"\f203"}.fa-toggle-off:before{content:"\f204"}.fa-toggle-on:before{content:"\f205"}.fa-bicycle:before{content:"\f206"}.fa-bus:before{content:"\f207"}.fa-ioxhost:before{content:"\f208"}.fa-angellist:before{content:"\f209"}.fa-cc:before{content:"\f20a"}.fa-shekel:before,.fa-sheqel:before,.fa-ils:before{content:"\f20b"}.fa-meanpath:before{content:"\f20c"}.fa-buysellads:before{content:"\f20d"}.fa-connectdevelop:before{content:"\f20e"}.fa-dashcube:before{content:"\f210"}.fa-forumbee:before{content:"\f211"}.fa-leanpub:before{content:"\f212"}.fa-sellsy:before{content:"\f213"}.fa-shirtsinbulk:before{content:"\f214"}.fa-simplybuilt:before{content:"\f215"}.fa-skyatlas:before{content:"\f216"}.fa-cart-plus:before{content:"\f217"}.fa-cart-arrow-down:before{content:"\f218"}.fa-diamond:before{content:"\f219"}.fa-ship:before{content:"\f21a"}.fa-user-secret:before{content:"\f21b"}.fa-motorcycle:before{content:"\f21c"}.fa-street-view:before{content:"\f21d"}.fa-heartbeat:before{content:"\f21e"}.fa-venus:before{content:"\f221"}.fa-mars:before{content:"\f222"}.fa-mercury:before{content:"\f223"}.fa-intersex:before,.fa-transgender:before{content:"\f224"}.fa-transgender-alt:before{content:"\f225"}.fa-venus-double:before{content:"\f226"}.fa-mars-double:before{content:"\f227"}.fa-venus-mars:before{content:"\f228"}.fa-mars-stroke:before{content:"\f229"}.fa-mars-stroke-v:before{content:"\f22a"}.fa-mars-stroke-h:before{content:"\f22b"}.fa-neuter:before{content:"\f22c"}.fa-genderless:before{content:"\f22d"}.fa-facebook-official:before{content:"\f230"}.fa-pinterest-p:before{content:"\f231"}.fa-whatsapp:before{content:"\f232"}.fa-server:before{content:"\f233"}.fa-user-plus:before{content:"\f234"}.fa-user-times:before{content:"\f235"}.fa-hotel:before,.fa-bed:before{content:"\f236"}.fa-viacoin:before{content:"\f237"}.fa-train:before{content:"\f238"}.fa-subway:before{content:"\f239"}.fa-medium:before{content:"\f23a"}.fa-yc:before,.fa-y-combinator:before{content:"\f23b"}.fa-optin-monster:before{content:"\f23c"}.fa-opencart:before{content:"\f23d"}.fa-expeditedssl:before{content:"\f23e"}.fa-battery-4:before,.fa-battery:before,.fa-battery-full:before{content:"\f240"}.fa-battery-3:before,.fa-battery-three-quarters:before{content:"\f241"}.fa-battery-2:before,.fa-battery-half:before{content:"\f242"}.fa-battery-1:before,.fa-battery-quarter:before{content:"\f243"}.fa-battery-0:before,.fa-battery-empty:before{content:"\f244"}.fa-mouse-pointer:before{content:"\f245"}.fa-i-cursor:before{content:"\f246"}.fa-object-group:before{content:"\f247"}.fa-object-ungroup:before{content:"\f248"}.fa-sticky-note:before{content:"\f249"}.fa-sticky-note-o:before{content:"\f24a"}.fa-cc-jcb:before{content:"\f24b"}.fa-cc-diners-club:before{content:"\f24c"}.fa-clone:before{content:"\f24d"}.fa-balance-scale:before{content:"\f24e"}.fa-hourglass-o:before{content:"\f250"}.fa-hourglass-1:before,.fa-hourglass-start:before{content:"\f251"}.fa-hourglass-2:before,.fa-hourglass-half:before{content:"\f252"}.fa-hourglass-3:before,.fa-hourglass-end:before{content:"\f253"}.fa-hourglass:before{content:"\f254"}.fa-hand-grab-o:before,.fa-hand-rock-o:before{content:"\f255"}.fa-hand-stop-o:before,.fa-hand-paper-o:before{content:"\f256"}.fa-hand-scissors-o:before{content:"\f257"}.fa-hand-lizard-o:before{content:"\f258"}.fa-hand-spock-o:before{content:"\f259"}.fa-hand-pointer-o:before{content:"\f25a"}.fa-hand-peace-o:before{content:"\f25b"}.fa-trademark:before{content:"\f25c"}.fa-registered:before{content:"\f25d"}.fa-creative-commons:before{content:"\f25e"}.fa-gg:before{content:"\f260"}.fa-gg-circle:before{content:"\f261"}.fa-tripadvisor:before{content:"\f262"}.fa-odnoklassniki:before{content:"\f263"}.fa-odnoklassniki-square:before{content:"\f264"}.fa-get-pocket:before{content:"\f265"}.fa-wikipedia-w:before{content:"\f266"}.fa-safari:before{content:"\f267"}.fa-chrome:before{content:"\f268"}.fa-firefox:before{content:"\f269"}.fa-opera:before{content:"\f26a"}.fa-internet-explorer:before{content:"\f26b"}.fa-tv:before,.fa-television:before{content:"\f26c"}.fa-contao:before{content:"\f26d"}.fa-500px:before{content:"\f26e"}.fa-amazon:before{content:"\f270"}.fa-calendar-plus-o:before{content:"\f271"}.fa-calendar-minus-o:before{content:"\f272"}.fa-calendar-times-o:before{content:"\f273"}.fa-calendar-check-o:before{content:"\f274"}.fa-industry:before{content:"\f275"}.fa-map-pin:before{content:"\f276"}.fa-map-signs:before{content:"\f277"}.fa-map-o:before{content:"\f278"}.fa-map:before{content:"\f279"}.fa-commenting:before{content:"\f27a"}.fa-commenting-o:before{content:"\f27b"}.fa-houzz:before{content:"\f27c"}.fa-vimeo:before{content:"\f27d"}.fa-black-tie:before{content:"\f27e"}.fa-fonticons:before{content:"\f280"}.fa-reddit-alien:before{content:"\f281"}.fa-edge:before{content:"\f282"}.fa-credit-card-alt:before{content:"\f283"}.fa-codiepie:before{content:"\f284"}.fa-modx:before{content:"\f285"}.fa-fort-awesome:before{content:"\f286"}.fa-usb:before{content:"\f287"}.fa-product-hunt:before{content:"\f288"}.fa-mixcloud:before{content:"\f289"}.fa-scribd:before{content:"\f28a"}.fa-pause-circle:before{content:"\f28b"}.fa-pause-circle-o:before{content:"\f28c"}.fa-stop-circle:before{content:"\f28d"}.fa-stop-circle-o:before{content:"\f28e"}.fa-shopping-bag:before{content:"\f290"}.fa-shopping-basket:before{content:"\f291"}.fa-hashtag:before{content:"\f292"}.fa-bluetooth:before{content:"\f293"}.fa-bluetooth-b:before{content:"\f294"}.fa-percent:before{content:"\f295"}.fa-gitlab:before{content:"\f296"}.fa-wpbeginner:before{content:"\f297"}.fa-wpforms:before{content:"\f298"}.fa-envira:before{content:"\f299"}.fa-universal-access:before{content:"\f29a"}.fa-wheelchair-alt:before{content:"\f29b"}.fa-question-circle-o:before{content:"\f29c"}.fa-blind:before{content:"\f29d"}.fa-audio-description:before{content:"\f29e"}.fa-volume-control-phone:before{content:"\f2a0"}.fa-braille:before{content:"\f2a1"}.fa-assistive-listening-systems:before{content:"\f2a2"}.fa-asl-interpreting:before,.fa-american-sign-language-interpreting:before{content:"\f2a3"}.fa-deafness:before,.fa-hard-of-hearing:before,.fa-deaf:before{content:"\f2a4"}.fa-glide:before{content:"\f2a5"}.fa-glide-g:before{content:"\f2a6"}.fa-signing:before,.fa-sign-language:before{content:"\f2a7"}.fa-low-vision:before{content:"\f2a8"}.fa-viadeo:before{content:"\f2a9"}.fa-viadeo-square:before{content:"\f2aa"}.fa-snapchat:before{content:"\f2ab"}.fa-snapchat-ghost:before{content:"\f2ac"}.fa-snapchat-square:before{content:"\f2ad"}.fa-pied-piper:before{content:"\f2ae"}.fa-first-order:before{content:"\f2b0"}.fa-yoast:before{content:"\f2b1"}.fa-themeisle:before{content:"\f2b2"}.fa-google-plus-circle:before,.fa-google-plus-official:before{content:"\f2b3"}.fa-fa:before,.fa-font-awesome:before{content:"\f2b4"}.fa-handshake-o:before{content:"\f2b5"}.fa-envelope-open:before{content:"\f2b6"}.fa-envelope-open-o:before{content:"\f2b7"}.fa-linode:before{content:"\f2b8"}.fa-address-book:before{content:"\f2b9"}.fa-address-book-o:before{content:"\f2ba"}.fa-vcard:before,.fa-address-card:before{content:"\f2bb"}.fa-vcard-o:before,.fa-address-card-o:before{content:"\f2bc"}.fa-user-circle:before{content:"\f2bd"}.fa-user-circle-o:before{content:"\f2be"}.fa-user-o:before{content:"\f2c0"}.fa-id-badge:before{content:"\f2c1"}.fa-drivers-license:before,.fa-id-card:before{content:"\f2c2"}.fa-drivers-license-o:before,.fa-id-card-o:before{content:"\f2c3"}.fa-quora:before{content:"\f2c4"}.fa-free-code-camp:before{content:"\f2c5"}.fa-telegram:before{content:"\f2c6"}.fa-thermometer-4:before,.fa-thermometer:before,.fa-thermometer-full:before{content:"\f2c7"}.fa-thermometer-3:before,.fa-thermometer-three-quarters:before{content:"\f2c8"}.fa-thermometer-2:before,.fa-thermometer-half:before{content:"\f2c9"}.fa-thermometer-1:before,.fa-thermometer-quarter:before{content:"\f2ca"}.fa-thermometer-0:before,.fa-thermometer-empty:before{content:"\f2cb"}.fa-shower:before{content:"\f2cc"}.fa-bathtub:before,.fa-s15:before,.fa-bath:before{content:"\f2cd"}.fa-podcast:before{content:"\f2ce"}.fa-window-maximize:before{content:"\f2d0"}.fa-window-minimize:before{content:"\f2d1"}.fa-window-restore:before{content:"\f2d2"}.fa-times-rectangle:before,.fa-window-close:before{content:"\f2d3"}.fa-times-rectangle-o:before,.fa-window-close-o:before{content:"\f2d4"}.fa-bandcamp:before{content:"\f2d5"}.fa-grav:before{content:"\f2d6"}.fa-etsy:before{content:"\f2d7"}.fa-imdb:before{content:"\f2d8"}.fa-ravelry:before{content:"\f2d9"}.fa-eercast:before{content:"\f2da"}.fa-microchip:before{content:"\f2db"}.fa-snowflake-o:before{content:"\f2dc"}.fa-superpowers:before{content:"\f2dd"}.fa-wpexplorer:before{content:"\f2de"}.fa-meetup:before{content:"\f2e0"}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0, 0, 0, 0);border:0}.sr-only-focusable:active,.sr-only-focusable:focus{position:static;width:auto;height:auto;margin:0;overflow:visible;clip:auto} 5 | --------------------------------------------------------------------------------