Skip to content

Commit 546d3c4

Browse files
committed
#675 Refactor RangeParser out of XSendFile, and add RangeParserTest
1 parent 3c3781f commit 546d3c4

File tree

3 files changed

+127
-98
lines changed

3 files changed

+127
-98
lines changed
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
package xitrum.handler.outbound
2+
3+
import xitrum.Log
4+
5+
import scala.util.control.NonFatal
6+
7+
trait RangeParserResult
8+
9+
// Valid range is send by client
10+
// Xitrum should return 206 (Partial content).
11+
case class SatisfiableRange(startIndex: Long, endIndex: Long) extends RangeParserResult
12+
13+
// first-byte-pos value greater than the current length of the selected resource.
14+
// Xitrum should return 416 (Requested Range Not Satisfiable).
15+
case object UnsatisfiableRange extends RangeParserResult
16+
17+
// Unsupported format, include syntax error.
18+
// Xitrum should ignore range header.
19+
case object UnsupportedRange extends RangeParserResult
20+
21+
object RangeParser {
22+
/**
23+
* "Range" request: http://tools.ietf.org/html/rfc2616#section-14.35
24+
*
25+
* For simplicity only these specs are supported:
26+
* bytes=123-456
27+
* bytes=123-
28+
*
29+
* If the last-byte-pos value is present, it MUST be greater than or
30+
* equal to the first-byte-pos in that byte-range-spec, or the byte-
31+
* range-spec is syntactically invalid. The recipient of a byte-range-
32+
* set that includes one or more syntactically invalid byte-range-spec
33+
* values MUST ignore the header field that includes that byte-range-
34+
* set.
35+
*
36+
* If the last-byte-pos value is absent, or if the value is greater than
37+
* or equal to the current length of the entity-body, last-byte-pos is
38+
* taken to be equal to one less than the current length of the entity-
39+
* body in bytes.
40+
*/
41+
def parse(spec: String, length: Long): RangeParserResult = {
42+
// Log unsupported Range specs, so that we know that we may need to support them later
43+
44+
if (spec == null) {
45+
return UnsupportedRange
46+
}
47+
48+
if (spec.length <= 6) { // 6: length of "bytes="
49+
Log.warn("Unsupported Range spec: " + spec)
50+
return UnsupportedRange
51+
}
52+
53+
// Skip "bytes="
54+
val range = spec.substring(6)
55+
56+
// Split start and end
57+
val se = range.split('-')
58+
if (se.length != 1 && se.length != 2) {
59+
Log.warn("Unsupported Range spec: " + spec)
60+
return UnsupportedRange
61+
}
62+
63+
// Catch toLong exception
64+
try {
65+
val s = se(0).toLong
66+
val e = if (se.length == 2) se(1).toLong else length - 1
67+
68+
if (s > length - 1 || s > e) {
69+
UnsatisfiableRange
70+
} else {
71+
SatisfiableRange(s, Math.min(e, length - 1))
72+
}
73+
} catch {
74+
case NonFatal(_) =>
75+
Log.warn("Unsupported Range spec: " + spec)
76+
UnsupportedRange
77+
}
78+
}
79+
}

src/main/scala/xitrum/handler/outbound/XSendFile.scala

Lines changed: 11 additions & 98 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
package xitrum.handler.outbound
22

33
import java.io.RandomAccessFile
4-
import scala.util.control.NonFatal
54

65
import io.netty.buffer.Unpooled
7-
import io.netty.channel.{ChannelOutboundHandlerAdapter, ChannelHandler, ChannelHandlerContext, ChannelFuture, ChannelPromise, DefaultFileRegion, ChannelFutureListener}
8-
import io.netty.handler.codec.http.{FullHttpRequest, FullHttpResponse, HttpMethod, HttpHeaderNames, HttpHeaderValues, HttpResponseStatus, HttpUtil, LastHttpContent}
6+
import io.netty.channel.{ChannelOutboundHandlerAdapter, ChannelHandler, ChannelHandlerContext, ChannelFuture, ChannelPromise, DefaultFileRegion}
7+
import io.netty.handler.codec.http.{FullHttpResponse, HttpMethod, HttpHeaderNames, HttpHeaderValues, HttpResponseStatus, HttpUtil, LastHttpContent}
98
import io.netty.handler.ssl.SslHandler
109
import io.netty.handler.stream.ChunkedFile
1110
import ChannelHandler.Sharable
@@ -14,23 +13,10 @@ import HttpHeaderValues._
1413
import HttpMethod._
1514
import HttpResponseStatus._
1615

17-
import xitrum.Log
1816
import xitrum.etag.{Etag, NotModified}
1917
import xitrum.handler.{AccessLog, HandlerEnv, NoRealPipelining}
2018
import xitrum.util.{ByteBufUtil, Gzip}
2119

