From: Ömer Görür <102440553+omergorur@users.noreply.github.com> Date: Sun, 21 Jun 2026 17:54:27 +0000 (+0300) Subject: Add Content-Disposition for playlist downloads X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=243e91cf641f04f68c79fff415a3c666ca42c9f2;p=thirdparty%2Ftvheadend.git Add Content-Disposition for playlist downloads Add Content-Disposition for playlist downloads Add support for sending a Content-Disposition header so playlists are downloaded instead of rendered inline by the browser. Change http_send_reply signature to accept a disposition string and update callers to pass NULL where not used. Add http_output_content_disposition wrapper and its prototype. Implement http_playlist_disposition to build an attachment filename for playlist downloads, providing an RFC 6266 ASCII fallback (filename=) together with an RFC 8187 percent-encoded UTF-8 name (filename*=) so non-ASCII channel and recording names are transmitted safely. Update playlist handlers (page_http_playlist, page_xspf, page_m3u) to use the new disposition behavior --- diff --git a/src/http.c b/src/http.c index 71a041805..2b7aa5873 100644 --- a/src/http.c +++ b/src/http.c @@ -636,8 +636,9 @@ http_check_local_ip( http_connection_t *hc ) * Transmit a HTTP reply */ static void -http_send_reply(http_connection_t *hc, int rc, const char *content, - const char *encoding, const char *location, int maxage) +http_send_reply(http_connection_t *hc, int rc, const char *content, + const char *encoding, const char *location, int maxage, + const char *disposition) { size_t size = hc->hc_reply.hq_size; uint8_t *data = NULL; @@ -653,7 +654,7 @@ http_send_reply(http_connection_t *hc, int rc, const char *content, http_send_begin(hc); http_send_header(hc, rc, content, size, - encoding, location, maxage, 0, NULL, NULL); + encoding, location, maxage, 0, disposition, NULL); if(!hc->hc_no_output) { if (data == NULL) @@ -713,9 +714,9 @@ http_error(http_connection_t *hc, int error) htsbuf_append_str(&hc->hc_reply, "\r\n"); - http_send_reply(hc, error, "text/html", NULL, NULL, 0); + http_send_reply(hc, error, "text/html", NULL, NULL, 0, NULL); } else { - http_send_reply(hc, error, NULL, NULL, NULL, 0); + http_send_reply(hc, error, NULL, NULL, NULL, 0, NULL); } } @@ -727,16 +728,27 @@ void http_output_html(http_connection_t *hc) { return http_send_reply(hc, HTTP_STATUS_OK, "text/html; charset=UTF-8", - NULL, NULL, 0); + NULL, NULL, 0, NULL); } /** - * Send an HTTP OK, simple version for text/html + * Send an HTTP OK, simple version for arbitrary content type */ void http_output_content(http_connection_t *hc, const char *content) { - return http_send_reply(hc, HTTP_STATUS_OK, content, NULL, NULL, 0); + return http_send_reply(hc, HTTP_STATUS_OK, content, NULL, NULL, 0, NULL); +} + +/** + * Send an HTTP OK with a Content-Disposition header, so browsers download + * the body instead of rendering it inline (used for playlist downloads). + */ +void +http_output_content_disposition(http_connection_t *hc, const char *content, + const char *disposition) +{ + http_send_reply(hc, HTTP_STATUS_OK, content, NULL, NULL, 0, disposition); } @@ -788,7 +800,7 @@ http_redirect(http_connection_t *hc, const char *location, "\r\n", loc, loc); - http_send_reply(hc, HTTP_STATUS_FOUND, "text/html", NULL, loc, 0); + http_send_reply(hc, HTTP_STATUS_FOUND, "text/html", NULL, loc, 0, NULL); if (loc != location) free((char *)loc); @@ -813,7 +825,7 @@ http_css_import(http_connection_t *hc, const char *location) htsbuf_qprintf(&hc->hc_reply, "@import url('%s');\r\n", loc); - http_send_reply(hc, HTTP_STATUS_OK, "text/css", NULL, loc, 0); + http_send_reply(hc, HTTP_STATUS_OK, "text/css", NULL, loc, 0, NULL); } /** diff --git a/src/http.h b/src/http.h index 0246311ba..1515c786c 100644 --- a/src/http.h +++ b/src/http.h @@ -243,6 +243,9 @@ void http_output_html(http_connection_t *hc); void http_output_content(http_connection_t *hc, const char *content); +void http_output_content_disposition(http_connection_t *hc, const char *content, + const char *disposition); + void http_redirect(http_connection_t *hc, const char *location, struct http_arg_list *req_args, int external); diff --git a/src/webui/webui.c b/src/webui/webui.c index 91383a23f..680aeab5a 100644 --- a/src/webui/webui.c +++ b/src/webui/webui.c @@ -1037,6 +1037,52 @@ http_dvr_playlist(http_connection_t *hc, int pltype, int urlauth, dvr_entry_t *d } +static char *sanitize_filename(char *filename); + +/** + * Build a "Content-Disposition: attachment" header value for a playlist + * download. Produces an RFC 6266 ASCII fallback in filename="..." together + * with an RFC 8187 percent-encoded UTF-8 filename*=..., so non-ASCII channel + * and recording names survive HTTP transport (header values must be US-ASCII) + * and browsers download the playlist instead of rendering it inline. + * Returns a malloc'd string the caller must free (NULL on allocation failure). + */ +static char * +http_playlist_disposition(const char *name, const char *ext) +{ + char base[256], *ascii, *enc, *result; + htsbuf_queue_t q; + size_t len; + + if (name == NULL || *name == '\0') + name = "playlist"; + snprintf(base, sizeof(base), "%s.%s", name, ext); + + /* ASCII fallback for the legacy filename="..." parameter */ + ascii = intlconv_utf8safestr(intlconv_charset_id("ASCII", 1, 1), + base, strlen(base) * 3); + if (ascii == NULL) + ascii = strdup("playlist"); + sanitize_filename(ascii); + + /* RFC 8187 percent-encoded UTF-8 for the filename*=... parameter */ + htsbuf_queue_init(&q, 0); + htsbuf_append_and_escape_rfc8187(&q, base); + enc = htsbuf_to_string(&q); + htsbuf_queue_flush(&q); + + len = strlen(ascii) + strlen(enc) + 50; + result = malloc(len); + if (result) + snprintf(result, len, + "attachment; filename=\"%s\"; filename*=UTF-8''%s", ascii, enc); + + free(enc); + free(ascii); + return result; +} + + /** * Handle requests for playlists. */ @@ -1044,13 +1090,15 @@ static int page_http_playlist_ (http_connection_t *hc, const char *remain, void *opaque, int urlauth) { - char *components[2], *cmd, *s, buf[40]; + char *components[2], *cmd, *s, buf[40], dispname[256]; const char *cs; int nc, r, pltype = PLAYLIST_M3U; channel_t *ch = NULL; dvr_entry_t *de = NULL; channel_tag_t *tag = NULL; + dispname[0] = '\0'; + if (remain && !strcmp(remain, "e2")) { pltype = PLAYLIST_E2; remain = NULL; @@ -1114,15 +1162,19 @@ page_http_playlist_ tag = channel_tag_find_by_name(components[1], 0); } - if(ch) + if(ch) { r = http_channel_playlist(hc, pltype, urlauth, ch); - else if(tag) + strlcpy(dispname, channel_get_name(ch, ""), sizeof(dispname)); + } else if(tag) { r = http_tag_playlist(hc, pltype, urlauth, tag); - else if(de) { + strlcpy(dispname, tag->ct_name ?: "", sizeof(dispname)); + } else if(de) { if (pltype == PLAYLIST_SATIP_M3U) r = HTTP_STATUS_BAD_REQUEST; - else + else { r = http_dvr_playlist(hc, pltype, urlauth, de); + strlcpy(dispname, lang_str_get(de->de_title, NULL) ?: "", sizeof(dispname)); + } } else { cmd = s = tvh_strdupa(components[0]); while (*s && *s != '.') s++; @@ -1142,12 +1194,20 @@ page_http_playlist_ else { r = HTTP_STATUS_BAD_REQUEST; } + if (r == 0) + strlcpy(dispname, cmd, sizeof(dispname)); } tvh_mutex_unlock(&global_lock); - if (r == 0) - http_output_content(hc, pltype == PLAYLIST_E2 ? MIME_E2 : MIME_M3U); + if (r == 0) { + char *disposition = http_playlist_disposition(dispname, + pltype == PLAYLIST_E2 ? "tv" : "m3u"); + http_output_content_disposition(hc, + pltype == PLAYLIST_E2 ? MIME_E2 : MIME_M3U, + disposition); + free(disposition); + } return r; } @@ -1775,7 +1835,11 @@ page_xspf(http_connection_t *hc, const char *remain, void *opaque, int urlauth) \r\n\ \r\n\ \r\n"); - http_output_content(hc, MIME_XSPF_XML); + { + char *disposition = http_playlist_disposition(title, "xspf"); + http_output_content_disposition(hc, MIME_XSPF_XML, disposition); + free(disposition); + } return 0; } @@ -1825,7 +1889,11 @@ page_m3u(http_connection_t *hc, const char *remain, void *opaque, int urlauth) break; } htsbuf_append_str(hq, "\n"); - http_output_content(hc, MIME_M3U); + { + char *disposition = http_playlist_disposition(title, "m3u"); + http_output_content_disposition(hc, MIME_M3U, disposition); + free(disposition); + } return 0; }