summary refs log tree commit diff
diff options
context:
space:
mode:
authorsternenseemann <0rpkxez4ksa01gb3typccl0i@systemli.org>2020-12-08 00:33:04 +0100
committersternenseemann <0rpkxez4ksa01gb3typccl0i@systemli.org>2020-12-08 00:33:04 +0100
commita4baebce984d5ef4140950dcee277d9fcee9e709 (patch)
treec804d1e592ea8fbb98ad8438825683fa2a33062e
parentccc4f4454c505110751ad32379e8fd55dbbf6151 (diff)
feat(warteraum): implement announcement expiry announcement
* Accept optional expiry_utc parameter in announcement PUT requests
  describing the point an announcement is deleted at
* Check for expiry everytime some announcement related request is
  sent
* implement announcement logic in announcement.c
* implement http_string_t stroul separately in http_string.c
* document API changes
* reflect API changes in python client
-rw-r--r--README.adoc23
-rw-r--r--clients/py/flipdot_gschichtler/__init__.py23
-rw-r--r--warteraum/announcement.c62
-rw-r--r--warteraum/announcement.h25
-rw-r--r--warteraum/default.o.do6
-rw-r--r--warteraum/http_string.c29
-rw-r--r--warteraum/http_string.h4
-rw-r--r--warteraum/main.c133
-rwxr-xr-xwarteraum/test/run2
-rwxr-xr-xwarteraum/test/test_integration.py27
-rw-r--r--warteraum/warteraum.do2
11 files changed, 280 insertions, 56 deletions
diff --git a/README.adoc b/README.adoc
index ace2cc5..6244f27 100644
--- a/README.adoc
+++ b/README.adoc
@@ -159,17 +159,25 @@ text. If one exists, the following response format is used:
 
 ---------------------------
 {
-  "announcement": "some announcement text"
+  "announcement": "some announcement text",
+  "expiry_utc": 1607383640
 }
 ---------------------------
 
+`expiriy_utc` describes the expiry time of the announcement in
+seconds since 1970-01-01 00:00 UTC. Note the expiry time doesn't
+necessarily need to be checked manually as the endpoint guarantees
+to never return an expired announcement. If the announcement
+never expires, `expiry_utc` is `null`.
+
 If however no announcement text has been set (or it has been
 deleted), the response looks like this and is returned with
 a response status of 404:
 
 ---------------------------
 {
-  "announcement": null
+  "announcement": null,
+  "expiry_utc": null
 }
 ---------------------------
 
@@ -198,8 +206,9 @@ call to this endpoint must send a form with
 the following fields:
 
 |=============================================
-| `text`  | text to be added to the queue
-| `token` | API token of the application
+| `text`       | text to be added to the queue
+| `token`      | API token of the application
+| `expiry_utc` | Optional: time in seconds since the unix epoch the announcement expires (is deleted) at
 |=============================================
 
 The response contains the announcement in the
@@ -208,10 +217,14 @@ except it's guaranteed to never be `null`:
 
 ----------------------
 {
-  "announcement": "new announcement"
+  "announcement": "new announcement",
+  "expiry_utc": null
 }
 ----------------------
 
+`expiry_utc` will of course hold a timestamp if
+one was given in the request.
+
 |=============================================
 | HTTP Status  | Meaning
 | 200          | Success, announcement set
diff --git a/clients/py/flipdot_gschichtler/__init__.py b/clients/py/flipdot_gschichtler/__init__.py
index 6942489..cb6b7b1 100644
--- a/clients/py/flipdot_gschichtler/__init__.py
+++ b/clients/py/flipdot_gschichtler/__init__.py
@@ -69,12 +69,16 @@ class FlipdotGschichtlerClient():
             message = self.__get_error_message(r)
             raise FlipdotGschichtlerClient(endpoint, r.status_code, message)
 
-    def announcement(self):
+    def announcement(self, with_expiry = False):
         endpoint = '/api/v2/announcement'
         r = requests.get(self.base_url + endpoint)
 
         if r.status_code == 200:
-            return r.json()['announcement']
+            j = r.json()
+            if with_expiry:
+                return { 'text' : j['announcement'], 'expiry' : j['expiry_utc'] }
+            else:
+                return j['announcement']
         elif r.status_code == 404:
             # no / empty announcement
             assert 'announcement' in r.json()
