From a4baebce984d5ef4140950dcee277d9fcee9e709 Mon Sep 17 00:00:00 2001 From: sternenseemann <0rpkxez4ksa01gb3typccl0i@systemli.org> Date: Tue, 8 Dec 2020 00:33:04 +0100 Subject: feat(warteraum): implement announcement expiry * 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 --- README.adoc | 23 +++-- clients/py/flipdot_gschichtler/__init__.py | 23 ++++- warteraum/announcement.c | 62 ++++++++++++++ warteraum/announcement.h | 25 ++++++ warteraum/default.o.do | 6 ++ warteraum/http_string.c | 29 +++++++ warteraum/http_string.h | 4 + warteraum/main.c | 133 +++++++++++++++++++---------- warteraum/test/run | 2 +- warteraum/test/test_integration.py | 27 +++++- warteraum/warteraum.do | 2 +- 11 files changed, 280 insertions(+), 56 deletions(-) create mode 100644 warteraum/announcement.c create mode 100644 warteraum/announcement.h create mode 100644 warteraum/http_string.c 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 +#include + +#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 +#include + +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 +#include +#include +#include + +#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 +#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 #include #include +#include #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 -- cgit 1.4.1