├── go.mod ├── README.md ├── Dockerfile ├── LICENSE.md ├── .github └── workflows │ └── docker_build.yml └── main.go /go.mod: -------------------------------------------------------------------------------- 1 | module awesomeProject 2 | 3 | go 1.16 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Docker/podman run command: `docker run -d -p 2107:2107 ghcr.io/coayer/there-goes-an-airplane:master` 2 | 3 | Siri shortcut (edit URL from `localhost:2107` to your server's socket address): https://www.icloud.com/shortcuts/47b2c3e92ae44d8193ae97709ed09b90 4 | 5 | `/health` for health check endpoint 6 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:alpine as builder 2 | RUN mkdir /build 3 | ADD . /build/ 4 | WORKDIR /build 5 | RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -ldflags '-extldflags "-static"' -o main . 6 | 7 | FROM scratch 8 | COPY --from=builder /etc/ssl/certs /etc/ssl/certs 9 | COPY --from=builder /build/main /app/ 10 | WORKDIR /app 11 | CMD ["./main"] -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Copeland Royall 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. -------------------------------------------------------------------------------- /.github/workflows/docker_build.yml: -------------------------------------------------------------------------------- 1 | name: Create and publish a Docker image 2 | 3 | on: 4 | push: 5 | branches: ['master'] 6 | 7 | env: 8 | REGISTRY: ghcr.io 9 | IMAGE_NAME: ${{ github.repository }} 10 | 11 | jobs: 12 | build-and-push-image: 13 | runs-on: ubuntu-latest 14 | permissions: 15 | contents: read 16 | packages: write 17 | 18 | steps: 19 | - name: Checkout repository 20 | uses: actions/checkout@v2 21 | 22 | - name: Log in to the Container registry 23 | uses: docker/login-action@f054a8b539a109f9f41c372932f1ae047eff08c9 24 | with: 25 | registry: ${{ env.REGISTRY }} 26 | username: ${{ github.actor }} 27 | password: ${{ secrets.GITHUB_TOKEN }} 28 | 29 | - name: Extract metadata (tags, labels) for Docker 30 | id: meta 31 | uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38 32 | with: 33 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 34 | 35 | - name: Set up QEMU 36 | uses: docker/setup-qemu-action@master 37 | with: 38 | platforms: all 39 | 40 | - name: Set up Docker Buildx 41 | id: buildx 42 | uses: docker/setup-buildx-action@master 43 | 44 | - name: Build and push Docker image 45 | uses: docker/build-push-action@v2 46 | with: 47 | builder: ${{ steps.buildx.outputs.name }} 48 | context: . 49 | file: ./Dockerfile 50 | platforms: linux/amd64,linux/arm64 51 | push: true 52 | tags: ${{ steps.meta.outputs.tags }} 53 | labels: ${{ steps.meta.outputs.labels }} 54 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io/ioutil" 7 | "log" 8 | "math" 9 | "net/http" 10 | "strconv" 11 | "strings" 12 | ) 13 | 14 | var fr24idCookie = getCookie() 15 | 16 | type flightPosition struct { 17 | fr24id string 18 | longitude float64 19 | latitude float64 20 | altitude int 21 | } 22 | 23 | type FlightDetailsJSON struct { 24 | Aircraft struct { 25 | Model struct { 26 | Text string 27 | } 28 | } 29 | 30 | Airline struct { 31 | Name string 32 | } 33 | 34 | Airport struct { 35 | Origin struct { 36 | Name string 37 | } 38 | 39 | Destination struct { 40 | Name string 41 | } 42 | } 43 | } 44 | 45 | func main() { 46 | http.HandleFunc("/", handler) 47 | http.HandleFunc("/health", healthCheckHandler) 48 | 49 | log.Println("Server started") 50 | log.Fatal(http.ListenAndServe(":2107", nil)) 51 | } 52 | 53 | func getCookie() string { 54 | resp, err := http.Get("https://www.flightradar24.com") 55 | if err != nil { 56 | log.Fatal(err) 57 | } 58 | 59 | cookies := resp.Header.Values("set-cookie") 60 | id := strings.Split(cookies[0], ";")[0] 61 | 62 | log.Println("Cookie set to " + id) 63 | return id 64 | } 65 | 66 | func healthCheckHandler(w http.ResponseWriter, request *http.Request) { 67 | _, _ = fmt.Fprint(w, "Neeeoowww\n") 68 | } 69 | 70 | func handler(w http.ResponseWriter, request *http.Request) { 71 | log.Printf("GET received from %s:\n%s", request.UserAgent(), request.URL.RawPath) 72 | 73 | longitude, err := strconv.ParseFloat(request.URL.Query().Get("longitude"), 64) 74 | 75 | if err != nil { 76 | http.Error(w, "Missing longitude parameter", 422) 77 | return 78 | } 79 | 80 | latitude, err := strconv.ParseFloat(request.URL.Query().Get("latitude"), 64) 81 | 82 | if err != nil { 83 | http.Error(w, "Missing latitude parameter", 422) 84 | return 85 | } 86 | 87 | altitude, err := strconv.ParseFloat(request.URL.Query().Get("altitude"), 64) 88 | 89 | if err != nil { 90 | altitude = 0 91 | } 92 | 93 | response := formatFlight(getClosestFlight(longitude, latitude, altitude).fr24id) 94 | if response == " \n" { 95 | response = "No aircraft found nearby\n" 96 | } 97 | 98 | _, err = fmt.Fprint(w, response) 99 | if err != nil { 100 | log.Println(err) 101 | } else { 102 | log.Println(response) 103 | } 104 | } 105 | 106 | func formatFlight(flight string) string { 107 | airline, aircraft, origin, destination := getFlightDetails(flight) 108 | 109 | if origin != "" { 110 | origin = "from " + origin 111 | } 112 | 113 | if destination != "" { 114 | destination = "to " + destination 115 | } 116 | 117 | return fmt.Sprintf("%s %s %s %s\n", airline, aircraft, origin, destination) 118 | } 119 | 120 | func getFlightDetails(fr24id string) (string, string, string, string) { 121 | url := "https://data-live.flightradar24.com/clickhandler/?flight=" + fr24id 122 | 123 | body := httpGet(url) 124 | details := parseFlightDetailsJSON(body) 125 | return details.Airline.Name, details.Aircraft.Model.Text, details.Airport.Origin.Name, details.Airport.Destination.Name 126 | } 127 | 128 | func parseFlightDetailsJSON(bytes []byte) FlightDetailsJSON { 129 | var response FlightDetailsJSON 130 | 131 | if err := json.Unmarshal(bytes, &response); err != nil { 132 | log.Println(err) 133 | } 134 | 135 | return response 136 | } 137 | 138 | func getClosestFlight(longitude float64, latitude float64, altitude float64) flightPosition { 139 | x, y, z := pointToCartesian(longitude, latitude, feetToMeters(altitude)) 140 | 141 | minDistance := math.Inf(1) 142 | closestPlane := flightPosition{} 143 | 144 | flights := getFlights(longitude, latitude) 145 | 146 | for _, flight := range flights { 147 | xFlight, yFlight, zFlight := pointToCartesian(flight.longitude, flight.latitude, float64(flight.altitude)) 148 | distanceToFlight := distance(x, y, z, xFlight, yFlight, zFlight) 149 | if distanceToFlight < minDistance { 150 | minDistance = distanceToFlight 151 | closestPlane = flight 152 | } 153 | } 154 | 155 | return closestPlane 156 | } 157 | 158 | func getFlights(longitude float64, latitude float64) []flightPosition { 159 | const latitudeDelta = 0.5 160 | longitudeDelta := latitudeDelta * math.Cos(latitude) 161 | 162 | url := fmt.Sprintf("https://data-live.flightradar24.com/zones/fcgi/feed.js?faa=1&bounds=%f,%f,%f,%f"+ 163 | "&satellite=1&mlat=1&flarm=1&adsb=1&gnd=0&air=1&vehicles=0&estimated=1&maxage=14400&gliders=0&stats=0", 164 | latitude+latitudeDelta, latitude-latitudeDelta, longitude-longitudeDelta, longitude+longitudeDelta) 165 | 166 | body := httpGet(url) 167 | return parseFlightsJSON(body) 168 | } 169 | 170 | func httpGet(url string) []byte { 171 | client := &http.Client{} 172 | 173 | req, err := http.NewRequest("GET", url, nil) 174 | if err != nil { 175 | log.Fatal(err) 176 | } 177 | 178 | req.Header.Set("Cookie", fr24idCookie) 179 | 180 | resp, err := client.Do(req) 181 | if err != nil { 182 | log.Fatal(err) 183 | } 184 | 185 | defer resp.Body.Close() 186 | body, err := ioutil.ReadAll(resp.Body) 187 | if err != nil { 188 | log.Fatal(err) 189 | } 190 | 191 | return body 192 | } 193 | 194 | func parseFlightsJSON(bytes []byte) []flightPosition { 195 | var response interface{} 196 | if err := json.Unmarshal(bytes, &response); err != nil { 197 | log.Println(err) 198 | } 199 | 200 | flightsData := response.(map[string]interface{}) 201 | delete(flightsData, "full_count") 202 | delete(flightsData, "version") 203 | 204 | var flights []flightPosition 205 | for fr24id, planeData := range flightsData { 206 | status := planeData.([]interface{}) 207 | var plane flightPosition 208 | plane.fr24id = fr24id 209 | plane.latitude = status[1].(float64) 210 | plane.longitude = status[2].(float64) 211 | plane.altitude = int(status[4].(float64)) 212 | flights = append(flights, plane) 213 | } 214 | 215 | return flights 216 | } 217 | 218 | func feetToMeters(feet float64) float64 { 219 | return 0.3048 * feet 220 | } 221 | 222 | func distance(x1 float64, y1 float64, z1 float64, x2 float64, y2 float64, z2 float64) float64 { 223 | return math.Sqrt(math.Pow(x2-x1, 2) + math.Pow(y2-y1, 2) + math.Pow(z2-z1, 2)) 224 | } 225 | 226 | func pointToCartesian(longitude float64, latitude float64, altitude float64) (float64, float64, float64) { 227 | // https://gis.stackexchange.com/a/278753 228 | 229 | N := func(phi float64) float64 { 230 | return 6378137 / math.Sqrt(1-0.006694379990197619*math.Pow(math.Sin(phi), 2)) 231 | } 232 | 233 | x := (N(latitude) + altitude) * math.Cos(latitude) * math.Cos(longitude) 234 | y := (N(latitude) + altitude) * math.Cos(latitude) * math.Sin(longitude) 235 | z := (0.9933056200098024*N(latitude) + altitude) * math.Sin(latitude) 236 | 237 | return x, y, z 238 | } 239 | --------------------------------------------------------------------------------