Pārlūkot izejas kodu

Reservations prosody plugin (#8386)

* Added mod_reservations prosody plugin

* Removed comments re mutex

* Add support for HTTP retries and expose config to tweak retry behaviour

* Removed TODO comment. Feature implemented

* Added multi-tenant support

* renamed config var and default to always including tenant name in name field

* Simplified handling of multi-tenant

* Fixed bug with DELETE not called on reservation expiry

* fix: Fixes destroying room.

Co-authored-by: damencho <damencho@jitsi.org>
j8
Shawn Chin 4 gadus atpakaļ
vecāks
revīzija
cff4ed83f6
Revīzijas autora e-pasta adrese nav piesaistīta nevienam kontam
1 mainītis faili ar 591 papildinājumiem un 0 dzēšanām
  1. 591
    0
      resources/prosody-plugins/mod_reservations.lua

+ 591
- 0
resources/prosody-plugins/mod_reservations.lua Parādīt failu

@@ -0,0 +1,591 @@
1
+--- This is a port of Jicofo's Reservation System as a prosody module
2
+--  ref: https://github.com/jitsi/jicofo/blob/master/doc/reservation.md
3
+--
4
+--  We try to retain the same behaviour and interfaces where possible, but there
5
+--  is some difference:
6
+--    * In the event that the DELETE call fails, Jicofo's reservation
7
+--      system retains reservation data and allows re-creation of room if requested by
8
+--      the same creator without making further call to the API; this module does not
9
+--      offer this behaviour. Re-creation of a closed room will behave like a new meeting
10
+--      and trigger a new API call to validate the reservation.
11
+--    * Jicofo's reservation system expect int-based conflict_id. We take any sensible string.
12
+--
13
+--  In broad strokes, this module works by intercepting Conference IQs sent to focus component
14
+--  and buffers it until reservation is confirmed (by calling the provided API endpoint).
15
+--  The IQ events are routed on to focus component if reservation is valid, or error
16
+--  response is sent back to the origin if reservation is denied. Events are routed as usual
17
+--  if the room already exists.
18
+--
19
+--
20
+--  Installation:
21
+--  =============
22
+--
23
+--  Under domain config,
24
+--   1. add "reservations" to modules_enabled.
25
+--   2. Specify URL base for your API endpoint using "reservations_api_prefix" (required)
26
+--   3. Optional config:
27
+--      * set "reservations_api_timeout" to change API call timeouts (defaults to 20 seconds)
28
+--      * set "reservations_api_headers" to specify custom HTTP headers included in
29
+--        all API calls e.g. to provide auth tokens.
30
+--      * set "reservations_api_retry_count" to the number of times API call failures are retried (defaults to 3)
31
+--      * set "reservations_api_retry_delay" seconds to wait between retries (defaults to 3s)
32
+--      * set "reservations_api_should_retry_for_code" to a function that takes an HTTP response code and
33
+--        returns true if API call should be retried. By default, retries are done for 5XX
34
+--        responses. Timeouts are never retried, and HTTP call failures are always retried.
35
+--
36
+--
37
+--  Example config:
38
+--
39
+--    VirtualHost "jitmeet.example.com"
40
+--        -- ....
41
+--        modules_enabled = {
42
+--            -- ....
43
+--            "reservations";
44
+--        }
45
+--        reservations_api_prefix = "http://reservation.example.com"
46
+--
47
+--        --- The following are all optional
48
+--        reservations_api_headers = {
49
+--            ["Authorization"] = "Bearer TOKEN-237958623045";
50
+--        }
51
+--        reservations_api_timeout = 10  -- timeout if API does not respond within 10s
52
+--        reservations_api_retry_count = 5  -- retry up to 5 times
53
+--        reservations_api_retry_delay = 1  -- wait 1s between retries
54
+--        reservations_api_should_retry_for_code = function (code)
55
+--            return code >= 500 or code == 408
56
+--        end
57
+--
58
+
59
+
60
+local jid = require 'util.jid';
61
+local http = require "net.http";
62
+local json = require "util.json";
63
+local st = require "util.stanza";
64
+local timer = require 'util.timer';
65
+local datetime = require 'util.datetime';
66
+
67
+local get_room_from_jid = module:require "util".get_room_from_jid;
68
+local is_healthcheck_room = module:require "util".is_healthcheck_room;
69
+local room_jid_match_rewrite = module:require "util".room_jid_match_rewrite;
70
+
71
+local api_prefix = module:get_option("reservations_api_prefix");
72
+local api_headers = module:get_option("reservations_api_headers");
73
+local api_timeout = module:get_option("reservations_api_timeout", 20);
74
+local api_retry_count = tonumber(module:get_option("reservations_api_retry_count", 3));
75
+local api_retry_delay = tonumber(module:get_option("reservations_api_retry_delay", 3));
76
+
77
+
78
+-- Option for user to control HTTP response codes that will result in a retry.
79
+-- Defaults to returning true on any 5XX code or 0
80
+local api_should_retry_for_code = module:get_option("reservations_api_should_retry_for_code", function (code)
81
+   return code >= 500;
82
+end)
83
+
84
+
85
+local muc_component_host = module:get_option_string("main_muc");
86
+
87
+-- How often to check and evict expired reservation data
88
+local expiry_check_period = 60;
89
+
90
+
91
+-- Cannot proceed if "reservations_api_prefix" not configured
92
+if not api_prefix then
93
+    module:log("error", "reservations_api_prefix not specified. Disabling %s", module:get_name());
94
+    return;
95
+end
96
+
97
+
98
+-- get/infer focus component hostname so we can intercept IQ bound for it
99
+local focus_component_host = module:get_option_string("focus_component");
100
+if not focus_component_host then
101
+    local muc_domain_base = module:get_option_string("muc_mapper_domain_base");
102
+    if not muc_domain_base then
103
+        module:log("error", "Could not infer focus domain. Disabling %s", module:get_name());
104
+        return;
105
+    end
106
+    focus_component_host = 'focus.'..muc_domain_base;
107
+end
108
+
109
+-- common HTTP headers added to all API calls
110
+local http_headers = {
111
+    ["User-Agent"] = "Prosody ("..prosody.version.."; "..prosody.platform..")";
112
+};
113
+if api_headers then -- extra headers from config
114
+    for key, value in pairs(api_headers) do
115
+       http_headers[key] = value;
116
+    end
117
+end
118
+
119
+
120
+--- Utils
121
+
122
+--- Converts int timestamp to datetime string compatible with Java SimpleDateFormat
123
+-- @param t timestamps in seconds. Supports int (as returned by os.time()) or higher
124
+--          precision (as returned by socket.gettime())
125
+-- @return formatted datetime string (yyyy-MM-dd'T'HH:mm:ss.SSSX)
126
+local function to_java_date_string(t)
127
+    local t_secs, mantissa = math.modf(t);
128
+    local ms_str = (mantissa == 0) and '.000' or tostring(mantissa):sub(2,5);
129
+    local date_str = os.date("!%Y-%m-%dT%H:%M:%S", t_secs);
130
+    return date_str..ms_str..'Z';
131
+end
132
+
133
+
134
+--- Start non-blocking HTTP call
135
+-- @param url URL to call
136
+-- @param options options table as expected by net.http where we provide optional headers, body or method.
137
+-- @param callback if provided, called with callback(response_body, response_code) when call complete.
138
+-- @param timeout_callback if provided, called without args when request times out.
139
+-- @param retries how many times to retry on failure; 0 means no retries.
140
+local function async_http_request(url, options, callback, timeout_callback, retries)
141
+    local completed = false;
142
+    local timed_out = false;
143
+    local retries = retries or api_retry_count;
144
+
145
+    local function cb_(response_body, response_code)
146
+        if not timed_out then  -- request completed before timeout
147
+            completed = true;
148
+            if (response_code == 0 or api_should_retry_for_code(response_code)) and retries > 0 then
149
+                module:log("warn", "API Response code %d. Will retry after %ds", response_code, api_retry_delay);
150
+                timer.add_task(api_retry_delay, function()
151
+                    async_http_request(url, options, callback, timeout_callback, retries - 1)
152
+                end)
153
+                return;
154
+            end
155
+
156
+            if callback then
157
+                callback(response_body, response_code)
158
+            end
159
+        end
160
+    end
161
+
162
+    local request = http.request(url, options, cb_);
163
+
164
+    timer.add_task(api_timeout, function ()
165
+        timed_out = true;
166
+
167
+        if not completed then
168
+            http.destroy_request(request);
169
+            if timeout_callback then
170
+                timeout_callback()
171
+            end
172
+        end
173
+    end);
174
+
175
+end
176
+
177
+--- Returns current timestamp
178
+local function now()
179
+    -- Don't really need higher precision of socket.gettime(). Besides, we loose
180
+    -- milliseconds precision when converting back to timestamp from date string
181
+    -- when we use datetime.parse(t), so let's be consistent.
182
+    return os.time();
183
+end
184
+
185
+--- Start RoomReservation implementation
186
+
187
+-- Status enums used in RoomReservation:meta.status
188
+local STATUS = {
189
+    PENDING = 0;
190
+    SUCCESS = 1;
191
+    FAILED  = -1;
192
+}
193
+
194
+local RoomReservation = {};
195
+RoomReservation.__index = RoomReservation;
196
+
197
+function newRoomReservation(room_jid, creator_jid)
198
+    return setmetatable({
199
+        room_jid = room_jid;
200
+
201
+        -- Reservation metadata. store as table so we can set and read atomically.
202
+        -- N.B. This should always be updated using self.set_status_*
203
+        meta = {
204
+            status = STATUS.PENDING;
205
+            mail_owner = jid.bare(creator_jid);
206
+            conflict_id = nil;
207
+            start_time = now();  -- timestamp, in seconds
208
+            expires_at = nil;  -- timestamp, in seconds
209
+            error_text = nil;
210
+            error_code = nil;
211
+        };
212
+
213
+        -- Array of pending events that we need to route once API call is complete
214
+        pending_events = {};
215
+
216
+        -- Set true when API call trigger has been triggered (by enqueue of first event)
217
+        api_call_triggered = false;
218
+    }, RoomReservation);
219
+end
220
+
221
+
222
+--- Extracts room name from room jid
223
+function RoomReservation:get_room_name()
224
+    return jid.node(self.room_jid);
225
+end
226
+
227
+--- Checks if reservation data is expires and should be evicted from store
228
+function RoomReservation:is_expired()
229
+    return self.meta.expires_at ~= nil and now() > self.meta.expires_at;
230
+end
231
+
232
+--- Main entry point for handing and routing events.
233
+function RoomReservation:enqueue_or_route_event(event)
234
+    if self.meta.status == STATUS.PENDING then
235
+        table.insert(self.pending_events, event)
236
+        if not self.api_call_triggered == true then
237
+            self:call_api_create_conference();
238
+        end
239
+    else
240
+        -- API call already complete. Immediately route without enqueueing.
241
+        -- This could happen if request comes in between the time reservation approved
242
+        -- and when Jicofo actually creates the room.
243
+        module:log("debug", "Reservation details already stored. Skipping queue for %s", self.room_jid);
244
+        self:route_event(event);
245
+    end
246
+end
247
+
248
+--- Updates status and initiates event routing. Called internally when API call complete.
249
+function RoomReservation:set_status_success(start_time, duration, mail_owner, conflict_id)
250
+    module:log("info", "Reservation created successfully for %s", self.room_jid);
251
+    self.meta = {
252
+        status = STATUS.SUCCESS;
253
+        mail_owner = mail_owner or self.meta.mail_owner;
254
+        conflict_id = conflict_id;
255
+        start_time = start_time;
256
+        expires_at = start_time + duration;
257
+        error_text = nil;
258
+        error_code = nil;
259
+    }
260
+    self:route_pending_events()
261
+end
262
+
263
+--- Updates status and initiates error response to pending events. Called internally when API call complete.
264
+function RoomReservation:set_status_failed(error_code, error_text)
265
+    module:log("info", "Reservation creation failed for %s - (%s) %s", self.room_jid, error_code, error_text);
266
+    self.meta = {
267
+        status = STATUS.FAILED;
268
+        mail_owner = self.meta.mail_owner;
269
+        conflict_id = nil;
270
+        start_time = self.meta.start_time;
271
+        -- Retain reservation rejection for a short while so we have time to report failure to
272
+        -- existing clients and not trigger a re-query too soon.
273
+        -- N.B. Expiry could take longer since eviction happens periodically.
274
+        expires_at = now() + 30;
275
+        error_text = error_text;
276
+        error_code = error_code;
277
+    }
278
+    self:route_pending_events()
279
+end
280
+
281
+--- Triggers routing of all enqueued events
282
+function RoomReservation:route_pending_events()
283
+    if self.meta.status == STATUS.PENDING then  -- should never be called while PENDING. check just in case.
284
+        return;
285
+    end
286
+
287
+    module:log("debug", "Routing all pending events for %s", self.room_jid);
288
+    local event;
289
+
290
+    while #self.pending_events ~= 0 do
291
+        event = table.remove(self.pending_events);
292
+        self:route_event(event)
293
+    end
294
+end
295
+
296
+--- Event routing implementation
297
+function RoomReservation:route_event(event)
298
+    -- this should only be called after API call complete and status no longer PENDING
299
+    assert(self.meta.status ~= STATUS.PENDING, "Attempting to route event while API call still PENDING")
300
+
301
+    local meta = self.meta;
302
+    local origin, stanza = event.origin, event.stanza;
303
+
304
+    if meta.status == STATUS.FAILED then
305
+        module:log("debug", "Route: Sending reservation error to %s", stanza.attr.from);
306
+        self:reply_with_error(event, meta.error_code, meta.error_text);
307
+    else
308
+        if meta.status == STATUS.SUCCESS then
309
+            if self:is_expired() then
310
+                module:log("debug", "Route: Sending reservation expiry to %s", stanza.attr.from);
311
+                self:reply_with_error(event, 419, "Reservation expired");
312
+            else
313
+                module:log("debug", "Route: Forwarding on event from %s", stanza.attr.from);
314
+                prosody.core_post_stanza(origin, stanza, false); -- route iq to intended target (focus)
315
+            end
316
+        else
317
+            -- this should never happen unless dev made a mistake. Block by default just in case.
318
+            module:log("error", "Reservation for %s has invalid state %s. Rejecting request.", self.room_jid, meta.status);
319
+            self:reply_with_error(event, 500, "Failed to determine reservation state");
320
+        end
321
+    end
322
+end
323
+
324
+--- Generates reservation-error stanza and sends to event origin.
325
+function RoomReservation:reply_with_error(event, error_code, error_text)
326
+    local stanza = event.stanza;
327
+    local id = stanza.attr.id;
328
+    local to = stanza.attr.from;
329
+    local from = stanza.attr.to;
330
+
331
+    event.origin.send(
332
+        st.iq({ type="error", to=to, from=from, id=id })
333
+            :tag("error", { type="cancel" })
334
+                :tag("service-unavailable", { xmlns="urn:ietf:params:xml:ns:xmpp-stanzas" }):up()
335
+                :tag("text", { xmlns="urn:ietf:params:xml:ns:xmpp-stanzas" }):text(error_text):up()
336
+                :tag("reservation-error", { xmlns="http://jitsi.org/protocol/focus", ["error-code"]=tostring(error_code) })
337
+    );
338
+end
339
+
340
+--- Initiates non-blocking API call to validate reservation
341
+function RoomReservation:call_api_create_conference()
342
+    self.api_call_triggered = true;
343
+
344
+    local url = api_prefix..'/conference';
345
+    local request_data = {
346
+        name = self:get_room_name();
347
+        start_time = to_java_date_string(self.meta.start_time);
348
+        mail_owner = self.meta.mail_owner;
349
+    }
350
+
351
+    local http_options = {
352
+        body = http.formencode(request_data);  -- because Jicofo reservation encodes as form data instead JSON
353
+        method = 'POST';
354
+        headers = http_headers;
355
+    }
356
+
357
+    module:log("debug", "Sending POST /conference for %s", self.room_jid);
358
+    async_http_request(url, http_options, function (response_body, response_code)
359
+        self:on_api_create_conference_complete(response_body, response_code);
360
+    end, function ()
361
+        self:on_api_call_timeout();
362
+    end);
363
+end
364
+
365
+--- Parses and validates HTTP response body for conference payload
366
+--  Ref: https://github.com/jitsi/jicofo/blob/master/doc/reservation.md
367
+--  @return nil if invalid, or table with keys "id", "name", "mail_owner", "start_time", "duration".
368
+function RoomReservation:parse_conference_response(response_body)
369
+    local data = json.decode(response_body);
370
+
371
+    if data == nil then  -- invalid JSON payload
372
+        module:log("error", "Invalid JSON response from API - %s", response_body);
373
+        return;
374
+    end
375
+
376
+    if data.name == nil or data.name:lower() ~= self:get_room_name() then
377
+        module:log("error", "Missing or mismathing room name - %s", data.name);
378
+        return;
379
+    end
380
+
381
+    if data.id == nil then
382
+        module:log("error", "Missing id");
383
+        return;
384
+    end
385
+
386
+    if data.mail_owner == nil then
387
+        module:log("error", "Missing mail_owner");
388
+        return;
389
+    end
390
+
391
+    local duration = tonumber(data.duration);
392
+    if duration == nil then
393
+        module:log("error", "Missing or invalid duration - %s", data.duration);
394
+        return;
395
+    end
396
+    data.duration = duration;
397
+
398
+    local start_time = datetime.parse(data.start_time);  -- N.B. we lose milliseconds portion of the date
399
+    if start_time == nil then
400
+        module:log("error", "Missing or invalid start_time - %s", data.start_time);
401
+        return;
402
+    end
403
+    data.start_time = start_time;
404
+
405
+    return data;
406
+end
407
+
408
+--- Parses and validates HTTP error response body for API call.
409
+--  Expect JSON with a "message" field.
410
+--  @return message string, or generic error message if invalid payload.
411
+function RoomReservation:parse_error_message_from_response(response_body)
412
+    local data = json.decode(response_body);
413
+    if data ~= nil and data.message ~= nil then
414
+        module:log("debug", "Invalid error response body. Will use generic error message.");
415
+        return data.message;
416
+    else
417
+        return "Rejected by reservation server";
418
+    end
419
+end
420
+
421
+--- callback on API timeout
422
+function RoomReservation:on_api_call_timeout()
423
+    self:set_status_failed(500, 'Reservation lookup timed out');
424
+end
425
+
426
+--- callback on API response
427
+function RoomReservation:on_api_create_conference_complete(response_body, response_code)
428
+    if response_code == 200 or response_code == 201 then
429
+        self:handler_conference_data_returned_from_api(response_body);
430
+    elseif response_code == 409 then
431
+        self:handle_conference_already_exist(response_body);
432
+    elseif response_code == nil then  -- warrants a retry, but this should be done automatically by the http call method.
433
+        self:set_status_failed(500, 'Could not contact reservation server');
434
+    else
435
+        self:set_status_failed(response_code, self:parse_error_message_from_response(response_body));
436
+    end
437
+end
438
+
439
+function RoomReservation:handler_conference_data_returned_from_api(response_body)
440
+    local data = self:parse_conference_response(response_body);
441
+    if not data then  -- invalid response from API
442
+        module:log("error", "API returned success code but invalid payload");
443
+        self:set_status_failed(500, 'Invalid response from reservation server');
444
+    else
445
+        self:set_status_success(data.start_time, data.duration, data.mail_owner, data.id)
446
+    end
447
+end
448
+
449
+function RoomReservation:handle_conference_already_exist(response_body)
450
+    local data = json.decode(response_body);
451
+    if data == nil or data.conflict_id == nil then
452
+        -- yes, in the case of 409, API expected to return "id" as "conflict_id".
453
+        self:set_status_failed(409, 'Invalid response from reservation server');
454
+    else
455
+        local url = api_prefix..'/conference/'..data.conflict_id;
456
+        local http_options = {
457
+            method = 'GET';
458
+            headers = http_headers;
459
+        }
460
+
461
+        async_http_request(url, http_options, function(response_body, response_code)
462
+            if response_code == 200 then
463
+                self:handler_conference_data_returned_from_api(response_body);
464
+            else
465
+                self:set_status_failed(response_code, self:parse_error_message_from_response(response_body));
466
+            end
467
+        end, function ()
468
+            self:on_api_call_timeout();
469
+        end);
470
+    end
471
+end
472
+
473
+--- End RoomReservation
474
+
475
+--- Store reservations lookups that are still pending or with room still active
476
+local reservations = {}
477
+
478
+local function get_or_create_reservations(room_jid, creator_jid)
479
+    if reservations[room_jid] == nil then
480
+        module:log("debug", "Creating new reservation data for %s", room_jid);
481
+        reservations[room_jid] = newRoomReservation(room_jid, creator_jid);
482
+    end
483
+
484
+    return reservations[room_jid];
485
+end
486
+
487
+local function evict_expired_reservations()
488
+    local expired = {}
489
+
490
+    -- first, gather jids of expired rooms. So we don't remove from table while iterating.
491
+    for room_jid, res in pairs(reservations) do
492
+        if res:is_expired() then
493
+            table.insert(expired, room_jid);
494
+        end
495
+    end
496
+
497
+    local room;
498
+    for _, room_jid in ipairs(expired) do
499
+        room = get_room_from_jid(room_jid);
500
+        if room then
501
+            -- Close room if still active (reservation duration exceeded)
502
+            module:log("info", "Room exceeded reservation duration. Terminating %s", room_jid);
503
+            room:destroy(nil, "Scheduled conference duration exceeded.");
504
+            -- Rely on room_destroyed to calls DELETE /conference and drops reservation[room_jid]
505
+        else
506
+            module:log("error", "Reservation references expired room that is no longer active. Dropping %s", room_jid);
507
+            -- This should not happen unless evict_expired_reservations somehow gets triggered
508
+            -- between the time room is destroyed and room_destroyed callback is called. (Possible?)
509
+            -- But just in case, we drop the reservation to avoid repeating this path on every pass.
510
+            reservations[room_jid] = nil;
511
+        end
512
+    end
513
+end
514
+
515
+timer.add_task(expiry_check_period, function()
516
+    evict_expired_reservations();
517
+    return expiry_check_period;
518
+end)
519
+
520
+
521
+--- Intercept conference IQ to Jicofo handle reservation checks before allowing normal event flow
522
+module:log("info", "Hook to global pre-iq/host");
523
+module:hook("pre-iq/host", function(event)
524
+    local stanza = event.stanza;
525
+
526
+    if stanza.name ~= "iq" or stanza.attr.to ~= focus_component_host or stanza.attr.type ~= 'set' then
527
+        return;  -- not IQ for jicofo. Ignore this event.
528
+    end
529
+
530
+    local conference = stanza:get_child('conference', 'http://jitsi.org/protocol/focus');
531
+    if conference == nil then
532
+        return; -- not Conference IQ. Ignore.
533
+    end
534
+
535
+    local room_jid = room_jid_match_rewrite(conference.attr.room);
536
+
537
+    if get_room_from_jid(room_jid) ~= nil then
538
+        module:log("debug", "Skip reservation check for existing room %s", room_jid);
539
+        return;  -- room already exists. Continue with normal flow
540
+    end
541
+
542
+    local res = get_or_create_reservations(room_jid, stanza.attr.from);
543
+    res:enqueue_or_route_event(event);  -- hand over to reservation obj to route event
544
+    return true;
545
+
546
+end);
547
+
548
+
549
+--- Forget reservation details once room destroyed so query is repeated if room re-created
550
+local function room_destroyed(event)
551
+    local res;
552
+    local room = event.room
553
+
554
+    if not is_healthcheck_room(room.jid) then
555
+        res = reservations[room.jid]
556
+
557
+        -- drop reservation data for this room
558
+        reservations[room.jid] = nil
559
+
560
+        if res then  -- just in case event triggered more than once?
561
+            module:log("info", "Dropped reservation data for destroyed room %s", room.jid);
562
+
563
+            local conflict_id = res.meta.conflict_id
564
+            if conflict_id then
565
+                local url = api_prefix..'/conference/'..conflict_id;
566
+                local http_options = {
567
+                    method = 'DELETE';
568
+                    headers = http_headers;
569
+                }
570
+
571
+                module:log("debug", "Sending DELETE /conference/%s", conflict_id);
572
+                async_http_request(url, http_options);
573
+            end
574
+        end
575
+    end
576
+end
577
+
578
+
579
+function process_host(host)
580
+    if host == muc_component_host then -- the conference muc component
581
+        module:log("info", "Hook to muc events on %s", host);
582
+        module:context(host):hook("muc-room-destroyed", room_destroyed, -1);
583
+    end
584
+end
585
+
586
+if prosody.hosts[muc_component_host] == nil then
587
+    module:log("info", "No muc component found, will listen for it: %s", muc_component_host)
588
+    prosody.events.add_handler("host-activated", process_host);
589
+else
590
+    process_host(muc_component_host);
591
+end

Notiek ielāde…
Atcelt
Saglabāt