Skip to content

Commit 872e96a

Browse files
committed
extended hls proxy manifest replacement logic.
1 parent 3a336a4 commit 872e96a

File tree

2 files changed

+341
-12
lines changed

2 files changed

+341
-12
lines changed

hlsproxy/manager.go

Lines changed: 108 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
package hlsproxy
22

33
import (
4+
"bufio"
45
"io"
56
"net/http"
6-
"regexp"
77
"strings"
88
"sync"
99
"time"
@@ -64,28 +64,23 @@ func (m *ManagerCtx) ServePlaylist(w http.ResponseWriter, r *http.Request) {
6464
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
6565
return
6666
}
67-
defer resp.Body.Close()
6867

6968
if resp.StatusCode < 200 && resp.StatusCode >= 300 {
7069
// read all response body
7170
io.Copy(io.Discard, resp.Body)
71+
resp.Body.Close()
7272

7373
m.logger.Err(err).Int("code", resp.StatusCode).Msg("invalid HTTP response")
7474
http.Error(w, http.StatusText(http.StatusBadGateway), http.StatusBadGateway)
7575
return
7676
}
7777

78-
buf, err := io.ReadAll(resp.Body)
79-
if err != nil {
80-
m.logger.Err(err).Msg("unadle to read response body")
81-
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
82-
return
83-
}
84-
85-
var re = regexp.MustCompile(`(?m:^(https?\:\/\/[^\/]+)?\/)`)
86-
text := re.ReplaceAllString(string(buf), m.prefix)
78+
// replace all urls in playlist with relative ones
79+
text := PlaylistUrlWalk(resp.Body, func(u string) string {
80+
return RelativePath(m.baseUrl, m.prefix, u)
81+
})
8782

88-
cache = m.saveToCache(url, strings.NewReader(data), time.Now().Add(playlistExpiration))
83+
cache = m.saveToCache(url, strings.NewReader(text), time.Now().Add(playlistExpiration))
8984
}
9085

9186
w.Header().Set("Content-Type", "application/vnd.apple.mpegurl")
@@ -124,3 +119,104 @@ func (m *ManagerCtx) ServeMedia(w http.ResponseWriter, r *http.Request) {
124119

125120
cache.ServeHTTP(w)
126121
}
122+
123+
// resolve path: remove ../ and ./ from path
124+
func resolvePath(path string) string {
125+
parts := strings.Split(path, "/")
126+
resolved := []string{}
127+
128+
for _, part := range parts {
129+
if part == ".." {
130+
if len(resolved) > 0 {
131+
resolved = resolved[:len(resolved)-1]
132+
}
133+
} else if part == "." {
134+
continue
135+
} else {
136+
resolved = append(resolved, part)
137+
}
138+
}
139+
140+
return strings.Join(resolved, "/")
141+
}
142+
143+
// simple relative path resolver
144+
func RelativePath(baseUrl, prefix, u string) string {
145+
if strings.HasPrefix(u, "http://") || strings.HasPrefix(u, "https://") {
146+
// replace base url with prefix
147+
u = strings.Replace(u, baseUrl, prefix, 1)
148+
u = resolvePath(u)
149+
return u
150+
}
151+
152+
u = resolvePath(u)
153+
154+
if strings.HasPrefix(u, "/") {
155+
// add prefix
156+
return strings.TrimRight(prefix, "/") + u
157+
}
158+
159+
// we expect this to already be relative
160+
return u
161+
}
162+
163+
// Walks playlist and replaces all urls with callback
164+
func PlaylistUrlWalk(reader io.Reader, replace func(string) string) string {
165+
var sb strings.Builder
166+
167+
scanner := bufio.NewScanner(reader)
168+
for scanner.Scan() {
169+
line := scanner.Text()
170+
// remove leading and trailing spaces
171+
line = strings.TrimSpace(line)
172+
173+
// if line is empty, ignore it
174+
if line == "" {
175+
sb.WriteRune('\n')
176+
continue
177+
}
178+
179+
// if line starts with #, try to find URI="..." in it
180+
if strings.HasPrefix(line, "#") {
181+
// split string by URI="
182+
parts1 := strings.SplitN(line, "URI=\"", 2)
183+
184+
// if we don't have 2 parts, we don't have URI="..."
185+
if len(parts1) != 2 {
186+
sb.WriteString(line)
187+
sb.WriteRune('\n')
188+
continue
189+
}
190+
191+
// split the rest of the string by " and get the first part
192+
parts2 := strings.SplitN(parts1[1], "\"", 2)
193+
194+
// if we don't have 2 parts, something is wrong
195+
if len(parts2) != 2 {
196+
sb.WriteString(line)
197+
sb.WriteRune('\n')
198+
continue
199+
}
200+
201+
// repalce url
202+
relUrl := replace(parts2[0])
203+
line = parts1[0] + "URI=\"" + relUrl + "\"" + parts2[1]
204+
205+
sb.WriteString(line)
206+
sb.WriteRune('\n')
207+
continue
208+
}
209+
210+
// whole line is url
211+
line = replace(line)
212+
sb.WriteString(line)
213+
sb.WriteRune('\n')
214+
}
215+
216+
// close reader, if it needs to be closed
217+
if closer, ok := reader.(io.ReadCloser); ok {
218+
closer.Close()
219+
}
220+
221+
return sb.String()
222+
}

