]> git.ipfire.org Git - thirdparty/tvheadend.git/commitdiff
Add Content-Disposition for playlist downloads master
authorÖmer Görür <102440553+omergorur@users.noreply.github.com>
Sun, 21 Jun 2026 17:54:27 +0000 (20:54 +0300)
committerFlole <Flole998@users.noreply.github.com>
Thu, 16 Jul 2026 09:28:58 +0000 (11:28 +0200)
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

src/http.c
src/http.h
src/webui/webui.c

index 71a04180598888c96f1b140e2c916f2e12283545..2b7aa58733ee464890fc34658d2e5821bad75659 100644 (file)
@@ -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, "</BODY></HTML>\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,
                 "</BODY></HTML>\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);
 }
 
 /**
index 0246311bac398f013f16c5d83ebc249d9f162157..1515c786ceebc4904fd47a4d508fca0d1901b0c8 100644 (file)
@@ -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);
 
index 91383a23f72b1a7b67eb9049aa3356259e1a169b..680aeab5a7a384420c1be778a21ec7ea27488304 100644 (file)
@@ -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)
      </track>\r\n\
   </trackList>\r\n\
 </playlist>\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;
 }