22-
// valid range is send by client
23-
// xitrum should return 206 (Partial content)
24-
private case class SatisfiableRange(startIndex: Long, endIndex: Long)
25-
26-
// first-byte-pos value greater than the current length of the selected resource
27-
// Xitrum should return 416 (Requested Range Not Satisfiable)
28-
private case class UnsatisfiableRange()
29-
30-
// Unsupported format, include syntax error
31-
// Xitrum should ignore range header
32-
private case class UnsupportedRange()
33-
3420
// Based on https://github.com/netty/netty/tree/master/example/src/main/java/io/netty/example/http/file
3521
object XSendFile {
3622
// setClientCacheAggressively should be called at PublicFileServer, not
@@ -91,7 +77,7 @@ object XSendFile {
9177
// headers, not a FullHttpResponse.
9278
Etag.forFile(path, mimeo, Gzip.isAccepted(request)) match {
9379
case Etag.NotFound =>
94-
XSendFile.removeHeaders(response)
80+
removeHeaders(response)
9581

9682
response.setStatus(NOT_FOUND)
9783
NotModified.setNoClientCache(response)
@@ -106,7 +92,7 @@ object XSendFile {
10692
}
10793

10894
case Etag.Small(bytes, etag, mmo, gzipped) =>
109-
XSendFile.removeHeaders(response)
95+
removeHeaders(response)
11096

11197
if (Etag.areEtagsIdentical(request, etag)) {
11298
response.setStatus(NOT_MODIFIED)
@@ -133,7 +119,7 @@ object XSendFile {
133119
// but it's still good to give it a try
134120
val lastModifiedRfc2822 = NotModified.formatRfc2822(file.lastModified)
135121
if (request.headers.get(IF_MODIFIED_SINCE) == lastModifiedRfc2822) {
136-
XSendFile.removeHeaders(response)
122+
removeHeaders(response)
137123

138124
response.setStatus(NOT_MODIFIED)
139125
response.content.clear()
@@ -145,12 +131,12 @@ object XSendFile {
145131

146132
val raf = new RandomAccessFile(path, "r")
147133

148-
val (offset, length) = getRangeFromRequest(request, raf.length) match {
134+
val (offset, length) = RangeParser.parse(request.headers.get(RANGE), raf.length) match {
149135
case UnsupportedRange =>
150136
(0L, raf.length) // 0L is for avoiding "type mismatch" compile error
151137

152138
case UnsatisfiableRange =>
153-
// A server sending a response with status code 416 (Requested range notsatisfiable)
139+
// A server sending a response with status code 416 (Requested range not satisfiable)
154140
// SHOULD include a Content-Range field with a byte-range-resp-spec of "*".
155141
// The instance-length specifies the current length of
156142
response.setStatus(REQUESTED_RANGE_NOT_SATISFIABLE)
@@ -170,19 +156,12 @@ object XSendFile {
170156
if (mmo.isDefined) response.headers.set(CONTENT_TYPE, mmo.get)
171157
if (!noLog) AccessLog.logStaticFileAccess(remoteAddress, request, response)
172158

173-
if (request.method == HEAD && response.status == OK) {
174-
XSendFile.removeHeaders(response)
159+
if (response.status == REQUESTED_RANGE_NOT_SATISFIABLE || (request.method == HEAD && response.status == OK)) {
160+
removeHeaders(response)
175161

176162
// http://stackoverflow.com/questions/3854842/content-length-header-with-head-requests
177163
response.content.clear()
178-
ctx.write(env, promise)
179-
NoRealPipelining.if_keepAliveRequest_then_resumeReading_else_closeOnComplete(request, channel, promise)
180-
return
181-
}
182164

183-
if (response.status == REQUESTED_RANGE_NOT_SATISFIABLE) {
184-
XSendFile.removeHeaders(response)
185-
response.content.clear()
186165
ctx.write(env, promise)
187166
NoRealPipelining.if_keepAliveRequest_then_resumeReading_else_closeOnComplete(request, channel, promise)
188167
return
@@ -196,86 +175,20 @@ object XSendFile {
196175
// Cannot use zero-copy with HTTPS
197176
ctx
198177
.write(new ChunkedFile(raf, offset, length, CHUNK_SIZE))
199-
.addListener(new ChannelFutureListener {
200-
def operationComplete(f: ChannelFuture) { raf.close() }
201-
})
178+
.addListener((_: ChannelFuture) => raf.close())
202179
} else {
203180
// No encryption - use zero-copy
204181
val region = new DefaultFileRegion(raf.getChannel, offset, length)
205182
ctx
206183
.write(region) // region will automatically be released
207-
.addListener(new ChannelFutureListener {
208-
def operationComplete(f: ChannelFuture) { raf.close() }
209-
})
184+
.addListener((_: ChannelFuture) => raf.close())
210185
}
211186

212187
// Write the end marker
213188
val future = ctx.writeAndFlush(LastHttpContent.EMPTY_LAST_CONTENT, promise)
214189
NoRealPipelining.if_keepAliveRequest_then_resumeReading_else_closeOnComplete(request, channel, future)
215190
}
216191
}
217-
218-
/**
219-
* "Range" request: http://tools.ietf.org/html/rfc2616#section-14.35
220-
* For simplicity only these specs are supported:
221-
* bytes=123-456
222-
* bytes=123-
223-
* If the last-byte-pos value is present, it MUST be greater than or
224-
* equal to the first-byte-pos in that byte-range-spec, or the byte-
225-
* range-spec is syntactically invalid. The recipient of a byte-range-
226-
* set that includes one or more syntactically invalid byte-range-spec
227-
* values MUST ignore the header field that includes that byte-range-
228-
* set.
229-
* If the last-byte-pos value is absent, or if the value is greater than
230-
* or equal to the current length of the entity-body, last-byte-pos is
231-
* taken to be equal to one less than the current length of the entity-
232-
* body in bytes.
233-
*
234-
* @return SatisfiableRange(startIndex, endIndex) or UnsatisfiableRange or UnsupportedRange
235-
*/
236-
private def getRangeFromRequest(request: FullHttpRequest, length: Long) = {
237-
val spec = request.headers.get(RANGE)
238-
try {
239-
if (spec == null) {
240-
UnsupportedRange
241-
} else {
242-
if (spec.length <= 6) {
243-
Log.warn("Unsupported Range spec: " + spec)
244-
UnsupportedRange
245-
} else {
246-
val range = spec.substring(6) // Skip "bytes="
247-
val se = range.split('-')
248-
if (se.length == 2) {
249-
val s = se(0).toLong
250-
val e = se(1).toLong
251-
if (s > length - 1) {
252-
UnsatisfiableRange
253-
} else if (s <= e) {
254-
SatisfiableRange(s, Math.min(e, length - 1))
255-
} else {
256-
Log.warn("Unsupported Range, last-byte-pos MUST be greater than or equal to the first-byte-pos. spec: " + spec)
257-
UnsupportedRange
258-
}
259-
} else if (se.length != 1) {
260-
Log.warn("Unsupported Range spec: " + spec)
261-
UnsupportedRange
262-
} else {
263-
val s = se(0).toLong
264-
val e = length - 1
265-
if (s > length - 1) {
266-
UnsatisfiableRange
267-
} else {
268-
SatisfiableRange(s, e)
269-
}
270-
}
271-
}
272-
}
273-
} catch {
274-
case NonFatal(e) =>
275-
Log.warn("Unsupported Range spec: " + spec)
276-
UnsupportedRange
277-
}
278-
}
279192
}
280193

281194
/**
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package xitrum.handler.outbound
2+
3+
import org.scalatest.{FlatSpec, Matchers}
4+
5+
class RangeParserTest extends FlatSpec with Matchers {
6+
behavior of "RangeParser"
7+
8+
def test(spec: String, expected: RangeParserResult) {
9+
"parse" should s"handle $spec" in {
10+
RangeParser.parse(spec, 1048576) shouldBe expected
11+
}
12+
}
13+
14+
// Invalid
15+
test(null, UnsupportedRange)
16+
test("bytes=", UnsupportedRange)
17+
test("bytes=-", UnsupportedRange)
18+
test("bytes=--", UnsupportedRange)
19+
test("bytes=0--1", UnsupportedRange)
20+
test("bytes=10-5", UnsatisfiableRange)
21+
test("bytes=1048576", UnsatisfiableRange)
22+
23+
// last-byte-pos value is absent
24+
test("bytes=0", SatisfiableRange(0, 1048575))
25+
test("bytes=0-", SatisfiableRange(0, 1048575))
26+
test("bytes=1048574", SatisfiableRange(1048574, 1048575))
27+
28+
// last-byte-pos value is present
29+
test("bytes=0-0", SatisfiableRange(0, 0))
30+
test("bytes=0-1", SatisfiableRange(0, 1))
31+
test("bytes=0-1048574", SatisfiableRange(0, 1048574))
32+
test("bytes=0-1048575", SatisfiableRange(0, 1048575))
33+
test("bytes=0-1048576", SatisfiableRange(0, 1048575))
34+
35+
// first-byte-pos value greater than the length
36+
test("bytes=0-1048577", SatisfiableRange(0, 1048575))
37+
}

0 commit comments

Comments
 (0)