@@ -83,12 +87,23 @@ class FlipdotGschichtlerClient():
             message = self.__get_error_message(r)
             raise FlipdotGschichtlerError(endpoint, r.status_code, message)
 
-    def set_announcement(self, text):
+    def set_announcement(self, text, expiry = None):
         if self.api_token == None:
             raise FlipdotGschichtlerNoToken
 
+        request = {
+            'text': text,
+            'token': self.api_token
+        }
+
+        if expiry != None:
+            if type(expiry) is int:
+                request['expiry_utc'] = expiry
+            else:
+                raise TypeError('expiry is expected to be an integer')
+
         endpoint = '/api/v2/announcement'
-        r = requests.put(self.base_url + endpoint, data = { 'text': text, 'token': self.api_token })
+        r = requests.put(self.base_url + endpoint, data = request)
 
         if r.status_code != 200:
             message = self.__get_error_message(r)
diff --git a/warteraum/announcement.c b/warteraum/announcement.c
new file mode 100644
index 0000000..3642aed
--- /dev/null
+++ b/warteraum/announcement.c
@@ -0,0 +1,62 @@
+#include <stdlib.h>
+#include <string.h>
+
+#include "announcement.h"
+
+void announcement_new(struct warteraum_announcement *announcement) {
+  announcement->text.len = -1;
+  announcement->text.buf = NULL;
+  announcement->announcement_expires = 0;
+  announcement->announcement_expiry = 0;
+}
+
+void announcement_delete(struct warteraum_announcement *announcement) {
+  if(announcement->text.buf != NULL) {
+    free((void *) announcement->text.buf);
+  }
+  announcement->text.len = -1;
+  announcement->text.buf = NULL;
+
+  announcement->announcement_expires = 0;
+  announcement->announcement_expiry = 0;
+}
+
+bool announcement_set(struct warteraum_announcement *announcement, struct http_string_s s) {
+  announcement_delete(announcement);
+
+  char *new_buf = malloc(s.len);
+
+  if(new_buf == NULL) {
+    return false;
+  }
+
+  memcpy(new_buf, s.buf, s.len);
+
+  announcement->text.len = s.len;
+  announcement->text.buf = new_buf;
+
+  announcement->announcement_expires = 0;
+  announcement->announcement_expiry = 0;
+
+  return true;
+}
+
+bool announcement_set_expiring(struct warteraum_announcement *announcement, struct http_string_s s, time_t t) {
+  if(!announcement_set(announcement, s)) {
+    return false;
+  }
+
+  announcement->announcement_expires = 1;
+  announcement->announcement_expiry = t;
+
+  return true;
+}
+
+bool announcement_expired(struct warteraum_announcement announcement) {
+  if(announcement.announcement_expires) {
+    time_t now = time(NULL);
+    return now > announcement.announcement_expiry;
+  } else {
+    return false;
+  }
+}
diff --git a/warteraum/announcement.h b/warteraum/announcement.h
new file mode 100644
index 0000000..eee7b1e
--- /dev/null
+++ b/warteraum/announcement.h
@@ -0,0 +1,25 @@
+#ifndef WARTERAUM_ANNOUNCEMENT_H
+#define WARTERAUM_ANNOUNCEMENT_H
+
+#include "../third_party/httpserver.h/httpserver.h"
+
+#include <stdbool.h>
+#include <time.h>
+
+struct warteraum_announcement {
+  struct http_string_s text;
+
+  bool   announcement_expires;
+  time_t announcement_expiry;
+};
+
+void announcement_new(struct warteraum_announcement *);
+
+void announcement_delete(struct warteraum_announcement *);
+
+bool announcement_set(struct warteraum_announcement *, struct http_string_s);
+
+bool announcement_set_expiring(struct warteraum_announcement *, struct http_string_s, time_t);
+
+bool announcement_expired(struct warteraum_announcement);
+#endif
diff --git a/warteraum/default.o.do b/warteraum/default.o.do
index f91bd8f..e79aaea 100644
--- a/warteraum/default.o.do
+++ b/warteraum/default.o.do
@@ -24,6 +24,12 @@ case "$2" in
   hashtoken)
     redo-ifchange scrypt.h
     ;;
+  announcement)
+    redo-ifchange ../third_party/httpserver.h/httpserver.h
+    ;;
+  http_string)
+    redo-ifchange ../third_party/httpserver.h/httpserver.h
+    ;;
 esac
 
 $CC $CFLAGS -o "$3" -c "$2.c"
