diff --git a/main.go b/main.go index a8edb40..a9522d5 100644 --- a/main.go +++ b/main.go @@ -18,451 +18,363 @@ along with this program. If not, see . Entwickelt mit Unterstützung von Claude.ai, einem KI-Assistenten von Anthropic, PBC. */ -package main import ( - "bytes" - "crypto/md5" "crypto/tls" - "encoding/xml" - "encoding/csv" "fmt" - "io" + "html/template" "log" "net/http" "os" + "path/filepath" "strings" - "sync" "time" + "unicode" "github.com/go-ldap/ldap/v3" - "golang.org/x/text/encoding/charmap" - "golang.org/x/text/transform" + "gopkg.in/yaml.v2" ) -type Config struct { - LDAPServer string - BindDN string - BindPassword string - SearchBase string - ServerPort string -} - -type AddressBook struct { - XMLName xml.Name `xml:"AddressBook"` - Version string `xml:"version,attr"` - PBGroups []PBGroup `xml:"pbgroup"` - Contacts []Contact `xml:"Contact"` -} - -type PBGroup struct { - ID int `xml:"id,attr"` - Name string `xml:"name"` -} - -type Contact struct { - FirstName string `xml:"FirstName"` - LastName string `xml:"LastName"` - Frequent int `xml:"Frequent"` - Phones []Phone `xml:"Phone"` - Department string `xml:"Department,omitempty"` - Group int `xml:"Group"` +type Person struct { + FirstName string + LastName string + Email string + Department string + Phones []Phone + RawPhoneNumber string + InternalPhone string + IsContact bool // Neues Feld zur Unterscheidung zwischen Benutzern und Kontakten } type Phone struct { - Type string `xml:"type,attr"` - PhoneNumber string `xml:"phonenumber"` - AccountIndex int `xml:"accountindex"` + PhoneNumber string + Type string } -type YealinkPhoneBook struct { - XMLName xml.Name `xml:"YealinkIPPhoneDirectory"` - Entries []YealinkEntry `xml:"DirectoryEntry"` +type Config struct { + PhoneRules struct { + Country struct { + Prefix string `yaml:"prefix"` + AreaCodes []string `yaml:"area_codes"` + InternalPrefix string `yaml:"internal_prefix"` + } `yaml:"country"` + InvalidNumber string `yaml:"invalid_number"` + } `yaml:"phone_rules"` } -type YealinkEntry struct { - Name string `xml:"Name"` - Telephone string `xml:"Telephone"` - Mobile string `xml:"Mobile,omitempty"` - OtherMobile string `xml:"Other,omitempty"` -} - -// Neue Struktur für das Contact-Format -type ContactXML struct { - XMLName xml.Name `xml:"contacts"` - Contacts []ContactEntry `xml:"contact"` -} - -type ContactEntry struct { - Name string `xml:"name,attr"` - Number string `xml:"number,attr"` - FirstName string `xml:"firstname,attr"` - LastName string `xml:"lastname,attr"` - Phone string `xml:"phone,attr"` - Mobile string `xml:"mobile,attr"` - Email string `xml:"email,attr"` - Address string `xml:"address,attr"` - City string `xml:"city,attr"` - State string `xml:"state,attr"` - Zip string `xml:"zip,attr"` - Comment string `xml:"comment,attr"` - ID string `xml:"id,attr"` - Info string `xml:"info,attr"` - Presence string `xml:"presence,attr"` - Starred string `xml:"starred,attr"` - Directory string `xml:"directory,attr"` +type LDAPConfig struct { + Server string + Port string + BindDN string + BindPassword string + BaseDN string + Filter string } var ( - err error - config Config - cache struct { - grandstreamFile *os.File - yealinkFile *os.File - contactFile *os.File - hash []byte - lastUpdate time.Time - mutex sync.RWMutex - } + cachedData []Person + lastUpdate time.Time + tmpl *template.Template + ldapConfig LDAPConfig + serverPort string + config Config ) +func init() { + loadEnvConfig() + loadYAMLConfig() + loadTemplate() +} + +func loadEnvConfig() { + requiredEnvVars := []string{ + "LDAP_SERVER", "LDAP_PORT", "LDAP_BIND_DN", "LDAP_BIND_PASSWORD", + "LDAP_BASE_DN", "LDAP_FILTER", "SERVER_PORT", + } + + for _, envVar := range requiredEnvVars { + if value := os.Getenv(envVar); value == "" { + log.Fatalf("Required environment variable %s is not set", envVar) + } + } + + ldapConfig = LDAPConfig{ + Server: os.Getenv("LDAP_SERVER"), + Port: os.Getenv("LDAP_PORT"), + BindDN: os.Getenv("LDAP_BIND_DN"), + BindPassword: os.Getenv("LDAP_BIND_PASSWORD"), + BaseDN: os.Getenv("LDAP_BASE_DN"), + Filter: os.Getenv("LDAP_FILTER"), + } + + serverPort = os.Getenv("SERVER_PORT") +} + +func loadYAMLConfig() { + configPath := filepath.Join("static", "config.yaml") + configFile, err := os.ReadFile(configPath) + if err != nil { + log.Fatalf("Failed to read config file: %v", err) + } + + err = yaml.Unmarshal(configFile, &config) + if err != nil { + log.Fatalf("Failed to parse config file: %v", err) + } + + validateConfig() +} + +func validateConfig() { + if config.PhoneRules.Country.Prefix == "" || + len(config.PhoneRules.Country.AreaCodes) == 0 || + config.PhoneRules.Country.InternalPrefix == "" || + config.PhoneRules.InvalidNumber == "" { + log.Fatalf("Missing required configuration in config.yaml") + } +} + +func loadTemplate() { + var err error + tmpl, err = template.ParseFiles(filepath.Join("static", "index.html")) + if err != nil { + log.Fatalf("Failed to parse template: %v", err) + } +} + func main() { - config = Config{ - LDAPServer: os.Getenv("LDAP_SERVER"), - BindDN: os.Getenv("BIND_DN"), - BindPassword: os.Getenv("BIND_PASSWORD"), - SearchBase: os.Getenv("SEARCH_BASE"), - ServerPort: os.Getenv("SERVER_PORT"), - } + go cacheRefresher() - if config.ServerPort == "" { - config.ServerPort = "8080" - } + http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static")))) + http.HandleFunc("/", handler) - // Initialisiere den Cache vor dem Start des Servers - err = initializeCache() - if err != nil { - log.Fatalf("Fehler beim Initialisieren des Caches: %v", err) - } - - http.HandleFunc("/phonebook.xml", grandstreamPhoneBookHandler) - http.HandleFunc("/yphonebook.xml", yealinkPhoneBookHandler) - http.HandleFunc("/Contacts.xml", contactXMLHandler) - http.HandleFunc("/phonebook.csv", csvExportHandler) - - log.Printf("Server starting on port %s", config.ServerPort) - log.Fatal(http.ListenAndServe(":"+config.ServerPort, nil)) + log.Printf("Server starting on port %s", serverPort) + log.Fatal(http.ListenAndServe(":"+serverPort, nil)) } -func grandstreamPhoneBookHandler(w http.ResponseWriter, r *http.Request) { - servePhoneBook(w, r, cache.grandstreamFile) -} - -func yealinkPhoneBookHandler(w http.ResponseWriter, r *http.Request) { - servePhoneBook(w, r, cache.yealinkFile) -} - -func contactXMLHandler(w http.ResponseWriter, r *http.Request) { - servePhoneBook(w, r, cache.contactFile) -} - -func csvExportHandler(w http.ResponseWriter, r *http.Request) { - err := updateCachedXMLIfNeeded() - if err != nil { - http.Error(w, "Fehler beim Aktualisieren der Kontakte", http.StatusInternalServerError) - return - } - - contacts, err := getADContacts() - if err != nil { - http.Error(w, "Fehler beim Abrufen der Kontakte", http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "text/csv; charset=windows-1252") - w.Header().Set("Content-Disposition", "attachment; filename=phonebook.csv") - - if err := exportToCSV(contacts, w); err != nil { - http.Error(w, "Fehler beim Exportieren als CSV", http.StatusInternalServerError) - return +func cacheRefresher() { + for { + newData, err := fetchDataFromLDAP() + if err != nil { + log.Printf("Error refreshing cache: %v", err) + } else if !dataEqual(cachedData, newData) { + cachedData = newData + lastUpdate = time.Now() + log.Println("Cache updated") + } + time.Sleep(5 * time.Minute) } } -func servePhoneBook(w http.ResponseWriter, r *http.Request, file *os.File) { - err := updateCachedXMLIfNeeded() - if err != nil { - http.Error(w, "Fehler beim Aktualisieren der Kontakte", http.StatusInternalServerError) - return +func dataEqual(a, b []Person) bool { + if len(a) != len(b) { + return false } - - cache.mutex.RLock() - defer cache.mutex.RUnlock() - - w.Header().Set("Content-Type", "application/xml") - w.WriteHeader(http.StatusOK) - - _, err = file.Seek(0, 0) - if err != nil { - log.Printf("Fehler beim Zurücksetzen des Datei-Offsets: %v", err) - http.Error(w, "Interner Serverfehler", http.StatusInternalServerError) - return - } - - _, err = io.Copy(w, file) - if err != nil { - log.Printf("Fehler beim Kopieren der XML-Datei: %v", err) + for i := range a { + if !personEqual(a[i], b[i]) { + return false + } } + return true } -func initializeCache() error { - log.Println("Initialisiere Cache...") - - // Führen Sie updateCachedXMLIfNeeded aus, um die Caching-Dateien zu erstellen - err := updateCachedXMLIfNeeded() - if err != nil { - return fmt.Errorf("Fehler beim Initialisieren des Caches: %v", err) - } - - // Überprüfen Sie, ob alle erforderlichen Dateien erstellt wurden - if cache.grandstreamFile == nil || cache.yealinkFile == nil || cache.contactFile == nil { - return fmt.Errorf("Nicht alle erforderlichen Cache-Dateien wurden erstellt") - } - - log.Println("Cache erfolgreich initialisiert") - return nil +func personEqual(a, b Person) bool { + return a.FirstName == b.FirstName && + a.LastName == b.LastName && + a.Email == b.Email && + a.InternalPhone == b.InternalPhone && + a.Department == b.Department && + phonesEqual(a.Phones, b.Phones) } -func updateCachedXMLIfNeeded() error { - cache.mutex.Lock() - defer cache.mutex.Unlock() - - contacts, err := getADContacts() - if err != nil { - return err +func phonesEqual(a, b []Phone) bool { + if len(a) != len(b) { + return false } - - newHash := calculateHash(contacts) - if cache.grandstreamFile != nil && cache.yealinkFile != nil && cache.contactFile != nil && bytes.Equal(newHash, cache.hash) { - return nil + for i := range a { + if a[i] != b[i] { + return false + } } + return true +} - addressBook := AddressBook{ - Version: "1.0", - PBGroups: []PBGroup{ - {ID: 1, Name: "Blacklist"}, - {ID: 2, Name: "Work"}, - {ID: 3, Name: "Friend"}, - }, - Contacts: contacts, - } +func handler(w http.ResponseWriter, r *http.Request) { + log.Printf("Received request for: %s", r.URL.Path) - yealinkPhoneBook := YealinkPhoneBook{ - Entries: make([]YealinkEntry, len(contacts)), - } - - for i, c := range contacts { - yealinkPhoneBook.Entries[i] = YealinkEntry{ - Name: c.FirstName + " " + c.LastName, - Telephone: getPhoneByType(c.Phones, "Work"), - Mobile: getPhoneByType(c.Phones, "Mobile"), - OtherMobile: getPhoneByType(c.Phones, "Home"), + if time.Since(lastUpdate) > 5*time.Minute { + newData, err := fetchDataFromLDAP() + if err != nil { + log.Printf("Error refreshing cache: %v", err) + } else { + cachedData = newData + lastUpdate = time.Now() } } - // Erstellen der Contacts.xml - contactXML := ContactXML{ - Contacts: make([]ContactEntry, len(contacts)), - } - - for i, c := range contacts { - contactXML.Contacts[i] = ContactEntry{ - Name: c.FirstName + " " + c.LastName, - Number: getPhoneByType(c.Phones, "Work"), - FirstName: c.FirstName, - LastName: c.LastName, - Phone: getPhoneByType(c.Phones, "Work"), - Mobile: getPhoneByType(c.Phones, "Mobile"), - Presence: "0", - Starred: "0", - Directory: "0", - } - } - - if err := saveToTempFile(&addressBook, &cache.grandstreamFile); err != nil { - return fmt.Errorf("Fehler beim Speichern der Grandstream-XML: %v", err) - } - - if err := saveToTempFile(&yealinkPhoneBook, &cache.yealinkFile); err != nil { - return fmt.Errorf("Fehler beim Speichern der Yealink-XML: %v", err) - } - - if err := saveToTempFile(&contactXML, &cache.contactFile); err != nil { - return fmt.Errorf("Fehler beim Speichern der Contact-XML: %v", err) - } - - csvFile, err := os.OpenFile("phonebook.csv", os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644) + err := tmpl.Execute(w, cachedData) if err != nil { - return fmt.Errorf("Fehler beim Erstellen der CSV-Datei: %v", err) + http.Error(w, err.Error(), http.StatusInternalServerError) } - defer csvFile.Close() - - if err := exportToCSV(contacts, csvFile); err != nil { - return fmt.Errorf("Fehler beim Exportieren als CSV: %v", err) - } - - cache.hash = newHash - cache.lastUpdate = time.Now() - - return nil } -func saveToTempFile(data interface{}, file **os.File) error { - tempFile, err := os.CreateTemp("", "phonebook_*.xml") +func fetchDataFromLDAP() ([]Person, error) { + l, err := ldap.DialTLS("tcp", ldapConfig.Server+":"+ldapConfig.Port, &tls.Config{InsecureSkipVerify: true}) if err != nil { - return fmt.Errorf("Fehler beim Erstellen der temporären Datei: %v", err) - } - - enc := xml.NewEncoder(tempFile) - enc.Indent("", " ") - if err := enc.Encode(data); err != nil { - tempFile.Close() - os.Remove(tempFile.Name()) - return fmt.Errorf("Fehler beim Kodieren der XML: %v", err) - } - - if *file != nil { - (*file).Close() - os.Remove((*file).Name()) - } - - *file = tempFile - return nil -} - -func calculateHash(contacts []Contact) []byte { - h := md5.New() - for _, contact := range contacts { - io.WriteString(h, contact.FirstName) - io.WriteString(h, contact.LastName) - for _, phone := range contact.Phones { - io.WriteString(h, phone.PhoneNumber) - } - } - return h.Sum(nil) -} - -func getADContacts() ([]Contact, error) { - l, err := ldap.DialTLS("tcp", config.LDAPServer+":636", &tls.Config{InsecureSkipVerify: true}) - if err != nil { - return nil, fmt.Errorf("LDAP-Verbindung fehlgeschlagen: %v", err) + return nil, fmt.Errorf("failed to connect to LDAP server: %v", err) } defer l.Close() - err = l.Bind(config.BindDN, config.BindPassword) + err = l.Bind(ldapConfig.BindDN, ldapConfig.BindPassword) if err != nil { - return nil, fmt.Errorf("LDAP-Bindung fehlgeschlagen: %v", err) + return nil, fmt.Errorf("failed to bind to LDAP server: %v", err) } - // Erweiterter LDAP-Filter für Benutzer und Kontakte - searchFilter := "(&(|(objectClass=user)(objectClass=contact))(|(telephoneNumber=*)(otherTelephone=*)(mobile=*)(homePhone=*))(!(userAccountControl:1.2.840.113556.1.4.803:=2)))" + // Erweitere den Filter, um sowohl Benutzer als auch Kontakte einzuschließen + combinedFilter := fmt.Sprintf("(|(objectClass=user)(objectClass=contact)%s)", ldapConfig.Filter) searchRequest := ldap.NewSearchRequest( - config.SearchBase, + ldapConfig.BaseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, - searchFilter, - []string{"givenName", "sn", "displayName", "telephoneNumber", "otherTelephone", "mobile", "homePhone"}, + combinedFilter, + []string{"objectClass", "givenName", "sn", "mail", "telephoneNumber", "mobile", "otherTelephone", "physicalDeliveryOfficeName"}, nil, ) sr, err := l.Search(searchRequest) if err != nil { - return nil, fmt.Errorf("LDAP-Suche fehlgeschlagen: %v", err) + return nil, fmt.Errorf("failed to search LDAP server: %v", err) } - var contacts []Contact + var people []Person for _, entry := range sr.Entries { - firstName := entry.GetAttributeValue("givenName") - lastName := entry.GetAttributeValue("sn") - - if firstName == "" || lastName == "" { + if !isValidEntry(entry) { continue } - phone := formatPhoneNumber(entry.GetAttributeValue("telephoneNumber")) - otherMobile := formatPhoneNumber(entry.GetAttributeValue("otherMobile")) - mobile := formatPhoneNumber(entry.GetAttributeValue("mobile")) + isContact := isContactObject(entry) - if isExcludedNumber(phone) || isExcludedNumber(otherMobile) || isExcludedNumber(mobile) { - continue + person := Person{ + FirstName: entry.GetAttributeValue("givenName"), + LastName: entry.GetAttributeValue("sn"), + Email: entry.GetAttributeValue("mail"), + Department: entry.GetAttributeValue("physicalDeliveryOfficeName"), + IsContact: isContact, } - if phone != "" || otherMobile != "" || mobile != "" { - contact := Contact{ - FirstName: firstName, - LastName: lastName, - Phones: []Phone{}, - Group: 2, // Standardmäßig zur "Work"-Gruppe hinzufügen + officePhone := entry.GetAttributeValue("telephoneNumber") + mobilePhone := entry.GetAttributeValue("mobile") + + // Normalisiere die Telefonnummern für den Vergleich + normalizedOffice := normalizePhoneNumber(officePhone) + normalizedMobile := normalizePhoneNumber(mobilePhone) + + if normalizedOffice == normalizedMobile && mobilePhone != "" { + formattedMobile := formatPhoneNumber(mobilePhone) + person.Phones = append(person.Phones, Phone{PhoneNumber: formattedMobile, Type: "Mobil"}) + person.RawPhoneNumber = formattedMobile + person.InternalPhone = "" // Keine interne Rufnummer für Mobiltelefone + } else if officePhone != "" { + formattedPhone := formatPhoneNumber(officePhone) + person.Phones = append(person.Phones, Phone{PhoneNumber: formattedPhone, Type: "Office"}) + person.InternalPhone = extractInternalNumber(officePhone) + person.RawPhoneNumber = formattedPhone + + if mobilePhone != "" && normalizedOffice != normalizedMobile { + formattedMobile := formatPhoneNumber(mobilePhone) + person.Phones = append(person.Phones, Phone{PhoneNumber: formattedMobile, Type: "Mobil"}) } - if phone != "" { - contact.Phones = append(contact.Phones, Phone{Type: "Work", PhoneNumber: phone, AccountIndex: 0}) - } - if mobile != "" { - contact.Phones = append(contact.Phones, Phone{Type: "Mobile", PhoneNumber: mobile, AccountIndex: 0}) - } - if otherMobile != "" { - contact.Phones = append(contact.Phones, Phone{Type: "Home", PhoneNumber: otherMobile, AccountIndex: 0}) - } - contacts = append(contacts, contact) + } else if mobilePhone != "" { + formattedMobile := formatPhoneNumber(mobilePhone) + person.Phones = append(person.Phones, Phone{PhoneNumber: formattedMobile, Type: "Mobil"}) + person.RawPhoneNumber = formattedMobile + person.InternalPhone = "" // Keine interne Rufnummer für Mobiltelefone + } + + for _, otherPhone := range entry.GetAttributeValues("otherTelephone") { + person.Phones = append(person.Phones, Phone{PhoneNumber: formatPhoneNumber(otherPhone), Type: "Other"}) + } + + if (len(person.Phones) > 0 && person.Email != "") || isContact { + people = append(people, person) } } - return contacts, nil + return people, nil } -func removeDuplicateNumbers(phones []Phone) []Phone { - seen := make(map[string]bool) - result := []Phone{} +func isValidEntry(entry *ldap.Entry) bool { + firstName := entry.GetAttributeValue("givenName") + lastName := entry.GetAttributeValue("sn") + telephoneNumber := entry.GetAttributeValue("telephoneNumber") + mobile := entry.GetAttributeValue("mobile") - for _, phone := range phones { - if !seen[phone.PhoneNumber] { - seen[phone.PhoneNumber] = true - result = append(result, phone) - } else if phone.Type == "Work" { - // Wenn die Nummer bereits gesehen wurde und dies die "Work" Nummer ist, - // ersetzen wir die vorherige (wahrscheinlich "Mobile") mit dieser. - for i, p := range result { - if p.PhoneNumber == phone.PhoneNumber { - result[i] = phone - break - } - } - } - } + // Für Kontakte erlauben wir auch Einträge ohne Telefonnummer + isContact := isContactObject(entry) - return result + return (firstName != "" && lastName != "") && + (isContact || telephoneNumber != config.PhoneRules.InvalidNumber || mobile != "") } -func isExcludedNumber(number string) bool { - excludedNumbers := []string{ - "+49 5331 89", - "+49533189", - "+ 49 5331 89", - } - - // Zuerst prüfen wir auf die ausgeschlossenen Nummern - for _, excluded := range excludedNumbers { - if strings.Contains(number, excluded) { +func isContactObject(entry *ldap.Entry) bool { + objectClasses := entry.GetAttributeValues("objectClass") + for _, class := range objectClasses { + if strings.ToLower(class) == "contact" { return true } } - - // Dann prüfen wir, ob die Nummer mit "089" endet - if len(number) >= 3 && number[len(number)-3:] == "089" { - return true - } - - // Wenn keine der obigen Bedingungen zutrifft, ist die Nummer nicht ausgeschlossen return false } -func \ No newline at end of file +func isValidContact(entry *ldap.Entry) bool { + firstName := entry.GetAttributeValue("givenName") + lastName := entry.GetAttributeValue("sn") + telephoneNumber := entry.GetAttributeValue("telephoneNumber") + + return firstName != "" && lastName != "" && telephoneNumber != config.PhoneRules.InvalidNumber +} + +func formatPhoneNumber(number string) string { + // Entferne alle Nicht-Ziffern + digits := strings.Map(func(r rune) rune { + if unicode.IsDigit(r) { + return r + } + return -1 + }, number) + + // Entferne führende Nullen + digits = strings.TrimLeft(digits, "0") + + // Entferne den Länderprefix, falls vorhanden + countryPrefix := config.PhoneRules.Country.Prefix + if strings.HasPrefix(digits, countryPrefix) { + digits = digits[len(countryPrefix):] + } + + // Füge eine führende "0" hinzu, wenn nicht vorhanden + if !strings.HasPrefix(digits, "0") { + digits = "0" + digits + } + + return digits +} + +func extractInternalNumber(phoneNumber string) string { + digits := formatPhoneNumber(phoneNumber) + + for _, areaCode := range config.PhoneRules.Country.AreaCodes { + if strings.HasPrefix(digits[1:], areaCode) { // Ignoriere die führende 0 + remaining := digits[1+len(areaCode):] + if strings.HasPrefix(remaining, config.PhoneRules.Country.InternalPrefix) { + return remaining[len(config.PhoneRules.Country.InternalPrefix):] + } + } + } + + return "" +} + +func normalizePhoneNumber(number string) string { + return formatPhoneNumber(number) +}