hlsproxy/manager_test.go

Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
package hlsproxy
2+
3+
import (
4+
"bytes"
5+
"io"
6+
"reflect"
7+
"regexp"
8+
"strings"
9+
"testing"
10+
)
11+
12+
func TestPlaylistUrlWalk(t *testing.T) {
13+
type args struct {
14+
input string
15+
replace func(string) string
16+
}
17+
tests := []struct {
18+
name string
19+
args args
20+
want string
21+
}{
22+
{
23+
name: "simple: absolute URL",
24+
args: args{
25+
input: `#EXTM3U
26+
#EXT-X-VERSION:3
27+
#EXT-X-STREAM-INF:BANDWIDTH=1000000,RESOLUTION=1280x720
28+
http://example.com/720p.m3u8
29+
#EXT-X-STREAM-INF:BANDWIDTH=500000,RESOLUTION=854x480
30+
http://example.com/480p.m3u8
31+
#EXT-X-STREAM-INF:BANDWIDTH=250000,RESOLUTION=640x360
32+
http://example.com/360p.m3u8?streamer=456
33+
#EXT-X-STREAM-INF:BANDWIDTH=125000,RESOLUTION=426x240
34+
http://example.com/240p.m3u8
35+
`,
36+
replace: func(s string) string { return "!!" + s + "!!" },
37+
},
38+
want: `#EXTM3U
39+
#EXT-X-VERSION:3
40+
#EXT-X-STREAM-INF:BANDWIDTH=1000000,RESOLUTION=1280x720
41+
!!http://example.com/720p.m3u8!!
42+
#EXT-X-STREAM-INF:BANDWIDTH=500000,RESOLUTION=854x480
43+
!!http://example.com/480p.m3u8!!
44+
#EXT-X-STREAM-INF:BANDWIDTH=250000,RESOLUTION=640x360
45+
!!http://example.com/360p.m3u8?streamer=456!!
46+
#EXT-X-STREAM-INF:BANDWIDTH=125000,RESOLUTION=426x240
47+
!!http://example.com/240p.m3u8!!
48+
`,
49+
},
50+
{
51+
name: "simple: relative URL",
52+
args: args{
53+
input: `#EXTM3U
54+
#EXT-X-VERSION:3
55+
#EXT-X-STREAM-INF:BANDWIDTH=1000000,RESOLUTION=1280x720
56+
/720p.m3u8
57+
#EXT-X-STREAM-INF:BANDWIDTH=500000,RESOLUTION=854x480
58+
/480p.m3u8
59+
#EXT-X-STREAM-INF:BANDWIDTH=250000,RESOLUTION=640x360
60+
/360p.m3u8?streamer=456
61+
#EXT-X-STREAM-INF:BANDWIDTH=125000,RESOLUTION=426x240
62+
/240p.m3u8
63+
`,
64+
replace: func(s string) string { return "http://example.com" + s },
65+
},
66+
want: `#EXTM3U
67+
#EXT-X-VERSION:3
68+
#EXT-X-STREAM-INF:BANDWIDTH=1000000,RESOLUTION=1280x720
69+
http://example.com/720p.m3u8
70+
#EXT-X-STREAM-INF:BANDWIDTH=500000,RESOLUTION=854x480
71+
http://example.com/480p.m3u8
72+
#EXT-X-STREAM-INF:BANDWIDTH=250000,RESOLUTION=640x360
73+
http://example.com/360p.m3u8?streamer=456
74+
#EXT-X-STREAM-INF:BANDWIDTH=125000,RESOLUTION=426x240
75+
http://example.com/240p.m3u8
76+
`,
77+
},
78+
{
79+
name: "advanced: absolute URL",
80+
args: args{
81+
input: `#EXTM3U
82+
#EXT-X-VERSION:3
83+
#EXT-X-KEY:METHOD=AES-128,URI="http://example.com/check",IV=0x00000000000000000000000000000000
84+
#EXTINF:2,
85+
http://example.com/01.ts
86+
#EXT-X-KEY:METHOD=AES-128,URI="http://example.com/check",IV=0x00000000000000000000000000000000
87+
#EXTINF:2,
88+
http://example.com/02.ts
89+
#EXT-X-KEY:METHOD=AES-128,URI="http://example.com/check",IV=0x00000000000000000000000000000000
90+
#EXTINF:2,
91+
http://example.com/03.ts
92+
#EXT-X-KEY:METHOD=AES-128,URI="http://example.com/check",IV=0x00000000000000000000000000000000
93+
#EXTINF:2,
94+
http://example.com/04.ts
95+
`,
96+
replace: func(s string) string { return strings.TrimPrefix(s, "http://example.com") },
97+
},
98+
want: `#EXTM3U
99+
#EXT-X-VERSION:3
100+
#EXT-X-KEY:METHOD=AES-128,URI="/check",IV=0x00000000000000000000000000000000
101+
#EXTINF:2,
102+
/01.ts
103+
#EXT-X-KEY:METHOD=AES-128,URI="/check",IV=0x00000000000000000000000000000000
104+
#EXTINF:2,
105+
/02.ts
106+
#EXT-X-KEY:METHOD=AES-128,URI="/check",IV=0x00000000000000000000000000000000
107+
#EXTINF:2,
108+
/03.ts
109+
#EXT-X-KEY:METHOD=AES-128,URI="/check",IV=0x00000000000000000000000000000000
110+
#EXTINF:2,
111+
/04.ts
112+
`,
113+
},
114+
{
115+
name: "advanced: realative URL",
116+
args: args{
117+
input: `#EXTM3U
118+
#EXT-X-VERSION:3
119+
#EXT-X-KEY:METHOD=AES-128,URI="/check",IV=0x00000000000000000000000000000000
120+
#EXTINF:2,
121+
/01.ts
122+
#EXT-X-KEY:METHOD=AES-128,URI="/check",IV=0x00000000000000000000000000000000
123+
#EXTINF:2,
124+
/02.ts
125+
#EXT-X-KEY:METHOD=AES-128,URI="/check",IV=0x00000000000000000000000000000000
126+
#EXTINF:2,
127+
/03.ts
128+
#EXT-X-KEY:METHOD=AES-128,URI="/check
129+
#EXTINF:2,
130+
/04.ts
131+
`,
132+
replace: func(s string) string { return "foo" + s },
133+
},
134+
want: `#EXTM3U
135+
#EXT-X-VERSION:3
136+
#EXT-X-KEY:METHOD=AES-128,URI="foo/check",IV=0x00000000000000000000000000000000
137+
#EXTINF:2,
138+
foo/01.ts
139+
#EXT-X-KEY:METHOD=AES-128,URI="foo/check",IV=0x00000000000000000000000000000000
140+
#EXTINF:2,
141+
foo/02.ts
142+
#EXT-X-KEY:METHOD=AES-128,URI="foo/check",IV=0x00000000000000000000000000000000
143+
#EXTINF:2,
144+
foo/03.ts
145+
#EXT-X-KEY:METHOD=AES-128,URI="/check
146+
#EXTINF:2,
147+
foo/04.ts
148+
`,
149+
},
150+
}
151+
for _, tt := range tests {
152+
t.Run(tt.name, func(t *testing.T) {
153+
tt.want = strings.TrimSpace(tt.want)
154+
output := PlaylistUrlWalk(io.NopCloser(bytes.NewBuffer([]byte(tt.args.input))), tt.args.replace)
155+
156+
// regexp remove whitespaces from start of all lines
157+
got := []byte(regexp.MustCompile(`(?m)^\s+`).ReplaceAll([]byte(output), []byte("")))
158+
got = bytes.TrimSpace(got)
159+
want := regexp.MustCompile(`(?m)^\s+`).ReplaceAll([]byte(tt.want), []byte(""))
160+
want = bytes.TrimSpace(want)
161+
162+
if !reflect.DeepEqual(got, want) {
163+
t.Errorf("HlsRelativePathManifest() = \n---------- have ----------\n%s\n---------- want ----------\n%s", got, want)
164+
}
165+
})
166+
}
167+
}
168+
169+
func TestRelativePath(t *testing.T) {
170+
type args struct {
171+
baseUrl string
172+
prefix string
173+
u string
174+
}
175+
tests := []struct {
176+
name string
177+
args args
178+
want string
179+
}{
180+
{
181+
name: "absolute URL",
182+
args: args{
183+
baseUrl: "http://example.com",
184+
prefix: "/foo",
185+
u: "http://example.com/bar",
186+
},
187+
want: "/foo/bar",
188+
},
189+
{
190+
name: "relative URL - start with /",
191+
args: args{
192+
baseUrl: "http://example.com",
193+
prefix: "/test",
194+
u: "/foo/bar",
195+
},
196+
want: "/test/foo/bar",
197+
},
198+
{
199+
name: "relative URL",
200+
args: args{
201+
baseUrl: "http://example.com",
202+
prefix: "/foo",
203+
u: "foo/bar",
204+
},
205+
want: "foo/bar",
206+
},
207+
{
208+
name: "relative URL - contains .",
209+
args: args{
210+
baseUrl: "http://example.com",
211+
prefix: "/foo",
212+
u: "foo/bar/./baz",
213+
},
214+
want: "foo/bar/baz",
215+
},
216+
{
217+
name: "relative URL - contains ..",
218+
args: args{
219+
baseUrl: "http://example.com",
220+
prefix: "/foo",
221+
u: "foo/bar/../baz",
222+
},
223+
want: "foo/baz",
224+
},
225+
}
226+
for _, tt := range tests {
227+
t.Run(tt.name, func(t *testing.T) {
228+
if got := RelativePath(tt.args.baseUrl, tt.args.prefix, tt.args.u); got != tt.want {
229+
t.Errorf("RelativePath() = %v, want %v", got, tt.want)
230+
}
231+
})
232+
}
233+
}

0 commit comments

Comments
 (0)