+ {getItemDescription(item)} +
+ )}From b3c07355a8bc453a3a3b0ba1ab62e84b82cf5b16 Mon Sep 17 00:00:00 2001 From: Linrador <68631622+Linrador@users.noreply.github.com> Date: Sat, 13 Jun 2026 10:15:57 +0200 Subject: [PATCH] updated --- backend/.env | 2 +- backend/address_reverse.go | 264 +- backend/address_search.go | 37 +- backend/admin.go | 30 +- backend/channels.go | 612 ++++ backend/chat.go | 1366 +++++++++ backend/chat_attachments.go | 160 + backend/chat_link_preview.go | 361 +++ backend/chat_preferences.go | 65 + backend/chat_search.go | 109 + backend/chat_unread.go | 121 + backend/chat_websocket.go | 464 +++ backend/feedback.go | 21 + backend/go.mod | 3 +- backend/main.go | 1 + backend/notification_preferences.go | 20 +- backend/notifications.go | 28 +- backend/operations.go | 231 +- backend/presence.go | 309 ++ backend/routes.go | 23 + backend/server.go | 90 +- backend/setup/main.go | 174 +- backend/user_events.go | 4 + backend/user_sessions.go | 25 +- backend/user_settings.go | 14 +- frontend/src/App.tsx | 272 +- frontend/src/components/ActivityLogView.tsx | 118 + frontend/src/components/AddressCombobox.tsx | 200 +- frontend/src/components/AppLayout.tsx | 8 +- frontend/src/components/Avatar.tsx | 47 +- frontend/src/components/Feedback.tsx | 249 +- frontend/src/components/Journal.tsx | 409 +-- frontend/src/components/Notifications.tsx | 89 +- frontend/src/components/SearchOverlay.tsx | 91 +- frontend/src/components/Sidebar.tsx | 63 +- frontend/src/components/Topbar.tsx | 76 +- frontend/src/components/types.ts | 16 +- frontend/src/main.tsx | 10 +- .../administration/AdministrationPage.tsx | 13 +- .../channels/ChannelsAdministration.tsx | 666 +++++ .../feedback/FeedbackAdministration.tsx | 300 +- .../users/UsersAdministration.tsx | 14 +- frontend/src/pages/chat/ChatPage.tsx | 2636 +++++++++++++++++ .../src/pages/devices/DeviceFormFields.tsx | 25 +- .../src/pages/devices/DeviceModalTabs.tsx | 12 +- frontend/src/pages/devices/DevicesPage.tsx | 33 +- .../pages/operations/CreateOperationModal.tsx | 411 +-- .../operations/OperationGeometryEditor.tsx | 858 ++++++ .../src/pages/operations/OperationMap.tsx | 184 +- .../src/pages/operations/OperationModal.tsx | 141 +- .../src/pages/operations/OperationsPage.tsx | 18 +- frontend/src/pages/settings/SettingsPage.tsx | 21 +- .../benachrichtigungen/Benachrichtigungen.tsx | 111 +- frontend/src/pages/settings/konto/Konto.tsx | 64 +- .../settings/presence/PresenceSettings.tsx | 151 + frontend/src/utils/activityLog.ts | 256 ++ frontend/src/utils/operationGeometry.ts | 147 + 57 files changed, 11109 insertions(+), 1134 deletions(-) create mode 100644 backend/channels.go create mode 100644 backend/chat.go create mode 100644 backend/chat_attachments.go create mode 100644 backend/chat_link_preview.go create mode 100644 backend/chat_preferences.go create mode 100644 backend/chat_search.go create mode 100644 backend/chat_unread.go create mode 100644 backend/chat_websocket.go create mode 100644 backend/presence.go create mode 100644 frontend/src/components/ActivityLogView.tsx create mode 100644 frontend/src/pages/administration/channels/ChannelsAdministration.tsx create mode 100644 frontend/src/pages/chat/ChatPage.tsx create mode 100644 frontend/src/pages/operations/OperationGeometryEditor.tsx create mode 100644 frontend/src/pages/settings/presence/PresenceSettings.tsx create mode 100644 frontend/src/utils/activityLog.ts create mode 100644 frontend/src/utils/operationGeometry.ts diff --git a/backend/.env b/backend/.env index 8fca91b..7f27cac 100644 --- a/backend/.env +++ b/backend/.env @@ -1,7 +1,7 @@ PORT=8080 DATABASE_URL=postgres://postgres:Timmy0104199%3F@localhost:5432/teg?sslmode=disable JWT_SECRET=tegvideo7010! -FRONTEND_ORIGIN=http://localhost:5173 +FRONTEND_ORIGIN=http://10.0.1.25:5173 ADDRESS_SEARCH_PROVIDER=photon ADDRESS_SEARCH_URL=https://photon.komoot.io/api ADDRESS_SEARCH_LANGUAGE=de diff --git a/backend/address_reverse.go b/backend/address_reverse.go index 4190767..cd76011 100644 --- a/backend/address_reverse.go +++ b/backend/address_reverse.go @@ -3,8 +3,10 @@ package main import ( + "context" "encoding/json" "fmt" + "math" "net/http" "net/url" "strconv" @@ -13,12 +15,13 @@ import ( ) type reverseAddressSuggestion struct { - ID string `json:"id"` - Label string `json:"label"` - Lat float64 `json:"lat"` - Lon float64 `json:"lon"` - Type string `json:"type,omitempty"` - GeoJSON json.RawMessage `json:"geojson,omitempty"` + ID string `json:"id"` + Label string `json:"label"` + Lat float64 `json:"lat"` + Lon float64 `json:"lon"` + Type string `json:"type,omitempty"` + HasHouseNumber bool `json:"hasHouseNumber"` + GeoJSON json.RawMessage `json:"geojson,omitempty"` } type nominatimReverseAddress struct { @@ -112,6 +115,215 @@ func formatReverseAddressLabel(result nominatimReverseResponse) string { return strings.TrimSpace(result.DisplayName) } +func isPolygonGeoJSON(value json.RawMessage) bool { + if len(value) == 0 || string(value) == "null" { + return false + } + + var object struct { + Type string `json:"type"` + Geometry json.RawMessage `json:"geometry"` + Features []json.RawMessage `json:"features"` + Geometries []json.RawMessage `json:"geometries"` + } + + if err := json.Unmarshal(value, &object); err != nil { + return false + } + + switch object.Type { + case "Polygon", "MultiPolygon": + return true + case "Feature": + return isPolygonGeoJSON(object.Geometry) + case "FeatureCollection": + for _, feature := range object.Features { + if isPolygonGeoJSON(feature) { + return true + } + } + case "GeometryCollection": + for _, geometry := range object.Geometries { + if isPolygonGeoJSON(geometry) { + return true + } + } + } + + return false +} + +func coordinateDistanceMeters(firstLat, firstLon, secondLat, secondLon float64) float64 { + const earthRadius = 6371000 + + firstLatitude := firstLat * math.Pi / 180 + secondLatitude := secondLat * math.Pi / 180 + latitudeDifference := (secondLat - firstLat) * math.Pi / 180 + longitudeDifference := (secondLon - firstLon) * math.Pi / 180 + + a := math.Sin(latitudeDifference/2)*math.Sin(latitudeDifference/2) + + math.Cos(firstLatitude)*math.Cos(secondLatitude)* + math.Sin(longitudeDifference/2)*math.Sin(longitudeDifference/2) + + return earthRadius * 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a)) +} + +type addressPolygonLookupResult struct { + GeoJSON json.RawMessage + HasHouseNumber bool +} + +func lookupAddressPolygon( + ctx context.Context, + query string, + latitude float64, + longitude float64, +) addressPolygonLookupResult { + query = strings.TrimSpace(query) + if query == "" { + return addressPolygonLookupResult{} + } + + params := url.Values{} + params.Set("format", "jsonv2") + params.Set("addressdetails", "1") + params.Set("polygon_geojson", "1") + params.Set("limit", "10") + params.Set("accept-language", "de") + params.Set("q", query) + + searchURL := fmt.Sprintf( + "https://nominatim.openstreetmap.org/search?%s", + params.Encode(), + ) + + request, err := http.NewRequestWithContext(ctx, http.MethodGet, searchURL, nil) + if err != nil { + return addressPolygonLookupResult{} + } + + request.Header.Set("Accept", "application/json") + request.Header.Set("User-Agent", addressSearchUserAgent()) + + response, err := httpClient().Do(request) + if err != nil { + return addressPolygonLookupResult{} + } + defer response.Body.Close() + + if response.StatusCode < 200 || response.StatusCode >= 300 { + return addressPolygonLookupResult{} + } + + var results []nominatimSearchResult + if err := json.NewDecoder(response.Body).Decode(&results); err != nil { + return addressPolygonLookupResult{} + } + + var bestResult addressPolygonLookupResult + bestDistance := math.Inf(1) + + for _, result := range results { + if !isPolygonGeoJSON(result.GeoJSON) { + continue + } + + resultLatitude, latitudeErr := strconv.ParseFloat(result.Lat, 64) + resultLongitude, longitudeErr := strconv.ParseFloat(result.Lon, 64) + if latitudeErr != nil || longitudeErr != nil { + continue + } + + distance := coordinateDistanceMeters( + latitude, + longitude, + resultLatitude, + resultLongitude, + ) + if distance > 250 || distance >= bestDistance { + continue + } + + bestDistance = distance + bestResult = addressPolygonLookupResult{ + GeoJSON: result.GeoJSON, + HasHouseNumber: strings.TrimSpace(result.Address.HouseNumber) != "", + } + } + + return bestResult +} + +func normalizeOSMIdentifier(value string) string { + value = strings.ToUpper(strings.TrimSpace(value)) + value = strings.ReplaceAll(value, ":", "") + + if len(value) < 2 { + return "" + } + + if value[0] != 'N' && value[0] != 'W' && value[0] != 'R' { + return "" + } + + if _, err := strconv.ParseInt(value[1:], 10, 64); err != nil { + return "" + } + + return value +} + +func lookupOSMPolygon(ctx context.Context, osmIdentifier string) addressPolygonLookupResult { + osmIdentifier = normalizeOSMIdentifier(osmIdentifier) + if osmIdentifier == "" { + return addressPolygonLookupResult{} + } + + params := url.Values{} + params.Set("format", "jsonv2") + params.Set("polygon_geojson", "1") + params.Set("osm_ids", osmIdentifier) + + lookupURL := fmt.Sprintf( + "https://nominatim.openstreetmap.org/lookup?%s", + params.Encode(), + ) + + request, err := http.NewRequestWithContext(ctx, http.MethodGet, lookupURL, nil) + if err != nil { + return addressPolygonLookupResult{} + } + + request.Header.Set("Accept", "application/json") + request.Header.Set("User-Agent", addressSearchUserAgent()) + + response, err := httpClient().Do(request) + if err != nil { + return addressPolygonLookupResult{} + } + defer response.Body.Close() + + if response.StatusCode < 200 || response.StatusCode >= 300 { + return addressPolygonLookupResult{} + } + + var results []nominatimSearchResult + if err := json.NewDecoder(response.Body).Decode(&results); err != nil { + return addressPolygonLookupResult{} + } + + for _, result := range results { + if isPolygonGeoJSON(result.GeoJSON) { + return addressPolygonLookupResult{ + GeoJSON: result.GeoJSON, + HasHouseNumber: strings.TrimSpace(result.Address.HouseNumber) != "", + } + } + } + + return addressPolygonLookupResult{} +} + func (s *Server) handleAddressReverse(w http.ResponseWriter, r *http.Request) { latRaw := strings.TrimSpace(r.URL.Query().Get("lat")) lonRaw := strings.TrimSpace(r.URL.Query().Get("lon")) @@ -179,14 +391,42 @@ func (s *Server) handleAddressReverse(w http.ResponseWriter, r *http.Request) { return } + geoJSON := result.GeoJSON + hasHouseNumber := strings.TrimSpace(result.Address.HouseNumber) != "" + if !isPolygonGeoJSON(geoJSON) { + lookupResult := lookupOSMPolygon( + r.Context(), + r.URL.Query().Get("osm_id"), + ) + geoJSON = lookupResult.GeoJSON + hasHouseNumber = hasHouseNumber || lookupResult.HasHouseNumber + } + + if !isPolygonGeoJSON(geoJSON) { + searchQuery := strings.TrimSpace(r.URL.Query().Get("q")) + if searchQuery == "" { + searchQuery = label + } + + lookupResult := lookupAddressPolygon( + r.Context(), + searchQuery, + lat, + lon, + ) + geoJSON = lookupResult.GeoJSON + hasHouseNumber = hasHouseNumber || lookupResult.HasHouseNumber + } + writeJSON(w, http.StatusOK, map[string]any{ "suggestion": reverseAddressSuggestion{ - ID: fmt.Sprintf("reverse-%d-%d", result.PlaceID, time.Now().UnixNano()), - Label: label, - Lat: lat, - Lon: lon, - Type: result.Type, - GeoJSON: result.GeoJSON, + ID: fmt.Sprintf("reverse-%d-%d", result.PlaceID, time.Now().UnixNano()), + Label: label, + Lat: lat, + Lon: lon, + Type: result.Type, + HasHouseNumber: hasHouseNumber, + GeoJSON: geoJSON, }, }) } diff --git a/backend/address_search.go b/backend/address_search.go index e4a60b8..ee992e5 100644 --- a/backend/address_search.go +++ b/backend/address_search.go @@ -13,11 +13,13 @@ import ( ) type AddressSuggestion struct { - ID string `json:"id"` - Label string `json:"label"` - Lat float64 `json:"lat"` - Lon float64 `json:"lon"` - Type string `json:"type"` + 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 { @@ -55,6 +57,7 @@ type nominatimSearchResult struct { 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) { @@ -164,11 +167,12 @@ func (s *Server) handlePhotonAddressSearch( } suggestions = append(suggestions, AddressSuggestion{ - ID: buildPhotonID(feature.Properties, label), - Label: label, - Lat: lat, - Lon: lon, - Type: buildPhotonType(feature.Properties), + ID: buildPhotonID(feature.Properties, label), + Label: label, + Lat: lat, + Lon: lon, + Type: buildPhotonType(feature.Properties), + HasHouseNumber: strings.TrimSpace(feature.Properties.HouseNumber) != "", }) } @@ -196,6 +200,7 @@ func (s *Server) handleNominatimAddressSearch( 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") == "" { @@ -252,11 +257,13 @@ func (s *Server) handleNominatimAddressSearch( } suggestions = append(suggestions, AddressSuggestion{ - ID: strconv.FormatInt(result.PlaceID, 10), - Label: label, - Lat: lat, - Lon: lon, - Type: result.Type, + ID: strconv.FormatInt(result.PlaceID, 10), + Label: label, + Lat: lat, + Lon: lon, + Type: result.Type, + HasHouseNumber: strings.TrimSpace(result.Address.HouseNumber) != "", + GeoJSON: result.GeoJSON, }) } diff --git a/backend/admin.go b/backend/admin.go index 24010d0..028c3ef 100644 --- a/backend/admin.go +++ b/backend/admin.go @@ -24,17 +24,20 @@ type AdminTeam struct { } type AdminUser struct { - ID string `json:"id"` - Username string `json:"username"` - DisplayName string `json:"displayName"` - Email string `json:"email"` - Avatar string `json:"avatar"` - Unit string `json:"unit"` - Group string `json:"group"` - Rights []string `json:"rights"` - Teams []AdminTeam `json:"teams"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` + ID string `json:"id"` + Username string `json:"username"` + DisplayName string `json:"displayName"` + Email string `json:"email"` + Avatar string `json:"avatar"` + Unit string `json:"unit"` + Group string `json:"group"` + Rights []string `json:"rights"` + Teams []AdminTeam `json:"teams"` + Online bool `json:"onlineStatus"` + PresenceStatus string `json:"presenceStatus"` + LastSeenAt *time.Time `json:"lastSeenAt"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` } type AdminUserRequest struct { @@ -120,6 +123,8 @@ func (s *Server) handleAdminListUsers(w http.ResponseWriter, r *http.Request) { COALESCE(unit, ''), COALESCE(user_group, ''), rights, + `+presenceStatusSQL+`, + last_seen_at, created_at, updated_at FROM users @@ -148,6 +153,8 @@ func (s *Server) handleAdminListUsers(w http.ResponseWriter, r *http.Request) { &user.Unit, &user.Group, &user.Rights, + &user.PresenceStatus, + &user.LastSeenAt, &user.CreatedAt, &user.UpdatedAt, ); err != nil { @@ -156,6 +163,7 @@ func (s *Server) handleAdminListUsers(w http.ResponseWriter, r *http.Request) { } user.Teams = []AdminTeam{} + user.Online = user.PresenceStatus == "online" users = append(users, user) userIndexByID[user.ID] = len(users) - 1 diff --git a/backend/channels.go b/backend/channels.go new file mode 100644 index 0000000..33659fd --- /dev/null +++ b/backend/channels.go @@ -0,0 +1,612 @@ +package main + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "encoding/hex" + "errors" + "net/http" + "regexp" + "strings" + "time" + + "github.com/jackc/pgx/v5" +) + +const zabbixWebhookScript = `var params = JSON.parse(value), + request = new HttpRequest(), + payload = { + subject: params.Subject, + message: params.Message, + host: params.Host, + severity: params.Severity, + status: params.Status, + eventId: params.EventId, + eventUrl: params.EventUrl + }; + +request.addHeader('Content-Type: application/json'); +request.addHeader('Authorization: Bearer ' + params.Token); + +var response = request.post(params.URL, JSON.stringify(payload)), + status = request.getStatus(); + +if (status < 200 || status >= 300) { + throw 'Webhook failed with HTTP status ' + status + ': ' + response; +} + +return response;` + +var channelSlugPattern = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`) + +type AdminChannel struct { + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + SourceName string `json:"sourceName"` + Image string `json:"image"` + AllUsers bool `json:"allUsers"` + UserIDs []string `json:"userIds"` + WebhookEnabled bool `json:"webhookEnabled"` + TokenConfigured bool `json:"tokenConfigured"` + WebhookTokenUpdatedAt *time.Time `json:"webhookTokenUpdatedAt"` + WebhookPath string `json:"webhookPath"` +} + +type AdminChannelUser struct { + ID string `json:"id"` + Username string `json:"username"` + DisplayName string `json:"displayName"` + Email string `json:"email"` + Unit string `json:"unit"` +} + +type saveAdminChannelRequest struct { + Name string `json:"name"` + Slug string `json:"slug"` + SourceName string `json:"sourceName"` + Image string `json:"image"` + AllUsers bool `json:"allUsers"` + UserIDs []string `json:"userIds"` + WebhookEnabled bool `json:"webhookEnabled"` +} + +type channelWebhookPayload struct { + Subject string `json:"subject"` + Message string `json:"message"` + Host string `json:"host"` + Severity string `json:"severity"` + Status string `json:"status"` + EventID string `json:"eventId"` + EventURL string `json:"eventUrl"` +} + +func (s *Server) ensureAllUserChannels(ctx context.Context, userID string) error { + _, err := s.db.Exec( + ctx, + ` + INSERT INTO conversation_members (conversation_id, user_id) + SELECT c.id, $1 + FROM conversations c + WHERE c.type = 'channel' + AND c.channel_all_users + ON CONFLICT (conversation_id, user_id) DO NOTHING + `, + userID, + ) + return err +} + +func normalizeChannelSlug(value string) string { + return strings.ToLower(strings.TrimSpace(value)) +} + +func validateAdminChannelInput(input saveAdminChannelRequest) (saveAdminChannelRequest, error) { + input.Name = strings.TrimSpace(input.Name) + input.Slug = normalizeChannelSlug(input.Slug) + input.SourceName = strings.TrimSpace(input.SourceName) + input.Image = strings.TrimSpace(input.Image) + userIDs := input.UserIDs + uniqueUserIDs := make(map[string]struct{}, len(userIDs)) + input.UserIDs = make([]string, 0, len(userIDs)) + for _, userID := range userIDs { + userID = strings.TrimSpace(userID) + if userID == "" { + continue + } + if _, exists := uniqueUserIDs[userID]; exists { + continue + } + uniqueUserIDs[userID] = struct{}{} + input.UserIDs = append(input.UserIDs, userID) + } + + if input.Name == "" { + return input, errors.New("Channelname ist erforderlich") + } + if !channelSlugPattern.MatchString(input.Slug) { + return input, errors.New("Der Webhook-Slug darf nur Kleinbuchstaben, Zahlen und Bindestriche enthalten") + } + if input.SourceName == "" { + input.SourceName = input.Name + } + if len(input.Image) > 3*1024*1024 { + return input, errors.New("Das Channel-Bild ist zu groß") + } + return input, nil +} + +func (s *Server) handleAdminListChannels(w http.ResponseWriter, r *http.Request) { + channels, err := s.listAdminChannels(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, "Channels konnten nicht geladen werden") + return + } + + users, err := s.listAdminChannelUsers(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, "Benutzer konnten nicht geladen werden") + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "channels": channels, + "users": users, + "zabbixScript": zabbixWebhookScript, + }) +} + +func (s *Server) listAdminChannels(ctx context.Context) ([]AdminChannel, error) { + rows, err := s.db.Query( + ctx, + ` + SELECT + c.id::TEXT, + c.name, + c.slug, + c.channel_source_name, + c.channel_image, + c.channel_all_users, + c.webhook_enabled, + c.webhook_token_hash <> '', + c.webhook_token_updated_at, + COALESCE(array_agg(cm.user_id::TEXT) FILTER (WHERE cm.user_id IS NOT NULL), '{}') + FROM conversations c + LEFT JOIN conversation_members cm ON cm.conversation_id = c.id + WHERE c.type = 'channel' + GROUP BY c.id + ORDER BY lower(c.name) + `, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + channels := []AdminChannel{} + for rows.Next() { + var channel AdminChannel + if err := rows.Scan( + &channel.ID, + &channel.Name, + &channel.Slug, + &channel.SourceName, + &channel.Image, + &channel.AllUsers, + &channel.WebhookEnabled, + &channel.TokenConfigured, + &channel.WebhookTokenUpdatedAt, + &channel.UserIDs, + ); err != nil { + return nil, err + } + channel.WebhookPath = "/webhooks/channels/" + channel.Slug + channels = append(channels, channel) + } + return channels, rows.Err() +} + +func (s *Server) listAdminChannelUsers(ctx context.Context) ([]AdminChannelUser, error) { + rows, err := s.db.Query( + ctx, + ` + SELECT + id::TEXT, + COALESCE(username, ''), + COALESCE(display_name, ''), + email, + COALESCE(unit, '') + FROM users + ORDER BY lower(COALESCE(NULLIF(display_name, ''), username, email)) + `, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + users := []AdminChannelUser{} + for rows.Next() { + var user AdminChannelUser + if err := rows.Scan( + &user.ID, + &user.Username, + &user.DisplayName, + &user.Email, + &user.Unit, + ); err != nil { + return nil, err + } + users = append(users, user) + } + return users, rows.Err() +} + +func (s *Server) handleAdminCreateChannel(w http.ResponseWriter, r *http.Request) { + var input saveAdminChannelRequest + if err := readJSON(r, &input); err != nil { + writeError(w, http.StatusBadRequest, "Ungültige Anfrage") + return + } + + input, err := validateAdminChannelInput(input) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + tx, err := s.db.Begin(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, "Channel konnte nicht erstellt werden") + return + } + defer tx.Rollback(r.Context()) + + var channelID string + err = tx.QueryRow( + r.Context(), + ` + INSERT INTO conversations ( + type, + name, + slug, + channel_source_name, + channel_image, + channel_all_users, + webhook_enabled + ) + VALUES ('channel', $1, $2, $3, $4, $5, $6) + RETURNING id::TEXT + `, + input.Name, + input.Slug, + input.SourceName, + input.Image, + input.AllUsers, + input.WebhookEnabled, + ).Scan(&channelID) + if err != nil { + writeError(w, http.StatusConflict, "Channelname oder Webhook-Slug wird bereits verwendet") + return + } + + if err := syncChannelMembers(r.Context(), tx, channelID, input.AllUsers, input.UserIDs); err != nil { + writeError(w, http.StatusInternalServerError, "Channelmitglieder konnten nicht gespeichert werden") + return + } + if err := tx.Commit(r.Context()); err != nil { + writeError(w, http.StatusInternalServerError, "Channel konnte nicht gespeichert werden") + return + } + + writeJSON(w, http.StatusCreated, map[string]any{"id": channelID}) +} + +func (s *Server) handleAdminUpdateChannel(w http.ResponseWriter, r *http.Request) { + channelID := strings.TrimSpace(r.PathValue("id")) + + var input saveAdminChannelRequest + if err := readJSON(r, &input); err != nil { + writeError(w, http.StatusBadRequest, "Ungültige Anfrage") + return + } + + input, err := validateAdminChannelInput(input) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + tx, err := s.db.Begin(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, "Channel konnte nicht aktualisiert werden") + return + } + defer tx.Rollback(r.Context()) + + commandTag, err := tx.Exec( + r.Context(), + ` + UPDATE conversations + SET + name = $2, + slug = $3, + channel_source_name = $4, + channel_image = $5, + channel_all_users = $6, + webhook_enabled = $7, + updated_at = now() + WHERE id = $1 + AND type = 'channel' + `, + channelID, + input.Name, + input.Slug, + input.SourceName, + input.Image, + input.AllUsers, + input.WebhookEnabled, + ) + if err != nil { + writeError(w, http.StatusConflict, "Channelname oder Webhook-Slug wird bereits verwendet") + return + } + if commandTag.RowsAffected() == 0 { + writeError(w, http.StatusNotFound, "Channel wurde nicht gefunden") + return + } + + if err := syncChannelMembers(r.Context(), tx, channelID, input.AllUsers, input.UserIDs); err != nil { + writeError(w, http.StatusInternalServerError, "Channelmitglieder konnten nicht gespeichert werden") + return + } + if err := tx.Commit(r.Context()); err != nil { + writeError(w, http.StatusInternalServerError, "Channel konnte nicht gespeichert werden") + return + } + + writeJSON(w, http.StatusOK, map[string]any{"id": channelID}) +} + +func syncChannelMembers( + ctx context.Context, + tx pgx.Tx, + channelID string, + allUsers bool, + userIDs []string, +) error { + if allUsers { + _, err := tx.Exec( + ctx, + ` + INSERT INTO conversation_members (conversation_id, user_id) + SELECT $1, id FROM users + ON CONFLICT (conversation_id, user_id) DO NOTHING + `, + channelID, + ) + return err + } + + if _, err := tx.Exec( + ctx, + ` + DELETE FROM conversation_members + WHERE conversation_id = $1 + AND NOT ( + user_id::TEXT = ANY( + COALESCE($2::TEXT[], ARRAY[]::TEXT[]) + ) + ) + `, + channelID, + userIDs, + ); err != nil { + return err + } + + for _, userID := range userIDs { + if _, err := tx.Exec( + ctx, + ` + INSERT INTO conversation_members (conversation_id, user_id) + SELECT $1, id FROM users WHERE id = $2 + ON CONFLICT (conversation_id, user_id) DO NOTHING + `, + channelID, + strings.TrimSpace(userID), + ); err != nil { + return err + } + } + return nil +} + +func generateChannelWebhookToken() (string, string, error) { + raw := make([]byte, 32) + if _, err := rand.Read(raw); err != nil { + return "", "", err + } + + token := base64.RawURLEncoding.EncodeToString(raw) + hash := sha256.Sum256([]byte(token)) + return token, hex.EncodeToString(hash[:]), nil +} + +func (s *Server) handleAdminRotateChannelToken(w http.ResponseWriter, r *http.Request) { + channelID := strings.TrimSpace(r.PathValue("id")) + token, tokenHash, err := generateChannelWebhookToken() + if err != nil { + writeError(w, http.StatusInternalServerError, "Webhook-Token konnte nicht erzeugt werden") + return + } + + commandTag, err := s.db.Exec( + r.Context(), + ` + UPDATE conversations + SET + webhook_token_hash = $2, + webhook_token_updated_at = now(), + updated_at = now() + WHERE id = $1 + AND type = 'channel' + `, + channelID, + tokenHash, + ) + if err != nil { + writeError(w, http.StatusInternalServerError, "Webhook-Token konnte nicht gespeichert werden") + return + } + if commandTag.RowsAffected() == 0 { + writeError(w, http.StatusNotFound, "Channel wurde nicht gefunden") + return + } + + writeJSON(w, http.StatusOK, map[string]any{"token": token}) +} + +func (s *Server) handleChannelWebhook(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, 64*1024) + slug := normalizeChannelSlug(r.PathValue("slug")) + + var channelID string + var tokenHash string + var enabled bool + err := s.db.QueryRow( + r.Context(), + ` + SELECT id::TEXT, webhook_token_hash, webhook_enabled + FROM conversations + WHERE type = 'channel' + AND slug = $1 + `, + slug, + ).Scan(&channelID, &tokenHash, &enabled) + if err != nil || !enabled || tokenHash == "" { + writeError(w, http.StatusNotFound, "Webhook wurde nicht gefunden") + return + } + + token := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")) + if token == "" { + token = strings.TrimSpace(r.Header.Get("X-Webhook-Token")) + } + hash := sha256.Sum256([]byte(token)) + providedHash := hex.EncodeToString(hash[:]) + if subtle.ConstantTimeCompare([]byte(providedHash), []byte(tokenHash)) != 1 { + writeError(w, http.StatusUnauthorized, "Ungültiges Webhook-Token") + return + } + + var input channelWebhookPayload + if err := readJSON(r, &input); err != nil { + writeError(w, http.StatusBadRequest, "Ungültige Webhook-Nachricht") + return + } + + body := formatChannelWebhookMessage(input) + if body == "" { + writeError(w, http.StatusBadRequest, "Nachricht darf nicht leer sein") + return + } + + message, err := s.createChannelMessage(r.Context(), channelID, body) + if err != nil { + writeError(w, http.StatusInternalServerError, "Channel-Nachricht konnte nicht gespeichert werden") + return + } + + s.publishChatMessage(r.Context(), message, "") + writeJSON(w, http.StatusCreated, map[string]any{ + "messageId": message.ID, + "status": "accepted", + }) +} + +func formatChannelWebhookMessage(input channelWebhookPayload) string { + parts := []string{} + if subject := strings.TrimSpace(input.Subject); subject != "" { + parts = append(parts, subject) + } + if message := strings.TrimSpace(input.Message); message != "" { + parts = append(parts, message) + } + + details := []string{} + for _, detail := range []struct { + label string + value string + }{ + {"Host", input.Host}, + {"Status", input.Status}, + {"Schweregrad", input.Severity}, + {"Event-ID", input.EventID}, + {"Link", input.EventURL}, + } { + if value := strings.TrimSpace(detail.value); value != "" { + details = append(details, detail.label+": "+value) + } + } + if len(details) > 0 { + parts = append(parts, strings.Join(details, "\n")) + } + + body := strings.Join(parts, "\n\n") + runes := []rune(body) + if len(runes) > 4000 { + body = string(runes[:4000]) + } + return body +} + +func (s *Server) createChannelMessage( + ctx context.Context, + conversationID string, + body string, +) (ChatMessage, error) { + tx, err := s.db.Begin(ctx) + if err != nil { + return ChatMessage{}, err + } + defer tx.Rollback(ctx) + + var messageID string + err = tx.QueryRow( + ctx, + ` + INSERT INTO messages (conversation_id, body) + SELECT id, $2 + FROM conversations + WHERE id = $1 + AND type = 'channel' + RETURNING id::TEXT + `, + conversationID, + body, + ).Scan(&messageID) + if err != nil { + return ChatMessage{}, err + } + + if _, err := tx.Exec( + ctx, + ` + UPDATE conversations + SET last_message_at = now(), updated_at = now() + WHERE id = $1 + `, + conversationID, + ); err != nil { + return ChatMessage{}, err + } + + if err := tx.Commit(ctx); err != nil { + return ChatMessage{}, err + } + return s.getChatMessage(ctx, messageID) +} diff --git a/backend/chat.go b/backend/chat.go new file mode 100644 index 0000000..f436e2b --- /dev/null +++ b/backend/chat.go @@ -0,0 +1,1366 @@ +package main + +import ( + "context" + "database/sql" + "errors" + "math" + "net/http" + "sort" + "strings" + "time" + + "github.com/jackc/pgx/v5" +) + +type ChatUser struct { + ID string `json:"id"` + Username string `json:"username"` + DisplayName string `json:"displayName"` + Email string `json:"email"` + Avatar string `json:"avatar"` + Unit string `json:"unit"` + Online bool `json:"onlineStatus"` + PresenceStatus string `json:"presenceStatus"` + LastSeenAt *time.Time `json:"lastSeenAt"` + AwaySince *time.Time `json:"awaySince"` + LastOnlineAt *time.Time `json:"lastOnlineAt"` +} + +type ChatMessage struct { + ID string `json:"id"` + ConversationID string `json:"conversationId"` + Body string `json:"body"` + CreatedAt time.Time `json:"createdAt"` + EditedAt *time.Time `json:"editedAt"` + Sender ChatUser `json:"sender"` + Attachments []ChatAttachment `json:"attachments"` + ReplyTo *ChatMessageReference `json:"replyTo"` + ForwardedFrom *ChatMessageReference `json:"forwardedFrom"` + Location *ChatLocation `json:"location"` + LinkPreview *ChatLinkPreview `json:"linkPreview"` + replyToMessageID string + forwardedFromMessageID string +} + +type ChatLocation struct { + Lat float64 `json:"lat"` + Lng float64 `json:"lng"` + Label string `json:"label"` +} + +type ChatLinkPreview struct { + URL string `json:"url"` + Title string `json:"title"` + Description string `json:"description"` + ImageURL string `json:"imageUrl"` + SiteName string `json:"siteName"` +} + +type ChatAttachment struct { + ID string `json:"id"` + FileName string `json:"fileName"` + ContentType string `json:"contentType"` + SizeBytes int64 `json:"sizeBytes"` + URL string `json:"url"` +} + +type ChatMessageReference struct { + ID string `json:"id"` + Body string `json:"body"` + SenderDisplayName string `json:"senderDisplayName"` + CreatedAt time.Time `json:"createdAt"` +} + +type ChatConversation struct { + ID string `json:"id"` + Type string `json:"type"` + Name string `json:"name"` + Image string `json:"image"` + TeamID string `json:"teamId"` + Participants []ChatUser `json:"participants"` + LastMessage *ChatMessage `json:"lastMessage"` + LastMessageAt *time.Time `json:"lastMessageAt"` + Muted bool `json:"muted"` + UnreadCount int `json:"unreadCount"` + CreatedAt time.Time `json:"createdAt"` +} + +type createDirectConversationRequest struct { + UserID string `json:"userId"` +} + +type createChatMessageRequest struct { + Body string `json:"body"` + AttachmentIDs []string `json:"attachmentIds"` + ReplyToMessageID string `json:"replyToMessageId"` + ForwardedFromMessageID string `json:"forwardedFromMessageId"` + Location *ChatLocation `json:"location"` +} + +type createChatMessageInput struct { + Body string + AttachmentIDs []string + ReplyToMessageID string + ForwardedFromMessageID string + Location *ChatLocation +} + +var ( + errChatAccessDenied = errors.New("kein zugriff auf diesen chat") + errChatMessageEmpty = errors.New("nachricht darf nicht leer sein") + errChatMessageTooLong = errors.New("nachricht ist zu lang") + errChatLocationInvalid = errors.New("ungültiger standort") + errChatForwardSame = errors.New("nachricht kann nicht in denselben chat weitergeleitet werden") + errChatReadOnly = errors.New("channel ist schreibgeschützt") +) + +func normalizeChatLocation(location *ChatLocation) (*ChatLocation, error) { + if location == nil { + return nil, nil + } + + if math.IsNaN(location.Lat) || math.IsNaN(location.Lng) || + location.Lat < -90 || location.Lat > 90 || + location.Lng < -180 || location.Lng > 180 { + return nil, errChatLocationInvalid + } + + label := strings.TrimSpace(location.Label) + if labelRunes := []rune(label); len(labelRunes) > 200 { + label = string(labelRunes[:200]) + } + + return &ChatLocation{Lat: location.Lat, Lng: location.Lng, Label: label}, nil +} + +func (s *Server) handleListChatUsers(w http.ResponseWriter, r *http.Request) { + user, ok := userFromContext(r.Context()) + if !ok { + writeError(w, http.StatusUnauthorized, "Unauthorized") + return + } + + rows, err := s.db.Query( + r.Context(), + ` + SELECT + id::TEXT, + COALESCE(username, ''), + COALESCE(display_name, ''), + email, + COALESCE(avatar, ''), + COALESCE(unit, ''), + `+presenceStatusSQL+`, + NULL::TIMESTAMPTZ, + NULL::TIMESTAMPTZ, + NULL::TIMESTAMPTZ + FROM users + WHERE id <> $1 + ORDER BY + (`+presenceStatusSQL+`) = 'online' DESC, + lower(COALESCE(NULLIF(display_name, ''), username, email)) + `, + user.ID, + ) + if err != nil { + writeError(w, http.StatusInternalServerError, "Personen konnten nicht geladen werden") + return + } + defer rows.Close() + + users := []ChatUser{} + for rows.Next() { + var chatUser ChatUser + if err := rows.Scan( + &chatUser.ID, + &chatUser.Username, + &chatUser.DisplayName, + &chatUser.Email, + &chatUser.Avatar, + &chatUser.Unit, + &chatUser.PresenceStatus, + &chatUser.LastSeenAt, + &chatUser.AwaySince, + &chatUser.LastOnlineAt, + ); err != nil { + writeError(w, http.StatusInternalServerError, "Personen konnten nicht gelesen werden") + return + } + chatUser.Online = chatUser.PresenceStatus == "online" + users = append(users, chatUser) + } + + if err := rows.Err(); err != nil { + writeError(w, http.StatusInternalServerError, "Personen konnten nicht vollständig geladen werden") + return + } + + writeJSON(w, http.StatusOK, map[string]any{"users": users}) +} + +func (s *Server) ensureTeamConversations(ctx context.Context, userID string) error { + _, err := s.db.Exec( + ctx, + ` + WITH user_team_conversations AS ( + INSERT INTO conversations (type, name, team_id, created_by) + SELECT 'team', t.name, t.id, $1 + FROM teams t + JOIN user_teams current_membership + ON current_membership.team_id = t.id + AND current_membership.user_id = $1 + ON CONFLICT (team_id) WHERE team_id IS NOT NULL + DO UPDATE SET + name = EXCLUDED.name, + updated_at = now() + RETURNING id, team_id + ) + INSERT INTO conversation_members (conversation_id, user_id) + SELECT utc.id, ut.user_id + FROM user_team_conversations utc + JOIN user_teams ut ON ut.team_id = utc.team_id + ON CONFLICT (conversation_id, user_id) DO NOTHING + `, + userID, + ) + + return err +} + +func (s *Server) handleListChatConversations(w http.ResponseWriter, r *http.Request) { + user, ok := userFromContext(r.Context()) + if !ok { + writeError(w, http.StatusUnauthorized, "Unauthorized") + return + } + + if err := s.ensureTeamConversations(r.Context(), user.ID); err != nil { + writeError(w, http.StatusInternalServerError, "Team-Chats konnten nicht vorbereitet werden") + return + } + if err := s.ensureAllUserChannels(r.Context(), user.ID); err != nil { + writeError(w, http.StatusInternalServerError, "Channels konnten nicht vorbereitet werden") + return + } + + rows, err := s.db.Query( + r.Context(), + ` + SELECT + c.id::TEXT, + c.type, + c.name, + c.channel_image, + COALESCE(c.team_id::TEXT, ''), + c.last_message_at, + COALESCE(( + SELECT cm.muted + FROM conversation_members cm + WHERE cm.conversation_id = c.id + AND cm.user_id = $1 + ), false), + COALESCE(( + SELECT COUNT(*)::INT + FROM messages unread_message + LEFT JOIN conversation_members current_membership + ON current_membership.conversation_id = c.id + AND current_membership.user_id = $1 + WHERE unread_message.conversation_id = c.id + AND unread_message.deleted_at IS NULL + AND unread_message.sender_id IS DISTINCT FROM $1::UUID + AND unread_message.created_at > COALESCE( + current_membership.last_read_at, + '-infinity'::TIMESTAMPTZ + ) + ), 0), + c.created_at + FROM conversations c + WHERE ( + c.type = 'team' + AND EXISTS ( + SELECT 1 + FROM user_teams ut + WHERE ut.team_id = c.team_id + AND ut.user_id = $1 + ) + ) OR ( + c.type <> 'team' + AND EXISTS ( + SELECT 1 + FROM conversation_members cm + WHERE cm.conversation_id = c.id + AND cm.user_id = $1 + ) + ) + ORDER BY COALESCE(c.last_message_at, c.created_at) DESC + `, + user.ID, + ) + if err != nil { + writeError(w, http.StatusInternalServerError, "Chats konnten nicht geladen werden") + return + } + defer rows.Close() + + conversations := []ChatConversation{} + for rows.Next() { + var conversation ChatConversation + if err := rows.Scan( + &conversation.ID, + &conversation.Type, + &conversation.Name, + &conversation.Image, + &conversation.TeamID, + &conversation.LastMessageAt, + &conversation.Muted, + &conversation.UnreadCount, + &conversation.CreatedAt, + ); err != nil { + writeError(w, http.StatusInternalServerError, "Chats konnten nicht gelesen werden") + return + } + + participants, err := s.listChatParticipants(r.Context(), conversation.ID, user.ShowLastSeen) + if err != nil { + writeError(w, http.StatusInternalServerError, "Chatmitglieder konnten nicht geladen werden") + return + } + conversation.Participants = participants + + lastMessage, err := s.getLastChatMessage(r.Context(), conversation.ID) + if err != nil && !errors.Is(err, pgx.ErrNoRows) { + writeError(w, http.StatusInternalServerError, "Letzte Nachricht konnte nicht geladen werden") + return + } + if err == nil { + conversation.LastMessage = &lastMessage + } + + conversations = append(conversations, conversation) + } + + if err := rows.Err(); err != nil { + writeError(w, http.StatusInternalServerError, "Chats konnten nicht vollständig geladen werden") + return + } + + writeJSON(w, http.StatusOK, map[string]any{"conversations": conversations}) +} + +func (s *Server) handleCreateDirectConversation(w http.ResponseWriter, r *http.Request) { + currentUser, ok := userFromContext(r.Context()) + if !ok { + writeError(w, http.StatusUnauthorized, "Unauthorized") + return + } + + var input createDirectConversationRequest + if err := readJSON(r, &input); err != nil { + writeError(w, http.StatusBadRequest, "Ungültige Anfrage") + return + } + + otherUserID := strings.TrimSpace(input.UserID) + if otherUserID == "" || otherUserID == currentUser.ID { + writeError(w, http.StatusBadRequest, "Eine andere Person ist erforderlich") + return + } + + var otherUserExists bool + if err := s.db.QueryRow( + r.Context(), + `SELECT EXISTS(SELECT 1 FROM users WHERE id = $1)`, + otherUserID, + ).Scan(&otherUserExists); err != nil || !otherUserExists { + writeError(w, http.StatusNotFound, "Person wurde nicht gefunden") + return + } + + userIDs := []string{currentUser.ID, otherUserID} + sort.Strings(userIDs) + directKey := strings.Join(userIDs, ":") + + tx, err := s.db.Begin(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, "Chat konnte nicht erstellt werden") + return + } + defer tx.Rollback(r.Context()) + + var conversationID string + err = tx.QueryRow( + r.Context(), + ` + INSERT INTO conversations (type, direct_key, created_by) + VALUES ('direct', $1, $2) + ON CONFLICT (direct_key) WHERE direct_key <> '' + DO UPDATE SET updated_at = conversations.updated_at + RETURNING id::TEXT + `, + directKey, + currentUser.ID, + ).Scan(&conversationID) + if err != nil { + writeError(w, http.StatusInternalServerError, "Chat konnte nicht erstellt werden") + return + } + + for _, userID := range userIDs { + if _, err := tx.Exec( + r.Context(), + ` + INSERT INTO conversation_members (conversation_id, user_id) + VALUES ($1, $2) + ON CONFLICT (conversation_id, user_id) DO NOTHING + `, + conversationID, + userID, + ); err != nil { + writeError(w, http.StatusInternalServerError, "Chatmitglieder konnten nicht gespeichert werden") + return + } + } + + if err := tx.Commit(r.Context()); err != nil { + writeError(w, http.StatusInternalServerError, "Chat konnte nicht gespeichert werden") + return + } + + conversation, err := s.getChatConversation(r.Context(), conversationID, currentUser.ID, currentUser.ShowLastSeen) + if err != nil { + writeError(w, http.StatusInternalServerError, "Chat konnte nicht geladen werden") + return + } + + writeJSON(w, http.StatusOK, map[string]any{"conversation": conversation}) +} + +func (s *Server) handleListChatMessages(w http.ResponseWriter, r *http.Request) { + user, ok := userFromContext(r.Context()) + if !ok { + writeError(w, http.StatusUnauthorized, "Unauthorized") + return + } + + conversationID := strings.TrimSpace(r.PathValue("id")) + if !s.canAccessConversation(r.Context(), conversationID, user.ID) { + writeError(w, http.StatusForbidden, "Kein Zugriff auf diesen Chat") + return + } + + rows, err := s.db.Query( + r.Context(), + ` + SELECT + m.id::TEXT, + m.conversation_id::TEXT, + m.body, + m.created_at, + m.edited_at, + COALESCE(m.reply_to_message_id::TEXT, ''), + COALESCE(m.forwarded_from_message_id::TEXT, ''), + COALESCE(u.id::TEXT, ''), + COALESCE(u.username, NULLIF(c.channel_source_name, ''), c.name, ''), + COALESCE(u.display_name, NULLIF(c.channel_source_name, ''), c.name, ''), + COALESCE(u.email, ''), + COALESCE(NULLIF(u.avatar, ''), NULLIF(c.channel_image, ''), ''), + COALESCE(u.unit, ''), + CASE + WHEN u.presence_mode = 'offline' + OR u.last_seen_at IS NULL + OR u.last_seen_at < now() - INTERVAL '2 minutes' + THEN 'offline' + WHEN u.presence_mode = 'away' + OR u.last_active_at IS NULL + OR u.last_active_at < now() - make_interval(mins => u.away_after_minutes) + THEN 'away' + ELSE 'online' + END, + CASE + WHEN c.type = 'direct' AND u.show_last_seen + THEN u.last_seen_at + ELSE NULL + END, + CASE + WHEN c.type <> 'direct' OR NOT u.show_last_seen THEN NULL + WHEN u.presence_mode = 'away' THEN u.presence_mode_updated_at + WHEN u.presence_mode = 'online' + AND u.last_seen_at >= now() - INTERVAL '2 minutes' + AND u.last_active_at < now() - make_interval(mins => u.away_after_minutes) + THEN u.last_active_at + make_interval(mins => u.away_after_minutes) + ELSE NULL + END, + CASE + WHEN c.type = 'direct' AND u.show_last_seen + THEN CASE + WHEN u.presence_mode = 'offline' THEN u.presence_mode_updated_at + ELSE u.last_seen_at + END + ELSE NULL + END + FROM messages m + JOIN conversations c ON c.id = m.conversation_id + LEFT JOIN users u ON u.id = m.sender_id + WHERE m.conversation_id = $1 + AND m.deleted_at IS NULL + ORDER BY m.created_at ASC, m.id ASC + LIMIT 200 + `, + conversationID, + ) + if err != nil { + writeError(w, http.StatusInternalServerError, "Nachrichten konnten nicht geladen werden") + return + } + defer rows.Close() + + messages := []ChatMessage{} + for rows.Next() { + message, err := scanChatMessage(rows) + if err != nil { + writeError(w, http.StatusInternalServerError, "Nachrichten konnten nicht gelesen werden") + return + } + if err := s.enrichChatMessage(r.Context(), &message); err != nil { + writeError(w, http.StatusInternalServerError, "Nachrichtendetails konnten nicht geladen werden") + return + } + messages = append(messages, message) + } + + if err := rows.Err(); err != nil { + writeError(w, http.StatusInternalServerError, "Nachrichten konnten nicht vollständig geladen werden") + return + } + + _ = s.markChatConversationRead(r.Context(), conversationID, user.ID) + + // Gegenseitigkeit: Wer selbst nichts teilt, bekommt auch keine Status-Details. + if !user.ShowLastSeen { + for i := range messages { + messages[i].Sender.LastSeenAt = nil + messages[i].Sender.AwaySince = nil + messages[i].Sender.LastOnlineAt = nil + } + } + + writeJSON(w, http.StatusOK, map[string]any{"messages": messages}) +} + +func (s *Server) handleCreateChatMessage(w http.ResponseWriter, r *http.Request) { + user, ok := userFromContext(r.Context()) + if !ok { + writeError(w, http.StatusUnauthorized, "Unauthorized") + return + } + + conversationID := strings.TrimSpace(r.PathValue("id")) + + var input createChatMessageRequest + if err := readJSON(r, &input); err != nil { + writeError(w, http.StatusBadRequest, "Ungültige Anfrage") + return + } + + message, err := s.createChatMessage( + r.Context(), + user, + conversationID, + createChatMessageInput{ + Body: input.Body, + AttachmentIDs: input.AttachmentIDs, + ReplyToMessageID: input.ReplyToMessageID, + ForwardedFromMessageID: input.ForwardedFromMessageID, + Location: input.Location, + }, + ) + if err != nil { + writeChatMessageError(w, err) + return + } + + s.publishChatMessage(r.Context(), message, "") + writeJSON(w, http.StatusCreated, map[string]any{"message": message}) +} + +func (s *Server) createChatMessage( + ctx context.Context, + user User, + conversationID string, + input createChatMessageInput, +) (ChatMessage, error) { + var message ChatMessage + + conversationID = strings.TrimSpace(conversationID) + if !s.canAccessConversation(ctx, conversationID, user.ID) { + return message, errChatAccessDenied + } + if !s.isConversationWritable(ctx, conversationID) { + return message, errChatReadOnly + } + + if replyToMessageID := strings.TrimSpace(input.ReplyToMessageID); replyToMessageID != "" { + var valid bool + err := s.db.QueryRow( + ctx, + ` + SELECT EXISTS( + SELECT 1 + FROM messages + WHERE id = $1 + AND conversation_id = $2 + AND deleted_at IS NULL + ) + `, + replyToMessageID, + conversationID, + ).Scan(&valid) + if err != nil { + return message, err + } + if !valid { + return message, errChatAccessDenied + } + } + + if forwardedFromMessageID := strings.TrimSpace(input.ForwardedFromMessageID); forwardedFromMessageID != "" { + var sourceConversationID string + err := s.db.QueryRow( + ctx, + ` + SELECT conversation_id::TEXT + FROM messages + WHERE id = $1 + AND deleted_at IS NULL + `, + forwardedFromMessageID, + ).Scan(&sourceConversationID) + if err != nil || !s.canAccessConversation(ctx, sourceConversationID, user.ID) { + return message, errChatAccessDenied + } + if sourceConversationID == conversationID { + return message, errChatForwardSame + } + } + + location, err := normalizeChatLocation(input.Location) + if err != nil { + return message, err + } + + body := strings.TrimSpace(input.Body) + if body == "" && + len(input.AttachmentIDs) == 0 && + strings.TrimSpace(input.ForwardedFromMessageID) == "" && + location == nil { + return message, errChatMessageEmpty + } + if len([]rune(body)) > 4000 { + return message, errChatMessageTooLong + } + + var locationLat, locationLng *float64 + locationLabel := "" + if location != nil { + locationLat = &location.Lat + locationLng = &location.Lng + locationLabel = location.Label + } + + tx, err := s.db.Begin(ctx) + if err != nil { + return message, err + } + defer tx.Rollback(ctx) + + var messageID string + err = tx.QueryRow( + ctx, + ` + INSERT INTO messages ( + conversation_id, + sender_id, + body, + reply_to_message_id, + forwarded_from_message_id, + location_lat, + location_lng, + location_label + ) + VALUES ( + $1, + $2, + $3, + NULLIF($4, '')::UUID, + NULLIF($5, '')::UUID, + $6, + $7, + $8 + ) + RETURNING id::TEXT + `, + conversationID, + user.ID, + body, + strings.TrimSpace(input.ReplyToMessageID), + strings.TrimSpace(input.ForwardedFromMessageID), + locationLat, + locationLng, + locationLabel, + ).Scan(&messageID) + if err != nil { + return message, err + } + + if _, err := tx.Exec( + ctx, + ` + UPDATE conversations + SET last_message_at = now(), updated_at = now() + WHERE id = $1 + `, + conversationID, + ); err != nil { + return message, err + } + + for _, attachmentID := range input.AttachmentIDs { + commandTag, err := tx.Exec( + ctx, + ` + UPDATE message_attachments + SET message_id = $1 + WHERE id = $2 + AND uploader_id = $3 + AND message_id IS NULL + `, + messageID, + strings.TrimSpace(attachmentID), + user.ID, + ) + if err != nil { + return message, err + } + if commandTag.RowsAffected() == 0 { + return message, errors.New("anhang wurde nicht gefunden") + } + } + + if forwardedFromMessageID := strings.TrimSpace(input.ForwardedFromMessageID); forwardedFromMessageID != "" { + if _, err := tx.Exec( + ctx, + ` + INSERT INTO message_attachments ( + message_id, + uploader_id, + file_name, + content_type, + size_bytes, + data + ) + SELECT + $1, + $2, + file_name, + content_type, + size_bytes, + data + FROM message_attachments + WHERE message_id = $3 + ORDER BY created_at ASC + `, + messageID, + user.ID, + forwardedFromMessageID, + ); err != nil { + return message, err + } + } + + if _, err := tx.Exec( + ctx, + ` + UPDATE conversation_members + SET last_read_at = now() + WHERE conversation_id = $1 AND user_id = $2 + `, + conversationID, + user.ID, + ); err != nil { + return message, err + } + + if err := tx.Commit(ctx); err != nil { + return message, err + } + + message, err = s.getChatMessage(ctx, messageID) + if err != nil { + return message, err + } + + return message, nil +} + +func writeChatMessageError(w http.ResponseWriter, err error) { + switch { + case errors.Is(err, errChatAccessDenied): + writeError(w, http.StatusForbidden, "Kein Zugriff auf diesen Chat") + case errors.Is(err, errChatMessageEmpty): + writeError(w, http.StatusBadRequest, "Nachricht darf nicht leer sein") + case errors.Is(err, errChatMessageTooLong): + writeError(w, http.StatusBadRequest, "Nachricht darf höchstens 4000 Zeichen enthalten") + case errors.Is(err, errChatLocationInvalid): + writeError(w, http.StatusBadRequest, "Ungültiger Standort") + case errors.Is(err, errChatForwardSame): + writeError(w, http.StatusBadRequest, "Nachrichten können nicht in denselben Chat weitergeleitet werden") + case errors.Is(err, errChatReadOnly): + writeError(w, http.StatusForbidden, "Channels sind schreibgeschützt") + default: + writeError(w, http.StatusInternalServerError, "Nachricht konnte nicht gesendet werden") + } +} + +func (s *Server) canAccessConversation(ctx context.Context, conversationID string, userID string) bool { + if conversationID == "" || userID == "" { + return false + } + + var allowed bool + err := s.db.QueryRow( + ctx, + ` + SELECT EXISTS( + SELECT 1 + FROM conversations c + WHERE c.id = $1 + AND ( + ( + c.type = 'team' + AND EXISTS ( + SELECT 1 + FROM user_teams ut + WHERE ut.team_id = c.team_id + AND ut.user_id = $2 + ) + ) + OR ( + c.type <> 'team' + AND EXISTS ( + SELECT 1 + FROM conversation_members cm + WHERE cm.conversation_id = c.id + AND cm.user_id = $2 + ) + ) + ) + ) + `, + conversationID, + userID, + ).Scan(&allowed) + + return err == nil && allowed +} + +func (s *Server) isConversationWritable(ctx context.Context, conversationID string) bool { + var writable bool + err := s.db.QueryRow( + ctx, + ` + SELECT type <> 'channel' + FROM conversations + WHERE id = $1 + `, + conversationID, + ).Scan(&writable) + return err == nil && writable +} + +func (s *Server) listChatParticipants(ctx context.Context, conversationID string, viewerShowLastSeen bool) ([]ChatUser, error) { + rows, err := s.db.Query( + ctx, + ` + SELECT + u.id::TEXT, + COALESCE(u.username, NULLIF(c.channel_source_name, ''), c.name, ''), + COALESCE(u.display_name, NULLIF(c.channel_source_name, ''), c.name, ''), + u.email, + COALESCE(NULLIF(u.avatar, ''), NULLIF(c.channel_image, ''), ''), + COALESCE(u.unit, ''), + CASE + WHEN u.presence_mode = 'offline' + OR u.last_seen_at IS NULL + OR u.last_seen_at < now() - INTERVAL '2 minutes' + THEN 'offline' + WHEN u.presence_mode = 'away' + OR u.last_active_at IS NULL + OR u.last_active_at < now() - make_interval(mins => u.away_after_minutes) + THEN 'away' + ELSE 'online' + END, + CASE + WHEN c.type = 'direct' AND u.show_last_seen + THEN u.last_seen_at + ELSE NULL + END, + CASE + WHEN c.type <> 'direct' OR NOT u.show_last_seen THEN NULL + WHEN u.presence_mode = 'away' THEN u.presence_mode_updated_at + WHEN u.presence_mode = 'online' + AND u.last_seen_at >= now() - INTERVAL '2 minutes' + AND u.last_active_at < now() - make_interval(mins => u.away_after_minutes) + THEN u.last_active_at + make_interval(mins => u.away_after_minutes) + ELSE NULL + END, + CASE + WHEN c.type = 'direct' AND u.show_last_seen + THEN CASE + WHEN u.presence_mode = 'offline' THEN u.presence_mode_updated_at + ELSE u.last_seen_at + END + ELSE NULL + END + FROM conversations c + JOIN users u ON ( + ( + c.type = 'team' + AND EXISTS ( + SELECT 1 + FROM user_teams ut + WHERE ut.team_id = c.team_id + AND ut.user_id = u.id + ) + ) + OR ( + c.type <> 'team' + AND EXISTS ( + SELECT 1 + FROM conversation_members cm + WHERE cm.conversation_id = c.id + AND cm.user_id = u.id + ) + ) + ) + WHERE c.id = $1 + ORDER BY lower(COALESCE(NULLIF(u.display_name, ''), u.username, u.email)) + `, + conversationID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + participants := []ChatUser{} + for rows.Next() { + var participant ChatUser + if err := rows.Scan( + &participant.ID, + &participant.Username, + &participant.DisplayName, + &participant.Email, + &participant.Avatar, + &participant.Unit, + &participant.PresenceStatus, + &participant.LastSeenAt, + &participant.AwaySince, + &participant.LastOnlineAt, + ); err != nil { + return nil, err + } + participant.Online = participant.PresenceStatus == "online" + participants = append(participants, participant) + } + + // Gegenseitigkeit: Wer den eigenen letzten Online-Zeitpunkt nicht teilt, + // sieht auch bei anderen keine Status-Details (zuletzt online / Abwesenheitsdauer). + if !viewerShowLastSeen { + for i := range participants { + participants[i].LastSeenAt = nil + participants[i].AwaySince = nil + participants[i].LastOnlineAt = nil + } + } + + return participants, rows.Err() +} + +func (s *Server) getChatConversation(ctx context.Context, conversationID string, userID string, viewerShowLastSeen bool) (ChatConversation, error) { + var conversation ChatConversation + err := s.db.QueryRow( + ctx, + ` + SELECT + c.id::TEXT, + c.type, + c.name, + c.channel_image, + COALESCE(c.team_id::TEXT, ''), + c.last_message_at, + COALESCE(( + SELECT cm.muted + FROM conversation_members cm + WHERE cm.conversation_id = c.id + AND cm.user_id = $2 + ), false), + COALESCE(( + SELECT COUNT(*)::INT + FROM messages unread_message + LEFT JOIN conversation_members current_membership + ON current_membership.conversation_id = c.id + AND current_membership.user_id = $2 + WHERE unread_message.conversation_id = c.id + AND unread_message.deleted_at IS NULL + AND unread_message.sender_id IS DISTINCT FROM $2::UUID + AND unread_message.created_at > COALESCE( + current_membership.last_read_at, + '-infinity'::TIMESTAMPTZ + ) + ), 0), + c.created_at + FROM conversations c + WHERE c.id = $1 + `, + conversationID, + userID, + ).Scan( + &conversation.ID, + &conversation.Type, + &conversation.Name, + &conversation.Image, + &conversation.TeamID, + &conversation.LastMessageAt, + &conversation.Muted, + &conversation.UnreadCount, + &conversation.CreatedAt, + ) + if err != nil { + return conversation, err + } + if !s.canAccessConversation(ctx, conversation.ID, userID) { + return conversation, pgx.ErrNoRows + } + + conversation.Participants, err = s.listChatParticipants(ctx, conversation.ID, viewerShowLastSeen) + if err != nil { + return conversation, err + } + + lastMessage, err := s.getLastChatMessage(ctx, conversation.ID) + if err == nil { + conversation.LastMessage = &lastMessage + } else if !errors.Is(err, pgx.ErrNoRows) { + return conversation, err + } + + return conversation, nil +} + +type chatMessageScanner interface { + Scan(dest ...any) error +} + +func scanChatMessage(scanner chatMessageScanner) (ChatMessage, error) { + var message ChatMessage + err := scanner.Scan( + &message.ID, + &message.ConversationID, + &message.Body, + &message.CreatedAt, + &message.EditedAt, + &message.replyToMessageID, + &message.forwardedFromMessageID, + &message.Sender.ID, + &message.Sender.Username, + &message.Sender.DisplayName, + &message.Sender.Email, + &message.Sender.Avatar, + &message.Sender.Unit, + &message.Sender.PresenceStatus, + &message.Sender.LastSeenAt, + &message.Sender.AwaySince, + &message.Sender.LastOnlineAt, + ) + message.Sender.Online = message.Sender.PresenceStatus == "online" + return message, err +} + +func (s *Server) getChatMessage(ctx context.Context, messageID string) (ChatMessage, error) { + row := s.db.QueryRow( + ctx, + ` + SELECT + m.id::TEXT, + m.conversation_id::TEXT, + m.body, + m.created_at, + m.edited_at, + COALESCE(m.reply_to_message_id::TEXT, ''), + COALESCE(m.forwarded_from_message_id::TEXT, ''), + COALESCE(u.id::TEXT, ''), + COALESCE(u.username, NULLIF(c.channel_source_name, ''), c.name, ''), + COALESCE(u.display_name, NULLIF(c.channel_source_name, ''), c.name, ''), + COALESCE(u.email, ''), + COALESCE(NULLIF(u.avatar, ''), NULLIF(c.channel_image, ''), ''), + COALESCE(u.unit, ''), + CASE + WHEN u.presence_mode = 'offline' + OR u.last_seen_at IS NULL + OR u.last_seen_at < now() - INTERVAL '2 minutes' + THEN 'offline' + WHEN u.presence_mode = 'away' + OR u.last_active_at IS NULL + OR u.last_active_at < now() - make_interval(mins => u.away_after_minutes) + THEN 'away' + ELSE 'online' + END, + CASE + WHEN c.type = 'direct' AND u.show_last_seen + THEN u.last_seen_at + ELSE NULL + END, + CASE + WHEN c.type <> 'direct' OR NOT u.show_last_seen THEN NULL + WHEN u.presence_mode = 'away' THEN u.presence_mode_updated_at + WHEN u.presence_mode = 'online' + AND u.last_seen_at >= now() - INTERVAL '2 minutes' + AND u.last_active_at < now() - make_interval(mins => u.away_after_minutes) + THEN u.last_active_at + make_interval(mins => u.away_after_minutes) + ELSE NULL + END, + CASE + WHEN c.type = 'direct' AND u.show_last_seen + THEN CASE + WHEN u.presence_mode = 'offline' THEN u.presence_mode_updated_at + ELSE u.last_seen_at + END + ELSE NULL + END + FROM messages m + JOIN conversations c ON c.id = m.conversation_id + LEFT JOIN users u ON u.id = m.sender_id + WHERE m.id = $1 + `, + messageID, + ) + message, err := scanChatMessage(row) + if err != nil { + return message, err + } + err = s.enrichChatMessage(ctx, &message) + return message, err +} + +func (s *Server) getLastChatMessage(ctx context.Context, conversationID string) (ChatMessage, error) { + row := s.db.QueryRow( + ctx, + ` + SELECT + m.id::TEXT, + m.conversation_id::TEXT, + m.body, + m.created_at, + m.edited_at, + COALESCE(m.reply_to_message_id::TEXT, ''), + COALESCE(m.forwarded_from_message_id::TEXT, ''), + COALESCE(u.id::TEXT, ''), + COALESCE(u.username, NULLIF(c.channel_source_name, ''), c.name, ''), + COALESCE(u.display_name, NULLIF(c.channel_source_name, ''), c.name, ''), + COALESCE(u.email, ''), + COALESCE(NULLIF(u.avatar, ''), NULLIF(c.channel_image, ''), ''), + COALESCE(u.unit, ''), + CASE + WHEN u.presence_mode = 'offline' + OR u.last_seen_at IS NULL + OR u.last_seen_at < now() - INTERVAL '2 minutes' + THEN 'offline' + WHEN u.presence_mode = 'away' + OR u.last_active_at IS NULL + OR u.last_active_at < now() - make_interval(mins => u.away_after_minutes) + THEN 'away' + ELSE 'online' + END, + CASE + WHEN c.type = 'direct' AND u.show_last_seen + THEN u.last_seen_at + ELSE NULL + END, + CASE + WHEN c.type <> 'direct' OR NOT u.show_last_seen THEN NULL + WHEN u.presence_mode = 'away' THEN u.presence_mode_updated_at + WHEN u.presence_mode = 'online' + AND u.last_seen_at >= now() - INTERVAL '2 minutes' + AND u.last_active_at < now() - make_interval(mins => u.away_after_minutes) + THEN u.last_active_at + make_interval(mins => u.away_after_minutes) + ELSE NULL + END, + CASE + WHEN c.type = 'direct' AND u.show_last_seen + THEN CASE + WHEN u.presence_mode = 'offline' THEN u.presence_mode_updated_at + ELSE u.last_seen_at + END + ELSE NULL + END + FROM messages m + JOIN conversations c ON c.id = m.conversation_id + LEFT JOIN users u ON u.id = m.sender_id + WHERE m.conversation_id = $1 + AND m.deleted_at IS NULL + ORDER BY m.created_at DESC, m.id DESC + LIMIT 1 + `, + conversationID, + ) + message, err := scanChatMessage(row) + if err != nil { + return message, err + } + err = s.enrichChatMessage(ctx, &message) + return message, err +} + +func (s *Server) enrichChatMessage(ctx context.Context, message *ChatMessage) error { + attachments, err := s.listChatAttachments(ctx, message.ID) + if err != nil { + return err + } + message.Attachments = attachments + + location, err := s.getChatMessageLocation(ctx, message.ID) + if err != nil { + return err + } + message.Location = location + + preview, err := s.getChatLinkPreview(ctx, message.ID) + if err != nil { + return err + } + message.LinkPreview = preview + + if message.replyToMessageID != "" { + reference, err := s.getChatMessageReference(ctx, message.replyToMessageID) + if err != nil && !errors.Is(err, pgx.ErrNoRows) { + return err + } + if err == nil { + message.ReplyTo = &reference + } + } + + if message.forwardedFromMessageID != "" { + reference, err := s.getChatMessageReference(ctx, message.forwardedFromMessageID) + if err != nil && !errors.Is(err, pgx.ErrNoRows) { + return err + } + if err == nil { + message.ForwardedFrom = &reference + } + } + + return nil +} + +func (s *Server) getChatMessageLocation( + ctx context.Context, + messageID string, +) (*ChatLocation, error) { + var lat, lng sql.NullFloat64 + var label string + + err := s.db.QueryRow( + ctx, + `SELECT location_lat, location_lng, COALESCE(location_label, '') FROM messages WHERE id = $1`, + messageID, + ).Scan(&lat, &lng, &label) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + return nil, err + } + + if !lat.Valid || !lng.Valid { + return nil, nil + } + + return &ChatLocation{Lat: lat.Float64, Lng: lng.Float64, Label: label}, nil +} + +func (s *Server) listChatAttachments( + ctx context.Context, + messageID string, +) ([]ChatAttachment, error) { + rows, err := s.db.Query( + ctx, + ` + SELECT id::TEXT, file_name, content_type, size_bytes + FROM message_attachments + WHERE message_id = $1 + ORDER BY created_at ASC + `, + messageID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + attachments := []ChatAttachment{} + for rows.Next() { + var attachment ChatAttachment + if err := rows.Scan( + &attachment.ID, + &attachment.FileName, + &attachment.ContentType, + &attachment.SizeBytes, + ); err != nil { + return nil, err + } + attachment.URL = "/chat/attachments/" + attachment.ID + attachments = append(attachments, attachment) + } + + return attachments, rows.Err() +} + +func (s *Server) getChatMessageReference( + ctx context.Context, + messageID string, +) (ChatMessageReference, error) { + var reference ChatMessageReference + + err := s.db.QueryRow( + ctx, + ` + SELECT + m.id::TEXT, + m.body, + COALESCE( + NULLIF(u.display_name, ''), + NULLIF(u.username, ''), + u.email, + NULLIF(c.channel_source_name, ''), + c.name, + 'Gelöschter Benutzer' + ), + m.created_at + FROM messages m + JOIN conversations c ON c.id = m.conversation_id + LEFT JOIN users u ON u.id = m.sender_id + WHERE m.id = $1 + AND m.deleted_at IS NULL + `, + messageID, + ).Scan( + &reference.ID, + &reference.Body, + &reference.SenderDisplayName, + &reference.CreatedAt, + ) + + return reference, err +} diff --git a/backend/chat_attachments.go b/backend/chat_attachments.go new file mode 100644 index 0000000..5e06b72 --- /dev/null +++ b/backend/chat_attachments.go @@ -0,0 +1,160 @@ +package main + +import ( + "fmt" + "io" + "mime" + "net/http" + "path/filepath" + "strings" +) + +const maxChatAttachmentSize = 10 << 20 + +func (s *Server) handleUploadChatAttachment(w http.ResponseWriter, r *http.Request) { + user, ok := userFromContext(r.Context()) + if !ok { + writeError(w, http.StatusUnauthorized, "Unauthorized") + return + } + + r.Body = http.MaxBytesReader(w, r.Body, maxChatAttachmentSize+(1<<20)) + if err := r.ParseMultipartForm(maxChatAttachmentSize); err != nil { + writeError(w, http.StatusBadRequest, "Datei ist zu groß oder ungültig") + return + } + + file, header, err := r.FormFile("file") + if err != nil { + writeError(w, http.StatusBadRequest, "Datei fehlt") + return + } + defer file.Close() + + data, err := io.ReadAll(io.LimitReader(file, maxChatAttachmentSize+1)) + if err != nil { + writeError(w, http.StatusBadRequest, "Datei konnte nicht gelesen werden") + return + } + if len(data) == 0 { + writeError(w, http.StatusBadRequest, "Datei ist leer") + return + } + if len(data) > maxChatAttachmentSize { + writeError(w, http.StatusBadRequest, "Datei darf höchstens 10 MB groß sein") + return + } + + fileName := strings.TrimSpace(filepath.Base(header.Filename)) + if fileName == "" || fileName == "." { + fileName = "Anhang" + } + + contentType := strings.TrimSpace(header.Header.Get("Content-Type")) + if contentType == "" { + contentType = http.DetectContentType(data) + } + + var attachment ChatAttachment + err = s.db.QueryRow( + r.Context(), + ` + INSERT INTO message_attachments ( + uploader_id, + file_name, + content_type, + size_bytes, + data + ) + VALUES ($1, $2, $3, $4, $5) + RETURNING id::TEXT, file_name, content_type, size_bytes + `, + user.ID, + fileName, + contentType, + len(data), + data, + ).Scan( + &attachment.ID, + &attachment.FileName, + &attachment.ContentType, + &attachment.SizeBytes, + ) + if err != nil { + writeError(w, http.StatusInternalServerError, "Datei konnte nicht gespeichert werden") + return + } + + attachment.URL = "/chat/attachments/" + attachment.ID + writeJSON(w, http.StatusCreated, map[string]any{"attachment": attachment}) +} + +func (s *Server) handleDownloadChatAttachment(w http.ResponseWriter, r *http.Request) { + user, ok := userFromContext(r.Context()) + if !ok { + writeError(w, http.StatusUnauthorized, "Unauthorized") + return + } + + attachmentID := strings.TrimSpace(r.PathValue("id")) + var uploaderID string + var messageID string + var conversationID string + var fileName string + var contentType string + var data []byte + + err := s.db.QueryRow( + r.Context(), + ` + SELECT + a.uploader_id::TEXT, + COALESCE(a.message_id::TEXT, ''), + COALESCE(m.conversation_id::TEXT, ''), + a.file_name, + a.content_type, + a.data + FROM message_attachments a + LEFT JOIN messages m ON m.id = a.message_id + WHERE a.id = $1 + `, + attachmentID, + ).Scan( + &uploaderID, + &messageID, + &conversationID, + &fileName, + &contentType, + &data, + ) + if err != nil { + writeError(w, http.StatusNotFound, "Datei wurde nicht gefunden") + return + } + + allowed := messageID == "" && uploaderID == user.ID + if messageID != "" { + allowed = s.canAccessConversation(r.Context(), conversationID, user.ID) + } + if !allowed { + writeError(w, http.StatusForbidden, "Kein Zugriff auf diese Datei") + return + } + + if contentType == "" { + contentType = "application/octet-stream" + } + + dispositionType := "attachment" + if strings.HasPrefix(strings.ToLower(contentType), "image/") { + dispositionType = "inline" + } + disposition := mime.FormatMediaType(dispositionType, map[string]string{ + "filename": fileName, + }) + w.Header().Set("Content-Type", contentType) + w.Header().Set("Content-Disposition", disposition) + w.Header().Set("Content-Length", fmt.Sprintf("%d", len(data))) + w.WriteHeader(http.StatusOK) + _, _ = w.Write(data) +} diff --git a/backend/chat_link_preview.go b/backend/chat_link_preview.go new file mode 100644 index 0000000..c3e2139 --- /dev/null +++ b/backend/chat_link_preview.go @@ -0,0 +1,361 @@ +package main + +import ( + "context" + "errors" + "html" + "io" + "net" + "net/http" + "net/url" + "regexp" + "strings" + "syscall" + "time" + + "github.com/jackc/pgx/v5" +) + +const ( + maxLinkPreviewBytes = 512 * 1024 + linkPreviewUserAgent = "Mozilla/5.0 (compatible; TEG-LinkPreview/1.0)" +) + +var ( + errLinkPreviewBlocked = errors.New("zieladresse ist nicht erlaubt") + + chatURLPattern = regexp.MustCompile(`https?://[^\s<>"']+`) + metaTagPattern = regexp.MustCompile(`(?is)]*>`) + metaAttrPattern = regexp.MustCompile(`(?is)([a-zA-Z:_-]+)\s*=\s*"([^"]*)"|([a-zA-Z:_-]+)\s*=\s*'([^']*)'`) + titleTagPattern = regexp.MustCompile(`(?is)
+ {emptyLabel} +
+ ) + } + + return ( ++ {entry.message} +
+ + {showPath && entry.path && ( ++ {entry.path} +
+ )} + + {stack && ( +
+ {stack}
+
+ )}
+ - Teile uns mit, wenn dir etwas auffällt, etwas nicht funktioniert oder du eine Idee zur Verbesserung hast. - Beim Absenden wird automatisch ein technisches Log mit deinem Benutzerkonto angehängt. -
+ return ( ++ Fällt dir etwas auf, funktioniert etwas nicht oder hast du eine + Idee? Beim Absenden wird automatisch das unten sichtbare Protokoll + angehängt. +
++ Dein Feedback wurde zusammen mit dem Protokoll gespeichert. Unser + Team schaut es sich an. +
++ Deine letzten Aktionen und Fehler in dieser Sitzung – hilft + uns, Probleme nachzuvollziehen. +
++ Notizen und wichtige Informationen nachvollziehbar festhalten. +
+- {emptyText} -
++ Noch keine Aktivitäten +
++ {emptyText} +
+