diff --git a/.DS_Store b/.DS_Store
new file mode 100644
index 0000000..bac3af0
Binary files /dev/null and b/.DS_Store differ
diff --git a/.github/workflows/docker-hub.yml b/.github/workflows/docker-hub.yml
new file mode 100644
index 0000000..00812be
--- /dev/null
+++ b/.github/workflows/docker-hub.yml
@@ -0,0 +1,47 @@
+# This workflow uses actions that are not certified by GitHub.
+# They are provided by a third-party and are governed by
+# separate terms of service, privacy policy, and support
+# documentation.
+
+name: Publish Docker image
+
+on:
+ release:
+ types: [published]
+
+jobs:
+ push_to_registry:
+ name: Push Docker image to Docker Hub
+ runs-on: ubuntu-latest
+ steps:
+ - name: Check out the repo
+ uses: actions/checkout@v2
+
+ - name: Set up QEMU
+ uses: docker/setup-qemu-action@v2
+ with:
+ platforms: 'amd64,arm64'
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v2
+
+ - name: Log in to Docker Hub
+ uses: docker/login-action@f054a8b539a109f9f41c372932f1ae047eff08c9
+ with:
+ username: ${{ secrets.DOCKER_USERNAME }}
+ password: ${{ secrets.DOCKER_PASSWORD }}
+
+ - name: Extract metadata (tags, labels) for Docker
+ id: meta
+ uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38
+ with:
+ images: interaapps/punyshort-redirect-proxy
+
+ - name: Build and push Docker image
+ uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc
+ with:
+ context: .
+ push: true
+ platforms: linux/amd64,linux/arm64
+ tags: ${{ steps.meta.outputs.tags }}
+ labels: ${{ steps.meta.outputs.labels }}
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..30f80e9
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,23 @@
+
+
+# If you prefer the allow list template instead of the deny list, see community template:
+# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
+#
+# Binaries for programs and plugins
+*.exe
+*.exe~
+*.dll
+*.so
+*.dylib
+
+# Test binary, built with `go test -c`
+*.test
+
+# Output of the go coverage tool, specifically when used with LiteIDE
+*.out
+
+# Dependency directories (remove the comment below to include it)
+# vendor/
+
+# Go workspace file
+go.work
\ No newline at end of file
diff --git a/.idea/.gitignore b/.idea/.gitignore
new file mode 100644
index 0000000..13566b8
--- /dev/null
+++ b/.idea/.gitignore
@@ -0,0 +1,8 @@
+# Default ignored files
+/shelf/
+/workspace.xml
+# Editor-based HTTP Client requests
+/httpRequests/
+# Datasource local storage ignored files
+/dataSources/
+/dataSources.local.xml
diff --git a/.idea/modules.xml b/.idea/modules.xml
new file mode 100644
index 0000000..8afe0bb
--- /dev/null
+++ b/.idea/modules.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/redirect-proxy.iml b/.idea/redirect-proxy.iml
new file mode 100644
index 0000000..5e764c4
--- /dev/null
+++ b/.idea/redirect-proxy.iml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..dc18a06
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,16 @@
+FROM golang:1.19
+
+# Set the Current Working Directory inside the container
+WORKDIR /punyshort-proxy
+
+# Copy everything from the current directory to the PWD (Present Working Directory) inside the container
+COPY . .
+
+# Download all the dependencies
+RUN go get -d -v ./...
+
+# Install the package
+RUN go install -v ./...
+
+# Run the executable
+CMD ["punyshort-redirect-proxy"]
\ No newline at end of file
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..98fc4c3
--- /dev/null
+++ b/README.md
@@ -0,0 +1,17 @@
+# Punyshort Redirection Proxy
+
+```bash
+docker run -p 80:80 \
+ -e PUNYSHORT_BASE_URL='https://api.punyshort.ga' \
+ -e PUNYSHORT_ERROR_URL='https://punyshort.ga/error-page' \
+ -e PUNYSHORT_KEY='xxx' \
+ -e PUNYSHORT_IP_FORWARDING='true' \
+ interaapps/punyshort-redirect-proxy
+```
+
+## Environment Variables
+- PUNYSHORT_BASE_URL
+- PUNYSHORT_KEY
+- PUNYSHORT_IP_FORWARDING - Allow x-forwarded-for
+- PUNYSHORT_ERROR_URL
+- PUNYSHORT_USE_SSL: !! Experimental !! - Generating SSL Certificate with Let's Encrypt automatically
\ No newline at end of file
diff --git a/apiclient/apiclient.go b/apiclient/apiclient.go
new file mode 100644
index 0000000..a6bd7d0
--- /dev/null
+++ b/apiclient/apiclient.go
@@ -0,0 +1,72 @@
+package apiclient
+
+import (
+ "bytes"
+ "encoding/json"
+ "io"
+ "io/ioutil"
+ "net/http"
+)
+
+type PunyshortAPI struct {
+ baseUrl string
+ apiToken string
+ httpClient http.Client
+}
+
+func NewClient(baseUrl string, key string) PunyshortAPI {
+ api := PunyshortAPI{
+ baseUrl: "https://api.punyshort.ga",
+ httpClient: http.Client{},
+ }
+ api.SetBaseURL(baseUrl)
+ api.SetApiToken(key)
+ return api
+}
+
+func (apiClient *PunyshortAPI) SetApiToken(token string) {
+ apiClient.apiToken = token
+}
+
+func (apiClient *PunyshortAPI) SetBaseURL(baseURL string) {
+ apiClient.baseUrl = baseURL
+}
+
+func (apiClient PunyshortAPI) Request(method string, url string, body interface{}) (*http.Response, error) {
+ var bodyReader io.Reader = nil
+
+ if body != nil {
+ bodyJson, _ := json.Marshal(body)
+ bodyReader = bytes.NewReader(bodyJson)
+ }
+
+ req, err := http.NewRequest(method, apiClient.baseUrl+url, bodyReader)
+
+ if err != nil {
+ return nil, err
+ }
+ if apiClient.apiToken != "" {
+ req.Header.Set("Authorization", "Bearer "+apiClient.apiToken)
+ }
+ res, err := apiClient.httpClient.Do(req)
+
+ return res, err
+}
+
+func (apiClient PunyshortAPI) RequestMap(method string, url string, body interface{}, ma interface{}) (*http.Response, error) {
+ response, err := apiClient.Request(method, url, body)
+ if err != nil {
+ return response, err
+ }
+ all, err := ioutil.ReadAll(response.Body)
+
+ if err != nil {
+ return nil, err
+ }
+
+ err2 := json.Unmarshal(all, &ma)
+ if err2 != nil {
+ return nil, err2
+ }
+ return response, err
+}
diff --git a/apiclient/shortenlink.go b/apiclient/shortenlink.go
new file mode 100644
index 0000000..1b0e11f
--- /dev/null
+++ b/apiclient/shortenlink.go
@@ -0,0 +1,26 @@
+package apiclient
+
+type ShortenLink struct {
+ Error bool `json:"error"`
+ Exception string `json:"exception"`
+ Domain string `json:"path"`
+ Path string `json:"path"`
+ LongLink string `json:"long_link"`
+}
+
+type RedirectionData struct {
+ Domain string `json:"domain"`
+ Referrer string `json:"referrer"`
+ Ip string `json:"ip"`
+ Path string `json:"path"`
+ UserAgent string `json:"user_agent"`
+}
+
+func (apiClient PunyshortAPI) FollowRedirection(data RedirectionData) (ShortenLink, error) {
+ proxyConfig := ShortenLink{}
+ _, err := apiClient.RequestMap("POST", "/v1/follow", data, &proxyConfig)
+ if err != nil {
+ return ShortenLink{}, err
+ }
+ return proxyConfig, nil
+}
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..da0fd66
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,10 @@
+module punyshort-redirect-proxy
+
+go 1.19
+
+require golang.org/x/crypto v0.5.0
+
+require (
+ golang.org/x/net v0.5.0 // indirect
+ golang.org/x/text v0.6.0 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..bc5ca45
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,6 @@
+golang.org/x/crypto v0.5.0 h1:U/0M97KRkSFvyD/3FSmdP5W5swImpNgle/EHFhOsQPE=
+golang.org/x/crypto v0.5.0/go.mod h1:NK/OQwhpMQP3MwtdjgLlYHnH9ebylxKWv3e0fK+mkQU=
+golang.org/x/net v0.5.0 h1:GyT4nK/YDHSqa1c4753ouYCDajOYKTja9Xb/OHtgvSw=
+golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws=
+golang.org/x/text v0.6.0 h1:3XmdazWV+ubf7QgHSTWeykHOci5oeekaGJBLkrkaw4k=
+golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
diff --git a/helper/redirect_proxy.go b/helper/redirect_proxy.go
new file mode 100644
index 0000000..9545fe8
--- /dev/null
+++ b/helper/redirect_proxy.go
@@ -0,0 +1,42 @@
+package helper
+
+import (
+ "errors"
+ "net"
+ "net/http"
+ "strings"
+)
+
+func GetIP(r *http.Request, allowForwarded bool) (string, error) {
+ ips := ""
+
+ if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" && allowForwarded {
+ ips = forwarded
+ }
+
+ splitIps := strings.Split(ips, ",")
+
+ if len(splitIps) > 0 {
+ // get last IP in list since ELB prepends other user defined IPs, meaning the last one is the actual client IP.
+ netIP := net.ParseIP(splitIps[len(splitIps)-1])
+ if netIP != nil {
+ return netIP.String(), nil
+ }
+ }
+
+ ip, _, err := net.SplitHostPort(r.RemoteAddr)
+ if err != nil {
+ return "", err
+ }
+
+ netIP := net.ParseIP(ip)
+ if netIP != nil {
+ ip := netIP.String()
+ if ip == "::1" {
+ return "127.0.0.1", nil
+ }
+ return ip, nil
+ }
+
+ return "", errors.New("IP not found")
+}
diff --git a/main.go b/main.go
new file mode 100644
index 0000000..bf54afa
--- /dev/null
+++ b/main.go
@@ -0,0 +1,111 @@
+package main
+
+import (
+ "crypto/tls"
+ "golang.org/x/crypto/acme/autocert"
+ "log"
+ "net/http"
+ "os"
+ "punyshort-redirect-proxy/apiclient"
+ "punyshort-redirect-proxy/helper"
+ "strings"
+ "time"
+)
+
+func main() {
+ println("Starting...")
+ apiClient := apiclient.NewClient(os.Getenv("PUNYSHORT_BASE_URL"), os.Getenv("PUNYSHORT_KEY"))
+
+ followRedirect := func(writer http.ResponseWriter, request *http.Request) {
+ ip, _ := helper.GetIP(request, os.Getenv("PUNYSHORT_IP_FORWARDING") == "true")
+
+ shorten, err := apiClient.FollowRedirection(apiclient.RedirectionData{
+ Domain: request.Host,
+ Path: request.URL.Path[1:],
+ Ip: ip,
+ UserAgent: request.UserAgent(),
+ Referrer: request.Referer(),
+ })
+
+ if err != nil {
+ log.Fatalln(err)
+ return
+ }
+
+ if shorten.Error {
+ errorUrl := os.Getenv("PUNYSHORT_ERROR_URL")
+ if errorUrl != "" {
+ writer.Header().Set("Location", errorUrl+"?error="+shorten.Exception)
+ writer.WriteHeader(307)
+ } else {
+ writer.Write([]byte(errorUrl + "Error: " + strings.Replace(shorten.Exception, "Exception", "", 1)))
+ }
+ return
+ }
+
+ writer.Header().Set("Location", shorten.LongLink)
+ writer.WriteHeader(307)
+ }
+
+ useSSL := false
+
+ if useSSL && os.Getenv("PUNYSHORT_USE_SSL") == "true" {
+ useSSL = true
+ }
+
+ mux := http.NewServeMux()
+
+ domains := []string{}
+
+ mux.HandleFunc("/", followRedirect)
+
+ certManager := &autocert.Manager{}
+
+ if useSSL {
+ certManager.Prompt = autocert.AcceptTOS
+ certManager.Cache = autocert.DirCache("letsencrypt")
+
+ server := http.Server{
+ Addr: ":443",
+ Handler: mux,
+ TLSConfig: &tls.Config{GetCertificate: func(info *tls.ClientHelloInfo) (*tls.Certificate, error) {
+
+ println("Serving " + info.ServerName)
+ for _, name := range domains {
+ if name == info.ServerName {
+ return certManager.GetCertificate(info)
+ }
+ }
+
+ println("Adding " + info.ServerName)
+ domains = append(domains, info.ServerName)
+ certManager.HostPolicy = autocert.HostWhitelist(domains...)
+
+ return certManager.GetCertificate(info)
+ }},
+ ReadHeaderTimeout: 60 * time.Second,
+ }
+
+ go server.ListenAndServeTLS("", "")
+ }
+
+ httpMux := http.NewServeMux()
+
+ httpMux.HandleFunc("/", func(writer http.ResponseWriter, request *http.Request) {
+ followRedirect(writer, request)
+
+ if useSSL {
+ host := request.Host
+
+ for _, name := range domains {
+ if name == host {
+ return
+ }
+ }
+ domains = append(domains, host)
+ certManager.HostPolicy = autocert.HostWhitelist(domains...)
+ }
+ })
+
+ log.Fatal(http.ListenAndServe(":80", httpMux))
+}