initial import - frontends - front-ends for some sites (experiment) (DIR) Log (DIR) Files (DIR) Refs (DIR) README (DIR) LICENSE --- (DIR) commit dc3d8095cbd02e17ceeaab5590816bfa5f93e06c (HTM) Author: Hiltjo Posthuma <hiltjo@codemadness.org> Date: Fri, 3 Jan 2020 12:46:08 +0100 initial import lots to clean-up, polish of course: - duckduckgo: based on the "dscrape" code. - reddit: start with a basic reddit interface. - twitch: rewrite Golang application to C and use the Helix API (was Kraken). - youtube: import idiotbox and unify the code (JSON, HTTPS, network code). Diffstat: A .gitignore | 8 ++++++++ A LICENSE | 15 +++++++++++++++ A Makefile | 141 +++++++++++++++++++++++++++++++ A README | 9 +++++++++ A duckduckgo/Makefile | 5 +++++ A duckduckgo/README | 3 +++ A duckduckgo/cli.c | 47 +++++++++++++++++++++++++++++++ A duckduckgo/duckduckgo.c | 228 +++++++++++++++++++++++++++++++ A duckduckgo/duckduckgo.h | 15 +++++++++++++++ A duckduckgo/gopher.c | 80 +++++++++++++++++++++++++++++++ A https.c | 199 +++++++++++++++++++++++++++++++ A https.h | 8 ++++++++ A json.c | 340 +++++++++++++++++++++++++++++++ A json.h | 28 ++++++++++++++++++++++++++++ A reddit/cgi.c | 383 +++++++++++++++++++++++++++++++ A reddit/cli.c | 122 +++++++++++++++++++++++++++++++ A reddit/gopher.c | 199 +++++++++++++++++++++++++++++++ A reddit/reddit.c | 234 +++++++++++++++++++++++++++++++ A reddit/reddit.h | 32 +++++++++++++++++++++++++++++++ A strlcat.c | 55 +++++++++++++++++++++++++++++++ A strlcpy.c | 50 +++++++++++++++++++++++++++++++ A twitch/cgi.c | 496 +++++++++++++++++++++++++++++++ A twitch/gopher.c | 481 +++++++++++++++++++++++++++++++ A twitch/twitch.c | 602 +++++++++++++++++++++++++++++++ A twitch/twitch.css | 105 +++++++++++++++++++++++++++++++ A twitch/twitch.h | 80 +++++++++++++++++++++++++++++++ A util.c | 218 +++++++++++++++++++++++++++++++ A util.h | 15 +++++++++++++++ A xml.c | 480 +++++++++++++++++++++++++++++++ A xml.h | 47 +++++++++++++++++++++++++++++++ A youtube/README | 68 +++++++++++++++++++++++++++++++ A youtube/cgi.c | 379 +++++++++++++++++++++++++++++++ A youtube/cli.c | 154 +++++++++++++++++++++++++++++++ A youtube/css/dark.css | 47 +++++++++++++++++++++++++++++++ A youtube/css/light.css | 47 +++++++++++++++++++++++++++++++ A youtube/css/pink.css | 47 +++++++++++++++++++++++++++++++ A youtube/css/templeos.css | 50 +++++++++++++++++++++++++++++++ A youtube/gopher.c | 149 +++++++++++++++++++++++++++++++ A youtube/youtube.c | 329 +++++++++++++++++++++++++++++++ A youtube/youtube.h | 22 ++++++++++++++++++++++ 40 files changed, 6017 insertions(+), 0 deletions(-) --- (DIR) diff --git a/.gitignore b/.gitignore @@ -0,0 +1,8 @@ +*.core +*.o +*/*.o +*/cgi +*/cli +*/gph +*/gopher +*/*.cgi (DIR) diff --git a/LICENSE b/LICENSE @@ -0,0 +1,15 @@ +ISC License + +Copyright (c) 2020 Hiltjo Posthuma <hiltjo@codemadness.org> + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. (DIR) diff --git a/Makefile b/Makefile @@ -0,0 +1,141 @@ +.POSIX: + +COMPATOBJ = strlcpy.o strlcat.o +CFLAGS = -I../ -I. + +build: clean deps duckduckgo reddit twitch youtube +#build: clean deps reddit + +deps: + ${CC} -c json.c ${CFLAGS} + ${CC} -c xml.c ${CFLAGS} + ${CC} -c https.c ${CFLAGS} + ${CC} -c util.c ${CFLAGS} + # compat + ${CC} -c strlcpy.c ${CFLAGS} + ${CC} -c strlcat.c ${CFLAGS} + +# front-end: duckduckgo search +duckduckgo: + ${CC} -o duckduckgo/duckduckgo.o -c duckduckgo/duckduckgo.c ${CFLAGS} + # CLI + ${CC} -o duckduckgo/cli.o -c duckduckgo/cli.c ${CFLAGS} + # Gopher + ${CC} -o duckduckgo/gopher.o -c duckduckgo/gopher.c ${CFLAGS} + # Link CLI UI + ${CC} -o duckduckgo/cli \ + https.o \ + util.o \ + xml.o \ + duckduckgo/duckduckgo.o duckduckgo/cli.o \ + ${COMPATOBJ} \ + ${LDFLAGS} \ + -ltls + # Link gopher CGI (static-link for chroot) + ${CC} -o duckduckgo/gopher \ + https.o \ + xml.o \ + util.o \ + duckduckgo/duckduckgo.o duckduckgo/gopher.o \ + ${COMPATOBJ} \ + ${LDFLAGS} \ + -ltls -lssl -lcrypto -static + +# front-end: reddit +reddit: + ${CC} -o reddit/reddit.o -c reddit/reddit.c ${CFLAGS} + # CLI + ${CC} -o reddit/cli.o -c reddit/cli.c ${CFLAGS} + # CLI + ${CC} -o reddit/gopher.o -c reddit/gopher.c ${CFLAGS} + # Link CLI UI + ${CC} -o reddit/cli \ + https.o \ + json.o \ + util.o \ + reddit/reddit.o reddit/cli.o \ + ${COMPATOBJ} \ + ${LDFLAGS} \ + -ltls + # Link gopher CGI (static-link for chroot) + ${CC} -o reddit/gopher \ + https.o \ + json.o \ + util.o \ + reddit/reddit.o reddit/gopher.o \ + ${COMPATOBJ} \ + ${LDFLAGS} \ + -ltls -lssl -lcrypto -static + +# front-end: twitch +twitch: + ${CC} -o twitch/twitch.o -c twitch/twitch.c ${CFLAGS} + # CGI + ${CC} -o twitch/cgi.o -c twitch/cgi.c ${CFLAGS} + # Gopher + ${CC} -o twitch/gopher.o -c twitch/gopher.c ${CFLAGS} + # Link HTML CGI (static-link for chroot) + ${CC} -o twitch/cgi \ + https.o \ + json.o \ + util.o \ + twitch/twitch.o twitch/cgi.o \ + ${COMPATOBJ} \ + ${LDFLAGS} \ + -ltls -lssl -lcrypto -static + # Link gopher CGI (static-link for chroot) + ${CC} -o twitch/gopher \ + https.o \ + json.o \ + util.o \ + twitch/twitch.o twitch/gopher.o \ + ${COMPATOBJ} \ + ${LDFLAGS} \ + -ltls -lssl -lcrypto -static + +# front-end: youtube +youtube: + ${CC} -o youtube/youtube.o -c youtube/youtube.c ${CFLAGS} + # UIs + # HTML + ${CC} -o youtube/cgi.o -c youtube/cgi.c ${CFLAGS} + # CLI + ${CC} -o youtube/cli.o -c youtube/cli.c ${CFLAGS} + # Gopher + ${CC} -o youtube/gopher.o -c youtube/gopher.c ${CFLAGS} + # Link HTML CGI (static-link for chroot) + ${CC} -o youtube/cgi \ + https.o \ + util.o \ + xml.o \ + youtube/youtube.o youtube/cgi.o \ + ${COMPATOBJ} \ + ${LDFLAGS} \ + -ltls -lssl -lcrypto -static + # Link gopher UI (static-link for chroot) + ${CC} -o youtube/gopher \ + https.o \ + util.o \ + xml.o \ + youtube/youtube.o youtube/gopher.o \ + ${COMPATOBJ} \ + ${LDFLAGS} \ + -ltls -lssl -lcrypto -static + # Link CLI UI + ${CC} -o youtube/cli \ + https.o \ + util.o \ + xml.o \ + youtube/youtube.o youtube/cli.o \ + ${COMPATOBJ} \ + ${LDFLAGS} \ + -ltls + +clean: + rm -f *.o + rm -f duckduckgo/cgi duckduckgo/cli duckduckgo/gopher duckduckgo/*.o + rm -f reddit/cgi reddit/cli reddit/gopher reddit/*.o + rm -f twitch/cgi twitch/cli twitch/gopher twitch/*.o + rm -f youtube/cgi youtube/cli youtube/gopher youtube/*.o + +.PHONY: clean deps duckduckgo reddit twitch youtube (DIR) diff --git a/README b/README @@ -0,0 +1,9 @@ +Work-in-progress / experiment + + +Frontends for: + +Duckduckgo +Reddit +Twitch +Youtube (DIR) diff --git a/duckduckgo/Makefile b/duckduckgo/Makefile @@ -0,0 +1,5 @@ +build: clean + cc xml.c main.c -o dscrape ${CFLAGS} ${LDFLAGS} + +clean: + rm -f dscrape *.o (DIR) diff --git a/duckduckgo/README b/duckduckgo/README @@ -0,0 +1,3 @@ +duckduckgo CLI search tool + +work-in-progress, do not use. (DIR) diff --git a/duckduckgo/cli.c b/duckduckgo/cli.c @@ -0,0 +1,47 @@ +#include <sys/types.h> + +#include <ctype.h> +#include <err.h> +#include <locale.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <unistd.h> +#include <wchar.h> + +#include "duckduckgo.h" +#include "util.h" + +int +main(int argc, char *argv[]) +{ + struct duckduckgo_results *results; + struct duckduckgo_result *result; + char buf[512]; + size_t i; + + setlocale(LC_CTYPE, ""); + +#if 0 + if (pledge("stdio", NULL) == -1) + err(1, "pledge"); +#endif + + if (argc != 2) { + fprintf(stderr, "usage: %s <search>\n", argv[0]); + exit(1); + } + + if ((results = duckduckgo_search(argv[1]))) { + for (i = 0; i < results->nitems; i++) { + result = &(results->items[i]); + + if (utf8pad(buf, sizeof(buf), result->title, 70, ' ') != -1) + fputs(buf, stdout); + fputs(" ", stdout); + puts(result->urldecoded); + } + } + + return 0; +} (DIR) diff --git a/duckduckgo/duckduckgo.c b/duckduckgo/duckduckgo.c @@ -0,0 +1,228 @@ +#include <sys/types.h> + +#include <ctype.h> +#include <err.h> +#include <locale.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <unistd.h> +#include <wchar.h> + +#include "duckduckgo.h" +#include "https.h" +#include "util.h" +#include "xml.h" + +//#define DEBUG_MODE 1 + +static XMLParser x; + +static struct duckduckgo_results *results; +static struct duckduckgo_result result; +static int istitle, isdescription, isurl, isresult; + +void +sanitize(char *s, size_t len) +{ + size_t i; + + /* trim trailing whitespace */ + for (i = strlen(s); i > 0; i--) { + if (!isspace((unsigned char)s[i - 1])) + break; + } + s[i] = '\0'; + + /* trim leading whitespace */ + for (i = 0; s[i]; i++) { // TODO: wrong + if (!isspace((unsigned char)s[i])) + break; + } + memmove(s, s + i, len - i + 1); + + for (i = 0; s[i]; i++) { + if (iscntrl((unsigned char)s[i])) + s[i] = ' '; + } +} + +void +xmlattr(XMLParser *x, const char *t, size_t tl, const char *a, size_t al, + const char *v, size_t vl) +{ + if (!strcmp(t, "div") && !strcmp(a, "class") && strstr(v, "results_links")) + isresult = 1; + + if (!isresult) + return; + + /* clear fix is use in the end of a result */ + if (!strcmp(t, "div") && !strcmp(a, "style") && strstr(v, "clear: both")) { + isresult = 0; + + if (!result.title[0] || !result.url[0]) + return; + + /* add result */ + if (results->nitems <= MAX_ITEMS) { + memcpy(&(results->items[results->nitems]), + &result, sizeof(result)); + results->nitems++; + } + memset(&result, 0, sizeof(result)); + return; + } + + if (!strcmp(t, "h2") && !strcmp(a, "class") && strstr(v, "result__title")) + istitle = 1; + if (!strcmp(t, "a") && !strcmp(a, "class") && strstr(v, "result__snippet")) + isdescription = 1; + if (!strcmp(t, "a") && !strcmp(a, "class") && strstr(v, "result__url")) + isurl = 1; + if (isurl && !strcmp(t, "a") && !strcmp(a, "href")) + strlcpy(result.url, v, sizeof(result.url)); +} + +void +xmlattrentity(XMLParser *x, const char *t, size_t tl, const char *a, size_t al, + const char *v, size_t vl) +{ + char buf[16]; + ssize_t len; + + if (!isresult || !istitle || !isdescription || !isurl) + return; + + if ((len = xml_entitytostr(v, buf, sizeof(buf))) > 0) + xmlattr(x, t, tl, a, al, buf, (size_t)len); + else + xmlattr(x, t, tl, a, al, v, vl); +} + +void +xmldata(XMLParser *x, const char *d, size_t dl) +{ + if (istitle) + strlcat(result.title, d, sizeof(result.title)); + if (isdescription) + strlcat(result.description, d, sizeof(result.description)); +} + +void +xmlcdata(XMLParser *x, const char *d, size_t dl) +{ + xmldata(x, d, dl); +} + +void +xmldataentity(XMLParser *x, const char *d, size_t dl) +{ + char buf[16]; + ssize_t len; + + if (!isresult || !istitle || !isdescription || !isurl) + return; + + if ((len = xml_entitytostr(d, buf, sizeof(buf))) > 0) + xmldata(x, buf, (size_t)len); + else + xmldata(x, d, dl); +} + +void +xmltagend(XMLParser *x, const char *t, size_t tl, int isshort) +{ + if (!isresult) + return; + + if (isdescription) { + /* highlight */ + if (!strcmp(t, "b")) + strlcat(result.description, "*", sizeof(result.description)); + } + + if (istitle && !strcmp(t, "h2")) + istitle = 0; + if (isdescription && !strcmp(t, "a")) + isdescription = 0; + if (isurl && !strcmp(t, "a")) + isurl = 0; + if (!strcmp(t, "div")) { + /* decode url and remove "tracking"/usage part via DDG */ + if (!strncmp(result.url, "uddg=", sizeof("uddg=") - 1)) { + if (decodeparam(result.urldecoded, sizeof(result.urldecoded), + result.url + sizeof("uddg=") - 1) == -1) + result.urldecoded[0] = '\0'; + } + + sanitize(result.title, strlen(result.title)); + sanitize(result.urldecoded, strlen(result.urldecoded)); + sanitize(result.description, strlen(result.description)); + + istitle = isdescription = isurl = 0; + } +} + +void +xmltagstart(XMLParser *x, const char *t, size_t tl) +{ + /* highlight */ + if (isdescription && !strcmp(t, "b")) + strlcat(result.description, "*", sizeof(result.description)); + +} + +char * +duckduckgo_search_data(const char *s) +{ + char path[4096]; + int r; + +#ifdef DEBUG_MODE + return readfile("example.html"); +#else + r = snprintf(path, sizeof(path), "/html/?q=%s", s); + if (r < 0 || (size_t)r >= sizeof(path)) + return NULL; + + return request("duckduckgo.com", path, ""); +#endif +} + +struct duckduckgo_results * +duckduckgo_search(const char *s) +{ + struct duckduckgo_results *r; + char *data; + + results = NULL; /* global */ + + if (!(r = calloc(1, sizeof(*r)))) + return NULL; + + // TODO: encodeuri s + if (!(data = duckduckgo_search_data(s))) { + free(r); + results = NULL; + return NULL; + } + + // TODO: xmlparser, parse data into struct duckduckgo_results. + + x.xmlattr = xmlattr; + x.xmlattrentity = xmlattrentity; + x.xmlcdata = xmlcdata; + x.xmldata = xmldata; + x.xmldataentity = xmldataentity; + x.xmltagend = xmltagend; + x.xmltagstart = xmltagstart; + + results = r; /* global: store */ + setxmldata(data, strlen(data)); + xml_parse(&x); + + free(data); + + return r; +} (DIR) diff --git a/duckduckgo/duckduckgo.h b/duckduckgo/duckduckgo.h @@ -0,0 +1,15 @@ +struct duckduckgo_result { + char title[1024]; + char url[1024]; + char urldecoded[1024]; + char description[4096]; +}; + +#define MAX_ITEMS 100 + +struct duckduckgo_results { + struct duckduckgo_result items[MAX_ITEMS + 1]; + size_t nitems; +}; + +struct duckduckgo_results *duckduckgo_search(const char *s); (DIR) diff --git a/duckduckgo/gopher.c b/duckduckgo/gopher.c @@ -0,0 +1,80 @@ +#include <sys/types.h> + +#include <ctype.h> +#include <err.h> +#include <locale.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <unistd.h> +#include <wchar.h> + +#include "duckduckgo.h" +#include "util.h" + +#define OUT(s) fputs(s, stdout) +#define OUTTEXT(s) gophertext(stdout, s, strlen(s)) + +static const char *host = "127.0.0.1", *port = "70"; + +int +main(void) +{ + struct duckduckgo_results *results; + struct duckduckgo_result *result; + char buf[512], *p, *search = NULL; + size_t i; + + setlocale(LC_CTYPE, ""); + + if ((p = getenv("SERVER_NAME"))) + host = p; + if ((p = getenv("SERVER_PORT"))) + port = p; + if ((p = getenv("X_GOPHER_SEARCH"))) + search = p; + +#if 0 + if (pledge("stdio", NULL) == -1) + err(1, "pledge"); +#endif + + if (search == NULL) { + printf("3\tSpecify a search term\t%s\t%s\r\n", host, port); + printf(".\r\n"); + exit(1); + } + + if ((results = duckduckgo_search(search))) { + for (i = 0; i < results->nitems; i++) { + result = &(results->items[i]); + + OUT("h"); + OUTTEXT(result->title); + OUT("\tURL:"); + OUTTEXT(result->urldecoded); + printf("\t%s\t%s\r\n", host, port); + +#if 0 + OUT("h"); + OUTTEXT(result->urldecoded); + OUT("\tURL:")); + OUTTEXT(result->urldecoded); + printf("\t%s\t%s\r\n", host, port); +#endif + + /* TODO: multi-line wrap ? */ + OUT("h"); + OUTTEXT(result->description); + OUT("\tURL:"); + OUTTEXT(result->urldecoded); + printf("\t%s\t%s\r\n", host, port); + + printf("i\t\t%s\t%s\r\n", host, port); + printf("i\t\t%s\t%s\r\n", host, port); + } + } + printf(".\r\n"); + + return 0; +} (DIR) diff --git a/https.c b/https.c @@ -0,0 +1,199 @@ +#include <sys/socket.h> +#include <sys/types.h> + +#include <ctype.h> +#include <errno.h> +#include <netdb.h> +#include <stdarg.h> +#include <stdint.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <time.h> +#include <unistd.h> + +#include <tls.h> + +#define READ_BUF_SIZ 16384 /* read buffer in bytes */ +#define MAX_RESPONSETIMEOUT 10 /* timeout in seconds */ +#define MAX_RESPONSESIZ 500000 /* max download size in bytes */ + +static void +die(const char *fmt, ...) +{ + va_list ap; + + va_start(ap, fmt); + vfprintf(stderr, fmt, ap); + va_end(ap); + + exit(1); +} + +/* TODO: use die or rename die to fatal */ +void +fatal(const char *s) +{ + fputs(s, stderr); + exit(1); +} + +char * +readtls(struct tls *t) +{ + char *buf; + size_t len = 0, size = 0; + ssize_t r; + + /* always allocate an empty buffer */ + if (!(buf = calloc(1, size + 1))) + die("calloc: %s\n", strerror(errno)); + + while (1) { + if (len + READ_BUF_SIZ + 1 > size) { + /* allocate size: common case is small textfiles */ + size += READ_BUF_SIZ; + if (!(buf = realloc(buf, size + 1))) + die("realloc: %s\n", strerror(errno)); + } + if ((r = tls_read(t, &buf[len], READ_BUF_SIZ)) <= 0) + break; + len += r; + buf[len] = '\0'; + if (len > MAX_RESPONSESIZ) + die("response is too big: > %zu bytes\n", MAX_RESPONSESIZ); + } + if (r < 0) + die("tls_read: %s\n", tls_error(t)); + + return buf; +} + +int +edial(const char *host, const char *port) +{ + struct addrinfo hints, *res, *res0; + int error, save_errno, s; + const char *cause = NULL; + struct timeval timeout; + + memset(&hints, 0, sizeof(hints)); + hints.ai_family = AF_UNSPEC; + hints.ai_socktype = SOCK_STREAM; + hints.ai_flags = AI_NUMERICSERV; /* numeric port only */ + if ((error = getaddrinfo(host, port, &hints, &res0))) + die("%s: %s: %s:%s\n", __func__, gai_strerror(error), host, port); + s = -1; + for (res = res0; res; res = res->ai_next) { + s = socket(res->ai_family, res->ai_socktype, + res->ai_protocol); + if (s == -1) { + cause = "socket"; + continue; + } + + timeout.tv_sec = MAX_RESPONSETIMEOUT; + timeout.tv_usec = 0; + if (setsockopt(s, SOL_SOCKET, SO_SNDTIMEO, &timeout, sizeof(timeout)) == -1) + die("%s: setsockopt: %s\n", __func__, strerror(errno)); + + timeout.tv_sec = MAX_RESPONSETIMEOUT; + timeout.tv_usec = 0; + if (setsockopt(s, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout)) == -1) + die("%s: setsockopt: %s\n", __func__, strerror(errno)); + + if (connect(s, res->ai_addr, res->ai_addrlen) == -1) { + cause = "connect"; + save_errno = errno; + close(s); + errno = save_errno; + s = -1; + continue; + } + break; + } + if (s == -1) + die("%s: %s: %s:%s\n", __func__, cause, host, port); + freeaddrinfo(res0); + + return s; +} + +char * +request(const char *host, const char *path, const char *headers) +{ + struct tls *t; + char request[4096]; + char *data; + ssize_t w; + int fd; + + /* use HTTP/1.0, don't use HTTP/1.1 using ugly chunked-encoding */ + snprintf(request, sizeof(request), + "GET %s HTTP/1.0\r\n" + "Host: %s\r\n" + "Accept-Language: en-US,en;q=0.5\r\n" + "Connection: close\r\n" + "%s" + "\r\n", path, host, headers); + + if (tls_init() == -1) + die("tls_init\n"); + + if (!(t = tls_client())) + die("tls_client: %s\n", tls_error(t)); + + fd = edial(host, "443"); + + if (tls_connect_socket(t, fd, host) == -1) + die("tls_connect: %s\n", tls_error(t)); + + if ((w = tls_write(t, request, strlen(request))) < 0) + die("tls_write: %s\n", tls_error(t)); + + data = readtls(t); + + tls_close(t); + tls_free(t); + + return data; +} + +/* DEBUG */ +char * +readfile(const char *file) +{ + FILE *fp; + char *buf; + size_t n, len = 0, size = 0; + + fp = fopen(file, "rb"); + if (!fp) + die("fopen"); + buf = calloc(1, size + 1); /* always allocate an empty buffer */ + if (!buf) + die("calloc"); + while (!feof(fp)) { + if (len + READ_BUF_SIZ + 1 > size) { + /* allocate size: common case is small textfiles */ + size += READ_BUF_SIZ; + if (!(buf = realloc(buf, size + 1))) { + fprintf(stderr, "realloc: %s\n", strerror(errno)); + exit(1); + } + } + if (!(n = fread(&buf[len], 1, READ_BUF_SIZ, fp))) + break; + len += n; + buf[len] = '\0'; + if (n != READ_BUF_SIZ) + break; + } + if (ferror(fp)) { + fprintf(stderr, "fread: file: %s: %s\n", file, strerror(errno)); + exit(1); + } + fclose(fp); + + return buf; +} (DIR) diff --git a/https.h b/https.h @@ -0,0 +1,8 @@ +#ifndef TLS_CA_CERT_FILE +#define TLS_CA_CERT_FILE "/etc/ssl/cert.pem" +#endif + +char *request(const char *host, const char *path, const char *headers); + +/* DEBUG function */ +char *readfile(const char *file); (DIR) diff --git a/json.c b/json.c @@ -0,0 +1,340 @@ +#include <ctype.h> +#include <errno.h> +#include <stdint.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> + +#define GETNEXT getnext + +#include "json.h" + +static const unsigned char *json_data; +static size_t json_data_size; +static size_t json_data_off; + +static int +getnext(void) +{ + if (json_data_off >= json_data_size) + return EOF; + return json_data[json_data_off++]; +} + +static void +setjsondata(const char *s, size_t len) +{ + json_data_off = 0; + json_data_size = len; + json_data = s; +} + +static int +codepointtoutf8(long r, char *s) +{ + if (r == 0) { + return 0; /* NUL byte */ + } else if (r <= 0x7F) { + /* 1 byte: 0aaaaaaa */ + s[0] = r; + return 1; + } else if (r <= 0x07FF) { + /* 2 bytes: 00000aaa aabbbbbb */ + s[0] = 0xC0 | ((r & 0x0007C0) >> 6); /* 110aaaaa */ + s[1] = 0x80 | (r & 0x00003F); /* 10bbbbbb */ + return 2; + } else if (r <= 0xFFFF) { + /* 3 bytes: aaaabbbb bbcccccc */ + s[0] = 0xE0 | ((r & 0x00F000) >> 12); /* 1110aaaa */ + s[1] = 0x80 | ((r & 0x000FC0) >> 6); /* 10bbbbbb */ + s[2] = 0x80 | (r & 0x00003F); /* 10cccccc */ + return 3; + } else { + /* 4 bytes: 000aaabb bbbbcccc ccdddddd */ + s[0] = 0xF0 | ((r & 0x1C0000) >> 18); /* 11110aaa */ + s[1] = 0x80 | ((r & 0x03F000) >> 12); /* 10bbbbbb */ + s[2] = 0x80 | ((r & 0x000FC0) >> 6); /* 10cccccc */ + s[3] = 0x80 | (r & 0x00003F); /* 10dddddd */ + return 4; + } +} + +static int +hexdigit(int c) +{ + if (c >= '0' && c <= '9') + return c - '0'; + else if (c >= 'a' && c <= 'f') + return 10 + (c - 'a'); + else if (c >= 'A' && c <= 'F') + return 10 + (c - 'A'); + return 0; +} + +static int +capacity(char **value, size_t *sz, size_t cur, size_t inc) +{ + size_t need, newsiz; + char *newp; + + /* check for addition overflow */ + if (cur > SIZE_MAX - inc) { + errno = EOVERFLOW; + return -1; + } + need = cur + inc; + + if (need > *sz) { + if (need > SIZE_MAX / 2) { + newsiz = SIZE_MAX; + } else { + for (newsiz = *sz < 64 ? 64 : *sz; newsiz <= need; newsiz *= 2) + ; + } + if (!(newp = realloc(*value, newsiz))) + return -1; /* up to caller to free *value */ + *value = newp; + *sz = newsiz; + } + return 0; +} + +#define EXPECT_VALUE "{[\"-0123456789tfn" +#define EXPECT_STRING "\"" +#define EXPECT_END "}]," +#define EXPECT_OBJECT_STRING EXPECT_STRING "}" +#define EXPECT_OBJECT_KEY ":" +#define EXPECT_ARRAY_VALUE EXPECT_VALUE "]" + +#define JSON_INVALID() do { ret = JSON_ERROR_INVALID; goto end; } while (0); + +/* DEBUG */ +#undef JSON_INVALID +#define JSON_INVALID() do { ret = JSON_ERROR_INVALID; fprintf(stderr, "%zu: expect %s, data: %s\n", json_data_off, expect, json_data + json_data_off); goto end; } while (0); + +int +parsejson(const char *s, size_t slen, + void (*cb)(struct json_node *, size_t, const char *, void *), void *pp) +{ + struct json_node nodes[JSON_MAX_NODE_DEPTH] = { 0 }; + size_t depth = 0, p = 0, len, sz = 0; + long cp, hi, lo; + char pri[128], *str = NULL; + int c, i, escape, iskey = 0, ret = JSON_ERROR_MEM; + const char *expect = EXPECT_VALUE; + + setjsondata(s, slen); + + if (capacity(&(nodes[0].name), &(nodes[0].namesiz), 0, 1) == -1) + goto end; + nodes[0].name[0] = '\0'; + + while (1) { + c = GETNEXT(); +handlechr: + if (c == EOF) + break; + + /* skip JSON white-space, (NOTE: no \v, \f, \b etc) */ + if (c == ' ' || c == '\t' || c == '\n' || c == '\r') + continue; + + if (!c || !strchr(expect, c)) + JSON_INVALID(); + + switch (c) { + case ':': + iskey = 0; + expect = EXPECT_VALUE; + break; + case '"': + nodes[depth].type = TYPE_STRING; + escape = 0; + len = 0; + while (1) { + c = GETNEXT(); +chr: + /* EOF or control char: 0x7f is not defined as a control char in RFC8259 */ + if (c < 0x20) + JSON_INVALID(); + + if (escape) { +escchr: + escape = 0; + switch (c) { + case '"': /* FALLTHROUGH */ + case '\\': + case '/': break; + case 'b': c = '\b'; break; + case 'f': c = '\f'; break; + case 'n': c = '\n'; break; + case 'r': c = '\r'; break; + case 't': c = '\t'; break; + case 'u': /* hex hex hex hex */ + if (capacity(&str, &sz, len, 4) == -1) + goto end; + for (i = 12, cp = 0; i >= 0; i -= 4) { + if ((c = GETNEXT()) == EOF || !isxdigit(c)) + JSON_INVALID(); /* invalid codepoint */ + cp |= (hexdigit(c) << i); + } + /* RFC8259 - 7. Strings - surrogates. + * 0xd800 - 0xdb7f - high surrogates */ + if (cp >= 0xd800 && cp <= 0xdb7f) { + if ((c = GETNEXT()) != '\\') { + len += codepointtoutf8(cp, &str[len]); + goto chr; + } + if ((c = GETNEXT()) != 'u') { + len += codepointtoutf8(cp, &str[len]); + goto escchr; + } + for (hi = cp, i = 12, lo = 0; i >= 0; i -= 4) { + if ((c = GETNEXT()) == EOF || !isxdigit(c)) + JSON_INVALID(); /* invalid codepoint */ + lo |= (hexdigit(c) << i); + } + /* 0xdc00 - 0xdfff - low surrogates */ + if (lo >= 0xdc00 && lo <= 0xdfff) { + cp = (hi << 10) + lo - 56613888; /* - offset */ + } else { + /* handle graceful: raw invalid output bytes */ + len += codepointtoutf8(hi, &str[len]); + if (capacity(&str, &sz, len, 4) == -1) + goto end; + len += codepointtoutf8(lo, &str[len]); + continue; + } + } + len += codepointtoutf8(cp, &str[len]); + continue; + default: + JSON_INVALID(); /* invalid escape char */ + } + if (capacity(&str, &sz, len, 1) == -1) + goto end; + str[len++] = c; + } else if (c == '\\') { + escape = 1; + } else if (c == '"') { + if (capacity(&str, &sz, len, 1) == -1) + goto end; + str[len++] = '\0'; + + if (iskey) { + /* copy string as key, including NUL byte */ + if (capacity(&(nodes[depth].name), &(nodes[depth].namesiz), len, 1) == -1) + goto end; + memcpy(nodes[depth].name, str, len); + } else { + cb(nodes, depth + 1, str, pp); + } + break; + } else { + if (capacity(&str, &sz, len, 1) == -1) + goto end; + str[len++] = c; + } + } + if (iskey) + expect = EXPECT_OBJECT_KEY; + else + expect = EXPECT_END; + break; + case '[': + case '{': + if (depth + 1 >= JSON_MAX_NODE_DEPTH) + JSON_INVALID(); /* too deep */ + + nodes[depth].index = 0; + if (c == '[') { + nodes[depth].type = TYPE_ARRAY; + expect = EXPECT_ARRAY_VALUE; + } else if (c == '{') { + iskey = 1; + nodes[depth].type = TYPE_OBJECT; + expect = EXPECT_OBJECT_STRING; + } + + cb(nodes, depth + 1, "", pp); + + depth++; + nodes[depth].index = 0; + if (capacity(&(nodes[depth].name), &(nodes[depth].namesiz), 0, 1) == -1) + goto end; + nodes[depth].name[0] = '\0'; + break; + case ']': + case '}': + if (!depth || + (c == ']' && nodes[depth - 1].type != TYPE_ARRAY) || + (c == '}' && nodes[depth - 1].type != TYPE_OBJECT)) + JSON_INVALID(); /* unbalanced nodes */ + + nodes[--depth].index++; + expect = EXPECT_END; + break; + case ',': + if (!depth) + JSON_INVALID(); /* unbalanced nodes */ + + nodes[depth - 1].index++; + if (nodes[depth - 1].type == TYPE_OBJECT) { + iskey = 1; + expect = EXPECT_STRING; + } else { + expect = EXPECT_VALUE; + } + break; + case 't': /* true */ + if (GETNEXT() != 'r' || GETNEXT() != 'u' || GETNEXT() != 'e') + JSON_INVALID(); + nodes[depth].type = TYPE_BOOL; + cb(nodes, depth + 1, "true", pp); + expect = EXPECT_END; + break; + case 'f': /* false */ + if (GETNEXT() != 'a' || GETNEXT() != 'l' || GETNEXT() != 's' || + GETNEXT() != 'e') + JSON_INVALID(); + nodes[depth].type = TYPE_BOOL; + cb(nodes, depth + 1, "false", pp); + expect = EXPECT_END; + break; + case 'n': /* null */ + if (GETNEXT() != 'u' || GETNEXT() != 'l' || GETNEXT() != 'l') + JSON_INVALID(); + nodes[depth].type = TYPE_NULL; + cb(nodes, depth + 1, "null", pp); + expect = EXPECT_END; + break; + default: /* number */ + nodes[depth].type = TYPE_NUMBER; + p = 0; + pri[p++] = c; + expect = EXPECT_END; + while (1) { + c = GETNEXT(); + if (c == EOF || + !c || !strchr("0123456789eE+-.", c) || + p + 1 >= sizeof(pri)) { + pri[p] = '\0'; + cb(nodes, depth + 1, pri, pp); + goto handlechr; /* do not read next char, handle this */ + } else { + pri[p++] = c; + } + } + } + } + if (depth) + JSON_INVALID(); /* unbalanced nodes */ + + ret = 0; /* success */ +end: + for (depth = 0; depth < sizeof(nodes) / sizeof(nodes[0]); depth++) + free(nodes[depth].name); + free(str); + + return ret; +} (DIR) diff --git a/json.h b/json.h @@ -0,0 +1,28 @@ +#include <stdint.h> + +enum JSONType { + TYPE_ARRAY = 'a', + TYPE_OBJECT = 'o', + TYPE_STRING = 's', + TYPE_BOOL = 'b', + TYPE_NULL = '?', + TYPE_NUMBER = 'n' +}; + +enum JSONError { + JSON_ERROR_MEM = -2, + JSON_ERROR_INVALID = -1 +}; + +#define JSON_MAX_NODE_DEPTH 64 + +struct json_node { + enum JSONType type; + char *name; + size_t namesiz; + size_t index; /* count/index for array or object type */ +}; + +int parsejson(const char *, size_t, + void (*cb)(struct json_node *, size_t, const char *, void *), + void *); (DIR) diff --git a/reddit/cgi.c b/reddit/cgi.c @@ -0,0 +1,383 @@ +#include <sys/socket.h> +#include <sys/types.h> + +#include <ctype.h> +#include <errno.h> +#include <netdb.h> +#include <stdarg.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <time.h> +#include <unistd.h> + +#include "reddit.h" + +#define OUT(s) (fputs((s), stdout)) + +extern char **environ; + +static struct list_response *response; +static time_t now; + +/* CGI parameters */ +static char rawsearch[4096], search[4096]; +static char subreddit[1024], mode[16], slimit[32], order[16]; +static long limit = 100; + +void +parsecgi(void) +{ + char *query, *p; + size_t len; + + if (!(query = getenv("QUERY_STRING"))) + query = ""; + + /* subreddit: select subreddit */ + if ((p = getparam(query, "subreddit"))) { + if (decodeparam(subreddit, sizeof(subreddit), p) == -1) + subreddit[0] = '\0'; + } + + /* order */ + if ((p = getparam(query, "o"))) { + if (decodeparam(order, sizeof(order), p) == -1 || + (strcmp(order, "hot") && + strcmp(order, "new") && + strcmp(order, "rising") && + strcmp(order, "controversial"))) + order[0] = '\0'; + } + if (!order[0]) + snprintf(order, sizeof(order), "hot"); + +#if 0 + /* TODO */ + /* page */ + if ((p = getparam(query, "page"))) { + if (decodeparam(page, sizeof(page), p) == -1) + page[0] = '\0'; + /* check if it's a number > 0 and < 100 */ + errno = 0; + curpage = strtol(page, NULL, 10); + if (errno || curpage < 0 || curpage > 100) { + curpage = 1; + page[0] = '\0'; + } + } +#endif + + /* limit */ + if ((p = getparam(query, "limit"))) { + if (decodeparam(slimit, sizeof(slimit), p) == -1) + slimit[0] = '\0'; + /* check if it's a number >= 0 and < 100 */ + errno = 0; + limit = strtol(slimit, NULL, 10); + if (errno || limit <= 0 || limit > 100) { + limit = 100; /* default */ + slimit[0] = '\0'; + } + } + + /* mode */ + if ((p = getparam(query, "m"))) { + if (decodeparam(mode, sizeof(mode), p) != -1) { + /* fixup first character (label) for matching */ + if (mode[0]) + mode[0] = tolower((unsigned char)mode[0]); + /* allowed themes */ + if (strcmp(mode, "light") && + strcmp(mode, "dark") && + strcmp(mode, "pink") && + strcmp(mode, "templeos")) + mode[0] = '\0'; + } + } + if (!mode[0]) + snprintf(mode, sizeof(mode), "light"); + + /* search */ + if ((p = getparam(query, "q"))) { + if ((len = strcspn(p, "&")) && len + 1 < sizeof(rawsearch)) { + memcpy(rawsearch, p, len); + rawsearch[len] = '\0'; + } + + if (decodeparam(search, sizeof(search), p) == -1) { + OUT("Status: 401 Bad Request\r\n\r\n"); + exit(1); + } + } +} + +int +render(struct list_response *r) +{ + struct item *items = r->items, *item; + char tmp[64], timebuf[32], baseurl[256]; + char *start, *end; + int i; + +#if 0 + if (pledge("stdio", NULL) == -1) { + OUT("Status: 500 Internal Server Error\r\n\r\n"); + exit(1); + } +#endif + + OUT( + "Content-Type: text/html; charset=utf-8\r\n\r\n" + "<!DOCTYPE html>\n<html>\n<head>\n" + "<meta name=\"referrer\" content=\"no-referrer\" />\n" + "<meta http-equiv=\"Content-Type\" content=\"text/html; charset=UTF-8\" />\n" + "<title>"); + if (r->nitems && subreddit[0]) { + OUT("r/"); + xmlencode(subreddit); + OUT(" - Reddit"); + } else { + OUT("Reddit"); + } + OUT("</title>\n"); + OUT( + "<link rel=\"stylesheet\" href=\"css/"); + xmlencode(mode); + OUT( + ".css\" type=\"text/css\" media=\"screen\" />\n" + "<link rel=\"icon\" type=\"image/png\" href=\"/favicon.png\" />\n" + "<meta content=\"width=device-width\" name=\"viewport\" />\n" + "</head>\n" + "<body class=\"search\">\n" + "<form method=\"get\" action=\"\">\n"); + + OUT("<input type=\"hidden\" name=\"m\" value=\""); + xmlencode(mode); + OUT("\" />\n"); + if (subreddit[0]) { + OUT("<input type=\"hidden\" name=\"subreddit\" value=\""); + xmlencode(subreddit); + OUT("\" />\n"); + } + OUT("<input type=\"hidden\" name=\"limit\" value=\""); + xmlencode("100"); /* TODO maybe */ + OUT("\" />\n"); + OUT("<input type=\"hidden\" name=\"order\" value=\""); + xmlencode("hot"); /* TODO maybe */ + OUT("\" />\n"); + + OUT( + "<table class=\"search\" width=\"100%\" border=\"0\" cellpadding=\"0\" cellspacing=\"0\">\n" + "<tr>\n" + "<td width=\"100%\" class=\"input\">\n" + " <input type=\"search\" name=\"q\" value=\"\" placeholder=\"Search...\" size=\"72\" autofocus=\"autofocus\" class=\"search\" accesskey=\"f\" />\n" + "</td>\n" + "<td nowrap class=\"nowrap\">\n" + " <input type=\"submit\" value=\"Search\" class=\"button\"/>\n" + " <select name=\"o\" title=\"Order by\" accesskey=\"o\">\n" + " <option value=\"hot\" selected=\"selected\">Hot</option>\n" + " <option value=\"new\">New</option>\n" + " <option value=\"rising\">Rising</option>\n" + " <option value=\"controversial\">Controversial</option>\n" + " </select>\n" + " <label for=\"m\">Style: </label>\n"); + + if (!strcmp(mode, "light")) + OUT("\t\t<input type=\"submit\" name=\"m\" value=\"Dark\" title=\"Dark mode\" id=\"m\" accesskey=\"s\"/>\n"); + else + OUT("\t\t<input type=\"submit\" name=\"m\" value=\"Light\" title=\"Light mode\" id=\"m\" accesskey=\"s\"/>\n"); + + OUT( + " </td>\n" + "</tr>\n" + "</table>\n" + "</form>\n"); + + if (r->nitems) { + OUT( + "<hr/>\n" + "<table class=\"items\" width=\"100%\" border=\"0\" cellpadding=\"0\" cellspacing=\"0\">\n" + "<tbody>\n"); + + for (i = 0; i < r->nitems; i++) { + item = r->items + i; + + OUT( + "<tr>\n" + "<td valign=\"middle\" align=\"center\" class=\"score\" width=\"100\">\n" + " \n"); + printf("%zu", item->ups); /* upvotes / score */ + OUT( + "</td>\n" + "<td valign=\"middle\" align=\"center\" class=\"thumb\" width=\"140\">\n"); + + if (item->thumbnail[0] && !strncmp(item->thumbnail, "https://", 8)) { + OUT("<a href=\""); + + /* link directly to dash url for video */ + if (item->is_video) + xmlencode(item->dash_url); + else + xmlencode(item->url); + + OUT("\"><img src=\""); + xmlencode(item->thumbnail); + OUT("\" width=\"140\" alt=\"\" /></a>"); + } + + OUT( + "</td>\n" + "<td valign=\"top\">\n" + " <h2><a href=\""); + /* link directly to dash url for video */ + if (item->is_video) + xmlencode(item->dash_url); + else + xmlencode(item->url); + OUT("\">"); + + /* base url of url: somesite.org */ + baseurl[0] = '\0'; + if (!item->is_video && item->url[0]) { + if ((start = strstr(item->url, "://"))) { + start += strlen("://"); + if ((end = strstr(start, "/"))) { + if (end - start + 1 < sizeof(baseurl)) { + memcpy(baseurl, start, end - start); + baseurl[end - start] = '\0'; + } + } + } + } + + if (item->is_video) + OUT("[video] "); + xmlencode(item->title); + + strftime(timebuf, sizeof(timebuf), "%Y-%m-%d %H:%M", &(item->created_tm)); + + OUT("</a></h2>\n"); + OUT( + " <time datetime=\""); + OUT(timebuf); + OUT("\">Submitted "); + if (!friendlytime(item->created_utc)) { + OUT("on "); + OUT(timebuf); + } + OUT(" by</time>\n" + " <a href=\"\">"); + xmlencode(item->author); + OUT("</a>\n"); + + /* global view: show subreddit */ + if (!subreddit[0]) { + /* TODO: link */ + OUT(" in r/"); + xmlencode(item->subreddit); + } + + OUT( + " <br/>\n" + " <br/>\n" + " <a href=\"https://old.reddit.com/"); + xmlencode(item->permalink); + OUT("\">"); + + printf("%zu", item->num_comments); + + OUT(" comments</a>\n" + "</td>\n" + "<td valign=\"top\" align=\"right\" class=\"tags\">\n" + " <span class=\"base\">"); + OUT(baseurl); + OUT( + "</span>\n" + " <br/>\n" + " <span class=\"tag\">"); + + /* TODO: + flair color */ + xmlencode(item->link_flair_text); + OUT(" </span>\n" + "</td>\n" + "</tr>\n"); + + OUT("<tr><td colspan=\"4\"><hr/></td></tr>\n"); + } + OUT("</tbody>\n"); + + /* pagination does not work for user/channel search */ + if (r->before[0] || r->after[0]) { + OUT( + "<tfoot>\n" + "<tr>\n" + "\t<td align=\"left\" class=\"nowrap\" nowrap>\n"); + if (r->before[0]) { + OUT("\t\t<a href=\"?q="); + xmlencode(""); // TODO: remove q param later + OUT("&before="); + xmlencode(r->before); + OUT("&m="); + xmlencode(mode); + OUT("\" rel=\"prev\" accesskey=\"p\">← prev</a>\n"); + } + if (r->after[0]) { + OUT( + "\t</td>\n\t<td colspan=\"2\"></td>\n" + "\t<td align=\"right\" class=\"a-r nowrap\" nowrap>\n"); + OUT("\t\t<a href=\"?q="); // TODO: remove q param later. + xmlencode(""); // + OUT("&after="); + xmlencode(r->after); + OUT("&m="); + xmlencode(mode); + OUT("\" rel=\"next\" accesskey=\"n\">next →</a>\n"); + } + + OUT( + "\t</td>\n" + "</tr>\n" + "</tfoot>\n"); + } + OUT("</table>\n"); + } + + OUT("</body>\n</html>\n"); + + return 0; +} + +int +main(void) +{ + struct list_response *r; + +#if 0 + if (pledge("stdio dns inet rpath unveil", NULL) == -1 || + unveil(TLS_CA_CERT_FILE, "r") == -1 || + unveil(NULL, NULL) == -1) { + OUT("Status: 500 Internal Server Error\r\n\r\n"); + exit(1); + } +#endif + + parsecgi(); + +#if 0 + if (!rawsearch[0] && !chan[0] && !user[0]) + goto show; +#endif + + r = reddit_list(subreddit, ""); // TODO: page + if (!r || r->nitems <= 0) { + OUT("Status: 500 Internal Server Error\r\n\r\n"); + exit(1); + } + +show: + now = time(NULL); + render(r); + + return 0; +} (DIR) diff --git a/reddit/cli.c b/reddit/cli.c @@ -0,0 +1,122 @@ +#include <sys/socket.h> +#include <sys/types.h> + +#include <ctype.h> +#include <errno.h> +#include <netdb.h> +#include <stdarg.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <time.h> +#include <unistd.h> + +#include "util.h" +#include "reddit.h" + +#define OUT(s) (fputs((s), stdout)) +#define OUTESCAPE(s) (printescape(s)) + +/* print: ignore control-characters */ +void +printescape(const char *s) +{ + for (; *s; ++s) + if (!iscntrl((unsigned char)*s)) + fputc(*s, stdout); +} + +void +printitem(struct item *item) +{ + if (!item || !item->title[0]) + return; + + printf("title: %s\n", item->title); + printf("url: %s\n", item->url); + printf("subreddit: %s\n", item->subreddit); + printf("author: %s\n", item->author); + printf("thumbnail: %s\n", item->thumbnail); + printf("ups: %ld\n", item->ups); + printf("downs: %ld\n", item->downs); + printf("num_comments: %ld\n", item->num_comments); + + struct tm *tm = gmtime(&(item->created_utc)); + printf("created_utc: %lld\n", item->created_utc); + printf("created_utc: %s", asctime(tm)); + + if (item->is_video) { + printf("is_video: %d\n", item->is_video); + printf("duration: %ld\n", item->duration); + printf("dash_url: %s\n", item->dash_url); + } + + printf("===\n"); +} + +int +render(struct list_response *r) +{ + size_t i; + +#if 0 + if (pledge("stdio", NULL) == -1) { + fprintf(stderr, "pledge: %s\n", strerror(errno)); + exit(1); + } +#endif + + for (i = 0; i < r->nitems; i++) + printitem(&(r->items[i])); + + if (r->before[0]) + printf("before pagination token: %s\n", r->before); + if (r->after[0]) + printf("after pagination token: %s\n", r->after); + + return 0; +} + +static void +usage(const char *argv0) +{ + fprintf(stderr, "usage: %s [subreddit]\n", argv0); + exit(1); +} + +int +main(int argc, char *argv[]) +{ + struct list_response *r; + char subreddit[1024] = ""; + +#if 0 + if (pledge("stdio dns inet rpath unveil", NULL) == -1) { + fprintf(stderr, "pledge: %s\n", strerror(errno)); + exit(1); + } + if (unveil(TLS_CA_CERT_FILE, "r") == -1) { + fprintf(stderr, "unveil: %s\n", strerror(errno)); + exit(1); + } + if (unveil(NULL, NULL) == -1) { + fprintf(stderr, "unveil: %s\n", strerror(errno)); + exit(1); + } +#endif + + if (argc > 1) { + if (!uriencode(argv[1], subreddit, sizeof(subreddit))) + usage(argv[0]); + } + + r = reddit_list(subreddit, ""); /* TODO: pagination */ + if (!r || r->nitems == 0) { + OUT("No items found\n"); + exit(1); + } + + render(r); + + return 0; +} (DIR) diff --git a/reddit/gopher.c b/reddit/gopher.c @@ -0,0 +1,199 @@ +#include <sys/socket.h> +#include <sys/types.h> + +#include <ctype.h> +#include <errno.h> +#include <netdb.h> +#include <stdarg.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <time.h> +#include <unistd.h> + +#include "util.h" +#include "reddit.h" + +#define OUT(s) (fputs((s), stdout)) +#define OUTLINK(s) gophertext(stdout, s, strlen(s)) +#define OUTTEXT(s) gophertext(stdout, s, strlen(s)) + +static const char *baserel = "/reddit.cgi"; +static const char *host = "127.0.0.1", *port = "70"; + +void +line(int _type, const char *username, const char *selector) +{ + putchar(_type); + OUTTEXT(username); + putchar('\t'); + OUTLINK(selector); + printf("\t%s\t%s\r\n", host, port); +} + +void +info(const char *s) +{ + line('i', s, ""); +} + +void +dir(const char *username, const char *selector) +{ + line('1', username, selector); +} + +void +html(const char *username, const char *selector) +{ + line('h', username, selector); +} + +void +page(int _type, const char *username, const char *page) +{ + putchar(_type); + OUTTEXT(username); + putchar('\t'); + printf("%s?p=%s", baserel, page); + printf("\t%s\t%s\r\n", host, port); +} + +void +printitem(struct item *item) +{ + if (!item || !item->title[0]) + return; + + struct tm *tm = gmtime(&(item->created_utc)); + + putchar('h'); + OUTTEXT(item->title); + OUT(" by "); + OUTTEXT(item->author); + OUT(" at "); + printf("%04d-%02d-%02d %02d:%02d", + tm->tm_year + 1900, tm->tm_mon + 1, tm->tm_mday, + tm->tm_hour, tm->tm_min); + OUT("\tURL:"); + OUTLINK(item->url); + printf("\t%s\t%s\r\n", host, port); + +#if 0 + printf("1Posted in r/"); + OUTTEXT(item->subreddit); + OUT("\t"); + OUTTEXT(baserel); + OUT("?subreddit="); + OUTTEXT(item->subreddit); + printf("\t%s\t%s\r\n", host, port); +#endif + +// printf("thumbnail: %s\n", item->thumbnail); + printf("iUpvotes: %ld, downvotes %ld, comments: %ld\t\t%s\t%s\r\n", + item->ups, item->downs, item->num_comments, host, port); + + if (item->thumbnail[0]) { + putchar('h'); + OUT("Thumbnail: "); + OUTTEXT(item->thumbnail); + OUT("\tURL:"); + OUTTEXT(item->thumbnail); + printf("\t%s\t%s\r\n", host, port); + } + + if (item->is_video) { + putchar('h'); + OUT("Video, duration: "); + printf("%ld, url: ", item->duration); + OUTTEXT(item->dash_url); + OUT("\tURL:"); + OUTTEXT(item->dash_url); + printf("\t%s\t%s\r\n", host, port); + } + + info(""); +} + +int +render(struct list_response *r) +{ + size_t i; + +#if 0 + if (pledge("stdio", NULL) == -1) { + fprintf(stderr, "pledge: %s\n", strerror(errno)); + exit(1); + } +#endif + + for (i = 0; i < r->nitems; i++) + printitem(&(r->items[i])); + +#if 0 + if (r->before[0]) + printf("before pagination token: %s\n", r->before); + if (r->after[0]) + printf("after pagination token: %s\n", r->after); +#endif + + return 0; +} + +static void +usage(void) +{ + printf("3Specify a subreddit\t\t%s\t%s\r\n", host, port); + printf(".\r\n"); + exit(1); +} + +int +main(int argc, char *argv[]) +{ + struct list_response *r; + char subreddit[1024] = ""; + char *querystring, *p, *search; + +#if 0 + if (pledge("stdio dns inet rpath unveil", NULL) == -1) { + fprintf(stderr, "pledge: %s\n", strerror(errno)); + exit(1); + } + if (unveil(TLS_CA_CERT_FILE, "r") == -1) { + fprintf(stderr, "unveil: %s\n", strerror(errno)); + exit(1); + } + if (unveil(NULL, NULL) == -1) { + fprintf(stderr, "unveil: %s\n", strerror(errno)); + exit(1); + } +#endif + + if (!(querystring = getenv("QUERY_STRING"))) + querystring = ""; + + if ((p = getparam(querystring, "subreddit"))) { + if (decodeparam(subreddit, sizeof(subreddit), p) == -1) + subreddit[0] = '\0'; + } + + if (!subreddit[0]) { + search = getenv("X_GOPHER_SEARCH"); + if (search && !uriencode(search, subreddit, sizeof(subreddit))) + usage(); + } + + if (!subreddit[0]) + usage(); + + r = reddit_list(subreddit, ""); /* TODO: pagination */ + if (!r || r->nitems == 0) { + printf("iNo items found\t\t%s\t%s\r\n", host, port); + exit(1); + } + + render(r); + + return 0; +} (DIR) diff --git a/reddit/reddit.c b/reddit/reddit.c @@ -0,0 +1,234 @@ +#include <sys/socket.h> +#include <sys/types.h> + +#include <ctype.h> +#include <errno.h> +#include <netdb.h> +#include <stdarg.h> +#include <stdint.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <time.h> +#include <unistd.h> + +#include "https.h" +#include "json.h" +#include "reddit.h" +#include "util.h" + +//#define DEBUG_MODE 1 + +static char * +reddit_request(const char *path) +{ + return request("old.reddit.com", path, ""); +} + +/* unmarshal JSON response, skip HTTP headers */ +/* TODO: add to util.c or json.c ? */ +int +json_unmarshal(const char *data, + void (*cb)(struct json_node *, size_t, const char *, void *), + void *pp) +{ + const char *s; + +#ifdef DEBUG_MODE + s = data; /* DEBUG: has no headers */ +#else + /* strip/skip header part */ + if (!(s = strstr(data, "\r\n\r\n"))) { + fprintf(stderr, "error parsing HTTP response header\n"); + return -1; /* invalid response */ + } + s += strlen("\r\n\r\n"); +#endif + + /* parse */ + if (parsejson(s, strlen(s), cb, pp) < 0) { + fprintf(stderr, "error parsing JSON\n"); + return -1; + } + + return 0; +} + +char * +reddit_list_data(const char *subreddit, const char *page) +{ + char path[4096]; + int r; + + if (subreddit[0]) + r = snprintf(path, sizeof(path), "/r/%s/.json", subreddit); + else + r = snprintf(path, sizeof(path), "/.json"); + + +#ifdef DEBUG_MODE +// data = readfile("test.json"); +// data = readfile("poe.json"); + return readfile("nl.json"); +#endif +/* + TODO + if (page[0]) { + strlcat(path, "?page=", sizeof(path)); + strlcat(path, page, sizeof(path)); + }*/ + + if (r < 0 || (size_t)r >= sizeof(path)) + return NULL; + + return reddit_request(path); +} + +void +reddit_list_processnode(struct json_node *nodes, size_t depth, const char *value, + void *pp) +{ + struct list_response *r = (struct list_response *)pp; + struct item *item; + struct json_node *node; + struct tm *tm; + + if (r->nitems >= MAX_ITEMS) + return; + item = &(r->items[r->nitems]); + + if (depth == 3 && + nodes[0].type == TYPE_OBJECT && + nodes[1].type == TYPE_OBJECT && + nodes[2].type == TYPE_STRING && + !strcmp(nodes[0].name, "") && + !strcmp(nodes[1].name, "data")) { + if (!strcmp(nodes[2].name, "before")) { + strlcpy(r->before, value, sizeof(r->before)); + } else if (!strcmp(nodes[2].name, "after")) { + strlcpy(r->after, value, sizeof(r->after)); + } + } + + if (depth == 5 && + nodes[0].type == TYPE_OBJECT && + nodes[1].type == TYPE_OBJECT && + nodes[2].type == TYPE_ARRAY && + nodes[3].type == TYPE_OBJECT && + nodes[4].type == TYPE_OBJECT && + !strcmp(nodes[0].name, "") && + !strcmp(nodes[1].name, "data") && + !strcmp(nodes[2].name, "children") && + !strcmp(nodes[3].name, "") && + !strcmp(nodes[4].name, "data")) { + if (r->nitems < MAX_ITEMS && r->items[r->nitems].title[0]) + r->nitems++; + } + + if (depth >= 5 && + nodes[0].type == TYPE_OBJECT && + nodes[1].type == TYPE_OBJECT && + nodes[2].type == TYPE_ARRAY && + nodes[3].type == TYPE_OBJECT && + nodes[4].type == TYPE_OBJECT && + !strcmp(nodes[0].name, "") && + !strcmp(nodes[1].name, "data") && + !strcmp(nodes[2].name, "children") && + !strcmp(nodes[3].name, "") && + !strcmp(nodes[4].name, "data")) { + if (depth == 6) { + node = &nodes[5]; + switch (node->type) { + case TYPE_BOOL: + if (!strcmp(node->name, "is_video")) + item->is_video = value[0] == 't'; + break; + case TYPE_NUMBER: + if (!strcmp(node->name, "ups")) + item->ups = strtol(value, NULL, 10); + else if (!strcmp(node->name, "downs")) + item->downs = strtol(value, NULL, 10); + else if (!strcmp(node->name, "num_comments")) + item->num_comments = strtol(value, NULL, 10); + else if (!strcmp(node->name, "created_utc")) { + item->created_utc = strtoll(value, NULL, 10); + /* convert to struct tm */ + tm = gmtime(&(item->created_utc)); + memcpy(&(item->created_tm), tm, sizeof(*tm)); + } + break; + case TYPE_STRING: + if (!strcmp(node->name, "title")) + strlcpy(item->title, value, sizeof(item->title)); + else if (!strcmp(node->name, "url")) + strlcpy(item->url, value, sizeof(item->url)); + else if (!strcmp(node->name, "permalink")) + strlcpy(item->permalink, value, sizeof(item->permalink)); + else if (!strcmp(node->name, "subreddit")) + strlcpy(item->subreddit, value, sizeof(item->subreddit)); + else if (!strcmp(node->name, "author")) + strlcpy(item->author, value, sizeof(item->author)); + else if (!strcmp(node->name, "thumbnail")) + strlcpy(item->thumbnail, value, sizeof(item->thumbnail)); + else if (!strcmp(node->name, "link_flair_text")) + strlcpy(item->link_flair_text, value, sizeof(item->link_flair_text)); + else if (!strcmp(node->name, "link_flair_background_color") && value[0] == '#') + strlcpy(item->link_flair_background_color, value, sizeof(item->link_flair_background_color)); + break; + default: + break; + } + } else if (depth == 8 && + nodes[5].type == TYPE_OBJECT && + nodes[6].type == TYPE_OBJECT && + (!strcmp(nodes[5].name, "media") || !strcmp(nodes[5].name, "secure_media")) && + !strcmp(nodes[6].name, "reddit_video")) { + node = &nodes[7]; + + switch (node->type) { + case TYPE_NUMBER: + /* prefer "insecure" */ + if (nodes[5].name[0] == 's' && item->duration) + break; + if (!strcmp(node->name, "duration")) + item->duration = strtol(value, NULL, 10); + break; + case TYPE_STRING: + /* prefer "insecure" */ + if (nodes[5].name[0] == 's' && item->dash_url[0]) + break; + if (!strcmp(node->name, "dash_url")) + strlcpy(item->dash_url, value, sizeof(item->dash_url)); + break; + default: + break; + } + } + } +} + +struct list_response * +reddit_list(const char *subreddit, const char *page) +{ + struct list_response *r; + const char *errstr; + char *data, *s; + + if (!(data = reddit_list_data(subreddit, page))) { + fprintf(stderr, "%s\n", __func__); + return NULL; + } + + if (!(r = calloc(1, sizeof(*r)))) { + fprintf(stderr, "calloc\n"); + return NULL; + } + + if (json_unmarshal(data, reddit_list_processnode, r) == -1) { + free(r); + r = NULL; + } + free(data); + + return r; +} (DIR) diff --git a/reddit/reddit.h b/reddit/reddit.h @@ -0,0 +1,32 @@ +struct item { + char title[1024]; + char url[4096]; + char permalink[4096]; + char subreddit[256]; + char author[256]; + char thumbnail[4096]; + long ups; + long downs; + time_t created_utc; + struct tm created_tm; + int is_video; + long num_comments; + long duration; + char dash_url[4096]; + /* flair */ + char link_flair_text[256]; + char link_flair_background_color[8]; +}; + +/* Reddit supports max 100 items in their API */ +#define MAX_ITEMS 100 + +struct list_response { + struct item items[MAX_ITEMS + 1]; + size_t nitems; + /* tokens */ + char before[256]; + char after[256]; +}; + +struct list_response *reddit_list(const char *subreddit, const char *page); (DIR) diff --git a/strlcat.c b/strlcat.c @@ -0,0 +1,55 @@ +/* $OpenBSD: strlcat.c,v 1.15 2015/03/02 21:41:08 millert Exp $ */ + +/* + * Copyright (c) 1998, 2015 Todd C. Miller <Todd.Miller@courtesan.com> + * + * Permission to use, copy, modify, and distribute this software for any + * purpose with or without fee is hereby granted, provided that the above + * copyright notice and this permission notice appear in all copies. + * + * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + */ + +#include <sys/types.h> +#include <string.h> + +/* + * Appends src to string dst of size dsize (unlike strncat, dsize is the + * full size of dst, not space left). At most dsize-1 characters + * will be copied. Always NUL terminates (unless dsize <= strlen(dst)). + * Returns strlen(src) + MIN(dsize, strlen(initial dst)). + * If retval >= dsize, truncation occurred. + */ +size_t +strlcat(char *dst, const char *src, size_t dsize) +{ + const char *odst = dst; + const char *osrc = src; + size_t n = dsize; + size_t dlen; + + /* Find the end of dst and adjust bytes left but don't go past end. */ + while (n-- != 0 && *dst != '\0') + dst++; + dlen = dst - odst; + n = dsize - dlen; + + if (n-- == 0) + return(dlen + strlen(src)); + while (*src != '\0') { + if (n != 0) { + *dst++ = *src; + n--; + } + src++; + } + *dst = '\0'; + + return(dlen + (src - osrc)); /* count does not include NUL */ +} (DIR) diff --git a/strlcpy.c b/strlcpy.c @@ -0,0 +1,50 @@ +/* $OpenBSD: strlcpy.c,v 1.12 2015/01/15 03:54:12 millert Exp $ */ + +/* + * Copyright (c) 1998, 2015 Todd C. Miller <Todd.Miller@courtesan.com> + * + * Permission to use, copy, modify, and distribute this software for any + * purpose with or without fee is hereby granted, provided that the above + * copyright notice and this permission notice appear in all copies. + * + * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + */ + +#include <sys/types.h> +#include <string.h> + +/* + * Copy string src to buffer dst of size dsize. At most dsize-1 + * chars will be copied. Always NUL terminates (unless dsize == 0). + * Returns strlen(src); if retval >= dsize, truncation occurred. + */ +size_t +strlcpy(char *dst, const char *src, size_t dsize) +{ + const char *osrc = src; + size_t nleft = dsize; + + /* Copy as many bytes as will fit. */ + if (nleft != 0) { + while (--nleft != 0) { + if ((*dst++ = *src++) == '\0') + break; + } + } + + /* Not enough room in dst, add NUL and traverse rest of src. */ + if (nleft == 0) { + if (dsize != 0) + *dst = '\0'; /* NUL-terminate dst */ + while (*src++) + ; + } + + return(src - osrc - 1); /* count does not include NUL */ +} (DIR) diff --git a/twitch/cgi.c b/twitch/cgi.c @@ -0,0 +1,496 @@ +#include <sys/socket.h> +#include <sys/types.h> + +#include <ctype.h> +#include <errno.h> +#include <netdb.h> +#include <stdarg.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <time.h> +#include <unistd.h> + +#include "https.h" +#include "json.h" +#include "twitch.h" +#include "util.h" + +#define OUT(s) (fputs((s), stdout)) + +extern char **environ; + +/* page variables */ +static char *title = "", *pagetitle = "", *classname = ""; + +static int +gamecmp_name(const void *v1, const void *v2) +{ + struct game *g1 = (struct game *)v1; + struct game *g2 = (struct game *)v2; + + return strcmp(g1->name, g2->name); +} + +void +header(void) +{ + OUT( + "<!DOCTYPE html>\n" + "<html>\n" + "<head>\n" + "<title>"); + if (title[0]) { + xmlencode(title); + OUT(" - "); + } + if (pagetitle[0]) { + xmlencode(pagetitle); + OUT(" - "); + } + OUT("Twitch.tv</title>\n" + " <meta http-equiv=\"Content-Type\" content=\"text/html; charset=UTF-8\" />\n" + " <meta http-equiv=\"Content-Language\" content=\"en\" />\n" + " <link href=\"/twitch.css\" rel=\"stylesheet\" type=\"text/css\" />\n" + "</head>\n" + "<body class=\""); + xmlencode(classname); + OUT( + "\">\n" + "<div id=\"menuwrap\">\n" + "<div id=\"menu\">\n" + "<span id=\"links\">\n" + " <a href=\"/featured\">Featured</a> | \n" + " <a href=\"/games\">Games</a> | \n" + " <a href=\"/vods\">VODS</a> |\n" + " <a href=\"https://git.codemadness.org/twitch-go/\">Source-code</a> | \n" + " <a href=\"/links\">Links</a>\n" + "</span>\n" + "</div></div>\n" + " <hr class=\"hidden\" />\n" + " <div id=\"mainwrap\">\n" + " <div id=\"main\">\n"); + + if (pagetitle[0] || title[0]) { + OUT("<h1><a href=\"\">"); + if (title[0]) + xmlencode(title); + if (pagetitle[0]) { + if (title[0]) + OUT(" - "); + xmlencode(pagetitle); + } + OUT("</a></h1>\n"); + } +} + +void +footer(void) +{ + OUT("</div></div></body></html>\n"); +} + +void +render_links(void) +{ + OUT("Content-Type: text/html; charset=utf-8\r\n\r\n"); + + header(); + OUT("<ul>\n" + "<li><a href=\"https://mpv.io/installation/\">mpv player</a></li>\n" + "<li><a href=\"https://github.com/ytdl-org/youtube-dl\">youtube-dl</a></li>\n" + "<li><a href=\"https://www.videolan.org/\">VLC</a></li>\n" + "<li><a href=\"https://dev.twitch.tv/docs\">Twitch.tv API</a></li>\n" + "</ul>\n"); + footer(); +} + +void +render_games_top(struct games_response *r) +{ + struct game *game; + size_t i; + + OUT("Content-Type: text/html; charset=utf-8\r\n\r\n"); + + header(); + OUT("<table class=\"table\" border=\"0\">"); + OUT("<thead>\n<tr>"); + OUT("<th align=\"left\" class=\"name\">Name</th>"); + OUT("</tr>\n</thead>\n<tbody>\n"); + for (i = 0; i < r->nitems; i++) { + game = &(r->data[i]); + + OUT("<tr><td><a href=\"streams?game_id="); + xmlencode(game->id); + OUT("\">"); + xmlencode(game->name); + OUT("</a></td></tr>\n"); + } + OUT("</tbody></table>"); + footer(); +} + +void +render_streams(struct streams_response *r, const char *game_id) +{ + struct stream *stream; + size_t i; + + OUT("Content-Type: text/html; charset=utf-8\r\n\r\n"); + + header(); + OUT("<table class=\"table\" border=\"0\">"); + OUT("<thead>\n<tr>"); + if (!game_id[0]) + OUT("<th align=\"left\" class=\"game\">Game</th>"); + + OUT("<th align=\"left\" class=\"name\">Name</th>"); + OUT("<th align=\"left\" class=\"title\">Title</th>"); + OUT("<th align=\"right\" class=\"viewers\">Viewers</th>"); + OUT("</tr>\n</thead>\n<tbody>\n"); + for (i = 0; i < r->nitems; i++) { + stream = &(r->data[i]); + + OUT("<tr>"); + + if (!game_id[0]) { + OUT("<td>"); + if (r->data[i].game) { + OUT("<a href=\"streams?game_id="); + xmlencode(r->data[i].game->id); + OUT("\">"); + xmlencode(r->data[i].game->name); + OUT("</a>"); + } + OUT("</td>"); + } + + OUT("<td class=\"name\">"); + if (stream->user) { + OUT("<a href=\"https://www.twitch.tv/"); + xmlencode(stream->user->login); + OUT("\">"); + xmlencode(stream->user_name); + OUT("</a>"); + } else { + xmlencode(stream->user_name); + } + OUT("</td>"); + + OUT("<td class=\"title\">"); + if (stream->user) { + OUT("<a href=\"https://www.twitch.tv/"); + xmlencode(stream->user->login); + OUT("\">"); + } + if (stream->language[0]) { + OUT("["); + xmlencode(stream->language); + OUT("] "); + } + xmlencode(stream->title); + if (stream->user) + OUT("</a>"); + + OUT("</td>"); + + OUT("<td align=\"right\" class=\"viewers\">"); + printf("%lld", stream->viewer_count); + OUT("</td>"); + + OUT("</tr>\n"); + } + OUT("</tbody></table>"); + footer(); +} + +void +render_videos_atom(struct videos_response *r, const char *login) +{ + struct video *video; + size_t i; + + OUT("Content-Type: text/xml; charset=utf-8\r\n\r\n"); + + OUT("<feed xmlns=\"http://www.w3.org/2005/Atom\" xml:lang=\"en\">\n"); + for (i = 0; i < r->nitems; i++) { + video = &(r->data[i]); + + OUT("<entry>\n"); + OUT("\t<title type=\"text\">"); + xmlencode(video->title); + OUT("</title>\n"); + OUT("\t<link rel=\"alternate\" type=\"text/html\" href=\""); + xmlencode(video->url); + OUT("\" />\n"); + OUT("\t<id>"); + xmlencode(video->url); + OUT("</id>\n"); + OUT("\t<updated>"); + xmlencode(video->created_at); + OUT("</updated>\n"); + OUT("\t<published>"); + xmlencode(video->created_at); + OUT("</published>\n"); + OUT("</entry>\n"); + } + OUT("</feed>\n"); +} + +void +render_videos(struct videos_response *r, const char *login) +{ + struct video *video; + size_t i; + + OUT("Content-Type: text/html; charset=utf-8\r\n\r\n"); + + header(); + + OUT("<form method=\"get\" action=\"\">\n"); + OUT("\t<input type=\"search\" name=\"login\" value=\"\" placeholder=\"Login name...\" autofocus=\"autofocus\" />\n"); + OUT("\t<input type=\"submit\" name=\"list\" value=\"List vods\" />\n"); + OUT("</form>\n<hr/>\n"); + + /* no results or no user_id parameter: quick exit */ + if (r == NULL) { + footer(); + return; + } + + /* link to atom format. */ + if (login[0]) { + OUT("<p><a href=\"?login="); + xmlencode(login); + OUT("&format=atom\">Atom feed</a></p>\n"); + } + + OUT("<table class=\"table\" border=\"0\">"); + OUT("<thead>\n<tr>"); + OUT("<th align=\"left\" class=\"created_at\">Created</th>"); + OUT("<th align=\"left\" class=\"title\">Title</th>"); + OUT("<th align=\"right\" class=\"duration\">Duration</th>"); + OUT("<th align=\"right\" class=\"views\">Views</th>"); + OUT("</tr>\n</thead>\n<tbody>\n"); + for (i = 0; i < r->nitems; i++) { + video = &(r->data[i]); + + OUT("<tr>"); + + OUT("<td>"); + xmlencode(video->created_at); + OUT("</td>"); + + OUT("<td class=\"wrap\"><a href=\""); + xmlencode(video->url); + OUT("\">"); + xmlencode(video->title); + OUT("</a></td>"); + + OUT("<td align=\"right\"><a href=\""); + xmlencode(video->url); + OUT("\">"); + xmlencode(video->duration); + OUT("</a></td>"); + + OUT("<td align=\"right\">"); + printf("%lld", video->view_count); + OUT("</td>"); + + OUT("</tr>\n"); + } + OUT("</tbody></table>"); + footer(); +} + +void +handle_streams(void) +{ + struct streams_response *r; + struct users_response *ru = NULL; + struct games_response *rg = NULL; + char game_id[32] = ""; + char *p, *querystring; + + pagetitle = "Streams"; + classname = "streams"; + + /* parse "game_id" parameter */ + if ((querystring = getenv("QUERY_STRING"))) { + if ((p = getparam(querystring, "game_id"))) { + if (decodeparam(game_id, sizeof(game_id), p) == -1) + game_id[0] = '\0'; + } + } + + if (game_id[0]) + r = twitch_streams_bygame(game_id); + else + r = twitch_streams(); + + if (r == NULL) + return; + + /* find detailed games data with streams */ + if (!game_id[0]) + rg = twitch_streams_games(r); + + /* find detailed user data with streams */ + ru = twitch_streams_users(r); + + if (pledge("stdio", NULL) == -1) { + OUT("Status: 500 Internal Server Error\r\n\r\n"); + exit(1); + } + + render_streams(r, game_id); + + free(r); + free(rg); + free(ru); +} + +void +handle_videos(void) +{ + struct videos_response *r = NULL; + struct users_response *ru = NULL; + char user_id[32] = "", login[64] = "", format[6] = ""; + char *p, *querystring; + + pagetitle = "Videos"; + classname = "videos"; + + /* parse "user_id" or "login" parameter */ + if ((querystring = getenv("QUERY_STRING"))) { + if ((p = getparam(querystring, "user_id"))) { + if (decodeparam(user_id, sizeof(user_id), p) == -1) + user_id[0] = '\0'; + } + if ((p = getparam(querystring, "login"))) { + if (decodeparam(login, sizeof(login), p) == -1) + login[0] = '\0'; + } + if ((p = getparam(querystring, "format"))) { + if (decodeparam(format, sizeof(format), p) == -1) + format[0] = '\0'; + } + } + + /* no parameter given, show form */ + if (!user_id[0] && !login[0]) { + if (pledge("stdio", NULL) == -1) { + OUT("Status: 500 Internal Server Error\r\n\r\n"); + exit(1); + } + + render_videos(r, ""); + return; + } + + if (user_id[0]) { + r = twitch_videos_byuserid(user_id); + } else { + ru = twitch_users_bylogin(login); + if (ru && ru->nitems > 0) + r = twitch_videos_byuserid(ru->data[0].id); + } + + if (pledge("stdio", NULL) == -1) { + OUT("Status: 500 Internal Server Error\r\n\r\n"); + exit(1); + } + + if (r && r->nitems > 0) + title = r->data[0].user_name; + + if (!strcmp(format, "atom")) + render_videos_atom(r, login); + else + render_videos(r, login); + + free(ru); + free(r); +} + +void +handle_games_top(void) +{ + struct games_response *r; + + pagetitle = "Top 100 games"; + classname = "topgames"; + + if (!(r = twitch_games_top())) + return; + + if (pledge("stdio", NULL) == -1) { + OUT("Status: 500 Internal Server Error\r\n\r\n"); + exit(1); + } + + /* sort by name alphabetically, NOTE: the results are the top 100 + sorted by viewcount. View counts are not visible in the new + Helix API data). */ + qsort(r->data, r->nitems, sizeof(r->data[0]), gamecmp_name); + + render_games_top(r); + free(r); +} + +void +handle_links(void) +{ + if (pledge("stdio", NULL) == -1) { + OUT("Status: 500 Internal Server Error\r\n\r\n"); + exit(1); + } + + pagetitle = "Links"; + classname = "links"; + + render_links(); +} + +int +main(void) +{ + char *path, *pathinfo; + +#if 1 + if (pledge("stdio dns inet rpath unveil", NULL) == -1 || + unveil(TLS_CA_CERT_FILE, "r") == -1 || + unveil(NULL, NULL) == -1) { + OUT("Status: 500 Internal Server Error\r\n\r\n"); + exit(1); + } +#endif + + if (!(pathinfo = getenv("PATH_INFO"))) + pathinfo = "/"; + + path = pathinfo; + if (path[0] == '/') + path++; + + if (!strcmp(path, "") || + !strcmp(path, "featured") || + !strcmp(path, "streams")) { + /* featured / by game id */ + handle_streams(); + } else if (!strcmp(path, "topgames") || + !strcmp(path, "games")) { + handle_games_top(); + } else if (!strcmp(path, "videos") || + !strcmp(path, "vods")) { + handle_videos(); + } else if (!strcmp(path, "links")) { + handle_links(); + } else { + OUT("Status: 404 Not Found\r\n\r\n"); + exit(1); + } + + return 0; +} (DIR) diff --git a/twitch/gopher.c b/twitch/gopher.c @@ -0,0 +1,481 @@ +#include <sys/socket.h> +#include <sys/types.h> + +#include <ctype.h> +#include <errno.h> +#include <locale.h> +#include <netdb.h> +#include <stdarg.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <time.h> +#include <unistd.h> +#include <wchar.h> + +#include "https.h" +#include "json.h" +#include "twitch.h" +#include "util.h" + +#define OUT(s) (fputs((s), stdout)) +#define OUTTEXT(s) gophertext(stdout, s, strlen(s)) +#define OUTLINK(s) gophertext(stdout, s, strlen(s)) + +extern char **environ; + +static const char *baserel = "/twitch.cgi"; +static const char *host = "127.0.0.1", *port = "70"; + +/* page variables */ +static char *title = "", *pagetitle = ""; + +void +line(int _type, const char *username, const char *selector) +{ + putchar(_type); + OUTTEXT(username); + putchar('\t'); + OUTLINK(selector); + printf("\t%s\t%s\r\n", host, port); +} + +void +info(const char *s) +{ + line('i', s, ""); +} + +void +dir(const char *username, const char *selector) +{ + line('1', username, selector); +} + +void +html(const char *username, const char *selector) +{ + line('h', username, selector); +} + +void +page(int _type, const char *username, const char *page) +{ + putchar(_type); + OUTTEXT(username); + putchar('\t'); + printf("%s?p=%s", baserel, page); + printf("\t%s\t%s\r\n", host, port); +} + +static int +gamecmp_name(const void *v1, const void *v2) +{ + struct game *g1 = (struct game *)v1; + struct game *g2 = (struct game *)v2; + + return strcmp(g1->name, g2->name); +} + +void +header(void) +{ + putchar('i'); + if (title[0]) { + OUTTEXT(title); + OUT(" - "); + } + if (pagetitle[0]) { + OUTTEXT(pagetitle); + OUT(" - "); + } + printf("Twitch.tv\t%s\t%s\t%s\r\n", "", host, port); + info("---"); + page('1', "Featured", "featured"); + page('1', "Games", "games"); + page('1', "VODS", "vods"); + dir("Source-code", "/git/twitch-go"); + page('1', "Links", "links"); + info("---"); +} + +void +footer(void) +{ + printf(".\r\n"); +} + +void +render_links(void) +{ + header(); + info(""); + html("mpv player", "URL:https://mpv.io/installation/"); + html("youtube-dl", "URL:https://github.com/ytdl-org/youtube-dl"); + html("VLC", "URL:https://www.videolan.org/"); + html("Twitch.tv API", "URL:https://dev.twitch.tv/docs"); + footer(); +} + +void +render_games_top(struct games_response *r) +{ + struct game *game; + size_t i; + + header(); + info("Name"); + for (i = 0; i < r->nitems; i++) { + game = &(r->data[i]); + + putchar('1'); + OUTTEXT(game->name); + printf("\t%s?p=streams&game_id=", baserel); + OUTLINK(game->id); + printf("\t%s\t%s\r\n", host, port); + } + footer(); +} + +void +render_streams(struct streams_response *r, const char *game_id) +{ + struct stream *stream; + char buf[256], title[256]; + size_t i; + + header(); + if (!game_id[0]) + printf("i%-20s %-20s %-50s %7s\t%s\t%s\t%s\r\n", + "Game", "Name", "Title", "Viewers", "", host, port); + else + printf("i%-20s %-50s %7s\t%s\t%s\t%s\r\n", + "Name", "Title", "Viewers", "", host, port); + + for (i = 0; i < r->nitems; i++) { + stream = &(r->data[i]); + + if (stream->user) + putchar('h'); + else + putchar('i'); + + if (!game_id[0]) { + if (stream->game) { + if (utf8pad(buf, sizeof(buf), stream->game->name, 20, ' ') != -1) + OUTTEXT(buf); + } else { + printf("%20s", ""); + } + OUT(" "); + } + + if (utf8pad(buf, sizeof(buf), stream->user_name, 20, ' ') != -1) + OUTTEXT(buf); + OUT(" "); + + if (stream->language[0]) + snprintf(title, sizeof(title), "[%s] %s", stream->language, stream->title); + if (utf8pad(buf, sizeof(buf), title, 50, ' ') != -1) + OUTTEXT(buf); + else { + if (utf8pad(buf, sizeof(buf), stream->title, 50, ' ') != -1) + OUTTEXT(buf); + } + + printf(" %7lld\t", stream->viewer_count); + + if (stream->user) { + OUT("URL:https://www.twitch.tv/"); + OUTLINK(stream->user->login); + } + printf("\t%s\t%s\r\n", host, port); + } + footer(); +} + +void +render_videos_atom(struct videos_response *r, const char *login) +{ + struct video *video; + size_t i; + + OUT("<feed xmlns=\"http://www.w3.org/2005/Atom\" xml:lang=\"en\">\n"); + for (i = 0; i < r->nitems; i++) { + video = &(r->data[i]); + + OUT("<entry>\n"); + OUT("\t<title type=\"text\">"); + xmlencode(video->title); + OUT("</title>\n"); + OUT("\t<link rel=\"alternate\" type=\"text/html\" href=\""); + xmlencode(video->url); + OUT("\" />\n"); + OUT("\t<id>"); + xmlencode(video->url); + OUT("</id>\n"); + OUT("\t<updated>"); + xmlencode(video->created_at); + OUT("</updated>\n"); + OUT("\t<published>"); + xmlencode(video->created_at); + OUT("</published>\n"); + OUT("</entry>\n"); + } + OUT("</feed>\n"); +} + +void +render_videos(struct videos_response *r, const char *login) +{ + struct video *video; + char buf[256]; + size_t i; + + header(); + + page('7', "Submit Twitch login name to list VODs", "vods"); + info(""); + + /* no results or no user_id parameter: quick exit */ + if (r == NULL) { + footer(); + return; + } + + /* link to Atom format (text). */ + if (login[0]) { + OUT("0Atom feed for "); + OUTLINK(login); + printf("\t%s?p=vods&format=atom&login=", baserel); + OUTLINK(login); + printf("\t%s\t%s\r\n", host, port); + info(""); + } + + printf("i%-20s %-50s %-10s %7s\t%s\t%s\t%s\r\n", + "Created", "Title", "Duration", "Views", "", host, port); + + for (i = 0; i < r->nitems; i++) { + video = &(r->data[i]); + + putchar('h'); + if (utf8pad(buf, sizeof(buf), video->created_at, 20, ' ') != -1) + OUTLINK(buf); + OUT(" "); + if (utf8pad(buf, sizeof(buf), video->title, 50, ' ') != -1) + OUTLINK(buf); + OUT(" "); + if (utf8pad(buf, sizeof(buf), video->duration, 10, ' ') != -1) + OUTLINK(buf); + + printf(" %7lld\t", video->view_count); + OUT("URL:"); + OUTLINK(video->url); + printf("\t%s\t%s\r\n", host, port); + } + footer(); +} + +void +handle_streams(void) +{ + struct streams_response *r; + struct users_response *ru = NULL; + struct games_response *rg = NULL; + char game_id[32] = ""; + char *p, *querystring; + + pagetitle = "Streams"; + + /* parse "game_id" parameter */ + if ((querystring = getenv("QUERY_STRING"))) { + if ((p = getparam(querystring, "game_id"))) { + if (decodeparam(game_id, sizeof(game_id), p) == -1) + game_id[0] = '\0'; + } + } + + if (game_id[0]) + r = twitch_streams_bygame(game_id); + else + r = twitch_streams(); + + if (r == NULL) + return; + + /* find detailed games data with streams */ + if (!game_id[0]) + rg = twitch_streams_games(r); + + /* find detailed user data with streams */ + ru = twitch_streams_users(r); + + if (pledge("stdio", NULL) == -1) { +// OUT("Status: 500 Internal Server Error\r\n\r\n"); + exit(1); + } + + render_streams(r, game_id); + + free(r); + free(rg); + free(ru); +} + +void +handle_videos(void) +{ + struct videos_response *r = NULL; + struct users_response *ru = NULL; + char user_id[32] = "", login[64] = "", format[6] = ""; + char *p, *querystring; + + pagetitle = "Videos"; + + /* parse "user_id" or "login" parameter */ + if ((querystring = getenv("QUERY_STRING"))) { + if ((p = getparam(querystring, "user_id"))) { + if (decodeparam(user_id, sizeof(user_id), p) == -1) + user_id[0] = '\0'; + } + if ((p = getparam(querystring, "login"))) { + if (decodeparam(login, sizeof(login), p) == -1) + login[0] = '\0'; + } + if ((p = getparam(querystring, "format"))) { + if (decodeparam(format, sizeof(format), p) == -1) + format[0] = '\0'; + } + } + + /* login: if not set as query string parameter then use gopher search + parameter */ + if (login[0] == '\0' && (p = getenv("X_GOPHER_SEARCH"))) { + if (decodeparam(login, sizeof(login), p) == -1) + login[0] = '\0'; + } + + /* no parameter given, show form */ + if (!user_id[0] && !login[0]) { + if (pledge("stdio", NULL) == -1) { +// OUT("Status: 500 Internal Server Error\r\n\r\n"); + exit(1); + } + + render_videos(r, ""); + return; + } + + if (user_id[0]) { + r = twitch_videos_byuserid(user_id); + } else { + ru = twitch_users_bylogin(login); + if (ru && ru->nitems > 0) + r = twitch_videos_byuserid(ru->data[0].id); + } + + if (pledge("stdio", NULL) == -1) { +// OUT("Status: 500 Internal Server Error\r\n\r\n"); + exit(1); + } + + if (r && r->nitems > 0) + title = r->data[0].user_name; + + if (!strcmp(format, "atom")) + render_videos_atom(r, login); + else + render_videos(r, login); + + free(ru); + free(r); +} + +void +handle_games_top(void) +{ + struct games_response *r; + + pagetitle = "Top 100 games"; + + if (!(r = twitch_games_top())) + return; + + if (pledge("stdio", NULL) == -1) { +// OUT("Status: 500 Internal Server Error\r\n\r\n"); + exit(1); + } + + /* sort by name alphabetically, NOTE: the results are the top 100 + sorted by viewcount. View counts are not visible in the new + Helix API data). */ + qsort(r->data, r->nitems, sizeof(r->data[0]), gamecmp_name); + + render_games_top(r); + + free(r); +} + +void +handle_links(void) +{ + if (pledge("stdio", NULL) == -1) { +// OUT("Status: 500 Internal Server Error\r\n\r\n"); + exit(1); + } + + pagetitle = "Links"; + + render_links(); +} + +int +main(int argc, char *argv[]) +{ + char *p, path[256] = "", *querystring; + + setlocale(LC_CTYPE, ""); + +#if 1 + if (pledge("stdio dns inet rpath unveil", NULL) == -1 || + unveil(TLS_CA_CERT_FILE, "r") == -1 || + unveil(NULL, NULL) == -1) { +// OUT("Status: 500 Internal Server Error\r\n\r\n"); + exit(1); + } +#endif + + if ((p = getenv("SERVER_NAME"))) + host = p; + if ((p = getenv("SERVER_PORT"))) + port = p; + + if (!(querystring = getenv("QUERY_STRING"))) + querystring = ""; + + if ((p = getparam(querystring, "p"))) { + if (decodeparam(path, sizeof(path), p) == -1) + path[0] = '\0'; + } + + if (!strcmp(path, "") || + !strcmp(path, "featured") || + !strcmp(path, "streams")) { + /* featured / by game id */ + handle_streams(); + } else if (!strcmp(path, "topgames") || + !strcmp(path, "games")) { + handle_games_top(); + } else if (!strcmp(path, "videos") || + !strcmp(path, "vods")) { + handle_videos(); + } else if (!strcmp(path, "links")) { + handle_links(); + } else { +// OUT("Status: 404 Not Found\r\n\r\n"); + exit(1); + } + + return 0; +} (DIR) diff --git a/twitch/twitch.c b/twitch/twitch.c @@ -0,0 +1,602 @@ +#include <sys/socket.h> +#include <sys/types.h> + +#include <ctype.h> +#include <errno.h> +#include <netdb.h> +#include <stdarg.h> +#include <stdint.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <time.h> +#include <unistd.h> + +#include "https.h" +#include "json.h" +#include "twitch.h" +#include "util.h" + +//#define DEBUG_MODE 1 + +#ifndef TWITCH_API_KEY +#error "make sure set a TWITCH_API_KEY in twitch.c" +#endif +static char *twitch_headers = "Client-ID: " TWITCH_API_KEY "\r\n"; + +static char * +twitch_request(const char *path) +{ + char *data; + + data = request("api.twitch.tv", path, twitch_headers); +// if (data) { +// printf("DEBUG: path=%s\ndata: %s\n", path, data); +// } + return data; +} + +/* unmarshal JSON response, skip HTTP headers */ +/* TODO: add to util.c or json.c ? */ +int +json_unmarshal(const char *data, + void (*cb)(struct json_node *, size_t, const char *, void *), + void *pp) +{ + const char *s; + +#ifdef DEBUG_MODE + s = data; /* DEBUG: has no headers */ +#else + /* strip/skip header part */ + if (!(s = strstr(data, "\r\n\r\n"))) { + fprintf(stderr, "error parsing HTTP response header\n"); + return -1; /* invalid response */ + } + s += strlen("\r\n\r\n"); +#endif + + /* parse */ + if (parsejson(s, strlen(s), cb, pp) < 0) { + fprintf(stderr, "error parsing JSON\n"); + return -1; + } + + return 0; +} + +char * +twitch_games_bygameids_data(const char *param) +{ + char path[4096]; + int r; + + r = snprintf(path, sizeof(path), "/helix/games?%s", param); + if (r < 0 || (size_t)r >= sizeof(path)) + return NULL; + +#ifdef DEBUG_MODE + return readfile("examples/games.json"); /* DEBUG */ +#else + return twitch_request(path); +#endif +} + +char * +twitch_users_byuserids_data(const char *param) +{ + char path[4096]; + int r; + + r = snprintf(path, sizeof(path), "/helix/users?%s", param); + if (r < 0 || (size_t)r >= sizeof(path)) + return NULL; + +#ifdef DEBUG_MODE + return readfile("examples/users_byid.json"); /* DEBUG */ +#else + return twitch_request(path); +#endif +} + +char * +twitch_users_bylogin_data(const char *login) +{ + char path[256]; + int r; + + r = snprintf(path, sizeof(path), "/helix/users?login=%s", login); + if (r < 0 || (size_t)r >= sizeof(path)) + return NULL; + +#ifdef DEBUG_MODE + return readfile("examples/users.json"); /* DEBUG */ +#else + return twitch_request(path); +#endif +} + +char * +twitch_videos_byuserid_data(const char *user_id) +{ + char path[128]; + int r; + + r = snprintf(path, sizeof(path), "/helix/videos?first=100&user_id=%s", + user_id); + if (r < 0 || (size_t)r >= sizeof(path)) + return NULL; + +#ifdef DEBUG_MODE + return readfile("examples/videos.json"); /* DEBUG */ +#else + return twitch_request(path); +#endif +} + +char * +twitch_streams_data(void) +{ +#ifdef DEBUG_MODE + return readfile("examples/streams.json"); /* DEBUG */ +#else + return twitch_request("/helix/streams?first=100"); +#endif +} + +char * +twitch_streams_game_data(const char *game_id) +{ + char path[64]; + int r; + + r = snprintf(path, sizeof(path), "/helix/streams?first=100&game_id=%s", + game_id); + if (r < 0 || (size_t)r >= sizeof(path)) + return NULL; + +#ifdef DEBUG_MODE + return readfile("examples/streams_game_30921.json"); /* DEBUG */ +#else + return twitch_request(path); +#endif +} + +char * +twitch_games_top_data(void) +{ +#ifdef DEBUG_MODE + return readfile("examples/topgames.json"); /* DEBUG */ +#else + return twitch_request("/helix/games/top?first=100"); +#endif +} + +void +twitch_games_processnode(struct json_node *nodes, size_t depth, const char *value, + void *pp) +{ + struct games_response *r = (struct games_response *)pp; + struct game *item; + + if (r->nitems > MAX_ITEMS) + return; + + /* new item */ + if (depth == 3 && + nodes[0].type == TYPE_OBJECT && + nodes[1].type == TYPE_ARRAY && + nodes[2].type == TYPE_OBJECT && + !strcmp(nodes[1].name, "data")) { + r->nitems++; + return; + } + + if (r->nitems == 0) + return; + item = &(r->data[r->nitems - 1]); + + if (depth == 4 && + nodes[0].type == TYPE_OBJECT && + nodes[1].type == TYPE_ARRAY && + nodes[2].type == TYPE_OBJECT && + nodes[3].type == TYPE_STRING && + !strcmp(nodes[1].name, "data")) { + if (!strcmp(nodes[3].name, "id")) + strlcpy(item->id, value, sizeof(item->id)); + else if (!strcmp(nodes[3].name, "name")) + strlcpy(item->name, value, sizeof(item->name)); + } +} + +struct games_response * +twitch_games_top(void) +{ + struct games_response *r; + char *data; + + if ((data = twitch_games_top_data()) == NULL) { + fprintf(stderr, "%s\n", __func__); + return NULL; + } + + if (!(r = calloc(1, sizeof(*r)))) { + fprintf(stderr, "calloc\n"); + return NULL; + } + if (json_unmarshal(data, twitch_games_processnode, r) == -1) { + free(r); + r = NULL; + } + free(data); + + return r; +} + +struct games_response * +twitch_games_bygameids(const char *param) +{ + struct games_response *r; + char *data; + + if ((data = twitch_games_bygameids_data(param)) == NULL) { + fprintf(stderr, "%s\n", __func__); + return NULL; + } + + if (!(r = calloc(1, sizeof(*r)))) { + fprintf(stderr, "calloc\n"); + return NULL; + } + if (json_unmarshal(data, twitch_games_processnode, r) == -1) { + free(r); + r = NULL; + } + free(data); + + return r; +} + +void +twitch_streams_processnode(struct json_node *nodes, size_t depth, const char *value, + void *pp) +{ + struct streams_response *r = (struct streams_response *)pp; + struct stream *item; + + if (r->nitems > MAX_ITEMS) + return; + item = &(r->data[r->nitems]); + + /* new item */ + if (depth == 3 && + nodes[0].type == TYPE_OBJECT && + nodes[1].type == TYPE_ARRAY && + nodes[2].type == TYPE_OBJECT && + !strcmp(nodes[1].name, "data")) { + r->nitems++; + return; + } + + if (r->nitems == 0) + return; + item = &(r->data[r->nitems - 1]); + + if (depth == 4 && + nodes[0].type == TYPE_OBJECT && + nodes[1].type == TYPE_ARRAY && + nodes[2].type == TYPE_OBJECT && + !strcmp(nodes[1].name, "data")) { + if (nodes[3].type == TYPE_STRING) { + if (!strcmp(nodes[3].name, "id")) + strlcpy(item->id, value, sizeof(item->id)); + else if (!strcmp(nodes[3].name, "title")) + strlcpy(item->title, value, sizeof(item->title)); + else if (!strcmp(nodes[3].name, "user_id")) + strlcpy(item->user_id, value, sizeof(item->user_id)); + else if (!strcmp(nodes[3].name, "user_name")) + strlcpy(item->user_name, value, sizeof(item->user_name)); + else if (!strcmp(nodes[3].name, "game_id")) + strlcpy(item->game_id, value, sizeof(item->game_id)); + else if (!strcmp(nodes[3].name, "language")) + strlcpy(item->language, value, sizeof(item->language)); + } else if (nodes[3].type == TYPE_NUMBER) { + /* TODO: check? */ + if (!strcmp(nodes[3].name, "viewer_count")) + item->viewer_count = strtoll(value, NULL, 10); + } + } +} + +struct streams_response * +twitch_streams_bygame(const char *game_id) +{ + struct streams_response *r; + char *data; + + if (game_id[0]) + data = twitch_streams_game_data(game_id); + else + data = twitch_streams_data(); + + if (!(r = calloc(1, sizeof(*r)))) { + fprintf(stderr, "calloc\n"); + return NULL; + } + if (json_unmarshal(data, twitch_streams_processnode, r) == -1) { + free(r); + r = NULL; + } + free(data); + + return r; +} + +struct streams_response * +twitch_streams(void) +{ + return twitch_streams_bygame(""); +} + +int +ids_cmp(const void *v1, const void *v2) +{ + const char *s1 = *((const char**)v1), *s2 = *((const char **)v2); + + return strcmp(s1, s2); +} + +/* fill in games in the streams response */ +struct games_response * +twitch_streams_games(struct streams_response *r) +{ + struct games_response *rg; + char *game_ids[MAX_ITEMS]; + char game_ids_param[4096] = ""; + size_t i, j; + + /* create a list of game_ids, sort them and filter unique */ + for (i = 0; i < r->nitems; i++) + game_ids[i] = r->data[i].game_id; + + qsort(game_ids, r->nitems, sizeof(*game_ids), ids_cmp); + for (i = 0; i < r->nitems; i++) { + if (!game_ids[i][0]) + continue; + + /* first or different than previous */ + if (i && !strcmp(game_ids[i], game_ids[i - 1])) + continue; + + if (game_ids_param[0]) + strlcat(game_ids_param, "&", sizeof(game_ids_param)); + + strlcat(game_ids_param, "id=", sizeof(game_ids_param)); + strlcat(game_ids_param, game_ids[i], sizeof(game_ids_param)); + } + + if ((rg = twitch_games_bygameids(game_ids_param))) { + for (i = 0; i < r->nitems; i++) { + for (j = 0; j < rg->nitems; j++) { + /* match game on game_id */ + if (!strcmp(r->data[i].game_id, rg->data[j].id)) { + r->data[i].game = &(rg->data[j]); + break; + } + } + } + } + return rg; +} + +/* fill in users in the streams response */ +struct users_response * +twitch_streams_users(struct streams_response *r) +{ + struct users_response *ru = NULL; + char *user_ids[MAX_ITEMS]; + char user_ids_param[4096] = ""; + size_t i, j; + + /* create a list of user_ids, sort them and filter unique */ + for (i = 0; i < r->nitems; i++) + user_ids[i] = r->data[i].user_id; + + qsort(user_ids, r->nitems, sizeof(*user_ids), ids_cmp); + for (i = 0; i < r->nitems; i++) { + if (!user_ids[i][0]) + continue; + /* first or different than previous */ + if (i && !strcmp(user_ids[i], user_ids[i - 1])) + continue; + + if (user_ids_param[0]) + strlcat(user_ids_param, "&", sizeof(user_ids_param)); + + strlcat(user_ids_param, "id=", sizeof(user_ids_param)); + strlcat(user_ids_param, user_ids[i], sizeof(user_ids_param)); + } + + if ((ru = twitch_users_byuserids(user_ids_param))) { + for (i = 0; i < r->nitems; i++) { + for (j = 0; j < ru->nitems; j++) { + /* match user on user_id */ + if (!strcmp(r->data[i].user_id, ru->data[j].id)) { + r->data[i].user = &(ru->data[j]); + break; + } + } + } + } + return ru; +} + +void +twitch_users_processnode(struct json_node *nodes, size_t depth, const char *value, + void *pp) +{ + struct users_response *r = (struct users_response *)pp; + struct user *item; + + if (r->nitems > MAX_ITEMS) + return; + item = &(r->data[r->nitems]); + + /* new item */ + if (depth == 3 && + nodes[0].type == TYPE_OBJECT && + nodes[1].type == TYPE_ARRAY && + nodes[2].type == TYPE_OBJECT && + !strcmp(nodes[1].name, "data")) { + r->nitems++; + return; + } + + if (r->nitems == 0) + return; + item = &(r->data[r->nitems - 1]); + + if (depth == 4 && + nodes[0].type == TYPE_OBJECT && + nodes[1].type == TYPE_ARRAY && + nodes[2].type == TYPE_OBJECT && + !strcmp(nodes[1].name, "data")) { + if (nodes[3].type == TYPE_STRING) { + if (!strcmp(nodes[3].name, "id")) + strlcpy(item->id, value, sizeof(item->id)); + else if (!strcmp(nodes[3].name, "login")) + strlcpy(item->login, value, sizeof(item->login)); + else if (!strcmp(nodes[3].name, "display_name")) + strlcpy(item->display_name, value, sizeof(item->display_name)); + } else if (nodes[3].type == TYPE_NUMBER) { + /* TODO: check? */ + if (!strcmp(nodes[3].name, "view_count")) + item->view_count = strtoll(value, NULL, 10); + } + } +} + +struct users_response * +twitch_users_byuserids(const char *param) +{ + struct users_response *r; + char *data; + + if ((data = twitch_users_byuserids_data(param)) == NULL) { + fprintf(stderr, "%s\n", __func__); + return NULL; + } + + if (!(r = calloc(1, sizeof(*r)))) { + fprintf(stderr, "calloc\n"); + return NULL; + } + if (json_unmarshal(data, twitch_users_processnode, r) == -1) { + free(r); + r = NULL; + } + free(data); + + return r; +} + +struct users_response * +twitch_users_bylogin(const char *login) +{ + struct users_response *r; + char *data; + + if ((data = twitch_users_bylogin_data(login)) == NULL) { + fprintf(stderr, "%s\n", __func__); + return NULL; + } + + if (!(r = calloc(1, sizeof(*r)))) { + fprintf(stderr, "calloc\n"); + return NULL; + } + if (json_unmarshal(data, twitch_users_processnode, r) == -1) { + free(r); + r = NULL; + } + free(data); + + return r; +} + +void +twitch_videos_processnode(struct json_node *nodes, size_t depth, const char *value, + void *pp) +{ + struct videos_response *r = (struct videos_response *)pp; + struct video *item; + + if (r->nitems > MAX_ITEMS) + return; + item = &(r->data[r->nitems]); + + /* new item */ + if (depth == 3 && + nodes[0].type == TYPE_OBJECT && + nodes[1].type == TYPE_ARRAY && + nodes[2].type == TYPE_OBJECT && + !strcmp(nodes[1].name, "data")) { + r->nitems++; + return; + } + + if (r->nitems == 0) + return; + item = &(r->data[r->nitems - 1]); + + if (depth == 4 && + nodes[0].type == TYPE_OBJECT && + nodes[1].type == TYPE_ARRAY && + nodes[2].type == TYPE_OBJECT && + !strcmp(nodes[1].name, "data")) { + if (nodes[3].type == TYPE_STRING) { + if (!strcmp(nodes[3].name, "id")) + strlcpy(item->id, value, sizeof(item->id)); + else if (!strcmp(nodes[3].name, "user_id")) + strlcpy(item->user_id, value, sizeof(item->user_id)); + else if (!strcmp(nodes[3].name, "user_name")) + strlcpy(item->user_name, value, sizeof(item->user_name)); + else if (!strcmp(nodes[3].name, "title")) + strlcpy(item->title, value, sizeof(item->title)); + else if (!strcmp(nodes[3].name, "created_at")) + strlcpy(item->created_at, value, sizeof(item->created_at)); + else if (!strcmp(nodes[3].name, "url")) + strlcpy(item->url, value, sizeof(item->url)); + else if (!strcmp(nodes[3].name, "duration")) + strlcpy(item->duration, value, sizeof(item->duration)); + } else if (nodes[3].type == TYPE_NUMBER) { + /* TODO: check? */ + if (!strcmp(nodes[3].name, "view_count")) + item->view_count = strtoll(value, NULL, 10); + } + } +} + +struct videos_response * +twitch_videos_byuserid(const char *user_id) +{ + struct videos_response *r; + char *data; + + if ((data = twitch_videos_byuserid_data(user_id)) == NULL) { + fprintf(stderr, "%s\n", __func__); + return NULL; + } + + if (!(r = calloc(1, sizeof(*r)))) { + fprintf(stderr, "calloc\n"); + return NULL; + } + if (json_unmarshal(data, twitch_videos_processnode, r) == -1) { + free(r); + r = NULL; + } + free(data); + + return r; +} (DIR) diff --git a/twitch/twitch.css b/twitch/twitch.css @@ -0,0 +1,105 @@ +body { + font-family: sans-serif, monospace; + text-align: center; + overflow-y: scroll; + color: #000; + background-color: #fff; + margin: 0; + padding: 0; +} +table { + border: 0; +} +hr { + height: 1px; + color: #ccc; + background-color: #ccc; + border: 0; +} +h1 { + font-size: 140%; +} +h2 { + font-size: 120%; +} +h3 { + font-size: 100%; +} +h1, h1 a, h1 a:visited, +h2, h2 a, h2 a:visited, +h3, h3 a, h3 a:visited, +h1 a:hover, h2 a:hover, h3 a:hover { + color: inherit; +} + +table.table { + border-collapse: collapse; + width: 100%; +} +table tr th { + text-align: left; + font-weight: bold; +} +table.table tr th { + padding: 3px; + border: 1px solid #777; + border-bottom: 3px solid #777; +} +table.table tr td { + padding: 3px; + border: 1px solid #777; +} +table.table tr th { + background-color: #eee; +} + +table.table tr th.viewers, +table.table tr th.channels { + text-align: right; +} + +table.table tr td.title { + max-width: 30ex; + text-overflow: ellipsis; + overflow: hidden; +} +pre, code { + border: 1px dashed #777; + background-color: #eee; + padding: 5px; + display: block; + overflow-x: auto; +} +code { + white-space: nowrap; + word-wrap: normal; +} +#menuwrap { + background-color: #eee; + padding: 1ex; + border-bottom: 1px solid #ccc; +} +#main { + padding: 1ex; +} +#menu, +#main { + margin: 0px auto; + text-align: left; + max-width: 100ex; +} +#menu a { + font-weight: bold; + vertical-align: middle; +} +#links-contact { + float: right; +} +.hidden { + display: none; +} + +label { + display: inline-block; + width: 10ex; +} (DIR) diff --git a/twitch/twitch.h b/twitch/twitch.h @@ -0,0 +1,80 @@ +struct game { + char id[16]; + char name[256]; + // char box_art_url[256]; +}; + +struct stream { + char id[16]; + char user_id[16]; + char user_name[256]; + char game_id[16]; +// char type[32]; + char title[256]; + long long viewer_count; +// char started_at[24]; + char language[8]; +// char thumbnail_url[256]; + + /* added (not part of API) */ + struct game *game; /* will be set if matched and not a specific game */ + struct user *user; /* will be set if matched */ +}; + +struct user { + char id[16]; + char login[256]; + char display_name[256]; +// char broadcaster_type[256]; /* "partner" */ +// char description[256]; +// char profile_image_url[256]; +// char offline_image_url[256]; + long long view_count; +}; + +struct video { + char id[16]; + char user_id[16]; + char user_name[64]; + char title[256]; + char created_at[32]; + char url[1024]; + long long view_count; + char duration[32]; +}; + +#define MAX_ITEMS 100 + +struct games_response { + struct game data[MAX_ITEMS + 1]; + size_t nitems; +// char pagination[256]; +}; + +struct streams_response { + struct stream data[MAX_ITEMS + 1]; + size_t nitems; +// char pagination[256]; +}; + +struct users_response { + struct user data[MAX_ITEMS + 1]; + size_t nitems; +}; + +struct videos_response { + struct video data[MAX_ITEMS + 1]; + size_t nitems; +// char pagination[256]; +}; + +struct games_response *twitch_games_bygameids(const char *param); +struct games_response *twitch_games_top(void); +struct streams_response *twitch_streams(void); +struct streams_response *twitch_streams_bygame(const char *game_id); +struct users_response *twitch_users_bylogin(const char *login); +struct users_response *twitch_users_byuserids(const char *param); +struct videos_response *twitch_videos_byuserid(const char *user_id); + +struct games_response *twitch_streams_games(struct streams_response *r); +struct users_response *twitch_streams_users(struct streams_response *r); (DIR) diff --git a/util.c b/util.c @@ -0,0 +1,218 @@ +#include <sys/socket.h> +#include <sys/types.h> + +#include <ctype.h> +#include <errno.h> +#include <netdb.h> +#include <stdarg.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <time.h> +#include <unistd.h> +#include <wchar.h> + +void +die(const char *fmt, ...) +{ + va_list ap; + + va_start(ap, fmt); + vfprintf(stderr, fmt, ap); + va_end(ap); + + exit(1); +} + +int +uriencode(const char *s, char *buf, size_t bufsiz) +{ + static char hex[] = "0123456789ABCDEF"; + char *d = buf, *e = buf + bufsiz; + unsigned char c; + + if (!bufsiz) + return 0; + + for (; *s; ++s) { + c = (unsigned char)*s; + if (d + 4 >= e) + return 0; + if (c == ' ' || c == '#' || c == '%' || c == '?' || c == '"' || + c == '&' || c == '<' || c <= 0x1f || c >= 0x7f) { + *d++ = '%'; + *d++ = hex[c >> 4]; + *d++ = hex[c & 0x0f]; + } else { + *d++ = *s; + } + } + *d = '\0'; + + return 1; +} + +int +hexdigit(int c) +{ + if (c >= '0' && c <= '9') + return c - '0'; + else if (c >= 'A' && c <= 'F') + return c - 'A' + 10; + else if (c >= 'a' && c <= 'f') + return c - 'a' + 10; + + return 0; +} + +/* decode until NUL separator or end of "key". */ +int +decodeparam(char *buf, size_t bufsiz, const char *s) +{ + size_t i; + + if (!bufsiz) + return -1; + + for (i = 0; *s && *s != '&'; s++) { + switch (*s) { + case '%': + if (i + 3 >= bufsiz) + return -1; + if (!isxdigit((unsigned char)*(s+1)) || + !isxdigit((unsigned char)*(s+2))) + return -1; + buf[i++] = hexdigit(*(s+1)) * 16 + hexdigit(*(s+2)); + s += 2; + break; + case '+': + if (i + 1 >= bufsiz) + return -1; + buf[i++] = ' '; + break; + default: + if (i + 1 >= bufsiz) + return -1; + buf[i++] = *s; + break; + } + } + buf[i] = '\0'; + + return i; +} + +char * +getparam(const char *query, const char *s) +{ + const char *p, *last = NULL; + size_t len; + + len = strlen(s); + for (p = query; (p = strstr(p, s)); p += len) { + if (p[len] == '=' && (p == query || p[-1] == '&' || p[-1] == '?')) + last = p + len + 1; + } + + return (char *)last; +} + +int +friendlytime(time_t now, time_t t) +{ + long long d = now - t; + + if (d < 60) { + printf("just now"); + } else if (d < 3600) { + printf("%lld minutes ago", d / 60); + } else if (d <= 24*3600) { + printf("%lld hours ago", d / 3600); + } else { + return 0; + } + return 1; +} + +/* Escape characters below as HTML 2.0 / XML 1.0. */ +void +xmlencode(const char *s) +{ + for (; *s; s++) { + switch(*s) { + case '<': fputs("<", stdout); break; + case '>': fputs(">", stdout); break; + case '\'': fputs("'", stdout); break; + case '&': fputs("&", stdout); break; + case '"': fputs(""", stdout); break; + default: putchar(*s); + } + } +} + +/* format `len' columns of characters. If string is shorter pad the rest + * with characters `pad`. */ +int +utf8pad(char *buf, size_t bufsiz, const char *s, size_t len, int pad) +{ + wchar_t wc; + size_t col = 0, i, slen, siz = 0; + int rl, w; + + if (!len) + return -1; + + slen = strlen(s); + for (i = 0; i < slen; i += rl) { + if ((rl = mbtowc(&wc, &s[i], slen - i < 4 ? slen - i : 4)) <= 0) + break; + if ((w = wcwidth(wc)) == -1) + continue; + if (col + w > len || (col + w == len && s[i + rl])) { + if (siz + 4 >= bufsiz) + return -1; + memcpy(&buf[siz], "\xe2\x80\xa6", 3); + siz += 3; + if (col + w == len && w > 1) + buf[siz++] = pad; + buf[siz] = '\0'; + return 0; + } + if (siz + rl + 1 >= bufsiz) + return -1; + memcpy(&buf[siz], &s[i], rl); + col += w; + siz += rl; + buf[siz] = '\0'; + } + + len -= col; + if (siz + len + 1 >= bufsiz) + return -1; + memset(&buf[siz], pad, len); + siz += len; + buf[siz] = '\0'; + + return 0; +} + +/* Escape characters in gopher, CR and LF are ignored */ +void +gophertext(FILE *fp, const char *s, size_t len) +{ + size_t i; + + for (i = 0; *s && i < len; s++, i++) { + switch (*s) { + case '\r': /* ignore CR */ + case '\n': /* ignore LF */ + break; + case '\t': + fputs(" ", fp); + break; + default: + fputc(*s, fp); + break; + } + } +} (DIR) diff --git a/util.h b/util.h @@ -0,0 +1,15 @@ +#ifndef __OpenBSD__ +#define pledge(p1,p2) 0 +#define unveil(p1,p2) 0 +#endif + +void die(const char *fmt, ...); + +int decodeparam(char *buf, size_t bufsiz, const char *s); +int friendlytime(time_t now, time_t t); +char *getparam(const char *query, const char *s); +void gophertext(FILE *fp, const char *s, size_t len); +int hexdigit(int c); +int uriencode(const char *s, char *buf, size_t bufsiz); +int utf8pad(char *buf, size_t bufsiz, const char *s, size_t len, int pad); +void xmlencode(const char *s); (DIR) diff --git a/xml.c b/xml.c @@ -0,0 +1,480 @@ +#include <sys/types.h> + +#include <ctype.h> +#include <errno.h> +#include <limits.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> + +#include "xml.h" + +/* data buffers, size and offset used for parsing XML, see getnext() */ +static const unsigned char *xml_data_buf; +static size_t xml_data_size; +static size_t xml_data_off; + +void +setxmldata(const char *s, size_t len) +{ + xml_data_off = 0; + xml_data_size = len; + xml_data_buf = s; +} + +static int +getnext(void) +{ + if (xml_data_off >= xml_data_size) + return EOF; + return xml_data_buf[xml_data_off++]; +} + +static void +xml_parseattrs(XMLParser *x) +{ + size_t namelen = 0, valuelen; + int c, endsep, endname = 0, valuestart = 0; + + while ((c = GETNEXT()) != EOF) { + if (isspace(c)) { + if (namelen) + endname = 1; + continue; + } else if (c == '?') + ; /* ignore */ + else if (c == '=') { + x->name[namelen] = '\0'; + valuestart = 1; + endname = 1; + } else if (namelen && ((endname && !valuestart && isalpha(c)) || (c == '>' || c == '/'))) { + /* attribute without value */ + x->name[namelen] = '\0'; + if (x->xmlattrstart) + x->xmlattrstart(x, x->tag, x->taglen, x->name, namelen); + if (x->xmlattr) + x->xmlattr(x, x->tag, x->taglen, x->name, namelen, "", 0); + if (x->xmlattrend) + x->xmlattrend(x, x->tag, x->taglen, x->name, namelen); + endname = 0; + x->name[0] = c; + namelen = 1; + } else if (namelen && valuestart) { + /* attribute with value */ + if (x->xmlattrstart) + x->xmlattrstart(x, x->tag, x->taglen, x->name, namelen); + + valuelen = 0; + if (c == '\'' || c == '"') { + endsep = c; + } else { + endsep = ' '; /* isspace() */ + goto startvalue; + } + + while ((c = GETNEXT()) != EOF) { +startvalue: + if (c == '&') { /* entities */ + x->data[valuelen] = '\0'; + /* call data function with data before entity if there is data */ + if (valuelen && x->xmlattr) + x->xmlattr(x, x->tag, x->taglen, x->name, namelen, x->data, valuelen); + x->data[0] = c; + valuelen = 1; + while ((c = GETNEXT()) != EOF) { + if (c == endsep || (endsep == ' ' && (c == '>' || isspace(c)))) + break; + if (valuelen < sizeof(x->data) - 1) + x->data[valuelen++] = c; + else { + /* entity too long for buffer, handle as normal data */ + x->data[valuelen] = '\0'; + if (x->xmlattr) + x->xmlattr(x, x->tag, x->taglen, x->name, namelen, x->data, valuelen); + x->data[0] = c; + valuelen = 1; + break; + } + if (c == ';') { + x->data[valuelen] = '\0'; + if (x->xmlattrentity) + x->xmlattrentity(x, x->tag, x->taglen, x->name, namelen, x->data, valuelen); + valuelen = 0; + break; + } + } + } else if (c != endsep && !(endsep == ' ' && (c == '>' || isspace(c)))) { + if (valuelen < sizeof(x->data) - 1) { + x->data[valuelen++] = c; + } else { + x->data[valuelen] = '\0'; + if (x->xmlattr) + x->xmlattr(x, x->tag, x->taglen, x->name, namelen, x->data, valuelen); + x->data[0] = c; + valuelen = 1; + } + } + if (c == endsep || (endsep == ' ' && (c == '>' || isspace(c)))) { + x->data[valuelen] = '\0'; + if (x->xmlattr) + x->xmlattr(x, x->tag, x->taglen, x->name, namelen, x->data, valuelen); + if (x->xmlattrend) + x->xmlattrend(x, x->tag, x->taglen, x->name, namelen); + break; + } + } + namelen = endname = valuestart = 0; + } else if (namelen < sizeof(x->name) - 1) { + x->name[namelen++] = c; + } + if (c == '>') { + break; + } else if (c == '/') { + x->isshorttag = 1; + x->name[0] = '\0'; + namelen = 0; + } + } +} + +static void +xml_parsecomment(XMLParser *x) +{ + size_t datalen = 0, i = 0; + int c; + + if (x->xmlcommentstart) + x->xmlcommentstart(x); + while ((c = GETNEXT()) != EOF) { + if (c == '-' || c == '>') { + if (x->xmlcomment) { + x->data[datalen] = '\0'; + x->xmlcomment(x, x->data, datalen); + datalen = 0; + } + } + + if (c == '-') { + if (++i > 2) { + if (x->xmlcomment) + for (; i > 2; i--) + x->xmlcomment(x, "-", 1); + i = 2; + } + continue; + } else if (c == '>' && i == 2) { + if (x->xmlcommentend) + x->xmlcommentend(x); + return; + } else if (i) { + if (x->xmlcomment) { + for (; i > 0; i--) + x->xmlcomment(x, "-", 1); + } + i = 0; + } + + if (datalen < sizeof(x->data) - 1) { + x->data[datalen++] = c; + } else { + x->data[datalen] = '\0'; + if (x->xmlcomment) + x->xmlcomment(x, x->data, datalen); + x->data[0] = c; + datalen = 1; + } + } +} + +static void +xml_parsecdata(XMLParser *x) +{ + size_t datalen = 0, i = 0; + int c; + + if (x->xmlcdatastart) + x->xmlcdatastart(x); + while ((c = GETNEXT()) != EOF) { + if (c == ']' || c == '>') { + if (x->xmlcdata) { + x->data[datalen] = '\0'; + x->xmlcdata(x, x->data, datalen); + datalen = 0; + } + } + + if (c == ']') { + if (++i > 2) { + if (x->xmlcdata) + for (; i > 2; i--) + x->xmlcdata(x, "]", 1); + i = 2; + } + continue; + } else if (c == '>' && i == 2) { + if (x->xmlcdataend) + x->xmlcdataend(x); + return; + } else if (i) { + if (x->xmlcdata) + for (; i > 0; i--) + x->xmlcdata(x, "]", 1); + i = 0; + } + + if (datalen < sizeof(x->data) - 1) { + x->data[datalen++] = c; + } else { + x->data[datalen] = '\0'; + if (x->xmlcdata) + x->xmlcdata(x, x->data, datalen); + x->data[0] = c; + datalen = 1; + } + } +} + +static int +codepointtoutf8(long r, char *s) +{ + if (r == 0) { + return 0; /* NUL byte */ + } else if (r <= 0x7F) { + /* 1 byte: 0aaaaaaa */ + s[0] = r; + return 1; + } else if (r <= 0x07FF) { + /* 2 bytes: 00000aaa aabbbbbb */ + s[0] = 0xC0 | ((r & 0x0007C0) >> 6); /* 110aaaaa */ + s[1] = 0x80 | (r & 0x00003F); /* 10bbbbbb */ + return 2; + } else if (r <= 0xFFFF) { + /* 3 bytes: aaaabbbb bbcccccc */ + s[0] = 0xE0 | ((r & 0x00F000) >> 12); /* 1110aaaa */ + s[1] = 0x80 | ((r & 0x000FC0) >> 6); /* 10bbbbbb */ + s[2] = 0x80 | (r & 0x00003F); /* 10cccccc */ + return 3; + } else { + /* 4 bytes: 000aaabb bbbbcccc ccdddddd */ + s[0] = 0xF0 | ((r & 0x1C0000) >> 18); /* 11110aaa */ + s[1] = 0x80 | ((r & 0x03F000) >> 12); /* 10bbbbbb */ + s[2] = 0x80 | ((r & 0x000FC0) >> 6); /* 10cccccc */ + s[3] = 0x80 | (r & 0x00003F); /* 10dddddd */ + return 4; + } +} + +static int +namedentitytostr(const char *e, char *buf, size_t bufsiz) +{ + static const struct { + const char *entity; + int c; + } entities[] = { + { "amp;", '&' }, + { "lt;", '<' }, + { "gt;", '>' }, + { "apos;", '\'' }, + { "quot;", '"' }, + { "AMP;", '&' }, + { "LT;", '<' }, + { "GT;", '>' }, + { "APOS;", '\'' }, + { "QUOT;", '"' } + }; + size_t i; + + /* buffer is too small */ + if (bufsiz < 2) + return -1; + + for (i = 0; i < sizeof(entities) / sizeof(*entities); i++) { + if (!strcmp(e, entities[i].entity)) { + buf[0] = entities[i].c; + buf[1] = '\0'; + return 1; + } + } + return 0; +} + +static int +numericentitytostr(const char *e, char *buf, size_t bufsiz) +{ + long l; + int len; + char *end; + + /* buffer is too small */ + if (bufsiz < 5) + return -1; + + errno = 0; + /* hex (16) or decimal (10) */ + if (*e == 'x') + l = strtoul(e + 1, &end, 16); + else + l = strtoul(e, &end, 10); + /* invalid value or not a well-formed entity or too high codepoint */ + if (errno || *end != ';' || l > 0x10FFFF) + return 0; + len = codepointtoutf8(l, buf); + buf[len] = '\0'; + + return len; +} + +/* convert named- or numeric entity string to buffer string + * returns byte-length of string. */ +int +xml_entitytostr(const char *e, char *buf, size_t bufsiz) +{ + /* doesn't start with & */ + if (e[0] != '&') + return 0; + /* numeric entity */ + if (e[1] == '#') + return numericentitytostr(e + 2, buf, bufsiz); + else /* named entity */ + return namedentitytostr(e + 1, buf, bufsiz); +} + +void +xml_parse(XMLParser *x) +{ + size_t datalen, tagdatalen; + int c, isend; + + while ((c = GETNEXT()) != EOF && c != '<') + ; /* skip until < */ + + while (c != EOF) { + if (c == '<') { /* parse tag */ + if ((c = GETNEXT()) == EOF) + return; + + if (c == '!') { /* cdata and comments */ + for (tagdatalen = 0; (c = GETNEXT()) != EOF;) { + /* NOTE: sizeof(x->data) must be atleast sizeof("[CDATA[") */ + if (tagdatalen <= sizeof("[CDATA[") - 1) + x->data[tagdatalen++] = c; + if (c == '>') + break; + else if (c == '-' && tagdatalen == sizeof("--") - 1 && + (x->data[0] == '-')) { + xml_parsecomment(x); + break; + } else if (c == '[') { + if (tagdatalen == sizeof("[CDATA[") - 1 && + !strncmp(x->data, "[CDATA[", tagdatalen)) { + xml_parsecdata(x); + break; + } + } + } + } else { + /* normal tag (open, short open, close), processing instruction. */ + x->tag[0] = c; + x->taglen = 1; + x->isshorttag = isend = 0; + + /* treat processing instruction as shorttag, don't strip "?" prefix. */ + if (c == '?') { + x->isshorttag = 1; + } else if (c == '/') { + if ((c = GETNEXT()) == EOF) + return; + x->tag[0] = c; + isend = 1; + } + + while ((c = GETNEXT()) != EOF) { + if (c == '/') + x->isshorttag = 1; /* short tag */ + else if (c == '>' || isspace(c)) { + x->tag[x->taglen] = '\0'; + if (isend) { /* end tag, starts with </ */ + if (x->xmltagend) + x->xmltagend(x, x->tag, x->taglen, x->isshorttag); + x->tag[0] = '\0'; + x->taglen = 0; + } else { + /* start tag */ + if (x->xmltagstart) + x->xmltagstart(x, x->tag, x->taglen); + if (isspace(c)) + xml_parseattrs(x); + if (x->xmltagstartparsed) + x->xmltagstartparsed(x, x->tag, x->taglen, x->isshorttag); + } + /* call tagend for shortform or processing instruction */ + if (x->isshorttag) { + if (x->xmltagend) + x->xmltagend(x, x->tag, x->taglen, x->isshorttag); + x->tag[0] = '\0'; + x->taglen = 0; + } + break; + } else if (x->taglen < sizeof(x->tag) - 1) + x->tag[x->taglen++] = c; /* NOTE: tag name truncation */ + } + } + } else { + /* parse tag data */ + datalen = 0; + if (x->xmldatastart) + x->xmldatastart(x); + while ((c = GETNEXT()) != EOF) { + if (c == '&') { + if (datalen) { + x->data[datalen] = '\0'; + if (x->xmldata) + x->xmldata(x, x->data, datalen); + } + x->data[0] = c; + datalen = 1; + while ((c = GETNEXT()) != EOF) { + if (c == '<') + break; + if (datalen < sizeof(x->data) - 1) + x->data[datalen++] = c; + else { + /* entity too long for buffer, handle as normal data */ + x->data[datalen] = '\0'; + if (x->xmldata) + x->xmldata(x, x->data, datalen); + x->data[0] = c; + datalen = 1; + break; + } + if (c == ';') { + x->data[datalen] = '\0'; + if (x->xmldataentity) + x->xmldataentity(x, x->data, datalen); + datalen = 0; + break; + } + } + } else if (c != '<') { + if (datalen < sizeof(x->data) - 1) { + x->data[datalen++] = c; + } else { + x->data[datalen] = '\0'; + if (x->xmldata) + x->xmldata(x, x->data, datalen); + x->data[0] = c; + datalen = 1; + } + } + if (c == '<') { + x->data[datalen] = '\0'; + if (x->xmldata && datalen) + x->xmldata(x, x->data, datalen); + if (x->xmldataend) + x->xmldataend(x); + break; + } + } + } + } +} (DIR) diff --git a/xml.h b/xml.h @@ -0,0 +1,47 @@ +#ifndef _XML_H +#define _XML_H + +typedef struct xmlparser { + /* handlers */ + void (*xmlattr)(struct xmlparser *, const char *, size_t, + const char *, size_t, const char *, size_t); + void (*xmlattrend)(struct xmlparser *, const char *, size_t, + const char *, size_t); + void (*xmlattrstart)(struct xmlparser *, const char *, size_t, + const char *, size_t); + void (*xmlattrentity)(struct xmlparser *, const char *, size_t, + const char *, size_t, const char *, size_t); + void (*xmlcdatastart)(struct xmlparser *); + void (*xmlcdata)(struct xmlparser *, const char *, size_t); + void (*xmlcdataend)(struct xmlparser *); + void (*xmlcommentstart)(struct xmlparser *); + void (*xmlcomment)(struct xmlparser *, const char *, size_t); + void (*xmlcommentend)(struct xmlparser *); + void (*xmldata)(struct xmlparser *, const char *, size_t); + void (*xmldataend)(struct xmlparser *); + void (*xmldataentity)(struct xmlparser *, const char *, size_t); + void (*xmldatastart)(struct xmlparser *); + void (*xmltagend)(struct xmlparser *, const char *, size_t, int); + void (*xmltagstart)(struct xmlparser *, const char *, size_t); + void (*xmltagstartparsed)(struct xmlparser *, const char *, + size_t, int); + +#undef GETNEXT +#define GETNEXT getnext + + /* current tag */ + char tag[1024]; + size_t taglen; + /* current tag is in short form ? <tag /> */ + int isshorttag; + /* current attribute name */ + char name[1024]; + /* data buffer used for tag data, cdata and attribute data */ + char data[BUFSIZ]; +} XMLParser; + +int xml_entitytostr(const char *, char *, size_t); +void xml_parse(XMLParser *); +#endif + +void setxmldata(const char *s, size_t len); (DIR) diff --git a/youtube/README b/youtube/README @@ -0,0 +1,68 @@ +Dependencies: +------------- + +- C compiler. +- LibreSSL + libtls. + + +Compile +------- + +- make +- doas make install + + +Features +-------- + +- Search videos. +- Doesn't use JavaScript. +- Doesn't use (tracking) cookies. +- CSS is optional. +- Multiple interfaces available: CGI web, CLI, gopher (gph), this is a + work-in-progress. +- Shows an Atom feed of the user/channel. +- Doesn't use or require the Google API. +- CGI interface works nice in most browsers, including text-based ones. + On OpenBSD it runs "sandboxed" and it can be compiled as a static-linked + binary with pledge(2), unveil(2) in a chroot. + + +Cons/caveats +------------ + +- Order by upload date is incorrect (same as on Youtube). +- Some Youtube features are not supported. +- Uses scraping so might break at any point. + + +Install HTTP CGI +---------------- + +Nginx + slowcgi example: + + location /idiotbox/css/.* { + root /home/www/domains/www.codemadness.org/htdocs/idiotbox/css; + } + + location ~ ^/idiotbox(/|/\?.*)$ { + include /etc/nginx/fastcgi_params; + fastcgi_pass unix:/run/slowcgi.sock; + fastcgi_param SCRIPT_FILENAME /cgi-bin/idiotbox; + fastcgi_param SCRIPT_NAME /cgi-bin/idiotbox; + fastcgi_param REQUEST_URI /cgi-bin/idiotbox; + } + +httpd + slowcgi example: + + location match "/idiotbox" { + root "/cgi-bin/idiotbox.cgi" + fastcgi + } + + +When using a chroot make sure to copy /etc/resolv.conf and /etc/ssl/cert.pem. + +To test from the command-line you can do: + + QUERY_STRING="q=funny+cat+video" ./main | sed 1,2d | lynx -stdin (DIR) diff --git a/youtube/cgi.c b/youtube/cgi.c @@ -0,0 +1,379 @@ +#include <sys/socket.h> +#include <sys/types.h> + +#include <ctype.h> +#include <errno.h> +#include <netdb.h> +#include <stdarg.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <unistd.h> + +#include "https.h" +#include "util.h" +#include "youtube.h" + +#define OUT(s) (fputs((s), stdout)) + +extern char **environ; + +static int curpage = 1; + +/* CGI parameters */ +static char rawsearch[4096], search[4096], mode[16], order[16], page[64]; +static char chan[1024], user[1024]; + +void +parsecgi(void) +{ + char *query, *p; + size_t len; + + if (!(query = getenv("QUERY_STRING"))) + query = ""; + + /* channel: search in channel */ + if ((p = getparam(query, "chan"))) { + if (decodeparam(chan, sizeof(chan), p) == -1) + chan[0] = '\0'; + } + /* user: search in user */ + if ((p = getparam(query, "user"))) { + if (decodeparam(user, sizeof(user), p) == -1) + user[0] = '\0'; + } + if (!strcmp(chan, "Search all") || !strcmp(user, "Search all")) { + chan[0] = '\0'; + user[0] = '\0'; + } + + /* prefer channel over user when both are set */ + if (chan[0] && user[0]) + user[0] = '\0'; + + /* order */ + if ((p = getparam(query, "o"))) { + if (decodeparam(order, sizeof(order), p) == -1 || + (strcmp(order, "date") && + strcmp(order, "relevance") && + strcmp(order, "views"))) + order[0] = '\0'; + } + if (!order[0]) + snprintf(order, sizeof(order), chan[0] || user[0] ? "date" : "relevance"); + + /* page */ + if ((p = getparam(query, "page"))) { + if (decodeparam(page, sizeof(page), p) == -1) + page[0] = '\0'; + /* check if it's a number > 0 and < 100 */ + errno = 0; + curpage = strtol(page, NULL, 10); + if (errno || curpage < 0 || curpage > 100) { + curpage = 1; + page[0] = '\0'; + } + } + + /* mode */ + if ((p = getparam(query, "m"))) { + if (decodeparam(mode, sizeof(mode), p) != -1) { + /* fixup first character (label) for matching */ + if (mode[0]) + mode[0] = tolower((unsigned char)mode[0]); + /* allowed themes */ + if (strcmp(mode, "light") && + strcmp(mode, "dark") && + strcmp(mode, "pink") && + strcmp(mode, "templeos")) + mode[0] = '\0'; + } + } + if (!mode[0]) + snprintf(mode, sizeof(mode), "light"); + + /* search */ + if ((p = getparam(query, "q"))) { + if ((len = strcspn(p, "&")) && len + 1 < sizeof(rawsearch)) { + memcpy(rawsearch, p, len); + rawsearch[len] = '\0'; + } + + if (decodeparam(search, sizeof(search), p) == -1) { + OUT("Status: 401 Bad Request\r\n\r\n"); + exit(1); + } + } +} + +int +render(struct search_response *r) +{ + struct item *videos = r ? r->items : NULL; + char tmp[64]; + size_t i; + + if (pledge("stdio", NULL) == -1) { + OUT("Status: 500 Internal Server Error\r\n\r\n"); + exit(1); + } + + OUT( + "Content-Type: text/html; charset=utf-8\r\n\r\n" + "<!DOCTYPE html>\n<html>\n<head>\n" + "<meta name=\"referrer\" content=\"no-referrer\" />\n" + "<meta http-equiv=\"Content-Type\" content=\"text/html; charset=UTF-8\" />\n" + "<title>Search: \""); + xmlencode(search); + OUT("\""); + if (r && r->nitems && (chan[0] || user[0])) { + if (videos[0].channelid[0]) + printf(" in %s", videos[0].channeltitle); + else if (videos[0].userid[0]) + printf(" in %s", videos[0].userid); + } + printf(" sorted by %s</title>\n", order); + OUT( + "<link rel=\"stylesheet\" href=\"css/"); + xmlencode(mode); + OUT( + ".css\" type=\"text/css\" media=\"screen\" />\n" + "<link rel=\"icon\" type=\"image/png\" href=\"/favicon.png\" />\n" + "<meta content=\"width=device-width\" name=\"viewport\" />\n" + "</head>\n" + "<body class=\"search\">\n" + "<form method=\"get\" action=\"\">\n"); + + OUT("<input type=\"hidden\" name=\"m\" value=\""); + xmlencode(mode); + OUT("\" />\n"); + if (chan[0]) { + OUT("<input type=\"hidden\" name=\"chan\" value=\""); + xmlencode(chan); + OUT("\" />\n"); + } + if (user[0]) { + OUT("<input type=\"hidden\" name=\"user\" value=\""); + xmlencode(user); + OUT("\" />\n"); + } + + OUT( + "<table class=\"search\" width=\"100%\" border=\"0\" cellpadding=\"0\" cellspacing=\"0\">\n" + "<tr>\n" + " <td width=\"100%\" class=\"input\">\n" + " <input type=\"search\" name=\"q\" value=\""); + xmlencode(search); + OUT( + "\" placeholder=\"Search...\" size=\"72\" autofocus=\"autofocus\" class=\"search\" accesskey=\"f\" />\n" + " </td>\n" + " <td nowrap class=\"nowrap\">\n" + " <input type=\"submit\" value=\"Search\" class=\"button\"/>\n"); + + if (chan[0]) + OUT(" <input type=\"submit\" name=\"chan\" value=\"Search all\" title=\"Search globally and not in the selected channel\" accesskey=\"c\" />\n"); + if (user[0]) + OUT(" <input type=\"submit\" name=\"user\" value=\"Search all\" title=\"Search globally and not in the selected user\" accesskey=\"c\" />\n"); + + OUT( + " <select name=\"o\" title=\"Order by\" accesskey=\"o\">\n"); + printf(" <option value=\"date\"%s>Creation date</option>\n", !strcmp(order, "date") ? " selected=\"selected\"" : ""); + printf(" <option value=\"relevance\"%s>Relevance</option>\n", !strcmp(order, "relevance") ? " selected=\"selected\"" : ""); + printf(" <option value=\"views\"%s>Views</option>\n", !strcmp(order, "views") ? " selected=\"selected\"" : ""); + OUT( + " </select>\n" + " <label for=\"m\">Style: </label>\n"); + + if (!strcmp(mode, "light")) + OUT("\t\t<input type=\"submit\" name=\"m\" value=\"Dark\" title=\"Dark mode\" id=\"m\" accesskey=\"s\"/>\n"); + else + OUT("\t\t<input type=\"submit\" name=\"m\" value=\"Light\" title=\"Light mode\" id=\"m\" accesskey=\"s\"/>\n"); + + OUT( + " </td>\n" + "</tr>\n" + "</table>\n" + "</form>\n"); + + if (r && r->nitems) { + OUT( + "<hr/>\n" + "<table class=\"videos\" width=\"100%\" border=\"0\" cellpadding=\"0\" cellspacing=\"0\">\n" + "<tbody>\n"); + + for (i = 0; i < r->nitems; i++) { + OUT("<tr class=\"v\">\n" + " <td class=\"thumb\" width=\"120\" align=\"center\">\n"); + + if (videos[i].id[0]) { + OUT(" <a href=\"https://www.youtube.com/embed/"); + xmlencode(videos[i].id); + OUT("\"><img src=\"https://i.ytimg.com/vi/"); + xmlencode(videos[i].id); + OUT("/default.jpg\" alt=\"\" height=\"90\" border=\"0\" /></a>\n"); + } else { + /* placeholder image */ + OUT(" <img src=\"https://i.ytimg.com/vi/\" alt=\"\" height=\"90\" border=\"0\" />\n"); + } + OUT(" </td>\n" + " <td>\n" + " <span class=\"title\">"); + + if (videos[i].id[0]) { + OUT("<a href=\"https://www.youtube.com/embed/"); + xmlencode(videos[i].id); + printf("\" accesskey=\"%zu\">", i); + } + + switch (videos[i].linktype) { + case Channel: + OUT("[Channel] "); + xmlencode(videos[i].channeltitle); + break; + case Movie: + OUT("[Movie] "); + xmlencode(videos[i].title); + break; + case Playlist: + OUT("[Playlist] "); + xmlencode(videos[i].title); + break; + default: + xmlencode(videos[i].title); + break; + } + + if (videos[i].id[0]) + OUT("</a>"); + + OUT( + "</span><br/>\n" + "\t\t<span class=\"channel\">"); + + OUT("<a title=\"Search in "); + xmlencode(videos[i].channeltitle); + OUT("\" href=\"?"); + if (videos[i].channelid[0]) { + OUT("chan="); + xmlencode(videos[i].channelid); + } else if (videos[i].userid[0]) { + OUT("user="); + xmlencode(videos[i].userid); + } + OUT("&m="); + xmlencode(mode); + OUT("\">"); + xmlencode(videos[i].channeltitle); + OUT("</a>"); + if (videos[i].channelid[0] || videos[i].userid[0]) { + OUT(" | <a title=\""); + xmlencode(videos[i].channeltitle); + OUT(" Atom feed\" href=\"https://www.youtube.com/feeds/videos.xml?"); + if (videos[i].channelid[0]) { + OUT("channel_id="); + xmlencode(videos[i].channelid); + } else if (videos[i].userid[0]) { + OUT("user="); + xmlencode(videos[i].userid); + } + OUT("\">Atom feed</a>"); + } + OUT("</span><br/>\n"); + if (videos[i].publishedat[0]) { + OUT(" <span class=\"publishedat\">Published: "); + OUT(videos[i].publishedat); + OUT("</span><br/>\n"); + } + OUT(" <span class=\"stats\">"); + OUT(videos[i].viewcount); + OUT( + "</span><br/>\n" + " </td>\n" + " <td align=\"right\" class=\"a-r\">\n" + " <span class=\"duration\">"); + OUT(videos[i].duration); + OUT( + "</span>\n" + " </td>\n" + "</tr>\n" + "<tr class=\"hr\">\n" + " <td colspan=\"3\"><hr/></td>\n" + "</tr>\n"); + } + OUT("</tbody>\n"); + + /* pagination does not work for user/channel search */ + if (!user[0] && !chan[0]) { + OUT( + "<tfoot>\n" + "<tr>\n" + "\t<td align=\"left\" class=\"nowrap\" nowrap>\n"); + if (curpage > 1) { + OUT("\t\t<a href=\"?q="); + xmlencode(search); + OUT("&page="); + snprintf(tmp, sizeof(tmp), "%d", curpage - 1); + xmlencode(tmp); + OUT("&m="); + xmlencode(mode); + OUT("&o="); + xmlencode(order); + OUT("\" rel=\"prev\" accesskey=\"p\">← prev</a>\n"); + } + OUT( + "\t</td>\n\t<td></td>\n" + "\t<td align=\"right\" class=\"a-r nowrap\" nowrap>\n"); + + OUT("\t\t<a href=\"?q="); + xmlencode(search); + OUT("&page="); + snprintf(tmp, sizeof(tmp), "%d", curpage + 1); + xmlencode(tmp); + OUT("&m="); + xmlencode(mode); + OUT("&o="); + xmlencode(order); + OUT("\" rel=\"next\" accesskey=\"n\">next →</a>\n"); + + OUT( + "\t</td>\n" + "</tr>\n" + "</tfoot>\n"); + } + OUT("</table>\n"); + } + + OUT("</body>\n</html>\n"); + + return 0; +} + +int +main(void) +{ + struct search_response *r = NULL; + + if (pledge("stdio dns inet rpath unveil", NULL) == -1 || + unveil(TLS_CA_CERT_FILE, "r") == -1 || + unveil(NULL, NULL) == -1) { + OUT("Status: 500 Internal Server Error\r\n\r\n"); + exit(1); + } + + parsecgi(); + + if (!rawsearch[0] && !chan[0] && !user[0]) + goto show; + + r = youtube_search(rawsearch, chan, user, page, order); + if (!r || r->nitems == 0) { + OUT("Status: 500 Internal Server Error\r\n\r\n"); + exit(1); + } + +show: + render(r); + + return 0; +} (DIR) diff --git a/youtube/cli.c b/youtube/cli.c @@ -0,0 +1,154 @@ +#include <sys/socket.h> +#include <sys/types.h> + +#include <ctype.h> +#include <errno.h> +#include <netdb.h> +#include <stdarg.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <unistd.h> + +#include "https.h" +#include "util.h" +#include "youtube.h" + +#define OUT(s) (fputs((s), stdout)) +#define OUTESCAPE(s) (printescape(s)) + +/* print: ignore control-characters */ +void +printescape(const char *s) +{ + for (; *s; ++s) + if (!iscntrl((unsigned char)*s)) + fputc(*s, stdout); +} + +int +render(struct search_response *r) +{ + struct item *videos = r->items; + size_t i; + + if (pledge("stdio", NULL) == -1) { + fprintf(stderr, "pledge: %s\n", strerror(errno)); + exit(1); + } + + for (i = 0; i < r->nitems; i++) { + /* TODO: better printing of other types */ + switch (videos[i].linktype) { + case Channel: + OUT("[Channel] "); + OUTESCAPE(videos[i].channeltitle); + break; + case Movie: + OUT("[Movie] "); + OUTESCAPE(videos[i].title); + break; + case Playlist: + OUT("[Playlist] "); + OUTESCAPE(videos[i].title); + break; + default: + OUTESCAPE(videos[i].title); + break; + } + OUT("\n"); + + if (videos[i].id[0]) { + OUT("URL: https://www.youtube.com/embed/"); + OUTESCAPE(videos[i].id); + OUT("\n"); + } + + if (videos[i].channelid[0] || videos[i].userid[0]) { + OUT("Atom feed: https://www.youtube.com/feeds/videos.xml?"); + if (videos[i].channelid[0]) { + OUT("channel_id="); + OUTESCAPE(videos[i].channelid); + } else if (videos[i].userid[0]) { + OUT("user="); + OUTESCAPE(videos[i].userid); + } + OUT("\n"); + } + + if (videos[i].channelid[0] || videos[i].userid[0]) { + OUT("Channel title: "); + OUTESCAPE(videos[i].channeltitle); + OUT("\n"); + if (videos[i].channelid[0]) { + OUT("Channelid: "); + OUTESCAPE(videos[i].channelid); + OUT("\n"); + } else if (videos[i].userid[0]) { + OUT("Userid: "); + OUTESCAPE(videos[i].userid); + OUT("\n"); + } + } + if (videos[i].publishedat[0]) { + OUT("Published: "); + OUTESCAPE(videos[i].publishedat); + OUT("\n"); + } + if (videos[i].viewcount[0]) { + OUT("Viewcount: "); + OUTESCAPE(videos[i].viewcount); + OUT("\n"); + } + if (videos[i].duration[0]) { + OUT("Duration: " ); + OUTESCAPE(videos[i].duration); + OUT("\n"); + } + OUT("===\n"); + } + + return 0; +} + +static void +usage(const char *argv0) +{ + fprintf(stderr, "usage: %s <keywords>\n", argv0); + exit(1); +} + +int +main(int argc, char *argv[]) +{ + struct search_response *r; + char search[1024]; + + if (pledge("stdio dns inet rpath unveil", NULL) == -1) { + fprintf(stderr, "pledge: %s\n", strerror(errno)); + exit(1); + } + if (unveil(TLS_CA_CERT_FILE, "r") == -1) { + fprintf(stderr, "unveil: %s\n", strerror(errno)); + exit(1); + } + if (unveil(NULL, NULL) == -1) { + fprintf(stderr, "unveil: %s\n", strerror(errno)); + exit(1); + } + + if (argc < 2 || !argv[1][0]) + usage(argv[0]); + if (!uriencode(argv[1], search, sizeof(search))) + usage(argv[0]); + + r = youtube_search(search, "", "", "", "relevance"); + if (!r || r->nitems == 0) { + OUT("No videos found\n"); + exit(1); + } + + render(r); + + return 0; +} (DIR) diff --git a/youtube/css/dark.css b/youtube/css/dark.css @@ -0,0 +1,47 @@ +body { + background-color: #000; + color: #eee; + max-width: 80ex; + margin: 0 auto; + padding: 3px; +} +form { + margin: 0; +} +.nowrap { + whitespace: no-wrap; +} +.a-r { + text-align: right; +} +table.videos, +table.search, +table.search .input, +input.search { + width: 100%; +} +table.videos { + border-collapse: collapse; +} +table.videos tr td { + vertical-align: top; + padding: 0 3px; +} +table.videos tr.v:hover td { + background-color: #333; +} +td.thumb { + width: 120px; + text-align: center; +} +td.thumb img { + height: 90px; +} +a { + color: #fff; +} +hr { + height: 1px; + border: 0; + border-bottom: 1px solid #333; +} (DIR) diff --git a/youtube/css/light.css b/youtube/css/light.css @@ -0,0 +1,47 @@ +body { + background-color: #fff; + color: #000; + max-width: 80ex; + margin: 0 auto; + padding: 3px; +} +form { + margin: 0; +} +.nowrap { + whitespace: no-wrap; +} +.a-r { + text-align: right; +} +table.videos, +table.search, +table.search .input, +input.search { + width: 100%; +} +table.videos { + border-collapse: collapse; +} +table.videos tr td { + vertical-align: top; + padding: 0 3px; +} +table.videos tr.v:hover td { + background-color: #eee; +} +td.thumb { + width: 120px; + text-align: center; +} +td.thumb img { + height: 90px; +} +a { + color: #000; +} +hr { + height: 1px; + border: 0; + border-bottom: 1px solid #777; +} (DIR) diff --git a/youtube/css/pink.css b/youtube/css/pink.css @@ -0,0 +1,47 @@ +body { + background-color: pink; + color: #000; + max-width: 80ex; + margin: 0 auto; + padding: 3px; +} +form { + margin: 0; +} +.nowrap { + whitespace: no-wrap; +} +.a-r { + text-align: right; +} +table.videos, +table.search, +table.search .input, +input.search { + width: 100%; +} +table.videos { + border-collapse: collapse; +} +table.videos tr td { + vertical-align: top; + padding: 0 3px; +} +table.videos tr.v:hover td { + background-color: #fff; +} +td.thumb { + width: 120px; + text-align: center; +} +td.thumb img { + height: 90px; +} +a { + color: #000; +} +hr { + height: 1px; + border: 0; + border-bottom: 1px solid #777; +} (DIR) diff --git a/youtube/css/templeos.css b/youtube/css/templeos.css @@ -0,0 +1,50 @@ +body { + background-color: #55ffff; + color: #aa00aa; + color: #000; + max-width: 80ex; + margin: 0 auto; + padding: 3px; +} +form { + margin: 0; +} +.nowrap { + whitespace: no-wrap; +} +.a-r { + text-align: right; +} +table.videos, +table.search, +table.search .input, +input.search { + width: 100%; +} +table.videos { + border-collapse: collapse; +} +table.videos tr td { + vertical-align: top; + padding: 0 3px; +} +td.thumb { + width: 120px; + text-align: center; +} +td.thumb img { + height: 90px; +} +a { + text-decoration: none; + color: #aa0000; + border-bottom: 1px solid #0000aa; +} +td.thumb a { + border: 0; +} +hr { + height: 1px; + border: 0; + border-bottom: 1px solid #777; +} (DIR) diff --git a/youtube/gopher.c b/youtube/gopher.c @@ -0,0 +1,149 @@ +#include <sys/socket.h> +#include <sys/types.h> + +#include <ctype.h> +#include <errno.h> +#include <netdb.h> +#include <stdarg.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <unistd.h> + +#include "https.h" +#include "util.h" +#include "youtube.h" + +#define OUT(s) (fputs((s), stdout)) +#define OUTTEXT(s) gophertext(stdout, s, strlen(s)); +#define OUTLINK(s) gophertext(stdout, s, strlen(s)); + +static const char *server = "127.0.0.1"; +static const char *port = "70"; + +int +render(struct search_response *r) +{ + struct item *videos = r->items; + size_t i; + + if (pledge("stdio", NULL) == -1) { + fprintf(stderr, "pledge: %s\n", strerror(errno)); + exit(1); + } + + for (i = 0; i < r->nitems; i++) { + if (videos[i].id[0]) + putchar('h'); + else + putchar('i'); + + switch (videos[i].linktype) { + case Channel: + OUT("[Channel] "); + OUTTEXT(videos[i].channeltitle); + break; + case Movie: + OUT("[Movie] "); + OUTTEXT(videos[i].title); + break; + case Playlist: + OUT("[Playlist] "); + OUTTEXT(videos[i].title); + break; + default: + OUTTEXT(videos[i].title); + break; + } + + OUT("\t"); + if (videos[i].id[0]) { + OUT("URL:https://www.youtube.com/embed/"); + OUTLINK(videos[i].id); + } + printf("\t%s\t%s\r\n", server, port); + + if (videos[i].channelid[0] || videos[i].userid[0]) { + OUT("hAtom feed of "); + OUTTEXT(videos[i].channeltitle); + OUT("\t"); + OUTLINK("URL:https://www.youtube.com/feeds/videos.xml?"); + if (videos[i].channelid[0]) { + OUT("channel_id="); + OUTLINK(videos[i].channelid); + } else if (videos[i].userid[0]) { + OUT("user="); + OUTLINK(videos[i].userid); + } + printf("\t%s\t%s\r\n", server, port); + } + if (videos[i].duration[0]) { + OUT("iDuration: " ); + OUTTEXT(videos[i].duration); + printf("\t%s\t%s\t%s\r\n", "", server, port); + } + if (videos[i].publishedat[0]) { + OUT("iPublished: "); + OUTTEXT(videos[i].publishedat); + printf("\t%s\t%s\t%s\r\n", "", server, port); + } + if (videos[i].viewcount[0]) { + OUT("iViews: "); + OUTTEXT(videos[i].viewcount); + printf("\t%s\t%s\t%s\r\n", "", server, port); + } + printf("i%s\t%s\t%s\t%s\r\n", "", "", server, port); + printf("i%s\t%s\t%s\t%s\r\n", "", "", server, port); + } + printf(".\r\n"); + + return 0; +} + +static void +usage(const char *argv0) +{ + fprintf(stderr, "usage: %s <keywords>\n", argv0); + exit(1); +} + +int +main(int argc, char *argv[]) +{ + struct search_response *r; + char *p, search[1024]; + + if (pledge("stdio dns inet rpath unveil", NULL) == -1) { + fprintf(stderr, "pledge: %s\n", strerror(errno)); + exit(1); + } + if (unveil(TLS_CA_CERT_FILE, "r") == -1) { + fprintf(stderr, "unveil: %s\n", strerror(errno)); + exit(1); + } + if (unveil(NULL, NULL) == -1) { + fprintf(stderr, "unveil: %s\n", strerror(errno)); + exit(1); + } + + if ((p = getenv("SERVER_NAME"))) + server = p; + if ((p = getenv("SERVER_PORT"))) + port = p; + + if (argc < 2 || !argv[1][0]) + usage(argv[0]); + if (!uriencode(argv[1], search, sizeof(search))) + usage(argv[0]); + + r = youtube_search(search, "", "", "", "relevance"); + if (!r || r->nitems == 0) { + printf("iNo videos found\t%s\t%s\t%s\r\n", "", server, port); + printf(".\r\n"); + exit(1); + } + + render(r); + + return 0; +} (DIR) diff --git a/youtube/youtube.c b/youtube/youtube.c @@ -0,0 +1,329 @@ +#include <sys/socket.h> +#include <sys/types.h> + +#include <ctype.h> +#include <errno.h> +#include <netdb.h> +#include <stdarg.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <unistd.h> + +#include "https.h" +#include "util.h" +#include "youtube.h" +#include "xml.h" + +#define STRP(s) s,sizeof(s)-1 + +/* temporary variables to copy for states */ +static char id[256], userid[256]; + +/* states */ +static int metainfocount; +static enum ItemState { + None = 0, + Item = 1, Pager = 2, + Metainfo = 4, Title = 8, User = 16, Videotime = 32, +} state; + +static struct item *videos; +static size_t nvideos; + +static char * +youtube_request(const char *path) +{ + return request("www.youtube.com", path, ""); +} + +static int +isclassmatch(const char *classes, const char *clss, size_t len) +{ + const char *p; + + if (!(p = strstr(classes, clss))) + return 0; + return (p == classes || isspace((unsigned char)p[-1])) && + (isspace((unsigned char)p[len]) || !p[len]); +} + +/* XML/HTML entity conversion */ +static const char * +entitytostr(const char *s) +{ + static char buf[16]; + ssize_t len; + + if ((len = xml_entitytostr(s, buf, sizeof(buf))) > 0) + return buf; + + return s; +} + +static void +xmlattr(XMLParser *x, const char *t, size_t tl, const char *a, size_t al, + const char *v, size_t vl) +{ + /* grouped channel index, used for channelid and channel title */ + static int grouped = -1; + + if (!strcmp(t, "div") && !strcmp(a, "class") && isclassmatch(v, STRP("search-pager"))) { + /* last video */ + if (nvideos < MAX_VIDEOS && videos[nvideos].linktype) { + if (grouped != -1 && !videos[nvideos].channelid[0]) { + strlcpy(videos[nvideos].channelid, videos[grouped].channelid, sizeof(videos[nvideos].channelid)); + strlcpy(videos[nvideos].channeltitle, videos[grouped].channeltitle, sizeof(videos[nvideos].channeltitle)); + } + nvideos++; + } + state &= ~Item; + state |= Pager; + } + + if (nvideos >= MAX_VIDEOS) + return; + + if (!strcmp(t, "div") && !strcmp(a, "class") && + isclassmatch(v, STRP("yt-lockup"))) { + state |= Item; + if (videos[nvideos].linktype) { + if (videos[nvideos].channelid[0] || videos[nvideos].userid[0] || + videos[nvideos].linktype != Video) + grouped = -1; + if (videos[nvideos].linktype == Channel) + grouped = nvideos; + if (grouped != -1 && !videos[nvideos].channelid[0]) { + strlcpy(videos[nvideos].channelid, videos[grouped].channelid, sizeof(videos[nvideos].channelid)); + strlcpy(videos[nvideos].channeltitle, videos[grouped].channeltitle, sizeof(videos[nvideos].channeltitle)); + } + nvideos++; + } + if (strstr(v, " yt-lockup-channel ")) + videos[nvideos].linktype = Channel; + else if (strstr(v, "yt-lockup-movie-")) + videos[nvideos].linktype = Movie; + else if (strstr(v, " yt-lockup-playlist ")) + videos[nvideos].linktype = Playlist; + if (strstr(v, " yt-lockup-video ")) + videos[nvideos].linktype = Video; + } + if (!(state & Item)) + return; + + if (!strcmp(t, "span") && !strcmp(a, "class") && isclassmatch(v, STRP("video-time"))) + state |= Videotime; + if (!strcmp(t, "ul") && !strcmp(a, "class") && isclassmatch(v, STRP("yt-lockup-meta-info"))) { + state |= Metainfo; + metainfocount = 0; + } + if (!strcmp(t, "h3") && !strcmp(a, "class") && isclassmatch(v, STRP("yt-lockup-title"))) + state |= Title; + if (!strcmp(t, "div") && !strcmp(a, "class") && isclassmatch(v, STRP("yt-lockup-byline"))) + state |= User; + + if ((state & Title) && !strcmp(t, "a") && !strcmp(a, "title")) { + if (videos[nvideos].linktype == Channel) + strlcat(videos[nvideos].channeltitle, v, sizeof(videos[nvideos].channeltitle)); + else + strlcat(videos[nvideos].title, v, sizeof(videos[nvideos].title)); + } + + if ((state & Title) && !strcmp(t, "a") && !strcmp(a, "href")) + strlcat(id, v, sizeof(id)); + + if (!strcmp(t, "button") && !strcmp(a, "data-channel-external-id")) + strlcat(videos[nvideos].channelid, v, sizeof(videos[nvideos].channelid)); + + if ((state & User) && !strcmp(t, "a") && !strcmp(a, "href")) + strlcat(userid, v, sizeof(userid)); +} + +static void +xmlattrentity(XMLParser *x, const char *t, size_t tl, const char *a, size_t al, + const char *v, size_t vl) +{ + const char *s; + + if (!(state & Pager) && nvideos >= MAX_VIDEOS) + return; + + s = entitytostr(v); + xmlattr(x, t, tl, a, al, s, strlen(s)); +} + +static void +xmldata(XMLParser *x, const char *d, size_t dl) +{ + if ((state & Pager)) + return; + + /* optimization: no need to process and must not process videos after this */ + if (!state || nvideos >= MAX_VIDEOS) + return; + + /* use parsed link type for meta info since this metainfo differs per type like: + channel, playlist, video */ + if ((state & Metainfo)) { + switch (videos[nvideos].linktype) { + case Playlist: + break; /* ignore */ + case Channel: + if (metainfocount == 1) + strlcat(videos[nvideos].channelvideos, d, sizeof(videos[nvideos].channelvideos)); + break; + default: + if (metainfocount == 1) + strlcat(videos[nvideos].publishedat, d, sizeof(videos[nvideos].publishedat)); + else if (metainfocount == 2) + strlcat(videos[nvideos].viewcount, d, sizeof(videos[nvideos].viewcount)); + } + } + if ((state & Videotime) && !strcmp(x->tag, "span")) + strlcat(videos[nvideos].duration, d, sizeof(videos[nvideos].duration)); + if ((state & User) && !strcmp(x->tag, "a")) + strlcat(videos[nvideos].channeltitle, d, sizeof(videos[nvideos].channeltitle)); +} + +static void +xmldataentity(XMLParser *x, const char *d, size_t dl) +{ + const char *s; + + /* optimization: no need for entity conversion */ + if (!state || nvideos >= MAX_VIDEOS) + return; + + s = entitytostr(d); + xmldata(x, s, strlen(s)); +} + +static void +xmltagend(XMLParser *x, const char *t, size_t tl, int isshort) +{ + char *p; + + if ((state & Metainfo) && !strcmp(t, "ul")) + state &= ~Metainfo; + if ((state & Title) && !strcmp(t, "h3")) { + state &= ~Title; + + if (nvideos >= MAX_VIDEOS) + return; + + if (!strncmp(id, "/watch", sizeof("/watch") - 1)) { + if (!videos[nvideos].linktype) + videos[nvideos].linktype = Video; + if ((p = getparam(id, "v"))) { + if (decodeparam(videos[nvideos].id, sizeof(videos[nvideos].id), p) == -1) + videos[nvideos].id[0] = '\0'; + } + } + + id[0] = '\0'; + } + if ((state & User)) { + state &= ~User; + + if (nvideos >= MAX_VIDEOS) + return; + + /* can be user or channel */ + if (!strncmp(userid, "/channel/", sizeof("/channel/") - 1)) { + strlcpy(videos[nvideos].channelid, + userid + sizeof("/channel/") - 1, + sizeof(videos[nvideos].channelid)); + } else if (!strncmp(userid, "/user/", sizeof("/user/") - 1)) { + strlcpy(videos[nvideos].userid, + userid + sizeof("/user/") - 1, + sizeof(videos[nvideos].userid)); + } + + userid[0] = '\0'; + } + if ((state & Videotime)) + state &= ~Videotime; +} + +static void +xmltagstart(XMLParser *x, const char *t, size_t tl) +{ + if ((state & Metainfo) && !strcmp(t, "li")) + metainfocount++; +} + +static char * +request_search(const char *s, const char *chan, const char *user, + const char *page, const char *order) +{ + char path[4096]; + + /* when searching in channel or user but the search string is empty: + fake a search with a single space. */ + if ((chan[0] || user[0]) && !s[0]) + s = "+"; + + if (user[0]) + snprintf(path, sizeof(path), "/user/%s/search?query=%s", user, s); + else if (chan[0]) + snprintf(path, sizeof(path), "/channel/%s/search?query=%s", chan, s); + else + snprintf(path, sizeof(path), "/results?search_query=%s", s); + + if (page[0]) { + strlcat(path, "&page=", sizeof(path)); + strlcat(path, page, sizeof(path)); + } + + if (order[0]) { + strlcat(path, "&search_sort=", sizeof(path)); + if (!strcmp(order, "date")) + strlcat(path, "video_date_uploaded", sizeof(path)); + else if (!strcmp(order, "relevance")) + strlcat(path, "video_relevance", sizeof(path)); + else if (!strcmp(order, "views")) + strlcat(path, "video_view_count", sizeof(path)); + } + + /* check if request is too long (truncation) */ + if (strlen(path) >= sizeof(path) - 1) + return NULL; + + return youtube_request(path); +} + +struct search_response * +youtube_search(const char *rawsearch, const char *chan, const char *user, + const char *page, const char *order) +{ + struct search_response *r; + XMLParser x = { 0 }; + char *data, *s; + + if (!(data = request_search(rawsearch, chan, user, page, order))) + return NULL; + if (!(s = strstr(data, "\r\n\r\n"))) + return NULL; /* invalid response */ + /* skip header */ + s += strlen("\r\n\r\n"); + + if (!(r = calloc(1, sizeof(*r)))) + return NULL; + + nvideos = 0; + videos = r->items; + + x.xmlattr = xmlattr; + x.xmlattrentity = xmlattrentity; + x.xmldata = xmldata; + x.xmldataentity = xmldataentity; + x.xmltagend = xmltagend; + x.xmltagstart = xmltagstart; + + setxmldata(s, strlen(s)); + xml_parse(&x); + + r->nitems = nvideos; + + return r; +} (DIR) diff --git a/youtube/youtube.h b/youtube/youtube.h @@ -0,0 +1,22 @@ +struct item { + enum LinkType { Unknown = 0, Channel, Movie, Playlist, Video } linktype; + char id[32]; + char title[1024]; + char channeltitle[1024]; + char channelid[256]; + char userid[256]; + char publishedat[32]; + char viewcount[32]; + char duration[32]; + char channelvideos[32]; /* for channel */ +}; + +#define MAX_VIDEOS 30 +struct search_response { + struct item items[MAX_VIDEOS + 1]; + size_t nitems; +}; + +struct search_response * +youtube_search(const char *rawsearch, const char *chan, const char *user, + const char *page, const char *order);