diff --git a/warteraum/http_string.c b/warteraum/http_string.c
new file mode 100644
index 0000000..bf93c77
--- /dev/null
+++ b/warteraum/http_string.c
@@ -0,0 +1,29 @@
+#include <errno.h>
+#include <stdint.h>
+#include <stdlib.h>
+#include <limits.h>
+
+#include "http_string.h"
+
+unsigned long long http_string_to_uint(struct http_string_s s) {
+  char *zero_terminated = malloc(s.len + 1);
+  char *end = NULL;
+
+  if(zero_terminated == NULL) {
+    errno = ENOMEM;
+    return ULONG_MAX;
+  }
+
+  memcpy(zero_terminated, s.buf, s.len);
+  zero_terminated[s.len] = '\0';
+
+  unsigned long long int l = strtoull(zero_terminated, &end, 10);
+
+  if(*end != '\0') {
+    errno = EINVAL;
+  }
+
+  free(zero_terminated);
+
+  return l;
+}
diff --git a/warteraum/http_string.h b/warteraum/http_string.h
index ee47aa6..ba54364 100644
--- a/warteraum/http_string.h
+++ b/warteraum/http_string.h
@@ -3,6 +3,8 @@
 
 #include <string.h>
 
+#include "../third_party/httpserver.h/httpserver.h"
+
 #define STATIC_HTTP_STRING(s) \
   { s, sizeof(s) - 1 }
 
@@ -12,4 +14,6 @@
 #define HTTP_STRING_IS(a, s) \
   (a.len == sizeof(s) - 1 && strncmp(a.buf, s, a.len) == 0)
 
+unsigned long long http_string_to_uint(struct http_string_s);
+
 #endif
diff --git a/warteraum/main.c b/warteraum/main.c
index ff360cf..8e6d8a5 100644
--- a/warteraum/main.c
+++ b/warteraum/main.c
@@ -6,11 +6,13 @@
 #include <stdbool.h>
 #include <stdio.h>
 #include <string.h>
+#include <time.h>
 
 #include "emitjson.h"
 
 #include "http_string.h"
 
+#include "announcement.h"
 #include "queue.h"
 #include "routing.h"
 #include "form.h"
@@ -40,38 +42,13 @@
 static struct queue flip_queue;
 static struct http_server_s* server;
 
-static struct http_string_s announcement;
-
-void delete_announcement(void) {
-  if(announcement.buf != NULL) {
-    free((void *) announcement.buf);
-  }
-  announcement.len = -1;
-  announcement.buf = NULL;
-}
-
-bool set_announcement(http_string_t s) {
-  delete_announcement();
-
-  char *new_buf = malloc(s.len);
-
-  if(new_buf == NULL) {
-    return false;
-  }
-
-  memcpy(new_buf, s.buf, s.len);
-
-  announcement.len = s.len;
-  announcement.buf = new_buf;
-
-  return true;
-}
+static struct warteraum_announcement announcement;
 
 void cleanup(int signum) {
   if(signum == SIGTERM || signum == SIGINT) {
     queue_free(flip_queue);
     free(server);
-    delete_announcement();
+    announcement_delete(&announcement);
     exit(EXIT_SUCCESS);
   }
 }
@@ -399,18 +376,8 @@ enum warteraum_result response_queue_del(http_string_t id_str, enum warteraum_ve
     return WARTERAUM_UNAUTHORIZED;
   }
 
-  char *id_zero_terminated = malloc(sizeof(char) * (id_str.len + 1));
-  if(id_zero_terminated == NULL) {
-    return WARTERAUM_INTERNAL_ERROR;
-  }
-
-  memcpy(id_zero_terminated, id_str.buf, id_str.len);
-  id_zero_terminated[id_str.len] = '\0';
-
   errno = 0;
-  unsigned long int id = strtoul(id_zero_terminated, NULL, 10);
-
-  free(id_zero_terminated);
+  unsigned long int id = http_string_to_uint(id_str);
 
   // check for conversion errors
   // also abort if id is greater than max id
