Blob


1 /*
2 * Copyright (c) 2023 Omar Polo <op@omarpolo.com>
3 * Copyright (c) 2014 Reyk Floeter <reyk@openbsd.org>
4 *
5 * Permission to use, copy, modify, and distribute this software for any
6 * purpose with or without fee is hereby granted, provided that the above
7 * copyright notice and this permission notice appear in all copies.
8 *
9 * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
10 * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
11 * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
12 * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
13 * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
14 * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
15 * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
16 */
18 #include "config.h"
20 #include <sys/socket.h>
21 #include <sys/types.h>
22 #include <sys/un.h>
24 #include <ctype.h>
25 #include <errno.h>
26 #include <fnmatch.h>
27 #include <limits.h>
28 #include <locale.h>
29 #include <netdb.h>
30 #include <poll.h>
31 #include <signal.h>
32 #include <stdio.h>
33 #include <stdlib.h>
34 #include <string.h>
35 #include <syslog.h>
36 #include <unistd.h>
38 #include "amused.h"
39 #include "bufio.h"
40 #include "ev.h"
41 #include "http.h"
42 #include "log.h"
43 #include "playlist.h"
44 #include "xmalloc.h"
46 #ifndef nitems
47 #define nitems(x) (sizeof(x)/sizeof(x[0]))
48 #endif
50 #define FORM_URLENCODED "application/x-www-form-urlencoded"
52 #define ICON_REPEAT_ALL "🔁"
53 #define ICON_REPEAT_ONE "🔂"
54 #define ICON_PREV "⏮"
55 #define ICON_NEXT "⏭"
56 #define ICON_STOP "⏹"
57 #define ICON_PAUSE "⏸"
58 #define ICON_TOGGLE "⏯"
59 #define ICON_PLAY "⏵"
61 static struct imsgbuf ibuf;
62 static const char *prefix = "";
63 static size_t prefixlen;
65 const char *head = "<!doctype html>"
66 "<html>"
67 "<head>"
68 "<meta name='viewport' content='width=device-width, initial-scale=1'/>"
69 "<title>Amused Web</title>"
70 "<style>"
71 "*{box-sizing:border-box}"
72 "html,body{"
73 " padding: 0;"
74 " border: 0;"
75 " margin: 0;"
76 "}"
77 "main{"
78 " display: flex;"
79 " flex-direction: column;"
80 "}"
81 "button{cursor:pointer}"
82 ".searchbox{"
83 " position: sticky;"
84 " top: 0;"
85 "}"
86 ".searchbox input{"
87 " width: 100%;"
88 " padding: 9px;"
89 "}"
90 ".playlist-wrapper{min-height:80vh}"
91 ".playlist{"
92 " list-style: none;"
93 " padding: 0;"
94 " margin: 0;"
95 "}"
96 ".playlist button{"
97 " font-family: monospace;"
98 " text-align: left;"
99 " width: 100%;"
100 " padding: 5px;"
101 " border: 0;"
102 " background: transparent;"
103 " transition: background-color .25s ease-in-out;"
104 "}"
105 ".playlist button::before{"
106 " content: \"\";"
107 " width: 2ch;"
108 " display: inline-block;"
109 "}"
110 ".playlist button:hover{"
111 " background-color: #dfdddd;"
112 "}"
113 ".playlist #current button{"
114 " font-weight: bold;"
115 "}"
116 ".playlist #current button::before{"
117 " content: \"→ \";"
118 " font-weight: bold;"
119 "}"
120 ".controls{"
121 " position: sticky;"
122 " width: 100%;"
123 " max-width: 800px;"
124 " margin: 0 auto;"
125 " bottom: 0;"
126 " background-color: white;"
127 " background: #3d3d3d;"
128 " color: white;"
129 " border-radius: 10px 10px 0 0;"
130 " padding: 10px;"
131 " text-align: center;"
132 " order: 2;"
133 "}"
134 ".controls p{"
135 " margin: .4rem;"
136 "}"
137 ".controls a{"
138 " color: white;"
139 "}"
140 ".controls .status{"
141 " font-size: 0.9rem;"
142 "}"
143 ".controls button{"
144 " margin: 5px;"
145 " padding: 5px 20px;"
146 "}"
147 ".mode-active{"
148 " color: #0064ff;"
149 "}"
150 "</style>"
151 "</head>"
152 "<body>";
154 const char *foot = "<script>"
155 "function cur(e) {"
156 " if (e) {e.preventDefault()}"
157 " let cur = document.querySelector('#current');"
158 " if (cur) {cur.scrollIntoView(); window.scrollBy(0, -100);}"
159 "}"
160 "cur();"
161 "document.querySelector('.controls a').addEventListener('click',cur)"
162 "</script></body></html>";
164 static int
165 dial(const char *sock)
167 struct sockaddr_un sa;
168 size_t len;
169 int s;
171 memset(&sa, 0, sizeof(sa));
172 sa.sun_family = AF_UNIX;
173 len = strlcpy(sa.sun_path, sock, sizeof(sa.sun_path));
174 if (len >= sizeof(sa.sun_path))
175 err(1, "path too long: %s", sock);
177 if ((s = socket(AF_UNIX, SOCK_STREAM, 0)) == -1)
178 err(1, "socket");
179 if (connect(s, (struct sockaddr *)&sa, sizeof(sa)) == -1)
180 err(1, "failed to connect to %s", sock);
182 return s;
186 /*
187 * Adapted from usr.sbin/httpd/httpd.c' url_decode.
188 */
189 static int
190 url_decode(char *url)
192 char*p, *q;
193 char hex[3] = {0};
194 unsigned long x;
196 p = q = url;
197 while (*p != '\0') {
198 switch (*p) {
199 case '%':
200 /* Encoding character is followed by two hex chars */
201 if (!isxdigit((unsigned char)p[1]) ||
202 !isxdigit((unsigned char)p[2]) ||
203 (p[1] == '0' && p[2] == '0'))
204 return (-1);
206 hex[0] = p[1];
207 hex[1] = p[2];
209 /*
210 * We don't have to validate "hex" because it is
211 * guaranteed to include two hex chars followed
212 * by NUL.
213 */
214 x = strtoul(hex, NULL, 16);
215 *q = (char)x;
216 p += 2;
217 break;
218 case '+':
219 *q = ' ';
220 break;
221 default:
222 *q = *p;
223 break;
225 p++;
226 q++;
228 *q = '\0';
230 return (0);
233 static void
234 unexpected_imsg(struct imsg *imsg, const char *expected)
236 const char *msg;
237 size_t datalen;
239 if (imsg->hdr.type != IMSG_CTL_ERR) {
240 log_warnx("got event %d while expecting %s",
241 imsg->hdr.type, expected);
242 return;
245 datalen = IMSG_DATA_SIZE(*imsg);
246 msg = imsg->data;
247 if (datalen == 0 || msg[datalen - 1] != '\0')
248 fatalx("malformed error message");
249 log_warnx("failure: %s", msg);
252 static void
253 route_notfound(struct client *clt)
255 if (http_reply(clt, 404, "Not Found", "text/plain") == -1 ||
256 http_writes(clt, "Page not found\n") == -1)
257 return;
260 static void
261 render_playlist(struct client *clt)
263 struct imsg imsg;
264 struct player_status ps;
265 ssize_t n;
266 const char *p;
267 int current, done;
269 imsg_compose(&ibuf, IMSG_CTL_SHOW, 0, 0, -1, NULL, 0);
270 imsg_flush(&ibuf);
272 http_writes(clt, "<section class='playlist-wrapper'>");
273 http_writes(clt, "<form action=jump method=post"
274 " enctype='"FORM_URLENCODED"'>");
275 http_writes(clt, "<ul class=playlist>");
277 done = 0;
278 while (!done) {
279 if ((n = imsg_read(&ibuf)) == -1)
280 fatal("imsg_read");
281 if (n == 0)
282 fatalx("pipe closed");
284 for (;;) {
285 if ((n = imsg_get(&ibuf, &imsg)) == -1)
286 fatal("imsg_get");
287 if (n == 0)
288 break;
290 if (imsg.hdr.type != IMSG_CTL_SHOW) {
291 unexpected_imsg(&imsg, "IMSG_CTL_SHOW");
292 imsg_free(&imsg);
293 continue;
296 if (IMSG_DATA_SIZE(imsg) == 0) {
297 done = 1;
298 break;
301 if (IMSG_DATA_SIZE(imsg) != sizeof(ps))
302 fatalx("wrong size for seek ctl");
303 memcpy(&ps, imsg.data, sizeof(ps));
304 if (ps.path[sizeof(ps.path) - 1] != '\0')
305 fatalx("received corrupted data");
307 current = ps.status == STATE_PLAYING;
309 p = ps.path;
310 if (!strncmp(p, prefix, prefixlen))
311 p += prefixlen;
313 http_fmt(clt, "<li%s>",
314 current ? " id=current" : "");
315 http_writes(clt,
316 "<button type=submit name=jump value=\"");
317 http_htmlescape(clt, ps.path);
318 http_writes(clt, "\">");
319 http_htmlescape(clt, p);
320 http_writes(clt, "</button></li>");
322 imsg_free(&imsg);
326 http_writes(clt, "</ul>");
327 http_writes(clt, "</form>");
328 http_writes(clt, "</section>");
331 static void
332 render_controls(struct client *clt)
334 struct imsg imsg;
335 struct player_status ps;
336 ssize_t n;
337 const char *oc, *ac, *p;
338 int playing;
340 imsg_compose(&ibuf, IMSG_CTL_STATUS, 0, 0, -1, NULL, 0);
341 imsg_flush(&ibuf);
343 if ((n = imsg_read(&ibuf)) == -1)
344 fatal("imsg_read");
345 if (n == 0)
346 fatalx("pipe closed");
348 if ((n = imsg_get(&ibuf, &imsg)) == -1)
349 fatal("imsg_get");
350 if (n == 0)
351 return;
353 if (imsg.hdr.type != IMSG_CTL_STATUS) {
354 unexpected_imsg(&imsg, "IMSG_CTL_STATUS");
355 goto done;
357 if (IMSG_DATA_SIZE(imsg) != sizeof(ps))
358 fatalx("wrong size for IMSG_CTL_STATUS");
359 memcpy(&ps, imsg.data, sizeof(ps));
360 if (ps.path[sizeof(ps.path) - 1] != '\0')
361 fatalx("received corrupted data");
363 ac = ps.mode.repeat_all ? " class='mode-active'" : "";
364 oc = ps.mode.repeat_one ? " class='mode-active'" : "";
365 playing = ps.status == STATE_PLAYING;
367 if ((p = strrchr(ps.path, '/')) != NULL)
368 p++;
369 else
370 p = ps.path;
372 if (http_writes(clt, "<section class=controls>") == -1 ||
373 http_writes(clt, "<p><a href='#current'>") == -1 ||
374 http_htmlescape(clt, p) == -1 ||
375 http_writes(clt, "</a></p>") == -1 ||
376 http_writes(clt, "<form action=ctrls method=post"
377 " enctype='"FORM_URLENCODED"'>") == -1 ||
378 http_writes(clt, "<button type=submit name=ctl value=prev>"
379 ICON_PREV"</button>") == -1 ||
380 http_fmt(clt, "<button type=submit name=ctl value=%s>"
381 "%s</button>", playing ? "pause" : "play",
382 playing ? ICON_PAUSE : ICON_PLAY) == -1 ||
383 http_writes(clt, "<button type=submit name=ctl value=next>"
384 ICON_NEXT"</button>") == -1 ||
385 http_writes(clt, "</form>") == -1 ||
386 http_writes(clt, "<form action=mode method=post"
387 " enctype='"FORM_URLENCODED"'>") == -1 ||
388 http_fmt(clt, "<button%s type=submit name=mode value=all>"
389 ICON_REPEAT_ALL"</button>", ac) == -1 ||
390 http_fmt(clt, "<button%s type=submit name=mode value=one>"
391 ICON_REPEAT_ONE"</button>", oc) == -1 ||
392 http_writes(clt, "</form>") == -1 ||
393 http_writes(clt, "</section>") == -1)
394 return;
396 done:
397 imsg_free(&imsg);
400 static void
401 route_home(struct client *clt)
403 if (http_reply(clt, 200, "OK", "text/html;charset=UTF-8") == -1)
404 return;
406 if (http_write(clt, head, strlen(head)) == -1)
407 return;
409 if (http_writes(clt, "<main>") == -1)
410 return;
412 if (http_writes(clt, "<section class=searchbox>"
413 "<input type=search name=filter aria-label='Filter playlist'"
414 " placeholder='Filter playlist' id=search />"
415 "</section>") == -1)
416 return;
418 render_controls(clt);
419 render_playlist(clt);
421 if (http_writes(clt, "</main>") == -1)
422 return;
424 http_write(clt, foot, strlen(foot));
427 static void
428 route_jump(struct client *clt)
430 struct imsg imsg;
431 struct player_status ps;
432 ssize_t n;
433 char path[PATH_MAX];
434 char *form, *field;
435 int found = 0;
437 form = clt->buf;
438 while ((field = strsep(&form, "&")) != NULL) {
439 if (url_decode(field) == -1)
440 goto badreq;
442 if (strncmp(field, "jump=", 5) != 0)
443 continue;
444 field += 5;
445 found = 1;
447 memset(&path, 0, sizeof(path));
448 if (strlcpy(path, field, sizeof(path)) >= sizeof(path))
449 goto badreq;
451 log_warnx("path is %s", path);
452 imsg_compose(&ibuf, IMSG_CTL_JUMP, 0, 0, -1,
453 path, sizeof(path));
454 imsg_flush(&ibuf);
456 if ((n = imsg_read(&ibuf)) == -1)
457 fatal("imsg_read");
458 if (n == 0)
459 fatalx("pipe closed");
461 for (;;) {
462 if ((n = imsg_get(&ibuf, &imsg)) == -1)
463 fatal("imsg_get");
464 if (n == 0)
465 break;
467 if (imsg.hdr.type != IMSG_CTL_STATUS) {
468 unexpected_imsg(&imsg, "IMSG_CTL_STATUS");
469 imsg_free(&imsg);
470 continue;
473 if (IMSG_DATA_SIZE(imsg) != sizeof(ps))
474 fatalx("data size mismatch");
475 memcpy(&ps, imsg.data, sizeof(ps));
476 if (ps.path[sizeof(ps.path) - 1] != '\0')
477 fatalx("received corrupted data");
478 log_debug("jumped to %s", ps.path);
481 break;
484 if (!found)
485 goto badreq;
487 http_reply(clt, 302, "See Other", "/");
488 return;
490 badreq:
491 http_reply(clt, 400, "Bad Request", "text/plain");
492 http_writes(clt, "Bad Request.\n");
495 static void
496 route_controls(struct client *clt)
498 char *form, *field;
499 int cmd, found = 0;
501 form = clt->buf;
502 while ((field = strsep(&form, "&")) != NULL) {
503 if (url_decode(field) == -1)
504 goto badreq;
506 if (strncmp(field, "ctl=", 4) != 0)
507 continue;
508 field += 4;
509 found = 1;
511 if (!strcmp(field, "play"))
512 cmd = IMSG_CTL_PLAY;
513 else if (!strcmp(field, "pause"))
514 cmd = IMSG_CTL_PAUSE;
515 else if (!strcmp(field, "next"))
516 cmd = IMSG_CTL_NEXT;
517 else if (!strcmp(field, "prev"))
518 cmd = IMSG_CTL_PREV;
519 else
520 goto badreq;
522 imsg_compose(&ibuf, cmd, 0, 0, -1, NULL, 0);
523 imsg_flush(&ibuf);
524 break;
527 if (!found)
528 goto badreq;
530 http_reply(clt, 302, "See Other", "/");
531 return;
533 badreq:
534 http_reply(clt, 400, "Bad Request", "text/plain");
535 http_writes(clt, "Bad Request.\n");
538 static void
539 route_mode(struct client *clt)
541 char *form, *field;
542 int found = 0;
543 ssize_t n;
544 struct player_status ps;
545 struct player_mode pm;
546 struct imsg imsg;
548 pm.repeat_one = pm.repeat_all = pm.consume = MODE_UNDEF;
550 form = clt->buf;
551 while ((field = strsep(&form, "&")) != NULL) {
552 if (url_decode(field) == -1)
553 goto badreq;
555 if (strncmp(field, "mode=", 5) != 0)
556 continue;
557 field += 5;
558 found = 1;
560 if (!strcmp(field, "all"))
561 pm.repeat_all = MODE_TOGGLE;
562 else if (!strcmp(field, "one"))
563 pm.repeat_one = MODE_TOGGLE;
564 else
565 goto badreq;
567 imsg_compose(&ibuf, IMSG_CTL_MODE, 0, 0, -1, &pm, sizeof(pm));
568 imsg_flush(&ibuf);
570 if ((n = imsg_read(&ibuf)) == -1)
571 fatal("imsg_read");
572 if (n == 0)
573 fatalx("pipe closed");
575 for (;;) {
576 if ((n = imsg_get(&ibuf, &imsg)) == -1)
577 fatal("imsg_get");
578 if (n == 0)
579 break;
581 if (imsg.hdr.type != IMSG_CTL_STATUS) {
582 unexpected_imsg(&imsg, "IMSG_CTL_STATUS");
583 imsg_free(&imsg);
584 continue;
587 if (IMSG_DATA_SIZE(imsg) != sizeof(ps))
588 fatalx("data size mismatch");
589 memcpy(&ps, imsg.data, sizeof(ps));
590 if (ps.path[sizeof(ps.path) - 1] != '\0')
591 fatalx("received corrupted data");
594 break;
597 if (!found)
598 goto badreq;
600 http_reply(clt, 302, "See Other", "/");
601 return;
603 badreq:
604 http_reply(clt, 400, "Bad Request", "text/plain");
605 http_writes(clt, "Bad Request.\n");
608 static void
609 route_dispatch(struct client *clt)
611 static const struct route {
612 int method;
613 const char *path;
614 route_fn route;
615 } routes[] = {
616 { METHOD_GET, "/", &route_home },
617 { METHOD_POST, "/jump", &route_jump },
618 { METHOD_POST, "/ctrls", &route_controls },
619 { METHOD_POST, "/mode", &route_mode },
621 { METHOD_GET, "*", &route_notfound },
622 { METHOD_POST, "*", &route_notfound },
623 };
624 struct request *req = &clt->req;
625 size_t i;
627 if ((req->method != METHOD_GET && req->method != METHOD_POST) ||
628 (req->ctype != NULL && strcmp(req->ctype, FORM_URLENCODED) != 0) ||
629 req->path == NULL) {
630 http_reply(clt, 400, "Bad Request", NULL);
631 return;
634 for (i = 0; i < nitems(routes); ++i) {
635 if (req->method != routes[i].method ||
636 fnmatch(routes[i].path, req->path, 0) != 0)
637 continue;
638 clt->done = 1; /* assume with one round is done */
639 clt->route = routes[i].route;
640 clt->route(clt);
641 if (clt->done)
642 http_close(clt);
643 return;
647 static void
648 client_ev(int fd, int ev, void *d)
650 struct client *clt = d;
652 if (ev & (POLLIN|POLLHUP)) {
653 if (bufio_read(&clt->bio) == -1 && errno != EAGAIN) {
654 log_warn("bufio_read");
655 goto err;
659 if (ev & POLLOUT) {
660 if (bufio_write(&clt->bio) == -1 && errno != EAGAIN) {
661 log_warn("bufio_read");
662 goto err;
666 if (clt->route == NULL) {
667 if (http_parse(clt) == -1) {
668 if (errno == EAGAIN)
669 goto again;
670 log_warnx("HTTP parse request failed");
671 goto err;
673 if (clt->req.method == METHOD_POST &&
674 http_read(clt) == -1) {
675 if (errno == EAGAIN)
676 goto again;
677 log_warnx("failed to read POST data");
678 goto err;
680 route_dispatch(clt);
681 goto again;
684 if (!clt->done)
685 clt->route(clt);
687 again:
688 ev = bufio_pollev(&clt->bio);
689 if (ev == POLLIN && clt->done) {
690 goto err; /* done with this client */
693 ev_add(fd, ev, client_ev, clt);
694 return;
696 err:
697 ev_del(fd);
698 http_free(clt);
701 static void
702 web_accept(int psock, int ev, void *d)
704 struct client *clt;
705 int sock;
707 if ((sock = accept(psock, NULL, NULL)) == -1) {
708 warn("accept");
709 return;
711 clt = xcalloc(1, sizeof(*clt));
712 if ((clt = calloc(1, sizeof(*clt))) == NULL ||
713 http_init(clt, sock) == -1) {
714 log_warn("failed to initialize client");
715 free(clt);
716 close(sock);
717 return;
720 client_ev(sock, POLLIN, clt);
721 return;
724 void __dead
725 usage(void)
727 fprintf(stderr, "usage: %s [-v] [-s sock] [-t prefix] [[host] port]\n",
728 getprogname());
729 exit(1);
732 int
733 main(int argc, char **argv)
735 struct addrinfo hints, *res, *res0;
736 const char *cause = NULL;
737 const char *host = NULL;
738 const char *port = "9090";
739 char *sock = NULL;
740 size_t nsock, error, save_errno;
741 int ch, v, amused_sock, fd;
742 int verbose = 0;
744 setlocale(LC_ALL, NULL);
746 log_init(1, LOG_DAEMON);
748 if (pledge("stdio rpath unix inet dns", NULL) == -1)
749 err(1, "pledge");
751 while ((ch = getopt(argc, argv, "s:t:v")) != -1) {
752 switch (ch) {
753 case 's':
754 sock = optarg;
755 break;
756 case 't':
757 prefix = optarg;
758 prefixlen = strlen(prefix);
759 break;
760 case 'v':
761 verbose = 1;
762 break;
763 default:
764 usage();
767 argc -= optind;
768 argv += optind;
770 if (argc == 1)
771 port = argv[0];
772 if (argc == 2) {
773 host = argv[0];
774 port = argv[1];
776 if (argc > 2)
777 usage();
779 log_setverbose(verbose);
781 if (sock == NULL)
782 xasprintf(&sock, "/tmp/amused-%d", getuid());
784 signal(SIGPIPE, SIG_IGN);
786 if (ev_init() == -1)
787 fatal("ev_init");
789 amused_sock = dial(sock);
790 imsg_init(&ibuf, amused_sock);
792 memset(&hints, 0, sizeof(hints));
793 hints.ai_family = AF_UNSPEC;
794 hints.ai_socktype = SOCK_STREAM;
795 hints.ai_flags = AI_PASSIVE;
796 error = getaddrinfo(host, port, &hints, &res0);
797 if (error)
798 errx(1, "%s", gai_strerror(error));
800 nsock = 0;
801 for (res = res0; res; res = res->ai_next) {
802 fd = socket(res->ai_family, res->ai_socktype,
803 res->ai_protocol);
804 if (fd == -1) {
805 cause = "socket";
806 continue;
809 v = 1;
810 if (setsockopt(fd, SOL_SOCKET, SO_REUSEADDR,
811 &v, sizeof(v)) == -1)
812 fatal("setsockopt(SO_REUSEADDR)");
814 if (bind(fd, res->ai_addr, res->ai_addrlen) == -1) {
815 cause = "bind";
816 save_errno = errno;
817 close(fd);
818 errno = save_errno;
819 continue;
822 if (listen(fd, 5) == -1)
823 err(1, "listen");
825 if (ev_add(fd, POLLIN, web_accept, NULL) == -1)
826 fatal("ev_add");
827 nsock++;
829 if (nsock == 0)
830 err(1, "%s", cause);
831 freeaddrinfo(res0);
833 if (pledge("stdio inet", NULL) == -1)
834 err(1, "pledge");
836 log_info("starting");
837 ev_loop();
838 return (1);