pax_global_header00006660000000000000000000000064152032223760014514gustar00rootroot0000000000000052 comment=c1964aa44f80012335b3f95fe9dd2aee79d99d83 pat-1.0.0/000077500000000000000000000000001520322237600122765ustar00rootroot00000000000000pat-1.0.0/.docker/000077500000000000000000000000001520322237600136235ustar00rootroot00000000000000pat-1.0.0/.docker/tmp.tar000066400000000000000000000240001520322237600151270ustar00rootroot00000000000000tmp/0001777000000000000000000000000014564577047010404 5ustar rootrootpat-1.0.0/.editorconfig000066400000000000000000000002051520322237600147500ustar00rootroot00000000000000[*.{js,html,scss}] indent_style = space indent_size = 2 insert_final_newline = true trim_trailing_whitespace = true end_of_line = lf pat-1.0.0/.github/000077500000000000000000000000001520322237600136365ustar00rootroot00000000000000pat-1.0.0/.github/workflows/000077500000000000000000000000001520322237600156735ustar00rootroot00000000000000pat-1.0.0/.github/workflows/docker.yaml000066400000000000000000000023101520322237600200220ustar00rootroot00000000000000name: docker-push on: push: branches: - 'ci-test/*' - 'release/*' tags: - 'v*' jobs: docker: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v6 - name: Generate Docker metadata id: meta uses: docker/metadata-action@v6 with: images: la5nta/pat tags: | type=ref,event=branch type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} - name: Set up QEMU uses: docker/setup-qemu-action@v4 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 - name: Login to Docker Hub uses: docker/login-action@v4 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Build and push uses: docker/build-push-action@v7 with: context: . platforms: linux/amd64,linux/386,linux/arm64/v8,linux/arm/v7,linux/arm/v6 push: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} pat-1.0.0/.github/workflows/go.yaml000066400000000000000000000027631520322237600171740ustar00rootroot00000000000000name: build on: push: pull_request: types: [ review_requested ] jobs: build: strategy: matrix: os: [ubuntu-latest, macos-latest, windows-latest] go-version: [ '1.x' ] include: - os: ubuntu-latest go-version: '1.24' gotoolchain: local runs-on: ${{ matrix.os }} env: GOTOOLCHAIN: ${{ matrix.gotoolchain }} steps: - uses: actions/checkout@v6 - name: Setup Go ${{ matrix.go-version }} uses: actions/setup-go@v6 with: go-version: ${{ matrix.go-version }} check-latest: true cache: true - if: ${{ matrix.os == 'ubuntu-latest' }} name: Cache libax25 id: cache-libax25 uses: actions/cache@v5 env: cache-name: cache-libax25 with: path: .build key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ matrix.go-version }}-${{ hashFiles('make.bash') }} restore-keys: ${{ runner.os }}-build-${{ env.cache-name }}-${{ matrix.go-version }}- - if: ${{ matrix.os == 'ubuntu-latest' && steps.cache-libax25.outputs.cache-hit != 'true' }} name: Setup libax25 run: ./make.bash libax25 - name: Display Go version run: go version && go env - name: Vet run: go vet ./... - name: Govulncheck if: ${{ matrix.go-version == '1.x' && matrix.os == 'ubuntu-latest' }} run: go tool govulncheck ./... - name: Build run: ./make.bash pat-1.0.0/.gitignore000066400000000000000000000000601520322237600142620ustar00rootroot00000000000000.build/ pat pat*.pkg docker-data/ .aider* *.swp pat-1.0.0/.gitmodules000066400000000000000000000000001520322237600144410ustar00rootroot00000000000000pat-1.0.0/CONTRIBUTING.md000066400000000000000000000063441520322237600145360ustar00rootroot00000000000000# Contributing to Pat We welcome contributions to Pat of any kind including documentation, tutorials, bug reports, issues, feature requests, feature implementation, pull requests, answering questions on the mailing list, helping to manage issues, etc. If you have any questions about how to contribute or what to contribute, please ask on the [pat-users](https://groups.google.com/group/pat-users) list. ## Issue tracker Guidelines We use github's [issue tracker](https://github.com/la5nta/pat/issues) for keeping track of bugs, features and technical development discussions. To keep the issue tracker nice and tidy, we ask for the following: - Keep one issue per topic: - Don't report multiple bugs in the same issue unless they closely relates to each other. - Open one issue per feature request. - When reporting a bug, please add the following: - Output of pat version (including the SHA). - Operating system and architecture. - What you expected to happen. - What actually happened (including full stack trace and/or error message). - Issues should not be closed until they are either discarded or deployed. This means that code changing issues should not be closed until the changes have been merged to the master branch. ## Code Contribution Guideline We welcome your contributions. To make the process as seamless as possible, we ask for the following: - Go ahead and fork the project and make your changes. We encourage pull requests to discuss code changes. - Base your changes off the "develop" branch, not master - When you’re ready to create a pull request, be sure to: - Run `go fmt` - Consider squashing your commits into a single commit. `git rebase -i`. It's okay to force update your pull request. - **Write a good commit message.** This [blog article](http://chris.beams.io/posts/git-commit/) is a good resource for learning how to write good commit messages, the most important part being that each commit message should have a title/subject in imperative mood starting with a capital letter and no trailing period: *"Return error on wrong use of the Paginator"*, **NOT** *"returning some error."* Also, if your commit references one or more GitHub issues, always end your commit message body with *See #1234* or *Fixes #1234*. Replace *1234* with the GitHub issue ID. The last example will close the issue when the commit is merged into *master*. - Make sure `go test ./...` passes, and `go build` completes. Our [Travis CI loop](https://app.travis-ci.com/github/la5nta/pat) (Linux and OS X) will catch most things that are missing. ## The release process New releases of Pat is done by these steps: 1. All issues targeted by the next release are moved into a milestone with the corresponding version name. 2. A release/*-branch is prepared and VERSION.go is updated. 3. A pull request to *master* is opened. 4. The release-branch is built and tested on *all targeted platforms*. 5. If all status checks (Travis CI) passes, the release-branch is merged into *master* and tagged. 6. Issues in the targeted milestone is either closed or moved to another milestone. The milestone is closed. 7. The various binary packages are built and uploaded to [releases/](https://github.com/la5nta/Pat/releases). pat-1.0.0/Dockerfile000066400000000000000000000013511520322237600142700ustar00rootroot00000000000000FROM golang:alpine AS builder RUN apk add --no-cache git ca-certificates WORKDIR /src ADD go.mod go.sum ./ RUN go mod download ADD . . RUN go build -o /src/pat FROM scratch LABEL org.opencontainers.image.source=https://github.com/la5nta/pat LABEL org.opencontainers.image.description="Pat - A portable Winlink client for amateur radio email" LABEL org.opencontainers.image.licenses=MIT # Make sure we have a /tmp directory with the correct permissions (01777) ADD .docker/tmp.tar / COPY --from=builder /etc/ssl/certs /etc/ssl/certs COPY --from=builder /src/pat /bin/pat USER 65534:65534 WORKDIR /app ENV XDG_CONFIG_HOME=/app ENV XDG_DATA_HOME=/app ENV XDG_STATE_HOME=/app ENV PAT_HTTPADDR=:8080 EXPOSE 8080 ENTRYPOINT ["/bin/pat"] CMD ["http"] pat-1.0.0/LICENSE000066400000000000000000000021121520322237600132770ustar00rootroot00000000000000The MIT License (MIT) Copyright (c) 2020 Martin Hebnes Pedersen (LA5NTA) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. pat-1.0.0/README.md000066400000000000000000000067421520322237600135660ustar00rootroot00000000000000 [![Build status](https://github.com/la5nta/pat/actions/workflows/go.yaml/badge.svg)](https://github.com/la5nta/pat/actions) [![Go Report Card](https://goreportcard.com/badge/github.com/la5nta/pat)](https://goreportcard.com/report/github.com/la5nta/pat) [![Liberapay Patreons](http://img.shields.io/liberapay/patrons/la5nta.svg?logo=liberapay)](https://liberapay.com/la5nta) ## Overview Pat is a cross platform Winlink client with basic messaging capabilities. It is the primary sandbox/prototype application for the [wl2k-go](https://github.com/la5nta/wl2k-go) project, and provides both a command line interface and a responsive (mobile-friendly) web interface. It is mainly developed for Linux, but is also known to run on OS X, Windows and Android. #### Features * Message composer/reader (basic mailbox functionality). * Auto-shrink image attachments. * Post position reports with location from local GPS, browser location or manual entry. * Rig control (using hamlib). * CRON-like syntax for execution of scheduled commands (e.g. QSY or connect). * Built in http-server with web interface (mobile friendly). * Git style command line interface. * Listen for P2P connections using multiple modes concurrently. * AX.25, telnet, PACTOR and ARDOP support. * Experimental gzip message compression (See "Gzip experiment" below). ##### Example ``` martinhpedersen@duo:~$ pat interactive > listen winmor,telnet-p2p,ax25 2015/02/03 10:33:10 Listening for incoming traffic (winmor,telnet-p2p,ax25)... > connect winmor:///LA3F 2015/02/03 10:34:28 Connecting to winmor:LA3F... 2015/02/03 10:34:33 Connected to WINMOR:LA3F RMS Trimode 1.3.3.0 Follo.SE Oslo. Pactor & Winmor Hybrid Gateway LA5NTA has 117 minutes remaining with LA3F [WL2K-2.8.4.8-B2FWIHJM$] Wien CMS via LA3F > >FF FC EM FOYNU8AKXX59 260 221 0 F> 68 1 proposal(s) received Accepting FOYNU8AKXX59 Receiving [//WL2K test til linux] [offset 0] >FF FQ Waiting for remote node to close the connection... > _ ``` ### Gzip experiment Gzip message compression has been added as an experimental B2F extension. The extension is implemented as a backwards compatible alternative to the ancient LZHUF compression. This experiment is enabled by default and sessions between two Pat nodes (or other software supporting this B2F extension) will use gzip compression when transferring messages. For more information, see . ## Copyright/License Copyright (c) 2020 Martin Hebnes Pedersen LA5NTA ### Contributors (alphabetical) * AB3E - Justin Overfelt * DL1THM - Torsten Harenberg * HB9GPA - Matthias Renner * K0RET - Ryan Turner * K0SWE - Chris Keller * KD8DRX - Will Davidson * KE8HMG - Andrew Huebner * KI7RMJ - Rainer Grosskopf * KM6LBU - Robert Hernandez * LA3QMA - Kai Günter Brandt * LA4TTA - Erlend Grimseid * LA5NTA - Martin Hebnes Pedersen * N2YGK - Alan Crosswell * VE7GNU - Doug Collinge * W6IPA - JC Martin * WY2K - Benjamin Seidenberg ## Thanks to The JNOS developers for the properly maintained lzhuf implementation, as well as the original author Haruyasu Yoshizaki. The paclink-unix team (Nicholas S. Castellano N2QZ and others) - reference implementation Amateur Radio Safety Foundation, Inc. - The Winlink 2000 project F6FBB Jean-Paul ROUBELAT - the FBB forwarding protocol _Pat/wl2k-go is not affiliated with The Winlink Development Team nor the Winlink 2000 project [http://winlink.org]._ pat-1.0.0/api/000077500000000000000000000000001520322237600130475ustar00rootroot00000000000000pat-1.0.0/api/api.go000066400000000000000000000350041520322237600141510ustar00rootroot00000000000000// Copyright 2016 Martin Hebnes Pedersen (LA5NTA). All rights reserved. // Use of this source code is governed by the MIT-license that can be // found in the LICENSE file. package api import ( "context" "encoding/json" "fmt" "log" "maps" "net" "net/http" "os" "sort" "strconv" "strings" "time" "github.com/la5nta/pat/app" "github.com/la5nta/pat/cfg" "github.com/la5nta/pat/internal/buildinfo" "github.com/la5nta/pat/internal/gpsd" "github.com/la5nta/pat/internal/patapi" "github.com/la5nta/pat/web" "github.com/gorilla/mux" "github.com/gorilla/websocket" "github.com/hashicorp/go-version" "github.com/la5nta/wl2k-go/catalog" "github.com/la5nta/wl2k-go/transport/ardop" "github.com/n8jja/Pat-Vara/vara" "github.com/pd0mz/go-maidenhead" ) type HTTPError struct { error StatusCode int } func ListenAndServe(ctx context.Context, a *app.App, addr string) error { log.Printf("Starting HTTP service (http://%s)...", addr) if host, _, _ := net.SplitHostPort(addr); host == "" && a.Config().GPSd.EnableHTTP { // TODO: maybe make a popup showing the warning ont the web UI? fmt.Fprintf(os.Stderr, "\nWARNING: You have enable GPSd HTTP endpoint (enable_http). You might expose"+ "\n your current position to anyone who has access to the Pat web interface!\n\n") } handler := NewHandler(a) go handler.wsHub.WatchMBox(ctx, a.Mailbox()) if err := a.EnableWebSocket(ctx, handler.wsHub); err != nil { return err } srv := http.Server{ Addr: addr, Handler: handler, } errs := make(chan error, 1) go func() { errs <- srv.ListenAndServe() }() select { case <-ctx.Done(): log.Println("Shutting down HTTP server...") ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() srv.Shutdown(ctx) return nil case err := <-errs: return err } } type Handler struct { *app.App wsHub *WSHub r *mux.Router } func (h Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.r.ServeHTTP(w, r) } func NewHandler(app *app.App) *Handler { r := mux.NewRouter() h := &Handler{app, NewWSHub(app), r} r.HandleFunc("/api/connect", h.ConnectHandler) r.HandleFunc("/api/disconnect", h.DisconnectHandler) r.HandleFunc("/api/mailbox/{box}", h.mailboxHandler).Methods("GET") r.HandleFunc("/api/mailbox/{box}/{mid}", h.messageHandler).Methods("GET") r.HandleFunc("/api/mailbox/{box}/{mid}", h.messageDeleteHandler).Methods("DELETE") r.HandleFunc("/api/mailbox/{box}/{mid}/{attachment}", h.attachmentHandler).Methods("GET") r.HandleFunc("/api/mailbox/{box}/{mid}/read", h.readHandler).Methods("POST") r.HandleFunc("/api/mailbox/{box}", h.postMessageHandler).Methods("POST") r.HandleFunc("/api/posreport", h.postPositionHandler).Methods("POST") r.HandleFunc("/api/status", h.statusHandler).Methods("GET") r.HandleFunc("/api/current_gps_position", h.positionHandler).Methods("GET") r.HandleFunc("/api/coords_to_locator", h.coordsToLocatorHandler).Methods("POST") r.HandleFunc("/api/qsy", h.qsyHandler).Methods("POST") r.HandleFunc("/api/rmslist", h.rmslistHandler).Methods("GET") r.HandleFunc("/api/config", h.configHandler).Methods("GET", "PUT") r.HandleFunc("/api/config/connect_aliases", h.connectAliasesHandler).Methods("GET") r.HandleFunc("/api/config/connect_aliases/{alias}", h.connectAliasHandler).Methods("GET", "PUT", "DELETE") r.HandleFunc("/api/reload", h.reloadHandler).Methods("POST") r.HandleFunc("/api/bandwidths", h.bandwidthsHandler).Methods("GET") r.HandleFunc("/api/connect_aliases", h.connectAliasesHandler).Methods("GET") // DEPRECATED: Use /api/config/connect_aliases. r.HandleFunc("/api/new-release-check", h.newReleaseCheckHandler).Methods("GET") r.HandleFunc("/api/formcatalog", h.FormsManager().GetFormsCatalogHandler).Methods("GET") r.HandleFunc("/api/form", h.FormsManager().PostFormDataHandler(h.Mailbox().MBoxPath)).Methods("POST") r.HandleFunc("/api/template", h.FormsManager().GetTemplateDataHandler(h.Mailbox().MBoxPath)).Methods("GET") r.HandleFunc("/api/form", h.FormsManager().GetFormDataHandler).Methods("GET") r.HandleFunc("/api/forms", h.FormsManager().GetFormTemplateHandler).Methods("GET") r.PathPrefix("/api/forms/").Handler(http.StripPrefix("/api/forms/", http.HandlerFunc(h.FormsManager().GetFormAssetHandler))).Methods("GET") r.HandleFunc("/api/formsUpdate", h.FormsManager().UpdateFormTemplatesHandler).Methods("POST") r.HandleFunc("/api/winlink-account/password-recovery-email", h.winlinkPasswordRecoveryEmailHandler).Methods("GET", "PUT") r.HandleFunc("/api/winlink-account/registration", h.winlinkAccountRegistrationHandler).Methods("GET", "POST") r.HandleFunc("/ws", h.wsHandler) r.PathPrefix("/ui").Handler(web.UIHandler(h.Options().MyCall)) r.PathPrefix("/dist").Handler(web.DistHandler()) r.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/ui", http.StatusFound) }) return h } func (h Handler) connectAliasesHandler(w http.ResponseWriter, _ *http.Request) { _ = json.NewEncoder(w).Encode(h.Config().ConnectAliases) } func (h Handler) connectAliasHandler(w http.ResponseWriter, r *http.Request) { // Make a copy of the map to avoid concurrenct read/write of the "live" map currentAliases := maps.Clone(h.Config().ConnectAliases) alias := mux.Vars(r)["alias"] switch r.Method { case http.MethodGet: v, ok := currentAliases[alias] if !ok { http.NotFound(w, r) return } json.NewEncoder(w).Encode(v) case http.MethodDelete: delete(currentAliases, alias) if err := h.SetConnectAliases(currentAliases); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.WriteHeader(http.StatusNoContent) case http.MethodPut: var v string if err := json.NewDecoder(r.Body).Decode(&v); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } currentAliases[alias] = v if err := h.SetConnectAliases(currentAliases); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } json.NewEncoder(w).Encode(v) default: w.WriteHeader(http.StatusMethodNotAllowed) } } func (h Handler) postPositionHandler(w http.ResponseWriter, r *http.Request) { var pos catalog.PosReport if err := json.NewDecoder(r.Body).Decode(&pos); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } if pos.Date.IsZero() { pos.Date = time.Now() } msg := pos.Message(h.Options().MyCall) // Post to outbox if err := h.Mailbox().AddOut(msg); err != nil { log.Println(err) http.Error(w, err.Error(), http.StatusInternalServerError) return } fmt.Fprintln(w, "Position update posted") } func (h Handler) wsHandler(w http.ResponseWriter, r *http.Request) { upgrader := websocket.Upgrader{ ReadBufferSize: 1024, WriteBufferSize: 1024, } conn, err := upgrader.Upgrade(w, r, nil) if err != nil { log.Println(err) return } _ = conn.WriteJSON(struct{ MyCall string }{h.Options().MyCall}) h.wsHub.Handle(conn) } func (h Handler) statusHandler(w http.ResponseWriter, _ *http.Request) { _ = json.NewEncoder(w).Encode(h.GetStatus()) } func (h Handler) bandwidthsHandler(w http.ResponseWriter, req *http.Request) { type BandwidthResponse struct { Mode string `json:"mode"` Bandwidths []string `json:"bandwidths"` Default string `json:"default,omitempty"` } mode := strings.ToLower(req.FormValue("mode")) resp := BandwidthResponse{Mode: mode, Bandwidths: []string{}} switch mode { case app.MethodArdop: for _, bw := range ardop.Bandwidths() { resp.Bandwidths = append(resp.Bandwidths, bw.String()) } if bw := h.Config().Ardop.ARQBandwidth; !bw.IsZero() { resp.Default = bw.String() } case app.MethodVaraHF: resp.Bandwidths = vara.Bandwidths() if bw := h.Config().VaraHF.Bandwidth; bw != 0 { resp.Default = fmt.Sprintf("%d", bw) } } _ = json.NewEncoder(w).Encode(resp) } func (h Handler) rmslistHandler(w http.ResponseWriter, req *http.Request) { var ( forceDownload, _ = strconv.ParseBool(req.FormValue("force-download")) band = req.FormValue("band") mode = strings.ToLower(req.FormValue("mode")) prefix = strings.ToUpper(req.FormValue("prefix")) ) list, err := h.ReadRMSList(req.Context(), forceDownload, func(r app.RMS) bool { switch { case r.URL == nil: return false case mode != "" && !r.IsMode(mode): return false case band != "" && !r.IsBand(band): return false case prefix != "" && !strings.HasPrefix(r.Callsign, prefix): return false default: return true } }) if err != nil { log.Println(err) http.Error(w, err.Error(), http.StatusInternalServerError) return } // Sort by predictions if we have more than 1/3 entries with predictions, // otherwise sort by distance. nPredictions := 0 for _, rms := range list { if rms.Prediction != nil { nPredictions++ } } if nPredictions > len(list)/3 { sort.Sort(sort.Reverse(app.ByLinkQuality(list))) } else { sort.Sort(app.ByDist(list)) } json.NewEncoder(w).Encode(list) } func (h Handler) qsyHandler(w http.ResponseWriter, req *http.Request) { type QSYPayload struct { Transport string `json:"transport"` Freq json.Number `json:"freq"` } var payload QSYPayload if err := json.NewDecoder(req.Body).Decode(&payload); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } rig, rigName, ok, err := h.VFOForTransport(payload.Transport) switch { case rigName == "": // Either unsupported mode or no rig configured for this transport w.WriteHeader(http.StatusServiceUnavailable) return case !ok: // A rig is configured, but not loaded properly w.WriteHeader(http.StatusInternalServerError) log.Printf("QSY failed: Hamlib rig '%s' not loaded.", rigName) case err != nil: w.WriteHeader(http.StatusInternalServerError) log.Printf("QSY failed: %v", err) default: if _, _, err := app.SetFreq(rig, string(payload.Freq)); err != nil { w.WriteHeader(http.StatusInternalServerError) log.Printf("QSY failed: %v", err) return } _ = json.NewEncoder(w).Encode(payload) } } func (h Handler) positionHandler(w http.ResponseWriter, req *http.Request) { // Throw error if GPSd http endpoint is not enabled if !h.Config().GPSd.EnableHTTP || h.Config().GPSd.Addr == "" { http.Error(w, "GPSd not enabled or address not set in config file", http.StatusInternalServerError) return } host, _, _ := net.SplitHostPort(req.RemoteAddr) log.Printf("Location data from GPSd served to %s", host) conn, err := gpsd.Dial(h.Config().GPSd.Addr) if err != nil { // do not pass error message to response as GPSd address might be leaked http.Error(w, "GPSd Dial failed", http.StatusInternalServerError) return } defer conn.Close() conn.Watch(true) pos, err := conn.NextPosTimeout(5 * time.Second) if err != nil { http.Error(w, "GPSd get next position failed: "+err.Error(), http.StatusInternalServerError) return } if h.Config().GPSd.UseServerTime { pos.Time = time.Now() } _ = json.NewEncoder(w).Encode(pos) } func (h Handler) DisconnectHandler(w http.ResponseWriter, req *http.Request) { dirty, _ := strconv.ParseBool(req.FormValue("dirty")) if ok := h.AbortActiveConnection(dirty); !ok { w.WriteHeader(http.StatusBadRequest) } _ = json.NewEncoder(w).Encode(struct{}{}) } func (h Handler) ConnectHandler(w http.ResponseWriter, req *http.Request) { connectStr := req.FormValue("url") nMsgs := h.Mailbox().InboxCount() if success := h.Connect(connectStr); !success { http.Error(w, "Session failure", http.StatusInternalServerError) } _ = json.NewEncoder(w).Encode(struct{ NumReceived int }{ h.Mailbox().InboxCount() - nMsgs, }) } func (h Handler) newReleaseCheckHandler(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) defer cancel() release, err := patapi.GetLatestVersion(ctx) if err != nil { http.Error(w, "Error getting latest version: "+err.Error(), http.StatusInternalServerError) return } currentVer, err := version.NewVersion(buildinfo.Version) if err != nil { http.Error(w, "Invalid current version format: "+err.Error(), http.StatusInternalServerError) return } latestVer, err := version.NewVersion(release.Version) if err != nil { http.Error(w, "Invalid latest version format: "+err.Error(), http.StatusInternalServerError) return } if currentVer.Compare(latestVer) >= 0 { w.WriteHeader(http.StatusNoContent) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(release) } func (h Handler) configHandler(w http.ResponseWriter, r *http.Request) { const RedactedPassword = "[REDACTED]" currentConfig, err := app.LoadConfig(h.Options().ConfigPath, cfg.DefaultConfig) if err != nil { log.Println(err) http.Error(w, err.Error(), http.StatusInternalServerError) return } if r.Method == "GET" { if currentConfig.SecureLoginPassword != "" { // Redact password before sending over unsafe channel. currentConfig.SecureLoginPassword = RedactedPassword } json.NewEncoder(w).Encode(currentConfig) return } var newConfig cfg.Config if err := json.NewDecoder(r.Body).Decode(&newConfig); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } // Security: Prevent GPSd EnableHTTP from being changed via web interface if newConfig.GPSd.EnableHTTP != currentConfig.GPSd.EnableHTTP { http.Error(w, "GPSd EnableHTTP setting cannot be changed via web interface for security reasons. Please edit the configuration file manually.", http.StatusForbidden) return } // Reset redacted password if it was unmodified (to retain old value) if newConfig.SecureLoginPassword == RedactedPassword { newConfig.SecureLoginPassword = currentConfig.SecureLoginPassword } if err := app.WriteConfig(newConfig, h.Options().ConfigPath); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) } _ = json.NewEncoder(w).Encode("OK") } func (h Handler) reloadHandler(w http.ResponseWriter, r *http.Request) { if err := h.App.Reload(); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.WriteHeader(http.StatusOK) } func (h Handler) coordsToLocatorHandler(w http.ResponseWriter, r *http.Request) { var req struct { Lat float64 `json:"lat"` Lon float64 `json:"lon"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "Invalid JSON: "+err.Error(), http.StatusBadRequest) return } point := maidenhead.NewPoint(req.Lat, req.Lon) locator, err := point.GridSquare() if err != nil { http.Error(w, "Failed to convert coordinates to locator: "+err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(struct { Locator string `json:"locator"` }{Locator: locator}) } pat-1.0.0/api/mailbox.go000066400000000000000000000277611520322237600150460ustar00rootroot00000000000000package api import ( "bufio" "bytes" "encoding/json" "errors" "fmt" "log" "mime/multipart" "net/http" "net/url" "os" "path" "path/filepath" "sort" "strconv" "strings" "time" "github.com/la5nta/pat/app" "github.com/la5nta/pat/internal/debug" "github.com/la5nta/pat/internal/directories" "github.com/la5nta/wl2k-go/fbb" "github.com/la5nta/wl2k-go/mailbox" "github.com/gorilla/mux" "github.com/microcosm-cc/bluemonday" ) func (h Handler) mailboxHandler(w http.ResponseWriter, r *http.Request) { box := mux.Vars(r)["box"] var messages []*fbb.Message var err error switch box { case "in": messages, err = h.Mailbox().Inbox() case "out": messages, err = h.Mailbox().Outbox() case "sent": messages, err = h.Mailbox().Sent() case "archive": messages, err = h.Mailbox().Archive() default: http.NotFound(w, r) return } if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) log.Println(err) } sort.Sort(sort.Reverse(fbb.ByDate(messages))) jsonSlice := make([]JSONMessage, len(messages)) for i, msg := range messages { jsonSlice[i] = JSONMessage{Message: msg} } _ = json.NewEncoder(w).Encode(jsonSlice) } type JSONMessage struct { *fbb.Message inclBody bool } func (m JSONMessage) MarshalJSON() ([]byte, error) { msg := struct { MID string Date time.Time From fbb.Address To []fbb.Address Cc []fbb.Address Subject string Body string BodyHTML string Files []*fbb.File P2POnly bool Unread bool }{ MID: m.MID(), Date: m.Date(), From: m.From(), To: m.To(), Cc: m.Cc(), Subject: m.Subject(), Files: m.Files(), P2POnly: m.Header.Get("X-P2POnly") == "true", Unread: mailbox.IsUnread(m.Message), } if m.inclBody { msg.Body, _ = m.Body() unsafe := toHTML([]byte(msg.Body)) msg.BodyHTML = string(bluemonday.UGCPolicy().SanitizeBytes(unsafe)) } return json.Marshal(msg) } func (h Handler) messageDeleteHandler(w http.ResponseWriter, r *http.Request) { box, mid := mux.Vars(r)["box"], mux.Vars(r)["mid"] file := filepath.Clean(filepath.Join(h.Mailbox().MBoxPath, box, mid+mailbox.Ext)) if !directories.IsInPath(h.Mailbox().MBoxPath, file) { log.Println("Malicious source path in move:", file) http.Error(w, "malicious source path", http.StatusBadRequest) return } err := os.Remove(file) if os.IsNotExist(err) { http.NotFound(w, r) return } else if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) } _ = json.NewEncoder(w).Encode("OK") } func (h Handler) messageHandler(w http.ResponseWriter, r *http.Request) { box, mid := mux.Vars(r)["box"], mux.Vars(r)["mid"] msg, err := mailbox.OpenMessage(path.Join(h.Mailbox().MBoxPath, box, mid+mailbox.Ext)) if os.IsNotExist(err) { http.NotFound(w, r) return } else if err != nil { log.Println(err) http.Error(w, err.Error(), http.StatusInternalServerError) return } _ = json.NewEncoder(w).Encode(JSONMessage{msg, true}) } func (h Handler) attachmentHandler(w http.ResponseWriter, r *http.Request) { // Attachments are potentially unsanitized HTML and/or javascript. // To avoid XSS, we enable the CSP sandbox directive so that these // attachments can't call other parts of the API (deny same origin). w.Header().Set("Content-Security-Policy", "sandbox allow-forms allow-modals allow-orientation-lock allow-pointer-lock allow-popups allow-popups-to-escape-sandbox allow-presentation allow-scripts") // Allow different sandboxed attachments to refer to each other. // This can be useful to provide rich HTML content as attachments, // without having to bundle it all up in one big file. w.Header().Set("Access-Control-Allow-Origin", "null") box, mid, attachment := mux.Vars(r)["box"], mux.Vars(r)["mid"], mux.Vars(r)["attachment"] inReplyTo := r.URL.Query().Get("in-reply-to") renderToHtml, _ := strconv.ParseBool(r.URL.Query().Get("rendertohtml")) if inReplyTo != "" || renderToHtml { // no-store is needed for displaying and replying to Winlink form-based messages w.Header().Set("Cache-Control", "no-store") } msg, err := mailbox.OpenMessage(path.Join(h.Mailbox().MBoxPath, box, mid+mailbox.Ext)) if os.IsNotExist(err) { http.NotFound(w, r) return } else if err != nil { log.Println(err) http.Error(w, err.Error(), http.StatusInternalServerError) return } // Find and write attachment var found bool for _, f := range msg.Files() { if f.Name() != attachment { continue } found = true if !renderToHtml { http.ServeContent(w, r, f.Name(), msg.Date(), bytes.NewReader(f.Data())) return } var inReplyToMsg *fbb.Message if inReplyTo != "" { var err error inReplyToMsg, err = mailbox.OpenMessage(path.Join(h.Mailbox().MBoxPath, inReplyTo+mailbox.Ext)) if err != nil { err = fmt.Errorf("Failed to load in-reply-to message (%q): %v", inReplyTo, err) log.Println(err) http.Error(w, err.Error(), http.StatusBadRequest) return } } formRendered, err := h.FormsManager().RenderForm(f.Data(), inReplyToMsg, inReplyTo) if err != nil { log.Println(err) http.Error(w, err.Error(), http.StatusInternalServerError) return } http.ServeContent(w, r, f.Name()+".html", msg.Date(), bytes.NewReader([]byte(formRendered))) } if !found { http.NotFound(w, r) } } func (h Handler) readHandler(w http.ResponseWriter, r *http.Request) { var data struct{ Read bool } if err := json.NewDecoder(r.Body).Decode(&data); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) log.Printf("%s %s: %s", r.Method, r.URL.Path, err) return } box, mid := mux.Vars(r)["box"], mux.Vars(r)["mid"] msg, err := mailbox.OpenMessage(path.Join(h.Mailbox().MBoxPath, box, mid+mailbox.Ext)) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } if err := mailbox.SetUnread(msg, !data.Read); err != nil { log.Printf("%s %s: %s", r.Method, r.URL.Path, err) http.Error(w, err.Error(), http.StatusInternalServerError) } } func (h Handler) postMessageHandler(w http.ResponseWriter, r *http.Request) { box := mux.Vars(r)["box"] if box == "out" { h.postOutboundMessageHandler(w, r) return } srcPath := r.Header.Get("X-Pat-SourcePath") if srcPath == "" { http.Error(w, "Not implemented", http.StatusNotImplemented) return } srcPath, _ = url.PathUnescape(strings.TrimPrefix(srcPath, "/api/mailbox/")) srcPath = filepath.Join(h.Mailbox().MBoxPath, srcPath+mailbox.Ext) // Check that we don't escape our mailbox path srcPath = filepath.Clean(srcPath) if !directories.IsInPath(h.Mailbox().MBoxPath, srcPath) { log.Println("Malicious source path in move:", srcPath) http.Error(w, "malicious source path", http.StatusBadRequest) return } targetPath := filepath.Join(h.Mailbox().MBoxPath, box, filepath.Base(srcPath)) if err := os.Rename(srcPath, targetPath); err != nil { log.Println("Could not move message:", err) http.Error(w, err.Error(), http.StatusBadRequest) } else { _ = json.NewEncoder(w).Encode("OK") } } func (h Handler) postOutboundMessageHandler(w http.ResponseWriter, r *http.Request) { err := r.ParseMultipartForm(10 * (1024 ^ 2)) // 10Mb if err != nil { if err := r.ParseForm(); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } } msg := fbb.NewMessage(fbb.Private, h.Options().MyCall) // files if r.MultipartForm != nil { files := r.MultipartForm.File["files"] for _, f := range files { err := addAttachmentFromMultipartFile(msg, f) switch err := err.(type) { case nil: // No problem case HTTPError: http.Error(w, err.Error(), err.StatusCode) default: http.Error(w, err.Error(), http.StatusInternalServerError) } } } if cookie, err := r.Cookie("forminstance"); err == nil { // We must add the attachment files here because it is impossible // for the frontend to dynamically add form files due to legacy // security vulnerabilities in older HTML specs. // The rest of the form data (to, subject, body etc) is added by // the frontend. formData, ok := h.FormsManager().GetPostedFormData(cookie.Value) if !ok { debug.Printf("form instance key (%q) not valid", cookie.Value) http.Error(w, "form instance key not valid", http.StatusBadRequest) return } for _, f := range formData.Attachments { msg.AddFile(f) } } // Other fields if v := r.Form["to"]; len(v) == 1 { addrs := strings.FieldsFunc(v[0], app.SplitFunc) msg.AddTo(addrs...) } if v := r.Form["cc"]; len(v) == 1 { addrs := strings.FieldsFunc(v[0], app.SplitFunc) msg.AddCc(addrs...) } if v := r.Form["subject"]; len(v) == 1 { msg.SetSubject(v[0]) } if v := r.Form["body"]; len(v) == 1 { _ = msg.SetBody(v[0]) } if v := r.Form["p2ponly"]; len(v) == 1 && v[0] != "" { msg.Header.Set("X-P2POnly", "true") } if v := r.Form["date"]; len(v) == 1 { t, err := time.Parse(time.RFC3339, v[0]) if err != nil { log.Printf("Unable to parse message date: %s", err) http.Error(w, err.Error(), http.StatusBadRequest) return } msg.SetDate(t) } else { log.Printf("Missing date value") http.Error(w, "Missing date value", http.StatusBadRequest) return } if err := msg.Validate(); err != nil { http.Error(w, "Validation error: "+err.Error(), http.StatusBadRequest) return } // Post to outbox if err := h.Mailbox().AddOut(msg); err != nil { log.Println(err) http.Error(w, err.Error(), http.StatusInternalServerError) return } w.WriteHeader(http.StatusCreated) var buf bytes.Buffer _ = msg.Write(&buf) _, _ = fmt.Fprintf(w, "Message posted (%.2f kB)", float64(buf.Len()/1024)) } func addAttachmentFromMultipartFile(msg *fbb.Message, f *multipart.FileHeader) error { // For some unknown reason, we receive this empty unnamed file when no // attachment is provided. Prior to Go 1.10, this was filtered by // multipart.Reader. if f.Size == 0 && f.Filename == "" { return nil } if f.Filename == "" { err := errors.New("missing attachment name") return HTTPError{err, http.StatusBadRequest} } file, err := f.Open() if err != nil { return HTTPError{err, http.StatusInternalServerError} } defer file.Close() if err := app.AddAttachment(msg, f.Filename, f.Header.Get("Content-Type"), file); err != nil { return HTTPError{err, http.StatusInternalServerError} } return nil } // toHTML takes the given body and turns it into proper html with // paragraphs, blockquote, and
line breaks. func toHTML(body []byte) []byte { buf := bytes.NewBuffer(body) var out bytes.Buffer _, _ = fmt.Fprint(&out, "

") scanner := bufio.NewScanner(buf) var blockquote int for scanner.Scan() { line := scanner.Text() if len(line) == 0 { _, _ = fmt.Fprint(&out, "

") continue } depth := blockquoteDepth(line) for depth != blockquote { if depth > blockquote { _, _ = fmt.Fprintf(&out, "

") blockquote++ } else { _, _ = fmt.Fprintf(&out, "

") blockquote-- } } line = line[depth:] line = htmlEncode(line) line = linkify(line) _, _ = fmt.Fprint(&out, line+"\n") } for ; blockquote > 0; blockquote-- { _, _ = fmt.Fprintf(&out, "

") } _, _ = fmt.Fprint(&out, "

") return out.Bytes() } // blcokquoteDepth counts the number of '>' at the beginning of the string. func blockquoteDepth(str string) (n int) { for _, c := range str { if c != '>' { break } n++ } return } // htmlEncode encodes html characters func htmlEncode(str string) string { str = strings.ReplaceAll(str, ">", ">") str = strings.ReplaceAll(str, "<", "<") return str } // linkify detects url's in the given string and adds %s%s`, str[:start], link, str[start:end], linkify(str[end:])) } pat-1.0.0/api/types/000077500000000000000000000000001520322237600142135ustar00rootroot00000000000000pat-1.0.0/api/types/prompt.go000066400000000000000000000014651520322237600160710ustar00rootroot00000000000000package types type PromptKind string const ( PromptKindPassword PromptKind = "password" PromptKindMultiSelect PromptKind = "multi-select" PromptKindBusyChannel PromptKind = "busy-channel" PromptKindPreAccountActivation PromptKind = "pre-account-activation" PromptKindAccountActivation PromptKind = "account-activation" ) type Prompt struct { ID string `json:"id"` Kind PromptKind `json:"kind"` Message string `json:"message"` Options []PromptOption `json:"options,omitempty"` // For multi-select } type PromptOption struct { Value string `json:"value"` Desc string `json:"desc,omitempty"` Checked bool `json:"checked"` } type PromptResponse struct { ID string `json:"id"` Value string `json:"value"` Err error `json:"error"` } pat-1.0.0/api/types/types.go000066400000000000000000000016451520322237600157140ustar00rootroot00000000000000package types // Status represents a status report as sent to the Web GUI type Status struct { ActiveListeners []string `json:"active_listeners"` Connected bool `json:"connected"` Dialing bool `json:"dialing"` RemoteAddr string `json:"remote_addr"` HTTPClients []string `json:"http_clients"` ConfigHash string `json:"config_hash"` } // Progress represents a progress report as sent to the Web GUI type Progress struct { BytesTransferred int `json:"bytes_transferred"` BytesTotal int `json:"bytes_total"` MID string `json:"mid"` Subject string `json:"subject"` Receiving bool `json:"receiving"` Sending bool `json:"sending"` Done bool `json:"done"` } // Notification represents a desktop notification as sent to the Web GUI type Notification struct { Title string `json:"title"` Body string `json:"body"` } pat-1.0.0/api/winlink_account.go000066400000000000000000000051441520322237600165710ustar00rootroot00000000000000package api import ( "encoding/json" "net/http" "github.com/la5nta/pat/internal/cmsapi" ) func (h Handler) winlinkAccountRegistrationHandler(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: callsign := h.Options().MyCall if v := r.URL.Query().Get("callsign"); v != "" { callsign = v } if callsign == "" { http.Error(w, "Empty callsign", http.StatusBadRequest) return } exists, err := cmsapi.AccountExists(r.Context(), callsign) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } json.NewEncoder(w).Encode(struct { Callsign string `json:"callsign"` Exists bool `json:"exists"` }{callsign, exists}) case http.MethodPost: type body struct { Callsign string `json:"callsign"` Password string `json:"password"` RecoveryEmail string `json:"recovery_email"` // optional } var v body if err := json.NewDecoder(r.Body).Decode(&v); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } switch { case v.Callsign == "": http.Error(w, "Empty callsign", http.StatusBadRequest) return case len(v.Password) < 6 || len(v.Password) > 12: http.Error(w, "Password must be 6-12 characters", http.StatusBadRequest) return } if err := cmsapi.AccountAdd(r.Context(), v.Callsign, v.Password, v.RecoveryEmail); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } json.NewEncoder(w).Encode(v) } } func (h Handler) winlinkPasswordRecoveryEmailHandler(w http.ResponseWriter, r *http.Request) { type body struct { RecoveryEmail string `json:"recovery_email"` } var ( ctx = r.Context() callsign = h.Options().MyCall password = h.Config().SecureLoginPassword ) if callsign == "" || password == "" { http.Error(w, "Missing callsign or password in config", http.StatusBadRequest) return } switch r.Method { case http.MethodGet: email, err := cmsapi.PasswordRecoveryEmailGet(ctx, callsign, password) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(body{RecoveryEmail: email}) case http.MethodPut: var v body if err := json.NewDecoder(r.Body).Decode(&v); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } if err := cmsapi.PasswordRecoveryEmailSet(ctx, callsign, password, v.RecoveryEmail); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(v) } } pat-1.0.0/api/wshub.go000066400000000000000000000165471520322237600145430ustar00rootroot00000000000000// Copyright 2016 Martin Hebnes Pedersen (LA5NTA). All rights reserved. // Use of this source code is governed by the MIT-license that can be // found in the LICENSE file. package api import ( "bufio" "context" "encoding/json" "errors" "io" "log" "os" "path" "runtime" "sync" "time" "github.com/fsnotify/fsnotify" "github.com/gorilla/websocket" "github.com/la5nta/pat/api/types" "github.com/la5nta/pat/app" "github.com/la5nta/pat/internal/debug" "github.com/la5nta/pat/internal/osutil" "github.com/la5nta/wl2k-go/mailbox" ) const KeepaliveInterval = 4 * time.Minute // WSConn represent one connection in the WSHub pool type WSConn struct { conn *websocket.Conn out chan interface{} } // WSHub is a hub for broadcasting data to several websocket connections type WSHub struct { *app.App mu sync.Mutex pool map[*WSConn]struct{} } func NewWSHub(app *app.App) *WSHub { return &WSHub{App: app, pool: map[*WSConn]struct{}{}} } func (w *WSHub) UpdateStatus() { w.WriteJSON(struct{ Status types.Status }{w.GetStatus()}) } func (w *WSHub) WriteProgress(p types.Progress) { w.WriteJSON(struct{ Progress types.Progress }{p}) } func (w *WSHub) WriteNotification(n types.Notification) { w.WriteJSON(struct{ Notification types.Notification }{n}) } func (w *WSHub) Prompt(p app.Prompt) { w.WriteJSON(struct{ Prompt types.Prompt }{p.Prompt}) go func() { <-p.Done(); w.WriteJSON(struct{ PromptAbort types.Prompt }{p.Prompt}) }() } func (w *WSHub) WriteJSON(v interface{}) { w.mu.Lock() defer w.mu.Unlock() for c := range w.pool { select { case c.out <- v: case <-time.After(3 * time.Second): debug.Printf("Closing one unresponsive web socket") c.conn.Close() delete(w.pool, c) } } } // Close closes all active WebSocket connections in the hub. // // The hub should not be used after calling Close. func (w *WSHub) Close() error { w.mu.Lock() defer w.mu.Unlock() if w.pool == nil { return nil } for conn, _ := range w.pool { // Closing the connection should trigger the deferred cleanup in the Handle method for that client, // which includes removing it from the pool. err := conn.conn.Close() if err != nil { debug.Printf("Error closing WebSocket connection %s: %v", conn.conn.RemoteAddr(), err) } } w.pool = nil return nil } func (w *WSHub) NumClients() int { return len(w.ClientAddrs()) } func (w *WSHub) ClientAddrs() []string { w.mu.Lock() defer w.mu.Unlock() addrs := make([]string, 0, len(w.pool)) for c := range w.pool { addrs = append(addrs, c.conn.RemoteAddr().String()) } return addrs } func (w *WSHub) WatchMBox(ctx context.Context, mbox *mailbox.DirHandler) { // Maximise ulimit -n: // fsnotify opens a file descriptor for every file in the directories it watches, which // may more files than the current soft limit. The is especially a problem on macOS which // has a default soft limit of only 256 files. Windows does not have a such a limit. if runtime.GOOS != "windows" { if err := osutil.RaiseOpenFileLimit(4096); err != nil { log.Printf("Unable to raise open file limit: %v", err) } } fsWatcher, err := fsnotify.NewWatcher() if err != nil { log.Println("Unable to start fs watcher: ", err) return } defer fsWatcher.Close() // Add all directories in the mailbox to the watcher for _, dir := range []string{mailbox.DIR_INBOX, mailbox.DIR_OUTBOX, mailbox.DIR_SENT, mailbox.DIR_ARCHIVE} { p := path.Join(mbox.MBoxPath, dir) debug.Printf("Adding '%s' to fs watcher", p) if err := fsWatcher.Add(p); err != nil { log.Printf("Unable to add path '%s' to fs watcher: %v", p, err) } } // Listen for filesystem events and broadcast updates to all clients for { select { case <-ctx.Done(): return case e := <-fsWatcher.Events: if e.Op == fsnotify.Chmod { continue } // Make sure we don't send many of these events over a short period. drainUntilSilence(fsWatcher, 100*time.Millisecond) w.WriteJSON(struct { UpdateMailbox bool }{true}) case err := <-fsWatcher.Errors: log.Println(err) } } } // Handle adds a new websocket to the hub // // It will block until the client either stops responding or closes the connection. func (w *WSHub) Handle(conn *websocket.Conn) { debug.Printf("ws[%s] subscribed", conn.RemoteAddr()) c := &WSConn{ conn: conn, out: make(chan interface{}, 1), } w.mu.Lock() w.pool[c] = struct{}{} w.mu.Unlock() // Initial status update // (broadcasted as it includes info to other clients about this new one) w.UpdateStatus() quit := w.wsReadLoop(conn) // Disconnect and remove client when this handler returns. defer func() { debug.Printf("ws[%s] unsubscribing...", conn.RemoteAddr()) c.conn.Close() w.mu.Lock() delete(w.pool, c) w.mu.Unlock() w.UpdateStatus() debug.Printf("ws[%s] unsubscribed", conn.RemoteAddr()) }() lines, done, err := tailFile(w.Options().LogPath) if err != nil { log.Println(err) return } defer close(done) ticker := time.NewTicker(KeepaliveInterval) defer ticker.Stop() for { var err error c.conn.SetWriteDeadline(time.Time{}) select { case <-ticker.C: debug.Printf("ws[%s] ping", conn.RemoteAddr()) c.conn.SetWriteDeadline(time.Now().Add(5 * time.Second)) err = c.conn.WriteJSON(struct { Ping bool }{true}) case line := <-lines: c.conn.SetWriteDeadline(time.Now().Add(5 * time.Second)) err = c.conn.WriteJSON(struct { LogLine string }{string(line)}) case v := <-c.out: c.conn.SetWriteDeadline(time.Now().Add(5 * time.Second)) err = c.conn.WriteJSON(v) case <-quit: // The read loop failed/disconnected. Abort. return } if err != nil { debug.Printf("ws[%s] write error: %v", conn.RemoteAddr(), err) return } } } // drainEvents reads from w.Events and blocks until the channel has been silent for at least 50 ms. func drainUntilSilence(w *fsnotify.Watcher, silenceDur time.Duration) { timer := time.NewTimer(silenceDur) defer timer.Stop() for { select { case <-w.Events: if !timer.Stop() { <-timer.C } timer.Reset(silenceDur) case <-timer.C: return } } } // Expects the file to never get renamed/truncated or deleted func tailFile(path string) (<-chan []byte, chan<- struct{}, error) { lines := make(chan []byte) done := make(chan struct{}) file, err := os.Open(path) if err != nil { return nil, nil, err } go func() { rd := bufio.NewReader(file) for { data, _, err := rd.ReadLine() if errors.Is(err, io.EOF) { time.Sleep(time.Millisecond * 100) continue } select { case <-done: file.Close() return case lines <- data: } } }() return lines, done, nil } func (w *WSHub) handleWSMessage(v map[string]json.RawMessage) { raw, ok := v["prompt_response"] if !ok { return } var resp app.PromptResponse json.Unmarshal(raw, &resp) w.PromptHub().Respond(resp.ID, resp.Value, resp.Err) } func (w *WSHub) wsReadLoop(c *websocket.Conn) <-chan struct{} { quit := make(chan struct{}) go func() { for { v := map[string]json.RawMessage{} // We should at least get a ping response once per KeepaliveInterval. c.SetReadDeadline(time.Now().Add(KeepaliveInterval + 10*time.Second)) err := c.ReadJSON(&v) if err != nil { debug.Printf("ws[%s] read error: %v", c.RemoteAddr(), err) close(quit) return } if _, ok := v["Pong"]; ok { // That's the Ping response. debug.Printf("ws[%s] pong", c.RemoteAddr()) continue } go w.handleWSMessage(v) } }() return quit } pat-1.0.0/app/000077500000000000000000000000001520322237600130565ustar00rootroot00000000000000pat-1.0.0/app/account_activation.go000066400000000000000000000073661520322237600172760ustar00rootroot00000000000000package app import ( "context" "errors" "regexp" "strings" "time" "github.com/la5nta/pat/internal/cmsapi" "github.com/la5nta/pat/internal/debug" "github.com/la5nta/wl2k-go/fbb" ) func (a *App) promptUnconfirmedAccount() (confirmed bool) { accountConfirmed := func() bool { ctx, cancel := context.WithTimeout(context.TODO(), 10*time.Second) defer cancel() exists, err := cmsapi.AccountExists(ctx, a.options.MyCall) switch { case err != nil: // API is unavailable. Use heuristic method based on message count. debug.Printf("Using heuristic method. API call failed: %v", err) if a.Mailbox().InboxCount() != 0 || a.Mailbox().SentCount() != 0 || a.Mailbox().ArchiveCount() != 0 { return true } case exists: // API confirmed active account. debug.Printf("API confirmed active account") return true } debug.Printf("Unable to confirm active account. Prompting user...") resp := <-a.promptHub.Prompt( context.Background(), 2*time.Minute, PromptKindPreAccountActivation, "Winlink Account activation", ) return resp.Value == "confirmed" } debug.Printf("Checking for active Winlink account...") err := DoIfElapsed(a.options.MyCall, "account-confirmed", 100*24*time.Hour, func() error { if accountConfirmed() { return nil // Account is confirmed. Persist state with TTL. } return errors.New("account not confirmed") }) debug.Printf("Account confirmation error: %v", err) return err == nil || err == ErrRateLimited } func isServiceMessage(m *fbb.Message) bool { return m.From().EqualString("SERVICE") } func isAccountActivation(from fbb.Address, subject string) bool { return from.EqualString("SERVICE") && strings.EqualFold(strings.TrimSpace(subject), "Your New Winlink Account") } var ( reSentenceSplit = regexp.MustCompile(`[.!?]`) rePassword = regexp.MustCompile("['\"`]([a-zA-Z0-9]{6,12})['\"`]") ) func isAccountActivationMessage(m *fbb.Message) (t bool, password string) { if !isAccountActivation(m.From(), m.Subject()) { return false, "" } body, _ := m.Body() // Search the message for a sentence that includes the word "password" and // contains a quoted string of 6-12 alphanumeric characters that is not the // users callsign. sentences := reSentenceSplit.Split(body, -1) for _, sentence := range sentences { if !strings.Contains(strings.ToLower(sentence), "password") { continue } matches := rePassword.FindStringSubmatch(sentence) if len(matches) > 1 && matches[1] != m.To()[0].String() { return true, matches[1] } } return true, "" // Is activation message, but no password was identified. } func mockNewAccountMsg() *fbb.Message { m := fbb.NewMessage(fbb.Private, "SERVICE") m.AddTo("LA5NTA") m.SetSubject("Your New Winlink Account") m.SetBody(`A new Winlink account for 'LA5NTA' has been activated. The next time you connect to a Winlink server or gateway you will be required to use 'K1CHN7' as your account password (no quotes). In Winlink Express you'll find the option for configuring your password under "Winlink Express Setup" in the "Files" menu. In Airmail it is called the "Radio Password" and is on the "Tools | Options | Settings" Tab. For other programs, consult the appropriate documentation or help file. You can manage your Winlink account (to include changing your password) by logging on to the Winlink web site at https://www.winlink.org. It is important that you establish a password recovery address as well! This address is used to send you your password if you happen to forget it. You can manage your password recovery address either at the Winlink web site or by sending an OPTIONS message to SYSTEM. See WL2K_Help category, item USER_OPTIONS for details. Please print and save this message in case you forget your password. Thanks for using Winlink.`) return m } pat-1.0.0/app/account_activation_test.go000066400000000000000000000010231520322237600203150ustar00rootroot00000000000000// Copyright 2021 Martin Hebnes Pedersen (LA5NTA). All rights reserved. // Use of this source code is governed by the MIT-license that can be // found in the LICENSE file. package app import "testing" func TestIsAccountActivationMessage(t *testing.T) { msg := mockNewAccountMsg() isActivation, password := isAccountActivationMessage(msg) if !isActivation { t.Errorf("Expected isActivation to be true, but was false") } if password != "K1CHN7" { t.Errorf("Expected password to be 'K1CHN7', but was '%s'", password) } } pat-1.0.0/app/app.go000066400000000000000000000333071520322237600141730ustar00rootroot00000000000000// Copyright 2016 Martin Hebnes Pedersen (LA5NTA). All rights reserved. // Use of this source code is governed by the MIT-license that can be // found in the LICENSE file. // Package app implements the core functionality shared by cli and api. package app import ( "context" "crypto/sha1" "encoding/json" "fmt" "io" "log" "net" "os" "path/filepath" "sort" "strings" "time" "github.com/harenber/ptc-go/v2/pactor" "github.com/la5nta/pat/api/types" "github.com/la5nta/pat/cfg" "github.com/la5nta/pat/internal/buildinfo" "github.com/la5nta/pat/internal/debug" "github.com/la5nta/pat/internal/directories" "github.com/la5nta/pat/internal/forms" "github.com/la5nta/pat/internal/propagation" "github.com/la5nta/wl2k-go/fbb" "github.com/la5nta/wl2k-go/mailbox" "github.com/la5nta/wl2k-go/rigcontrol/hamlib" "github.com/la5nta/wl2k-go/transport" "github.com/la5nta/wl2k-go/transport/ardop" "github.com/la5nta/wl2k-go/transport/ax25" "github.com/la5nta/wl2k-go/transport/ax25/agwpe" "github.com/n8jja/Pat-Vara/vara" ) const ( MethodArdop = "ardop" MethodTelnet = "telnet" MethodPactor = "pactor" MethodVaraHF = "varahf" MethodVaraFM = "varafm" MethodAX25 = "ax25" MethodAX25AGWPE = MethodAX25 + "+agwpe" MethodAX25Linux = MethodAX25 + "+linux" MethodAX25SerialTNC = MethodAX25 + "+serial-tnc" // TODO: Remove after some release cycles (2023-05-21) MethodSerialTNCDeprecated = "serial-tnc" ) type Options struct { IgnoreBusy bool // Move to connect? SendOnly bool // Move to connect? RadioOnly bool Robust bool MyCall string Listen string MailboxPath string ConfigPath string PrehooksPath string LogPath string EventLogPath string FormsPath string } type App struct { options Options config cfg.Config OnReload func() error mbox *mailbox.DirHandler formsMgr *forms.Manager exchangeChan chan ex // The channel that the exchange loop is listening on exchangeConn net.Conn // Pointer to the active session connection (exchange) dialing *transport.URL // The connect URL currently being dialed (if any) dialCancelFunc func() // Context cancellation function for aborting while dialing. listenHub *ListenerHub promptHub *PromptHub websocketHub WSHub // Persistent modem connections ardop *ardop.TNC agwpe *agwpe.TNCPort pactor *pactor.Modem varaHF *vara.Modem varaFM *vara.Modem rigs map[string]rig predictor propagation.Predictor eventLog *EventLogger termWriter io.WriteCloser // termWriter writes to both stdout and the log file (web gui echoes log file) } // A rig holds a VFO and a closer for the underlying rig connection. type rig struct { hamlib.VFO io.Closer } func New(opts Options) *App { opts.MailboxPath = filepath.Clean(opts.MailboxPath) opts.FormsPath = filepath.Clean(opts.FormsPath) opts.ConfigPath = filepath.Clean(opts.ConfigPath) opts.LogPath = filepath.Clean(opts.LogPath) opts.EventLogPath = filepath.Clean(opts.EventLogPath) return &App{options: opts, websocketHub: noopWSSocket{}} } func (a *App) Mailbox() *mailbox.DirHandler { return a.mbox } func (a *App) FormsManager() *forms.Manager { return a.formsMgr } func (a *App) Config() cfg.Config { return a.config } func (a *App) Options() Options { return a.options } func (a *App) PromptHub() *PromptHub { return a.promptHub } func (a *App) Reload() error { return a.OnReload() } func (a *App) VFOForRig(rig string) (hamlib.VFO, bool) { r, ok := a.rigs[rig]; return r, ok } // Locator implements forms.LocatorProvider func (a *App) Locator() string { return a.config.Locator } func (a *App) VFOForTransport(transport string) (vfo hamlib.VFO, rigName string, ok bool, err error) { var rig string switch { case transport == MethodArdop: rig = a.config.Ardop.Rig case transport == MethodAX25, strings.HasPrefix(transport, MethodAX25+"+"): rig = a.config.AX25.Rig case transport == MethodPactor: rig = a.config.Pactor.Rig case transport == MethodVaraHF: rig = a.config.VaraHF.Rig case transport == MethodVaraFM: rig = a.config.VaraFM.Rig default: return vfo, "", false, fmt.Errorf("not supported with transport '%s'", transport) } if rig == "" { return vfo, "", false, fmt.Errorf("missing rig reference in config section for %s", transport) } vfo, ok = a.VFOForRig(rig) return vfo, rig, ok, nil } func (a *App) EnableWebSocket(ctx context.Context, wsHub WSHub) error { a.websocketHub = wsHub a.promptHub.AddPrompter(wsHub) return nil } func (a *App) Run(ctx context.Context, cmd Command, args []string) { debug.Printf("Version: %s", buildinfo.VersionString()) debug.Printf("Command: %s %v", cmd.Str, args) debug.Printf("Mailbox dir is\t'%s'", a.options.MailboxPath) debug.Printf("Forms dir is\t'%s'", a.options.FormsPath) debug.Printf("Config file is\t'%s'", a.options.ConfigPath) debug.Printf("Log file is \t'%s'", a.options.LogPath) debug.Printf("Event log file is\t'%s'", a.options.EventLogPath) directories.MigrateLegacyDataDir() a.listenHub = NewListenerHub(a) a.listenHub.websocketHub = a.websocketHub a.promptHub = NewPromptHub() // Skip initialization for some commands switch cmd.Str { case "configure", "version": cmd.HandleFunc(ctx, a, args) return } // Enable the GZIP extension experiment by default if _, ok := os.LookupEnv("GZIP_EXPERIMENT"); !ok { os.Setenv("GZIP_EXPERIMENT", "1") } os.Setenv("PATH", fmt.Sprintf(`%s%c%s`, a.options.PrehooksPath, os.PathListSeparator, os.Getenv("PATH"))) // Parse configuration file var err error a.config, err = LoadConfig(a.options.ConfigPath, cfg.DefaultConfig) if err != nil { log.Fatalf("Unable to load/write config: %s", err) } // Initialize logger f, err := os.Create(a.options.LogPath) if err != nil { log.Fatalf("Unable to create log file at %s: %v", a.options.LogPath, err) } a.termWriter = struct { io.Writer io.Closer }{io.MultiWriter(f, os.Stdout), f} log.SetOutput(io.MultiWriter(f, os.Stderr)) // web gui echoes the log file a.eventLog, err = NewEventLogger(a.options.EventLogPath) if err != nil { log.Fatal("Unable to open event log file:", err) } // Read command line options from config if unset if a.options.MyCall == "" && a.config.MyCall == "" { fmt.Fprint(os.Stderr, "Missing mycall\n") if cmd.Str != "http" { os.Exit(1) } } else if a.options.MyCall == "" { a.options.MyCall = a.config.MyCall } // Ensure mycall is all upper case. a.options.MyCall = strings.ToUpper(a.options.MyCall) // Don't use config password if we don't use config mycall if !strings.EqualFold(a.options.MyCall, a.config.MyCall) { a.config.SecureLoginPassword = "" } if a.options.Listen == "" && len(a.config.Listen) > 0 { a.options.Listen = strings.Join(a.config.Listen, ",") } // Initialize HF prediction engine (VOACAP) switch p := a.config.Prediction; p.Engine { case cfg.PredictionEngineVOACAP, cfg.PredictionEngineAuto: voacap, err := propagation.NewVOACAPPredictor(p.VOACAP.Executable, p.VOACAP.DataDir) if err != nil { // Only log error if engine is set explicitly if p.Engine == cfg.PredictionEngineVOACAP { log.Println("Failed to initialize VOACAP:", err) } else { debug.Printf("Failed to initialize VOACAP: %v", err) } break } debug.Printf("Prediction engine: %s (%q)", p.Engine, voacap.Version()) a.predictor = propagation.WithCaching(voacap) case cfg.PredictionEngineVOACAPAPI: voacap := propagation.NewVOACAPAPIPredictor(p.VOACAPAPI.BaseURL) debug.Printf("Prediction engine: %s (%q)", p.Engine, voacap.Version()) a.predictor = propagation.WithCaching(voacap) } // init forms subsystem a.formsMgr = forms.NewManager(forms.Config{ FormsPath: a.options.FormsPath, SequencePath: filepath.Join(directories.StateDir(), "template-sequence-number.json"), SequenceFormat: "%03d", MyCall: a.options.MyCall, AppVersion: buildinfo.AppName + " " + buildinfo.VersionStringShort(), UserAgent: buildinfo.UserAgent(), GPSd: a.config.GPSd, LocatorProvider: a, }) // Load the mailbox handler a.mbox = mailbox.NewDirHandler( filepath.Join(a.options.MailboxPath, a.options.MyCall), a.options.SendOnly, ) // Ensure the mailbox handler is ready if err := a.mbox.Prepare(); err != nil { log.Fatal(err) } if cmd.MayConnect { a.loadHamlibRigs(a.config.HamlibRigs) a.exchangeChan = a.exchangeLoop(ctx) go func() { if a.config.VersionReportingDisabled { return } for { a.postVersionUpdate() // 24 hour hold on success a.checkPasswordRecoveryEmailIsSet(ctx) // 14 day hold on success select { case <-time.After(6 * time.Hour): // Retry every 6 hours case <-ctx.Done(): return } } }() } if cmd.LongLived { if a.options.Listen != "" { a.Listen(a.options.Listen) } if a.config.GPSd.UpdateLocator { go a.gpsdLocatorUpdater(ctx) } } // Start command execution cmd.HandleFunc(ctx, a, args) } type Heard struct { Callsign string `json:"callsign"` Time time.Time `json:"time"` } func (a *App) ActiveListeners() []string { slice := []string{} for _, tl := range a.listenHub.Active() { slice = append(slice, tl.Name()) } sort.Strings(slice) return slice } func (a *App) Heard() map[string][]Heard { heard := make(map[string][]Heard) if a.ardop != nil { for callsign, time := range a.ardop.Heard() { heard[MethodArdop] = append(heard[MethodArdop], Heard{ Callsign: callsign, Time: time, }) } } if ax25, err := ax25.Heard(a.Config().AX25Linux.Port); err == nil { for callsign, time := range ax25 { heard[MethodAX25Linux] = append(heard[MethodAX25Linux], Heard{ Callsign: callsign, Time: time, }) } } return heard } func (a *App) GetStatus() types.Status { configHash := func(c cfg.Config) string { h := sha1.New() if err := json.NewEncoder(h).Encode(c); err != nil { panic(err) } return fmt.Sprintf("%x", h.Sum(nil)) } status := types.Status{ ActiveListeners: a.ActiveListeners(), Dialing: a.dialing != nil, Connected: a.exchangeConn != nil, HTTPClients: a.websocketHub.ClientAddrs(), ConfigHash: configHash(a.config), } if a.exchangeConn != nil { addr := a.exchangeConn.RemoteAddr() status.RemoteAddr = fmt.Sprintf("%s:%s", addr.Network(), addr) } return status } func (a *App) Close() { debug.Printf("Starting cleanup") defer func() { debug.Printf("Cleanup done") if a.termWriter != nil { a.termWriter.Close() } }() debug.Printf("Closing active connection and/or listeners") a.AbortActiveConnection(false) a.listenHub.Close() debug.Printf("Closing modems") if a.ardop != nil { if err := a.ardop.Close(); err != nil { log.Printf("Failure to close ardop TNC: %s", err) } } if a.pactor != nil { if err := a.pactor.Close(); err != nil { log.Printf("Failure to close pactor modem: %s", err) } } if a.varaFM != nil { if err := a.varaFM.Close(); err != nil { log.Printf("Failure to close varafm modem: %s", err) } } if a.varaHF != nil { if err := a.varaHF.Close(); err != nil { log.Printf("Failure to close varahf modem: %s", err) } } if a.agwpe != nil { if err := a.agwpe.Close(); err != nil { log.Printf("Failure to close AGWPE TNC: %s", err) } } // Close rigs debug.Printf("Closing rigs") for name, r := range a.rigs { if err := r.Close(); err != nil { log.Printf("Failure to close rig %s: %s", name, err) } } a.promptHub.Close() a.websocketHub.Close() a.eventLog.Close() a.formsMgr.Close() } func (a *App) onServiceMessageReceived(msg *fbb.Message) { // Recover any panic here, as we really REALLY don't want the user to lose system messages due to panics. defer func() { if r := recover(); r != nil { log.Println(r) } }() // Write all service messages to the log body, _ := msg.Body() subject := msg.Subject() fmt.Fprintln(a.termWriter) fmt.Fprintln(a.termWriter, strings.Repeat("=", (60-len(subject))/2), subject, strings.Repeat("=", (60-len(subject))/2)) fmt.Fprintln(a.termWriter, strings.TrimSpace(body)) fmt.Fprintln(a.termWriter, strings.Repeat("=", 62)) fmt.Fprintln(a.termWriter) // Handle account activation email if isActivation, password := isAccountActivationMessage(msg); isActivation && password != "" { fmt.Fprintln(a.termWriter, "DO NOT LOSE YOUR PASSWORD:", password) fmt.Fprintln(a.termWriter) } } func (a *App) loadHamlibRigs(rigsConfig map[string]cfg.HamlibConfig) { a.rigs = make(map[string]rig, len(rigsConfig)) for name, conf := range rigsConfig { if conf.Address == "" { log.Printf("Missing address-field for rig '%s', skipping.", name) continue } if conf.Network == "" { conf.Network = "tcp" } r, err := hamlib.Open(conf.Network, conf.Address) if err != nil { log.Printf("Initialization hamlib rig %s failed: %s.", name, err) continue } var vfo hamlib.VFO switch strings.ToUpper(conf.VFO) { case "A", "VFOA": vfo, err = r.VFOA() case "B", "VFOB": vfo, err = r.VFOB() case "": vfo = r.CurrentVFO() default: log.Printf("Cannot load rig '%s': Unrecognized VFO identifier '%s'", name, conf.VFO) r.Close() // Close rig if we can't use it continue } if err != nil { log.Printf("Cannot load rig '%s': Unable to select VFO: %s", name, err) r.Close() // Close rig if we can't use it continue } f, err := vfo.GetFreq() if err != nil { log.Printf("Unable to get frequency from rig %s: %s.", name, err) } else { log.Printf("%s ready. Dial frequency is %s.", name, Frequency(f)) } a.rigs[name] = rig{VFO: vfo, Closer: r} } } func (a *App) SetConnectAliases(aliases map[string]string) error { onDisk, err := LoadConfig(a.options.ConfigPath, cfg.DefaultConfig) if err != nil { return err } onDisk.ConnectAliases = aliases if err := WriteConfig(onDisk, a.options.ConfigPath); err != nil { return err } a.config.ConnectAliases = aliases return nil } pat-1.0.0/app/attachment.go000066400000000000000000000032611520322237600155370ustar00rootroot00000000000000package app import ( "bytes" "image" "image/jpeg" "io" "log" "mime" "path" "path/filepath" "strings" "github.com/la5nta/wl2k-go/fbb" "github.com/nfnt/resize" ) func AddAttachment(msg *fbb.Message, filename string, contentType string, r io.Reader) error { p, err := io.ReadAll(r) if err != nil { return err } if ok, mediaType := isConvertableImageMediaType(filename, contentType); ok { log.Printf("Auto converting '%s' [%s]...", filename, mediaType) if converted, err := convertImage(p); err != nil { log.Printf("Error converting image: %s", err) } else { log.Printf("Done converting '%s'.", filename) ext := filepath.Ext(filename) filename = filename[:len(filename)-len(ext)] + ".jpg" p = converted } } msg.AddFile(fbb.NewFile(filename, p)) return nil } func isConvertableImageMediaType(filename, contentType string) (convertable bool, mediaType string) { if contentType != "" { mediaType, _, _ = mime.ParseMediaType(contentType) } if mediaType == "" { mediaType = mime.TypeByExtension(path.Ext(filename)) } switch mediaType { case "image/svg+xml": // This is a text file return false, mediaType default: return strings.HasPrefix(mediaType, "image/"), mediaType } } func convertImage(orig []byte) ([]byte, error) { img, _, err := image.Decode(bytes.NewReader(orig)) if err != nil { return nil, err } // Scale down if img.Bounds().Dx() > 600 { img = resize.Resize(600, 0, img, resize.NearestNeighbor) } // Re-encode as low quality jpeg var buf bytes.Buffer if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 40}); err != nil { return orig, err } if buf.Len() >= len(orig) { return orig, nil } return buf.Bytes(), nil } pat-1.0.0/app/command.go000066400000000000000000000017501520322237600150260ustar00rootroot00000000000000// Copyright 2016 Martin Hebnes Pedersen (LA5NTA). All rights reserved. // Use of this source code is governed by the MIT-license that can be // found in the LICENSE file. package app import ( "context" "fmt" "os" "strings" ) var ErrNoCmd = fmt.Errorf("no cmd") type Command struct { Str string Aliases []string Desc string HandleFunc func(ctx context.Context, app *App, args []string) Usage string Options map[string]string Example string LongLived bool MayConnect bool } func (cmd Command) PrintUsage() { fmt.Fprintf(os.Stderr, "%s - %s\n", cmd.Str, cmd.Desc) fmt.Fprintf(os.Stderr, "\nUsage:\n %s %s\n", cmd.Str, strings.TrimSpace(cmd.Usage)) if len(cmd.Options) > 0 { fmt.Fprint(os.Stderr, "\nOptions:\n") for f, desc := range cmd.Options { fmt.Fprintf(os.Stderr, " %-17s %s\n", f, desc) } } if cmd.Example != "" { fmt.Fprintf(os.Stderr, "\nExample:\n %s\n", strings.TrimSpace(cmd.Example)) } fmt.Fprint(os.Stderr, "\n") } pat-1.0.0/app/config.go000066400000000000000000000127671520322237600146670ustar00rootroot00000000000000// Copyright 2016 Martin Hebnes Pedersen (LA5NTA). All rights reserved. // Use of this source code is governed by the MIT-license that can be // found in the LICENSE file. package app import ( "encoding/json" "fmt" "log" "os" "path" "strings" "github.com/kelseyhightower/envconfig" "github.com/la5nta/pat/cfg" "github.com/la5nta/pat/internal/buildinfo" ) func LoadConfig(cfgPath string, fallback cfg.Config) (config cfg.Config, err error) { config, err = ReadConfig(cfgPath) switch { case os.IsNotExist(err): config = fallback if err := WriteConfig(config, cfgPath); err != nil { return config, err } case err != nil: return config, err } // Environment variables overrides values from the config file if err := envconfig.Process(buildinfo.AppName, &config); err != nil { return config, err } // Environment variables for hamlib rigs (custom syntax not handled by envconfig) if err := readRigsFromEnv(&config.HamlibRigs); err != nil { return config, err } // Ensure the alias "telnet" exists if config.ConnectAliases == nil { config.ConnectAliases = make(map[string]string) } if _, exists := config.ConnectAliases["telnet"]; !exists { config.ConnectAliases["telnet"] = cfg.DefaultConfig.ConnectAliases["telnet"] } // TODO: Remove after some release cycles (2023-05-21) // Rewrite deprecated serial-tnc:// aliases to ax25-serial-tnc:// var deprecatedAliases []string for k, v := range config.ConnectAliases { if !strings.HasPrefix(v, MethodSerialTNCDeprecated+"://") { continue } deprecatedAliases = append(deprecatedAliases, k) config.ConnectAliases[k] = strings.Replace(v, MethodSerialTNCDeprecated, MethodAX25SerialTNC, 1) } if len(deprecatedAliases) > 0 { log.Printf("Alias(es) %s uses deprecated transport scheme %s://. Please use %s:// instead.", strings.Join(deprecatedAliases, ", "), MethodSerialTNCDeprecated, MethodAX25SerialTNC) } // Ensure ServiceCodes has a default value if len(config.ServiceCodes) == 0 { config.ServiceCodes = cfg.DefaultConfig.ServiceCodes } // Ensure we have a default AX.25 engine if config.AX25.Engine == "" { config.AX25.Engine = cfg.DefaultAX25Engine() } // Ensure we have a default AGWPE config if config.AGWPE == (cfg.AGWPEConfig{}) { config.AGWPE = cfg.DefaultConfig.AGWPE } // Enforce minimum beacon intervals if config.Ardop.BeaconInterval > 0 && config.Ardop.BeaconInterval < 10 { config.Ardop.BeaconInterval = 10 } if config.AX25.Beacon.Every > 0 && config.AX25.Beacon.Every < 10 { config.AX25.Beacon.Every = 10 } // Ensure we have a default AX.25 Linux config if config.AX25Linux == (cfg.AX25LinuxConfig{}) { config.AX25Linux = cfg.DefaultConfig.AX25Linux } // TODO: Remove after some release cycles (2023-04-30) if v := config.AX25.AXPort; v != "" && v != config.AX25Linux.Port { log.Println("Using deprecated configuration option ax25.port. Please set ax25_linux.port instead.") config.AX25Linux.Port = v } // Ensure Pactor has a default value if config.Pactor == (cfg.PactorConfig{}) { config.Pactor = cfg.DefaultConfig.Pactor } // Ensure VARA FM and VARA HF has default values if config.VaraHF.IsZero() { config.VaraHF = cfg.DefaultConfig.VaraHF } if config.VaraFM.IsZero() { config.VaraFM = cfg.DefaultConfig.VaraFM } // Ensure GPSd has a default value if config.GPSd == (cfg.GPSdConfig{}) { config.GPSd = cfg.DefaultConfig.GPSd } // Ensure SerialTNC has a default hbaud and serialbaud if config.SerialTNC.HBaud == 0 { config.SerialTNC.HBaud = cfg.DefaultConfig.SerialTNC.HBaud } if config.SerialTNC.SerialBaud == 0 { config.SerialTNC.SerialBaud = cfg.DefaultConfig.SerialTNC.SerialBaud } // Compatibility for old type default 'Kenwood' (should be 'kenwood') if v := config.SerialTNC.Type; v == "Kenwood" { config.SerialTNC.Type = strings.ToLower(v) } // Ensure ARDOP.ConnectRequests has a default value if config.Ardop.ConnectRequests == 0 { config.Ardop.ConnectRequests = cfg.DefaultConfig.Ardop.ConnectRequests } return config, nil } // readRigsFromEnv reads hamlib rigs config from environment. // Syntax: PAT_HAMLIB_RIGS_{rig name}_{ATTRIBUTE} // _{ATTRIBUTE} is optional (defaults to _ADDRESS). // Examples: // - PAT_HAMLIB_RIGS_rig1_NETWORK=tcp // - PAT_HAMLIB_RIGS_rig1_ADDRESS=localhost:8080 // - PAT_HAMLIB_RIGS_rig1_VFO=A // - PAT_HAMLIB_RIGS_rig2=localhost:8080 func readRigsFromEnv(rigs *map[string]cfg.HamlibConfig) error { prefix := strings.ToUpper(buildinfo.AppName) + "_HAMLIB_RIGS_" for _, env := range os.Environ() { attribute, value, _ := strings.Cut(env, "=") if !strings.HasPrefix(attribute, prefix) { continue } attribute = strings.TrimPrefix(attribute, prefix) name, attribute, _ := strings.Cut(attribute, "_") if *rigs == nil { *rigs = make(map[string]cfg.HamlibConfig) } rig := (*rigs)[name] switch attribute { case "ADDRESS", "": rig.Address = value case "NETWORK": rig.Network = value case "VFO": rig.VFO = value default: return fmt.Errorf("invalid attribute '%s' for rig '%s'", attribute, name) } (*rigs)[name] = rig } return nil } func ReadConfig(path string) (config cfg.Config, err error) { data, err := os.ReadFile(path) if err != nil { return } err = json.Unmarshal(data, &config) return } func WriteConfig(config cfg.Config, filePath string) error { b, err := json.MarshalIndent(config, "", " ") if err != nil { return err } // Add trailing new-line b = append(b, '\n') // Ensure path dir is available os.Mkdir(path.Dir(filePath), os.ModePerm|os.ModeDir) return os.WriteFile(filePath, b, 0o600) } pat-1.0.0/app/config_test.go000066400000000000000000000030071520322237600157110ustar00rootroot00000000000000package app import ( "os" "strings" "testing" "github.com/la5nta/pat/cfg" ) func TestReadRigsFromEnv(t *testing.T) { const prefix = "PAT_HAMLIB_RIGS" unset := func() { for _, env := range os.Environ() { key, _, _ := strings.Cut(env, "=") if strings.HasPrefix(key, prefix) { os.Unsetenv(key) } } } t.Run("simple", func(t *testing.T) { defer unset() var rigs map[string]cfg.HamlibConfig os.Setenv(prefix+"_rig", "localhost:4532") if err := readRigsFromEnv(&rigs); err != nil { t.Fatal(err) } if got := rigs["rig"]; (got != cfg.HamlibConfig{Address: "localhost:4532"}) { t.Fatalf("Got unexpected config: %#v", got) } }) t.Run("with VFO", func(t *testing.T) { defer unset() var rigs map[string]cfg.HamlibConfig os.Setenv(prefix+"_rig", "localhost:4532") os.Setenv(prefix+"_rig_VFO", "A") if err := readRigsFromEnv(&rigs); err != nil { t.Fatal(err) } if got := rigs["rig"]; (got != cfg.HamlibConfig{Address: "localhost:4532", VFO: "A"}) { t.Fatalf("Got unexpected config: %#v", got) } }) t.Run("full", func(t *testing.T) { defer unset() var rigs map[string]cfg.HamlibConfig os.Setenv(prefix+"_rig_ADDRESS", "/dev/ttyS0") os.Setenv(prefix+"_rig_NETWORK", "serial") os.Setenv(prefix+"_rig_VFO", "B") if err := readRigsFromEnv(&rigs); err != nil { t.Fatal(err) } expect := cfg.HamlibConfig{ Address: "/dev/ttyS0", Network: "serial", VFO: "B", } if got := rigs["rig"]; got != expect { t.Fatalf("Got unexpected config: %#v", got) } }) } pat-1.0.0/app/connect.go000066400000000000000000000263361520322237600150500ustar00rootroot00000000000000// Copyright 2016 Martin Hebnes Pedersen (LA5NTA). All rights reserved. // Use of this source code is governed by the MIT-license that can be // found in the LICENSE file. package app import ( "context" "errors" "fmt" "log" "os" "strconv" "strings" "time" "github.com/la5nta/pat/cfg" "github.com/la5nta/pat/internal/buildinfo" "github.com/la5nta/pat/internal/debug" "github.com/la5nta/pat/internal/prehook" "github.com/harenber/ptc-go/v2/pactor" "github.com/la5nta/wl2k-go/transport" "github.com/la5nta/wl2k-go/transport/ardop" "github.com/la5nta/wl2k-go/transport/ax25/agwpe" "github.com/n8jja/Pat-Vara/vara" // Register stateless dialers _ "github.com/la5nta/wl2k-go/transport/ax25" _ "github.com/la5nta/wl2k-go/transport/telnet" ) func hasSSID(str string) bool { return strings.Contains(str, "-") } func (a *App) Connect(connectStr string) (success bool) { if connectStr == "" { return false } else if aliased, ok := a.config.ConnectAliases[connectStr]; ok { return a.Connect(aliased) } // Replace placeholders connectStr = strings.ReplaceAll(connectStr, cfg.PlaceholderMycall, a.options.MyCall) // Prompt if Winlink account is unconfirmed if confirmed := a.promptUnconfirmedAccount(); !confirmed { return false } // Hack around bug in frontend which may occur if the status updates too quickly. if a.websocketHub.NumClients() > 0 { defer func() { time.Sleep(time.Second); a.websocketHub.UpdateStatus() }() } debug.Printf("connectStr: %s", connectStr) url, err := transport.ParseURL(connectStr) if err != nil { log.Println(err) return false } // TODO: Remove after some release cycles (2023-05-21) // Rewrite legacy serial-tnc scheme. if url.Scheme == MethodSerialTNCDeprecated { log.Printf("Transport scheme %s:// is deprecated, use %s:// instead.", MethodSerialTNCDeprecated, MethodAX25SerialTNC) url.Scheme = MethodAX25SerialTNC } // Rewrite the generic ax25:// scheme to use a specified AX.25 engine. if url.Scheme == MethodAX25 { url.Scheme = a.defaultAX25Method() } // Init TNCs switch url.Scheme { case MethodAX25AGWPE: if err := a.initAGWPE(); err != nil { log.Println(err) return } case MethodArdop: if err := a.initARDOP(); err != nil { log.Println(err) return } case MethodPactor: ptCmdInit := "" if val, ok := url.Params["init"]; ok { ptCmdInit = strings.Join(val, "\n") } if err := a.initPACTOR(ptCmdInit); err != nil { log.Println(err) return } case MethodVaraHF: if err := a.initVARAHF(); err != nil { log.Println(err) return } case MethodVaraFM: if err := a.initVARAFM(); err != nil { log.Println(err) return } } // Set default userinfo (mycall) if url.User == nil { url.SetUser(a.options.MyCall) } // Set default host interface address if url.Host == "" { switch url.Scheme { case MethodAX25Linux: url.Host = a.config.AX25Linux.Port case MethodAX25SerialTNC: url.Host = a.config.SerialTNC.Path if hbaud := a.config.SerialTNC.HBaud; hbaud > 0 { url.Params.Set("hbaud", fmt.Sprint(hbaud)) } if sbaud := a.config.SerialTNC.SerialBaud; sbaud > 0 { url.Params.Set("serial_baud", fmt.Sprint(sbaud)) } } } // Radio Only? radioOnly := a.options.RadioOnly if v := url.Params.Get("radio_only"); v != "" { radioOnly, _ = strconv.ParseBool(v) } if radioOnly { if hasSSID(a.options.MyCall) { log.Println("Radio Only does not support callsign with SSID") return } if strings.HasPrefix(url.Scheme, MethodAX25) { log.Printf("Radio-Only is not available for %s", url.Scheme) return } url.SetUser(url.User.Username() + "-T") } // QSY var revertFreq func() if freq := url.Params.Get("freq"); freq != "" { revertFreq, err = a.qsy(url.Scheme, freq) if err != nil { log.Printf("Unable to QSY: %s", err) return } defer revertFreq() } var currFreq Frequency if vfo, _, ok, _ := a.VFOForTransport(url.Scheme); ok { f, _ := vfo.GetFreq() currFreq = Frequency(f) } ctx, cancel := context.WithCancel(context.Background()) a.dialCancelFunc = func() { a.dialing = nil; cancel() } defer a.dialCancelFunc() // Signal web gui that we are dialing a connection a.dialing = url a.websocketHub.UpdateStatus() prehookScript := prehook.Script{ Dir: a.options.PrehooksPath, File: url.Params.Get("prehook"), Args: url.Params["prehook-arg"], } if prehookScript.File != "" { if err := prehookScript.VerifyFile(); err != nil { log.Printf("invalid prehook: %s", err) return } } log.Printf("Connecting to %s (%s)...", url.Target, url.Scheme) conn, err := transport.DialURLContext(ctx, url) // Signal web gui that we are no longer dialing a.dialing = nil a.websocketHub.UpdateStatus() a.eventLog.LogConn("connect "+connectStr, currFreq, conn, err) switch { case errors.Is(err, context.Canceled): log.Printf("Connect cancelled") return case err != nil: log.Printf("Unable to establish connection to remote: %s", err) return } if prehookScript.File != "" { log.Println("Running prehook...") prehookScript.Env = append([]string{ buildinfo.AppName + "_DIAL_URL=" + connectStr, buildinfo.AppName + "_REMOTE_ADDR=" + conn.RemoteAddr().String(), buildinfo.AppName + "_LOCAL_ADDR=" + conn.LocalAddr().String(), }, append(os.Environ(), a.Env()...)...) conn = prehook.Wrap(conn) if err := prehookScript.Execute(ctx, conn); err != nil { conn.Close() log.Printf("Prehook script failed: %s", err) return } log.Println("Prehook succeeded") } err = a.exchange(conn, url.Target, false) if err != nil { log.Printf("Exchange failed: %s", err) } else { log.Println("Disconnected.") success = true } return } func (a *App) qsy(method, addr string) (revert func(), err error) { noop := func() {} rig, rigName, ok, err := a.VFOForTransport(method) if err != nil { return noop, err } else if !ok { return noop, fmt.Errorf("hamlib rig '%s' not loaded", rigName) } log.Printf("QSY %s: %s", method, addr) _, oldFreq, err := SetFreq(rig, addr) if err != nil { return noop, err } time.Sleep(3 * time.Second) return func() { time.Sleep(time.Second) log.Printf("QSX %s: %.3f", method, float64(oldFreq)/1e3) rig.SetFreq(oldFreq) }, nil } func (a *App) onBusyChannel(ctx context.Context) (abort bool) { if a.options.IgnoreBusy { log.Println("Ignoring busy channel!") return false } log.Println("Waiting for clear channel...") select { case <-ctx.Done(): // The channel is no longer busy. log.Println("Channel clear") return false case resp := <-a.promptHub.Prompt(ctx, 5*time.Minute, PromptKindBusyChannel, "Waiting for clear channel..."): return resp.Value == "abort" || resp.Err == context.DeadlineExceeded } } // ARDOP returns the initialized ARDOP modem, initializing it if necessary. func (a *App) ARDOP() (*ardop.TNC, error) { if err := a.initARDOP(); err != nil { return nil, err } return a.ardop, nil } func (a *App) initARDOP() error { if a.ardop != nil && a.ardop.Ping() == nil { return nil } if a.ardop != nil { a.ardop.Close() } var err error a.ardop, err = ardop.OpenTCP(a.config.Ardop.Addr, a.options.MyCall, a.config.Locator) if err != nil { return fmt.Errorf("ARDOP TNC initialization failed: %w", err) } a.ardop.SetBusyFunc(a.onBusyChannel) if !a.config.Ardop.ARQBandwidth.IsZero() { if err := a.ardop.SetARQBandwidth(a.config.Ardop.ARQBandwidth); err != nil { return fmt.Errorf("unable to set ARQ bandwidth for ardop TNC: %w", err) } } if err := a.ardop.SetCWID(a.config.Ardop.CWID); err != nil { return fmt.Errorf("unable to configure CWID for ardop TNC: %w", err) } if v, err := a.ardop.Version(); err != nil { return fmt.Errorf("ARDOP TNC initialization failed: %s", err) } else { log.Printf("ARDOP TNC (%s) initialized", v) } transport.RegisterDialer(MethodArdop, a.ardop) if !a.config.Ardop.PTTControl { return nil } rig, ok := a.rigs[a.config.Ardop.Rig] if !ok { return fmt.Errorf("unable to set PTT rig '%s': Not defined or not loaded", a.config.Ardop.Rig) } a.ardop.SetPTT(rig) return nil } func (a *App) initPACTOR(cmdlineinit string) error { if a.pactor != nil { a.pactor.Close() } var err error a.pactor, err = pactor.OpenModem(a.config.Pactor.Path, a.config.Pactor.Baudrate, a.options.MyCall, a.config.Pactor.InitScript, cmdlineinit) if err != nil || a.pactor == nil { return fmt.Errorf("pactor initialization failed: %w", err) } transport.RegisterDialer(MethodPactor, a.pactor) return nil } // VARAHF returns the initialized VARA HF modem, initializing it if necessary. func (a *App) VARAHF() (*vara.Modem, error) { if err := a.initVARAHF(); err != nil { return nil, err } return a.varaHF, nil } func (a *App) initVARAHF() error { if a.varaHF != nil && a.varaHF.Ping() { return nil } if a.varaHF != nil { a.varaHF.Close() } m, err := a.initVARA(MethodVaraHF, a.config.VaraHF) if err != nil { return err } if bw := a.config.VaraHF.Bandwidth; bw != 0 { if err := m.SetBandwidth(fmt.Sprint(bw)); err != nil { m.Close() return err } } a.varaHF = m return nil } // VARAFM returns the initialized VARA FM modem, initializing it if necessary. func (a *App) VARAFM() (*vara.Modem, error) { if err := a.initVARAFM(); err != nil { return nil, err } return a.varaFM, nil } func (a *App) initVARAFM() error { if a.varaFM != nil && a.varaFM.Ping() { return nil } if a.varaFM != nil { a.varaFM.Close() } m, err := a.initVARA(MethodVaraFM, a.config.VaraFM) if err != nil { return err } a.varaFM = m return nil } func (a *App) initVARA(scheme string, conf cfg.VaraConfig) (*vara.Modem, error) { vConf := vara.ModemConfig{ Host: conf.Host(), CmdPort: conf.CmdPort(), DataPort: conf.DataPort(), } m, err := vara.NewModem(scheme, a.options.MyCall, vConf) if err != nil { return nil, fmt.Errorf("vara initialization failed: %w", err) } transport.RegisterDialer(scheme, m) m.SetBusyFunc(a.onBusyChannel) if conf.PTTControl { rig, ok := a.rigs[conf.Rig] if !ok { m.Close() return nil, fmt.Errorf("unable to set PTT rig '%s': not defined or not loaded", conf.Rig) } m.SetPTT(rig) } v, _ := m.Version() log.Printf("VARA modem (%s) initialized", v) return m, nil } // AGWPE returns the initialized AGWPE TNC, initializing it if necessary. func (a *App) AGWPE() (*agwpe.TNCPort, error) { if err := a.initAGWPE(); err != nil { return nil, err } return a.agwpe, nil } func (a *App) initAGWPE() error { if a.agwpe != nil && a.agwpe.Ping() == nil { return nil } if a.agwpe != nil { a.agwpe.Close() } var err error a.agwpe, err = agwpe.OpenPortTCP(a.config.AGWPE.Addr, a.config.AGWPE.RadioPort, a.options.MyCall) if err != nil { return fmt.Errorf("AGWPE TNC initialization failed: %w", err) } if v, err := a.agwpe.Version(); err != nil { return fmt.Errorf("AGWPE TNC initialization failed: %w", err) } else { log.Printf("AGWPE TNC (%s) initialized", v) } transport.RegisterContextDialer(MethodAX25AGWPE, a.agwpe) return nil } // defaultAX25Method resolves the generic ax25:// scheme to a implementation specific scheme. func (a *App) defaultAX25Method() string { switch a.config.AX25.Engine { case cfg.AX25EngineAGWPE: return MethodAX25AGWPE case cfg.AX25EngineSerialTNC: return MethodAX25SerialTNC case cfg.AX25EngineLinux: return MethodAX25Linux default: panic(fmt.Sprintf("invalid ax25 engine: %s", a.config.AX25.Engine)) } } pat-1.0.0/app/env.go000066400000000000000000000020471520322237600142000ustar00rootroot00000000000000package app import ( "os" "runtime" "github.com/la5nta/pat/internal/buildinfo" ) func (a *App) Env() []string { return []string{ `PAT_MYCALL="` + a.options.MyCall + `"`, `PAT_LOCATOR="` + a.config.Locator + `"`, `PAT_VERSION="` + buildinfo.Version + `"`, `PAT_ARCH="` + runtime.GOARCH + `"`, `PAT_OS="` + runtime.GOOS + `"`, `PAT_MAILBOX_PATH="` + a.options.MailboxPath + `"`, `PAT_CONFIG_PATH="` + a.options.ConfigPath + `"`, `PAT_LOG_PATH="` + a.options.LogPath + `"`, `PAT_EVENTLOG_PATH="` + a.options.EventLogPath + `"`, `PAT_FORMS_PATH="` + a.options.FormsPath + `"`, `PAT_DEBUG="` + os.Getenv("PAT_DEBUG") + `"`, `PAT_WEB_DEV_ADDR="` + os.Getenv("PAT_WEB_DEV_ADDR") + `"`, `ARDOP_DEBUG="` + os.Getenv("ARDOP_DEBUG") + `"`, `PACTOR_DEBUG="` + os.Getenv("PACTOR_DEBUG") + `"`, `AGWPE_DEBUG="` + os.Getenv("AGWPE_DEBUG") + `"`, `VARA_DEBUG="` + os.Getenv("VARA_DEBUG") + `"`, `GZIP_EXPERIMENT="` + os.Getenv("GZIP_EXPERIMENT") + `"`, `ARDOP_FSKONLY_EXPERIMENT="` + os.Getenv("ARDOP_FSKONLY_EXPERIMENT") + `"`, } } pat-1.0.0/app/event_log.go000066400000000000000000000024171520322237600153730ustar00rootroot00000000000000// Copyright 2016 Martin Hebnes Pedersen (LA5NTA). All rights reserved. // Use of this source code is governed by the MIT-license that can be // found in the LICENSE file. package app import ( "encoding/json" "net" "os" "time" ) type EventLogger struct { file *os.File enc *json.Encoder } func NewEventLogger(path string) (*EventLogger, error) { file, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_RDWR, 0o666) return &EventLogger{file, json.NewEncoder(file)}, err } func (l *EventLogger) Close() error { if l == nil || l.file == nil { return nil } return l.file.Close() } func (l *EventLogger) Log(what string, event map[string]interface{}) { event["log_time"] = time.Now() event["what"] = what if err := l.enc.Encode(event); err != nil { panic(err) } } func (l *EventLogger) LogConn(op string, freq Frequency, conn net.Conn, err error) { e := map[string]interface{}{"success": err == nil} if err != nil { e["error"] = err.Error() } else { if remote := conn.RemoteAddr(); remote != nil { e["remote_addr"] = remote.String() e["network"] = conn.RemoteAddr().Network() } if local := conn.LocalAddr(); local != nil { e["local_addr"] = local.String() } } if freq > 0 { e["freq"] = freq } e["operation"] = op l.Log("connect", e) } pat-1.0.0/app/exchange.go000066400000000000000000000227711520322237600152000ustar00rootroot00000000000000// Copyright 2016 Martin Hebnes Pedersen (LA5NTA). All rights reserved. // Use of this source code is governed by the MIT-license that can be // found in the LICENSE file. package app import ( "context" "fmt" "log" "net" "os" "strconv" "strings" "time" "github.com/la5nta/pat/api/types" "github.com/la5nta/pat/internal/buildinfo" "github.com/la5nta/wl2k-go/fbb" ) type ex struct { conn net.Conn target string master bool errors chan error } func (a *App) exchangeLoop(ctx context.Context) chan ex { ce := make(chan ex) go func() { for { select { case ex := <-ce: ex.errors <- a.sessionExchange(ex.conn, ex.target, ex.master) close(ex.errors) case <-ctx.Done(): return } } }() return ce } func (a *App) exchange(conn net.Conn, targetCall string, master bool) error { e := ex{ conn: conn, target: targetCall, master: master, errors: make(chan error), } a.exchangeChan <- e return <-e.errors } type NotifyMBox struct { fbb.MBoxHandler *App } func (m NotifyMBox) ProcessInbound(msgs ...*fbb.Message) error { if err := m.MBoxHandler.ProcessInbound(msgs...); err != nil { return err } for _, msg := range msgs { m.websocketHub.WriteNotification(types.Notification{ Title: fmt.Sprintf("New message from %s", msg.From().Addr), Body: msg.Subject(), }) if isServiceMessage(msg) { m.onServiceMessageReceived(msg) } } return nil } func (m NotifyMBox) GetInboundAnswers(p []fbb.Proposal) []fbb.ProposalAnswer { answers := make([]fbb.ProposalAnswer, len(p)) var outsideLimit bool var hasAccountActivation bool for idx, p := range p { answers[idx] = m.GetInboundAnswer(p) outsideLimit = outsideLimit || p.CompressedSize() >= m.config.AutoDownloadSizeLimit if pm := p.PendingMessage(); pm != nil { hasAccountActivation = hasAccountActivation || isAccountActivation(pm.From, pm.Subject) } } if hasAccountActivation { res := <-m.promptHub.Prompt( context.Background(), time.Minute, types.PromptKindAccountActivation, "Important: Your New Account Password", ) if declined := res.Value != "accept"; declined { // Defer all proposals for idx := range answers { answers[idx] = fbb.Defer } return answers } } if !outsideLimit || m.config.AutoDownloadSizeLimit < 0 { // All proposals are within the prompt limit. Go ahead. return answers } // Build multi-select build options for those accepted by the mailbox handler. var options []PromptOption for idx, p := range p { if answers[idx] != fbb.Accept { continue } answers[idx] = fbb.Defer // Defer unless user explicitly accepts through prompt answer. sender, subject := "Unkown sender", "Unknown subject" if pm := p.PendingMessage(); pm != nil { sender, subject = pm.From.String(), pm.Subject } desc := fmt.Sprintf("%s (%d bytes): %s", sender, p.CompressedSize(), subject) options = append(options, PromptOption{Value: p.MID(), Desc: desc, Checked: p.CompressedSize() < m.config.AutoDownloadSizeLimit}) } // Prompt the user ans := <-m.promptHub.Prompt(context.Background(), time.Minute, PromptKindMultiSelect, "Select messages for download", options...) // If timeout was reached, use our default values to fill in for the user if ans.Err == context.DeadlineExceeded { var checked []string for _, opt := range options { if opt.Checked { checked = append(checked, opt.Value) } } ans.Value = strings.Join(checked, ",") } // For each mid in answer, search the proposals and update answer to Accept. for _, val := range strings.Split(ans.Value, ",") { for idx, p := range p { if p.MID() != val { continue } answers[idx] = fbb.Accept } } return answers } func (a *App) sessionExchange(conn net.Conn, targetCall string, master bool) error { a.exchangeConn = conn a.websocketHub.UpdateStatus() defer func() { a.exchangeConn = nil; a.websocketHub.UpdateStatus() }() // New wl2k Session targetCall = strings.Split(targetCall, ` `)[0] session := fbb.NewSession( a.options.MyCall, targetCall, a.config.Locator, NotifyMBox{a.mbox, a}, ) session.SetUserAgent(fbb.UserAgent{ Name: buildinfo.AppName, Version: buildinfo.Version, }) if len(a.config.MOTD) > 0 { session.SetMOTD(a.config.MOTD...) } // Handle secure login session.SetSecureLoginHandleFunc(func(addr fbb.Address) (string, error) { if addr.Addr == a.options.MyCall && a.config.SecureLoginPassword != "" { return a.config.SecureLoginPassword, nil } for _, aux := range a.config.AuxAddrs { if !addr.EqualString(aux.Address) { continue } switch { case aux.Password != nil: return *aux.Password, nil case a.config.SecureLoginPassword != "": return a.config.SecureLoginPassword, nil } } resp := <-a.promptHub.Prompt(context.Background(), time.Minute, PromptKindPassword, "Enter secure login password for "+addr.String()) return resp.Value, resp.Err }) for _, addr := range a.config.AuxAddrs { session.AddAuxiliaryAddress(fbb.AddressFromString(addr.Address)) } session.IsMaster(master) session.SetLogger(log.New(a.termWriter, "", 0)) session.SetStatusUpdater(StatusUpdate{a.websocketHub}) if a.options.Robust { session.SetRobustMode(fbb.RobustForced) } log.Printf("Connected to %s (%s)", conn.RemoteAddr(), conn.RemoteAddr().Network()) start := time.Now() stats, err := session.Exchange(conn) if fbb.IsLoginFailure(err) { fmt.Println("NOTE: A new password scheme for Winlink is being implemented as of 2018-01-31.") fmt.Println(" Users with passwords created/changed prior to January 31, 2018 should be") fmt.Println(" aware that their password MUST be entered in ALL-UPPERCASE letters. Only") fmt.Println(" passwords created/changed/issued after January 31, 2018 should/may contain") fmt.Println(" lowercase letters. - https://github.com/la5nta/pat/issues/113") } if t, _ := strconv.ParseBool(os.Getenv("PAT_MOCK_NEW_ACCOUNT_MSG")); t { log.Println("Mocking new account msg...") NotifyMBox{a.mbox, a}.ProcessInbound(mockNewAccountMsg()) } event := map[string]interface{}{ "mycall": session.Mycall(), "targetcall": session.Targetcall(), "remote_fw": session.RemoteForwarders(), "remote_sid": session.RemoteSID(), "master": master, "local_locator": a.config.Locator, "auxiliary_addresses": a.config.AuxAddrs, "network": conn.RemoteAddr().Network(), "remote_addr": conn.RemoteAddr().String(), "local_addr": conn.LocalAddr().String(), "sent": stats.Sent, "received": stats.Received, "start": start.Unix(), "end": time.Now().Unix(), "success": err == nil, } if err != nil { event["error"] = err.Error() } a.eventLog.Log("exchange", event) return err } func (a *App) AbortActiveConnection(dirty bool) (ok bool) { switch { case dirty: // This mean we've already tried to abort, but the connection is still active. // Fallback to the below cases to try to identify the busy modem and abort hard. case a.dialing != nil: // If we're currently dialing a transport, attempt to abort by cancelling the associated context. log.Printf("Got abort signal while dialing %s, cancelling...", a.dialing.Scheme) go a.dialCancelFunc() return true case a.exchangeConn != nil: // If we have an active connection, close it gracefully. log.Println("Got abort signal, disconnecting...") go a.exchangeConn.Close() return true } // Any connection and/or dial operation has been cancelled at this point. // User is attempting to abort something, so try to identify any non-idling transports and abort. // It might be a "dirty disconnect" of an already cancelled connection or dial operation which is in the // process of gracefully terminating. It might also be an attempt to close an inbound P2P connection. switch { case a.ardop != nil && !a.ardop.Idle(): if dirty { log.Println("Dirty disconnecting ardop...") a.ardop.Abort() return true } log.Println("Disconnecting ardop...") go func() { if err := a.ardop.Disconnect(); err != nil { log.Println(err) } }() return true case a.varaFM != nil && !a.varaFM.Idle(): if dirty { log.Println("Dirty disconnecting varafm...") a.varaFM.Abort() return true } log.Println("Disconnecting varafm...") go func() { if err := a.varaFM.Close(); err != nil { log.Println(err) } }() return true case a.varaHF != nil && !a.varaHF.Idle(): if dirty { log.Println("Dirty disconnecting varahf...") a.varaHF.Abort() return true } log.Println("Disconnecting varahf...") go func() { if err := a.varaHF.Close(); err != nil { log.Println(err) } }() return true case a.pactor != nil: log.Println("Disconnecting pactor...") err := a.pactor.Close() if err != nil { log.Println(err) } return err == nil default: return false } } type StatusUpdate struct{ WSHub } func (s StatusUpdate) UpdateStatus(stat fbb.Status) { var prop fbb.Proposal switch { case stat.Receiving != nil: prop = *stat.Receiving case stat.Sending != nil: prop = *stat.Sending } s.WriteProgress(types.Progress{ MID: prop.MID(), BytesTotal: stat.BytesTotal, BytesTransferred: stat.BytesTransferred, Subject: prop.Title(), Receiving: stat.Receiving != nil, Sending: stat.Sending != nil, Done: stat.Done, }) percent := float64(stat.BytesTransferred) / float64(stat.BytesTotal) * 100 fmt.Printf("\r%s: %3.0f%%", prop.Title(), percent) if stat.Done { fmt.Println("") } os.Stdout.Sync() } pat-1.0.0/app/freq.go000066400000000000000000000044671520322237600143550ustar00rootroot00000000000000// Copyright 2016 Martin Hebnes Pedersen (LA5NTA). All rights reserved. // Use of this source code is governed by the MIT-license that can be // found in the LICENSE file. package app import ( "encoding/json" "fmt" "strconv" "strings" "github.com/la5nta/wl2k-go/rigcontrol/hamlib" ) var bands = map[string]Band{ "160m": {1.8e6, 2.0e6}, "80m": {3.5e6, 4.0e6}, "60m": {5.2e6, 5.5e6}, "40m": {7.0e6, 7.3e6}, "30m": {10.1e6, 10.2e6}, "20m": {14.0e6, 14.4e6}, "17m": {18.0e6, 18.2e6}, "15m": {21.0e6, 21.5e6}, "12m": {24.8e6, 25.0e6}, "10m": {28.0e6, 30.0e6}, "6m": {50.0e6, 54.0e6}, "4m": {70.0e6, 70.5e6}, "2m": {144.0e6, 148.0e6}, "1.25m": {219.0e6, 225.0e6}, // 220, 222 (MHz) "70cm": {420.0e6, 450.0e6}, } type Band struct{ lower, upper Frequency } func (b Band) Contains(f Frequency) bool { if b.lower == 0 && b.upper == 0 { return true } return f >= b.lower && f <= b.upper } type Frequency int // Hz func (f Frequency) String() string { m := f / 1e6 k := (float64(f) - float64(m)*1e6) / 1e3 return fmt.Sprintf("%d.%06.2f MHz", m, k) } func (f Frequency) MarshalJSON() ([]byte, error) { type obj struct { Hz json.Number `json:"hz"` KHz json.Number `json:"khz"` Desc string `json:"desc"` } return json.Marshal(obj{ Hz: json.Number(fmt.Sprint(int(f))), KHz: json.Number(fmt.Sprint(f.KHz())), Desc: f.String(), }) } func (f Frequency) KHz() float64 { return float64(f) / 1e3 } func (f Frequency) MHz() float64 { return float64(f) / 1e6 } func (f Frequency) Dial(mode string) Frequency { mode = strings.ToLower(mode) // Try to detect FM modes, e.g. `ARDOP 2000 FM` and `VARA FM WIDE` if strings.Contains(mode, "fm") { return f } offsets := map[string]Frequency{ MethodPactor: 1500, MethodArdop: 1500, // varahf doesn't appear in RMS list from WDT "vara": 1500, } var shift Frequency for m, offset := range offsets { if strings.Contains(mode, m) { shift = -offset break } } return f + shift } func SetFreq(rig hamlib.VFO, freq string) (newFreq, oldFreq int, err error) { oldFreq, err = rig.GetFreq() if err != nil { return 0, 0, fmt.Errorf("unable to get rig frequency: %w", err) } f, err := strconv.ParseFloat(freq, 64) if err != nil { return 0, 0, err } newFreq = int(f * 1e3) err = rig.SetFreq(newFreq) return } pat-1.0.0/app/gpsd_locator.go000066400000000000000000000026611520322237600160720ustar00rootroot00000000000000package app import ( "context" "fmt" "log" "time" "github.com/la5nta/pat/internal/debug" "github.com/la5nta/pat/internal/gpsd" "github.com/pd0mz/go-maidenhead" ) // gpsdLocatorUpdater polls GPSd every hour and updates the in-memory locator field func (a *App) gpsdLocatorUpdater(ctx context.Context) { // Logs first error to standard logger and the rest to the debug logger for logger := log.Printf; ; logger = debug.Printf { if err := a.updateLocatorFromGPSd(); err != nil && ctx.Err() == nil { logger("Failed to update locator from GPSd: %v", err) } select { case <-time.After(time.Hour): continue case <-ctx.Done(): return } } } // updateLocatorFromGPSd connects to GPSd, gets position, and updates the config locator func (a *App) updateLocatorFromGPSd() error { conn, err := gpsd.Dial(a.config.GPSd.Addr) if err != nil { return fmt.Errorf("connection failed: %w", err) } defer conn.Close() conn.Watch(true) pos, err := conn.NextPosTimeout(time.Minute) if err != nil { return fmt.Errorf("failed to provide position: %w", err) } point := maidenhead.NewPoint(pos.Lat, pos.Lon) locator, err := point.GridSquare() switch { case err != nil: return fmt.Errorf("failed to convert coordinates to locator: %w", err) case a.config.Locator == locator: return nil // Locator is up to date } log.Printf("Locator changed from %s to %s", a.config.Locator, locator) a.config.Locator = locator return nil } pat-1.0.0/app/listen.go000066400000000000000000000144721520322237600147130ustar00rootroot00000000000000// Copyright 2016 Martin Hebnes Pedersen (LA5NTA). All rights reserved. // Use of this source code is governed by the MIT-license that can be // found in the LICENSE file. package app import ( "context" "log" "net" "strings" "time" "github.com/la5nta/pat/cfg" "github.com/la5nta/wl2k-go/rigcontrol/hamlib" "github.com/la5nta/wl2k-go/transport/ardop" "github.com/la5nta/wl2k-go/transport/ax25" "github.com/la5nta/wl2k-go/transport/ax25/agwpe" "github.com/la5nta/wl2k-go/transport/telnet" "github.com/n8jja/Pat-Vara/vara" ) func (a *App) Unlisten(param string) { methods := strings.FieldsFunc(param, SplitFunc) for _, method := range methods { ok, err := a.listenHub.Disable(method) if err != nil { log.Printf("Unable to close %s listener: %s", method, err) } else if !ok { log.Printf("No active %s listener, ignoring.\n", method) } } } func (a *App) Listen(listenStr string) { methods := strings.FieldsFunc(listenStr, SplitFunc) for _, method := range methods { // Rewrite the generic ax25:// scheme to use a specified AX.25 engine. if method == MethodAX25 { method = a.defaultAX25Method() } switch strings.ToLower(method) { case MethodArdop: a.listenHub.Enable(&ARDOPListener{a, nil}) case MethodTelnet: a.listenHub.Enable(TelnetListener{a}) case MethodAX25AGWPE: a.listenHub.Enable(&AX25AGWPEListener{a, nil}) case MethodAX25Linux: a.listenHub.Enable(&AX25LinuxListener{a, nil}) case MethodVaraFM: a.listenHub.Enable(VaraFMListener{a}) case MethodVaraHF: a.listenHub.Enable(VaraHFListener{a}) case MethodAX25SerialTNC, MethodSerialTNCDeprecated: log.Printf("%s listen not implemented, ignoring.", method) default: log.Printf("'%s' is not a valid listen method", method) return } } log.Printf("Listening for incoming traffic on %s...", listenStr) } type AX25LinuxListener struct { a interface { Options() Options Config() cfg.Config } stopBeacon func() } func (l *AX25LinuxListener) Init() (net.Listener, error) { return ax25.ListenAX25(l.a.Config().AX25Linux.Port, l.a.Options().MyCall) } func (l *AX25LinuxListener) BeaconStart() error { interval := time.Duration(l.a.Config().AX25.Beacon.Every) * time.Second if interval <= 0 { return nil } b, err := ax25.NewAX25Beacon(l.a.Config().AX25Linux.Port, l.a.Options().MyCall, l.a.Config().AX25.Beacon.Destination, l.a.Config().AX25.Beacon.Message) if err != nil { return err } l.stopBeacon = doEvery(interval, func() { if err := b.Now(); err != nil { log.Printf("%s beacon failed: %s", l.Name(), err) l.stopBeacon() } }) return nil } func (l *AX25LinuxListener) BeaconStop() { if l.stopBeacon != nil { l.stopBeacon() } } func (l *AX25LinuxListener) CurrentFreq() (Frequency, bool) { return 0, false } func (l *AX25LinuxListener) Name() string { return MethodAX25Linux } type ARDOPListener struct { a interface { ARDOP() (*ardop.TNC, error) VFOForRig(string) (hamlib.VFO, bool) Config() cfg.Config } stopBeacon func() } func (l ARDOPListener) Name() string { return MethodArdop } func (l ARDOPListener) Init() (net.Listener, error) { m, err := l.a.ARDOP() if err != nil { return nil, err } return m.Listen() } func (l ARDOPListener) CurrentFreq() (Frequency, bool) { if rig, ok := l.a.VFOForRig(l.a.Config().Ardop.Rig); ok { f, _ := rig.GetFreq() return Frequency(f), ok } return 0, false } func (l *ARDOPListener) BeaconStart() error { interval := time.Duration(l.a.Config().Ardop.BeaconInterval) * time.Second if interval <= 0 { return nil } m, err := l.a.ARDOP() if err != nil { return err } l.stopBeacon = func() { m.BeaconEvery(0) } return m.BeaconEvery(interval) } func (l ARDOPListener) BeaconStop() { if l.stopBeacon != nil { l.stopBeacon() } } type VaraFMListener struct { a interface { Config() cfg.Config VFOForRig(string) (hamlib.VFO, bool) VARAFM() (*vara.Modem, error) } } func (l VaraFMListener) Name() string { return MethodVaraFM } func (l VaraFMListener) Init() (net.Listener, error) { m, err := l.a.VARAFM() if err != nil { return nil, err } return m.Listen() } func (l VaraFMListener) CurrentFreq() (Frequency, bool) { if rig, ok := l.a.VFOForRig(l.a.Config().VaraFM.Rig); ok { f, _ := rig.GetFreq() return Frequency(f), ok } return 0, false } type VaraHFListener struct { a interface { Config() cfg.Config VFOForRig(string) (hamlib.VFO, bool) VARAHF() (*vara.Modem, error) } } func (l VaraHFListener) Name() string { return MethodVaraHF } func (l VaraHFListener) Init() (net.Listener, error) { m, err := l.a.VARAHF() if err != nil { return nil, err } return m.Listen() } func (l VaraHFListener) CurrentFreq() (Frequency, bool) { if rig, ok := l.a.VFOForRig(l.a.Config().VaraHF.Rig); ok { f, _ := rig.GetFreq() return Frequency(f), ok } return 0, false } type AX25AGWPEListener struct { a interface { Config() cfg.Config AGWPE() (*agwpe.TNCPort, error) } stopBeacon func() } func (l *AX25AGWPEListener) Name() string { return MethodAX25AGWPE } func (l *AX25AGWPEListener) Init() (net.Listener, error) { m, err := l.a.AGWPE() if err != nil { return nil, err } return m.Listen() } func (l *AX25AGWPEListener) CurrentFreq() (Frequency, bool) { return 0, false } func (l *AX25AGWPEListener) BeaconStart() error { b := l.a.Config().AX25.Beacon interval := time.Duration(b.Every) * time.Second if interval <= 0 { return nil } m, err := l.a.AGWPE() if err != nil { return err } l.stopBeacon = doEvery(interval, func() { if err := m.SendUI([]byte(b.Message), b.Destination); err != nil { log.Printf("%s beacon failed: %s", l.Name(), err) l.stopBeacon() } }) return nil } func (l AX25AGWPEListener) BeaconStop() { if l.stopBeacon != nil { l.stopBeacon() } } type TelnetListener struct { a interface { Config() cfg.Config } } func (l TelnetListener) Name() string { return MethodTelnet } func (l TelnetListener) Init() (net.Listener, error) { return telnet.Listen(l.a.Config().Telnet.ListenAddr) } func (l TelnetListener) CurrentFreq() (Frequency, bool) { return 0, false } func doEvery(interval time.Duration, fn func()) (cancel func()) { if interval == 0 { return } ctx, cancel := context.WithCancel(context.Background()) go func() { t := time.NewTicker(interval) defer t.Stop() for { select { case <-ctx.Done(): return case <-t.C: fn() } } }() return cancel } pat-1.0.0/app/listener_hub.go000066400000000000000000000073421520322237600160760ustar00rootroot00000000000000// Copyright 2017 Martin Hebnes Pedersen (LA5NTA). All rights reserved. // Use of this source code is governed by the MIT-license that can be // found in the LICENSE file. package app import ( "log" "net" "sync" "time" ) type TransportListener interface { Init() (net.Listener, error) Name() string CurrentFreq() (Frequency, bool) } type Beaconer interface { BeaconStop() BeaconStart() error } type Listener struct { *App t TransportListener done chan struct{} mu sync.Mutex err error ln net.Listener } func (h *ListenerHub) NewListener(t TransportListener) *Listener { return &Listener{ App: h.App, t: t, done: make(chan struct{}), } } func (l *Listener) Err() error { l.mu.Lock() defer l.mu.Unlock() return l.err } func (l *Listener) Close() error { l.mu.Lock() defer l.mu.Unlock() select { case <-l.done: return l.err default: close(l.done) if l.ln != nil { return l.ln.Close() } return l.err } } func (l *Listener) listenLoop(h *ListenerHub) { var silenceErr bool for { select { case <-l.done: return default: ln, err := l.t.Init() l.mu.Lock() l.ln, l.err = ln, err l.mu.Unlock() if err != nil { if !silenceErr { log.Printf("Listener %s failed: %s", l.t.Name(), err) log.Printf("Will try to re-establish listener in the background...") silenceErr = true h.websocketHub.UpdateStatus() } time.Sleep(time.Second) continue } if silenceErr { log.Printf("Listener %s re-established", l.t.Name()) silenceErr = false h.websocketHub.UpdateStatus() } if b, ok := l.t.(Beaconer); ok { b.BeaconStart() } // Run the accept loop until an error occurs if err := l.acceptLoop(); err != nil { select { case <-l.done: // Ignore errors during shutdown default: log.Printf("Accept %s failed: %s", l.t.Name(), err) } } if b, ok := l.t.(Beaconer); ok { b.BeaconStop() } } } } type RemoteCaller interface { RemoteCall() string } func (l *Listener) acceptLoop() error { for { conn, err := l.ln.Accept() if err != nil { return err } remoteCall := conn.RemoteAddr().String() if c, ok := conn.(RemoteCaller); ok { remoteCall = c.RemoteCall() } freq, _ := l.t.CurrentFreq() l.eventLog.LogConn("accept", freq, conn, nil) log.Printf("Got connect (%s:%s)", l.t.Name(), remoteCall) err = l.exchange(conn, remoteCall, true) if err != nil { log.Printf("Exchange failed: %s", err) } else { log.Println("Disconnected.") } } } type ListenerHub struct { *App mu sync.Mutex listeners map[string]*Listener } func NewListenerHub(a *App) *ListenerHub { return &ListenerHub{ App: a, listeners: map[string]*Listener{}, } } func (h *ListenerHub) Active() []TransportListener { h.mu.Lock() defer h.mu.Unlock() slice := make([]TransportListener, 0, len(h.listeners)) for _, l := range h.listeners { if l.Err() != nil { continue } slice = append(slice, l.t) } return slice } func (h *ListenerHub) Enable(t TransportListener) { h.mu.Lock() defer func() { h.mu.Unlock() h.websocketHub.UpdateStatus() }() l := h.NewListener(t) if _, ok := h.listeners[t.Name()]; ok { return } h.listeners[t.Name()] = l go l.listenLoop(h) } func (h *ListenerHub) Disable(name string) (bool, error) { if name == MethodAX25 { name = h.defaultAX25Method() } h.mu.Lock() defer func() { h.mu.Unlock() h.websocketHub.UpdateStatus() }() l, ok := h.listeners[name] if !ok { return false, nil } delete(h.listeners, name) return true, l.Close() } func (h *ListenerHub) Close() error { h.mu.Lock() defer func() { h.mu.Unlock() h.websocketHub.UpdateStatus() }() for k, l := range h.listeners { l.Close() delete(h.listeners, k) } return nil } pat-1.0.0/app/listener_hub_test.go000066400000000000000000000040731520322237600171330ustar00rootroot00000000000000package app import ( "net" "testing" "time" ) type mockListener struct { closed bool acceptErr error } func (m *mockListener) Accept() (net.Conn, error) { if m.acceptErr != nil { return nil, m.acceptErr } select {} // Block forever to simulate working listener } func (m *mockListener) Close() error { m.closed = true; m.acceptErr = net.ErrClosed; return nil } func (m *mockListener) Addr() net.Addr { return nil } type mockTransportListener struct { name string initErr error initCallCount int } func (m *mockTransportListener) Init() (net.Listener, error) { m.initCallCount++ if m.initErr != nil { return nil, m.initErr } return &mockListener{}, nil } func (m *mockTransportListener) Name() string { return m.name } func (m *mockTransportListener) CurrentFreq() (Frequency, bool) { return 0, false } func createTestApp() *App { return &App{ websocketHub: noopWSSocket{}, eventLog: &EventLogger{}, } } func TestListenerHub_EnableDisable(t *testing.T) { hub := NewListenerHub(createTestApp()) defer hub.Close() t.Run("Enable", func(t *testing.T) { hub.Enable(&mockTransportListener{name: "test"}) active := hub.Active() if len(active) != 1 { t.Errorf("Expected 1 active listener, got %d", len(active)) } }) t.Run("Disable", func(t *testing.T) { removed, err := hub.Disable("test") if err != nil { t.Fatalf("Disable should not return error: %v", err) } if !removed { t.Error("Disable should return true when listener was removed") } active := hub.Active() if len(active) != 0 { t.Errorf("Expected 0 active listeners after removal, got %d", len(active)) } }) } func TestListener_RetriesOnFailure(t *testing.T) { hub := NewListenerHub(createTestApp()) defer hub.Close() transport := &mockTransportListener{ name: "test", initErr: net.ErrClosed, } hub.Enable(transport) // Wait for multiple retry attempts time.Sleep(2500 * time.Millisecond) if transport.initCallCount < 2 { t.Errorf("Expected at least 2 Init calls due to retries, got %d", transport.initCallCount) } } pat-1.0.0/app/prompt_hub.go000066400000000000000000000053671520322237600155770ustar00rootroot00000000000000package app import ( "context" "fmt" "sync" "time" "github.com/la5nta/pat/api/types" "github.com/la5nta/pat/internal/debug" ) const ( PromptKindBusyChannel = types.PromptKindBusyChannel PromptKindMultiSelect = types.PromptKindMultiSelect PromptKindPassword = types.PromptKindPassword PromptKindPreAccountActivation = types.PromptKindPreAccountActivation PromptKindAccountActivation = types.PromptKindAccountActivation ) type ( PromptResponse = types.PromptResponse PromptKind = types.PromptKind PromptOption = types.PromptOption ) type Prompt struct { types.Prompt hub *PromptHub resp chan PromptResponse ctx context.Context cancel context.CancelFunc } type Prompter interface{ Prompt(Prompt) } func (p Prompt) Done() <-chan struct{} { return p.ctx.Done() } func (p Prompt) Err() error { return p.ctx.Err() } func (p Prompt) Respond(val string, err error) { p.hub.Respond(p.ID, val, err) } type PromptHub struct { c chan *Prompt rc chan PromptResponse closeOnce sync.Once prompters map[Prompter]struct{} } func NewPromptHub() *PromptHub { p := &PromptHub{ c: make(chan *Prompt), rc: make(chan PromptResponse, 1), } go p.loop() return p } func (p *PromptHub) Close() error { if p == nil { return nil } p.closeOnce.Do(func() { close(p.c) }) return nil } func (p *PromptHub) AddPrompter(prompters ...Prompter) { if p.prompters == nil { p.prompters = make(map[Prompter]struct{}, len(prompters)) } for _, prompter := range prompters { p.prompters[prompter] = struct{}{} } } func (p *PromptHub) loop() { defer close(p.rc) defer debug.Printf("PromptHub run loop stopped") for prompt := range p.c { debug.Printf("New prompt: %#v", prompt) select { case <-prompt.ctx.Done(): debug.Printf("Prompt cancelled: %v", prompt.ctx.Err()) prompt.resp <- PromptResponse{ID: prompt.ID, Err: prompt.ctx.Err()} case resp := <-p.rc: debug.Printf("Prompt resp: %#v", resp) if resp.ID != prompt.ID { continue } prompt.resp <- resp prompt.cancel() } } } func (p *PromptHub) Respond(id, value string, err error) { select { case p.rc <- PromptResponse{ID: id, Value: value, Err: err}: default: } } func (p *PromptHub) Prompt(ctx context.Context, timeout time.Duration, kind PromptKind, message string, options ...PromptOption) <-chan PromptResponse { ctx, cancel := context.WithTimeout(ctx, timeout) prompt := &Prompt{ Prompt: types.Prompt{ ID: fmt.Sprint(time.Now().UnixNano()), Kind: kind, Message: message, Options: options, }, hub: p, resp: make(chan PromptResponse, 1), ctx: ctx, cancel: cancel, } p.c <- prompt for prompter := range p.prompters { go prompter.Prompt(*prompt) } return prompt.resp } pat-1.0.0/app/rmslist.go000066400000000000000000000202721520322237600151050ustar00rootroot00000000000000// Copyright 2016 Martin Hebnes Pedersen (LA5NTA). All rights reserved. // Use of this source code is governed by the MIT-license that can be // found in the LICENSE file. package app import ( "context" "encoding/json" "errors" "fmt" "log" "math" "net/url" "path/filepath" "sort" "strings" "time" "github.com/la5nta/pat/internal/cmsapi" "github.com/la5nta/pat/internal/debug" "github.com/la5nta/pat/internal/directories" "github.com/la5nta/pat/internal/propagation" "github.com/la5nta/pat/internal/propagation/silso" "github.com/pd0mz/go-maidenhead" ) type JSONURL struct{ url.URL } func (url JSONURL) MarshalJSON() ([]byte, error) { return json.Marshal(url.String()) } // JSONFloat64 is a float64 which serializes NaN and Inf(+-) as JSON value null type JSONFloat64 float64 func (f JSONFloat64) MarshalJSON() ([]byte, error) { if math.IsNaN(float64(f)) || math.IsInf(float64(f), 0) { return json.Marshal(nil) } return json.Marshal(float64(f)) } type Prediction struct { LinkQuality int `json:"link_quality"` OutputRaw string `json:"output_raw"` OutputValues any `json:"output_values"` } type RMS struct { Callsign string `json:"callsign"` Gridsquare string `json:"gridsquare"` Distance JSONFloat64 `json:"distance"` Azimuth JSONFloat64 `json:"azimuth"` Modes string `json:"modes"` Freq Frequency `json:"freq"` Dial Frequency `json:"dial"` URL *JSONURL `json:"url"` Prediction *Prediction `json:"prediction"` } func (r RMS) IsMode(mode string) bool { if mode == MethodVaraFM { return strings.HasPrefix(r.Modes, "VARA FM") } if mode == MethodVaraHF { return strings.HasPrefix(r.Modes, "VARA") && !strings.HasPrefix(r.Modes, "VARA FM") } return strings.Contains(strings.ToLower(r.Modes), mode) } func (r RMS) IsBand(band string) bool { return bands[band].Contains(r.Freq) } type ByLinkQuality []RMS func (r ByLinkQuality) Len() int { return len(r) } func (r ByLinkQuality) Swap(i, j int) { r[i], r[j] = r[j], r[i] } func (r ByLinkQuality) Less(i, j int) bool { quality := func(idx int) int { if r[idx].Prediction == nil { return -1 } return r[idx].Prediction.LinkQuality } if iq, jq := quality(i), quality(j); iq != jq { return iq < jq } // Fallback to distance sort (reversed since smaller value is better, as oppose to link quality) return sort.Reverse(ByDist(r)).Less(i, j) } type ByDist []RMS func (r ByDist) Len() int { return len(r) } func (r ByDist) Swap(i, j int) { r[i], r[j] = r[j], r[i] } func (r ByDist) Less(i, j int) bool { if r[i].Distance != r[j].Distance { return r[i].Distance < r[j].Distance } if r[i].Callsign != r[j].Callsign { return r[i].Callsign < r[j].Callsign } return r[i].Freq < r[j].Freq } func (a *App) ReadRMSList(ctx context.Context, forceDownload bool, filterFn func(rms RMS) (keep bool)) ([]RMS, error) { me, err := maidenhead.ParseLocator(a.config.Locator) if err != nil { log.Print("Missing or Invalid Locator, will not compute distance and Azimuth") } fileName := "rmslist" isDefaultServiceCode := len(a.config.ServiceCodes) == 1 && a.config.ServiceCodes[0] == "PUBLIC" if !isDefaultServiceCode { fileName += "-" + strings.Join(a.config.ServiceCodes, "-") } filePath := filepath.Join(directories.DataDir(), fileName+".json") debug.Printf("RMS list file is %s", filePath) f, err := cmsapi.GetGatewayStatusCached(ctx, filePath, forceDownload, a.config.ServiceCodes...) if err != nil { return nil, err } defer f.Close() var status cmsapi.GatewayStatus if err = json.NewDecoder(f).Decode(&status); err != nil { return nil, err } slice := []RMS{} for _, gw := range status.Gateways { for _, channel := range gw.Channels { r := RMS{ Callsign: gw.Callsign, Gridsquare: channel.Gridsquare, Modes: channel.SupportedModes, Freq: Frequency(channel.Frequency), Dial: Frequency(channel.Frequency).Dial(channel.SupportedModes), } if chURL := toURL(channel, gw.Callsign); chURL != nil { r.URL = &JSONURL{*chURL} } hasLocator := me != maidenhead.Point{} if them, err := maidenhead.ParseLocator(channel.Gridsquare); err == nil && hasLocator { r.Distance = JSONFloat64(me.Distance(them)) r.Azimuth = JSONFloat64(me.Bearing(them)) } if keep := filterFn(r); !keep { continue } slice = append(slice, r) } } if a.predictor == nil { return slice, nil } // Grab the forecasted SSN for today ssn, err := getSSN(ctx, time.Now()) if err != nil { log.Println(err) } // In case this takes a while, add a log statement if it's not within 5 seconds ctx, cancel := context.WithCancel(ctx) defer cancel() go func() { select { case <-time.After(5 * time.Second): log.Println("Hang tight - calculating propagation predictions...") case <-ctx.Done(): } }() // Run prediction (in parallel) params := make([]propagation.PredictionParams, len(slice)) for i, r := range slice { params[i] = propagation.PredictionParams{ From: propagation.Maidenhead(a.Config().Locator), To: propagation.Maidenhead(r.Gridsquare), Frequency: int(r.Freq), SSN: ssn, TransmitPower: 50, // TODO: Consider making this configurable Time: time.Now(), } } propagation.PredictParallel(ctx, a.predictor, params, func(i int, p *propagation.Prediction, err error) { switch { case errors.Is(err, propagation.ErrFrequencyOutOfBounds), ctx.Err() != nil: case err != nil: debug.Printf("Could not predict propagation for %s: %s", slice[i].Callsign, err) default: slice[i].Prediction = &Prediction{LinkQuality: p.LinkQuality, OutputRaw: p.OutputRaw, OutputValues: p.OutputValues} } }) return slice, nil } func getSSN(ctx context.Context, now time.Time) (int, error) { ctx, cancel := context.WithTimeout(ctx, time.Minute) defer cancel() debug.Printf("Fetching SIDC SSN prediction...") // In case this takes a while, add a log statement if it's not ready after one second go func() { select { case <-time.After(time.Second): log.Println("Updating SIDC SSN prediction...") case <-ctx.Done(): } }() cachePath := filepath.Join(directories.DataDir(), ".predicted-ssn-silso.json") predictions, err := silso.GetPredictedSSNCached(ctx, cachePath) if err != nil || len(predictions) == 0 { return 50, fmt.Errorf("failed to get SSN prediction data: %v", err) } targetMonth := now.Format("2006-01") for _, p := range predictions { if p.TimeTag == targetMonth { debug.Printf("SSN: %v", p.PredictedSSN) return int(p.PredictedSSN), nil } } p := predictions[len(predictions)-1] return int(p.PredictedSSN), fmt.Errorf("failed to find SSN prediction for current month. Using %.0f (%s)", p.PredictedSSN, p.TimeTag) } func toURL(gc cmsapi.GatewayChannel, targetCall string) *url.URL { freq := Frequency(gc.Frequency).Dial(gc.SupportedModes) chURL, _ := url.Parse(fmt.Sprintf("%s:///%s?freq=%v", toTransport(gc), targetCall, freq.KHz())) addBandwidth(gc, chURL) return chURL } func addBandwidth(gc cmsapi.GatewayChannel, chURL *url.URL) { bw := "" modeF := strings.Fields(gc.SupportedModes) switch modeF[0] { case "ARDOP": if len(modeF) > 1 { bw = modeF[1] + "MAX" } case "VARA": if len(modeF) > 1 && modeF[1] == "FM" { // VARA FM should not set bandwidth in connect URL or sent over the command port, // it's set in the VARA Setup dialog bw = "" } else { // VARA HF may be 500, 2750, or none which is implicitly 2300 if len(modeF) > 1 { if len(modeF) > 1 { bw = modeF[1] } } else { bw = "2300" } } } if bw != "" { v := chURL.Query() v.Set("bw", bw) chURL.RawQuery = v.Encode() } } var transports = []string{MethodAX25, MethodPactor, MethodArdop, MethodVaraFM, MethodVaraHF} func toTransport(gc cmsapi.GatewayChannel) string { modes := strings.ToLower(gc.SupportedModes) for _, transport := range transports { if strings.Contains(modes, "packet") { // bug(maritnhpedersen): We really don't know which transport to use here. It could be serial-tnc or ax25, but ax25 is most likely. return MethodAX25 } if strings.HasPrefix(modes, "vara fm") { return MethodVaraFM } if strings.HasPrefix(modes, "vara") { return MethodVaraHF } if strings.Contains(modes, transport) { return transport } } return "" } pat-1.0.0/app/rmslist_test.go000066400000000000000000000076121520322237600161470ustar00rootroot00000000000000package app import ( "net/url" "reflect" "testing" "github.com/la5nta/pat/internal/cmsapi" ) func Test_toURL(t *testing.T) { type args struct { channel cmsapi.GatewayChannel targetCall string } tests := []struct { name string args args want *url.URL }{ { name: "ax25 1200", args: args{ channel: cmsapi.GatewayChannel{ Frequency: 145050000, SupportedModes: "Packet 1200", }, targetCall: "K0NTS-10", }, want: parseURL("ax25:///K0NTS-10?freq=145050"), }, { name: "ax25 9600", args: args{ channel: cmsapi.GatewayChannel{ Frequency: 438075000, SupportedModes: "Packet 9600", }, targetCall: "HB9AK-14", }, want: parseURL("ax25:///HB9AK-14?freq=438075"), }, { name: "adrop 2000", args: args{ channel: cmsapi.GatewayChannel{ Frequency: 3586500, SupportedModes: "ARDOP 2000", }, targetCall: "K0SI", }, want: parseURL("ardop:///K0SI?bw=2000MAX&freq=3585"), }, { name: "adrop 500", args: args{ channel: cmsapi.GatewayChannel{ Frequency: 3584000, SupportedModes: "ARDOP 500", }, targetCall: "F1ZWL", }, want: parseURL("ardop:///F1ZWL?bw=500MAX&freq=3582.5"), }, { // These are quite rare but are seen in the wild name: "adrop 1000", args: args{ channel: cmsapi.GatewayChannel{ Frequency: 3588000, SupportedModes: "ARDOP 1000", }, targetCall: "N3HYM-10", }, want: parseURL("ardop:///N3HYM-10?bw=1000MAX&freq=3586.5"), }, { // This is a notional ARDOP station that doesn't specify bandwidth in supportedModes. // None appear today in the RMS list, but maybe they could. name: "adrop unspec", args: args{ channel: cmsapi.GatewayChannel{ Frequency: 7584000, SupportedModes: "ARDOP", }, targetCall: "T3ST", }, want: parseURL("ardop:///T3ST?freq=7582.5"), }, { name: "pactor", args: args{ channel: cmsapi.GatewayChannel{ Frequency: 1850000, SupportedModes: "Pactor 1,2", }, targetCall: "K1EHZ", }, want: parseURL("pactor:///K1EHZ?freq=1848.5"), }, { name: "robust packet", args: args{ channel: cmsapi.GatewayChannel{ Frequency: 7099400, SupportedModes: "Robust Packet", }, targetCall: "N3HYM-10", }, want: parseURL("ax25:///N3HYM-10?freq=7099.4"), }, { name: "vara hf 500", args: args{ channel: cmsapi.GatewayChannel{ Frequency: 7064000, SupportedModes: "VARA 500", }, targetCall: "W0VG", }, want: parseURL("varahf:///W0VG?bw=500&freq=7062.5"), }, { name: "vara hf unspec", args: args{ channel: cmsapi.GatewayChannel{ Frequency: 7103000, SupportedModes: "VARA", }, targetCall: "W0VG", }, want: parseURL("varahf:///W0VG?bw=2300&freq=7101.5"), }, { name: "vara hf 2750", args: args{ channel: cmsapi.GatewayChannel{ Frequency: 3597900, SupportedModes: "VARA 2750", }, targetCall: "W1EO", }, want: parseURL("varahf:///W1EO?bw=2750&freq=3596.4"), }, { name: "vara fm narrow", args: args{ channel: cmsapi.GatewayChannel{ Frequency: 145070000, SupportedModes: "VARA FM", }, targetCall: "W0TQ", }, // vara transport adapter will default to narrow want: parseURL("varafm:///W0TQ?freq=145070"), }, { name: "vara fm wide", args: args{ channel: cmsapi.GatewayChannel{ Frequency: 145510000, SupportedModes: "VARA FM WIDE", }, targetCall: "W1AW-10", }, want: parseURL("varafm:///W1AW-10?freq=145510"), }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := toURL(tt.args.channel, tt.args.targetCall); !reflect.DeepEqual(got, tt.want) { t.Errorf("toURL() = %v, want %v", got, tt.want) } }) } } func parseURL(str string) *url.URL { parse, _ := url.Parse(str) return parse } pat-1.0.0/app/utils.go000066400000000000000000000004441520322237600145470ustar00rootroot00000000000000// Copyright 2016 Martin Hebnes Pedersen (LA5NTA). All rights reserved. // Use of this source code is governed by the MIT-license that can be // found in the LICENSE file. package app import ( "unicode" ) func SplitFunc(c rune) bool { return unicode.IsSpace(c) || c == ',' || c == ';' } pat-1.0.0/app/winlink_api.go000066400000000000000000000103401520322237600157070ustar00rootroot00000000000000package app import ( "context" "encoding/json" "errors" "fmt" "os" "path/filepath" "runtime" "time" "github.com/la5nta/pat/internal/buildinfo" "github.com/la5nta/pat/internal/cmsapi" "github.com/la5nta/pat/internal/debug" "github.com/la5nta/pat/internal/directories" ) var ErrRateLimited error = errors.New("call was rate-limited") // DoIfElapsed implements a per-callsign rate limited function. func DoIfElapsed(callsign, name string, t time.Duration, fn func() error) error { filePath := filepath.Join(directories.StateDir(), "."+name+"_"+callsign+".json") file, err := os.OpenFile(filePath, os.O_RDWR|os.O_CREATE, 0o600) if err != nil { return err } defer file.Close() var lastUpdated time.Time json.NewDecoder(file).Decode(&lastUpdated) if since := time.Since(lastUpdated); since < t { debug.Printf("Skipping %q (last run: %s ago)", name, since.Truncate(time.Minute)) return ErrRateLimited } if err := fn(); err != nil { return err } file.Truncate(0) file.Seek(0, 0) return json.NewEncoder(file).Encode(time.Now()) } func (a *App) postVersionUpdate() { const interval = 24 * time.Hour err := DoIfElapsed(a.Options().MyCall, "version_report", interval, func() error { debug.Printf("Posting version update...") // WDT do not want us to post version reports for callsigns without a registered account if exists, err := accountExists(a.Options().MyCall); err != nil { return err } else if !exists { return fmt.Errorf("account does not exist") } return cmsapi.VersionAdd{ Callsign: a.Options().MyCall, Program: buildinfo.AppName, Version: buildinfo.Version, Comments: fmt.Sprintf("%s - %s/%s", buildinfo.GitRev, runtime.GOOS, runtime.GOARCH), }.Post() }) if err != nil && !errors.Is(err, ErrRateLimited) { debug.Printf("Failed to post version update: %v", err) } } func (a *App) checkPasswordRecoveryEmailIsSet(ctx context.Context) { const interval = 14 * 24 * time.Hour err := DoIfElapsed(a.Options().MyCall, "pw_recovery_email_check", interval, func() error { debug.Printf("Checking if winlink.org password recovery email is set...") set, err := passwordRecoveryEmailSet(ctx, a.Options().MyCall, a.Config().SecureLoginPassword) if err != nil { return err } debug.Printf("Password recovery email set: %t", set) if set { return nil } fmt.Println("") fmt.Println("WINLINK NOTICE: Password recovery email is not set for your Winlink account. It is highly recommended to do so.") fmt.Println("Run `" + os.Args[0] + " account --help` for help setting your recovery address. You can also manage your account settings at https://winlink.org/.") fmt.Println("") return nil }) if err != nil && !errors.Is(err, ErrRateLimited) { debug.Printf("Failed to check if password recovery email is set: %v", err) } } func passwordRecoveryEmailSet(ctx context.Context, callsign, password string) (bool, error) { if password == "" { return false, fmt.Errorf("missing password") } switch exists, err := accountExists(callsign); { case err != nil: return false, fmt.Errorf("error checking if account exist: %w", err) case !exists: return false, fmt.Errorf("account does not exist") } email, err := cmsapi.PasswordRecoveryEmailGet(ctx, callsign, password) return email != "", err } func accountExists(callsign string) (bool, error) { var cache struct { Expires time.Time AccountExists bool } fileName := fmt.Sprintf(".cached_account_check_%s.json", callsign) filePath := filepath.Join(directories.StateDir(), fileName) f, err := os.OpenFile(filePath, os.O_RDWR|os.O_CREATE, 0o600) if err != nil { return false, err } json.NewDecoder(f).Decode(&cache) if time.Since(cache.Expires) < 0 { return cache.AccountExists, nil } defer func() { f.Truncate(0) f.Seek(0, 0) json.NewEncoder(f).Encode(cache) }() debug.Printf("Checking if account exists...") exists, err := cmsapi.AccountExists(context.Background(), callsign) debug.Printf("Account exists: %t (%v)", exists, err) if !exists || err != nil { // Let's try again in 48 hours cache.Expires = time.Now().Add(48 * time.Hour) return false, err } // Keep this response for a month. It will probably not change. cache.Expires = time.Now().Add(30 * 24 * time.Hour) cache.AccountExists = exists return exists, err } pat-1.0.0/app/wshub.go000066400000000000000000000013401520322237600145330ustar00rootroot00000000000000package app import "github.com/la5nta/pat/api/types" type WSHub interface { UpdateStatus() WriteProgress(types.Progress) WriteNotification(types.Notification) Prompt(Prompt) NumClients() int ClientAddrs() []string Close() error } type noopWSSocket struct{} func (noopWSSocket) UpdateStatus() {} func (noopWSSocket) WriteProgress(types.Progress) {} func (noopWSSocket) WriteNotification(types.Notification) {} func (noopWSSocket) Prompt(Prompt) {} func (noopWSSocket) NumClients() int { return 0 } func (noopWSSocket) ClientAddrs() []string { return []string{} } func (noopWSSocket) Close() error { return nil } pat-1.0.0/cfg/000077500000000000000000000000001520322237600130355ustar00rootroot00000000000000pat-1.0.0/cfg/ax25_engine.go000066400000000000000000000010251520322237600154660ustar00rootroot00000000000000package cfg import ( "encoding/json" "fmt" ) const ( AX25EngineAGWPE AX25Engine = "agwpe" AX25EngineLinux = "linux" AX25EngineSerialTNC = "serial-tnc" ) type AX25Engine string func (a *AX25Engine) UnmarshalJSON(p []byte) error { var str string if err := json.Unmarshal(p, &str); err != nil { return err } switch v := AX25Engine(str); v { case AX25EngineLinux, AX25EngineAGWPE, AX25EngineSerialTNC: *a = v return nil default: return fmt.Errorf("invalid AX.25 engine '%s'", v) } } pat-1.0.0/cfg/ax25_engine_libax25.go000066400000000000000000000001401520322237600170110ustar00rootroot00000000000000//go:build libax25 package cfg func DefaultAX25Engine() AX25Engine { return AX25EngineLinux } pat-1.0.0/cfg/ax25_engine_other.go000066400000000000000000000001411520322237600166650ustar00rootroot00000000000000//go:build !libax25 package cfg func DefaultAX25Engine() AX25Engine { return AX25EngineAGWPE } pat-1.0.0/cfg/config.go000066400000000000000000000320361520322237600146350ustar00rootroot00000000000000// Copyright 2016 Martin Hebnes Pedersen (LA5NTA). All rights reserved. // Use of this source code is governed by the MIT-license that can be // found in the LICENSE file. package cfg import ( "encoding/json" "fmt" "net" "strconv" "strings" "github.com/la5nta/wl2k-go/transport/ardop" ) const ( PlaceholderMycall = "{mycall}" ) type AuxAddr struct { Address string Password *string } func (a AuxAddr) MarshalJSON() ([]byte, error) { if a.Password == nil { return json.Marshal(a.Address) } return json.Marshal(a.Address + ":" + *a.Password) } func (a *AuxAddr) UnmarshalJSON(p []byte) error { var str string if err := json.Unmarshal(p, &str); err != nil { return err } parts := strings.SplitN(str, ":", 2) a.Address = parts[0] if len(parts) > 1 { a.Password = &parts[1] } return nil } type Config struct { // This station's callsign. MyCall string `json:"mycall"` // Secure login password used when a secure login challenge is received. // // The user is prompted if this is undefined. SecureLoginPassword string `json:"secure_login_password"` // Auxiliary callsigns to fetch email on behalf of. // // Passwords can optionally be specified by appending :MYPASS (e.g. EMCOMM-1:MyPassw0rd). // If no password is specified, the SecureLoginPassword is used. AuxAddrs []AuxAddr `json:"auxiliary_addresses"` // Maidenhead grid square (e.g. JP20qe). Locator string `json:"locator"` // Message size limit (in bytes) for automatic download of pending messages. // // When receiving messages larger than this limit, the user will be prompted // with the option to defer the download to a later session. // // Negative value means no limit. AutoDownloadSizeLimit int `json:"auto_download_size_limit"` // List of service codes for rmslist (defaults to PUBLIC) ServiceCodes []string `json:"service_codes"` // Default HTTP listen address (for web UI). // // Use ":8080" to listen on any device, port 8080. HTTPAddr string `json:"http_addr"` // Handshake comment lines sent to remote node on incoming connections. // // Example: ["QTH: Hagavik, Norway. Operator: Martin", "Rig: FT-897 with Signalink USB"] MOTD []string `json:"motd"` // Connect aliases // // Example: {"LA1B-10": "ax25:///LD5GU/LA1B-10", "LA1B": "ardop://LA3F?freq=5350"} // Any occurrence of the substring "{mycall}" will be replaced with user's callsign. ConnectAliases map[string]string `json:"connect_aliases"` // Methods to listen for incoming P2P connections by default. // // Example: ["ax25", "telnet", "ardop"] Listen []string `json:"listen"` // Hamlib rigs available (with reference name) for ptt and frequency control. HamlibRigs map[string]HamlibConfig `json:"hamlib_rigs"` AX25 AX25Config `json:"ax25"` // See AX25Config. AX25Linux AX25LinuxConfig `json:"ax25_linux"` // See AX25LinuxConfig. AGWPE AGWPEConfig `json:"agwpe"` // See AGWPEConfig. SerialTNC SerialTNCConfig `json:"serial-tnc"` // See SerialTNCConfig. Ardop ArdopConfig `json:"ardop"` // See ArdopConfig. Pactor PactorConfig `json:"pactor"` // See PactorConfig. Telnet TelnetConfig `json:"telnet"` // See TelnetConfig. VaraHF VaraConfig `json:"varahf"` // See VaraConfig. VaraFM VaraConfig `json:"varafm"` // See VaraConfig. // See GPSdConfig. GPSd GPSdConfig `json:"gpsd"` // See PredictionConfig. Prediction PredictionConfig `json:"prediction,omitzero"` // Command schedule (cron-like syntax). // // Examples: // # Connect to telnet once every hour // "@hourly": "connect telnet" // // # Change ardop listen frequency based on hour of day // "00 10 * * *": "freq ardop:7350.000", # 40m from 10:00 // "00 18 * * *": "freq ardop:5347.000", # 60m from 18:00 // "00 22 * * *": "freq ardop:3602.000" # 80m from 22:00 Schedule map[string]string `json:"schedule"` // By default, Pat posts your callsign and running version to the Winlink CMS Web Services // // Set to true if you don't want your information sent. VersionReportingDisabled bool `json:"version_reporting_disabled"` } func (c *Config) UnmarshalJSON(b []byte) error { type aliased Config tmp := struct { aliased // Default to -1 if omitted (legacy configs) AutoDownloadSizeLimit *int `json:"auto_download_size_limit"` }{} if err := json.Unmarshal(b, &tmp); err != nil { return err } if ptr := tmp.AutoDownloadSizeLimit; ptr == nil { tmp.aliased.AutoDownloadSizeLimit = -1 } else { tmp.aliased.AutoDownloadSizeLimit = *tmp.AutoDownloadSizeLimit } *c = Config(tmp.aliased) return nil } type PredictionConfig struct { // engine sets the HF propagation prediction engine to be used. // Valid options are: // - empty string (autodetect) // - voacap // - voacap-api // - disabled // If empty, autodetection is attempted. Engine PredictionEngine `json:"engine"` // See VOACAPConfig. VOACAP VOACAPConfig `json:"voacap,omitzero"` // See VOACAPAPIConfig. VOACAPAPI VOACAPAPIConfig `json:"voacap_api,omitzero"` } func (p PredictionConfig) IsZero() bool { return p == (PredictionConfig{}) } type VOACAPConfig struct { // Executable overrides the default executable for VOACAP. // Default is c:\itshfbc\bin_win\voacapw.exe on Windows and voacapl for other platforms. Executable string `json:"executable"` // DataDir overrides the default data directory for VOACAP. // Default is c:\itshfbc on Windows and $HOME/itshfbc for other platforms. DataDir string `json:"data_dir"` } func (v VOACAPConfig) IsZero() bool { return v == (VOACAPConfig{}) } type VOACAPAPIConfig struct { // BaseURL is the base URL for the VOACAP API. // E.g. http://localhost:3000 BaseURL string `json:"base_url"` } func (v VOACAPAPIConfig) IsZero() bool { return v == (VOACAPAPIConfig{}) } type HamlibConfig struct { // The network type ("serial" or "tcp"). Use 'tcp' for rigctld (default). // // (For serial support: build with "-tags libhamlib".) Network string `json:"network,omitempty"` // The rig address. // // For tcp (rigctld): "address:port" (e.g. localhost:4532). // For serial: "/path/to/tty?model=&baudrate=" (e.g. /dev/ttyS0?model=123&baudrate=4800). Address string `json:"address,omitempty"` // The rig's VFO to control ("A" or "B"). If empty, the current active VFO is used. VFO string `json:"VFO"` } type ArdopConfig struct { // Network address of the Ardop TNC (e.g. localhost:8515). Addr string `json:"addr"` // Default/listen ARQ bandwidth (200/500/1000/2000 MAX/FORCED). ARQBandwidth ardop.Bandwidth `json:"arq_bandwidth"` // Number of connect frames to transmit when dialing. // // Some stations may require mutliple attempts before responding. ConnectRequests int `json:"connect_requests"` // (optional) Reference name to the Hamlib rig to control frequency and ptt. Rig string `json:"rig"` // Set to true if hamlib should control PTT (SignaLink=false, most rigexpert=true). PTTControl bool `json:"ptt_ctrl"` // (optional) Send ID frame at a regular interval when the listener is active (unit is seconds) BeaconInterval int `json:"beacon_interval"` // Send FSK CW ID after an ID frame. CWID bool `json:"cwid_enabled"` } type VaraConfig struct { // Network host of the VARA modem (defaults to localhost:8300). Addr string `json:"addr"` // Default/listen bandwidth (HF: 500/2300/2750 Hz). Bandwidth int `json:"bandwidth,omitempty"` // (optional) Reference name to the Hamlib rig to control frequency and ptt. Rig string `json:"rig"` // Set to true if hamlib should control PTT (SignaLink=false, most rigexpert=true). PTTControl bool `json:"ptt_ctrl"` } // UnmarshalJSON implements VaraConfig JSON unmarshalling with support for legacy format. func (v *VaraConfig) UnmarshalJSON(b []byte) error { type newFormat VaraConfig legacy := struct { newFormat Host string `json:"host"` CmdPort int `json:"cmdPort"` DataPort int `json:"dataPort"` }{} if err := json.Unmarshal(b, &legacy); err != nil { return err } if legacy.newFormat.Addr == "" && legacy.Host != "" { legacy.newFormat.Addr = fmt.Sprintf("%s:%d", legacy.Host, legacy.CmdPort) } *v = VaraConfig(legacy.newFormat) if !v.IsZero() && v.CmdPort() <= 0 { return fmt.Errorf("invalid addr format") } return nil } func (v VaraConfig) IsZero() bool { return v == (VaraConfig{}) } func (v VaraConfig) Host() string { host, _, _ := net.SplitHostPort(v.Addr) return host } func (v VaraConfig) CmdPort() int { _, portStr, _ := net.SplitHostPort(v.Addr) port, _ := strconv.Atoi(portStr) return port } func (v VaraConfig) DataPort() int { return v.CmdPort() + 1 } type PactorConfig struct { // Path/port to TNC device (e.g. /dev/ttyUSB0 or COM1). Path string `json:"path"` // Baudrate for the serial port (e.g. 57600). Baudrate int `json:"baudrate"` // (optional) Reference name to the Hamlib rig for frequency control. Rig string `json:"rig"` // (optional) Path to custom TNC initialization script. InitScript string `json:"custom_init_script"` } type TelnetConfig struct { // Network address (and port) to listen for telnet-p2p connections (e.g. :8774). ListenAddr string `json:"listen_addr"` // Telnet-p2p password. Password string `json:"password"` } type SerialTNCConfig struct { // Serial port (e.g. /dev/ttyUSB0 or COM1). Path string `json:"path"` // SerialBaud is the serial port's baudrate (e.g. 57600). SerialBaud int `json:"serial_baud"` // HBaud is the the packet connection's baudrate (1200 or 9600). HBaud int `json:"hbaud"` // Type of TNC (currently only 'kenwood'). Type string `json:"type"` } type AGWPEConfig struct { // The TCP address of the TNC. Addr string `json:"addr"` // The AGWPE "radio port" (0-3). RadioPort int `json:"radio_port"` } type AX25Config struct { // The AX.25 engine to be used. // // Valid options are: // - linux // - agwpe // - serial-tnc Engine AX25Engine `json:"engine"` // (optional) Reference name to the Hamlib rig for frequency control. Rig string `json:"rig"` // DEPRECATED: See AX25Linux.Port. AXPort string `json:"port,omitempty"` // Optional beacon when listening for incoming packet-p2p connections. Beacon BeaconConfig `json:"beacon"` } type AX25LinuxConfig struct { // axport to use (as defined in /etc/ax25/axports). Only applicable to ax25 engine 'linux'. Port string `json:"port"` } type BeaconConfig struct { // Beacon interval in seconds (e.g. 3600 for once every 1 hour) Every int `json:"every"` // (seconds) // Beacon data/message Message string `json:"message"` // Beacon destination (e.g. IDENT) Destination string `json:"destination"` } type GPSdConfig struct { // Enable GPSd proxy for HTTP (web GUI) // // Caution: Your GPS position will be accessible to any network device able to access Pat's HTTP interface. EnableHTTP bool `json:"enable_http"` // Allow Winlink forms to use GPSd for aquiring your position. // // Caution: Your current GPS position will be automatically injected, without your explicit consent, into forms requesting such information. AllowForms bool `json:"allow_forms"` // Use server time instead of timestamp provided by GPSd (e.g for older GPS device with week roll-over issue). UseServerTime bool `json:"use_server_time"` // Automatically update the locator field in-memory by polling GPSd every hour. // // Note: This only updates the locator in-memory. The config file is not modified. // On startup, the config's locator value will be used until the first position is received from GPSd. UpdateLocator bool `json:"update_locator"` // Address and port of GPSd server (e.g. localhost:2947). Addr string `json:"addr"` } var DefaultConfig = Config{ MOTD: []string{"Open source Winlink client - getpat.io"}, AuxAddrs: []AuxAddr{}, ServiceCodes: []string{"PUBLIC"}, AutoDownloadSizeLimit: -1, ConnectAliases: map[string]string{ "telnet": "telnet://{mycall}:CMSTelnet@cms.winlink.org:8772/wl2k", }, Listen: []string{}, HTTPAddr: "localhost:8080", AX25: AX25Config{ Engine: DefaultAX25Engine(), Beacon: BeaconConfig{ Every: 3600, Message: "Winlink P2P", Destination: "IDENT", }, }, AX25Linux: AX25LinuxConfig{ Port: "wl2k", }, SerialTNC: SerialTNCConfig{ Path: "/dev/ttyUSB0", SerialBaud: 9600, HBaud: 1200, Type: "kenwood", }, AGWPE: AGWPEConfig{ Addr: "localhost:8000", RadioPort: 0, }, Ardop: ArdopConfig{ Addr: "localhost:8515", ARQBandwidth: ardop.Bandwidth500Max, ConnectRequests: ardop.DefaultConnectRequests, CWID: true, }, Pactor: PactorConfig{ Path: "/dev/ttyUSB0", Baudrate: 57600, }, Telnet: TelnetConfig{ ListenAddr: ":8774", Password: "", }, VaraHF: VaraConfig{ Addr: "localhost:8300", Bandwidth: 2300, }, VaraFM: VaraConfig{ Addr: "localhost:8300", }, GPSd: GPSdConfig{ EnableHTTP: false, // Default to false to help protect privacy of unknowing users (see github.com//issues/146) AllowForms: false, // Default to false to help protect location privacy of unknowing users UseServerTime: false, UpdateLocator: false, Addr: "localhost:2947", // Default listen address for GPSd }, Schedule: map[string]string{}, HamlibRigs: map[string]HamlibConfig{}, } pat-1.0.0/cfg/prediction_engine.go000066400000000000000000000013401520322237600170470ustar00rootroot00000000000000package cfg import ( "encoding/json" "fmt" "strings" ) type PredictionEngine string const ( PredictionEngineAuto PredictionEngine = "" PredictionEngineDisabled PredictionEngine = "disabled" PredictionEngineVOACAP PredictionEngine = "voacap" PredictionEngineVOACAPAPI PredictionEngine = "voacap-api" ) func (p *PredictionEngine) UnmarshalJSON(b []byte) error { var str string if err := json.Unmarshal(b, &str); err != nil { return err } switch v := PredictionEngine(strings.ToLower(strings.TrimSpace(str))); v { case PredictionEngineVOACAP, PredictionEngineVOACAPAPI, PredictionEngineDisabled, PredictionEngineAuto: *p = v return nil default: return fmt.Errorf("invalid prediction engine '%s'", v) } } pat-1.0.0/cli/000077500000000000000000000000001520322237600130455ustar00rootroot00000000000000pat-1.0.0/cli/account.go000066400000000000000000000037701520322237600150370ustar00rootroot00000000000000package cli import ( "context" "fmt" "log" "os" "strings" "time" "github.com/la5nta/pat/app" "github.com/la5nta/pat/internal/cmsapi" ) const ( AccountUsage = `property [value] properties: password.recovery.email ` AccountExample = ` account password.recovery.email Get your current password recovery email for winlink.org. account password.recovery.email me@example.com Set your password recovery email to for winlink.org to "me@example.com". ` ) func AccountHandle(ctx context.Context, app *app.App, args []string) { switch cmd, args := shiftArgs(args); cmd { case "password.recovery.email": if err := passwordRecoveryEmailHandle(ctx, app, args); err != nil { fmt.Println("ERROR:", err) os.Exit(1) } default: fmt.Println("Missing argument, try 'account help'.") } } // getPasswordForCallsign gets the password for the specified callsign // It tries the configured SecureLoginPassword first, then prompts if not available func getPasswordForCallsign(ctx context.Context, a *app.App, callsign string) string { password := a.Config().SecureLoginPassword if password != "" { return password } select { case <-ctx.Done(): return "" case resp := <-a.PromptHub().Prompt(ctx, time.Minute, app.PromptKindPassword, "Enter account password for "+callsign): if resp.Err != nil { log.Printf("Password prompt error: %v", resp.Err) return "" } return resp.Value } } func passwordRecoveryEmailHandle(ctx context.Context, a *app.App, args []string) error { mycall := a.Options().MyCall password := getPasswordForCallsign(ctx, a, mycall) arg, _ := shiftArgs(args) if arg != "" { if err := cmsapi.PasswordRecoveryEmailSet(ctx, mycall, password, arg); err != nil { return fmt.Errorf("failed to set value: %w", err) } } email, err := cmsapi.PasswordRecoveryEmailGet(ctx, mycall, password) switch { case err != nil: return fmt.Errorf("failed to get value: %w", err) case strings.TrimSpace(email) == "": email = "[not set]" } fmt.Println(email) return nil } pat-1.0.0/cli/commands.go000066400000000000000000000127601520322237600152030ustar00rootroot00000000000000package cli import ( "context" "log" "github.com/la5nta/pat/app" ) var Commands = []app.Command{ { Str: "init", Desc: "Initial configuration setup.", HandleFunc: InitHandle, Usage: "Interactive basic setup with Winlink account verification.", }, { Str: "configure", Desc: "Open configuration file for editing.", HandleFunc: ConfigureHandle, }, { Str: "connect", Desc: "Connect to a remote station.", HandleFunc: ConnectHandle, Usage: UsageConnect, Example: ExampleConnect, MayConnect: true, }, { Str: "interactive", Desc: "Run interactive mode.", Usage: "[options]", Options: map[string]string{ "--http, -h": "Start http server for web UI in the background.", }, HandleFunc: InteractiveHandle, MayConnect: true, LongLived: true, }, { Str: "http", Desc: "Run http server for web UI.", Usage: "[options]", Options: map[string]string{ "--addr, -a": "Listen address. Default is :8080.", }, HandleFunc: HTTPHandle, MayConnect: true, LongLived: true, }, { Str: "compose", Desc: "Compose a new message.", Usage: "[options]\n" + "\tIf no options are passed, composes interactively.\n" + "\tIf options are passed, reads message from stdin similar to mail(1).", Options: map[string]string{ "--from, -r": "Address to send from. Default is your call from config or --mycall, but can be specified to use tactical addresses.", "--forward": "Forward given message (full path or mid)", "--in-reply-to": "Compose in reply to given message (full path or mid)", "--reply-all": "Reply to all (only applicable in combination with --in-reply-to)", "--template": "Compose using template file. Uses the --forms directory as root for relative paths.", "--subject, -s": "Subject", "--attachment , -a": "Attachment path (may be repeated)", "--cc, -c": "CC Address(es) (may be repeated)", "--p2p-only": "Send over peer to peer links only (avoid CMS)", "": "Recipient address (may be repeated)", }, HandleFunc: ComposeMessage, }, { Str: "read", Desc: "Read messages.", HandleFunc: ReadHandle, }, { Str: "composeform", Aliases: []string{"formPath"}, Desc: "Post form-based report. (DEPRECATED)", Usage: "[options]", Options: map[string]string{ "--template": "path to the form template file. Uses the --forms directory as root. Defaults to 'ICS USA Forms/ICS213.txt'", }, HandleFunc: func(ctx context.Context, app *app.App, args []string) { log.Println("DEPRECATED: Use `compose --template` instead") if len(args) == 0 || args[0] == "" { args = []string{"ICS USA Forms/ICS213.txt"} } ComposeMessage(ctx, app, append([]string{"--template"}, args...)) }, }, { Str: "position", Aliases: []string{"pos"}, Desc: "Post a position report (GPSd or manual entry).", Usage: "[options]", Options: map[string]string{ "--latlon": "latitude,longitude in decimal degrees for manual entry. Will use GPSd if this is empty.", "--comment, -c": "Comment to be included in the position report.", }, Example: ExamplePosition, HandleFunc: PositionHandle, }, { Str: "extract", Desc: "Extract attachments from a message file.", Usage: "[full path or mid]", HandleFunc: ExtractMessageHandle, }, { Str: "rmslist", Desc: "Print/search in list of RMS nodes.", Usage: "[options] [search term]", Options: map[string]string{ "--mode, -m": "Mode filter.", "--band, -b": "Band filter (e.g. '80m').", "--force-download, -d": "Force download of latest list from winlink.org.", "--sort-distance, -s": "Sort by distance", "--sort-link-quality, -q": "Sort by predicted link quality (requires VOACAP)", }, HandleFunc: RMSListHandle, }, { Str: "updateforms", Desc: "Download the latest form templates. (DEPRECATED)", HandleFunc: func(ctx context.Context, a *app.App, args []string) { log.Println("DEPRECATED: Use `templates update` instead") TemplatesHandle(ctx, a, []string{"update"}) }, }, { Str: "templates", Desc: "Manage message templates and HTML forms.", Usage: TemplatesUsage, Example: TemplatesExample, HandleFunc: TemplatesHandle, }, { Str: "account", Desc: "Get and set Winlink.org account settings.", Usage: AccountUsage, Example: AccountExample, HandleFunc: AccountHandle, }, { Str: "mps", Desc: "Manage message pickup stations.", Usage: MPSUsage, Example: MPSExample, HandleFunc: MPSHandle, }, { Str: "version", Desc: "Print the application version.", Usage: "[options]", Options: map[string]string{ "--check, -c": "Check if a new version is available", "--verbose, -v": "Show detailed build information", }, HandleFunc: VersionHandle, }, { Str: "env", Desc: "List environment variables.", HandleFunc: EnvHandle, }, { Str: "help", Desc: "Print detailed help for a given command.", // Avoid initialization loop by invoking helpHandler in main }, } func FindCommand(args []string) (cmd app.Command, pre, post []string, err error) { cmdMap := make(map[string]app.Command, len(Commands)) for _, c := range Commands { cmdMap[c.Str] = c for _, alias := range c.Aliases { cmdMap[alias] = c } } for i, arg := range args { if cmd, ok := cmdMap[arg]; ok { return cmd, args[1:i], args[i+1:], nil } } err = app.ErrNoCmd return } pat-1.0.0/cli/composer.go000066400000000000000000000217451520322237600152340ustar00rootroot00000000000000// Copyright 2016 Martin Hebnes Pedersen (LA5NTA). All rights reserved. // Use of this source code is governed by the MIT-license that can be // found in the LICENSE file. package cli import ( "bufio" "bytes" "context" "fmt" "io" "log" "os" "path/filepath" "strings" "github.com/la5nta/pat/app" "github.com/la5nta/pat/internal/editor" "github.com/la5nta/wl2k-go/fbb" "github.com/spf13/pflag" ) type composerFlags struct { // Headers from string to []string cc []string subject string p2pOnly bool body string attachmentPaths []string attachments []*fbb.File // forwarded attachments and/or attachments generated from template template string // path to template/form forward string // path/mid inReplyTo string // path/mid replyAll bool } func ComposeMessage(ctx context.Context, app *app.App, args []string) { exitOnContextCancellation(ctx) var flags composerFlags set := pflag.NewFlagSet("compose", pflag.ExitOnError) set.StringVarP(&flags.from, "from", "r", app.Options().MyCall, "") set.StringVarP(&flags.subject, "subject", "s", "", "") set.StringArrayVarP(&flags.attachmentPaths, "attachment", "a", nil, "") set.StringArrayVarP(&flags.cc, "cc", "c", nil, "") set.BoolVarP(&flags.p2pOnly, "p2p-only", "", false, "") set.StringVarP(&flags.template, "template", "", "", "") set.StringVarP(&flags.inReplyTo, "in-reply-to", "", "", "") set.StringVarP(&flags.forward, "forward", "", "", "") set.BoolVarP(&flags.replyAll, "reply-all", "", false, "") set.Parse(args) // Remaining args are recipients for _, r := range set.Args() { if strings.TrimSpace(r) == "" { // Filter out empty args (this actually happens) continue } flags.to = append(flags.to, r) } composeMessage(app, flags, isTerminal(os.Stdin)) } func composeMessage(app *app.App, flags composerFlags, interactive bool) { switch { case flags.inReplyTo != "" && flags.forward != "": log.Fatal("reply and forward are mutually exclusive operations") case flags.inReplyTo != "": if err := prepareReply(app, &flags); err != nil { log.Fatal(err) } case flags.forward != "": if err := prepareForward(app, &flags); err != nil { log.Fatal(err) } } if interactive { promptHeader(&flags) } if err := buildBody(app, &flags, interactive); err != nil { log.Fatal(err) } if interactive { promptAttachments(&flags.attachmentPaths) if !previewAndPromptConfirmation(&flags) { return } } msg := buildMessage(app.Options().MyCall, flags) postMessage(app, msg) } func prepareReply(app *app.App, flags *composerFlags) error { originalMsg, err := openMessage(app, flags.inReplyTo) if err != nil { return err } if flags.subject == "" { flags.subject = "Re: " + strings.TrimSpace(strings.TrimPrefix(originalMsg.Subject(), "Re:")) } flags.to = append(flags.to, originalMsg.From().String()) if flags.replyAll { for _, addr := range append(originalMsg.To(), originalMsg.Cc()...) { if !addr.EqualString(app.Options().MyCall) { flags.cc = append(flags.cc, addr.String()) } } } var buf bytes.Buffer writeMessageCitation(&buf, originalMsg) flags.body = buf.String() return nil } func prepareForward(app *app.App, flags *composerFlags) error { originalMsg, err := openMessage(app, flags.forward) if err != nil { return err } if flags.subject == "" { flags.subject = "Fwd: " + strings.TrimSpace(strings.TrimPrefix(originalMsg.Subject(), "Fwd:")) } flags.attachments = append(flags.attachments, originalMsg.Files()...) var buf bytes.Buffer writeMessageCitation(&buf, originalMsg) flags.body = buf.String() return nil } func buildBody(app *app.App, flags *composerFlags, interactive bool) error { switch { case flags.template != "": inReplyTo, err := openMessage(app, flags.inReplyTo) if err != nil && flags.inReplyTo != "" { return err } formMsg, err := app.FormsManager().ComposeTemplate(flags.template, flags.subject, inReplyTo, readLine) if err != nil { return fmt.Errorf("failed to compose message for template: %w", err) } flags.subject = formMsg.Subject flags.body = formMsg.Body flags.attachments = append(flags.attachments, formMsg.Attachments...) case interactive: promptBody(&flags.body) default: // Read body from stdin body, _ := io.ReadAll(os.Stdin) if len(body) == 0 { fmt.Fprint(os.Stderr, "Null message body; hope that's ok\n") } flags.body = string(body) } return nil } func postMessage(a *app.App, msg *fbb.Message) { if err := msg.Validate(); err != nil { fmt.Printf("WARNING - Message does not validate: %s\n", err) } if err := a.Mailbox().AddOut(msg); err != nil { log.Fatal(err) } fmt.Println("Message posted") } func promptHeader(flags *composerFlags) { flags.from = prompt("From", flags.from) flags.to = strings.FieldsFunc(prompt("To", strings.Join(flags.to, ",")), SplitFunc) flags.cc = strings.FieldsFunc(prompt("Cc", strings.Join(flags.cc, ",")), SplitFunc) switch len(flags.to) + len(flags.cc) { case 1: if flags.p2pOnly { flags.p2pOnly = strings.EqualFold(prompt("P2P only", "Y", "n"), "y") } else { flags.p2pOnly = strings.EqualFold(prompt("P2P only", "N", "y"), "y") } case 0: fmt.Println("Message must have at least one recipient") os.Exit(1) } flags.subject = prompt("Subject", flags.subject) // A message without subject is not valid, so let's use a sane default if flags.subject == "" { flags.subject = "" } } func promptBody(body *string) { fmt.Printf(`Press ENTER to start composing the message body. `) readLine() var err error *body, err = composeBody(*body) if err != nil { log.Fatal(err) } } func promptAttachments(attachmentPaths *[]string) { for _, path := range *attachmentPaths { fmt.Println("Attachment [empty when done]:", path) } for { path := prompt("Attachment [empty when done]", "") if path == "" { break } if _, err := os.Stat(path); err != nil { log.Println(err) continue } *attachmentPaths = append(*attachmentPaths, path) } } func buildMessage(mycall string, flags composerFlags) *fbb.Message { // We have to verify the args here. Follow the same pattern as main() // We'll allow a missing recipient if CC is present (or vice versa) if len(flags.to)+len(flags.cc) <= 0 { fmt.Fprint(os.Stderr, "ERROR: Missing recipients in non-interactive mode!\n") os.Exit(1) } msg := fbb.NewMessage(fbb.Private, mycall) msg.SetFrom(flags.from) for _, to := range flags.to { msg.AddTo(to) } for _, cc := range flags.cc { msg.AddCc(cc) } // Subject is optional. Print a mailx style warning if flags.subject == "" { fmt.Fprint(os.Stderr, "Warning: missing subject; hope that's OK\n") } msg.SetSubject(flags.subject) // Handle Attachments. Since we're not interactive, treat errors as fatal so the user can fix for _, path := range flags.attachmentPaths { if err := addAttachmentFromPath(msg, path); err != nil { fmt.Fprint(os.Stderr, err.Error()+"\nAborting! (Message not posted)\n") os.Exit(1) } } for _, f := range flags.attachments { msg.AddFile(f) } msg.SetBody(flags.body) if flags.p2pOnly { msg.Header.Set("X-P2POnly", "true") } return msg } func previewAndPromptConfirmation(flags *composerFlags) (ok bool) { preview := func() { var attachments []string for _, a := range flags.attachments { attachments = append(attachments, a.Name()) } attachments = append(attachments, flags.attachmentPaths...) fmt.Println() fmt.Println("================================================================") fmt.Println("To:", strings.Join(flags.to, ", ")) fmt.Println("Cc:", strings.Join(flags.cc, ", ")) fmt.Println("From:", flags.from) fmt.Println("Subject:", flags.subject) fmt.Println("Attachments:", strings.Join(attachments, ", ")) fmt.Println("================================================================") fmt.Println(flags.body) fmt.Println("================================================================") } preview() for { fmt.Print("Post message to outbox? [Y,q,e,?]: ") switch readLine() { case "Y", "y", "": return true case "e": flags.body, _ = composeBody(flags.body) preview() case "q": return false case "?": fallthrough default: fmt.Println("y = post message to outbox") fmt.Println("e = edit message body") fmt.Println("q = quit, discarding the message") } } } func composeBody(template string) (string, error) { body, err := editor.EditText(template) if err != nil { return body, err } // An empty message body is illegal. Let's set a sane default. if len(strings.TrimSpace(body)) == 0 { body = "\n" } return body, nil } func writeMessageCitation(w io.Writer, inReplyToMsg *fbb.Message) { fmt.Fprintf(w, "--- %s %s wrote: ---\n", inReplyToMsg.Date(), inReplyToMsg.From().Addr) body, _ := inReplyToMsg.Body() scanner := bufio.NewScanner(strings.NewReader(body)) for scanner.Scan() { fmt.Fprintf(w, ">%s\n", scanner.Text()) } } func addAttachmentFromPath(msg *fbb.Message, path string) error { f, err := os.Open(path) if err != nil { return err } defer f.Close() return app.AddAttachment(msg, filepath.Base(path), "", f) } pat-1.0.0/cli/configure.go000066400000000000000000000017371520322237600153650ustar00rootroot00000000000000package cli import ( "context" "fmt" "log" "os" "strings" "github.com/la5nta/pat/app" "github.com/la5nta/pat/cfg" "github.com/la5nta/pat/internal/editor" ) func ConfigureHandle(ctx context.Context, a *app.App, args []string) { cancel := exitOnContextCancellation(ctx) defer cancel() // Ensure config file has been written config, err := app.ReadConfig(a.Options().ConfigPath) if os.IsNotExist(err) { err = app.WriteConfig(cfg.DefaultConfig, a.Options().ConfigPath) if err != nil { log.Fatalf("Unable to write default config: %s", err) } } if err != nil || config.MyCall == "" || config.Locator == "" { fmt.Println("Hello there! Do you want to be guided through the basic setup?") ans := strings.ToLower(prompt(fmt.Sprintf("Run '%s init'?", os.Args[0]), "Y", "n")) if ans == "y" || ans == "yes" { InitHandle(ctx, a, args) return } } if err := editor.Open(a.Options().ConfigPath); err != nil { log.Fatalf("Unable to start editor: %s", err) } } pat-1.0.0/cli/connect.go000066400000000000000000000054231520322237600150310ustar00rootroot00000000000000package cli import ( "context" "fmt" "os" "github.com/la5nta/pat/app" ) func ConnectHandle(_ context.Context, app *app.App, args []string) { if args[0] == "" { fmt.Println("Missing argument, try 'connect help'.") } app.PromptHub().AddPrompter(TerminalPrompter{}) if success := app.Connect(args[0]); !success { os.Exit(1) } } const ( UsageConnect = `'alias' or 'transport://[host][/digi]/targetcall[?params...]' transport: telnet: TCP/IP ardop: ARDOP TNC pactor: SCS PTC modems varahf: VARA HF TNC varafm: VARA FM TNC ax25: AX.25 (Default - uses engine specified in config) ax25+agwpe: AX.25 (AGWPE/Direwolf) ax25+linux: AX.25 (Linux kernel) ax25+serial-tnc: AX.25 (Serial TNC) host: Used to address the host interface (TNC/modem), _not_ to be confused with the connection PATH. Format: [user[:pass]@]host[:port] telnet: [user:pass]@host:port ax25+linux: (optional) host=axport pactor: (optional) serial device (e.g. COM1 or /dev/ttyUSB0) path: The last element of the path is the target station's callsign. If the path has multiple hops (e.g. AX.25), they are separated by '/'. params: ?freq= Sets QSY frequency (ardop and ax25 only) ?host= Overrides the host part of the path. Useful for serial-tnc to specify e.g. /dev/ttyS0. ?prehook= Sets an executable middleware to run before the connection is handed over to the B2F protocol. The executable must be located in {CONFIG_DIR}/prehooks/. Received packets are forwarded to STDIN. Data written to STDOUT forwarded to the remote node. Additional arguments can be passed with one or more &prehook-arg=. Environment variables describing the dialed connection are provided. Useful for packet node traversal. Supported across all transports. ` ExampleConnect = ` connect telnet (alias) Connect to one of the Winlink Common Message Servers via tcp. connect ax25:///LA1B-10 Connect to the RMS Gateway LA1B-10 using AX.25 engine as per configuration. connect ax25+linux://tmd710/LA1B-10 Connect to LA1B-10 using Linux kernel's AX.25 stack on axport 'tmd710'. connect ax25:///LA1B/LA5NTA Peer-to-peer connection with LA5NTA via LA1B digipeater. connect ardop:///LA3F Connect to the RMS HF Gateway LA3F using ARDOP on the default tcp address and port. connect ardop:///LA3F?freq=5350 Same as above, but set dial frequency of the radio using rigcontrol. connect pactor:///LA3F Connect to RMS HF Gateway LA3F using PACTOR. connect varahf:///LA1B Connect to RMS HF Gateway LA1B using VARA HF TNC. connect varafm:///LA5NTA Connect to LA5NTA using VARA FM TNC. ` ) pat-1.0.0/cli/env.go000066400000000000000000000003001520322237600141550ustar00rootroot00000000000000package cli import ( "context" "fmt" "strings" "github.com/la5nta/pat/app" ) func EnvHandle(_ context.Context, app *app.App, _ []string) { fmt.Println(strings.Join(app.Env(), "\n")) } pat-1.0.0/cli/extract.go000066400000000000000000000007541520322237600150540ustar00rootroot00000000000000package cli import ( "context" "fmt" "log" "os" "github.com/la5nta/pat/app" ) func ExtractMessageHandle(_ context.Context, app *app.App, args []string) { if len(args) == 0 || args[0] == "" { fmt.Println("Missing argument, try 'extract help'.") os.Exit(1) } msg, err := openMessage(app, args[0]) if err != nil { log.Fatal(err) } fmt.Println(msg) for _, f := range msg.Files() { if err := os.WriteFile(f.Name(), f.Data(), 0o664); err != nil { log.Fatal(err) } } } pat-1.0.0/cli/help.go000066400000000000000000000004321520322237600143230ustar00rootroot00000000000000package cli import ( "github.com/spf13/pflag" ) func HelpHandle(args []string) { // Print usage for the specified command arg := args[0] for _, cmd := range Commands { if cmd.Str == arg { cmd.PrintUsage() return } } // Fallback to main help text pflag.Usage() } pat-1.0.0/cli/http.go000066400000000000000000000011321520322237600143500ustar00rootroot00000000000000package cli import ( "context" "log" "os" "github.com/la5nta/pat/api" "github.com/la5nta/pat/app" "github.com/spf13/pflag" ) func HTTPHandle(ctx context.Context, a *app.App, args []string) { addr := a.Config().HTTPAddr if addr == "" { addr = ":8080" // For backwards compatibility (remove in future) } set := pflag.NewFlagSet("http", pflag.ExitOnError) set.StringVarP(&addr, "addr", "a", addr, "Listen address.") set.Parse(args) if addr == "" { set.Usage() os.Exit(1) } scheduleLoop(ctx, a) if err := api.ListenAndServe(ctx, a, addr); err != nil { log.Println(err) } } pat-1.0.0/cli/init.go000066400000000000000000000272471520322237600143530ustar00rootroot00000000000000package cli import ( "context" "fmt" "log" "os" "strings" "time" "github.com/la5nta/pat/app" "github.com/la5nta/pat/cfg" "github.com/la5nta/pat/internal/cmsapi" "github.com/la5nta/pat/internal/debug" "github.com/pd0mz/go-maidenhead" ) func InitHandle(ctx context.Context, a *app.App, args []string) { cancel := exitOnContextCancellation(ctx) defer cancel() cfg, err := app.LoadConfig(a.Options().ConfigPath, cfg.DefaultConfig) if err != nil { log.Fatal(err) } fmt.Println("Pat Initial Configuration") fmt.Println("=========================") fmt.Print("(Press ctrl+c at any time to abort)\n\n") // Prompt for callsign callsign := prompt("Enter your callsign", cfg.MyCall) if callsign == "" { log.Fatal("Callsign is required") } cfg.MyCall = strings.ToUpper(callsign) // Prompt for Maidenhead grid square locator := prompt("Enter your Maidenhead locator", cfg.Locator) if locator == "" { log.Fatal("Maidenhead locator is required") } if _, err := maidenhead.ParseLocator(locator); err != nil { fmt.Printf("⚠ %q might be an invalid locator. Using it anyway.\n", locator) } cfg.Locator = locator // Check if account exists via Winlink API fmt.Printf("\nChecking Winlink account: %s...\n", callsign) switch exists, err := accountExists(ctx, callsign); { case err != nil: fmt.Println("⚠ Check failed due to network error. Assuming account exists.") handleExistingAccount(ctx, &cfg) case exists: fmt.Println("✓ Account exists") handleExistingAccount(ctx, &cfg) case !exists: fmt.Println("✗ Account does not exist") handleNewAccount(ctx, &cfg) } // Write the new/modified config if err := app.WriteConfig(cfg, a.Options().ConfigPath); err != nil { log.Fatal(err) } fmt.Printf("\nThat's it! Basic configuration is set. For advanced settings, run '%s configure' or use the web gui.\n", os.Args[0]) } // promptNewPassword prompts the user to enter a password twice for confirmation func promptNewPassword() string { for { fmt.Println("\nPlease choose a password for your account (6-12 characters)") password1, err := promptPassword("Enter password: ") if err != nil { log.Fatal(err) } if len(password1) < 6 || len(password1) > 12 { fmt.Println("✗ Password can be no less than 6 and no more than 12 characters long") continue } password2, err := promptPassword("Confirm password: ") if err != nil { log.Fatal(err) } if string(password1) != string(password2) { fmt.Println("✗ Passwords do not match. Please try again.") continue } return string(password1) } } // handleNewAccount guides the user through creating a new Winlink account func handleNewAccount(ctx context.Context, cfg *cfg.Config) { // This function is designed with **GDPR compliance** in mind, specifically addressing the roles // and responsibilities of a desktop application developer when handling user credentials // for a third-party service (Winlink). // // --- GDPR Compliance Summary --- // // 1. **Role as Data Controller:** // For the process of collecting, transferring to Winlink, and securely storing user credentials (callsign, password, recovery email) locally, this application acts as a **Data Controller**. We determine the purpose and means of this specific data processing. // // 2. **Lawful Basis for Processing (Consent):** // User **consent** (GDPR Article 6(1)(a)) is the chosen legal basis. // - **Informed Consent:** The user is provided with a clear, concise, and prominent consent dialogue that explicitly states: // - Which data (callsign, password, recovery email) is collected. // - The purpose of collection (Winlink account creation). // - That data is sent *directly* to Winlink's API. // - That callsign and password will be stored *locally* in the application's configuration file for continued access, and that the developer does not have access to these local credentials. // - Links to Winlink's Terms and Conditions and Privacy Policy. // - **Unambiguous Consent:** Consent is obtained through an active, explicit action by the user (repeating their callsign to confirm). This goes beyond a simple "Yes/No" to demonstrate clear intent. // // 3. **Transparency and Information Duty (GDPR Article 13):** // The consent dialogue fulfills the information requirements by clearly communicating: // - The identity of the controller (the application/developer implicitly). // - The purposes of processing. // - The recipients of the data (Winlink). // - The fact of local storage and its purpose. // - Links to relevant third-party policies (Winlink). // - Implicitly, users retain rights over their data, both with Winlink and for the locally stored credentials (e.g., through application features for credential management). // // 4. **Security and Data Protection by Design (GDPR Articles 25 & 32):** // - **Data Minimization:** Only necessary data for account creation and local access is collected. // - **Secure Transmission:** All communication with Winlink's API (including credentials) occurs over **HTTPS/TLS** to ensure data integrity and confidentiality during transit. // // By adhering to these principles, this function aims to ensure robust GDPR compliance for the account creation and local credential storage process. fmt.Println("\nWould you like to create a new Winlink account? It is highly recommended to do so.") resp := prompt("Create account?", "Y", "n") if resp != "Y" && strings.ToLower(resp) != "y" && strings.ToLower(resp) != "yes" { fmt.Println("\n⚠ Account creation skipped. If you connect to the Winlink system without an active account, an over-the-air activation process will be initiated by the CMS. You'll receive a generated password the first time you connect. DO NOT LOSE THIS PASSWORD, AS YOU WILL BE LOCKED OUT OF THE SYSTEM.") if resp := prompt("Continue without an account?", "Y", "n"); strings.ToLower(resp) == "y" || strings.ToLower(resp) == "yes" { return } } // Prompt for password password := promptNewPassword() // Prompt for recovery email fmt.Println("\nWould you like to set a password recovery email? This is optional, but highly recommended.") recoveryEmail := prompt("Password recovery email (optional)", "") if recoveryEmail == "" { fmt.Println("⚠ Warning: You have chosen not to provide a password recovery email. If you proceed and forget your password, it cannot be recovered!") resp := prompt("Are you sure?", "N", "y") if resp != "y" && strings.ToLower(resp) != "yes" { log.Fatal("Winlink account creation cancelled") } } getConsent := func(callsign string) bool { fmt.Println() fmt.Println("======== CONSENT REQUIRED ========") fmt.Println("To create your Winlink account, we'll send your chosen callsign, password, and recovery email address directly to the Winlink system.") fmt.Println() fmt.Println("Your callsign and password will also be stored locally on your computer in the configuration file. This is so you can log in and use Winlink services directly from here.") fmt.Println() fmt.Println("By proceeding, you agree that your data will be handled according to Winlink's Terms, Conditions and Privacy Policy:") fmt.Println("* https://winlink.org/terms_conditions (Terms, Conditions and Privacy Policy)") fmt.Println("") fmt.Println("Do you agree to create your Winlink account and store your credentials locally?") fmt.Println("==================================") for { switch resp := strings.ToUpper(prompt("Repeat your callsign to confirm your consent", "")); resp { case "": return false case callsign: return true default: fmt.Println("✗ Callsigns do not match. Please try again.") } } } if consent := getConsent(cfg.MyCall); !consent { log.Fatal("Winlink account creation cancelled") } fmt.Println("✓ Consent granted") // Create the account fmt.Println("\nCreating Winlink account...") ctx, cancel := context.WithTimeout(ctx, 20*time.Second) defer cancel() err := cmsapi.AccountAdd(ctx, cfg.MyCall, password, recoveryEmail) if err != nil { log.Fatalf("Failed to create Winlink account: %v", err) } fmt.Printf("✓ Congratulations! Your Winlink account for %s has been successfully created.\n", cfg.MyCall) cfg.SecureLoginPassword = password } func handleExistingAccount(ctx context.Context, cfg *cfg.Config) { // Prompt for password and validate fmt.Println() L: for { promptStr := "Enter account password: " if cfg.SecureLoginPassword != "" { promptStr = promptStr[:len(promptStr)-2] + fmt.Sprintf(" [%s]: ", strings.Repeat("*", len(cfg.SecureLoginPassword))) } password, err := promptPassword(promptStr) switch { case err != nil: log.Fatal(err) case len(password) == 0: if cfg.SecureLoginPassword != "" { break // Use whatever exists now. } // TODO: What about users that use Pat for P2P exclusively? fmt.Println("✗ Account password is required") continue L // Prompt again default: cfg.SecureLoginPassword = string(password) } fmt.Println("Checking password...") switch valid, err := validatePassword(ctx, cfg.MyCall, cfg.SecureLoginPassword); { case err != nil: fmt.Println("⚠ Password verification failed. Assuming password is correct.") break L case valid: fmt.Println("✓ Password verified") break L case !valid: fmt.Println("✗ Invalid password") } } // Verify password recovery email is set fmt.Println("\nChecking for password recovery email...") switch exists, err := getPasswordRecoveryEmail(context.Background(), cfg.MyCall, cfg.SecureLoginPassword); { case err != nil: fmt.Println("⚠ Password recovery email check failed. Assuming it is set.") case exists == "": fmt.Printf("✗ No password recovery email set\n") handleMissingPasswordRecoveryEmail(context.Background(), *cfg) default: fmt.Printf("✓ Password recovery email: %s\n", exists) } } func handleMissingPasswordRecoveryEmail(ctx context.Context, cfg cfg.Config) { fmt.Println() fmt.Println("Would you like to set a password recovery email now? This is highly recommended.") email := prompt("Enter password recovery email (optional)", "") if email == "" { fmt.Println("No email provided, continuing without setting password recovery email") return } fmt.Println("Setting password recovery email...") ctx, cancel := context.WithTimeout(ctx, 15*time.Second) defer cancel() if err := cmsapi.PasswordRecoveryEmailSet(ctx, cfg.MyCall, cfg.SecureLoginPassword, email); err != nil { fmt.Printf("⚠ Failed to set password recovery email: %v\n", err) return } fmt.Printf("✓ Password recovery email set to: %s\n", email) } func accountExists(ctx context.Context, callsign string) (exists bool, err error) { for retry := 0; retry < 5; retry++ { ctx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() exists, err = cmsapi.AccountExists(ctx, callsign) if err == nil { break } debug.Printf("Winlink API call failed: %v. Retrying...", err) time.Sleep(time.Second) } return exists, err } func validatePassword(ctx context.Context, callsign, password string) (valid bool, err error) { for retry := 0; retry < 5; retry++ { ctx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() valid, err = cmsapi.ValidatePassword(ctx, callsign, password) cancel() if err == nil { break } debug.Printf("Winlink API call failed: %v. Retrying...", err) time.Sleep(time.Second) } return valid, err } func getPasswordRecoveryEmail(ctx context.Context, callsign, password string) (email string, err error) { for retry := 0; retry < 5; retry++ { ctx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() email, err = cmsapi.PasswordRecoveryEmailGet(ctx, callsign, password) cancel() if err == nil { break } debug.Printf("Winlink API call failed: %v. Retrying...", err) } return email, err } pat-1.0.0/cli/interactive.go000066400000000000000000000113411520322237600157110ustar00rootroot00000000000000package cli import ( "bytes" "context" "fmt" "log" "os" "runtime" "strconv" "strings" "time" "github.com/la5nta/pat/api" "github.com/la5nta/pat/app" "github.com/la5nta/wl2k-go/rigcontrol/hamlib" "github.com/peterh/liner" "github.com/spf13/pflag" ) func InteractiveHandle(ctx context.Context, a *app.App, args []string) { var http string set := pflag.NewFlagSet("interactive", pflag.ExitOnError) set.StringVar(&http, "http", "", "HTTP listen address") set.Lookup("http").NoOptDefVal = a.Config().HTTPAddr set.Parse(args) a.PromptHub().AddPrompter(TerminalPrompter{}) if http == "" { Interactive(ctx, a) return } ctx, cancel := context.WithCancel(ctx) defer cancel() go func() { if err := api.ListenAndServe(ctx, a, http); err != nil { log.Println(err) } }() time.Sleep(time.Second) Interactive(ctx, a) } func Interactive(ctx context.Context, a *app.App) { scheduleLoop(ctx, a) line := liner.NewLiner() defer line.Close() done := make(chan struct{}) go func() { defer close(done) for { str, _ := line.Prompt(getPrompt(a)) if str == "" { continue } line.AppendHistory(str) if str[0] == '#' { continue } if quit := execCmd(a, str); quit { break } } }() select { case <-ctx.Done(): case <-done: } } func execCmd(a *app.App, line string) (quit bool) { cmd, param := parseCommand(line) switch cmd { case "connect": if param == "" { printInteractiveUsage() return } a.Connect(param) case "listen": a.Listen(param) case "unlisten": a.Unlisten(param) case "heard": PrintHeard(a) case "freq": freq(a, param) case "qtc": PrintQTC(a) case "debug": os.Setenv("ardop_debug", "1") fmt.Println("Number of goroutines:", runtime.NumGoroutine()) case "q", "quit": return true case "": return default: printInteractiveUsage() } return } func printInteractiveUsage() { fmt.Println("Uri examples: 'LA3F@5350', 'LA1B-10 v LA5NTA-1', 'LA5NTA:secret@192.168.1.1:54321'") transports := []string{ app.MethodArdop, app.MethodAX25, app.MethodAX25AGWPE, app.MethodAX25Linux, app.MethodAX25SerialTNC, app.MethodPactor, app.MethodTelnet, app.MethodVaraHF, app.MethodVaraFM, } fmt.Println("Transports:", strings.Join(transports, ", ")) cmds := []string{ "connect Connect to a remote station.", "listen Listen for incoming connections.", "unlisten Unregister listener for incoming connections.", "freq [:] Read/set rig frequency.", "heard Display all stations heard over the air.", "qtc Print pending outbound messages.", } fmt.Println("Commands: ") for _, cmd := range cmds { fmt.Printf(" %s\n", cmd) } } func getPrompt(a *app.App) string { var buf bytes.Buffer if listeners := a.ActiveListeners(); len(listeners) > 0 { fmt.Fprintf(&buf, "L%v", listeners) } fmt.Fprint(&buf, "> ") return buf.String() } func PrintHeard(a *app.App) { pf := func(call string, t time.Time) { fmt.Printf(" %-10s (%s)\n", call, t.Format(time.RFC1123)) } for method, heard := range a.Heard() { fmt.Printf("%s:\n", method) for _, v := range heard { pf(v.Callsign, v.Time) } } } func PrintQTC(a *app.App) { msgs, err := a.Mailbox().Outbox() if err != nil { log.Println(err) return } fmt.Printf("QTC: %d.\n", len(msgs)) for _, msg := range msgs { fmt.Printf(`%-12.12s (%s): %s`, msg.MID(), msg.Subject(), fmt.Sprint(msg.To())) if msg.Header.Get("X-P2POnly") == "true" { fmt.Printf(" (P2P only)") } fmt.Println("") } } func freq(a *app.App, param string) { parts := strings.SplitN(param, ":", 2) if parts[0] == "" { fmt.Println("Missing transport parameter.") fmt.Println("Syntax: freq [:]") return } rig, rigName, ok, err := a.VFOForTransport(parts[0]) if err != nil { log.Println(err) return } else if !ok { log.Printf("Rig '%s' not loaded.", rigName) return } if len(parts) < 2 { freq, err := rig.GetFreq() if err != nil { log.Printf("Unable to get frequency: %s", err) } fmt.Printf("%.3f\n", float64(freq)/1e3) return } if _, _, err := setFreq(rig, parts[1]); err != nil { log.Printf("Unable to set frequency: %s", err) } } func setFreq(rig hamlib.VFO, freq string) (newFreq, oldFreq int, err error) { oldFreq, err = rig.GetFreq() if err != nil { return 0, 0, fmt.Errorf("unable to get rig frequency: %w", err) } f, err := strconv.ParseFloat(freq, 64) if err != nil { return 0, 0, err } newFreq = int(f * 1e3) err = rig.SetFreq(newFreq) return } func parseCommand(str string) (mode, param string) { parts := strings.SplitN(str, " ", 2) if len(parts) == 1 { return parts[0], "" } return parts[0], parts[1] } pat-1.0.0/cli/mps.go000066400000000000000000000110341520322237600141720ustar00rootroot00000000000000package cli import ( "context" "fmt" "os" "strings" "github.com/la5nta/pat/app" "github.com/la5nta/pat/internal/cmsapi" ) const ( MPSUsage = `subcommand [options] subcommands: list [--all] List message pickup stations for your callsign, or all MPS with --all clear Delete all message pickup stations for your callsign add [CALLSIGN] Add a message pickup station` MPSExample = ` list List your message pickup stations list --all List all message pickup stations clear Delete all your message pickup stations add W1AW Add W1AW as a message pickup station` ) func MPSHandle(ctx context.Context, a *app.App, args []string) { mycall := a.Options().MyCall if mycall == "" { fmt.Println("ERROR: MyCall not configured") os.Exit(1) } switch cmd, args := shiftArgs(args); cmd { case "list": option, _ := shiftArgs(args) if option == "--all" { err := mpsListAllHandle(ctx, mycall) if err != nil { fmt.Println("ERROR:", err) os.Exit(1) } } else if err := mpsListMineHandle(ctx, mycall); err != nil { fmt.Println("ERROR:", err) os.Exit(1) } case "clear": if err := mpsClearHandle(ctx, a, mycall); err != nil { fmt.Println("ERROR:", err) os.Exit(1) } case "add": addCall, _ := shiftArgs(args) if err := mpsAddHandle(ctx, a, mycall, addCall); err != nil { fmt.Println("ERROR:", err) os.Exit(1) } default: fmt.Println("Missing argument, try 'mps help'.") } } func mpsListAllHandle(ctx context.Context, mycall string) error { mpsList, err := cmsapi.HybridStationList(ctx) if err != nil { return fmt.Errorf("failed to retrieve MPS list: %w", err) } if len(mpsList) == 0 { fmt.Println("No message pickup stations found.") return nil } fmtStr := "%-12s %-16s\n" // Print header fmt.Printf(fmtStr, "mps callsign", "forwarding type") // Print MPS records for _, station := range mpsList { fwdType := "unknown" if station.AutomaticForwarding { fwdType = "automatic" } else if station.ManualForwarding { fwdType = "manual" } fmt.Printf(fmtStr, station.Callsign, fwdType) } return nil } func mpsListMineHandle(ctx context.Context, mycall string) error { mpsList, err := cmsapi.MPSGet(ctx, mycall, mycall) if err != nil { return fmt.Errorf("failed to retrieve your MPS records: %w", err) } if len(mpsList) == 0 { fmt.Println("No message pickup stations configured for your callsign.") return nil } fmtStr := "%-12.12s %s\n" // Print header fmt.Printf(fmtStr, "mps callsign", "timestamp") // Print MPS records for _, mps := range mpsList { fmt.Printf(fmtStr, mps.MpsCallsign, mps.Timestamp.Format("2006-01-02 15:04:05")) } return nil } func mpsClearHandle(ctx context.Context, a *app.App, mycall string) error { password := getPasswordForCallsign(ctx, a, mycall) if password == "" { return fmt.Errorf("password required for clear operation") } mpsList, err := cmsapi.MPSGet(ctx, mycall, mycall) if err != nil { return fmt.Errorf("failed to retrieve your MPS records for display before clear: %w", err) } if err := cmsapi.MPSDelete(ctx, mycall, mycall, password); err != nil { return fmt.Errorf("failed to clear MPS records: %w", err) } fmt.Println("All message pickup stations deleted successfully.") fmt.Println("Previous message pickup stations:") for _, station := range mpsList { fmt.Println(station.MpsCallsign) } return nil } func mpsAddHandle(ctx context.Context, a *app.App, mycall, mpsCallsign string) error { // Validate callsign format mpsCallsign = strings.ToUpper(strings.TrimSpace(mpsCallsign)) if mpsCallsign == "" { return fmt.Errorf("MPS callsign cannot be empty") } password := getPasswordForCallsign(ctx, a, mycall) if password == "" { return fmt.Errorf("password required for add operation") } // get list to ensure that we don't allow more than // 3 stations total, with 2 suggested mpsList, err := cmsapi.MPSGet(ctx, mycall, mycall) if err != nil { return fmt.Errorf("failed to retrieve your MPS records to check if addition is allowed: %w", err) } numMPS := len(mpsList) if numMPS >= 3 { return fmt.Errorf("configuring more than 3 message pickup stations is not allowed") } else if numMPS == 2 { fmt.Println("Warning: You already have 2 message pickup stations configured, more is not recommended. The maximum allowed is 3 stations") } if err := cmsapi.MPSAdd(ctx, mycall, mycall, password, mpsCallsign); err != nil { return fmt.Errorf("failed to add MPS station: %w", err) } fmt.Printf("Message pickup station %s added successfully.\n", mpsCallsign) return nil } pat-1.0.0/cli/position.go000066400000000000000000000051061520322237600152420ustar00rootroot00000000000000package cli import ( "context" "fmt" "log" "os" "strconv" "strings" "time" "github.com/la5nta/pat/app" "github.com/la5nta/pat/internal/gpsd" "github.com/la5nta/wl2k-go/catalog" "github.com/spf13/pflag" ) var ExamplePosition = ` position -c "QRV 145.500MHz" Send position and comment with coordinates retrieved from GPSd. position --latlon 59.123,005.123 Send position 59.123N 005.123E. position --latlon 40.704,-73.945 Send position 40.704N 073.945W. position --latlon -10.123,-60.123 Send position 10.123S 060.123W. ` func PositionHandle(ctx context.Context, app *app.App, args []string) { var latlon, comment string set := pflag.NewFlagSet("position", pflag.ExitOnError) set.StringVar(&latlon, "latlon", "", "") set.StringVarP(&comment, "comment", "c", "", "") set.Parse(args) report := catalog.PosReport{Comment: comment} if latlon != "" { parts := strings.Split(latlon, ",") if len(parts) != 2 { log.Fatal(`Invalid position format. Expected "latitude,longitude".`) } lat, err := strconv.ParseFloat(parts[0], 64) if err != nil { log.Fatal(err) } report.Lat = &lat lon, err := strconv.ParseFloat(parts[1], 64) if err != nil { log.Fatal(err) } report.Lon = &lon } else if app.Config().GPSd.Addr != "" { conn, err := gpsd.Dial(app.Config().GPSd.Addr) if err != nil { log.Fatalf("GPSd daemon: %s", err) } defer conn.Close() conn.Watch(true) posChan := make(chan gpsd.Position) go func() { defer close(posChan) pos, err := conn.NextPos() if err != nil { log.Printf("GPSd: %s", err) return } posChan <- pos }() log.Println("Waiting for position from GPSd...") // TODO: Spinning bar? var pos gpsd.Position select { case p := <-posChan: pos = p case <-ctx.Done(): log.Println("Cancelled") return } report.Lat = &pos.Lat report.Lon = &pos.Lon if app.Config().GPSd.UseServerTime { report.Date = time.Now() } else { report.Date = pos.Time } // Course and speed is part of the spec, but does not seem to be // supported by winlink.org anymore. Ignore it for now. if false && pos.Track != 0 { course := CourseFromFloat64(pos.Track, false) report.Course = &course } } else { fmt.Println("No position available. See --help") os.Exit(1) } if report.Date.IsZero() { report.Date = time.Now() } postMessage(app, report.Message(app.Options().MyCall)) } func CourseFromFloat64(f float64, magnetic bool) catalog.Course { c := catalog.Course{Magnetic: magnetic} str := fmt.Sprintf("%03.0f", f) for i := 0; i < 3; i++ { c.Digits[i] = str[i] } return c } pat-1.0.0/cli/position_test.go000066400000000000000000000030221520322237600162740ustar00rootroot00000000000000package cli import ( "testing" "github.com/la5nta/wl2k-go/catalog" ) func TestCourseFromFloat64(t *testing.T) { tests := []struct { name string input float64 magnetic bool expected catalog.Course }{ { name: "zero degrees", input: 0.0, magnetic: false, expected: catalog.Course{Magnetic: false, Digits: [3]byte{'0', '0', '0'}}, }, { name: "90 degrees", input: 90.0, magnetic: true, expected: catalog.Course{Magnetic: true, Digits: [3]byte{'0', '9', '0'}}, }, { name: "180 degrees", input: 180.0, magnetic: false, expected: catalog.Course{Magnetic: false, Digits: [3]byte{'1', '8', '0'}}, }, { name: "359 degrees", input: 359.0, magnetic: true, expected: catalog.Course{Magnetic: true, Digits: [3]byte{'3', '5', '9'}}, }, { name: "123.456 degrees", input: 123.456, magnetic: false, expected: catalog.Course{Magnetic: false, Digits: [3]byte{'1', '2', '3'}}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := CourseFromFloat64(tt.input, tt.magnetic) if result.Magnetic != tt.expected.Magnetic { t.Errorf("CourseFromFloat64(%v, %v): Magnetic = %v; expected %v", tt.input, tt.magnetic, result.Magnetic, tt.expected.Magnetic) } for i, expectedDigit := range tt.expected.Digits { if result.Digits[i] != expectedDigit { t.Errorf("CourseFromFloat64(%v, %v): Digits[%d] = %c; expected %c", tt.input, tt.magnetic, i, result.Digits[i], expectedDigit) } } }) } } pat-1.0.0/cli/prompter.go000066400000000000000000000060431520322237600152470ustar00rootroot00000000000000package cli import ( "fmt" "log" "os" "strconv" "strings" "github.com/la5nta/pat/app" ) type TerminalPrompter struct{} func (t TerminalPrompter) Prompt(prompt app.Prompt) { q := make(chan struct{}, 1) defer close(q) go func() { select { case <-prompt.Done(): fmt.Printf(" Prompt Aborted - Press ENTER to continue...") case <-q: return } }() switch prompt.Kind { case app.PromptKindMultiSelect: fmt.Println(prompt.Message + ":") answers := map[string]app.PromptOption{} for idx, opt := range prompt.Options { answers[strconv.Itoa(idx+1)] = opt answers[opt.Value] = opt fmt.Printf(" %d: %s (%s)\n", idx+1, opt.Desc, opt.Value) } fmt.Printf("Select [1-%d, ...]: ", len(prompt.Options)) ans := strings.FieldsFunc(readLine(), SplitFunc) var selected []string for _, str := range ans { opt, ok := answers[str] if !ok { log.Printf("Skipping unknown option %q", str) continue } selected = append(selected, opt.Value) } prompt.Respond(strings.Join(selected, ","), nil) case app.PromptKindPassword: passwd, err := promptPassword(prompt.Message + ": ") prompt.Respond(string(passwd), err) case app.PromptKindBusyChannel: fmt.Println(prompt.Message + ":") for prompt.Err() == nil { fmt.Printf("Answer [c(ontinue), a(bort)]: ") switch ans := readLine(); strings.TrimSpace(ans) { case "c", "continue": prompt.Respond("continue", nil) return case "a", "abort": prompt.Respond("abort", nil) return } } case app.PromptKindPreAccountActivation: fmt.Println() fmt.Println("WARNING: We were unable to confirm that your Winlink account is active.") fmt.Println("If you continue, an over-the-air activation will be initiated and you will receive a message with a new password.") fmt.Println("This password will be the only key to your account. If you lose it, it cannot be recovered.") fmt.Printf("It is strongly recommended to use '%s init' or the web gui to create your account before proceeding.\n", os.Args[0]) fmt.Println() for prompt.Err() == nil { fmt.Printf("Answer [c(ontinue), a(bort)]: ") switch ans := readLine(); strings.TrimSpace(ans) { case "c", "continue": prompt.Respond("confirmed", nil) return case "a", "abort": prompt.Respond("abort", nil) return } } case app.PromptKindAccountActivation: fmt.Println() fmt.Println("WARNING:") fmt.Println("You are about to receive a computer-generated password for your new Winlink account.") fmt.Println("Once you download this message, the password inside is the only key to your account.") fmt.Println("If you lose it, it cannot be recovered.") fmt.Println() fmt.Println("Are you ready to receive this message and save the password securely right now?") for prompt.Err() == nil { fmt.Printf("Answer (yes/no): ") switch ans := readLine(); strings.TrimSpace(ans) { case "y", "yes": prompt.Respond("accept", nil) return case "n", "no": prompt.Respond("defer", nil) return } } default: log.Printf("Prompt kind %q not implemented", prompt.Kind) } } pat-1.0.0/cli/read.go000066400000000000000000000102021520322237600143020ustar00rootroot00000000000000// Copyright 2016 Martin Hebnes Pedersen (LA5NTA). All rights reserved. // Use of this source code is governed by the MIT-license that can be // found in the LICENSE file. package cli import ( "context" "fmt" "io" "log" "os" "path/filepath" "sort" "strconv" "strings" "github.com/bndr/gotabulate" "github.com/la5nta/pat/app" "github.com/la5nta/wl2k-go/fbb" "github.com/la5nta/wl2k-go/mailbox" ) var mailboxes = []string{"in", "out", "sent", "archive"} func ReadHandle(ctx context.Context, app *app.App, _ []string) { cancel := exitOnContextCancellation(ctx) defer cancel() w := os.Stdout for { // Query user for mailbox to list printMailboxes(w) fmt.Fprintf(w, "\nChoose mailbox [n]: ") mailboxIdx, ok := readInt() if !ok { break } else if mailboxIdx+1 > len(mailboxes) { fmt.Fprintln(w, "Invalid mailbox number") continue } for { // Fetch messages msgs, err := mailbox.LoadMessageDir(filepath.Join(app.Mailbox().MBoxPath, mailboxes[mailboxIdx])) if err != nil { log.Fatal(err) } else if len(msgs) == 0 { fmt.Fprintf(w, "(empty)\n") break } // Print messages (sorted by date) sort.Sort(fbb.ByDate(msgs)) printMessages(w, msgs) // Query user for message to print fmt.Fprintf(w, "Choose message [n]: ") msgIdx, ok := readInt() if !ok { break } else if msgIdx+1 > len(msgs) { fmt.Fprintf(w, "invalid message number\n") continue } printMsg(w, msgs[msgIdx]) // Mark as read? if mailbox.IsUnread(msgs[msgIdx]) { fmt.Fprintf(w, "Mark as read? [Y/n]: ") ans := readLine() if ans == "" || strings.EqualFold(ans, "y") { mailbox.SetUnread(msgs[msgIdx], false) } } L: for { fmt.Fprintf(w, "Action [C,r,ra,f,e,d,q,?]: ") switch ans := readLine(); ans { case "C", "c", "": break L case "d": fmt.Fprint(w, "Delete message? [y/N]: ") if ans := readLine(); strings.EqualFold(ans, "y") { msg := msgs[msgIdx] mbox := mailboxes[mailboxIdx] path := filepath.Join(app.Mailbox().MBoxPath, mbox, msg.MID()+mailbox.Ext) if err := os.Remove(path); err != nil { log.Printf("Failed to delete message %s from %s: %v", msg.MID(), mbox, err) } else { fmt.Fprintln(w, "Message deleted.") } break L } case "r": composeMessage(app, composerFlags{from: app.Options().MyCall, inReplyTo: msgs[msgIdx].MID()}, true) case "ra": composeMessage(app, composerFlags{from: app.Options().MyCall, inReplyTo: msgs[msgIdx].MID(), replyAll: true}, true) case "f": composeMessage(app, composerFlags{from: app.Options().MyCall, forward: msgs[msgIdx].MID()}, true) case "e": ExtractMessageHandle(ctx, app, []string{msgs[msgIdx].MID()}) case "q": return case "?": fallthrough default: fmt.Fprintln(w, "c - continue") fmt.Fprintln(w, "r - reply") fmt.Fprintln(w, "ra - reply all") fmt.Fprintln(w, "f - forward") fmt.Fprintln(w, "e - extract (attachments)") fmt.Fprintln(w, "d - delete") fmt.Fprintln(w, "q - quit") } } } } } func readInt() (int, bool) { str := readLine() if str == "" { return 0, false } i, _ := strconv.Atoi(str) return i, true } func printMsg(w io.Writer, msg *fbb.Message) { fmt.Fprintf(w, "========================================\n") fmt.Fprintln(w, msg) fmt.Fprintf(w, "========================================\n\n") } func printMailboxes(w io.Writer) { for i, mbox := range mailboxes { fmt.Fprintf(w, "%d:%s\t", i, mbox) } } func printMessages(w io.Writer, msgs []*fbb.Message) { rows := make([][]string, len(msgs)) for i, msg := range msgs { var to string if len(msg.To()) > 0 { to = msg.To()[0].Addr } if len(msg.To()) > 1 { to += ", ..." } var flags string if mailbox.IsUnread(msg) { flags += "N" // New } rows[i] = []string{ fmt.Sprintf("%2d", i), flags, msg.Subject(), msg.From().Addr, msg.Date().String(), to, } } t := gotabulate.Create(rows) t.SetHeaders([]string{"i", "Flags", "Subject", "From", "Date", "To"}) t.SetAlign("left") t.SetWrapStrings(true) t.SetMaxCellSize(60) fmt.Fprintln(w, t.Render("simple")) } pat-1.0.0/cli/riglist.go000066400000000000000000000016621520322237600150560ustar00rootroot00000000000000// Copyright 2016 Martin Hebnes Pedersen (LA5NTA). All rights reserved. // Use of this source code is governed by the MIT-license that can be // found in the LICENSE file. //go:build libhamlib package tui import ( "context" "fmt" "strings" "github.com/la5nta/pat/app" "github.com/la5nta/wl2k-go/rigcontrol/hamlib" ) func init() { cmd := app.Command{ Str: "riglist", Usage: "[search term]", Desc: "Print/search a list of rigcontrol supported transceivers.", HandleFunc: riglistHandle, } Commands = append(Commands[:8], append([]app.Command{cmd}, Commands[8:]...)...) } func riglistHandle(ctx context.Context, _ *app.App, args []string) { if args[0] == "" { fmt.Println("Missing argument") } term := strings.ToLower(args[0]) fmt.Print("id\ttransceiver\n") for m, str := range hamlib.Rigs() { if !strings.Contains(strings.ToLower(str), term) { continue } fmt.Printf("%d\t%s\n", m, str) } } pat-1.0.0/cli/rmslist.go000066400000000000000000000041511520322237600150720ustar00rootroot00000000000000package cli import ( "context" "fmt" "log" "sort" "strconv" "strings" "github.com/la5nta/pat/app" "github.com/spf13/pflag" ) func RMSListHandle(ctx context.Context, a *app.App, args []string) { cancel := exitOnContextCancellation(ctx) defer cancel() set := pflag.NewFlagSet("rmslist", pflag.ExitOnError) mode := set.StringP("mode", "m", "", "") band := set.StringP("band", "b", "", "") forceDownload := set.BoolP("force-download", "d", false, "") byDistance := set.BoolP("sort-distance", "s", false, "") byLinkQuality := set.BoolP("sort-link-quality", "q", false, "Sort by predicted link quality") set.Parse(args) var query string if len(set.Args()) > 0 { query = strings.ToUpper(set.Args()[0]) } *mode = strings.ToLower(*mode) rList, err := a.ReadRMSList(ctx, *forceDownload, func(rms app.RMS) bool { switch { case query != "" && !strings.HasPrefix(rms.Callsign, query): return false case mode != nil && !rms.IsMode(*mode): return false case band != nil && !rms.IsBand(*band): return false default: return true } }) if err != nil { log.Fatal(err) } switch { case *byDistance: sort.Sort(app.ByDist(rList)) case *byLinkQuality: sort.Sort(sort.Reverse(app.ByLinkQuality(rList))) } fmtStr := "%-9.9s [%-6.6s] %-6.6s %3.3s %-15.15s %14.14s %14.14s %5.5s %s\n" // Print header fmt.Printf(fmtStr, "callsign", "gridsq", "dist", "Az", "mode(s)", "dial freq", "center freq", "qual", "url") // Print gateways (separated by blank line) for i, r := range rList { qual := "N/A" if r.Prediction != nil { qual = fmt.Sprintf("%d%%", r.Prediction.LinkQuality) } printRMS(r, qual) if i+1 < len(rList) && rList[i].Callsign != rList[i+1].Callsign { fmt.Println("") } } } func printRMS(r app.RMS, qual string) { fmtStr := "%-9.9s [%-6.6s] %-6.6s %3.3s %-15.15s %14.14s %14.14s %5.5s %s\n" distance := strconv.FormatFloat(float64(r.Distance), 'f', 0, 64) azimuth := strconv.FormatFloat(float64(r.Azimuth), 'f', 0, 64) url := "" if r.URL != nil { url = r.URL.String() } fmt.Printf(fmtStr, r.Callsign, r.Gridsquare, distance, azimuth, r.Modes, r.Dial, r.Freq, qual, url) } pat-1.0.0/cli/schedule.go000066400000000000000000000021241520322237600151670ustar00rootroot00000000000000// Copyright 2016 Martin Hebnes Pedersen (LA5NTA). All rights reserved. // Use of this source code is governed by the MIT-license that can be // found in the LICENSE file. package cli import ( "context" "log" "time" "github.com/gorhill/cronexpr" "github.com/la5nta/pat/app" ) type Job struct { expr *cronexpr.Expression cmd string next time.Time } func scheduleLoop(ctx context.Context, a *app.App) { jobs := make([]*Job, 0, len(a.Config().Schedule)) for exprStr, cmd := range a.Config().Schedule { expr, err := cronexpr.Parse(exprStr) if err != nil { log.Printf("Skipping invalid schedule expression %q: %v", exprStr, err) continue } jobs = append(jobs, &Job{ expr, cmd, expr.Next(time.Now()), }) } go func() { t := time.NewTicker(time.Second) defer t.Stop() for { select { case <-ctx.Done(): return case <-t.C: for _, j := range jobs { if time.Now().Before(j.next) { continue } log.Printf("Executing scheduled command '%s'...", j.cmd) execCmd(a, j.cmd) j.next = j.expr.Next(time.Now()) } } } }() } pat-1.0.0/cli/templates.go000066400000000000000000000016721520322237600154000ustar00rootroot00000000000000package cli import ( "context" "fmt" "log" "strconv" "github.com/la5nta/pat/app" ) const ( TemplatesUsage = `subcommand [option ...] subcommands: update Update standard Winlink form templates. seqset [number] Set the template sequence value. ` TemplatesExample = ` update Download the latest form templates from winlink.org. seqset 0 Reset the current sequence value to 0. ` ) func TemplatesHandle(ctx context.Context, app *app.App, args []string) { switch cmd, args := shiftArgs(args); cmd { case "update": if _, err := app.FormsManager().UpdateFormTemplates(ctx); err != nil { log.Printf("%v", err) } case "seqset": v, err := strconv.Atoi(args[0]) if err != nil { log.Printf("invalid sequence number: %q", args[0]) return } if err := app.FormsManager().SeqSet(v); err != nil { log.Fatal(err) } default: fmt.Println("Missing argument, try 'templates help'.") } } pat-1.0.0/cli/utils.go000066400000000000000000000056671520322237600145520ustar00rootroot00000000000000package cli import ( "bufio" "context" "fmt" "io" "io/fs" "os" "path/filepath" "strings" "unicode" "github.com/la5nta/pat/app" "github.com/la5nta/pat/internal/debug" "github.com/la5nta/wl2k-go/fbb" "github.com/la5nta/wl2k-go/mailbox" "golang.org/x/term" ) func shiftArgs(s []string) (string, []string) { if len(s) == 0 { return "", nil } return strings.TrimSpace(s[0]), s[1:] } var stdin *bufio.Reader func readLine() string { if stdin == nil { stdin = bufio.NewReader(os.Stdin) } str, _ := stdin.ReadString('\n') return strings.TrimSpace(str) } func isTerminal(f interface{ Fd() uintptr }) bool { return term.IsTerminal(int(f.Fd())) } func prompt2(w io.Writer, question, defaultValue string, options ...string) string { var suffix string if len(options) > 0 { // Ensure default is included in options if not already present allOptions := options defaultFound := false for _, opt := range options { if strings.EqualFold(opt, defaultValue) { defaultFound = true break } } if !defaultFound && defaultValue != "" { allOptions = append([]string{defaultValue}, options...) } // Use standard (Y/n) format where uppercase indicates default formatted := make([]string, len(allOptions)) for i, opt := range allOptions { if strings.EqualFold(opt, defaultValue) { formatted[i] = strings.ToUpper(opt) } else { formatted[i] = strings.ToLower(opt) } } suffix = fmt.Sprintf(" (%s)", strings.Join(formatted, "/")) } else if defaultValue != "" { // Free-text field with default value suffix = fmt.Sprintf(" [%s]", defaultValue) } fmt.Fprintf(w, "%s%s: ", question, suffix) response := readLine() if response == "" { return defaultValue } return response } func prompt(question, defaultValue string, options ...string) string { return prompt2(os.Stdout, question, defaultValue, options...) } func promptPassword(prompt string) (string, error) { defer fmt.Fprintln(os.Stdout) fmt.Fprint(os.Stdout, prompt) passwd, err := term.ReadPassword(int(os.Stdin.Fd())) return string(passwd), err } func SplitFunc(c rune) bool { return unicode.IsSpace(c) || c == ',' || c == ';' } func exitOnContextCancellation(ctx context.Context) (cancel func()) { done := make(chan struct{}, 1) go func() { select { case <-ctx.Done(): fmt.Println() os.Exit(1) case <-done: } }() return func() { select { case done <- struct{}{}: default: } } } func openMessage(a *app.App, path string) (*fbb.Message, error) { // Search if only MID is specified. if filepath.Dir(path) == "." && filepath.Ext(path) == "" { debug.Printf("openMessage(%q): Searching...", path) path += mailbox.Ext fs.WalkDir(os.DirFS(a.Mailbox().MBoxPath), ".", func(p string, d fs.DirEntry, err error) error { if d.Name() != path { return nil } debug.Printf("openMessage(%q): Found %q", d.Name(), p) path = filepath.Join(a.Mailbox().MBoxPath, p) return io.EOF }) } return mailbox.OpenMessage(path) } pat-1.0.0/cli/utils_test.go000066400000000000000000000110421520322237600155710ustar00rootroot00000000000000package cli import ( "bufio" "bytes" "strings" "testing" ) func TestShiftArgs(t *testing.T) { tests := []struct { name string input []string expected string expectedRest []string }{ { name: "empty slice", input: []string{}, expected: "", expectedRest: nil, }, { name: "single element", input: []string{"hello"}, expected: "hello", expectedRest: []string{}, }, { name: "multiple elements", input: []string{"hello", "world", "test"}, expected: "hello", expectedRest: []string{"world", "test"}, }, { name: "element with spaces", input: []string{" hello ", "world"}, expected: "hello", expectedRest: []string{"world"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result, rest := shiftArgs(tt.input) if result != tt.expected { t.Errorf("shiftArgs(%v): expected result %q, got %q", tt.input, tt.expected, result) } if len(rest) != len(tt.expectedRest) { t.Errorf("shiftArgs(%v): expected rest length %d, got %d", tt.input, len(tt.expectedRest), len(rest)) } for i, expected := range tt.expectedRest { if rest[i] != expected { t.Errorf("shiftArgs(%v): rest[%d] = %q; expected %q", tt.input, i, rest[i], expected) } } }) } } func setupMockStdin(input string) func() { originalStdin := stdin stdin = bufio.NewReader(strings.NewReader(input)) return func() { stdin = originalStdin } } func TestPrompt(t *testing.T) { tests := []struct { name string input string question string defaultValue string options []string expected string expectedPrompt string }{ { name: "basic prompt with response", input: "test response\n", question: "Test question", defaultValue: "default", expected: "test response", expectedPrompt: "Test question [default]: ", }, { name: "prompt with default value but no input", input: "\n", question: "Test question", defaultValue: "default", expected: "default", expectedPrompt: "Test question [default]: ", }, { name: "prompt with options and matching input", input: "YES\n", question: "Test question", defaultValue: "default", options: []string{"yes", "no"}, expected: "YES", expectedPrompt: "Test question (DEFAULT/yes/no): ", }, { name: "prompt with options and default not in options", input: "\n", question: "Test question", defaultValue: "default", options: []string{"yes", "no"}, expected: "default", expectedPrompt: "Test question (DEFAULT/yes/no): ", }, { name: "prompt with default value not in options", input: "\n", question: "Test question", defaultValue: "yes", options: []string{"no"}, expected: "yes", expectedPrompt: "Test question (YES/no): ", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { restore := setupMockStdin(tt.input) defer restore() var buf bytes.Buffer result := prompt2(&buf, tt.question, tt.defaultValue, tt.options...) if result != tt.expected { t.Errorf("prompt(%q, %q, %v) = %q; expected %q", tt.question, tt.defaultValue, tt.options, result, tt.expected) } promptOutput := buf.String() if promptOutput != tt.expectedPrompt { t.Errorf("prompt output = %q; expected %q", promptOutput, tt.expectedPrompt) } }) } } func TestSplitFunc(t *testing.T) { tests := []struct { input rune expected bool }{ {' ', true}, {'\t', true}, {'\n', true}, {',', true}, {';', true}, {'a', false}, {'A', false}, {'1', false}, } for _, test := range tests { result := SplitFunc(test.input) if result != test.expected { t.Errorf("SplitFunc(%c): expected %v, got %v", test.input, test.expected, result) } } } func TestReadLine(t *testing.T) { tests := []struct { name string input string expected string }{ { name: "read single line", input: "line1\n", expected: "line1", }, { name: "read multiple lines", input: "line1\nline2\n", expected: "line1", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Set up mock stdin restore := setupMockStdin(tt.input) defer restore() result := readLine() if result != tt.expected { t.Errorf("readLine() = %q; expected %q", result, tt.expected) } }) } } pat-1.0.0/cli/version.go000066400000000000000000000033631520322237600150660ustar00rootroot00000000000000package cli import ( "context" "fmt" "log" "time" "github.com/hashicorp/go-version" "github.com/la5nta/pat/app" "github.com/la5nta/pat/internal/buildinfo" "github.com/la5nta/pat/internal/patapi" "github.com/spf13/pflag" ) func VersionHandle(ctx context.Context, app *app.App, args []string) { var ( check bool verbose bool ) set := pflag.NewFlagSet("version", pflag.ExitOnError) set.BoolVarP(&check, "check", "c", false, "Check if new version is available") set.BoolVarP(&verbose, "verbose", "v", false, "Show detailed build information") set.Parse(args) fmt.Printf("%s %s\n", buildinfo.AppName, buildinfo.VersionString()) if verbose { fmt.Println("Modules:") for _, m := range buildinfo.Modules { fmt.Printf(" %s@%s\n", m.Path, m.Version) } } if !check { return } fmt.Println() ctx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() release, err := patapi.GetLatestVersion(ctx) if err != nil { log.Printf("Error checking version: %v", err) return } current := buildinfo.Version fmt.Printf("Current version: %s\n", current) fmt.Printf("Latest version: %s\n", release.Version) // Compare using version parser currentVer, err := version.NewVersion(current) if err != nil { log.Printf("Warning: Invalid version format (current: %s): %v", current, err) return } latestVer, err := version.NewVersion(release.Version) if err != nil { log.Printf("Warning: Invalid version format (latest: %s): %v", release.Version, err) return } switch currentVer.Compare(latestVer) { case 0: fmt.Println("You are running the latest version!") case -1: fmt.Printf("A new version is available!\nRelease URL: %s\n", release.ReleaseURL) case 1: fmt.Println("You are running a development version!") } } pat-1.0.0/debian/000077500000000000000000000000001520322237600135205ustar00rootroot00000000000000pat-1.0.0/debian/.gitignore000066400000000000000000000000531520322237600155060ustar00rootroot00000000000000pat/ files pat.debhelper.log pat.substvars pat-1.0.0/debian/changelog000066400000000000000000000533231520322237600154000ustar00rootroot00000000000000pat (1.0.0) stable; urgency=medium * Fix panic on invalid schedule expressions * Configuration: - Remove deprecated config field "gpsd_addr" (use "gpsd.addr" instead) - Remove deprecated config field "serial_tnc.baudrate" (use "serial_tnc.hbaud" instead) - Remove obsolete serial_tnc.rig field from config * Web Config: - Fix "Back to mailbox" navigation issue - Fix hamlib VFO selection - Fix AX.25 engine Linux options - Add missing PACTOR init script wiring - Add missing schedule wiring - Remove unsupported PACTOR and AX.25 PTT Control options - Fix bfcache restoration issues * Limit prehook paths to the prehooks directory * Replace unmaintained howeyc/gopass with golang.org/x/term -- Martin Hebnes Pedersen Sun, 19 Apr 2026 17:47:46 +0200 pat (0.19.2) stable; urgency=medium * Fix rig selection reset on save in web config * Fix VOACAP midnight edge case * Add verbose option to version command * Remove FW_AUX_ONLY_EXPERIMENT * Various internal refactoring and modernization -- Martin Hebnes Pedersen Sat, 20 Dec 2025 12:13:04 +0100 pat (0.19.1) stable; urgency=medium * Fix Reply All button accumulating event handlers in web GUI * Fix interrupt (ctrl+c) in `pat configure` command * Search brew prefix and share folders for voacapl data directory * Improve Winlink over-the-air account activation flow following CMS fix * Require Go 1.24 or later (due to updated dependencies) -- Martin Hebnes Pedersen Sat, 13 Sep 2025 17:19:00 +0200 pat (0.19.0) stable; urgency=medium * Add VOACAP-based HF propagation prediction * Add support for automatic locator updates from GPSd * Add support for adding and deleting connect aliases in web GUI * Add filtering and pagination to RMS list in web GUI * Add 'Set from GPS' option for locator in web GUI config * Embed RMS list for offline operation * Log to stderr * Minor bug fixes -- Martin Hebnes Pedersen Tue, 02 Sep 2025 18:57:49 +0200 pat (0.18.0) stable; urgency=medium * Add support for Message Pickup Station (MPS) management in the CLI * Add an option to specify the ARDOP connect request count * Configuration Live Reload: - Support for live reloading of the configuration via SIGHUP signals - The web interface automatically reloads when settings are changed * Templates & Forms: - Add a text-only template editor to the web GUI - Add search functionality to the Forms catalog browser - Add support for the `{FormFolder}` tag in templates - Add a spinner during Standard Forms updates - Improved template parsing and detection - Fix duplicate subfolders in the form catalog listing - Fix parsing of ReplyTemplate template command * Version Management: - Show a popup in the web GUI when a new release is available - Add a CLI option to check for updates (`pat version --check`) * Busy Channel Detection: - Add busy channel detection for VARA HF and VARA FM - Show a prompt with an option to continue when waiting for a clear channel - Improve ARDOP busy channel detection and handling * Onboarding: - Guide users toward safer, online account creation to prevent lockouts - Add the `pat init` command, a CLI wizard for basic configuration and account creation - Add an account creation wizard to the web interface - Prompt users to create a new account online before their first connection in the web interface - Add reminders to set a password recovery email * CLI message reader/composer: - Refactor composer to unify interactive, non-interactive and template-based composition - Add message preview and confirmation prompt in interactive mode - Add option to forward message - Add option to delete message * Web GUI: - Add "Edit as new..." message action - Show desktop notifications for prompts - Add a favicon - Fix highlighting of the active tab in the web interface navbar - Fix bug removing attachment from composer - Fix Telnet URI building - Fix unintentional QSY during the initial load of the GUI * Web Config: - Fix VARA HF/FM bandwidth options - Prevent unwanted config saves on alias/schedule buttons -- Martin Hebnes Pedersen Thu, 31 Jul 2025 13:10:21 +0200 pat (0.17.0) stable; urgency=medium * Add Configuration page to web GUI * Add optional message download prompting with size limits * Add Reply All message action * Major improvements to attachment handling: - Include attachments when forwarding messages - Add button to remove attachments - Fix attachments not retained when adding more to the same message - Improve attachment layout * Templates: - Add support for multi-line prompt in text templates - Add CLI command 'templates' for managing forms/templates - Add support for template sequence numbers - Fix UTF-8 BOM handling in templates - Fix empty attached_text form values panic * Fix Safari GPS timestamp issues * Fix AGWPE timeout issues * Fix FBB protocol turnover handling when all messages are deferred and/or rejected -- Martin Hebnes Pedersen Sun, 25 May 2025 19:04:16 +0200 pat (0.16.0) stable; urgency=medium * Major overhaul of the template/forms engine (bug fixes and enhancements) * Add low-level support for packet node traversal (connection pre-hook) * Add support for administering Winlink password recovery email address * Always auto-convert images when using CLI composer (remove prompt) * Fix missing /tmp folder in Docker image * Fix error handling in interactive command 'freq' -- Martin Hebnes Pedersen Sun, 14 Apr 2024 12:43:32 +0200 pat (0.15.1) stable; urgency=medium * Support config overrides using env variables * Only attach Forms XML if a viewer file is defined * Fix handling of SVG attachments (don't attempt auto resize) * VARA: Silence "got a vara command I wasn't expecting..." messages * VARA: Fix leak when re-initializing the modem connection * hamlib: Fix compatibility with rigctld in VFO Mode * hamlib: Fix tests and compilation when statically linking libhamlib * New experiment FW_AUX_ONLY_EXPERIMENT (disabled by default) * Require Go 1.19 or later (due to updated dependencies) -- Martin Hebnes Pedersen Sun, 5 Nov 2023 12:44:08 +0100 pat (0.15.0) stable; urgency=medium * Restore previous connect parameters from browser's local storage * Add missing AX.25 schemes to connect modal's transport dropdown * Fix clearing of To/Cc fields after message is posted to outbox * Fix alignment of connect modal input fields * Improve the dirty disconnect feature * Add deprecation warning for newly deprecated config options * Remove support for previously deprecated config options * AGWPE: Add support for QtSoundModem * AGWPE: Wait for modem ack on dial cancellation * VARA: Add support for inbound (P2P) connections * VARA: Improved throughput, various bug fixes and other improvements * ARDOP: Experimental FSKONLY support (with ARDOP_FSKONLY_EXPERIMENT=1) -- Martin Hebnes Pedersen Sat, 10 Jun 2023 13:07:32 +0200 pat (0.14.1) stable; urgency=medium * VARA: Fix panic on 32-bit builds -- Martin Hebnes Pedersen Web, 02 May 2023 08:34:42 +0200 pat (0.14.0) stable; urgency=medium * AX.25: Implement ability to switch between different AX.25 engines * AX.25: Add AGWPE support (use Direwolf directly over TCP on all platforms) * Winlink HTML Forms: Various compatibility fixes * VARA: Switch to more idiomatic config fields * VARA: Improved progress report on outbound traffic * VARA: Add support for dial cancellation * VARA: Reject inbound P2P sessions (listener not supported yet) -- Martin Hebnes Pedersen Web, 19 Apr 2023 21:09:12 +0200 pat (0.13.1) stable; urgency=medium * Fix panic when using unregistered VARA instances * Use VARA HF/FM defaults if undefined in config * Fix case sensitive matching when resolving aux addresses' passwords -- Martin Hebnes Pedersen Sat, 17 Sep 2022 09:05:48 +0200 pat (0.13.0) stable; urgency=medium * Add support for VARAHF and VARAFM * Add support for setting the ARDOP ARQ bandwidth when dialing a connection * Include linux/arm64 deb package in releases * Remove support for WINMOR TNCs * Add generic support for dial cancellation * Implement dial cancellation for ax25:// and telnet:// * Improved non-interactive CLI compose command * Improved shutdown behavior * Improved FBB protocol compatibility with BPQ Mail * Minor improvements and bug fixes to the PACTOR and serial-tnc transports * Add a build system and package management for the Web GUI -- Martin Hebnes Pedersen Sat, 20 Aug 2022 21:42:05 +0200 pat (0.12.1) stable; urgency=medium * Add support for configurable telnet dial timeout (for Iridium GO users) * Add support for scriptable message composition * Add CLI command `env` for retrieving related environment variables (for scripting) * More reliable Forms updates by using a new API for retrieving latest version and archive URL * Improve websocket handling * Fix bug in Forms update procedure that would delete the OS temp directory in rare cases * Fix bug with pactor serial communication on macOS (Darwin) * Fix bug with Web GUI and Message IDs containing the hash (`#`) symbol -- Martin Hebnes Pedersen Sat, 11 Dec 2021 15:14:22 +0100 pat (0.12.0) stable; urgency=medium * Follow the XDG Base Directory Specification * Add support for sending in precedence order * Add new serial-tnc baudrate configuration options * Fix bug in forms parsing leading to missing forms * Fix permissions issue when updating forms * Fix FBB protocol handshake issue * Improve fsnotify handling for mailbox events * More descriptive error on premature disconnect * Add basic debug logging capabilities * Various dependency updates and refactoring -- Martin Hebnes Pedersen Sun, 31 Oct 2021 17:28:02 +0100 pat (0.11.0) stable; urgency=medium * Add support for Winlink HTML Forms * Add support for individual passords for auxiliary addresses * Add ability to abort ongoing dialing/connection in Web GUI * Add systemd unit file for rigctld * Improve version reporting to Winlink API * Improve websocket handling * Improve visibility of QSY errors in Web GUI * Improve 'reply' and 'forward' functionality in Web GUI * Fix issue with azimuth calculation when distance is zero * Fix incorrect transport URI scheme for packet nodes * Fix build on FreeBSD and macOS. * Avoid truncating rmslist cache on refresh failure * Avoid recompressing images where the resulting file size increases * Require Go 1.16 or later -- Martin Hebnes Pedersen Wed, 30 Jun 2021 21:13:40 +0100 pat (0.10.0) stable; urgency=medium * Add support for P4 Dragon modems * Add RMS list viewer in Web GUI's connect modal * Add support for additional connect parameters for pactor * New max length of message attachment filenames (255 characters) -- Martin Hebnes Pedersen Thu, 08 Sep 2020 19:39:40 +0100 pat (0.9.0) stable; urgency=medium * Less aggressive websocket timeout * Add column sorting in Web GUI * Require Go 1.10 or later * Fix GPSd config bug introduced in v0.8.0 * Fix (mainly macOS) bug related to many open file descriptors -- Martin Hebnes Pedersen Wed, 19 Feb 2020 20:13:18 +0100 pat (0.8.0) stable; urgency=medium * GPSd support in Web GUI * User configurable Service Code * High Accuracy HTML5 Geolocation * Minor PACTOR enhancements and bug fixes * Fixed ARDOP listener issue -- Martin Hebnes Pedersen Thu, 03 Oct 2019 21:48:51 +0200 pat (0.7.0) stable; urgency=medium * Support PACTOR PTC-II and PTC-III (https://github.com/la5nta/pat/issues/40) * Fix QSY frequency rounding error (https://github.com/la5nta/pat/issues/147) * Fix panic on ARDOP TNC connection teardown (https://github.com/la5nta/pat/issues/137) * Fix ARDOP compatibility issue (https://github.com/la5nta/pat/issues/139) -- Martin Hebnes Pedersen Wed, 18 Sep 2019 21:56:17 +0200 pat (0.6.1) stable; urgency=medium * Add deb package `dist` as conflicting package (https://github.com/la5nta/pat/issues/131) * Include systemd unit file for ARDOPc (https://github.com/la5nta/pat/issues/130) * Set correct URL parameter for serial-tnc.Baudrate (https://github.com/la5nta/pat/issues/129) * Fix Go 1.10 compatibility issue (https://github.com/la5nta/pat/issues/121) -- Martin Hebnes Pedersen Sun, 21 Apr 2018 11:23:40 +0200 pat (0.6.0) stable; urgency=high * Support Winlink's new mixed-case password scheme (https://github.com/la5nta/pat/issues/113) * Support for distance and azumuth in rmslist (https://github.com/la5nta/pat/pull/112) * Improved ARDOP ID-frame parser -- Martin Hebnes Pedersen Mon, 22 Jan 2018 21:41:13 +0100 pat (0.5.1) stable; urgency=medium * Support ARDOP >= v1.0 (https://github.com/la5nta/pat/issues/108) * Add rmslist support for ARDOP nodes * Switch to the new Winlink rest API (https://github.com/la5nta/pat/issues/110) * Fix bug which caused WINMOR connection failure when dialing the (non-idle) TNC -- Martin Hebnes Pedersen Tue, 12 Dec 2017 19:03:04 +0100 pat (0.5.0) stable; urgency=high * Fix XSS vulnerability when serving attachments over HTTP (https://github.com/la5nta/pat/issues/105) * Gracefully recover/initialize failed external devices (https://github.com/la5nta/pat/issues/88) * Switch to the new Winlink CMS and API hostname (https://github.com/la5nta/pat/issues/104) * Add config option for WINMOR's Drive Level parameter (https://github.com/la5nta/pat/issues/99) * Add password prompt in web GUI (https://github.com/la5nta/pat/issues/90) * Include man pages in deb and pkg packages (https://github.com/la5nta/pat/pull/91) * Various minor web GUI improvements (https://github.com/la5nta/pat/issues/97) -- Martin Hebnes Pedersen Sat, 18 Nov 2017 11:40:28 +0100 pat (0.4.0) stable; urgency=medium * Desktop notifications for web GUI users (https://github.com/la5nta/pat/issues/85) * New status indicator in web GUI for display of various alerts and info (https://github.com/la5nta/pat/issues/86) * Add Cc field to the web GUI composer (https://github.com/la5nta/pat/issues/83) * Tokenize address input in the web GUI composer (https://github.com/la5nta/pat/issues/84) * Check for empty To/Cc on compose (https://github.com/la5nta/pat/issues/89) -- Martin Hebnes Pedersen Tue, 17 Sep 2017 11:14:59 +0200 pat (0.3.0) stable; urgency=high (Fixes compatibility with an upcoming Winlink CMS release) * Fix critical compatibility issues with WL2K-4.0 aka "AWS-CMS" (https://github.com/la5nta/pat/issues/81) * Fix close of AX.25 listener on Linux (https://github.com/la5nta/pat/issues/68) * Add "Delete" and "Move to archive" actions in web GUI (https://github.com/la5nta/pat/issues/63) -- Martin Hebnes Pedersen Tue, 18 Jul 2017 21:13:08 +0200 pat (0.2.4) stable; urgency=medium * Add progress bar for message transfer in web GUI (https://github.com/la5nta/pat/pull/78) * Properly parse offset in B2 compressed message header for BPQ compatibility (https://github.com/la5nta/pat/issues/74) * Fix libax25 segfault on invalid axport (https://github.com/la5nta/pat/issues/73) * Silence FREQUENCY parse errors for ardop (https://github.com/la5nta/pat/issues/75) -- Martin Hebnes Pedersen Tue, 28 Feb 2017 19:07:00 +0100 pat (0.2.3) stable; urgency=medium * Support ARDOP >= v0.9 (https://github.com/la5nta/pat/issues/69) * Improve list parsing in various UI fields * Handle non-ascii attachment names -- Martin Hebnes Pedersen Fri, 27 Jan 2016 18:17:30 +0100 pat (0.2.2) stable; urgency=medium * Ensure default config is written before opening the configuration editor (https://github.com/la5nta/pat/issues/70) * Add some missing config defaults -- Martin Hebnes Pedersen Thu, 01 Dec 2016 18:14:09 +0100 pat (0.2.1) stable; urgency=medium * Support ARDOP >= v0.6 (https://github.com/la5nta/pat/issues/60) * Fix bug that caused 'configure' to fail if config format was invalid (https://github.com/la5nta/pat/issues/62) * Add position format examples for --latlon (https://github.com/la5nta/pat/issues/65) * Statically link libax25 (linux) to avoid crash on incompatible shared library (https://github.com/la5nta/pat/issues/59) -- Martin Hebnes Pedersen Wed, 12 Oct 2016 20:24:18 +0200 pat (0.2.0) stable; urgency=medium * Support Radio only - Winlink Hybrid Network (https://github.com/la5nta/pat/issues/44) * Switch to Go port of lzhuf (https://github.com/la5nta/pat/issues/50) * Linux ax25 scripts: Add method for custom TNC initialization (https://github.com/la5nta/pat/issues/53) * Fix ardop PTT rigcontrol (https://github.com/la5nta/pat/issues/58) * Minor bug fixes and improvements in the web GUI -- Martin Hebnes Pedersen Fri, 05 Aug 2016 15:16:51 +0200 pat (0.1.5) stable; urgency=medium * Fix bug that caused command-line interface composer's prompt scan to see whitespace as end of line (https://github.com/la5nta/pat/issues/45) * Fix Mac OS default install path (https://github.com/la5nta/pat/issues/47) -- Martin Hebnes Pedersen Mon, 27 Jun 2016 22:43:36 +0200 pat (0.1.4) stable; urgency=medium * Fix case where secure_login_password was ignored if mycall was not all upper case (https://github.com/la5nta/pat/issues/42) * Support image resize in cli composer (https://github.com/la5nta/pat/issues/38) * Remove imagemagick dependency for image resize (https://github.com/la5nta/pat/issues/13) * Minor improvement of cli mailbox navigation (https://github.com/la5nta/pat/issues/39) -- Martin Hebnes Pedersen Thu, 09 Jun 2016 21:02:42 +0200 pat (0.1.3) stable; urgency=medium * Add filename extension for mailbox messages (https://github.com/la5nta/pat/issues/34) * Fix broken ax25:// digipeater syntax (https://github.com/la5nta/pat/issues/33) * Enable gzip experiment by default (https://github.com/la5nta/pat/issues/29) -- Martin Hebnes Pedersen Sat, 07 May 2016 22:18:12 +0200 pat (0.1.2) stable; urgency=medium * Fix callsign casing bug (https://github.com/la5nta/pat/issues/19) * Fix web composer Re: prefix issues in replies (https://github.com/la5nta/pat/issues/30) * Support running http server while in interactive mode (https://github.com/la5nta/pat/issues/26) * Send smallest messages first (suggested in the Winlink FAQ) (https://github.com/la5nta/pat/issues/25) * Fix handling of proposal code H (https://github.com/la5nta/pat/issues/25) * Fix handling of blocks with all messages deferred/rejected (https://github.com/la5nta/pat/issues/25) * Fix unstable serialization of messages that could result in corrupt partial message transfer (https://github.com/la5nta/pat/issues/25) * Support both utf8 and iso-8859-1 encoded subject header (https://github.com/la5nta/pat/issues/23) * Re-implement ctrl+c for aborting connect/session (https://github.com/la5nta/pat/issues/22) * Fix GUI post button issues on some browsers (https://github.com/la5nta/pat/issues/21) * Fix WINMOR unexpected EOF issue on session termination (https://github.com/la5nta/pat/issues/20) * Fix improper handling of callsign casing (https://github.com/la5nta/pat/issues/19) -- Martin Hebnes Pedersen Sat, 02 Apr 2016 10:41:16 +0200 pat (0.1.1) stable; urgency=medium * Fix various file locking errors on Windows (https://github.com/la5nta/pat/issues/9). * Automatic version reporting to Winlink CMS Web Services. -- Martin Hebnes Pedersen Fri, 11 Mar 2016 21:06:16 +0100 pat (0.1.0) stable; urgency=medium * Initial release under new name. * Fix leak that caused increasing CPU load. * Add band filtering for rmslist command. * Fix winmor robust issues. -- Martin Hebnes Pedersen Sun, 06 Mar 2016 14:09:11 +0100 wl2k-go (0.0.4) stable; urgency=medium * Fixed parse error of Date field from RMS Relay'ed messages (https://github.com/la5nta/wl2k-go/issues/29). * Fixed parse of ax25 URLs with digipeaters (https://github.com/la5nta/wl2k-go/issues/28). * Fixed panic on misconfigured (empty) axport (https://github.com/la5nta/wl2k-go/issues/27). * Prompt user for login password if mycall is overridden by --mycall even though a password is defined in config. * Run winmor in robust mode during handshake and proposal chatter. * GPSd support (for position reporting using a local serial/usb GPS). -- Martin Hebnes Pedersen Sun, 14 Feb 2016 18:19:02 +0100 wl2k-go (0.0.3) stable; urgency=medium * Fixed web ui assets bug (https://github.com/la5nta/wl2k-go/issues/26). * Fixed systemd user install script. -- Martin Hebnes Pedersen Thu, 14 Jan 2016 19:26:49 +0100 wl2k-go (0.0.2) stable; urgency=medium * Fixed ARDOPc issues. -- Martin Hebnes Pedersen Sun, 10 Jan 2016 15:56:00 +0100 wl2k-go (0.0.1) stable; urgency=medium * Initial release. -- Martin Hebnes Pedersen Sun, 04 Nov 2016 16:24:24 +0100 pat-1.0.0/debian/compat000066400000000000000000000000021520322237600147160ustar00rootroot000000000000007 pat-1.0.0/debian/control000066400000000000000000000007601520322237600151260ustar00rootroot00000000000000Source: pat Section: ham Priority: extra Maintainer: Martin Hebnes Pedersen Homepage: http://getpat.io Build-Depends: debhelper (>= 7.0.50~), golang (>= 2:1.24), libax25, libax25-dev Standards-Version: 3.9.1 Package: pat Architecture: amd64 i386 armhf arm64 Conflicts: wl2k-go, dist Replaces: wl2k-go Recommends: libhamlib-utils (>= 1.2), ax25-tools, gpsd (>= 2.90), voacapl Suggests: tmd710-tncsetup Description: A portable Winlink client for amateur radio email. pat-1.0.0/debian/pat.manpages000066400000000000000000000000101520322237600160100ustar00rootroot00000000000000man/*.1 pat-1.0.0/debian/pat@.service000066400000000000000000000003521520322237600157660ustar00rootroot00000000000000[Unit] Description=pat - Winlink client for %I Documentation=https://github.com/la5nta/pat/wiki After=ax25.service network.target [Service] User=%i ExecStart=/usr/bin/pat http Restart=on-failure [Install] WantedBy=multi-user.target pat-1.0.0/debian/rules000077500000000000000000000010231520322237600145740ustar00rootroot00000000000000#!/usr/bin/make -f # -*- makefile -*- PKGDIR=debian/pat %: dh $@ clean: dh_clean rm -rf $(PKGDIR) build: ./make.bash binary-arch: clean build dh_prep dh_installdirs mkdir -p $(PKGDIR)/usr/bin mkdir -p $(PKGDIR)/usr/share/pat mkdir -p $(PKGDIR)/lib/systemd/system mv ./pat $(PKGDIR)/usr/bin/ cp -r share/* $(PKGDIR)/usr/share/pat/ cp debian/pat@.service $(PKGDIR)/lib/systemd/system/ dh_installman dh_strip dh_compress dh_fixperms dh_installdeb dh_gencontrol dh_md5sums dh_builddeb binary: binary-arch pat-1.0.0/docker-compose.yml000066400000000000000000000001751520322237600157360ustar00rootroot00000000000000services: pat: image: la5nta/pat build: . volumes: - ./docker-data:/app/pat ports: - 8080:8080 pat-1.0.0/go.mod000066400000000000000000000031761520322237600134130ustar00rootroot00000000000000module github.com/la5nta/pat go 1.24.0 tool golang.org/x/vuln/cmd/govulncheck require ( github.com/adrg/xdg v0.5.3 github.com/bndr/gotabulate v1.1.3-0.20170315142410-bc555436bfd5 github.com/fsnotify/fsnotify v1.9.0 github.com/gorhill/cronexpr v0.0.0-20180427100037-88b0669f7d75 github.com/gorilla/mux v1.8.1 github.com/gorilla/websocket v1.5.3 github.com/harenber/ptc-go/v2 v2.2.4 github.com/hashicorp/go-version v1.8.0 github.com/kelseyhightower/envconfig v1.4.0 github.com/la5nta/wl2k-go v1.0.1 github.com/microcosm-cc/bluemonday v1.0.27 github.com/n8jja/Pat-Vara v1.2.0 github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 github.com/pd0mz/go-maidenhead v1.0.0 github.com/peterh/liner v1.2.2 github.com/spf13/pflag v1.0.10 golang.org/x/sync v0.19.0 golang.org/x/term v0.30.0 ) require ( dario.cat/mergo v1.0.2 // indirect github.com/albenik/go-serial/v2 v2.6.1 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/uax29/v2 v2.4.0 // indirect github.com/creack/goselect v0.1.3 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/howeyc/crc16 v0.0.0-20171223171357-2b2a61e366a6 // indirect github.com/mattn/go-runewidth v0.0.19 // indirect github.com/paulrosania/go-charset v0.0.0-20190326053356-55c9d7a5834c // indirect go.uber.org/multierr v1.11.0 // indirect golang.org/x/mod v0.22.0 // indirect golang.org/x/net v0.38.0 // indirect golang.org/x/sys v0.40.0 // indirect golang.org/x/telemetry v0.0.0-20240522233618-39ace7a40ae7 // indirect golang.org/x/tools v0.29.0 // indirect golang.org/x/vuln v1.1.4 // indirect ) pat-1.0.0/go.sum000066400000000000000000000232631520322237600134370ustar00rootroot00000000000000dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78= github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ= github.com/albenik/go-serial/v2 v2.5.0/go.mod h1:ySdCqoERscw1xluK1n62R8Faoyu+jXKwVHPa1lSSAew= github.com/albenik/go-serial/v2 v2.6.1 h1:AhVjPVegSa/loFUmaIPNdhbeL/+6b+pCNgeCJ9CT7W8= github.com/albenik/go-serial/v2 v2.6.1/go.mod h1:sqQA6eeZHKUB6rAgrBsP/8d3Go5Md5cjCof1WcyaK0o= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/bndr/gotabulate v1.1.3-0.20170315142410-bc555436bfd5 h1:D48YSLPNJ8WpdwDqYF8bMMKUB2bgdWEiFx1MGwPIdbs= github.com/bndr/gotabulate v1.1.3-0.20170315142410-bc555436bfd5/go.mod h1:0+8yUgaPTtLRTjf49E8oju7ojpU11YmXyvq1LbPAb3U= github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= github.com/clipperhouse/uax29/v2 v2.4.0 h1:RXqE/l5EiAbA4u97giimKNlmpvkmz+GrBVTelsoXy9g= github.com/clipperhouse/uax29/v2 v2.4.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/creack/goselect v0.1.2/go.mod h1:a/NhLweNvqIYMuxcMOuWY516Cimucms3DglDzQP3hKY= github.com/creack/goselect v0.1.3 h1:MaGNMclRo7P2Jl21hBpR1Cn33ITSbKP6E49RtfblLKc= github.com/creack/goselect v0.1.3/go.mod h1:a/NhLweNvqIYMuxcMOuWY516Cimucms3DglDzQP3hKY= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/google/go-cmdtest v0.4.1-0.20220921163831-55ab3332a786 h1:rcv+Ippz6RAtvaGgKxc+8FQIpxHgsF+HBzPyYL2cyVU= github.com/google/go-cmdtest v0.4.1-0.20220921163831-55ab3332a786/go.mod h1:apVn/GCasLZUVpAJ6oWAuyP7Ne7CEsQbTnc0plM3m+o= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/renameio v0.1.0 h1:GOZbcHa3HfsPKPlmyPyN2KEohoMXOhdMbHrvbpl2QaA= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/gorhill/cronexpr v0.0.0-20180427100037-88b0669f7d75 h1:f0n1xnMSmBLzVfsMMvriDyA75NB/oBgILX2GcHXIQzY= github.com/gorhill/cronexpr v0.0.0-20180427100037-88b0669f7d75/go.mod h1:g2644b03hfBX9Ov0ZBDgXXens4rxSxmqFBbhvKv2yVA= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/harenber/ptc-go/v2 v2.2.4 h1:jeaVenKc0Qu86uge6M9R2JTLr77iPDJ4jhpnmpditFI= github.com/harenber/ptc-go/v2 v2.2.4/go.mod h1:wubxr0EvHHQ++eIR/ZSP7yustDHEW0ROdMKmiu0eOVw= github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4= github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/howeyc/crc16 v0.0.0-20171223171357-2b2a61e366a6 h1:IIVxLyDUYErC950b8kecjoqDet8P5S4lcVRUOM6rdkU= github.com/howeyc/crc16 v0.0.0-20171223171357-2b2a61e366a6/go.mod h1:JslaLRrzGsOKJgFEPBP65Whn+rdwDQSk0I0MCRFe2Zw= github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8= github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/la5nta/wl2k-go v0.7.3/go.mod h1:rTQaxPiAFD3pWGWN8Lh+BskN3Fpii84GoVwpTHNiCjE= github.com/la5nta/wl2k-go v0.11.5/go.mod h1:0c+/9KyDj7Ra7C/O4rVUYx1CzvdtS65di/93wlI22fo= github.com/la5nta/wl2k-go v1.0.1 h1:OCxkWQOMKKyCqxl5+zDoaivAzKlEA9elrgCJvZ+6Fi4= github.com/la5nta/wl2k-go v1.0.1/go.mod h1:DuuDzLELrEoDA8nYPa5KnJSQjGnxYS1HhveAJLNgENk= github.com/mattn/go-runewidth v0.0.3/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/n8jja/Pat-Vara v1.2.0 h1:ugQWynk1g2DHMcrB+0beWnyfVqLDye06wFgPDhL4jjg= github.com/n8jja/Pat-Vara v1.2.0/go.mod h1:9ovT5w1MeVtQ336AqhoPmgiQ4eGDgNiygBxFvAiSJbc= github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= github.com/paulrosania/go-charset v0.0.0-20151028000031-621bb39fcc83/go.mod h1:YnNlZP7l4MhyGQ4CBRwv6ohZTPrUJJZtEv4ZgADkbs4= github.com/paulrosania/go-charset v0.0.0-20190326053356-55c9d7a5834c h1:P6XGcuPTigoHf4TSu+3D/7QOQ1MbL6alNwrGhcW7sKw= github.com/paulrosania/go-charset v0.0.0-20190326053356-55c9d7a5834c/go.mod h1:YnNlZP7l4MhyGQ4CBRwv6ohZTPrUJJZtEv4ZgADkbs4= github.com/pd0mz/go-maidenhead v1.0.0 h1:zl2AXA36LnmP5TDEfshM0fWi1mc08fNc6qhj7YD5xjw= github.com/pd0mz/go-maidenhead v1.0.0/go.mod h1:4Q+QSDCqWqlabstLGUVm47rAcL06nEEty2d3KzsTNMk= github.com/peterh/liner v1.2.2 h1:aJ4AOodmL+JxOZZEL2u9iJf8omNRpqHc/EbrK+3mAXw= github.com/peterh/liner v1.2.2/go.mod h1:xFwJyiKIXJZUKItq5dGHZSTBRAuG/CpeNpWLyiNRNwI= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/tarm/goserial v0.0.0-20151007205400-b3440c3c6355/go.mod h1:jcMo2Odv5FpDA6rp8bnczbUolcICW6t54K3s9gOlgII= go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/multierr v1.7.0/go.mod h1:7EAYxJLBy9rStEaz58O2t4Uvip6FSURkq8/ppBp95ak= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4= golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20211117180635-dee7805ff2e1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/telemetry v0.0.0-20240522233618-39ace7a40ae7 h1:FemxDzfMUcK2f3YY4H+05K9CDzbSVr2+q/JKN45pey0= golang.org/x/telemetry v0.0.0-20240522233618-39ace7a40ae7/go.mod h1:pRgIJT+bRLFKnoM1ldnzKoxTIn14Yxz928LQRYYgIN0= golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y= golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= golang.org/x/tools v0.29.0 h1:Xx0h3TtM9rzQpQuR4dKLrdglAmCEN5Oi+P74JdhdzXE= golang.org/x/tools v0.29.0/go.mod h1:KMQVMRsVxU6nHCFXrBPhDB8XncLNLM0lIy/F14RP588= golang.org/x/vuln v1.1.4 h1:Ju8QsuyhX3Hk8ma3CesTbO8vfJD9EvUBgHvkxHBzj0I= golang.org/x/vuln v1.1.4/go.mod h1:F+45wmU18ym/ca5PLTPLsSzr2KppzswxPP603ldA67s= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= pat-1.0.0/internal/000077500000000000000000000000001520322237600141125ustar00rootroot00000000000000pat-1.0.0/internal/buildinfo/000077500000000000000000000000001520322237600160655ustar00rootroot00000000000000pat-1.0.0/internal/buildinfo/VERSION.go000066400000000000000000000010511520322237600175360ustar00rootroot00000000000000// Copyright 2017 Martin Hebnes Pedersen (LA5NTA). All rights reserved. // Use of this source code is governed by the MIT-license that can be // found in the LICENSE file. package buildinfo const ( // AppName is the friendly name of the app. // // Forks should consider using a different name. AppName = "Pat" // Version is the app's SemVer. // // Forks should NOT bump this unless they use a unique AppName. The Winlink // system uses this to derive the "these users should upgrade" wall of shame // from CMS connects. Version = "1.0.0" ) pat-1.0.0/internal/buildinfo/debug.go000066400000000000000000000011231520322237600174770ustar00rootroot00000000000000package buildinfo import ( "runtime/debug" ) // Modules is a list describing all modules that is part of this build. var Modules = func() []*debug.Module { info, ok := debug.ReadBuildInfo() if !ok { return nil } return append([]*debug.Module{&info.Main}, info.Deps...) }() // GitRev is the git commit hash that the binary was built at. var GitRev = func() string { if info, ok := debug.ReadBuildInfo(); ok { for _, setting := range info.Settings { if setting.Key == "vcs.revision" && len(setting.Value) > 7 { return setting.Value[:7] } } } return "unknown origin" }() pat-1.0.0/internal/buildinfo/strings.go000066400000000000000000000013751520322237600201130ustar00rootroot00000000000000package buildinfo import ( "fmt" "runtime" ) // VersionString returns a very descriptive version including the app SemVer, git rev plus the // Golang OS, architecture and version. func VersionString() string { return fmt.Sprintf("%s %s/%s - %s", VersionStringShort(), runtime.GOOS, runtime.GOARCH, runtime.Version()) } // VersionStringShort returns the app SemVer and git rev. func VersionStringShort() string { return fmt.Sprintf("v%s (%s)", Version, GitRev) } // UserAgent returns a suitable HTTP user agent string containing app name, SemVer, git rev, plus // the Golang OS, architecture and version. func UserAgent() string { return fmt.Sprintf("%v/%v (%v) %v (%v; %v)", AppName, Version, GitRev, runtime.Version(), runtime.GOOS, runtime.GOARCH) } pat-1.0.0/internal/cmsapi/000077500000000000000000000000001520322237600153665ustar00rootroot00000000000000pat-1.0.0/internal/cmsapi/api.go000066400000000000000000000145551520322237600165000ustar00rootroot00000000000000// Copyright 2016 Martin Hebnes Pedersen (LA5NTA). All rights reserved. // Use of this source code is governed by the MIT-license that can be // found in the LICENSE file. package cmsapi import ( "bytes" "compress/gzip" "context" _ "embed" "encoding/json" "fmt" "io" "log" "net/http" "net/url" "os" "strconv" "strings" "time" "github.com/la5nta/pat/internal/buildinfo" ) const ( RootURL = "https://api.winlink.org" PathVersionAdd = "/version/add" PathGatewayStatus = "/gateway/status.json" PathAccountExists = "/account/exists" PathPasswordValidate = "/account/password/validate" PathAccountAdd = "/account/add" // AccessKey issued December 2017 by the WDT for use with Pat AccessKey = "1880278F11684B358F36845615BD039A" ) type VersionAdd struct { Callsign string `json:"callsign"` Program string `json:"program"` Version string `json:"version"` Comments string `json:"comments,omitempty"` } func (v VersionAdd) Post() error { req := newJSONRequest("POST", PathVersionAdd, nil, bodyJSON(v)) var resp struct{ ResponseStatus responseStatus } if err := doJSON(req, &resp); err != nil { return err } return resp.ResponseStatus.errorOrNil() } func AccountExists(ctx context.Context, callsign string) (bool, error) { params := url.Values{"callsign": []string{callsign}} var resp struct { CallsignExists bool ResponseStatus responseStatus } if err := getJSON(ctx, PathAccountExists, params, &resp); err != nil { return false, err } return resp.CallsignExists, resp.ResponseStatus.errorOrNil() } type PasswordValidateRequest struct { Callsign string `json:"Callsign"` Password string `json:"Password"` } type PasswordValidateResponse struct { IsValid bool `json:"IsValid"` ResponseStatus responseStatus `json:"ResponseStatus"` } func ValidatePassword(ctx context.Context, callsign, password string) (bool, error) { req := PasswordValidateRequest{ Callsign: callsign, Password: password, } httpReq := newJSONRequest("POST", PathPasswordValidate, nil, bodyJSON(req)) httpReq = httpReq.WithContext(ctx) var resp PasswordValidateResponse if err := doJSON(httpReq, &resp); err != nil { return false, err } return resp.IsValid, resp.ResponseStatus.errorOrNil() } type AccountAddRequest struct { Callsign string `json:"Callsign"` Password string `json:"Password"` RecoveryEmail string `json:"RecoveryEmail,omitempty"` } type AccountAddResponse struct { ResponseStatus responseStatus `json:"ResponseStatus"` } func AccountAdd(ctx context.Context, callsign, password, recoveryEmail string) error { if t, _ := strconv.ParseBool(os.Getenv("PAT_CMSAPI_MOCK_ACCOUNT_ADD")); t { return nil } req := AccountAddRequest{ Callsign: callsign, Password: password, RecoveryEmail: recoveryEmail, } httpReq := newJSONRequest("POST", PathAccountAdd, nil, bodyJSON(req)) httpReq = httpReq.WithContext(ctx) var resp AccountAddResponse if err := doJSON(httpReq, &resp); err != nil { return err } return resp.ResponseStatus.errorOrNil() } type GatewayStatus struct { ServerName string `json:"ServerName"` ErrorCode int `json:"ErrorCode"` Gateways []Gateway `json:"Gateways"` } type Gateway struct { Callsign string BaseCallsign string RequestedMode string Comments string LastStatus RFC1123Time Latitude float64 Longitude float64 Channels []GatewayChannel `json:"GatewayChannels"` } type GatewayChannel struct { OperatingHours string SupportedModes string Frequency float64 ServiceCode string Baud string RadioRange string Mode int Gridsquare string Antenna string } type RFC1123Time struct{ time.Time } // GetGatewayStatus fetches the gateway status list returned by GatewayStatusUrl // // mode can be any of [packet, pactor, robustpacket, allhf or anyall]. Empty is AnyAll. // historyHours is the number of hours of history to include (maximum: 48). If < 1, then API default is used. // serviceCodes defaults to "PUBLIC". func GetGatewayStatus(ctx context.Context, mode string, historyHours int, serviceCodes ...string) (io.ReadCloser, error) { switch { case mode == "": mode = "AnyAll" case historyHours > 48: historyHours = 48 case len(serviceCodes) == 0: serviceCodes = []string{"PUBLIC"} } params := url.Values{"Mode": {mode}} params.Set("key", AccessKey) if historyHours >= 0 { params.Add("HistoryHours", fmt.Sprintf("%d", historyHours)) } for _, str := range serviceCodes { params.Add("ServiceCodes", str) } req, err := http.NewRequestWithContext(ctx, "POST", RootURL+PathGatewayStatus, strings.NewReader(params.Encode())) if err != nil { return nil, err } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("User-Agent", buildinfo.UserAgent()) resp, err := http.DefaultClient.Do(req) switch { case err != nil: return nil, err case resp.StatusCode != http.StatusOK: return nil, fmt.Errorf("unexpected http status '%v'", resp.Status) } return resp.Body, err } //go:embed gateway_status.json.gz var embeddedGatewayStatus []byte func getGatewayStatusEmbedded() (io.ReadCloser, error) { return gzip.NewReader(bytes.NewReader(embeddedGatewayStatus)) } // GetGatewayStatusCached fetches the gateway status list, either from a cache file or by downloading it. // // If error occurs while downloading, it will fall back to the embedded gateway status information. func GetGatewayStatusCached(ctx context.Context, cacheFile string, forceDownload bool, serviceCodes ...string) (io.ReadCloser, error) { if !forceDownload { file, err := os.Open(cacheFile) if err == nil { return file, nil } } log.Println("Downloading latest gateway status information...") fresh, err := GetGatewayStatus(ctx, "", 48, serviceCodes...) if !forceDownload && err != nil { // If user didn't explicitly request a forced download, fail gracefully with the embedded dataset. log.Printf("Download failed: %v", err) log.Println("Loading embedded gateway status information") fresh, err = getGatewayStatusEmbedded() } if err != nil { return nil, err } file, err := os.Create(cacheFile) if err != nil { return nil, err } _, err = io.Copy(file, fresh) file.Seek(0, 0) return file, err } func (t *RFC1123Time) UnmarshalJSON(b []byte) (err error) { var str string if err = json.Unmarshal(b, &str); err != nil { return err } t.Time, err = time.Parse(time.RFC1123, str) return err } pat-1.0.0/internal/cmsapi/api_test.go000066400000000000000000000021241520322237600175240ustar00rootroot00000000000000// Copyright 2023 Martin Hebnes Pedersen (LA5NTA). All rights reserved. // Use of this source code is governed by the MIT-license that can be // found in the LICENSE file. package cmsapi import ( "encoding/json" "testing" ) func TestGetGatewayStatusEmbedded(t *testing.T) { // Get the embedded gateway status reader, err := getGatewayStatusEmbedded() if err != nil { t.Fatalf("Failed to get embedded gateway status: %v", err) } defer reader.Close() // Unmarshal into GatewayStatus struct var status GatewayStatus if err := json.NewDecoder(reader).Decode(&status); err != nil { t.Fatalf("Failed to unmarshal gateway status data: %v", err) } // Check that Gateways slice is not empty if len(status.Gateways) == 0 { t.Error("Gateway status contains empty Gateways slice") } // Test at least one gateway has valid data if len(status.Gateways) > 0 { gateway := status.Gateways[0] if gateway.Callsign == "" { t.Error("First gateway has empty Callsign") } if gateway.Latitude == 0 && gateway.Longitude == 0 { t.Error("First gateway has invalid coordinates (0,0)") } } } pat-1.0.0/internal/cmsapi/client.go000066400000000000000000000030331520322237600171720ustar00rootroot00000000000000package cmsapi import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/url" "github.com/la5nta/pat/internal/buildinfo" ) type responseStatus struct { ErrorCode string Message string } func (r responseStatus) errorOrNil() error { if (r == responseStatus{}) { return nil } return &r } func (r *responseStatus) Error() string { return r.Message } func getJSON(ctx context.Context, path string, queryParams url.Values, v interface{}) error { req := newJSONRequest("GET", path, queryParams, nil).WithContext(ctx) return doJSON(req, v) } func doJSON(req *http.Request, v interface{}) error { resp, err := http.DefaultClient.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode/100 != 2 { return fmt.Errorf("unexpected status code: %d (%s)", resp.StatusCode, resp.Status) } return json.NewDecoder(resp.Body).Decode(v) } func bodyJSON(v interface{}) io.Reader { b, err := json.Marshal(v) if err != nil { panic(err) } return bytes.NewReader(b) } func newJSONRequest(method string, path string, queryParams url.Values, body io.Reader) *http.Request { url, err := url.JoinPath(RootURL, path) if err != nil { panic(err) } url += "?key=" + AccessKey if len(queryParams) > 0 { url += "&" + queryParams.Encode() } req, err := http.NewRequest(method, url, body) if err != nil { panic(err) } req.Header.Set("User-Agent", buildinfo.UserAgent()) req.Header.Set("Accept", "application/json") if body != nil { req.Header.Set("Content-Type", "application/json") } return req } pat-1.0.0/internal/cmsapi/gateway_status.json.gz000066400000000000000000004254361520322237600217620ustar00rootroot00000000000000[ii{H&W0e#Dn_EQ;)fϙ^ZbٜHJ*w LN$K7237o_Oy巷Ǜ}|70IǛv~kƟ&Cp@/>RO+|~6|?܏~{K!&J $U wɧo/9Tjm"r緣i0܍~?6xDD/w(-!e~hl 7N/5/^נy8~e)/_Lgiuߊ~`;f|o8%pf;M# m)YyBvO 3O'y&ϣd:wߍsa/ΎۿwǍv|HgnWYZ2J%28X+CaRp゙,Y^S+ԁIu&҃1}wzF8yFǚQ`! h~'kFP@N5deu8 a6Iɉ0q`KS y0SJ0 w3ȃy dʹA&\IǶQ0["(_^EO_lt}ZA1{"߁&!O%BU,Y~Y.,ٮL BQöS/Y26_Pe.6j Y>z;O -T/Y>jb4N}lhɀ G$󗗌Vymg@ʾH$L+Øapva%1\'J`Ȳe0>85" |5ӂF m%J`F6cW=GPYUmet!xwҜ5$|z+K"[)tʈ"edUOI\}zzb!m-qSXlVUx۠h8W9C@ ]20fU.K+\PFZz웬!ffL61c v2 fN= F`h慼Uf+)0JmYʂW&ݮ[\CTSEtV)p0̀HSrDM}Dc)E濝*m8Sth+'*;.jaC#>U?d2Xnl;%)0wj!`1yxP |k .qR܏pRFlEO|ޟ22El\ ]R[qztߝ`9%ū4|YSid%8ՐX<]X!,{482.׊:E WPˉW&G^ߣ&K,!(hQFg).(;%8YS+( uV <>}|w,G1!eh4ͤWS 5s Mzwy*JW믻n"\J<Z*\ *PRW\wݤb랖wk ppjnmۺޥc|-Bڙ+@8?HXk0Z5<&ë؞#KQLҽ 'ԭK &!BIcjGX)3mD֜i2IԔ_훬h-3Mc#a֡f4HgݕZu4zpS*Yظ־d,/hSiA vț '@fNk,ֻfIkUuXnAI&ח=MUc)Qsnuk}UǺGi1XɖVk/%^ގH{7YCIIf59|"͂`PRܡhoU'.1A3\,GnLJm%򚠼TjC!!^2lc+`(U%%,Ra^XʕBoUXsH#T 5kVHF:*X@EjZzƔ5#{tK7Ged/V3bsRJ':"9j-w!o2$G*kJ`Wf |JO hZh8S,+РhBL,9Ԭ(ԚpjVʕVX)TJCԜ`KW]8݌Ʉ̻*Szw0eP^do aE*St2j$F*} >ʒ34H$1rgJ5ZeR*ЀQqq*Dq8ؕѷGGO#EGպLf'S4ƟM?;F_ϟh89~ğ |YQLў{Îm/7"3D7i1EH3 Yn*+e-#nɶyNCQFʖ[ھɲQ%<Uφ/h&@SS+H@if7TPjJV(Y`4P?4hN8Q4P4ę\6R`p+^ʈW+?TddN->%rtab<{0Qr!P%<ʙDN)GXi %2{``z|^br9rJn>@m1f  »Ul#d,2KörL%4%;cӺ ˧IQ&q´@u7]J6(`.6a*9V/'͕W{-ZFъͧ d hqF6NsG.cU7Wd͠3{9Vʊؠkb"jM KLa=8*fN^wFI:[2?P}88w35Ը PYĶ҅uNayݑݑ{̜ So0bl`Vg.UāD)a47ll"ZAXCE*3Žr.\?PV"Fwɀ/FKhmL%z2۩&L TR%\3ʡ(w k*J%R񊏥i>&M;׹B!:u??ɫKVu;7AcC3ľNchdٗH Z\-۲f7*F s}e Φ 8QB4]4 _fIpafu*sP9|s7Q~yzF%cŌf7̅usmlT]V sEˋ+ʸh-&}Zީs!xnT /2$[LIv7Nb"u]6 fڬJR2F&9+pkr!#,wGh!\j7b{,.!DIKRzh1-ņj"lAR zmuBXZn7ƞVnXxH"Y`&,mE ;LNǠR–~aUYsF48LX3o2x ,2J+KZl\pUKMѐvݿLuKzpmhũ6FPzcRIc{kY*`٫6T+Xu<MJ bDђ.n)͙"E(><-t>"\;y?OƓ?b|.O+m5GTЧ\"`,scv]疴3D9kV3ߒltQ<ΖitqT i)$0T7?+jN!x @]-HE%Cf%[<07I ! }?ZDH<ȤA%g;ڭ~gxƜT+` +#S+ yYgpIr賝&8#ɨՔBQf-'P̒4FܷD)te*OTۥ̪%J$J2AAK+f$m).]<ߒ G}6.쨆e)K.#XX"mo)Y.,Tm[ Z:ndh vcE UIH)q5 6nTZ3{1@C#&ÚI/O8oiW5Eń[8%pJmGf,{Smı}޻=%- &[#EL6I.owMf!Ft8]_iNpEkoI;OՍtN"NF=Ey2[B`ɤ5"L} JWk1y/tUy״F\ s?ALcNZ+Șk%aTؚQIzǃhy8>Z/<.St7sht?? &JZGYbNz-5!P`֏]?-QD{vhfAPAthfv5onNaRⴢ^hu@RJrTbI(bWU~5VEnkb#a =э14"Y`)@E+=E,[M\^ÀW0_t%=5+XFEzxYǥ ?4D/ @dcƶWRoо<F 4GrӼe͈)*`tfJʄuYeQO>;(1=N1F]AiĤ a&x,9 TjTMg`Rcdƿ֭u&kvkM*[VL[+rzopJv[wZǎO."r'4x}Hc46`7f"idp\[]QL['7MlPF<#=~sv?[?q&e\&K~Fbd҄3лIENnuWxp'֟ŷ; >&h(ƨpj*;8];KB؅(*4UYl_='/ׯ_şM|N_bRds${ ;emd2,.Q{`v d y6T@]l`g~2O FRt5r5-᥄E^knoRZRB[mf pUpt\1t-!wѽӬmގBz7@֜Se;`̻O:d' iswJ =Jb&Tk0qu3z>)('j0NehtpI_ 6WH-POza<;sUq3yf"J,REⵂc*&(-ǥM5L V?ItlG2a;&=B*VT}ݹ̐peKi|vC>M/x,RS &cɹe4s򬝝l/inMՌ5&R(* .7Z$lY:;us7֟^"\jLem~n+Q͙bژAjiF}~)sg7z?=wϻG$75Q?z{]-{?ct|c+`?G8=?_GÇifDWj0|;U |$ƅEW?(s9=_,3ԫK.Ο)" sbx4aD4,uگ%ԓs|oKmK4m U'QocfQ6f"I4|z GBGnHD9?:WBVmt^.-\3Ydj;%C7LaXzy*^'I Emx\P!V]D{~E_>&ǝh[!<~}3܏wS/@@w~r`^jv/e&T E{(jhzxBQk-8zu(]f˅LP[ ꊣt6&ck'4M?zuҘ89Г~3x]雁z<g,HyCmtzMe8w6 dw<؉w4{eSE3D:faZN-?O1e!L0sG+Ε4Gչۨ]omtg?ep Xё=7 7l4Zauɴpp?$FS.HG6ÖH<= 8&<_߿u\23wJY"ƥ )=mF+`>eB<,&"`qZ7aɖU6mLE-$hn`kVV&,n|.Ʌ"+J\1M+v>.Gp6FGa^m't8>Fo,JÏgEg8zӃhx:ϿLg.e6z8pPWR8x.8ZsB]bEuI .|vlNHH/U*gL<ҡGjyw2A JQD0鐨tWק;Y; 3ĻTg&õ*ۺB1u8:+LhxڃP0dYqţ`x8Jv/=G}ƙƂIDe$dpf[?<6I3_ɋ|>WW~qJAn^+iA '{kؾJ逊hV1PIPsv e:@[ꙕ_"Պ`B(.nrzf{ Rء0q0KK_zNUg"4VRrE"n|s|O8BqY]ߞm4TM5YωFRYZu<#ǭj3!j& ʃLܾN^n% H0neg«P> [|p/ ,2\0j6 /3!^~j [)s;Kmhp32Rͻ%ڇ3zpr GC-P=k"C+fh8[:.ZUcEv4hdR ƕT90w ?ncOouXV!x!PbE6@r@5hnr/^*gyy;-΄knz dye[ĺՊp0+*ƃ<`(c_(e ҏ7gaf\PR+*>]\Lk.H-Eu~@j Pa<`,E2AL ;wTu׳42*ŕ0y'NF*8,veYI4ybķ`~% z(eKFT0+J⓫e` !,\!֚3ko+:b p-?m4"& S m1%g)墯㇇i:DI2U= ?9We;ڍzCt9zygO]qd콌-x]"$xAՅ8nAA=#X JS# Կ쿪1pYG㼞}SRiXQB01$Hplw{ 8i刎ϥvvZ*q~{,Qp X:4&USZ^ss٣&vgߏg/i ߫dqO>,mU0rʹ2;2 (dγz.m4'byYJl@+(-ZT#PDq/XVP\q< W5Ӭ>pR1iw.]? .l􌛴1lS0jOPv9;ʥ@xau~BP,o22@jM xk_;q.̿8~}`LFLJ駧/g/y98?~7=x~;gbu4çg>7gMwXݩZ_5)J_f\L;:@ 2Мedۄc|H[dF*ЯLǂZ&-[O!cSk͵Mm%Â4ſ̈́5~e}}{U*uisg\GE;__rKII E6td8` a6.bsmXA dM+|YFo.+.ڦV#֒ /r,{(}# B䂞 Qᔵ ȵ^~,'_%lJ[/d:4)Ijs|.qTJw~h:u"yMrCQ"Bdtb.5gJ)7A?;DzRT*6TL60QWKFd'%&A]*<7}--Іf^M)RK:e+A}ex]GinH%lUTnpJ,~{:j5!i:beƘ`~!98j[N;"&w1j8Pe< .m8hYpc55wQ#* *Ty*4ć[įbA촳=UlThHC?0e4Vdkô&E `4&Lp۔W?zv(z~(v(: '~<ೳCgik}O=V97/8[`dB'8|y! 1^WОk_\gq)/=˜=HHTCzzLfWв&_\koZ.$K.4@0R)Xl> sق+{]B2M4@jW?kPf4q)?߻}5.5PMs%Ҍ*ll!L8(cT$$:M__Ѡu=M,LQY",KzOF %y.$CNԙђBٹj|Y`uqr|nYC=!?h(> d,~)M%$&*„aQHRw Gں2u[Y*z΀l n=i`)>xwبI0G+÷ǩ0#hrHNE+KS273PPjڈ#*B\C(Ce3QrtWI$Ul g:.I]FŲL :JvԱ񒅪ZfÚ-[:6_@ul`dpuYDK`BqD TFHV}m{m4a / -T#/Y9bk5RmȆKVB#5 5jd% H9&Vj+lIq/?wdU3C4hxhMȋ!xo:;vӯ3(77RB{pfӺ% e[ȵXR^l6]6Vl6ٖpx5V<^txJW=ͽ2mLRZB &TpLc.O?efNpExj11J{ƭ7Ys(0&L\%v*ZQ܊Vs@jJOú{jMn6(#U8FL[  P-ڻv{YFn”YhgZQi8{CsUjgyFt(8]'Bf): Q"(- oLBQ/wq],[I5ge 2Ϛ͗52}ZJdύ@CB4ț(XL?c[y,nh~.L%&5/N9yvPJs BgfN(Q&^wV'Ƈ{nLh5!2X5Z)-;]lZ cJ?$4%no.e3h.rw2 ӊse*tP< a 7f,ȇ7Qp> &5bٚ*VZZPaxSws R E5 M7(zF{%jo_O/~4zJ æPsy6Дc L9rN]H ef Qe뺵mt( N1=x1폿LޛNM4}y\ZCa4bv-KV6z L[ Y L-邅CumevVBxB!Nl#f Y0h@o!h͗,4[lrL%! F~졓;y Lƕ TҎm $NWi8޽0G4/t}?L QߟFoj0RI+a+[lht1*/RAM,jRPU45`sۀ9BKʚXO<{?yZ$nĔIu`gRi7'̈́zG>0d8b>w@ƀۼNzvn~{߇2,]M.ORp"<j7DQDQc?}Owt':թ-V.O>eTAS;Y\i2TVPK$.1~tQNI0 3`>~9c*G,5Lk¶ b e5C >a8ΩN:)TyZl+ӄ6H0Rp)kQXunOa4nvbW=|DC=XׂJF*:YL»effJ89–K aVz0W'ÇS|"jJ!yR:>i_8T.T #4&=qW -y 8?L'O>4G}޽%s**FJRP&S Y\b ,7SP  Vb&'ʳq$ MS"a+A[dql EH2kdz L%S݋n|ggU>2FkWJLhB9\b)BBݍԾ+n_ qgROcv)-ޫpsEYiYp ZE|ӍF/Cͦ񸺖B#Izn=M˦'k nR?PPbyǏ*%HMhؔ#qԞՍR.O!D{?w&Xi_La-ǧ}Y%ZcGr%#\W+P^%Quu%E/]1~&klwڭM66"ȤLLђe`d&U:ΨN̘(AV1fU-|(SA<bb0[J F1F0EƂmj7ƭxt2W 8:~>q F=|ڢׯp6{$Ke]. [C^a2 _קܥjE6|9ԾOZ=閼ʹ)9PIPoGR[e7wTdpޏ̦kt o1z?*jR}.sGuM«+F&ýydpbh&̺RCZ.`o\tNBkgA/}/9YrDQ/afL5$]p1YKRs LE#nyxX9eqN+7MfT*/sqfeK$+8t%[DBjKz|B0aNE~]W&Ԓ*&[)+󬕂+' +-:8]sLj!-R)ڠ:HЃRjpW"h9bT[P B#pӨVZA "<irsOs[taM;UA /GS+iT ZѻsTOM'8m?:yO(ml#amt% 8؏k '(Hɝq} xDW~Yo/:2:D~4Ɵ>E}%KmFv?͆o7燓z<{~JU9Rn؅u]9b7֡ߞ.!d6٘ >4v2P0Wr)&1<%ʵы%O?<=GhT)ZNb; ϊsw* M26O 6U,ohŶX m{-M sx.#]A6iPBC.@:,ߟab¸VtJ+ja%ߨc6?6Xcۼ"%S[9;Xa,2EY z+H mFkdKzїQ&8p'DO?p_e35֮ңA9}eݜ4}GB]qX"J\d99{7 >:hLUe`gۻL_uWu_=fOlߣ$:~N+XNp5*G?R~\h[&eM&F(ZV+(s>fԀyO0F8_8nb@hԖu@ j9E0sA.F^'x;^idOߧh2=lT\a=)P#+EzA.vt\s[VVh#*TL $=-mLa{l|z 6c26Hl6 Y2jNIAUP3Sǜrt)4bo(ϻ3:˙PRfg܃49Jiˉe 2[RhgMnpO}@3$̴T\k: )0 cwܖ=EN["Y=@!H.@4¬-S6PAc$kFWGkzfѓm PV"i{ 'O̾48.-{ɆwuAY0J'(E}U-gQ+ u϶?6q]4vMzˋ (% */Lj쇲dbg9n@!i#(X6!*s f]prF9 ݾ"1ӘY "52& 6@)R )=l*]\ 5D9?LCV9("x?~!yia?W!WS PlI{g•`IRb9a6D$s)1U+K鰱J\=p\.ivp\~fnNSrzd[3ƕɔ!δ!W̫"Fb.гjmhX5"5K˧G7Rq˝C%˨0f*RZ`[#A{:<ÿG7y\&6݈ɑLGyW+ ,w 05GS|TwS(FGMhL*lQ1eJ jP.Z\IRpz4o-H;Q>6+'@-3XP=QEZ7C2KkA5̉EVVlfIU1re(U(ZhB:.U(Zh޾xeVer&,.V1%XNJj =s4Ni)Mʦd\FKm5hΦdd-ѤvsFJQjY0ϴO]5+Yp`? =K\[q;+Q8) @GD8z]d^>Ϣ+PZm>=> .ި>; {jQIF:SM(M̵fZHalv(/b܇_Q&Q$]AX)5WGr狀ݜ2e2JQܬ 3YR2 uSܑ 6sUZ]e7@7L9AH>&ab@s__IQJzzspm>ՠjҶ;%Ǩ6d$hN+>ӏQbοw?QhMF'jϴҐֹmNbLFA8vD'eu!Y.#Wn{^{ ;hu]8|QB WHf p5u;;Q$" AkN7j0J [s odK6SpML۩ گUB5 'S/Z26_ped-U וqeB- 7=>˶FIjlͮ%נ$8Z-2\c^*29v<:~Ɇ 0Q6't VvN!g$:µUY{jB\V*5رվԌ< YcESl/Ut-Pd ߛGMN@iٰS9mLkjES:ń[L %ZL jaP]Q%-F(̈́z˵OᒒըT>}ڶ* P2SmR]&'WKmR {fj:&ZHe+EYoP]ѰϚdUȶ,y<)h;s)AbpKX@Jܮ6`y4DȾ{bd˗ AYiKŠRQ.۾jʲ ar@9zYܤ[+@oYd=ϖ7Y!F5&?8hRVL%<"-G01!@5hAA{\2ȟu}:q(c[J4.tfO m)H'rRbluqaUgB8L|<=8<83$aL/=A˷DO*^N\QuNp[:pዬ[ZH(V:g4 UѐĠSZ]jޤyнD#M[U1mb;FayFbn_]hśo{yT]Ԃ gjUWTƪh,%^l [ӯN;.5?N0 +n8ٴ 33br >SD/,si9P!eOPP(l&EoQOd Zn,ۙGhD묑b#`q* Ϩ\Zd)o6L^#[a=cZH!`g HL.#KleY7fK⸑zXz\Jzy|;o\jI"FpA2tɂ+>_f.m.OϣPGuw7}D׭8m/'2]ʸձZp^%f:̈́FT&}SKNlRC] Gj7a;>-=}Zƈ`M&FV3fC6&iKzR(wM<5E?^EC7 wޝ7ٲ[ ‚^X{dJ^.HD(O3׋2MW_=&sPiw.Ė>LM ]⢌Fh^;,4SM6)p>b;#\O@|QX̂7\Au)jD|хKSB).b4nW 4۽c(ĻuRV_Ω$S`Ɋo`p{z!YMgC`К Y=1t;F!YMi)6UIX3sڙ{rC183)m $|F)pТPkHV,޾bDBѦS/Y26_PeSJmqe-RqX<]0 ZXZ4 W"T5ѧV"3v6%f6 c;7i=Ê aS`(Δ.zyEh.st?2%ձa-P%9s4H0UBmL2õ.F1J*txbPnԕGXgĩ73漝k, :wA~5B~J3XLC yZ|t SYɱ li eB1愼)n oҔ|yKRibotj,8&&hF(AMe댈F l3s9}7[Rqu:)`vʺ)JeyRBwR=4,VJuEJd ^4GTT<¨=<:Z(,J)j?|ɏ?&&G}t= psQv4X`F JW|oԿʞs1&@+.̩ /#Nך״Z**}[\X]Q2=<>49v4Wb]r:CUeLwt|8D B!<hL0\+HN4~JX<u};)9yN7ٷ.KSt5hUGJ1j#][^dK8̼k<&^+TۉmYט F$48iUs,{+uix^ΉUbr$M&D< C[.*aZ4_SBmB5O$εGַ+sUnj{Flym> ~\e&t*[ܰ %*SJz8PYA}}м 6b6q/MQԒ*8;Nni!̫[;5)rVW0hk2BWFyɤn~R iEAeg&M:HL&ӣ˟8be0O?m У]2+ ya}{?gWp?> +RÇI;gg7[d̿xuktLӎt@ Uq?P啼'bPva5!y'O|PÑ+hS}W1~]gң7y~飽[2×*&K4bNG{g*Y|+@yg9;(Y.@P\O:\:*QnKi\O^\ObFa-q?M~8ot50=q vE-)LWiZF- r eԮ"b6`vLzrR\ٖanj…wyU:9RXy=@9 5W*vP%,9*na#7](8AB@eNZ=޴|oD+"M@_7Ib$e<՜,>;/|VN܊a\CH8%՛iYfM|u# OQҮ83+SM&k{tb;U^Js;C' CG4y奔D^)9!zB#=5EDpqȼBhoFWV2a(xkv4ˆS?ȶl$̧K\k)V ,t<@ņW`)oNS*cI/gju$ⶴt'4$_bߒ06*=P OZ:A2nYttvpF8xsD &/POR/MOzAqq &]?[ c2??zꐬp NZP(RJ-ǡVy YP8Pi_o~0G]??FQQ쿲7 ׿0W¼~HLk?&hY8 k{= .B/QQ~LYSnԠ d`j{ޟwNcH *!+Gj Ҁ)HIˆ[obk-_-Z|/ViRSO/ҮX-Es/=7ϼE[KС=VxAr^-}1~a/(Yf+;ߙ7E0gz,[[79zc_uE9Bt3eۥv늗-@_v9a_ĖkhX=| /Gubw-mݷzM7¬/bfْ$54ʡ^ I tɪQNy'2I 8DޚT9Y3N c54ԓJ}e*f4=oj*Fx掩7f?n?f' ˭l2{~|M?y㶲]߿.qK_2xi6ܘ=.|4ѿ}}1O%jD*λʮ%RYcLp#򛉖DXPW]kliP;sz^.%dPvcƯ0 ' otjO|0-WbNioWGٶ>I 㴓U`V5Qk5Ai*PVc@le}i8ԧF+ @!X,)g&P8˶d}vKwsrA m`οΦGJB)E ?R-M5hަFSx֐7et)B6a?~ܓ)lJ-D.շSDR뷻$ݕW2N$*U^8JӒŴY/tGh&ط1)޲dN„B/;87 *C% T9DgTϷgdMvqq3qv7{znxI៲`|ѯq=Μ7}m~6eIa腗XVpr--Cɲ:m3v߲Hga&%n7iED6oZ?e^CeEdn4K &T bF=򶆁y6!g+ڼ*l2VՁWc0_~/禘aeq 23$aR{;sh \P;^JzB]nt$^ } .˵(y4b,J{{\FZwbeemH[ڷzB]R@CM{n[_J[D֢%4L)\p'Ϊ&Ί^Uڛtg'$i)a=wYE=bZ(q Z7Es82״=FV'oɮROOadXb0& C=\Ыe^CI%!9_jlbBgt̩UE G/ģهt74Mަ2`[~4pR_F))AV)~k DyHi*sD hH$RBv <0Tb/}X: g7~`W(>==d77\~52Of4g^7tGo2Oנ=9-;v8~Z)$QSPQeJ!Tq<{|qݵQVэ4#0[x ~ {CQTA  8 OXR e<Z)pA,AMqB&r9 pw d6`AvZcʁǻN{XLPa5}/rr::΁ϟ׏wg=rb.Ƒ;ޞ"RpˍxU\a᭻:릚wh7nhԔjEEc -;@^)2РZvhZ)- [/qos8=SVϞ|~VvDÌ<{3WK5rT:0ü_(Ө$K;=os_Xנd&G]z-=p15^t>,M(jA)WN,;mWhZ* T-O"9htgeu{B2;fϑziY ӵrFpt~\ݑ+ kO%pg6-Dw֙{9TڙUG#tKӀ SFz3Fe Fv63b!Y7<ػ M+}[S&0턲^ -/k;6mZ~GA`릕b:}RcQKQͽ\׬/Wqo55nI6Q6+ @-*䂻P EYlpn~r\B`08PJh'y)k'K@DGtQL4?M˜hجm/U1Jkøime>~B2}ДoǭJk}tOSQ}8…N,eSԏ J(3kE'ctz6W9lu.>e} M(w.vo%Z!8*R+ .ctpZEt |ˇO4^trPJz/^qzexPj}xI6w4]/']?tt/BZvx9 U%`XeHkɕ_ j{@ڜ6P23.Xxb޸ki*Zpo>PL'*8Ry%2 Y~bP@XqiZf]6Ϸynoϟ駛kVh=<~=|)EGcݖ1>>^) QTu1g3JqO &Lʹ'{.wӆ&^6̼D{!?bRNJeSp=zEA[nU=`Mu re7Q? 5+E fVşڳғkISN)~Jv S 4G"EH,mxⲝrs)G'i)!cjb˖\C"g%Eho~<{:WV=|ɫ# '=1 2e1q5{/1tgNF_-r!:OӢ&_b\*edtM=CV _ jK$(@boa2RAB\fr\A$-EpءhQkY`&.L?>e77ל7_ikv-A8{5+jWW VEN*Ǡ̛pƔ14v:1Zc9*ܢy[!÷U>8_6tH/PVU/% bnmȿna[wY!+f~_b[z,D7oY;v߰xwd.tL\"r ,kz1"}b=5HlGvߴX|-"8 UWJ({ \VԺݫin5{!eI?'bڀ7|.ryѓZTE=6b%y kS+4NB>|zT54V УüI`P%HԐggi?@JM;N֡oHUsRiʋQU[wrRԈ\]uRЊaN qEh3!ʂ_ehg# yfجP;Cg4*Dq$8I{) .^_$eǹ(HOodeivc*z0o=Ybݕ[Wi`^YihāVn=)cb}}Iap.gZnWo<2Nѥ AlȏgJ,A KC0Q -S({Sr߀^"媡jzy3"4mG&/~P>,NGt;{/L̴fdm@<&fM#"2>nB'NNdYeZ%FtN$mk.TV\Y9 Nj|G!FYnF+IJIEctFٛ#͑.vo!{2OG/]_-A^N[URC1>e{{F?^m\f+ڸ7̕Yߍоǃ|8Edo[ۥ0=XyADm_D5g`;IKbZo5^`mB8"h hXc!7kn<CKQ ?['xѮxiXĵ+)߬'eq7Z+57Ɨ\Դ<@kjY?~|[k`Y?"_T&oZ?v߲шyxa TXT^u{}BI@[0U6azruPl^N,!}U]yo]sUA9'v w^a= sJ]b}[ȝq ,k˼}b=5jz}غoY/عN7D}uh'J gE4lY,$~xD2=ʃ6yk:?YYgfN09EsBs9tMyh^cg~mMTf1X)h5so7+^)pX5:yynJ sqLrJILS-כO×Y?onfE_oo|Ͼ=tNI9.ݐej*R34(ܠ/{KOQqGo¶@lPhޅ*]220G!H^*fV]*)Q sG2I\fs9\}LgtQ EOѩ=b4X㩇 T Vbئȭ_dW"챡i1%欪pZU1!4ղVQIMV¬}H 6 ǃwN<7JIyPJoQ)ӳ,@eh-hxvʥrk6-KX|)vž]Na׫`/ l|S&gvLϩdmO}6UP7Jv"'Ѭ%Д4Α{*V - 4EϺ 6*!Z( 2 D"=h L{Q›Mհ HM@TOI)҄wҨ:g_>gg#]pSRM 9v6􂛽iQ0Tt@K'їrJ ro m[nK>[yV>eB[oVj⋮;eIxUV=yT%r.T[p?eg0ˎGgGa6Q|лyJ#"7PW+#|[rؠsrPϿ C(N:ig]4K[h>+}>-AVm5ھӰ*wekhWKXv_|$Ro"j]lIq]Y qF9D ŵыX[f7FƯ-㜮) ѲjPH2t65^I jފsbѓU$2F.{^23x9:Jj Dvn-mEihuߦX81׫$1(\YO9U8JxY2ZZnLDUKV v,RI JV8,$Q`ce)Pwm-K:tTe0F, ;ՅX,ڽԴTpvܖ6 }F߸,r,$tPnD]"ԲԮΩ*%8h~HYQ"N/VVq,Ui-+ ,(N T‘-ءP$^a*pY[u M/_5TcXqVōBlZ`Ik-l/u{X{縤Qy'%H:)-R(]ܣv$h7h _[20H>hoT˷ [+K9ئnWZJ5JP[R|:֕7[xz]?tM+yӳN$x+C:ZsO" ӓv7q%P OBXRl5"cJ)F],lhϞH{ ,bYq[JirW4-U}j̜de!%|hӚ/ZKJ= 4rCv&JLu)Zl  ✬Ek&K6a+iԑ[V,lg$ig}P!ǃRWK7+b%>/#mho?1~z;AOtE7*2&Y:`< -*+XijiA:/@áy.dT7o.H(ײIeY|)ɪg ^_sq垇v@jiegl4ٵ:FcIBNBYD1%G@ J@GݶZo&gzR*tkNZ^ʃ =p-3h($afA`PYՁ,S%lb^:AГJۖKў-Pk'%w"苛Eu@{8dNCĜ.nxT1ڀ/VÓRi@}:f2Mm)*^9϶W(\K} ufZ0a荦"Ψ,xAEX@K/3фmO~ԡZx):з#7aΫA>;/f䎤WK,97m_DPP-jD RG*-C96W-aTڇes, GwzYEgR`T =SŖvm-.H:ZZ?rct6b=y|Wʐ&Vrf'[~#+ B%[^ʽ๙9MoeKK89;N6-1fyi{ۡQ^*J E/PoZ;ta:+]:і)-SYp7楢s3ZUVURZySؽREڲl]*w`yjZ*.X 7P\6^sJҌW1H7Wr(}3x}[}=[K? y'x^cx;P\K9'YɂkzzNZʮi՟Qg~v"(ҍ5,B/T.PF9)XjJH:1 DS'Q,n۝Cq x)BjB{pŨWڞh=p=0~b^ߜIujbŊ-[rP8Ύޅ|> m/I{Fk@LmOݶiMnNMfvtC'I*M8qv!%xEeN-) %UEO"h-o/DIU( P/"<Hvi hvL-w.;Zx*lJt;7KP[ٕ4)s7-C{ђLO!r?5-%LpԽk5ʓid*jVlzaڪ ~|↥;[kTƱ֟@.?L$߳/Çlx6~y0{=<~=|CÊ\It&ݽ_*fyix3j4$]R & Oɬ+A)1Wz3]볛O<4|O4 &;ڑ&6-Y;?̪wcŒf Qz-"Bz=R Q AN-y+&t8p xn$0#Î*OiJE頯x(!p_, 3o(]d8Jی.*QWhW+ =s[X[Jyqͥl*k Mq׀F%EU iwۘPmpePtb"YKb$m0_*h@;mW–ymK#(ל9]m?;H/>nr=Ÿ+70S9[$˔^Po}<+Mz*MsUQzUPB{.-D-?H١]{.",#}ubNΓޢ'90_\ڄc顐tZC0W_0'gpv|><|ΤߚF) ?PN~%u6ц f\xӌӄY)u=Sji(8(GfOQEܻ7N 'v+|w:EYHQAk8 8}I]qoΩmb6MOQ >p³WCq=\|#AbKV+l8s:<:uã˨W}^c+0'F p9z[c(tZzynG td >^=۴T|)VVF j2=CktBFSLpi 3i?W0ȼG%8G"9JG Ox]i|S@ȟ 9&I7ĸ"˰E_ "]*Q(b03tKoGMҙ"Př5bLInƨd6t-H+[AU5HNCJ ɹ3oA{q~nk6Ff);het?@A_vJ~~՜LmTjlҺF-32&qvS!CҞ5bCB)^EwLlH{W BF/1n9Mj-[VT ;P/qH5,x荖UZVu}?}0Q449ܟY`JXti&X~#a~9,PnŽ9UeOYrrC[f۟vRXhs|F|/|I-lA8y\|i-\oyi+@pgt@Ϸt7H@+~tZ p>ڟ.C(O+dXkZ5"6 5xuߦx6[ $ðiuߨquߨx@ 7 50b\8ĺoTXc{-ם'JE^ N1ZB nݲ@cIDU+ړR eFvEKQ𞈖eckF1\6v>ʫƶ  rI$*zI UuAN`Ǒ=<ܮrozȅp 5ɖ6ϧĞsAaJ'A<ap,tq5_O-Yy{/n`^f_ݡ);/vB nY6(*%qQI--̶u`{E˖.[ e2#{}ɚrX]IƓ{Qr-P5C6M'kiW$`Nxu߮Hl"bk`X,dZʕkxkP"ʆo5 Ӆz 9nlYV- ~<o|6{=^pP\ `,yZJ9L7Mvoڍ^3va.Z_˪R"+fO5κąO2~tҲ߿kCHjͽ4C|#UNŐ%;KЮ:^JVmE[pZboXO^˨%/'[$j6ڕT:u7|.~f3I w?Ti "8P]OJ<:PBZdN0-AVV+d F{D>*L׉q/ wűE:c %u: ?竑6YM0DS5Xފbط/E붏OnKU3XPZ[Z hN%}%ٚ%5-;z lYl|*6fV'H3[U^S%q车bBt=hz(hijRbt4ȷ/C4-_iӢ+ư76V8KuD˝ʴ[i%BVuJoxU=ZUу}O9V'jp,poZqX^.Mhu721Zݷ)9#0hb^W3jW;ƝB9\X(}ΰೀ[o-jZT^6W qו^uMlm5Kͬk&BeM'N<'Z.RZ\GWF){SDV}y)?==^'}x>Zv00Wb*ZR2/whpeA!XX̥.,h+ˮͼAP* dam4:ްƼi,>ϕah3sRB}v^[(;c6rAT |ҍq=1ltB.\~.~6f:I+q~:%$8s 8VnF'jx%-}!UP7I&ǀ .IBS|W4@r:VSw2-tGegVxVd{/NsV۽S dp.VZʿhuVi锭kc@:b+2!:kKBZ+繚txSf ^-[Մa߯?]?) ?e7q/ۿn~ˮO3f[_ + <ϮbϏ׿v{KȭWToj+'{[u]KO5)+N`ðM˧#{6QY<ꇛjuv-ahᆢ zhQD frk%NR: d"̪1];dUOjVetxNݠYdPC]CAmҚIʡ~^tl@ c:ʾ`:@ze}۷ [@<Y 8dBvMuܳj8H+9 8lVH- 7"Z׶k[l%E:J GAJ/04%H?o o/gw7fCqvolNar}@~=}cx̾Y7=G}Ȝl@3'! ^ʷuxVJ/ų]ڶe=Svd w`74-HkdB?`ѥ  ^k^rH; "4Ӽݖ|;!:'-FNHԃ.~I2R1|)0IA.FݶL^QA yZ 7Q+cLҋVΙХdYhJbT|?S޻a6}}/'ltzVAUZlqtvQ =:|*E%Jxf }I˕k')%$+HlI]nӀ߁ \,NZ*.txxY8\,) -Kŏ_^&V^L'*/Dg />%< 'Dk{(#ZVl; cʽ֭xoJmDfyR!lD޼{w^I )a(1(FVQr:J.ƾvY=MwB-FiIt=:yjFՓb/,}eo{0Jw[U2 rY/;A@׶-tz_B>-4Wj(2tRER({L,HôC!#=Jq+5j)NFA;GʃA&2cXWeR H!0/>]1I7Daz aS߽[18Q2\ĭ!UmbA8(ZB6n=z's^˄|wڼI2zAGy|X:e^¹)+kP8MʘbmB0"#U4C{̸ j-[Rn08 4ΗJ:[:tB3=︉v4u~<9S`v~߲{9wzۍ ~e/nl.ب7C.1% By)'\諅Ǘa[m(g??^v8{~tu`§bSe\'N0HGo॒0x6/[*H=RzMpi/o1=^I^cnyCNˏp/R ,Š2a82Hapn꣇϶~Fg;o0ۛ]=|9}?tjWdQ_ j)C\]$(,[ssYAZ/T+LK7-7L:,>_yvB^"PQݯ9u],Xìi+XE!z յܱ#!,rCY}_]}v^}c+yu+:<1e)gX@xGrO"H_]7Bl#qLzNZ-qt&y=Jd>D7=Py!zdFҼEXBQOkAz/4`=;OU9 Whq8I&/F1f'46_ʻI/F)\-:Z,;qs"FؚQ' VKh8y 9o״LfE?injKTY׻Y6) Çyz=eahx!v7/d4 w@-ί0Pr V)hEIs6!d>eGnC>4lˮ3o;[lJkue$\'Ú\ky=ASX俞{}~$h<-'2<'琶[IiEu,ZDgҶwBWYnEV[u `/9= Y)2Xb] @x}tRrg؞{gηBHR֒=(.i'KL}ש;=)ަ-DʺiJ^rbJȃ]_YyOER$ GYY,qSE!58fZ-DD~8qxPT[ԫZlǨmJG1:]}Vv:+vo Ő,S]5OJk{h_Ių ub@rP,u6D_?.ێ#cez ۍ>xm CPRjt\rA(55 ڹp/Qm_UɊFm{jboAZ:TMj=Bnj4ѳT-ñWo5;B/rcv=\P @R'RO'D-9.4r|RK?i3ZkŢؠeBz=d6` vJk/(X4p HNDm;(ñRW岺.I~9F)oĪ\Pjnpm&eApkRn!Þ<}XRqBPԞQ;UY Pqz|b[p4leODžbBic7 HZ :]+aw= d%|UNi*^@]ag{moe~Άfɔ~[M/osvkǧ~6{=!#秌#u_~tT|%foV}Y& `lQsz^528{p踮ڞy.r zco^B67`9rti= KQ,1;C  nՄ eծÈ}IAT(ykԎ:ԁTtFAE j޻.Grꂯg"2 KVKUKqB:zyy'gySKeULUV|+ʒ0 tzTr`=T{"4:*;[7Mټ9/LqgDZ@/v)CGQ̪qONY-x;?e t(S.Za]xLhIU\4SyV@\ `Z7 T9}SFKޅ gPFs-GJv5ޙEq~|LqAe;a)vO07 '?\*߿>CwANWㆠ͵=_e4BǴbUմԆ|Qk ͚ZBM:y=N,UɉC;FW##b6%?lu'<'٣z.c5h+ 3FP" eװ&hίOOߋ?_<?rwX\pvm{hsZԤu.S(2˯tRsoP/YHѢ8r.ZyVg x~g70&/Vݰ-\Cxf˟Q?=]ϟ̜+ na_'`YVkMݳn9klmSB* :;z2ٲTU[ h5k8b5N4_L 2:?OA-KIRKtjh|mQ{و9\ϣ[G rn(KüǤvytRYf@_kwU~ٍ0r ~B?zݥS;Qnՠ3K(z;#%!`@k6Kfy`&dNvxBKRcrṖ|4 Q]Tq_W]cX!!lַ@!q.[%L)یa̶@X:ҕ QcȄʕs.JcCbpSH>܇/[yw~7@+^@4፳v5^` }&Zs[~ZI'6ܾJT#rZ.~|"vD,%TF~dV-\J%9S __a: (oDZǓ/;94ԗj> :*mȭd[4;Ň)-e?kg *P6- '9?TGٱB"э?pv{dȅ:bZE6AMΠe=j#;HeR9̘=M ڌvYaĜ ;M=QwQ?dhRy>x! $\\LW[WQ?)` z!_؅Ao6Kr 7p8;γ,r]HC}P0SԼgj^`݉$?xJN6PlVf2!XjZ^5y^dB`u;j/\c-pJ)CVk{ Ql\H8oɐ |/|(~z\\fxtS@c |/>^Fr_x{7*Ο)0xpuC/h ]ݱUB볲5xvR˞ͼ@zl!uTCX)Ġ/gT'xQGxсQWLn78o=LTN\v8kÂb]hZW*RO.RJ!K|0 i"fzMO=(hodw{;)eZS5=- ַ~kt3>mS[aVAzm$jDd%s_>~Yg:K!OҤ~2YH/ u smRRNY;^LqyQ*w!*xP=|SLNƇjz?=_|pmst^ BґYBwr`O7`-O J)n-4,& ah9sXr\>k ŠP>t~b``+ko{vadJػpmْCFviu*3j3a ,lBO=Fvf-sHCS/޿к1%mH*GvSVA>qrnlcX&rkqgڠA֡g]zD<#y  ;t?:$ݚ7}Ze+ڥt )ׂ"dOShe4`LXy2J._ZQu0ɼ|۷BoHUngf)/*4q^GhA[)5 1$7'ȁX2wB~jʔXcnh _\¨ܵ;\+ T6^o=^*/B(E5fTwOL"VjmG Xuq0yK AUQ޽ ,BM#g! dQ tƵx{:!wߤ:k(ù^񢊬:LIbbTqfZ"+WM1q4-dWqM>_B|n~˿O'g]}Ǜ?ʯw>N“!V̟Սq*w  hҼC98\t=ϑ_0'D0TsLN\ Q9)&Bo6BPw@F|JF#.`p ڈ')M}w7ZRZ.oD ~סcYl0esPdݞº}4ٓcPM;NPד㋨?>h-{~f4[ɡfgl;gv^չGC2k)l~-%cp9'uFmj}X3:5V{jK"ݘH7Y.`8i)Ƕ_!!\}@M{Ӛv Η,%vI%^xzSPT}guxG#uq'ل+T(&xASNRF2h8e'1G}盇ar ?&;,n_ onT6l붤ِLvٟtO?cqO?v]Z3YE-\cNj#{E є:fmM=NuV˕!8AԱx3\䯊2Pz]qQ^jjhL* Y k}l$ _z@? _lB>'.߁^aІ/Y.h:帬8h/&YMNORQ,Fq:.$#joB=@oWsf^V78˳ֲT} }k*uZqՎjɯf"'(;3ge ]nL8 G#GM-X OiK+SvV̷{-F t ٹx(F K5sڸ0TK^\D?p?>wū7&6_?㭃3]fhNX.jZsUGrϞ%-޴:(0j254# , rMi!']u;zr{8##+ٛq>TaG?.7bqTȷ}toIP{k xklc3!׈(u-N<R_XpVVNl>f"N9"`S]:DtgM齚U{qJeʚ>TWa܎dx}% uw'#[7پLD 2Rz[6T6H";w{* ^pvh0٫-`L ri5| P (5*B },&unلwаh\s\`]QYr#$jT$#cW0Z_]u=qp ڈׯ?EyJem[ u##⊗gntl)ETIHOQ DӠWIfِqŲҠkjITweDHp͠HX5zWZx# 3Xq5* =;z۝1[3mKoC`bCQ-MR:,KU%fNi1Kլmn+J:j3Ɋ}%TZ%'@G6vkjI̅+%Wّ3;ھe"Smo\ 2&ˡW܌5 j I=:LYj@h@HPN}g>m{gW0dny6jD{D}/\DUٌ`_e_ &|Wnh?lÔ #t(vZmd96ֈE&؛Z]Ԫʚd{:۳:G/f4"6dfଷ^u{k|qCsv! 'A⡋^EڏLpMDM]/}Gk[67 f'WSc饗-k~jʕ{B>5;_Ju-fǬ .F>e *Ћ7K.Vgپ)HG3YnJq Iܕ\FX9Uҕ \N~js6qe_ԕs@GVV#:hkƣC@zI{w&qg X tH˪g-`j}Ñzw%}z;Aʋv㔳gÀ뙯t" EjJ)4Xt#NNAaنo#:\`2'{Q= ;!Zb&R]glg+/.jV/juqmjG[ʹdMS=j35L{["b VA<hМtm=jl0*YokX]lWPƭ9k8KR8X,įB&D&{y;iH' Ki\l"Ƞ<{< jy4 w)e:ș =KQ8#|pT'?=uE&ǹrCeKx+uXm'v#kP5$dSXjg"vZ&еTO͓Բ4 5h 5+W 5 Z{!Ժ1A |`Sn5n3s`Þ[3(3VoR tE6va3=bjsՄ.؜tN7SIIS7*o;gQv cOgkyNq}A?ßE`{*`:R'ߞfKm 5_x *HqJeeMi٘"a.:?!zCl tde :!-2  MfVU-X_n,͂M#kBZR+FRkoɮ&n~kqf [ɴ$rj;y?rd=ry'jV[ "rڍB{F2իE'w>Po\fikn֦̗>5_'̖K^]cbtGLFV=^iH;"AQNjyGx4٩"-(mb\%)nyg 0yyKA^?_rߊ[a ._L]~kEUdT(ﶹ&!(=FN-Cg3}ov&S,UGX[#`  ] ,PvE3\[bZC=%:*v)OCXzg8ޣ@Ć:`(gnG9\#FpZo="R,Kzmc&de'Y1ϮA5͡^Oaj l!~库BUD째]0VP=<ߜSO=kfo5V)0sHp6 zyv#2Hw&UL@y 18pQL'KcŔՍF/.è%DC#$d3Dn#=)k@Y;hj'ޒZd^D5}4(=Qh0zoVkC%y\DÈ \*sJ+$zBF7NP4c\w;]?N/hnr%['q|Ug.]pN_XyD`.=ͧo7{*~飀P?M~W\F5.|(?/z0jR}(vo{?bzV\>tz7"u*HjN0Qh CN96Bz*,k_QlN1ŗq2拧z0Jو˭TCXvmB|f;t{Fg_4vi|(Ds* U:\u Q`aOWsb3=bV3wgDC,h_Hhg|ᯟu tlid5oM|tXl{}8zxWv><| -nﮟtq:S\ irzUg)ǝ]XW vzNR ¸UVKNw-S`o^΅ 55P YTbp3 QK!iZIVTRAv1]u7!d26Ul l3)]#itB>ӄ1g}̛|lkkLd<U/( ̎{nnNudXVcmAdHᔕ+й,k `}<(IRg}SAkͶ6<ޡamѴpz_'vOەKUVjϲ՞ s&H#+EkR(Gjg7{]T!*gs{]Gp(j;޻yoƏJUPy# iF- H]d&: p>ȩ;%3X0+ra%XM.Р~ j 8L}I@VfICH_x}WxS\xxǟ7+ίtOnYl~-n>} _;w+8"vWL]Շn3P ɥlIe:Ǡr}kjJ$2ߒ,$j-ԦwZuh+Bm2lU̖Gʸ@izi&, @~D/:R'cSNy{\~ 8Q,j#_gbϳDgv"WbkkR.>X7TZ.O,؆"u= . ,7ϒa5&.y#(!8ʟ[5P{uR;19x=?QR'g[)MTvΏXqUp]6U?kh?_G1-~(JJ/)VYDobר^ uF~۞$yPOQzE^H H!?J;5 ]D u|7ZG Kl뤵?ۙXlk~!TJ{~|9=.~x nT\ޅN˿O'g]=-~~zp}atݝZ<\t;Oui|rxR jӍpY#0cA#*ܞ0 Ar?ST|6T5,{rC]?HSdp6>:q$zcķ:y.=0o})Kؐlȼ&o)t&m"A>KT6Ģqvh㼽B[h[y ׭D1afξbLӴg,od פf-rJuRbYb(g5\i; κs5wȻ[}h ` \\\NB" ;ȖaVv2 ,8bLn=E|3JEPP YwjaDMz5ndMTh**|I! #)Ty$ef#2u){joƩL+YE$l]m"#45D*{Kl{3iVA 4G0_;=,.kW=5*VlxnߋDt|9-Ɵ.܏>gw3b|u 2O"ӆ?%pͨi_2wƧGExS24k!Y,_I|LZKud8|1.)'k+ ZBd-4Q#/ZF_ɺͻ9|r5r)ꀾ:~/ÝTa!m3adIY0訖n@X-`6Yj 3Sr"w( X%Y XQ3h$|(\(U`֢`-uR`TӛbzL_ )|X|:Qu9T*&p$;/ŧ_nFU,/XWۇ5dSs/6)|}6|+h\GV@<E\Kb-ҞC]9|X&֝eKJ;"0D^`^>|Sҵ_W1jdLv$GI;jleH0ZA,<-&ճwpf(a?@U5*>?=O*n*&KL l~0(zLwuPdN;;M$R'AvZκVi(Y׷R<$45_"tEVf[h$n%Y8 F ><"$s` 3]N:-@od xxz-NI=>Mj7jLdO>mưSJu77u9Y4kB +UF_աI$ '`qXnd `R%EJeVSNH_%b:dXF~]asnlu0< pJQT4ҽuմW<ϖpvp)ԓ75e,ܿeZdHB(J֙mK0FVѐ,JES:GR^GkV hզ;@L쐨Xy=A=r;GV雤"OT|a \kae~Z~Eϊ ]pL'KzNqb7,YZ=e#!)_>k}x{w{] O|b߅v\Lo~ nܝg۷z:;haa'Ө=>@>0^C4 @y2N&C4tyaِs2-P\)]`Gr7&瓒ustԁ\MƚՋ_drɘ5xm<6ĸlk#Zv`rjdz@NndD I*f!]ku,uƵj#Y+k03"GR2$pokW ;?:dqmuʹxxvGkŸ盇dׇuiTOF.@pQ~_d7Vu{X|y$|fkzpۨlePvT~OnNa2*qJ1O7ϾQjYCE(6BqzD9(fߑwZum_hlܡJIgj@ddWLf=wH|4uR ,9k5WF䰶4n+wfvVĢrp2] jtbP0p֜ Q`Nm#&szqՐq iY U")+?֎ح4A@WJLy 鯟ϼ+)W7S֑xk}ӦBoYew-x))L$nBVlui`CI̼U+ :i v}ǜā~~g gV` US|;NZ!YZ!x&ʔ}U+HBDM{R3ٚ|7.-R]d it_oG5t>w;*T:b(nR|!S*6zen1~jݔ!=j)OSӰTXȁ5k6,9%o@=zC`am*QN/R5aZS; u2jfFqdzӳb͓nuGUk|=go%c;R,KwsF3ѯ cMu^Ϲ٭7y}q lwoS0R#׸Ft8+@%3ewC3{͞xx8qPK\FrcPkQm+gu#=TG(d@'&Sǎ@Mw{MO==~nDϾ_եd~C {4*ybkK>iZ>t݁zڄX"D$%†>5kjF R&1/v6k2՗S#܎`7[}[dknլ.9+?GR r:v=bLdKG#h.TG2qDN%:c  bT:9Z 'ߢg_?Z.;kT'Y;?m㨸x)_/7&-3:hZ)X2 vŒE[V|&k9Zay =i41e122Xd-IKvYI*ˢ=sMEK0WSP,Tw6Y>ݬX=3.i϶$ޫncÈ Wc#l _)Wшsvڛ)E{ IOΒPUKqI*w}1gyg@rhўE>-/) U lY\lKj\WMٛl[ڻyQչF%ѪϞ0\'ڲ,m/Ss.^Aw K^ "ceE; )ս4'+%jiP&mSni8d؆"K1!iuo)^&yaF7.ntY|mV eYtHչ0F'MȄ8WG]ۻx9?}uQۙ;u(;xdi)^6C6~eZAzukv\%DeG}g@عH,R37k̹F~ٌKZg'IM>; ЌFI`-,G_VY ~&8?~yXn;VBŔS6G6elŴ~TMT2pʋ X7W!otrm~=C[[]ꬆ lÓn{&]2mo## 6)M@݄-ϛI:cW֩ZcHbPʵCakA[D] ~L9Glfkڭ aIPœIZf`gn*srwK&'#ת_aUSci=ܖ*AeKlhTvQ|.wBm"]֎[.'T4hmm̀6{U<&Z٤MoYyUL{BkP;MF |I]+]Ҧld h.iӭRUxT{j ˇiȁ:gMw]XaRiϒPU6Jkq2J~#iot4< _`-7uvgI{uV3WƸ0)0qēluij:kIwĝL<Opk<3M'm꣯b1eK-Ş{:f!'q^m#a["?qՠ6td?(Ps[WmC'$O8:m\ѫs(Պ@^5D: ܾ*bmv5S{Ɔ%KYo:&``;R) bkEB'R;ͧn d<b # "+wa Cl|WlE><kkɰVFF*GY?/|}'ӤP7kj(>,J8eM;PyIa9A[uGs0-xv.IƳ踽cM 5i/xV C3`5nJ+^w)g'7 |x/z0vA吝6|mUJ/3hIjrk_TfnqFV-H<͡DQ4{Y-5kS%5B 0;꡵˨^$=t0\x=XЉSNMjZCшv` -{;u{Mm5a-k\gr.yg?>=)d[vdt`"=Z!yxۃFqp]לS-8LTYKBeN-5nZ1(r5MҲȎI#t<M?n>#`O F܄@N9[1_ds%bl՞#{ϟ{:^n3.}W1.GoUדvN?\@{qS|Ջ8;M\T{ěQvmNoW)+tz1+TU8h%k4_ KM63{ 1oUK4 _P_W u&@;FS =j@ڪؙ|SPGl`jE  p@IF7KN$CJu~c+jؓzKh9 +X{ghr:'#NLߖKj;B,TӷGш'*heIAAZQr+Nu/_}EUiё\X8 iʱ[Ƒ>+v?v_7?Y|(c_oD·bz/Jt=ݿt}a\,˿|[\秧_~~x?yş>wФ:k$ʇUCd3ITCKQ_(쭐щ D `Sam~- K\Z7Z(0)k֭\ݩ{MxPrW'X+$8{*^|Xl{[f|sV|r<.Uj)w̸NP|Qm[1=OcklQh<RE-ZTwa]ˠ8 ,=HzLgu5%|Y뷨ES~~UwՋyZj6&oQb GGŽwuID<%s̗ᵲ-VVP<-Ҭ_o˔dZ zBe*HzRዖ/%Ԃmv~# u?hЀ7n̑391[}w4I8o"EGfGB}55E=1s<s_t5(\b`Ap r%(9UeJAlxbV\rd}GKh+>yz:QE5QB% xRjjZzHjԞ#z ] ի9Ǣi!X !i"U ӫ*ex¡7j)D9d}I"Ox6;{.SE.>$Um\VIN3 LQV U/.jS.~{bp[{l^ YjJ6?2CP몇,K.S"ޏ"h߮+ T*VfMUqeVE%:EP C`IW@oWٔo2d$њY GZyUuv5$-u;LP+8'F@.K_Jw&!ě/X}^m?Uee{2lO_-'jn׷|VJfpñެORiFwX-Eмٶ_?/WܗwStw>Wwn~)oC+O?|T|yuǘ`PǠJٺwPNYM1ɬ14|Үjʔw:˕F`\yx05[4K|x \Xq׬BS%~83y R\~Y{V G/v`^PҏphI((UX7Ua٦u0x/ڈhj\QwD.u'Hz_9#fȣ#\(لQhk *zkkl_7,@.Gt-I~5%[GL \ؕ¹7)Xu%7"[n M%v8|5PKR2-x~ig9Gea<'Gtq6dFu*Rɨ< y5AA!8Z@VdGD(n`%"wY5l KS 6."iupcV"U C-odyehc0mlc4\/-_I(W34"fVQv+)X6Փ+[y]TY~b>&jsdVO\=%߮×p2ez8x2p29XmA`yUDTZOEtjg<|qre+kޮ"UHYPk _oU/SAJW HgW@l]cO8tɲuq"]\2.]ut ^%+"&ZsbVJɏcG? %1t^u@s[)Rj=6 ~➄ ԩqT: #0Z!S#bjdg{lTOn>xPTn@*\HN{qyp]NQ:̯7Or }L~ Θ`2Co<#ְ;a?1=[ڠlᚌ^d sAѧ0޳"UR~7ua k]K3ViZ̹'BEMvY9NsO[_ uVin|ʄv$ ʢl c>._f>NOOvGޡu^ }ŒPS48LLײX7ueՠp%jԲi>bsZWR\5Z×)[8|rO)vp2坮A<qDMڱNPߚ Uu[v^*Aݽ:/ ȩ݋jFm &@<0@J[&0)-UF3{qkjU44drp i6+(i6CIY݃e%3×+xY|ň}L+(n׋ SWJ=]0W]l y=h 3]df c? ֲQ2.wәU!+TnvDF{6Unګe\m-N` l-r5 ,YKni}vE3a*bRYn(G# Ϟ $Ͱ#T}nS;m{N7ۗ`MUYT̩[EFxh6/X\fc3;7×)l lAW`xvTdȁyH2hг\d'"1қTL飕F46:VȺi9ܞl!?iLypQA+ULOO;Z}͗ *ldPD0x YErѲ$X+/S-]C[8|l-˕k 5*P4-uy;TiP $=^+F1*5kr+Mnt,ܖy-(XDŽSi\˥FK4 8"/ ݳklSNa aڌ! Hّ1 */Sys+Po%!?a?|[8ᦸ},7nF|(nWQ| \G?/\Q>.0s fv89.ǜ/[iߜ1\ɶs2^Yv5iXQ2 $sh$-\F_\|)T#8U7Qo\ݳ^3He :3;L WaJ 𤀾Xk~w+ޝ>ڑ"|œ XY u^t=W"Nh xdHJY5V#U&],†q3^#e9*~~uC`#<%Ǣ!(z9K-+z,F+o P[z%br("S>#$\xg]*)<Ѱ9~N&xda0g1 3y2α](?C7/7ߋӝbӗ7(*/^]?xQLb*V|+n)NouPoԧ)A˗q ֛5/qdKVS|PZnh _ϝqYO5 Okg5^L&5R|#)#fZF"q$𥎛7꤃ i{<2;ew -jjzcb[mM˔ug!x@íͤɗajBNΊ--WPI*$$NLjm#[F9%J 3)I2hcc%pY!T0V rOye[YJJ%Ɏ)AVIo=5Ɇ@Dކؙ-#vnueBN|v*擕 -l:\x # !ݭ3٬qw<::(g]LΈRumd ״3x*;{wUK怼?5yE;.h#nf5+Qt^ ٤L=^e +ջ kǁTXl/ Nd w6,MW+=WbN@ X)Zq[<;7WGՒ@\liMGER[TTi%:A~P|:0 KJ6:w4ŖZ&ە#2xp 1nHƷ3aj!)Vz6kzUʹx?-0Cڴ$.ǘua$CºuQ;<=1ޛ!ގ kr;.S|֍T،ugR< 5=r̞ 6{*ܹd9xVSahjq^k45!Y(4޻mq+¯c_= B%QgR2ut^Z YNfg_/r6M6֜='aK*|B_ 03ղV,E쮛VXVx-i~$8 ޻r) HХ$ ` VerήIƟ_`r3wEY g$jV ~\ :Ush|w+El0*0Y8RGSR?Lb,] 5䂍apn 4zzIRY)F:~D|w|r?<d'~vп?_ 7Ó* @3iSEL3BlE:v+ZXױܸkA H;yjsw+,l+"O/鷇_6O2 {)g#kʀ뽴5'55Z-, '%MݡحPemyvL n LpaDŽtʕ8٪{,Ka+I;HA̧߭wh f[ZЌP4L!.Dt҉JaԼ&Ԃp,iZh3ѳ0[v"1ԝh +͡ufrV:3]pohR3Z?` Gw>${(V)(UK+p5*0۶V!ը{7U"#B p5Wl6'c`0얣0L> ;:wN.f_nc#iŘSbj#?.@ :JqwQnh 1YL]bJY,{_{# [̸3ezl{_KW4X*Coېޖf8cG)#tjB!64E((K͕t,D%W#z=Pev~q'"nϺISʃ3A8JIŕ2ja*xs֩{pFм߂0-I4"ȦmKVV?o.*L+>2t\k[Qa]HZxv[:h'4ԥY X#Q&1-ps8i(V .$}Mh8Ơ뙉WoyL@ y;L;9ASEhZ)0isncV,1:6-.WO >7K n\|Fz R"陬-=@y6:vi[s>t3;a6]rxK +.MQ\j{rQ {1dž53&7\OTPnstX#X4$hD/C6dr8 %Pމ%2XPvC% d؅4WF enxE7 ɣ1[b_߁#MXqeǕ9j[ZFśPRo RMaZu~\ .Q-stհv^F:'R.X"v]&FE`?i[7fT5Ͳ7*~qD3tݟN,J"Z=eP:aB ji Q"vȱN0~o/ JZWcpq,Fts/ƆGi{j߄t0R&&U*iڤ,ŹFH_ku ҅R+:iAGoCPޖY@+A*iˬ(r$CZ-i8: vMIِEeE ʽсVr+ecENZ=2Pk ;(Q+XiOqO[fI:x-Ɂ߄\Z)1`]ϹvTyN͸Ԛвٸ㋴NBs5]oz~ VF~O/Q\2#A c1MӾn,Y4]l kzSTŋt"l'vÀ9Vlyht;Zj…47щ?Ifvhe:U+AdM=et# 6Mu2]k НE+%aW96j1?אָO_g<|x|HwxxTLQyP%XGLIe.YLƯY:ȫhg/ ל6Nu"bzq08'.oO7{eM?/q=M&}yC~ pWxҥk{Ϊs[) E:%0~.R:ӝĴGc99gRk,xۃ0e K[+T.хDž>_X'cn&%FZ8T7Ϋ[rBMN9KTxpFI{k3q^j/WG>ÄHGwn<0 Lv ^^oz'ZnYs0{ 8]NH:]vYV- n@z0?n҈ $2^+6 7sF O˵,)Q(sגڃծBƝhnŠ5 ܉y_"w8˴RFFғ0YҎ UU$5^˃@/g1 |1*6QJ#l hXd^\(g߲ûO}4y9{lCw9\QY {w̓Y2n}?.g?ץ,~l9Y؆`P fD!=x'J1as!4/P# JPhn?[׸RBUa2ma'"ՃGiGl{GXq!&,%TrE˂7 $})FF O+mZ:U 6T ;'V : V\uoMD+ `5hS+qH'!{AZW}¸xf7'bn|% %v\9W"v4lEt=T.I,h-E:ѣРHRNrW50K ̍QB(mtU?ܔU؉ _,\*J, NB*?T'Oۂ4R6+(7U;LTa!:%[F)Hm] V.TuYKϋm3Jm/.J%aٍ \Az u 78( 1e5L orףʣJ̑p? $Tʵ(ZB(ޜM.n!P#ZN,c^W5wOk8RrӛHr_Qd4D #FhT%2l7k:ijUE]=Fz #J3h7k*X,C;/w)WVPNXK!pWrx q/#-V|Xv,)W,^īrŚk$v_Xfd[H 5#xZ%aFV&Yl](\};(LQkon\6N|5L]k4o]54N. pUb̵UͭI^ X = 4ڐ5QCOZ1]wf)kqmZ棶ͽejqAcs \ F5j~%SGZ#*lC;zSAZ^9Ԉj]-ff$[)Vj?@[_^2^dP M+>=0%CePF Bn5=&BDe ފF55X;+^65J$"?[Z(#sPh܈P-TGimƀV񖖙 w˼aڦD |z%L+eUzȢA,)UX$0"ݢ9-VG!4%$^~@5Q`[)O֨$ٙ 298c82Kq1078y6~~y;{7I8}G?SvB[4;9p&m't(BqhVYjtSL6'm e܀[^ND@2 -5J}חó]i0-4Xܢ nT)/f6MԆofW3k}+Jy/3#"SAJnۉ2}~d^ -CiΖvۂۀJ9 IEٯ]ѼVimˡM2xrv r puq֎nzx)(H% DN$ze3@k p֊\B?8ΐ.IK TyB+%ӒK7z)Gmv5Yȅ@2rng\sW'8mibf4Viφu\a\x3}/a(g|J<;%9(ǻKV[SV2(Vbsbn*eȷ2] q{PUS6q)X`]`}j +7[H Yځ Z}ubbjƮ׮/8 bޑ23mb.bFn06Ja~p@ʓW [Y8c\ŭZ>L`]&:"׏a5YL:wO{Id̀kslCȸ[ U^Z6?(V`;)r:&!0~;۟oYo% dw?0!N fYdKfP`ʊ/-9 6t50HfbH᷇w_Uimp6??LzOvwSv9~?ݘїV1kM,ӬQ~"[ 7<;YK?uFYKPYakJKN׌xT M^tU%eJ F>\$Z.P-TrǝI9mnS?CeׅmrE)q,eD'rλP i6\+A6{&BB|li! -.t.;xJ9I?iEaUgԗYoԿʎz ,_^y>u=&4lw.F;bP}XA j20@JD iK7d%tsm֐^ɖ\:hoC\nWA eU@̬bX lk?9Tvɺjq;ӺqSiyYh[dCߝ9WuӅL)$?fB/$Z=jUjh9`93cpFBn wq'+ñFN,X,N@ EŜ!elFSSsF&W~DNڥNpu y*ޒ&T#h12s\A?>|?<烈7F}#vY1Lv VLڂ^R`7éJz qCΑzWL=ɟ|y>/ NNgϓdf.Np>4I7I%5^S7FYfDrT^jD^wEIV# *P<`a`a(@+eY 8!tLB ˬ2:E}kP ES}:17( (Jg@}l`ˋd*Ise7͙ݜl9sԼR mveij'e36(+u>L2t`}0y_e]fhFOj|`PPm4^\mNpME̷}?. &6nje%˕Z#8+?.ʏr:PB *dȯ2z0&8-utW F?c^}{~{s]*5t'eevq4Ɇ_>:y8(ZהK7h75C(B~n,G Wda m!i-,ln/%8Zl92DHeYada?@^OB ޕ1 J@@Z\*7(FDG;M#b WkD \+w#4}?.aܒ) bu󥸢o# n+wItNBNMʌ"zcEL4B궟@𹖝 xf C(}%u CM7(MѕcPD-4 cO 2G+| qP `'3Z|P!zwv鏨b&|w9 :JhFת@OJ[}PJ%$|֭׈^hLRqW'hҨQ Ѝd=$iJYw'U}=K]^no=6::&FYI.Z.b̰go)l#Пc'e^?.L@3XTł޿qMjcՈuh % sf% U*i"CTSEoRj41Q'MUqW#/79P@ؚ%&pud%:ᘈE==VҸ`YMZLrZ4ѥ  m cWs& ͦ3Z(kJD*)$+%Ad0bs׬-下 Y[HB+̄hIHNaXKevNdJjљ Ç/oNv=nMiրI5~!S9Zs,uo_COp<:L_A7olif( pL`r]7uy]ܹVDWmΦ (Bns\ᘠm7'mlzI`ABK3¤?=ypk}pBoh"+siAkI9:huvYϑ=:ִRęZր*k;'abK6-{Ɵ>ߟ~yz;壼_}&}!Z @Tս2'jhF2.Pq4H 5|=%DHEnkyU2|~f݁rf<,Y$@Ϟ8_?.%tE[ 'º<2<%?e FV۰wfO|_;|v٧L_i`VDLP7wB ^3Qh7ijCAr+]@iZ:T K Q 1\V}2G'ƺy_Gݽ}.3spߦKAVɖ+wOVd蒶mjвE+t==L3XwU 2?+10D!\,/Toٙ.C}j>mFj=d~˜ +!r)` =IKh??g7ן.YgL^B=f65o $tQ0/ʷR0Lu`HI膡a|,G%2Vy?+zTܛu1 ^ ,3XEQB#1sBLV+4Tys1YŅd3'nHo 0T7wzo`#(ȥYⶸ`׋wp䱾 2Ab'*GT|sd@Gu.]_ 4ڝW/ft{Yra9ͩaS !LZQ" _UܢnrxR4!V*d|.֚ʓНRa,)e-.Z59c` dy7]=DklPҙyQ)ɵ ?SBHyURDOP-w ~q=]Pvg љzJL l4ek*ZZtDv4Jwy x 2xL6NFu,d^gYJxR!#@ΰB]μz RH!4fvaBԁ<(?-IӬOιe9'?}r7y|74^܈Wph4oT4l(N-՚0!]=Dݼ$KNQYD2aC=-f#r˅e7XJe14ѣjYD^h^7B-n ^%"eZTW,Q,U^yMxQdZGBˍt ,?8^ʅ#eU=Χ0\)*:tx$Jcn^Qй7:ܟcYu5W\xpvٍ)-6/S,ZmkeBE:6ϭ*GQ8{# dz@6:&o:-a2:PsAFQΤmw!rڞa&īoKeVC#ظ ]Y.BlɐjS5KD{[N 2j-#@ {+%·B0(5hr+# HiVi.騂@PB cy bln8Z^2ў|YoлߌQ"?K,)ك0us-F f  YʾځBvf+1[d| ]RJsy&9Zh˵}y49ee9;Xd5|?FꈼRm$rנ|?fn߳V:׷1UR~;IMmCZN;qWpZ<\PF@.?h# l6m8%; kJiH02v]eX@VZHmk-_ǿHl8+Ln??=O\M˪.NX3L& Icri J6e>DYNԂKw.ٍJhUFlBItbASj7U&! 9PAuA֥@LƁn>usq/7Η2̅> Rhd4׹p=..Z/lE|:D엫plՅX-l5Hn]VZjHz3s}H5n|%QEj7͖}A*PPGRVMizpLJ҅  6Q4DEgd[ؚP6eV軰FU4r+@Yt8F8qF}үζEȶ6%ѿT4R`բl9Kk]#^WPnrZ^dlVׯ]K@V- ^}g^?/x8GGU#efecnd|^w"s[u?+石+eg;=d{l]8',BVTTfpl͔F9f DVudu7U#"n^vH6SK-#^A7褷IלY+a~A5hU㯓ӳ6$nskF~eaGA+.I ZIk&VRw"deuVR`s?gQh.R~ :6@ Ç?&|goW%$"ПUR 3>rzn Fes/bn-&5&tHVV4N(a1sΔ f]ߜʇel5/ l齹25D:[׊dZr2gG>yLLrDZke1i8ؖL)35NvcM rѠ`M--K2Fj356Aow2Jp $@ĝC KHzO6ɕ2L9x4~ʵ:paAZ8lxѷ^/6Ϧ碣c5NFyp&0dïiVϏu@ d Z0:k謢ܺAtftsPJFDzx\V~=lM6Qx [ ZӄVu4)%|+q$ Pl)l]ϳcNv#f)/sF_o6mW\t-+j͌r`ٖ**l8bvW׾⽯Y'܀JGr!eH&tZ[؇NKPo+{#qٕ6Ӽ6ZYBܨƋ368ۑT,`8:YC@L6QUJV2:^_?/aтRV84-HZ?oeIbYx g7ٱ{q;ί29Ȅ0@xW12&, ta~8nU%Td}+:doc]F%U; G ~jpQQ_\.ßUqiaӽsCfC߹ gg̅2V#ެw*[3-nq}x?i8~~άc\~U'P}BjYkuB'C*OK'1ȵP*MT9HrO*kK{u~4F߻;9d/nf˗???o7R0~ &t+Ia ,0 )<-m"7rE?V gObNv>}G?i#r6C ;ErlL[p#~+ Zj`kcB@Mg#nR77(Z=877L@I: - ׅe#ב (dh\X(TX@r WBhWl*rMq и^S?ʴPX0;WmS`1 r]xFd׮jvzO'a?8}| pPTȂZ+VDǗ+. y wIZCg3zFh2:,ShJW •Qƞh䶰j {i!54Z[ JtZ*-hA7ut}oo_<^Ci3cu]mI,~~ZU EN7<TM3qrz-{3Pϳ 5e~HKtY'|EQu䥓x4$z2he/\v) BZn7c=t2.]o7h{w_f&M+nlOxyI**.4U**fB̈́$v˰J@ h7u|[VD+YZEN5*}o"zY " IIA@rx XЮBǁ*vLH*UkTǁx<~~?ۄn,v3j89"b k5|e$ Πp(Qn\)--ZksI`,espdbp?v>&I@s}r N{xz|x=#2}c gKɼ,0.ڴRI tʔSyt\=e T.M/iO*[aA=)[BG'YRh5)mbdڅ0聾9IPCcs `Awdp-q3(U>`3Jqa-WMVY]fχqO^V*ElmG@@}tgptUʴ49G#%LƇ7d592s>?(2?ѱ{Gn@>d4MZZ̟>}KEȭF@ nevY,6߫Is?e_Og?R,>,|}ZMWvbӸ,KuZH&յI87B%owH_?墅+!9ۦQcZ\܉HպpbD+f2-rnTQ^N Uy®t|2%ta2͎m1u2xs[fɇtAe>:2zTʬ@NJAU%q:U$hTy&*#լ>*:5fy2oh ?MlP:[r/\);)=/ʬTќc*o{XLBN%s;/JP~&k7QX ,ۆVeE˹f*13 >}Z=Mwb|өh @ Ukʳiyl car X(Yy-?] nA&ۆVdj-ٖ jQ-~ vQB$(H'.K`&;u_~f P%Lj4\lA7\6U^bW5P:p_RC?e N\.R*D v[Y$0)dE"ݫٻ}@-V9i5VI)v.rA߇fi Z"iuy%UQDp6m `ֹ@n1d V }=M ݵD3EIōybG~mg/NLlʹ$Hjўo`h8S۩ݗ,R/X2΂۩ Z:nd( 7Y/Z>Jc #upq~99Z+My07Vkˠ!j?&oӟ_7_wd-FxY"Rs-_ZZבkHLf^eWhU(4=I?F\hThc/? MnQ+/r=/޽~>я7im˳{9~2*hwt h;ޟt2Lׁ0ۻ+[)+NFs+3D*ղpPa#b,N/==Vst,E`gI: 7V/V^:NRn70ƈJ_g8rZ܃w9] ʊpb ! vX!Ny<䷛]7 l:aPy!\&$+[]j#b7zufz'ٰ}w1:˦eOgAlOy©IӤp)ˠ OBy< \K8=|J' ZEBAۅPMWfVʆЬ~-ɐ@ldN>};e9#W+0hQ|!78m9YV؇fUu3"eX͉$&׌PkuUSL9AJ"͹̌r_h]Gc60<\J#WV`eeFqzlp!~pr}]1}<}>Avu|v6ieLѰ6v86SN`gkE]Em2v_He`Hi7@HuɢQrζ:nh*ɖ4sW[hVXEB}8n(Xi%_❈V j)(,vQhSm opڟN}?eϓ<]ߋm?Ew_"B\@{WepK%m@pd Paea/WxQ'*Fv`9m+gC@]j*H@)N//yY Q赨TG0ÍPR-t.#R9ޔxz}ɬh!D">#x!Se9$3cX 1t#Xj{UjgUFel$.xQI${-y3TбR 4e.\/!_9)?m:yӿ|zNg>{xWaz܇{,?|ʦ}<;t]^Y1 o<vh4e\.$ւYf]z-(,Bkx0a>M6~H+O˧*%ۍ LspWet-_;\\KҌp~Pn/_>_pQ A0^ p`:ʫV]ng~2J=ϳt19̃3_\ d2mXz*)d8~mB 2hԲkWd::N{d:*  ᨴeb(&P:T:$~{k-fs 3(#<"R+-6*= +d^.6oeqRF|P!"kkYk\gLҳ2zRMj4gϟdS]oc[?&ǙkͿNfϏOwݽnV=Zc79Xp;) fV0Hal?a˜4niE#rvTOZUo:B稦!ފqƚ۫\ӑ'ז Pڭ$('-x>NrAfV[~XyVjtsGp0(4E0Ρc܎6c2LiՖ6KCߞyɈ˘¬\K#{B  nj;'f.8Sa;NE4U*UÖHp@S T)TҌ/Ŗj(yf%)nxno.R%iD558!jp!R ')J*:vU2 c/)M/6I 0fnȸ. rLN! =0]D(Ϥq+$_Qu2}uG/M0Ntkn.%s%< Kr J^i2gp]4cM6UgA-DG0Sk\)\FTӹAPbwk@ hV!?*%d5 ݂+KQ9ؒ!!M#?ݻ㖲l}Wl?^2c.z#>23ett^3M RǦȓ:є29g ,+ ꖗϿ+BI(.:ܠ("z!h@x?^]jv"L>~ .5T94 -q~,_`* x n. E|-nuo/侗=KB1ZY %( gMXњY}l}b~+c(JҍpeV_dy?[֝;ءe=?kI6O~<&?gZ7j+Ln8=Jw.ƻU}v8)t= )ǥ4Ia- p D\LE"Rw8O;l9BGvMeN8Fi ᢀmxѕw\ճa| @tVңxI]Od''gQ ..B3ok0 6\b[ j, jQ*6\mލzV{ȏuE*`H}VM@9n5ll v~uf%_ ^4m7IX_%C*@2I,?:V[e ;#$ҷd%x@V^7$½nQ5F'LZ! g;EÍ 7-CTˉ$l";aT_>/Bku$X ˙q{4FO."xxּ%L)>+мϒ$Dm8`|Aۤϸ款j6M=i rť-JUv;teV_c@Պ0O^5i{PÔRQ\[[*-2 >u)_>_T݊. 1W݊hpED#D_s͠ Y:*2GF7F7v4sAj pPOU7߭nݷatTF:F!4oq eVh0,5UFW"7&/,5BaкJSg4#-@F%Z{m^m|rʖJl'Mܒv[)[ٍNͭ-NݵǵDE 9ǝW[̽k D1#mJ%XZQ7?*ⰸaEJU^ Ҍ|$gV5Syq>ͻb7*[E' pKj5<9 UU9SDsZ1nc\oufe:F uYQو8Ze(pيynJt7{* ߞsZ+p|] ϯJ鎬%H-Exv{R)%Jܾ~zoÛϓ;wO`zerf47/c"?:w5WM4ыMGir-, n y활d_Tޓ접 ]I }d96!5٧Iv7{uVe̞?Q't?_dn>4gSK}gZPgБ9l,V;t5_*yrԻ&^'yh[OZ|goܮ?&3w1=2M41ӳNKZ@ߞ8I9gY~!ȗ-^8f)˭,|kmPs-ߍSEe# L%Er6GSl?mHm8$J m/h׸Rj[AM`F~Tb$͸M"z;%Xc+ϳNeӇFYyyz5MǠw!dBւ64uPw(ќAg%1.[7PRfaO~Rd$NDAQaj7fʛ+Y-E f\DO7JڪԇRN|^xB/P`ݨbH7sgS$gO;||M_4'wta*ONpNK\&o#Mry)cj=Mܢ1c͉2ZM+ gWGVwkPP7B1Hd?q66?}|>{=XqBRMōNbY&5=.e',eYŲ.Zp{y2M0i|6-֞+yib h{i\iW]JohUA_ -:M/s]A^vq $! wTot (Iv&e wP/L!=6VW_cg?g.'j/۟mk˳xzsfo/mHTIUT m( !0YˤLI|4 >Ѓ Knd rQVHkPNl=/WE׏{CGǽ~'0}&%槃<ܽ:Yvq-~s_4o)ӇsW}>y$NNeӧİץu4kSO12o枓eth" #bYFn%dGf%.+d`~v!VLϜQhtEןc=^ڵ-p,(W;ufj,% yњ1AR07lV7aN;nqHDΕqCQ t0!Wӳ1iL]ƘG_l(`a8 `D 0D߬Xˇ_+U bmqjzQ?A@) f ,,֘ *M&YoLqֿkLx!uR A=%k %R`NHV4 Nx]UnF)[e.haQ )AVKUZ/<7Nn!;|u9~yЮ$+lE YH,ȏ-!ے\[$3hˡ+—a1Jx;YSڠE=EgRsm9/uf Jdm$2 Uno̾-L+̙9Z|̦.bwi0 ]p&Nȴ۵3͵Nrm (6Awm Gvm,z\P@U]kR-ōyIfl~}"MH^0̄:C[d3t`)BAT4flxEDͣ\ag39^ʝUAn!C5u<49 g?MBI[*Q>A/{ A}m2dOO$+ӽkjoAm|MR«J n \MP\o$*2b!TSaiE?D"+(Z GNVj.{&~@t|Mp+њ/OOOw$_':}S")\9[tp8Ji|hr{qBhXd].Vn+KN2ip+TÑ6* ~r֗[:\.nAZDҋb0!Z긵P^JwBQʵK!7OjTjw .aƴ^ˣދZ~&uW,`ܑ̛ sf5zUnVKu<ֱjV"VY29ir6uT_>겎f 6BQi)o]܊f' L0RLn%&CnBlhp HOaرՊ\vSe2CpB~scv6J٣Jȁvxӝ6R*t|kK%I}D+@5԰I ]B9He bׁm&v:`GY*=eVJj;H_||# &.$Mvh.0v|>N'h$$gI6f$wOBosdSgM`DUUư[䏘uyH(KVHJUl0%JfO7lT fs'iЩejtTd5(IՆ"l 9x(1@aکCRA=^0Ar߽`H͛6#E1i fdb4yʠ wU80XntXؑ ֒YE\IE!/"w)q,q;iKƎkmD=MP\M y&Ga+S#4sʌ@{gIaR4){2~9 &, 2p2(#eÃ5bRop 0y\5etUdM#JPhXީԿ՞׳ondgio?"oqhv7FGxBpɋTn`ebwGoκ% oNVM-VWdVssB~b͛1J-F8Dta R\,RUŅ nyV1I&I W*w#+l ']eFIX}pVXM8XMG>fKtE%%@< kXe3$-n|Bf~7 ;[c ։ .I_[wc!;oZ'`YM^nfn֙-7:B*Ж'ѵs;rg a5\yYUo>#x|ruM,Bv2ͤIEby?r2CvZ\$ڢelk;&C>=LG57oOvq$'f"xa,tb Kkي$zR*0_JfW 8H`FDokz* }ۚ.Q ÆInS XJ muv)l/jц< I rGie&dCi[!oXf j 3h=Jf^D :>bH7l%`tT|C tYV0g@P }'~ˊw)o^zx/=:<|Ə~v;cr=m2On4օf't9Y\Fl:疚&$M]#Jf[Wa2Ereu&Ucځgd"vI-{\6&lcl -92>^L\FbZv\p/N ޞǗiUcu<}Ӎ.Nҷ8}2~~azG9{|;}8ߧl=ua2Ûϳ?& e__c^8HiNvtcK2t!7d ^MajemRo Gͦ77$,ROX8 ӎ)[@Y DvU"OZDH38|7 ?C:U2Ah%] bpF `4Bҍ\apr]*//ªHn3Ta5GF*!^6ϧ9$GIv=1?{x{_N>M)}?fo2,#.e&N\L`Wr'wocbx/:3>?>q'H`ZJa g3zɵ-,msl[e+R.ZA wKJR!肙 BǢXCy <:{}t)|*Kv>䠂cKu @D~_֑o}20,k6&+Fd^ҳ&hW*7W:ן/2߾L?.__ǿwo;搈W*d>aFi2 Nɘo7=+KM+dɩg@D *]2(R~{gZ^i0JZz`Yhu"x,Ud3<[7ayIʐsau#b 2J(lɬv`V (a0wO=nVRVxv; <>S4hܮe|,.uKZA=~"FﶡќU2bgۇ.]N6!3|wRE>z <.Kd&{ٜ/t{O3>O&;n=a-a>;if9EOPbY.ʖz%BycITQ_?ɢ#I+W8עpZKeL<KTOo5AI/nRn|w+7]S;/#=m(E|Dw㶿3+4.7ҁ:"[ҧcjOG/=#l t ;.}Nnm3^;[P"7 BeV5k; 哴B-XD*i8|=61F~){ol|7N]Wͮ5[* !V" Qh@?Jm7hwU,f(d"B0ZAcb\;]W\zİ8@a`1Smߞ'5nf~&ymA[X݌CL-`ܫ%|HYi^3F C?Z?Qц JkYAZ|ր쒱ՅAqךCup|`5,ZQW3JV[QtMsj r#jbo! .E^]1^J^6pgVo}}eJ7MMVsnP V Ej`v#ܞJ..npABc,_$UEen":${O&F%I> I#VˈhZBIO? =He )K) a ՉkJVc#ͯ")J $6W۫˻ɧQSyF- EHo.J +&I5A*q@jZ6B.DoOY`t_^qnkgX~o2Z1Pe;"w/ !rF\{W^(p4i7c4~x|\Ŭ0Rn9a!+/N7#n2pqfg~Ě֜X: hq.'5 }%8_:9_XG9JmtFLt:-J/P\v`>&I_-/Cl߹X+Q` ER0Fi--&Qg:}ϒvRFP2+XAvUeN툛rVIuİ MY6Ҍ3=z ;DXn57+a ]6Ǿo\X|~ĭn,e˕! J_>NɎCpФTYJ~D3Z 5nņB,\CDpyqH7p{`M-$6+͌;HNlXߩ ϗQn! 9 ӂIg;p&T⠊3 rkWsZr*F#kC]߼)-fЅv8Fpcl1bYWGڏ(׿vo^׳\YUFr TAvKi$An5O0.][:peblB:H/g`OH^Ti#*qjDb,EN1Ylf[u( XYFLZ#>v,9].D3hhVǺ>f0 Dm9領L[mq-%),LkX[eن2Q$ፔ6$RL' .DIVy1tP+\Q.~юlcpfyĭs7K*5Bm1Εa:aH>Ip|iy=u~̮F&IB]jdwN ]IQI-Z([w8|*{lӜ8s:R㋤B'{f)y%Ƌt]ʐ-D[8xm0^'*m Q;)&@kD]ʺ vyӉ+jY T heɗb`d[me35Ív[22q 7pa+{ore|gr$3.l6+!GǣGEu3;6kcQ>yJ ˇs&+vVֽhfjp6r AH/EEnaj EWhu2nayӅ$GoI-q8]vQ0ZHSi~TiB3=@&a*q?Z9ˢ wI$+ў8܂e#^i}9QtSV^2.CLw%/MsdfXڂ3Ze[粬QIX"sK6˱lyX *#nstAj%:%G"M?G8i 3Tc#xI⼫c -Ţ6ΧfϢoo^ҋq{I/FńU^@9iv=sI{1ZtjGl'Bm' e+l}Wt?R.d \@PAq}_4YCv9-zl iX68t8R6f~͊d6TA6Àr[ ^>w-!!e~ *JgDo Oor݅F*M固<6]mBI x 2|Ej,;?M2''쮓~aٛ7`u.?iGm<5u/NNӻC6$7=>-Zn9ʅ`GE 6 c 0ym%Bi/>NД!9CUғ1h%m4Fh#r"ÕE2Uk/#EݕV!>9ނGc,d9Bc٬@]šb2|ESGw{YiIxe!RyNPhו]ț~Zea|0ibk90+e(R %~ž =Sq 4nO1`LWȒ–M:(bP I4{pJ~H FI,z@"F\&W[t%5댶\,4'2}4hzrm'ܭh@a?^vwqOC=Z`U]:>Q*`/xWJ5pr!I@k׺@ם[c)JSk6c\ڱ r;FFS'n}mig3w%C .!ࢸT E'gduD5[ ko\O$O_g}gwF:z8>$i*K@xZ( L-^EW}Z&|o}vX0`-17*ՀW~y o苪/-XBZ&4y0hj}aU#ߊuªD2y k}j 5F m-ʵXTFoS8@]*%E5JoSuMgJ} O3,CdaanqUdkְF)]z \^ntW@.hqbfx{Vв1^x<\rr̬E8D#:t&î`wx[HQZBחFIq6!Cz(}F$2/ap5fNF\R%4l7l'Knj8(6e:Wʵ]ny 7 n`ߝxҢZH%-*[$r<_az""}hnK=u Q&]f+_ĘWc<]rR/[ܼi*Xf㛫:*R,Z0lЁH&2:,[&" ʱI]61Hh MtP+L굆71-\D$1mH#r [*ƼhbL 蹨5K ݭ:RX9C^Qppg9QnCR:R[˕ ^9Pµ-xz5+ !1 b %+bYwM )J&1Ѝ^~̮ğ?v*D3'@VaD08̃a1,hRnCR˔n'&XFwmR嗏 NcxGB3zj;ݰ*]GԍzqˊwG<ȠuqPQ^K̝1`@7[(  GxըUIK!M' ^JhY銜gqNڸ~q‡ R,C~;sؼȌY)%= f3S?iZ!BY):(N2GXTђ+3:E~X6&+S@}QʖJJٰ,J(bT7-/uC)P"  (>P; dNuCńQ(e(-m`nn&m7K}*~ӷbVܔ.g*5I[n",Yvx1y/ fir U!~?,zVL0"V)9t\IfVh(7b8³/2ZׁiM *hQѲcR8p"eôſt ˄,Q)O!!#·JnI#pK?&^G~;}dO9==M^!as{`/&Of߲dF޹n?U1̈-X1tq]-8$^7ʗhIuK\RVh\ABhunZX0?OR6^lkPVxUYQܲ1m:l  2nafW"sͷ͕yшJ0M~r XSD0VݸR kyNxUD/#NîHԦɚ%Ġ<)˃(Ph- %xrwA#t K 71ۙ9< 2"b9),NH*c:ͳ,5ꦤ{ڴ)OR*,r;l a4Ȁ3p-uJ +ke1F- y(+O', Bz\pA[mdDatU*` VW`~Iǝ., 1*EFx DC*Dy6j!F{9RSi:=eyv3gg}R] ϮƟ_NJonkV$J~Ve>.vдbaM!ԉ;l۔X׾{5pUp{]^f bho'a,頨OMGqWE <z6{W?|<)]=6Xv~l|w2FAyx9q/Ʋ5T<١K}m5*TO)JpVkAIs0}o*𰋪;lLYFeY!ElKD)peG?LIj9_X˂`.Ks |L&$iyPZv eY *Ͱ{aq ,[e8W& wmee w򝭍\r!rBU̫8' 햪k[66%.U-I4 %ĵd$1z!$T:|0EH7I ڢE.//9,(dB- ȯ`&+@.Qĸfn)4)  E/B]LeWĦ3agvx?#n3$u6oQ*nQZTS6ɨjNPixZizxYm, ͽusUsJ p  kG4(b ԛn[NX[IWh2VmǖIX,vY~bYC ]P4$3q (v@gȖőV;ʢVdZ(3Bԃm钜vh.ȊQfn;R \y˪٧Ż]Rb[}miPpɱMB;a.>fNFM,MѦ2 8"XKHH [W(HmY/P Ev`-ͭ&vDo@0 )S3_ᔇPOp4hfA@zmK+h8,Mį-d/ڄ=:|`4MLL*i8SW^׻pшSgiG1i.$e)Q"2 2m/?@TP Kڟ}l4>v) K]d0Uׇ-(]6<`d"R^eR{#׾\n+5&ܶ`Bpu7H'~bEn J4H NwМl s[ 8AJN #zǤ݈Lrch`|x+_fenIbvrB#jC5[I*AVU7P%ϒ~@W],5I02?ˆBi&oG 6\Jp)U .e ֵŢ+S )I!;.=f(dբ#-0*YձHFdU74n"bY${С}HC1N;|pX5R`4H@3.h6bp B>O?=wIhryXVk퉗@;)1R`SJJuYashT6FL8 O[1j,>j40Pm [a \QL(dF9Vn[?Mzv$\O$8fiۛEy w'9KGWS^\1Vj[4?G+Z%̡}tmdR@eӖCY7.yD|6Y@h^">."aQo͊fFeH*7̣w4."Fg ;jqzJ^a%&Gwrѐݭi 5eI0Cڊi1zއ$VUmj:6`ZM=skDxr +kW`ܝxVm;HЅ2V_uvjђf7hD>FKwmF64Լp֘4Ϣ*]-:Y-v{,fgmh tҷ" m堽V_(0Y4<4 n6!(5UdMyب0"^pjD޲r!m系S'Ot{x_q\eݯD9,?LcIPS8yȫCqhtэ'- +0t$eӘXVƱ+j)ʗyDViM Hbb їVV>u *JVJb吂=r~~\OSbX3$nQѢԉ[@Z(VMڪ ?@,iUaָN.h%*,p\&7pqDQ~/|'%{$]q:,;OYNl?aq؆Idždq H-,R1ضa˥J([QM pit@1(?eʺ21 m4 o+yvL&[PKXɛl R 9{w(º#A@\ ,nÀWHAa&{"&A0e+ "LԸĹ) 0u)}Npͺ>U ʦˉ$l?)b7B(Q&qh&$aOiz`ăë#/(7@7CARH{X20ŔQĢvR[t̠-4BJbII݁FkRhXBr^&hm-'ެC 9%wRcz\pY;J08(aYUX4~/-x|1^EJvQ4A.ŭ5:(kLܒȿ@wo.mFEzW&Iqk an5++д [FfKzl ]?+,gVҴ,o֘"rf:BT.wzG$OPzzg3ne@-ƝӀX̕BQLV"Ѓ͌eM*CA^;U Fe6mlVDNOW:/%;OُuѴ!8Aq2(vIzU%{u5ƂLZq(/9ty}H)߻yh>{}/s^e}LÃUO=_-Y4O'_u8 w/njv:x?}ٜCͿL?'>e3h_A_nz@.NE7<]K7VRւ0PrGICfLK+v8=8 dh=((QՑZ膅˽7==:|ɥU e+vk`hl@!v'^F ar6W ^᎘)}O۔! ˘wL6˂ %i(R'G8S6?dS w{ g/Nu]Uo3$R5'?|p-iZXХv. v?5dAYKWʎ% E ^}r .èH&H&U ifj&l,PÃ!=s .bs*o۸uw{lG!Hԧ%J$[J:<%/IO -;1FMd2LC&]]BUy.3ijֳj"ӻ#܄cIq8,~::-bX[7wqP쎊b_魯}9'GiUAuW(-iB.s0o(^Xsj.z*"$9tB}-,p/^u%8夶FHPeVc۟PU.U{OO~/_ ofpWB ʁ؈IӯTJ8X7&= rL{Ar0mǭ1_p;^v*g T:ؤʨU۱g䎃‡O/OegLcͥ(Z45 ƠF w-hveTO0& | xevDAq'NLtwB;qz/P=ѺJ;/\Ŕ AX$?Jvh7JhOEqwK%e@Jo;{ߞۮh ZCN;a@ oț4 (nWTwc2֪'qRdZ8H3V:?V,(h#IIcplp5Ki!^ Z8@gIIc/png}dfgu[ ZΘQ_M؎Td(Av\Bi{l\p(Mɾ9 OzIjM,- ȗi٭^Ϭ@Q-CC-kGFn)7r8و^$V!5sRISꃒASjdrN鬂7^3&ew5?@l-[#-w2L'Z_OU*9J,U**J,Z.p,+!AJK5Rya[tʮVZZMQEtqԪ,80iu溶sd=dz#ݖpg^DZ l4Wϲ$Żֆpf@76ʎ6e:6ƚNdKʬiEe[W!>s*Vch۾W,o3{9e^ڐ8gX jMΛ#^[7Ӳ郇~ОWJoZgfI}OZ `{6UC=+>}Q9)3<#A1:Nef_D9Ϋ|)9zz.??|쾛CdE1+̋Hg=EAP54B뤑 [Zy1p:Pw|=}R&!W@M0ALcQPw,R{*RdǐtC"Y4,&W@vM~ cUPhXX=]>` mceHJO.Sd)@0d  ,  DYO 9pm?0fjțyu%С[&Lc5R&u}KT5m3X6b=?Z:y,I9Љq͐v+KkbW1DXhlisut"`t2Ǖ.CʻYTڅoV Euupj\W?OQҿ!+մ@sZct _!:}tv|ʊ7'O9##ttUp??B#[P8$ TJξ+_cD<QxEo`_Ba ZK%;}m2lSP-q;"~dj,zz@6kT P'_^5="RY}O2%m#^f: W'T차p 1HpRJZ{!f2g=cz>14r8v:Ph=7T9츟(U;>+-v<.BS7x^0x4"b4ގl;;&ZJUJb4< x"S R:u] ,r9j 4*Ԡ8[G57o)VW?WPkP ~GZJ( ꂣi=eus3y|,5=exzt/SD"3c~}y>|\g"{wۓ X"Qx'X6cAhc]z .{@=+ʌ@nOdiԌ-IQ^9"tD,d;^X~yDwX=16+0U"-3F׎sc\a4ƒGЊ?Ύwh= !:x*WV &#!@i~FP ^ަ|ʦhSNW%y=E80,uE)?eӇbgiT-j+K-8~[?[ פ5x"2^϶>(br=n/ |BG:=,Ma+QaRH\}'[RU8My $ɀⶸ*[` *?F"cT=TȑVP paui2OסU37?:BRg{: ^IXOORIOLKg_M{Ul%^x|㣠gt t4I gDTW=0昻 h$~xJjgSM -q. y⹨ ?HKZg+6vȁ6xl[p7]D xhK:CrqCYe,ҝv.Dkl|69T)pxW=藘G:,7Ш$\Đya 7ebFϤl !Vϸ<] JmÐDd%B,9%tNˮ!hǛQob4T~_(IzqWS1͂_n/j$֦ Ljߌb37Lk vzP,j,X;4"e6ckMp<>v [,iv0X<ԮD;Rew[02<v7 D+M' Bu[!%S $aY+&g{s ; ܎[m@$v MQM_ՋPtdzVqtJBӹ394$QBi1#@~|"!Jj@ZOg%@/'Vdu>*_مLLѣ)Wˊm4.[ h,Fe~gD j5i9~t5),6eBd ņ[+*Ui{P( b~[ߢfVLV\7V<-խX5b#2-:v@R΍1;Mjg 2.V\Y144bcFRTsFI*͵c8!uV\Fl3um~,nkr%I'6~ֲĶ&:.,ͼdg`uø%5Xac/&ؕh 2d?ɭM[ԅZ-'n\p} 9نO+M]Qp'ŇZ:=f)CbWofT܇}3*_3u5=kN`NR;n-#j<'̈́[54jw%F14h)WXRs*6FdK}Ј&E4V8B9TY(S}@BmPacp .\jѰq r;+V,pH5ZU}-QBFp\KFeIӾũx+-Uq|Π\xdF2v"އWs#AX#9 \V(Jt?K薎rUYȺ\Jgڂ"W1E"SSX YM$AR:M!<{<v|W/ŗӇb|g1}|d[ȗQoR8d%n$ϫ{_R z)SMo] -ЦܩW8yم"OmaB8qB`SR s44D \h$h\tz N RuCb}pQ4Lr`X=/(0|8̊?,v=ɻ[}{g=5ۘU 3Px^U}Kv 29>os\|LǷ_AB>MvzGq`2$5cp {r]OR͏|DKYd%nw:u&nEJmv 6:cE8֠DAwӟŰWgԚ/BKZ;z1k^b y"Ћ%%YPUƆ[T|2x3~U^UHmec61TU/ tVYTѹŁ/N3+3,~}z;eء~ûݘ|'m;AypL)!BbQZvfvOjc ѕفS<\>ݑ[g+ϰxAX}b^Bµt,'^1t=^7&ظWJep{!%Vu,8$PU 7p$ [+:s1 ٯ7uH0nkVZ=5]A(S m ԖLmYOaL-fMŎ]/p Q(ɳlY_ ,#Q&HT+ZɖxᎮ#E@2bcC*dc0FEdK4bU?wz}xFVjX*641NO.L?ݱ +o.l= _ |!l.kqIEwl}~|*mXS1M맴E!SNm1 ce[M{.k!l3ʼnmXf}W[nC]s&{w-DNh۬lF=!M!> E3VAANT{PC)L W$HTÓ%oS@1l tɉc1\(G񲸛wË|/|-haz3+Fw8ooY)?EG|lwzem-Jּ|nU 1[j5ǫs t V%z*qqj5ԏ]Žufѐ|_Fi'\:bQ}N:P_ ^v;8OÑ4d*$?Xp9q.S :jZL+vHctttR|n}[u&[ a$E%(ynW;Gv/~:^u9.c /Ժ99C6Gy&@Yb $k&#bD>TljQ$>' k,zx%3-g_@ў.G/K%*jǿF]֯5*q0 v(2ZKaQe*A̹<>)zmQ:Ǣ6j_|f{zDe2MpOgj2J(8v1-k3Z9wr9Qw\Nt#y^+ LM̟|PnZPLTCi5dԳEzzQmݰ+±Y$LRC-CkT;#F}j)^v9nx`M$(j$#҆C*B<,A&uUʻHZ>Yq]8yjH1:` ok /^E/2B6!PWA;H4v\˖Yv a:_h|U1U_rr* bl*kw4[»Z vh  }u9jc\p hW*|CD1i\fjRshQe2p9T#BMP~gM$SꤙV>T/\sfu>!;ViK76U15Gr$mب"%F8kCkUަ|8-NPg*R䞫\m  X+4TB4GUM˄ok\ҭt ujT~-i@g,: 7MiiIGu|uE t0rvFTEHPѵ+j"t)oO7wqq2}yrn-}x&Vb%9,Gޯ1DXf"$qiT f_cbE@kjH|kBs}'+z?nzSR?C a^wr4h[ϖbWTwMN5"dYe IQ'jETʭE-Xr7XA|6,7VMlZS$4< dJl.xza<-h_[OOL]-vksí$;4+r5g J&dgwWBޥǎ#FƭDC,7pg*dގ~vSBθIX+'=&ȾNTrhu:02JzҬ%QYp5s]K0N Nc&fty4/jZ `-gatR`/yF(<-'8{Wɟij^qwpM[[me8 3U͐T%Oʪ)45 4 2gV E!؅dʽ<2ob~jľ}[zz߈^wZr3ɗV&&h8+N'(^n=ceDwOxñ@F֞[|y7qBx!l OUɔ$8:JAcizmxBm-dubPU2RHE?z!sHZz]$کӼ5KièORHq*x]6wUmz؏Xfiޫ)@~r\ V܀-@Kҏ%BD㽕//p"FQMr—rsָdӿ.M7VNt_y/ꂯ/nog9~WSώwxP9iav0&'rh_r4k;Nq35)I{dہ݄Q],1oj\±?]hP>7mV3%=?%3#'9SNY-8#ht2hZ7` /9Y!W|q]&A`iӄ{~H WOcaw !F_ \+BK48k;>EfE-t~Bv ^V%-XY Y4U+{<6LxzQ|}>yz*-w+ӧ?LNƷŧgq34v2(.?L'gz, &qq?x0}C^խ>$|VuLEŻgѭKycͭ!⵳CV]rb۟ūOy|w#o{76ϟO_&]?^m??wU~Mo:=nhN>9;˃fLmÈWf N' >C yyjv=Z?~(-aa𢸘&/Yδ/[ljh9kDo'U4)(d7lQ٫E/UC~aA)0h v3K:faMv}>E2unq7C;ZZ󋭈**|ikUn %8I`7Ƚ{bMV,$'źu[gr:`OeA8ohPOꘆ2i{t1™z;y i9&AA['JkTr1̛H偑[{yy`T. \ &b*[q,rĒC>TD"OR5&gC/ݽ^YՋdyք< ( E-x4"gҰu\8vQd)1 o~ZqVu\Bˣ!Ҭ%feǜQUhN8%$z2^Hww c1.~|{[vɻoO+FOxݠعRX\^[H-wӷjbx9o'u\uZ)¿|ks[Cߞ⁵J": 7q3W:sv`;#KkMjN(KlOb}j~|qywU7`fKd;-"PMmU> {糕MU)3u-J(vaBYJб5cleWw5Rv'QIRt87U upv:­MRYfErp&k-ISp "fޜF-ʥٲ!LK'-tØR-Y)򭖮apfxZTG[!=}>>[IJcF8笿0Мaax`UFbxk00?sVӧɻA>Nlǟ7tr^ ]h,c]ef7<JcMm0"vbgv-xڭ( FiN S`K;ZȟUt'Wj;}QKXӻBsߔKPlu!{dPݳOOJo~pr_x#v=ߐùl K6**'iPYM{N^K Z;\O%sPgd6ݓ eJP3l~ԼpGuЙ>?j;YeA9E~]*>#k. `75ꨝ ۦ9ֶ3`^ O1ShKHanxzXlpY|CQjUxO۠G\˚gY C/l_=lL6Ӣ6]l4mFayU$@lQR8#;|WFw֭a-v_J菗e0SUjJ~@D oe* }r70(lppmoTL_@͟]丱LvX]~%c3YScx4x%=|:91 W}.1ΛrZw@ڳ'jJՏ1:'&bʅ_qV kvFAM??2D2i&8h@4&Oa1{m'>A x/_!U,wҬ+H5+\mtָvtw̧f$  +jxg䁄JU@I3*VQJu؄_SyPXYm0Ȯg<{Uz"K \:-|_F:[C6)I3ɵxյhfϮf+Yeh#^lj!^!l;8!DޚL95&;nB}9v6ɯ[z<ʥ*k.g=?@/gr(v=(XL+g('e:Y1XáڏX_so~PX1 kx5}{wyDN^ڟLS.kLFnm=ʚ2tg1K? ՒGo%,:LYul~/ȹ,nDi ȗF_J4Id7J"iؗnA"v6OKԽHz'"Q< OgR9I*'S9ʐ[& hx _ 2׵0ʭodM.Q{q%JA`o ]H~v!RDUڞ 8Kny"_7fux1/%k9D ʴunu25خiZaN;#t͵uqGtw:VHyfZ ӉeQhu*7MDŽ/"}we_Uw"Xu MP=r锴j6t]mpڡ_v#/UF=*+mj݁gh@etHk4{<+)w||,L>վvŸ'N\ `}A׹Et+G4 ^7[CY@͖=^_#%tL5.8=B3*uWUNYDٮM~2r7X\BhǮ\vl)@:΀A=dҫeӌw"ƝکqP%"SW^w[y)'R" 5:. Ӓ kiՒ0Ǒ(YZ$!C[/m!WױϺVO@WPlA8#Ԯ:>*rCO3~(S/Z`0tLiQ0ֽNE>fݔ8?(8"FWy15CAKʁ*:|[oh1*hİ`,Ef3Z!yl჊!~hS줞ÛdL'Ebzw|3-v^s5_&B% cηCfxLhO 53Oe}+01g#\B5.a;HWGӣX|i,5&\GRn%cK5FOnq DK45,pz3j˒lA ^G #se @$Pmf< c$cTpco;Tk^GoWEH1{|\22Լ h LI>$QEZC& yV1'+{dP8R(PI[Mr` @V{2{H'J )G;Cn_ouKStK|zPU2 Thjb@wXNAR7zC/j>>]bD&1A|Hoe^ؔi>Rjhr9;%KUW#-p`% ExtyvD898yGn޾l&eZUzBKZi!l S|xeKaMq>4{ϟn'~xzO?kO*Yd |xv/a=KSH_4b354QAdbDTNW8[ HdEd*{~woߋ]=??+7ym#uࡊ5۷{KAqJo aIXJ;$v Iפw>JR]]VٳU].Uau5 )^Rz .7cNI9LmLӼR"tjf'e~_|!:9. ~q^ }_胗ePӳ>+m%sPkiV@}]"Vl='J_􂞱kZ*FШ5xgS>c133SզM•=4)9|.wbt8X:i _r蕣u#Y̷X=[ RDbo^҂ @! S6@ NIj(Drw&n۔ }|CK1m䤶gYX?M_'}3o"53gR l"ouL(yAN6*,X(!$>=y0~'7Qr|_*X|׀ڒ팜8ˑ 5t 5_o)SaXr-յ4\!Štm-]@7l$ge*$K쭬N&t1ނHjO+D2(WMp6`t zz/ 0)>i=ZI΃lh]͖^UkR +1 FIRrJuP{./GyE<ں 3qdt-%eP@')n6Wui!=s"+w(Iu=g,Xpv F5}<[qҡNQ@Re5Mr&ӭ,&8:g}f%k2龖w؍$[pfEϗ#F[R;?dH2Lwxf_G"naHמTt3,\KeʍٱھKHŇSqP 0) YtM)Ibg'kR HV U\\#&8دA7VZcXy|Zo&hX~',5Bɒ=GHO)Nٛ5ؿ؀" v5d\cqrDckDjK2^Kja{Q_aȯ̆)f@H#ha.`HùqYFwӻ?J ʼnǵ{YlvZ6P~>}(ΦI1|gu{QW.'pwtEq ϫO'㇇/X!k~bo>iI (0@'d޼IӑlkDKti\+U5G|X Z[΍gH7*;;n>MoƷb7jľ |q|Wk&\^'yoA8遻'r:g987u$_v ~3. ҞX3!d;\2fj{ԕKUL\p7"Q=::_  Y5AMJi1]1]Rk92G&mEh(S|O&O_›*/*wU^Ka#%a=@|5tЭBa"͆I#`h%jV^$.TZ4(MKSV0qpWg /*}x/mϋ~ll:RJ]YH @;YgC>C, Qg4@ AIL- 5?޼C$8DSUa(S%AiDZ"]q@2[?0(~ʞF_bhO|jZYTR%E~j-[n 7KWE򆖺J)2t։Sl7_Z0Ƽ,* PCҏk3ut >&Mxg?/W&Ek:{VH_ g*PBjL )@3-ҳ0uʋ<ËHtMZ`5rvd#XӚ6_k͠TQEAV^z9XG[Nsʀ@ Y4B0XD'rٗxQd2I ;隗9-oI{j-%wvܾ1.RU '=Uuvŋ~iVjKv`6P9Auf%A7H 艹3=v,^R#xzRKU$wF&7'}b}o>B~vz]5Pn2C̾_ulߜP =~~莒[v@Ӑn]zYh/k4NnNFz5 [_hD}T%݈Igz5IYmM${]V}**}+g10H+82Ny%1raHt}xufEfd]gӿZUk؉Z̯cadQR)X1]cI߲q DK45, R%#y$_zUؔn9sUv23,pjOF3v%+Jk+ 'Z۳k_zFE҆ $ 9WF7'[e7:-x>Ҹ{uXXԍd ̇JhZ#hBa0jw !i,*:fJ\V {{W$F9Y05:JmzeJCHv~/S0Fm!h?nk T)z\!LB%҄bEK0~uDO낝Q@ xnCM@*XG@Lڃ z38%Ov3s A64V3) NB=''94&z* a=0!dv(6u@ ? !Zie[%*`=%K8j9fPc+58 X =[FwO_~n`ǣՎEP@ 1OAdps2ͼ6TƪU)նUdKU/MTYKU&l $KVWr TZ%KVIaq#,c@,?÷GLߣ#4iUK <!CR'̾c-4ҭ4)L0 RTmxG#|yif[4IDUN)̀#l:#l[Cģ`kF_N84Lۑ@J 4SrRx .>Mnb/`!w?w9ٴ~yOOz+?D~hUa#n߬F5+jo6&lX'!f#An'CůTA4'Hնb4,JH?C@س/"QB Oz0RCc/Wfc耤 V9X0^MdRaV=Ú^* mIZPCDFC) 1viD 4ޤ˸~_guJCRY5 n) JJvkJ/yXp K7 VZᔐ tTmX'ɷ Fo&z8 )X MUP]{RUi6s-MEfOCg;Y>IJYl76:9@]bXLeOɑgjz4q Euz=vm6%~?}tWli%CsKrxkUwdP;M2LkȎk;ػSy,.ݎ@n甍VЈoz 9FToOq`\S&%K|̤_KL7wO㛧nd4uDnJn$Ԃ.#.zYS$p#C9dd:n29r{Rt^j8+5h W)c뇪y+OGȪڂ'@B%kUmUsU' ty.EaPh`%VY\=iSƑ0CzNn#ne7R^;]Ifz()&G0jc,3_F(7Vͼƪ{"8_U{N_7chn`8Tb#=VZfwg^EB,|+o~܏jK8s@ e}Ȓ{sC0zqkXz/3!Z$P0Ihlh: Ctx5h!j-[BRl* *-!+>%ve? ^džlbnq~|gʺŭ$Ϳ8F>ibɪcęwNq{KIO?Y24dpƒp,Z,KGܙ% ;BDI Pg{d5;jrRkV]yCjVu j/,_/}]t}_Kj旜w4 0BCO Pǧ5[˲X>Y̺X}Y~%U0PFiDɚr`Tz(uyvZdTղF -ڊhW*Z'ZxMub@ ԛ0 ~v4~_l?n>(_/i~Ag’V{ì[eaS7+ˑE M-SvAy+gi{n-lMiE:_WL p#@ br:ڏ+*='|pV ^ qx?sA)W3bˆXپYUų(1v`*6Se,UeVe,]e=Fe},Uelr3=H4,Fik Yh)mY-5',zDSKzS h\r.m߳rz gx]sϵchn$4":TXcB E,+ISWVTX:~o_I$T_K`Íij\yFZI)t!:gԈɒsr\݊=D¥cv_5&Yq/Ȗn=?Pl_<7rV plҢod׷&C^Z4LnKU]=B7*`*rCU7tyx':[Z( 7ViJ,I6?Oc ^ :0Jdl N;}ÇG}] \u[[dlKzhĀ%['i,g=Mʒ@MQ\]_1ȠX߮;kĕ6}N%5:yIvjϙÝ-<2eu!JxVZbB)YvcW+Ēz-yX۰; k ʹ{0d?B \.շ2"|Iu+UQ:lx7VKt_?M8yx_x 7ӟx|Vhq{1Of|G1ߍOw&O2N@w?N\rD(BlY,xXcC+GoZA& s:x{Ͽp'8a38F; &?!Sϕ9[g_D 5-K5D+E/WJzJ6`=eJB+~_4+B,hk ڏh L%ⲄZk0o/_PW*X) lr4I:N,a\z?lnpat-1.0.0/internal/cmsapi/hybrid_station.go000066400000000000000000000010311520322237600207320ustar00rootroot00000000000000package cmsapi import ( "context" "net/url" ) const PathHybridStationList = "/hybridStation/list" type HybridStation struct { Callsign string AutomaticForwarding bool ManualForwarding bool } func HybridStationList(ctx context.Context) ([]HybridStation, error) { var resp struct { HybridList []HybridStation ResponseStatus responseStatus } if err := getJSON(ctx, PathHybridStationList, url.Values{}, &resp); err != nil { return nil, err } return resp.HybridList, resp.ResponseStatus.errorOrNil() } pat-1.0.0/internal/cmsapi/mps.go000066400000000000000000000054221520322237600165170ustar00rootroot00000000000000package cmsapi import ( "context" "encoding/json" "net/url" "regexp" "strconv" "time" ) const ( PathMPSAdd = "/mps/add" PathMPSDelete = "/mps/delete" PathMPSGet = "/mps/get" PathMPSList = "/mps/list" ) // MessagePickupStationRecord represents an MPS record type MessagePickupStationRecord struct { Callsign string `json:"callsign"` MpsCallsign string `json:"mpsCallsign"` Timestamp DotNetTime `json:"timestamp"` } // DotNetTime handles .NET-style JSON date serialization type DotNetTime struct{ time.Time } // UnmarshalJSON implements custom JSON unmarshaling for .NET date format func (t *DotNetTime) UnmarshalJSON(b []byte) error { var str string if err := json.Unmarshal(b, &str); err != nil { return err } // Handle .NET date format: \/Date(milliseconds)\/ re := regexp.MustCompile(`\/Date\((-?\d+)\)\/`) matches := re.FindStringSubmatch(str) if len(matches) == 2 { millis, err := strconv.ParseInt(matches[1], 10, 64) if err != nil { return err } t.Time = time.Unix(millis/1000, (millis%1000)*1000000) return nil } // Fall back to RFC3339 format parsedTime, err := time.Parse(time.RFC3339, str) if err == nil { t.Time = parsedTime return nil } // Fall back to RFC1123 format parsedTime, err = time.Parse(time.RFC1123, str) if err == nil { t.Time = parsedTime return nil } return err } // MPSAdd adds an entry to the MPS table func MPSAdd(ctx context.Context, requester, callsign, password, mpsCallsign string) error { params := url.Values{ "requester": []string{requester}, "callsign": []string{callsign}, "password": []string{password}, "mpsCallsign": []string{mpsCallsign}, } var resp struct{ ResponseStatus responseStatus } if err := getJSON(ctx, PathMPSAdd, params, &resp); err != nil { return err } return resp.ResponseStatus.errorOrNil() } // MPSDelete deletes all MPS records for the specified callsign func MPSDelete(ctx context.Context, requester, callsign, password string) error { params := url.Values{ "requester": []string{requester}, "callsign": []string{callsign}, "password": []string{password}, } var resp struct{ ResponseStatus responseStatus } if err := getJSON(ctx, PathMPSDelete, params, &resp); err != nil { return err } return resp.ResponseStatus.errorOrNil() } // MPSGet returns all MPS records for the specified callsign func MPSGet(ctx context.Context, requester, callsign string) ([]MessagePickupStationRecord, error) { params := url.Values{ "requester": []string{requester}, "callsign": []string{callsign}, } var resp struct { MpsList []MessagePickupStationRecord `json:"mpsList"` ResponseStatus responseStatus } if err := getJSON(ctx, PathMPSGet, params, &resp); err != nil { return nil, err } return resp.MpsList, resp.ResponseStatus.errorOrNil() } pat-1.0.0/internal/cmsapi/password_recovery.go000066400000000000000000000023561520322237600215030ustar00rootroot00000000000000package cmsapi import ( "context" "net/url" "os" "strconv" ) const ( PathAccountPasswordRecoveryEmailGet = "/account/password/recovery/email/get" PathAccountPasswordRecoveryEmailSet = "/account/password/recovery/email/set" ) func PasswordRecoveryEmailGet(ctx context.Context, callsign, password string) (string, error) { if t, _ := strconv.ParseBool(os.Getenv("PAT_CMSAPI_MOCK_NO_RECOVERY_EMAIL")); t { return "", nil } params := url.Values{"callsign": []string{callsign}, "password": []string{password}} var resp struct { RecoveryEmail string ResponseStatus responseStatus } if err := getJSON(ctx, PathAccountPasswordRecoveryEmailGet, params, &resp); err != nil { return "", err } return resp.RecoveryEmail, resp.ResponseStatus.errorOrNil() } func PasswordRecoveryEmailSet(ctx context.Context, callsign, password, email string) error { params := url.Values{"callsign": []string{callsign}, "password": []string{password}} body := bodyJSON(struct{ RecoveryEmail string }{email}) req := newJSONRequest("POST", PathAccountPasswordRecoveryEmailSet, params, body). WithContext(ctx) var resp struct{ ResponseStatus responseStatus } if err := doJSON(req, &resp); err != nil { return err } return resp.ResponseStatus.errorOrNil() } pat-1.0.0/internal/debug/000077500000000000000000000000001520322237600152005ustar00rootroot00000000000000pat-1.0.0/internal/debug/debug.go000066400000000000000000000005241520322237600166160ustar00rootroot00000000000000package debug import ( "log" "os" "strconv" ) const ( EnvVar = "PAT_DEBUG" Prefix = "[DEBUG] " ) var enabled bool func init() { enabled, _ = strconv.ParseBool(os.Getenv(EnvVar)) } func Enabled() bool { return enabled } func Printf(format string, v ...interface{}) { if !enabled { return } log.Printf(Prefix+format, v...) } pat-1.0.0/internal/directories/000077500000000000000000000000001520322237600164265ustar00rootroot00000000000000pat-1.0.0/internal/directories/directories.go000066400000000000000000000102131520322237600212660ustar00rootroot00000000000000package directories import ( "errors" "log" "os" "path/filepath" "strings" "sync" "github.com/la5nta/pat/internal/buildinfo" "github.com/la5nta/pat/internal/debug" "github.com/adrg/xdg" ) var ( lock = &sync.Mutex{} dataPath string configPath string statePath string ) // IsInPath returns true if sub is a sub-path of parent. // // Both paths must be either absolute or relative. func IsInPath(parent, sub string) bool { parent, sub = filepath.Clean(parent), filepath.Clean(sub) if filepath.IsAbs(parent) != filepath.IsAbs(sub) { panic("mix of rel and abs paths") } root, err := os.OpenRoot(parent) if err != nil { return false } defer root.Close() // Make sub relative to parent relSub, err := filepath.Rel(parent, sub) if err != nil { return false } switch _, err = root.Stat(relSub); { case err == nil: return true case errors.Is(err, os.ErrNotExist): return true // Path is within root, just not present default: return false } } func DataDir() string { return getDir(&dataPath, xdg.DataHome, "DataDir") } func ConfigDir() string { return getDir(&configPath, xdg.ConfigHome, "ConfigDir") } func StateDir() string { return getDir(&statePath, xdg.StateHome, "StateDir") } func getDir(dir *string, basePath string, methodName string) string { lock.Lock() defer lock.Unlock() if *dir == "" { initDir(dir, basePath, methodName) } return *dir } func initDir(dir *string, basePath string, methodName string) { *dir = filepath.Join(basePath, strings.ToLower(buildinfo.AppName)) if _, err := os.Stat(*dir); os.IsNotExist(err) { err := os.MkdirAll(*dir, os.ModeDir|0o755) if err != nil { log.Fatalf("unable to create or open %s %s: %v", methodName, *dir, err) } } } func MigrateLegacyDataDir() { if f, err := os.Stat(ConfigDir()); err == nil && f.IsDir() { debug.Printf("new config directory %s already exists, we have already migrated", ConfigDir()) return } homeDir, err := os.UserHomeDir() if err != nil { log.Fatal(err) } legacyDataDir := filepath.Join(homeDir, ".wl2k") switch f, err := os.Stat(legacyDataDir); { case os.IsNotExist(err): debug.Printf("tried to migrate from %s but it doesn't exist; nothing to do", legacyDataDir) return case err != nil: log.Fatal(err) case !f.IsDir(): log.Printf("tried to migrate from %s but it's not a directory, that's weird; ignoring", legacyDataDir) return } log.Printf("Migrating your Pat files from %s to new locations", legacyDataDir) if err = migrateFile("config.json", legacyDataDir, ConfigDir()); err != nil { log.Fatal(err) } if err = migrateFile("mailbox", legacyDataDir, DataDir()); err != nil { log.Fatal(err) } if err = migrateFile("Standard_Forms", legacyDataDir, DataDir()); err != nil { log.Fatal(err) } matches, err := filepath.Glob(filepath.Join(legacyDataDir, "rmslist*.json")) if err != nil { log.Fatal(err) } for _, match := range matches { _, f := filepath.Split(match) if err = migrateFile(f, legacyDataDir, DataDir()); err != nil { log.Fatal(err) } } debug.Printf("migration from %s finished, renaming it", legacyDataDir) err = os.Rename(legacyDataDir, legacyDataDir+"-old") if err != nil { log.Fatal(err) } } func migrateFile(fileName string, fromDir string, toDir string) error { // make sure the old file is there fromFile := filepath.Join(fromDir, fileName) if _, err := os.Stat(fromFile); errors.Is(err, os.ErrNotExist) { // no legacy file, nothing to do debug.Printf("File %s doesn't exist, not migrating it", fromFile) return nil } else if err != nil { return err } // touch the new file to make sure it's not there, and we can write to it toFile := filepath.Join(toDir, fileName) switch f, err := os.OpenFile(toFile, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0o666); { case errors.Is(err, os.ErrExist): // new file already exists, don't clobber it debug.Printf("new file %s already exists; ignoring %s", toFile, fromFile) return nil case err != nil: return err default: if err := f.Close(); err != nil { return err } if err := os.Remove(toFile); err != nil { return err } } debug.Printf("Migrating %s from %s to %s", fileName, fromDir, toDir) return os.Rename(fromFile, toFile) } pat-1.0.0/internal/directories/directories_test.go000066400000000000000000000040661520322237600223360ustar00rootroot00000000000000package directories import ( "os" "path/filepath" "testing" ) func TestIsInPath(t *testing.T) { t.Run("absolute paths", func(t *testing.T) { runIsInPathCases(t, t.TempDir(), false) }) t.Run("relative paths", func(t *testing.T) { runIsInPathCases(t, t.TempDir(), true) }) t.Run("parent does not exist", func(t *testing.T) { parent := filepath.Join(t.TempDir(), "does_not_exist_parent") sub := filepath.Join(parent, "subdir") if IsInPath(parent, sub) { t.Errorf("should return false when parent does not exist") } }) t.Run("mix abs/rel (should panic)", func(t *testing.T) { dir := t.TempDir() rel := "subdir" defer func() { if r := recover(); r == nil { t.Errorf("expected panic when mixing abs/rel paths") } }() _ = IsInPath(dir, rel) }) } func runIsInPathCases(t *testing.T, base string, makeRelative bool) { sub := filepath.Join(base, "subdir") file := filepath.Join(sub, "file.txt") os.MkdirAll(sub, 0755) os.WriteFile(file, []byte("test"), 0644) otherDir := filepath.Join(base, "..", "otherdir") os.MkdirAll(otherDir, 0755) otherFile := filepath.Join(otherDir, "other.txt") os.WriteFile(otherFile, []byte("other"), 0644) parent := filepath.Dir(base) nonExistent := filepath.Join(sub, "does_not_exist.txt") if makeRelative { // Change working directory origCwd, _ := os.Getwd() defer os.Chdir(origCwd) cwd := filepath.Join(base, "..") os.Chdir(cwd) // Convert all paths to be relative to cwd rel := func(p string) string { rel, _ := filepath.Rel(cwd, p) return rel } sub = rel(sub) file = rel(file) otherFile = rel(otherFile) parent = rel(parent) nonExistent = rel(nonExistent) base = rel(base) } if !IsInPath(base, sub) { t.Errorf("subdir should be in base") } if !IsInPath(base, file) { t.Errorf("file should be in base") } if IsInPath(base, otherFile) { t.Errorf("file in otherdir should not be in base") } if IsInPath(base, parent) { t.Errorf("parent should not be in base") } if !IsInPath(base, nonExistent) { t.Errorf("non-existent file within base should return true") } } pat-1.0.0/internal/editor/000077500000000000000000000000001520322237600154005ustar00rootroot00000000000000pat-1.0.0/internal/editor/editor.go000066400000000000000000000027201520322237600172160ustar00rootroot00000000000000package editor import ( "fmt" "io" "os" "os/exec" "runtime" "strings" "github.com/la5nta/pat/internal/buildinfo" ) func Executable() string { if e := os.Getenv("EDITOR"); e != "" { return e } else if e := os.Getenv("VISUAL"); e != "" { return e } switch runtime.GOOS { case "windows": return "notepad" case "linux": if path, err := exec.LookPath("editor"); err == nil { return path } } return "vi" } func Open(path string) error { cmd := exec.Command(Executable(), path) cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr return cmd.Run() } func EditText(template string) (string, error) { f, err := os.CreateTemp("", strings.ToLower(buildinfo.AppName)+"_edit_*.txt") if err != nil { return template, fmt.Errorf("Unable to prepare temporary file for body: %w", err) } defer f.Close() defer os.Remove(f.Name()) f.Write([]byte(template)) f.Sync() // Windows fix: Avoid 'cannot access the file because it is being used by another process' error. // Close the file before opening the editor. f.Close() // Fire up the editor if err := Open(f.Name()); err != nil { return template, fmt.Errorf("Unable to start text editor: %w", err) } // Read back the edited file f, err = os.OpenFile(f.Name(), os.O_RDWR, 0o666) if err != nil { return template, fmt.Errorf("Unable to read temporary file from editor: %w", err) } defer f.Close() defer os.Remove(f.Name()) body, err := io.ReadAll(f) return string(body), err } pat-1.0.0/internal/forms/000077500000000000000000000000001520322237600152405ustar00rootroot00000000000000pat-1.0.0/internal/forms/builder.go000066400000000000000000000371251520322237600172250ustar00rootroot00000000000000package forms import ( "bufio" "bytes" "context" "encoding/xml" "fmt" "io" "log" "net/http" "net/textproto" "os" "path" "path/filepath" "sort" "strconv" "strings" "time" "github.com/la5nta/wl2k-go/fbb" "github.com/la5nta/pat/internal/debug" "github.com/la5nta/pat/internal/editor" ) // Message represents a concrete message compiled from a template type Message struct { To string `json:"msg_to"` Cc string `json:"msg_cc"` Subject string `json:"msg_subject"` Body string `json:"msg_body"` Attachments []*fbb.File `json:"-"` submitted time.Time } type messageBuilder struct { Interactive bool LineReader func() string InReplyToMsg *fbb.Message Template Template FormValues map[string]string PromptResponses map[string]string FormsMgr *Manager } // build returns message subject, body, and attachments for the given template and variable map func (b messageBuilder) build() (Message, error) { b.setDefaultFormValues() msg, err := b.scanAndBuild(b.Template.Path) if err != nil { return Message{}, err } msg.Attachments = b.buildAttachments() return msg, nil } // TODO: What are these "default form vars"? It looks to be a subset of the // official insertion tags, but there is no mention of these special vars in // the forms documentation. Consider doing insertion tag replacement with the // {var ...} pattern instead of this. func (b messageBuilder) setDefaultFormValues() { if b.InReplyToMsg != nil { b.FormValues["msgisreply"] = "True" // Here be dragons. // Templates using this has a strange `Def: MsgOrignalBody=`. // Maybe to force the inclusion of the original body in the XML? But // why is it referenced as a variable and not the officially supported // tag (i.e. `Def: MsgOriginalBody=`)? if _, ok := b.FormValues["msgoriginalbody"]; !ok { b.FormValues["msgoriginalbody"], _ = b.InReplyToMsg.Body() } } else { b.FormValues["msgisreply"] = "False" } for _, key := range []string{"msgsender"} { if _, ok := b.FormValues[key]; !ok { b.FormValues[key] = b.FormsMgr.config.MyCall } } // some defaults that we can't set yet. Winlink doesn't seem to care about these // Set only if they're not set by form values. for _, key := range []string{"msgto", "msgcc", "msgsubject", "msgbody", "msgp2p", "txtstr"} { if _, ok := b.FormValues[key]; !ok { b.FormValues[key] = "" } } for _, key := range []string{"msgisforward", "msgisacknowledgement"} { if _, ok := b.FormValues[key]; !ok { b.FormValues[key] = "False" } } // TODO: Implement sequences for _, key := range []string{"msgseqnum"} { if _, ok := b.FormValues[key]; !ok { b.FormValues[key] = "0" } } } func (b messageBuilder) buildXML() []byte { type Variable struct { XMLName xml.Name Value string `xml:",chardata"` } filename := func(path string) string { // Avoid "." for empty paths if path == "" { return "" } return filepath.Base(path) } form := struct { XMLName xml.Name `xml:"RMS_Express_Form"` XMLFileVersion string `xml:"form_parameters>xml_file_version"` RMSExpressVersion string `xml:"form_parameters>rms_express_version"` SubmissionDatetime string `xml:"form_parameters>submission_datetime"` SendersCallsign string `xml:"form_parameters>senders_callsign"` GridSquare string `xml:"form_parameters>grid_square"` DisplayForm string `xml:"form_parameters>display_form"` ReplyTemplate string `xml:"form_parameters>reply_template"` Variables []Variable `xml:"variables>name"` }{ XMLFileVersion: "1.0", RMSExpressVersion: b.FormsMgr.config.AppVersion, SubmissionDatetime: now().UTC().Format("20060102150405"), SendersCallsign: b.FormsMgr.config.MyCall, GridSquare: b.FormsMgr.config.LocatorProvider.Locator(), DisplayForm: filename(b.Template.DisplayFormPath), ReplyTemplate: filename(b.Template.ReplyTemplatePath), } for k, v := range b.FormValues { // Trim leading and trailing whitespace. Winlink Express does // this, judging from the produced XML attachments. v = strings.TrimSpace(v) form.Variables = append(form.Variables, Variable{xml.Name{Local: k}, v}) } // Sort vars by name to make sure the output is deterministic. sort.Slice(form.Variables, func(i, j int) bool { a, b := form.Variables[i], form.Variables[j] return a.XMLName.Local < b.XMLName.Local }) data, err := xml.MarshalIndent(form, "", " ") if err != nil { panic(err) } return append([]byte(xml.Header), data...) } func (b messageBuilder) buildAttachments() []*fbb.File { var attachments []*fbb.File // Add optional text attachments defined by some forms as form values // pairs in the format attached_textN/attached_fileN (N=0 is omitted). for k := range b.FormValues { if !strings.HasPrefix(k, "attached_text") { continue } if strings.TrimSpace(b.FormValues[k]) == "" { // Some forms set this key as empty, meaning no real attachment. debug.Printf("Ignoring empty text attachment %q: %q", k, b.FormValues[k]) continue } textKey := k text := b.FormValues[textKey] nameKey := strings.Replace(k, "attached_text", "attached_file", 1) name := strings.TrimSpace(b.FormValues[nameKey]) if name == "" { debug.Printf("%s defined, but corresponding filename element %q is not set", textKey, nameKey) name = "FormData.txt" // Fallback (better than nothing) } attachments = append(attachments, fbb.NewFile(name, []byte(text))) delete(b.FormValues, nameKey) delete(b.FormValues, textKey) } // Add XML if a viewer is defined for this template if b.Template.DisplayFormPath != "" { filename := xmlName(b.Template) attachments = append(attachments, fbb.NewFile(filename, b.buildXML())) } return attachments } // scanAndBuild scans the template at the given path, applies placeholder substition and builds the message. // // If b,Interactive is true, the user is prompted for undefined placeholders via stdio. func (b messageBuilder) scanAndBuild(path string) (Message, error) { f, err := os.Open(path) if err != nil { return Message{}, err } defer f.Close() replaceInsertionTags := insertionTagReplacer(b.FormsMgr, b.InReplyToMsg, path, "<", ">") refreshInsertionTags := func() { replaceInsertionTags = insertionTagReplacer(b.FormsMgr, b.InReplyToMsg, path, "<", ">") } replaceVars := variableReplacer("<", ">", b.FormValues) addFormValue := func(k, v string) { b.FormValues[strings.ToLower(k)] = v replaceVars = variableReplacer("<", ">", b.FormValues) // Refresh variableReplacer (rebuild regular expressions) debug.Printf("Defined %q=%q", k, v) } scanner := bufio.NewScanner(newTrimBomReader(f)) msg := Message{submitted: now()} var inBody bool for scanner.Scan() { lineTmpl := scanner.Text() // Insertion tags and variables lineTmpl = replaceInsertionTags(lineTmpl) lineTmpl = replaceVars(lineTmpl) // Prompt responses already provided (from text template editor in frontend) for search, replace := range b.PromptResponses { lineTmpl = strings.Replace(lineTmpl, search, replace, 1) } // Prompts (mostly found in text templates) if b.Interactive { lineTmpl = promptAsks(lineTmpl, func(a Ask) string { var ans string if a.Multiline { fmt.Println(a.Prompt + " (Press ENTER to start external editor)") b.LineReader() var err error ans, err = editor.EditText("") if err != nil { log.Fatalf("Failed to start text editor: %v", err) } } else { fmt.Print(a.Prompt + " ") ans = b.LineReader() } if a.Uppercase { ans = strings.ToUpper(ans) } return ans }) lineTmpl = promptSelects(lineTmpl, func(s Select) Option { for { fmt.Println(s.Prompt) for i, opt := range s.Options { fmt.Printf(" %d\t%s\n", i, opt.Item) } fmt.Printf("select 0-%d: ", len(s.Options)-1) idx, err := strconv.Atoi(b.LineReader()) if err == nil && idx < len(s.Options) { return s.Options[idx] } } }) // Fallback prompt for undefined form variables. // Typically these are defined by the associated HTML form, but since // this is CLI land we'll just prompt for the variable value. lineTmpl = promptVars(lineTmpl, func(key string) string { fmt.Println(lineTmpl) fmt.Printf("%s: ", key) value := b.LineReader() addFormValue(key, value) return value }) } if inBody { msg.Body += lineTmpl + "\n" continue // No control fields in body } // Control fields switch key, value, _ := strings.Cut(lineTmpl, ":"); textproto.CanonicalMIMEHeaderKey(key) { case "Msg": // The message body starts here. No more control fields after this. msg.Body += value inBody = true case "Form", "Replytemplate": // Handled elsewhere continue case "Def", "Define": // Def: variable=value – Define the value of a variable. key, value, ok := strings.Cut(value, "=") if !ok { debug.Printf("Def: without key-value pair: %q", value) continue } key, value = strings.TrimSpace(key), strings.TrimSpace(value) addFormValue(key, value) case "Subject", "Subj": // Set the subject of the message msg.Subject = strings.TrimSpace(value) case "To": // Specify to whom the message is being sent msg.To = strings.TrimSpace(value) case "Cc": // Specify carbon copy addresses msg.Cc = strings.TrimSpace(value) case "Readonly": // Yes/No – Specify whether user can edit. // TODO: Disable editing of body in composer? case "Seqinc": value = strings.TrimSpace(value) if value == "" { value = "1" } incr, err := strconv.ParseInt(value, 10, 64) if err != nil { log.Printf("WARNING: failed to parse Seqinc value (%q): %v", value, err) } if _, err := b.FormsMgr.sequence.Incr(incr); err != nil { return Message{}, err } refreshInsertionTags() case "Seqset": value = strings.TrimSpace(value) num, err := strconv.ParseInt(value, 10, 64) if err != nil { log.Printf("WARNING: failed to parse Seqset value (%q): %v", value, err) } if _, err := b.FormsMgr.sequence.Set(num); err != nil { return Message{}, err } refreshInsertionTags() default: if strings.TrimSpace(lineTmpl) != "" { log.Printf("skipping unknown template line: '%q'", lineTmpl) } } } if b.InReplyToMsg != nil { var buf bytes.Buffer io.Copy(&buf, strings.NewReader(msg.Body)) writeMessageCitation(&buf, b.InReplyToMsg) msg.Body = buf.String() } return msg, nil } func writeMessageCitation(w io.Writer, inReplyToMsg *fbb.Message) { fmt.Fprintf(w, "--- %s %s wrote: ---\n", inReplyToMsg.Date(), inReplyToMsg.From().Addr) body, _ := inReplyToMsg.Body() scanner := bufio.NewScanner(strings.NewReader(body)) for scanner.Scan() { fmt.Fprintf(w, ">%s\n", scanner.Text()) } } // VariableReplacer returns a function that replaces the given key-value pairs. func variableReplacer(tagStart, tagEnd string, vars map[string]string) func(string) string { return placeholderReplacer(tagStart+"Var ", tagEnd, vars) } // InsertionTagReplacer returns a function that replaces the fixed set of insertion tags with their corresponding values. func insertionTagReplacer(m *Manager, inReplyToMsg *fbb.Message, templatePath string, tagStart, tagEnd string) func(string) string { now := now() validPos := "NO" nowPos, err := m.gpsPos() if err != nil { debug.Printf("GPSd error: %v", err) } else { validPos = "YES" debug.Printf("GPSd position: %s", positionFmt(signedDecimal, nowPos)) } internetAvailable := "NO" if isInternetAvailable() { internetAvailable = "YES" } seqNum, err := m.sequence.Load() if err != nil { debug.Printf("Error loading sequence number: %v", err) } // This list is based on RMSE_FORMS/insertion_tags.zip (copy in docs/) as well as searching Standard Forms's templates. tags := map[string]string{ "MsgSender": m.config.MyCall, "Callsign": m.config.MyCall, "ProgramVersion": m.config.AppVersion, "DateTime": formatDateTime(now), "UDateTime": formatDateTimeUTC(now), "Date": formatDate(now), "UDate": formatDateUTC(now), "UDTG": formatUDTG(now), "Time": formatTime(now), "UTime": formatTimeUTC(now), "Day": formatDay(now, location), "UDay": formatDay(now, time.UTC), "GPS": positionFmt(degreeMinute, nowPos), "GPSValid": validPos, "GPS_DECIMAL": positionFmt(decimal, nowPos), "GPS_SIGNED_DECIMAL": positionFmt(signedDecimal, nowPos), "GridSquare": positionFmt(gridSquare, nowPos), "Latitude": fmt.Sprintf("%.4f", nowPos.Lat), "Longitude": fmt.Sprintf("%.4f", nowPos.Lon), // No docs found for these, but they are referenced by a couple of templates in Standard Forms. // By reading the embedded javascript, they appear to be signed decimal. "GPSLatitude": fmt.Sprintf("%.4f", nowPos.Lat), "GPSLongitude": fmt.Sprintf("%.4f", nowPos.Lon), "InternetAvailable": internetAvailable, "MsgIsReply": strings.Title(strconv.FormatBool(inReplyToMsg != nil)), "MsgIsForward": "False", "MsgIsAcknowledgement": "False", "SeqNum": fmt.Sprintf(m.config.SequenceFormat, seqNum), "FormFolder": path.Join("/api/forms/", filepath.Dir(m.rel(templatePath))), // TODO (other insertion tags found in Standard Forms): // MsgTo // MsgCc // MsgSubject // MsgP2P // Sender (only in 'ARC Forms/Disaster Receipt 6409-B Reply.0') // Speed (only in 'GENERAL Forms/GPS Position Report.txt' - but not included in produced message body) // course (only in 'GENERAL Forms/GPS Position Report.txt' - but not included in produced message body) // decimal_separator } if inReplyToMsg != nil { tags["MsgOriginalSubject"] = inReplyToMsg.Subject() tags["MsgOriginalSender"] = inReplyToMsg.From().Addr tags["MsgOriginalBody"], _ = inReplyToMsg.Body() tags["MsgOriginalID"] = inReplyToMsg.MID() tags["MsgOriginalDate"] = formatDateTime(inReplyToMsg.Date()) // The documentation is not clear on these. Examples does not match the description. tags["MsgOriginalUtcDate"] = formatDateUTC(inReplyToMsg.Date()) tags["MsgOriginalUtcTime"] = formatTimeUTC(inReplyToMsg.Date()) tags["MsgOriginalLocalDate"] = formatDate(inReplyToMsg.Date()) tags["MsgOriginalLocalTime"] = formatTime(inReplyToMsg.Date()) tags["MsgOriginalDTG"] = formatUDTG(inReplyToMsg.Date()) // Assuming UTC (as per example). tags["MsgOriginalSize"] = fmt.Sprint(inReplyToMsg.BodySize()) // Assuming body size. tags["MsgOriginalAttachmentCount"] = fmt.Sprint(len(inReplyToMsg.Files())) for _, f := range inReplyToMsg.Files() { if strings.HasPrefix(f.Name(), "RMS_Express_Form_") && strings.HasSuffix(f.Name(), ".xml") { tags["MsgOriginalXML"] = string(f.Data()) } } } return placeholderReplacer(tagStart, tagEnd, tags) } // xmlName returns the user-visible filename for the message attachment that holds the form instance values func xmlName(t Template) string { attachmentName := filepath.Base(t.DisplayFormPath) attachmentName = strings.TrimSuffix(attachmentName, filepath.Ext(attachmentName)) attachmentName = "RMS_Express_Form_" + attachmentName + ".xml" if len(attachmentName) > 255 { attachmentName = strings.TrimPrefix(attachmentName, "RMS_Express_Form_") } return attachmentName } func isInternetAvailable() bool { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() req, _ := http.NewRequestWithContext(ctx, "HEAD", "https://www.google.com", nil) resp, err := http.DefaultClient.Do(req) debug.Printf("Internet available: %v (%v)", err == nil, err) if err != nil { return false } // Be nice, read the response body and close it. io.Copy(io.Discard, resp.Body) resp.Body.Close() return true } pat-1.0.0/internal/forms/builder_test.go000066400000000000000000000073001520322237600202540ustar00rootroot00000000000000package forms import ( "bufio" "bytes" "testing" "time" "github.com/la5nta/pat/cfg" ) // mockLocatorProvider implements LocatorProvider for testing type mockLocatorProvider string func (m mockLocatorProvider) Locator() string { return string(m) } func TestInsertionTagReplacer(t *testing.T) { m := &Manager{config: Config{ MyCall: "LA5NTA", AppVersion: "Pat v1.0.0 (test)", GPSd: cfg.GPSdConfig{Addr: gpsMockAddr}, LocatorProvider: mockLocatorProvider("JO29PJ"), }} location = time.FixedZone("UTC+1", 1*60*60) now = func() time.Time { return time.Date(1988, 3, 21, 0, 0, 0, 0, location).In(time.UTC) } tests := map[string]string{ "": "Pat v1.0.0 (test)", "": "LA5NTA", "": "LA5NTA", "": "1988-03-21 00:00:00", "": "1988-03-20 23:00:00Z", "": "1988-03-21", "": "1988-03-20Z", "": "202300Z MAR 1988", "