@@ -444,14 +411,21 @@ int make_announcement_response(struct ej_context *ctx) {
   ej_object(ctx);
   EJ_STATIC_BIND(ctx, "announcement");
 
-  if(announcement.len > 0 && announcement.buf != NULL) {
-    ej_string(ctx, announcement.buf, announcement.len);
+  if(announcement.text.len > 0 && announcement.text.buf != NULL) {
+    ej_string(ctx, announcement.text.buf, announcement.text.len);
     status = 200;
   } else {
     ej_null(ctx);
     status = 404;
   }
 
+  EJ_STATIC_BIND(ctx, "expiry_utc");
+  if(announcement.announcement_expires) {
+    ej_int(ctx, announcement.announcement_expiry);
+  } else {
+    ej_null(ctx);
+  }
+
   ej_object_end(ctx);
 
   return status;
@@ -461,6 +435,12 @@ int make_announcement_response(struct ej_context *ctx) {
 enum warteraum_result response_announcement(enum warteraum_version v, http_request_t *request, http_response_t *response) {
   (void) v; // surpress warnings
 
+  // instead of using a separate thread or something
+  // we check expiry every time it is requested
+  if(announcement_expired(announcement)) {
+    announcement_delete(&announcement);
+  }
+
   http_string_t method = http_request_method(request);
 
   if(HTTP_STRING_IS(method, "GET") || HTTP_STRING_IS(method, "PUT")) {
@@ -498,9 +478,12 @@ enum warteraum_result response_announcement(enum warteraum_version v, http_reque
 
       http_string_t text;
       http_string_t token;
+      http_string_t expiry_utc_str;
+
       const struct form_field_spec text_body_spec[] = {
         { STATIC_HTTP_STRING("text"), FIELD_TYPE_STRING, &text },
         { STATIC_HTTP_STRING("token"), FIELD_TYPE_STRING, &token },
+        { STATIC_HTTP_STRING("expiry_utc"), FIELD_TYPE_OPTIONAL_STRING, &expiry_utc_str },
       };
 
       bool parse_result = STATIC_FORM_PARSE(body, text_body_spec);
@@ -544,11 +527,72 @@ enum warteraum_result response_announcement(enum warteraum_version v, http_reque
         free(decoded_mem);
         fclose(out);
         free(buf);
-        return WARTERAUM_INTERNAL_ERROR;
+        return WARTERAUM_BAD_REQUEST;
       }
 
-      bool update_result = set_announcement(decoded) &&
-        (make_announcement_response(&ctx) == 200);
+      bool update_result = false;
+
+      // check if we have an expiry time
+      if(expiry_utc_str.len == -1) {
+        update_result = announcement_set(&announcement, decoded) &&
+          (make_announcement_response(&ctx) == 200);
+      } else {
+        time_t expiry_utc;
+
+        http_string_t expiry_utc_decoded;
+        char *expiry_utc_decoded_mem = malloc(expiry_utc_str.len);
+
+        if(expiry_utc_decoded_mem == NULL) {
+          fclose(out);
+          free(buf);
+          free(decoded_mem);
+          return WARTERAUM_INTERNAL_ERROR;
+        }
+
+        expiry_utc_decoded.len = urldecode(expiry_utc_str, expiry_utc_decoded_mem, (size_t) expiry_utc_str.len);
+        expiry_utc_decoded.buf = expiry_utc_decoded_mem;
+
+        if(expiry_utc_decoded.len <= 0) {
+          free(decoded_mem);
+          free(expiry_utc_decoded_mem);
+          fclose(out);
+          free(buf);
+          return WARTERAUM_BAD_REQUEST;
+        }
+
+        bool valid = true;
+
+        // check if its a proper number
+        for(int i = 0; i < expiry_utc_decoded.len; i++) {
+          if(!isdigit(expiry_utc_decoded.buf[i])) {
+            valid = false;
+          }
+        }
+
+        if(valid) {
+          errno = 0;
+          unsigned long long tmp = http_string_to_uint(expiry_utc_decoded);
+
+          if(errno != 0 || tmp > LONG_MAX) {
+            valid = false;
+          } else {
+            expiry_utc = (time_t) tmp;
+          }
+        }
+
+        if(!valid) {
+          free(decoded_mem);
+          free(expiry_utc_decoded_mem);
+          fclose(out);
+          free(buf);
+          return WARTERAUM_BAD_REQUEST;
+        }
+
+        update_result = announcement_set_expiring(&announcement, decoded, expiry_utc) &&
+          (make_announcement_response(&ctx) == 200);
+
+        free(expiry_utc_decoded_mem);
+      }
 
       free(decoded_mem);
 
@@ -602,7 +646,7 @@ enum warteraum_result response_announcement(enum warteraum_version v, http_reque
       return WARTERAUM_UNAUTHORIZED;
     }
 
-    delete_announcement();
+    announcement_delete(&announcement);
 
     http_response_status(response, 204);
     http_respond(request, response);
@@ -680,6 +724,7 @@ void handle_request(http_request_t *request) {
 
 int main(void) {
   queue_new(&flip_queue);
+  announcement_new(&announcement);
 
   signal(SIGTERM, cleanup);
   signal(SIGINT, cleanup);
diff --git a/warteraum/test/run b/warteraum/test/run
index 5b4a428..78129b2 100755
--- a/warteraum/test/run
+++ b/warteraum/test/run
@@ -44,7 +44,7 @@ if command -v pytest > /dev/null; then
 
   sleep 3
 
-  pytest ./test_integration.py
+  pytest -v ./test_integration.py
 
   kill $pid
 
diff --git a/warteraum/test/test_integration.py b/warteraum/test/test_integration.py
index 31fbb82..2819366 100755
--- a/warteraum/test/test_integration.py
+++ b/warteraum/test/test_integration.py
@@ -1,7 +1,9 @@
-import requests
 from flipdot_gschichtler import FlipdotGschichtlerClient, FlipdotGschichtlerError
+import math
 import pytest
+import requests
 import sys
+import time
 
 BASE_URL = 'http://localhost:9000'
 TOKEN = 'hannes'
@@ -53,6 +55,7 @@ def test_queue_404_format():
 
 def test_announcement_formats():
     my_text = 'important news'
+    my_timestamp = 1607356134
 
     r = requests.delete(BASE_URL + '/api/v2/announcement', data = { 'token' : TOKEN })
     assert r.status_code == 204
@@ -60,19 +63,27 @@ def test_announcement_formats():
     r1 = requests.get(BASE_URL + '/api/v2/announcement')
     assert r1.status_code == 404
     assert r1.json()['announcement'] == None
+    assert r1.json()['expiry_utc'] == None
 
     r2 = requests.put(BASE_URL + '/api/v2/announcement', data = { 'text' : my_text, 'token' : TOKEN })
     assert r2.status_code == 200
     assert r2.json()['announcement'] == my_text
+    assert r1.json()['expiry_utc'] == None
 
     r3 = requests.get(BASE_URL + '/api/v2/announcement')
     assert r3.status_code == 200
     assert r3.json()['announcement'] == my_text
+    assert r1.json()['expiry_utc'] == None
 
     # check that token is required
     r4 = requests.put(BASE_URL + '/api/v2/announcement', data = { 'text' : 'oops' })
     assert r4.status_code == 400
 
+    r5 = requests.put(BASE_URL + '/api/v2/announcement', data = { 'text' : my_text, 'token' : TOKEN, 'expiry_utc' : my_timestamp })
+    assert r5.status_code == 200
+    assert r5.json()['announcement'] == my_text
+    assert r5.json()['expiry_utc'] == my_timestamp
+
 # /api/v2/queue/add input validation and normalization
 
 def test_strip_whitespace():
@@ -197,3 +208,17 @@ def test_reassigning_only_after_emptying():
     assert api.add('only text') == 0
     assert len(api.queue()) == 1
     assert api.queue()[0]['id'] == 0
+
+# announcement properties
+
+def test_announcement_expiring():
+    my_text = 'this announcement will become irrelevant'
+    in_thirty = math.floor(time.time()) + 30
+
+    api.set_announcement(my_text, expiry = in_thirty)
+
+    assert api.announcement(with_expiry = True) == { 'text' : my_text, 'expiry' : in_thirty }
+
+    time.sleep(35)
+
+    assert api.announcement() == None
diff --git a/warteraum/warteraum.do b/warteraum/warteraum.do
index 1e6bda2..1591408 100644
--- a/warteraum/warteraum.do
+++ b/warteraum/warteraum.do
@@ -1,6 +1,6 @@
 source ./build_config
 redo-ifchange ./build_config
-OBJS="emitjson.o queue.o routing.o form.o main.o"
+OBJS="emitjson.o queue.o routing.o form.o main.o announcement.o http_string.o"
 DEPS="$OBJS ../third_party/httpserver.h/httpserver.h"
 redo-ifchange $DEPS