diff --git a/frontend/src/components/schedule_overlap/ScheduleOverlap.vue b/frontend/src/components/schedule_overlap/ScheduleOverlap.vue index b804872b..5e9d6335 100644 --- a/frontend/src/components/schedule_overlap/ScheduleOverlap.vue +++ b/frontend/src/components/schedule_overlap/ScheduleOverlap.vue @@ -2455,9 +2455,15 @@ export default { if (!timeMin || !timeMax) return // Fetch responses between timeMin and timeMax - const url = `/events/${ + let url = `/events/${ this.event._id }/responses?timeMin=${timeMin.toISOString()}&timeMax=${timeMax.toISOString()}` + + // Add guestName query parameter if user is a guest + if (this.guestName && this.guestName.length > 0) { + url += `&guestName=${encodeURIComponent(this.guestName)}` + } + get(url) .then((responses) => { this.fetchedResponses = responses @@ -2850,7 +2856,12 @@ export default { payload.guest = true payload.name = guestPayload.name payload.email = guestPayload.email + // Store with event._id (current format used by guestNameKey) localStorage[this.guestNameKey] = guestPayload.name + // Also store with shortId or _id (to match eventId prop format used in Event.vue) + // This allows refreshEvent() to read it immediately without needing event._id first + const eventIdKey = `${this.event.shortId ?? this.event._id}.guestName` + localStorage[eventIdKey] = guestPayload.name } } @@ -3515,7 +3526,11 @@ export default { oldName: this.curGuestId, newName, }) + // Store with event._id (current format used by guestNameKey) localStorage[this.guestNameKey] = newName + // Also store with shortId or _id (to match eventId prop format used in Event.vue) + const eventIdKey = `${this.event.shortId ?? this.event._id}.guestName` + localStorage[eventIdKey] = newName this.showInfo("Guest name updated successfully") this.editGuestNameDialog = false this.$emit("setCurGuestId", newName) diff --git a/frontend/src/views/Event.vue b/frontend/src/views/Event.vue index 12454ba2..cf26336c 100644 --- a/frontend/src/views/Event.vue +++ b/frontend/src/views/Event.vue @@ -650,8 +650,44 @@ export default { /** Refresh event details */ async refreshEvent() { let sanitizedId = this.eventId.replaceAll(".", "") - this.event = await get(`/events/${sanitizedId}`) + + // Try to get guest name from localStorage using eventId first (available immediately) + // This works because ScheduleOverlap now stores guest name with both keys + let guestName = null + if (typeof localStorage !== "undefined") { + // Try with eventId first (matches the key format used when storing in ScheduleOverlap) + const guestNameKeyFromEventId = `${sanitizedId}.guestName` + guestName = localStorage[guestNameKeyFromEventId] + + // If not found and event is already loaded, try with event._id (for backward compatibility) + if (!guestName && this.event?._id) { + const guestNameKeyFromEventId2 = `${this.event._id}.guestName` + guestName = localStorage[guestNameKeyFromEventId2] + } + } + + // Build URL with guestName if available + let url = `/events/${sanitizedId}` + if (guestName && guestName.length > 0) { + url += `?guestName=${encodeURIComponent(guestName)}` + } + + // Make single request with guestName if available + this.event = await get(url) processEvent(this.event) + + // After loading event, if we didn't have guestName before but now we have event._id, + // check localStorage one more time with the correct _id format (unlikely this code below will execute but have it for safety) + if (!guestName && this.event?._id && typeof localStorage !== "undefined") { + const guestNameKey = `${this.event._id}.guestName` + const foundGuestName = localStorage[guestNameKey] + if (foundGuestName && foundGuestName.length > 0) { + // Make one more request with the correct guestName + url = `/events/${sanitizedId}?guestName=${encodeURIComponent(foundGuestName)}` + this.event = await get(url) + processEvent(this.event) + } + } }, setAvailabilityAutomatically(calendarType = calendarTypes.GOOGLE) { @@ -1055,9 +1091,27 @@ export default { // Validation: Check timeIncrement exists, default to 15 if not const timeIncrement = this.event.timeIncrement ?? 15 - // Check if guestName is provided in payload - if so, force guest mode + // Security check: If blindAvailabilityEnabled is true and user is NOT the owner, + // reject any request with guestName parameter const payloadGuestName = event.data?.payload?.guestName - const forceGuestMode = payloadGuestName && payloadGuestName.length > 0 + const hasGuestName = payloadGuestName && payloadGuestName.length > 0 + + if (this.event.blindAvailabilityEnabled) { + // Check if user is owner: ownerId is only returned by backend if user is the owner + // So if ownerId exists and matches current user's ID, they are the owner + const isOwner = this.event.ownerId && this.authUser?._id === this.event.ownerId + if (!isOwner && hasGuestName) { + sendPluginError( + requestId, + command, + "Non-owners cannot set guest availability when 'Hide responses from respondents' is enabled." + ) + return + } + } + + // Check if guestName is provided in payload - if so, force guest mode + const forceGuestMode = hasGuestName // Determine if current user is guest or logged-in // If guestName is provided in payload, always treat as guest (ignore login status) @@ -1072,7 +1126,11 @@ export default { if (forceGuestMode) { // guestName provided in payload - use it and store in localStorage guestName = payloadGuestName + // Store with event._id (current format) localStorage[guestNameKey] = guestName + // Also store with shortId or _id (to match eventId prop format) + const eventIdKey = `${this.event.shortId ?? this.event._id}.guestName` + localStorage[eventIdKey] = guestName // If event collects emails, require guestEmail in payload if (this.event.collectEmails) { @@ -1470,7 +1528,28 @@ export default { try { // Fetch responses between timeMin and timeMax - const url = `/events/${sanitizedId}/responses?timeMin=${timeMin.toISOString()}&timeMax=${timeMax.toISOString()}` + //TODO: update this with the new getResponses model + + // Try to get guest name from localStorage + let guestName = null + if (typeof localStorage !== "undefined") { + // Try with eventId first (matches the key format used when storing in ScheduleOverlap) + const guestNameKeyFromEventId = `${sanitizedId}.guestName` + guestName = localStorage[guestNameKeyFromEventId] + + // If not found and event is already loaded, try with event._id (for backward compatibility) + if (!guestName && this.event?._id) { + const guestNameKeyFromEventId2 = `${this.event._id}.guestName` + guestName = localStorage[guestNameKeyFromEventId2] + } + } + + // Build URL with guestName if available + let url = `/events/${sanitizedId}/responses?timeMin=${timeMin.toISOString()}&timeMax=${timeMax.toISOString()}` + if (guestName && guestName.length > 0) { + url += `&guestName=${encodeURIComponent(guestName)}` + } + const responses = await get(url) // Build response object with all users' slots diff --git a/server/routes/events.go b/server/routes/events.go index c950083a..beb573f1 100644 --- a/server/routes/events.go +++ b/server/routes/events.go @@ -3,6 +3,7 @@ package routes import ( "context" + "encoding/json" //TODO: remove this before committing "fmt" "net/http" "time" @@ -522,8 +523,82 @@ func getEvent(c *gin.Context) { event.Attendees = &attendees } - // Create a copy of the event with responses in map format - c.JSON(http.StatusOK, event) + // Update event.ResponsesMap to match the final responsesMap + event.ResponsesMap = responsesMap + + // Apply privacy logic based on blindAvailabilityEnabled + if !utils.Coalesce(event.BlindAvailabilityEnabled) { + // Blind availability is NOT enabled - return response as-is + // Log response body + responseJSON, err := json.MarshalIndent(event, "", " ") + if err != nil { + logger.StdErr.Printf("Failed to marshal event for logging: %v\n", err) + } + _ = responseJSON + c.JSON(http.StatusOK, event) + return + } + + // Blind availability IS enabled - apply privacy filtering + ownerSesh := event.OwnerId.Hex() + session := sessions.Default(c) + userIdInterface := session.Get("userId") + var userSesh string + if userIdInterface != nil { + userSesh = userIdInterface.(string) + } + guestName := c.Query("guestName") + + var privatizedResponse map[string]interface{} + var err error + + if userSesh != "" { + // User session exists (user is logged in) + if ownerSesh == userSesh { + // User is the owner - return response as-is + privatizedResponse, err = utils.PrivatizeEventResponse(event, []string{}, []utils.PartialOmission{}) + } else { + // User is NOT the owner - privatize response + privateFields := []string{"ownerId", "numResponses"} + partialOmissions := []utils.PartialOmission{ + { + FieldName: "responses", + KeepKey: userSesh, + }, + } + privatizedResponse, err = utils.PrivatizeEventResponse(event, privateFields, partialOmissions) + } + } else if guestName != "" { + // Guest name query parameter exists + privateFields := []string{"ownerId", "numResponses"} + partialOmissions := []utils.PartialOmission{ + { + FieldName: "responses", + KeepKey: guestName, + }, + } + privatizedResponse, err = utils.PrivatizeEventResponse(event, privateFields, partialOmissions) + } else { + // No session, no guest name - remove all private fields + privateFields := []string{"ownerId", "numResponses", "responses", "remindees"} + privatizedResponse, err = utils.PrivatizeEventResponse(event, privateFields, []utils.PartialOmission{}) + } + + if err != nil { + logger.StdErr.Printf("Failed to privatize event response: %v\n", err) + // Fall back to returning the original event if privatization fails + c.JSON(http.StatusOK, event) + return + } + + // Log response body + responseJSON, err := json.MarshalIndent(privatizedResponse, "", " ") + if err != nil { + logger.StdErr.Printf("Failed to marshal privatized response for logging: %v\n", err) + } + _ = responseJSON + // Return the privatized response + c.JSON(http.StatusOK, privatizedResponse) } // @Summary Gets responses for an event, filtering availability to be within the date ranges @@ -584,7 +659,52 @@ func getResponses(c *gin.Context) { responsesMap[userId] = response } - c.JSON(http.StatusOK, responsesMap) + + + // Apply privacy logic based on blindAvailabilityEnabled + if !utils.Coalesce(event.BlindAvailabilityEnabled) { + // Blind availability is NOT enabled - return response as-is + c.JSON(http.StatusOK, responsesMap) + return + } + + // Blind availability IS enabled - apply privacy filtering + ownerSesh := event.OwnerId.Hex() + session := sessions.Default(c) + userIdInterface := session.Get("userId") + var userSesh string + if userIdInterface != nil { + userSesh = userIdInterface.(string) + } + guestName := c.Query("guestName") + if userSesh != "" { + // User session exists (user is logged in) + if ownerSesh == userSesh { + // User is the owner - return response as-is + c.JSON(http.StatusOK, responsesMap) + return + } else { + // User is NOT the owner - return only their own response + filteredMap := make(map[string]*models.Response) + if userResponse, exists := responsesMap[userSesh]; exists { + filteredMap[userSesh] = userResponse + } + c.JSON(http.StatusOK, filteredMap) + return + } + } else if guestName != "" { + // Guest name query parameter exists - return only that guest's response + filteredMap := make(map[string]*models.Response) + if guestResponse, exists := responsesMap[guestName]; exists { + filteredMap[guestName] = guestResponse + } + c.JSON(http.StatusOK, filteredMap) + return + } else { + // No session, no guest name - return empty map + c.JSON(http.StatusOK, make(map[string]*models.Response)) + return + } } // @Summary Updates the current user's availability @@ -624,6 +744,26 @@ func updateEventResponse(c *gin.Context) { c.JSON(http.StatusNotFound, responses.Error{Error: errs.EventNotFound}) return } + + // Security check: If blindAvailabilityEnabled is true, non-owners cannot set guest availability + //NOTE: this ONLY stops a user from setting guest availability from their account (via setSlots), somebody could still + // go on incognito and set guest availability. + if utils.Coalesce(event.BlindAvailabilityEnabled) { + ownerSesh := event.OwnerId.Hex() + userIdInterface := session.Get("userId") + var userSesh string + if userIdInterface != nil { + userSesh = userIdInterface.(string) + } + + // If user is logged in and NOT the owner, and they're trying to set guest availability, block it + if userSesh != "" && ownerSesh != userSesh && *payload.Guest { + c.JSON(http.StatusForbidden, responses.Error{Error: errs.UserNotEventOwner}) + c.Abort() + return + } + } + eventResponses := db.GetEventResponses(event.Id.Hex()) var userIdString string diff --git a/server/utils/response_utils.go b/server/utils/response_utils.go new file mode 100644 index 00000000..12f548c8 --- /dev/null +++ b/server/utils/response_utils.go @@ -0,0 +1,129 @@ +package utils + +import ( + "encoding/json" + "reflect" +) + +// PartialOmission represents a field that should be partially omitted +// FieldName is the name of the field to partially omit +// KeepKey is the key within that field to keep (all other keys will be removed) +type PartialOmission struct { + FieldName string + KeepKey string +} + +// getZeroValue returns the zero value for a given type +func getZeroValue(value interface{}) interface{} { + if value == nil { + return nil + } + + // Use reflection to determine the type + v := reflect.ValueOf(value) + switch v.Kind() { + case reflect.String: + return "" + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return 0 + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return 0 + case reflect.Float32, reflect.Float64: + return 0.0 + case reflect.Bool: + return false + case reflect.Map, reflect.Struct: + return make(map[string]interface{}) + case reflect.Slice, reflect.Array: + return []interface{}{} + case reflect.Ptr: + return nil + default: + // For unknown types, try to infer from the actual value + switch value.(type) { + case string: + return "" + case int, int8, int16, int32, int64: + return 0 + case uint, uint8, uint16, uint32, uint64: + return 0 + case float32, float64: + return 0.0 + case bool: + return false + case map[string]interface{}: + return make(map[string]interface{}) + case []interface{}: + return []interface{}{} + default: + return nil + } + } +} + +// PrivatizeResponse sets specified fields to their zero values in a response body and handles partial omissions +// responseBody: The response body as a map[string]interface{} +// omitFields: Array of field names to set to their zero values in the response +// partialOmissions: Array of PartialOmission structs for fields that should be partially omitted +// Returns: A new map with the specified fields set to their zero values +func PrivatizeResponse(responseBody map[string]interface{}, omitFields []string, partialOmissions []PartialOmission) map[string]interface{} { + // Create a deep copy of the response body + result := make(map[string]interface{}) + for k, v := range responseBody { + result[k] = v + } + + // Set omitted fields to their zero values + for _, field := range omitFields { + if originalValue, exists := result[field]; exists { + result[field] = getZeroValue(originalValue) + } else { + // Field doesn't exist, set to empty map as default + result[field] = make(map[string]interface{}) + } + } + + // Handle partial omissions + for _, partial := range partialOmissions { + if fieldValue, exists := result[partial.FieldName]; exists { + // Check if the field is a map + if fieldMap, ok := fieldValue.(map[string]interface{}); ok { + // Keep only the specified key + if keepValue, keyExists := fieldMap[partial.KeepKey]; keyExists { + result[partial.FieldName] = map[string]interface{}{ + partial.KeepKey: keepValue, + } + } else { + // Key doesn't exist, set the entire field to its zero value + if originalValue, exists := result[partial.FieldName]; exists { + result[partial.FieldName] = getZeroValue(originalValue) + } else { + result[partial.FieldName] = make(map[string]interface{}) + } + } + } + } + } + + return result +} + +// PrivatizeEventResponse is a convenience function that converts an Event struct to a map, +// applies privatization, and returns the result +func PrivatizeEventResponse(event interface{}, omitFields []string, partialOmissions []PartialOmission) (map[string]interface{}, error) { + // Convert event to JSON and then to map + eventJSON, err := json.Marshal(event) + if err != nil { + return nil, err + } + + var eventMap map[string]interface{} + if err := json.Unmarshal(eventJSON, &eventMap); err != nil { + return nil, err + } + + // Apply privatization + privatized := PrivatizeResponse(eventMap, omitFields, partialOmissions) + return privatized, nil +} +