// backend\address_search.go package main import ( "encoding/json" "net/http" "net/url" "os" "strconv" "strings" "time" ) type AddressSuggestion struct { ID string `json:"id"` Label string `json:"label"` Lat float64 `json:"lat"` Lon float64 `json:"lon"` Type string `json:"type"` HasHouseNumber bool `json:"hasHouseNumber"` GeoJSON json.RawMessage `json:"geojson,omitempty"` } type photonResponse struct { Features []photonFeature `json:"features"` } type photonFeature struct { Properties photonProperties `json:"properties"` Geometry photonGeometry `json:"geometry"` } type photonProperties struct { OSMID int64 `json:"osm_id"` OSMType string `json:"osm_type"` OSMValue string `json:"osm_value"` Name string `json:"name"` Street string `json:"street"` HouseNumber string `json:"housenumber"` Postcode string `json:"postcode"` City string `json:"city"` District string `json:"district"` State string `json:"state"` Country string `json:"country"` CountryCode string `json:"countrycode"` } type photonGeometry struct { Coordinates []float64 `json:"coordinates"` } type nominatimSearchResult struct { PlaceID int64 `json:"place_id"` DisplayName string `json:"display_name"` Lat string `json:"lat"` Lon string `json:"lon"` Type string `json:"type"` Address nominatimReverseAddress `json:"address"` GeoJSON json.RawMessage `json:"geojson,omitempty"` } func (s *Server) handleAddressSearch(w http.ResponseWriter, r *http.Request) { query := strings.TrimSpace(r.URL.Query().Get("q")) if len([]rune(query)) < 3 { writeJSON(w, http.StatusOK, map[string]any{ "suggestions": []AddressSuggestion{}, }) return } searchURL := strings.TrimSpace(os.Getenv("ADDRESS_SEARCH_URL")) if searchURL == "" { writeError(w, http.StatusNotImplemented, "Adresssuche ist nicht konfiguriert") return } provider := strings.ToLower(strings.TrimSpace(os.Getenv("ADDRESS_SEARCH_PROVIDER"))) if provider == "" { provider = detectAddressSearchProvider(searchURL) } switch provider { case "photon": s.handlePhotonAddressSearch(w, r, searchURL, query) case "nominatim": s.handleNominatimAddressSearch(w, r, searchURL, query) default: writeError(w, http.StatusInternalServerError, "Adresssuche-Provider ist unbekannt") } } func (s *Server) handlePhotonAddressSearch( w http.ResponseWriter, r *http.Request, searchURL string, query string, ) { parsedURL, err := url.Parse(searchURL) if err != nil { writeError(w, http.StatusInternalServerError, "Adresssuche ist falsch konfiguriert") return } limit := getAddressSearchLimit() language := envOrDefault("ADDRESS_SEARCH_LANGUAGE", "de") countryCode := strings.ToLower(strings.TrimSpace(envOrDefault("ADDRESS_SEARCH_COUNTRY_CODE", "de"))) values := parsedURL.Query() values.Set("q", query) values.Set("limit", strconv.Itoa(limit)) if language != "" { values.Set("lang", language) } parsedURL.RawQuery = values.Encode() request, err := http.NewRequestWithContext(r.Context(), http.MethodGet, parsedURL.String(), nil) if err != nil { writeError(w, http.StatusInternalServerError, "Adresssuche konnte nicht vorbereitet werden") return } request.Header.Set("Accept", "application/json") request.Header.Set("User-Agent", addressSearchUserAgent()) response, err := httpClient().Do(request) if err != nil { writeError(w, http.StatusBadGateway, "Adresssuche konnte nicht geladen werden") return } defer response.Body.Close() if response.StatusCode < 200 || response.StatusCode >= 300 { writeError(w, http.StatusBadGateway, "Adresssuche konnte nicht geladen werden") return } var result photonResponse if err := json.NewDecoder(response.Body).Decode(&result); err != nil { writeError(w, http.StatusBadGateway, "Adresssuche konnte nicht gelesen werden") return } suggestions := make([]AddressSuggestion, 0, len(result.Features)) for _, feature := range result.Features { if len(feature.Geometry.Coordinates) < 2 { continue } if countryCode != "" && feature.Properties.CountryCode != "" && strings.ToLower(feature.Properties.CountryCode) != countryCode { continue } lon := feature.Geometry.Coordinates[0] lat := feature.Geometry.Coordinates[1] label := buildPhotonLabel(feature.Properties) if label == "" { continue } suggestions = append(suggestions, AddressSuggestion{ ID: buildPhotonID(feature.Properties, label), Label: label, Lat: lat, Lon: lon, Type: buildPhotonType(feature.Properties), HasHouseNumber: strings.TrimSpace(feature.Properties.HouseNumber) != "", }) } writeJSON(w, http.StatusOK, map[string]any{ "suggestions": suggestions, }) } func (s *Server) handleNominatimAddressSearch( w http.ResponseWriter, r *http.Request, searchURL string, query string, ) { parsedURL, err := url.Parse(searchURL) if err != nil { writeError(w, http.StatusInternalServerError, "Adresssuche ist falsch konfiguriert") return } limit := getAddressSearchLimit() countryCode := strings.ToLower(strings.TrimSpace(envOrDefault("ADDRESS_SEARCH_COUNTRY_CODE", "de"))) values := parsedURL.Query() values.Set("q", query) values.Set("format", "jsonv2") values.Set("addressdetails", "1") values.Set("polygon_geojson", "1") values.Set("limit", strconv.Itoa(limit)) if countryCode != "" && values.Get("countrycodes") == "" { values.Set("countrycodes", countryCode) } parsedURL.RawQuery = values.Encode() request, err := http.NewRequestWithContext(r.Context(), http.MethodGet, parsedURL.String(), nil) if err != nil { writeError(w, http.StatusInternalServerError, "Adresssuche konnte nicht vorbereitet werden") return } request.Header.Set("Accept", "application/json") request.Header.Set("User-Agent", addressSearchUserAgent()) response, err := httpClient().Do(request) if err != nil { writeError(w, http.StatusBadGateway, "Adresssuche konnte nicht geladen werden") return } defer response.Body.Close() if response.StatusCode < 200 || response.StatusCode >= 300 { writeError(w, http.StatusBadGateway, "Adresssuche konnte nicht geladen werden") return } var results []nominatimSearchResult if err := json.NewDecoder(response.Body).Decode(&results); err != nil { writeError(w, http.StatusBadGateway, "Adresssuche konnte nicht gelesen werden") return } suggestions := make([]AddressSuggestion, 0, len(results)) for _, result := range results { label := formatReverseAddressLabel(nominatimReverseResponse{ DisplayName: result.DisplayName, Address: result.Address, }) if label == "" { continue } lat, latErr := strconv.ParseFloat(result.Lat, 64) lon, lonErr := strconv.ParseFloat(result.Lon, 64) if latErr != nil || lonErr != nil { continue } suggestions = append(suggestions, AddressSuggestion{ ID: strconv.FormatInt(result.PlaceID, 10), Label: label, Lat: lat, Lon: lon, Type: result.Type, HasHouseNumber: strings.TrimSpace(result.Address.HouseNumber) != "", GeoJSON: result.GeoJSON, }) } writeJSON(w, http.StatusOK, map[string]any{ "suggestions": suggestions, }) } func buildPhotonLabel(properties photonProperties) string { parts := []string{} streetLine := strings.TrimSpace( strings.Join( []string{ strings.TrimSpace(properties.Street), strings.TrimSpace(properties.HouseNumber), }, " ", ), ) if streetLine != "" { parts = append(parts, streetLine) } else if strings.TrimSpace(properties.Name) != "" { parts = append(parts, strings.TrimSpace(properties.Name)) } cityLine := strings.TrimSpace( strings.Join( []string{ strings.TrimSpace(properties.Postcode), firstNonEmpty(properties.City, properties.District), }, " ", ), ) if cityLine != "" { parts = append(parts, cityLine) } if strings.TrimSpace(properties.State) != "" { parts = append(parts, strings.TrimSpace(properties.State)) } if strings.TrimSpace(properties.Country) != "" { parts = append(parts, strings.TrimSpace(properties.Country)) } return strings.Join(uniqueNonEmpty(parts), ", ") } func buildPhotonID(properties photonProperties, fallback string) string { if properties.OSMID != 0 { if properties.OSMType != "" { return properties.OSMType + ":" + strconv.FormatInt(properties.OSMID, 10) } return strconv.FormatInt(properties.OSMID, 10) } return fallback } func buildPhotonType(properties photonProperties) string { if strings.TrimSpace(properties.OSMValue) != "" { return strings.TrimSpace(properties.OSMValue) } if strings.TrimSpace(properties.OSMType) != "" { return strings.TrimSpace(properties.OSMType) } return "Adresse" } func detectAddressSearchProvider(searchURL string) string { lowerURL := strings.ToLower(searchURL) if strings.Contains(lowerURL, "photon") { return "photon" } if strings.Contains(lowerURL, "nominatim") { return "nominatim" } return "photon" } func getAddressSearchLimit() int { rawLimit := strings.TrimSpace(os.Getenv("ADDRESS_SEARCH_LIMIT")) if rawLimit == "" { return 5 } limit, err := strconv.Atoi(rawLimit) if err != nil { return 5 } if limit < 1 { return 1 } if limit > 10 { return 10 } return limit } func addressSearchUserAgent() string { return envOrDefault("ADDRESS_SEARCH_USER_AGENT", "TEG-App/1.0") } func httpClient() *http.Client { return &http.Client{ Timeout: 8 * time.Second, } } func firstNonEmpty(values ...string) string { for _, value := range values { value = strings.TrimSpace(value) if value != "" { return value } } return "" } func uniqueNonEmpty(values []string) []string { seen := map[string]bool{} result := []string{} for _, value := range values { value = strings.TrimSpace(value) if value == "" { continue } key := strings.ToLower(value) if seen[key] { continue } seen[key] = true result = append(result, value) } return result } func envOrDefault(key string, fallback string) string { value := strings.TrimSpace(os.Getenv(key)) if value == "" { return fallback } return value }