/*
* Copyright 2013-2014 Odysseus Software GmbH
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.musicmount.server;
import java.io.File;
import java.io.FileInputStream;
import java.io.FilterInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.Map;
import java.util.logging.Logger;
import javax.xml.bind.DatatypeConverter;
import fi.iki.elonen.NanoHTTPD;
import fi.iki.elonen.NanoHTTPD.Response;
/**
* Experimental!
*
* not really working...
*/
public class MusicMountServerNano implements MusicMountServer {
static final Logger LOGGER = Logger.getLogger(MusicMountServerNano.class.getName());
/**
* Hack: nanohttpd uses <code>available()</code> to determine Content-Length!
* See https://github.com/NanoHttpd/nanohttpd/issues/51
*/
static class MusicMountResponseData extends FilterInputStream {
int available;
MusicMountResponseData(InputStream input, int available) {
super(input);
this.available = available;
}
@Override
public synchronized int available() throws IOException {
return available > 0 ? available : super.available();
}
@Override
public synchronized int read() throws IOException {
available = 0;
return super.read();
}
@Override
public synchronized int read(byte[] b, int off, int len) throws IOException {
available = 0;
return super.read(b, off, len);
}
}
static class MusicMountResponse extends Response {
final Long contentLength;
MusicMountResponse(Response.Status status, String mimeType, String message) {
super(status, mimeType, message);
this.contentLength = null;
addHeader("Accept-Ranges", "bytes");
}
MusicMountResponse(Response.Status status, String mimeType, InputStream data, long contentLength) {
super(status, mimeType, new MusicMountResponseData(data, (int)contentLength));
this.contentLength = contentLength;
addHeader("Accept-Ranges", "bytes");
}
}
private static String mimeType(File file) {
int dot = file.getName().lastIndexOf('.');
String fileType = dot > 0 ? file.getName().substring(dot + 1).toLowerCase() : null;
if (fileType != null) {
switch (fileType) {
case "json" :
return "text/json";
case "m4a" :
return "audio/mp4";
case "mp3" :
return "audio/mpeg";
case "jpg" :
case "jpeg":
return "image/jpeg";
case "png" :
return "image/png";
}
}
return null;
}
private FolderContext mountContext;
private FolderContext musicContext;
private String basicAuth;
private NanoHTTPD nano;
private final AccessLog accessLog;
public MusicMountServerNano(AccessLog accessLog) {
this.accessLog = accessLog;
// Runtime.getRuntime().addShutdownHook(new Thread(new Runnable() {
// @Override
// public void run() {
// if (isAlive()) {
// stop();
// }
// }
// }));
}
@Override
public void start(FolderContext music, FolderContext mount, int port, String user, String password) throws Exception {
this.mountContext = mount;
this.musicContext = music;
this.basicAuth = user != null ? "Basic " + DatatypeConverter.printBase64Binary((user + ":" + password).getBytes()) : null;
this.nano = new NanoHTTPD(port) {
@Override
public Response serve(final IHTTPSession session) {
final long requestTimestamp = System.currentTimeMillis();
final MusicMountResponse response = MusicMountServerNano.this.serve(session.getUri(), session.getHeaders());
if (accessLog != null) {
final long responseTimestamp = System.currentTimeMillis();
accessLog.log(new AccessLog.Entry() {
@Override
public long getResponseTimestamp() {
return responseTimestamp;
}
@Override
public int getResponseStatus() {
return response.getStatus() != null ? response.getStatus().getRequestStatus() : 0;
}
@Override
public String getResponseHeader(String header) {
if (header.equalsIgnoreCase("Content-Length")) {
if (response.contentLength != null) {
return String.valueOf(response.contentLength);
}
}
return null;
}
@Override
public String getRequestURI() {
return session.getUri();
}
@Override
public long getRequestTimestamp() {
return requestTimestamp;
}
@Override
public String getRequestMethod() {
return session.getMethod().name();
}
});
int methodAndURIFormatLength = 50;
StringBuilder builder = new StringBuilder();
String uri = session.getUri();
int maxURILength = methodAndURIFormatLength - 1 - session.getMethod().name().length();
if (uri.length() > maxURILength) {
uri = "..." + uri.substring(uri.length() - maxURILength + 3);
}
String methodAndURI = String.format("%s %s", session.getMethod().name(), uri);
builder.append(String.format(String.format("%%-%ds", methodAndURIFormatLength), methodAndURI));
builder.append(String.format("%4d", response.getStatus().getRequestStatus()));
if (response.contentLength != null) {
builder.append(String.format("%,11dB", response.contentLength));
} else {
builder.append(" ");
}
LOGGER.finer(builder.toString());
}
return response;
}
};
nano.start();
}
@Override
public void start(FolderContext music, MountContext mount, int port, String user, String password) throws Exception {
throw new UnsupportedOperationException();
}
@Override
public boolean isStarted() {
return nano != null && nano.isAlive();
}
@Override
public void await() {
try {
Thread.sleep(Long.MAX_VALUE);
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
} // sleep forever
}
@Override
public void stop() {
nano.stop();
nano = null;
}
MusicMountResponse serve(String uri, Map<String, String> headers) {
// strip down URI to relevant path
uri = uri.trim().replace(File.separatorChar, '/');
if (uri.lastIndexOf('?') >= 0) {
uri = uri.substring(0, uri.lastIndexOf('?'));
}
// apply basic authentication
if (basicAuth != null && !basicAuth.equals(headers.get("authorization"))) {
MusicMountResponse response = new MusicMountResponse(Response.Status.UNAUTHORIZED, NanoHTTPD.MIME_PLAINTEXT, "Needs authentication.");
response.addHeader("WWW-Authenticate", "Basic realm=\"MusicMount\"");
return response;
}
FolderContext context = null;
if (uri.startsWith(musicContext.getPath())) {
context = musicContext;
} else if (uri.startsWith(mountContext.getPath())) {
context = mountContext;
} else {
return new MusicMountResponse(Response.Status.NOT_FOUND, NanoHTTPD.MIME_PLAINTEXT, "File not found.");
}
File file = new File(context.getFolder(), uri.substring(context.getPath().length()));
if (context == mountContext && file.isDirectory()) {
if (!uri.endsWith("/")) {
uri += "/";
}
uri += "index.json";
file = new File(file, "index.json");
}
String mimeType = mimeType(file);
if (mimeType == null || !file.getAbsolutePath().startsWith(context.getFolder().getAbsolutePath())) {
return new MusicMountResponse(Response.Status.FORBIDDEN, NanoHTTPD.MIME_PLAINTEXT, "Won't serve.");
}
if (!file.exists()) {
return new MusicMountResponse(Response.Status.NOT_FOUND, NanoHTTPD.MIME_PLAINTEXT, "File not found.");
}
if (!file.canRead()) {
return new MusicMountResponse(Response.Status.FORBIDDEN, NanoHTTPD.MIME_PLAINTEXT, "Cannot read file.");
}
if (file.isDirectory()) {
return new MusicMountResponse(Response.Status.FORBIDDEN, NanoHTTPD.MIME_PLAINTEXT, "No directory listing.");
}
try {
return serveFile(file, headers, (mimeType + "; charset=utf-8"));
} catch (IOException e) {
return new MusicMountResponse(Response.Status.INTERNAL_ERROR, NanoHTTPD.MIME_PLAINTEXT, "Could not serve file.");
}
}
MusicMountResponse serveFile(File file, Map<String, String> headers, String contentType) throws IOException {
// Support (simple) skipping:
long startFrom = 0;
long endAt = -1;
String range = headers.get("range");
if (range != null) {
if (range.startsWith("bytes=")) {
range = range.substring("bytes=".length());
int minus = range.indexOf('-');
try {
if (minus > 0) {
startFrom = Long.parseLong(range.substring(0, minus));
endAt = Long.parseLong(range.substring(minus + 1));
}
} catch (NumberFormatException ignored) {
LOGGER.warning("Could not parse range: " + range);
}
}
}
// Calculate etag
String etag = Integer.toHexString((file.getAbsolutePath() + file.lastModified() + "" + file.length()).hashCode());
MusicMountResponse res;
// Change return code and add Content-Range header when skipping is requested
long fileLen = file.length();
if (range != null && startFrom >= 0) {
if (startFrom >= fileLen) {
res = new MusicMountResponse(Response.Status.RANGE_NOT_SATISFIABLE, NanoHTTPD.MIME_PLAINTEXT, null);
res.addHeader("Content-Range", "bytes */" + fileLen);
res.addHeader("ETag", etag);
} else {
if (endAt < 0) {
endAt = fileLen - 1;
}
long newLen = endAt - startFrom + 1;
if (newLen < 0) {
newLen = 0;
}
FileInputStream fis = new FileInputStream(file);
long position = 0;
while (position < startFrom) {
long skipped = fis.skip(startFrom - position);
if (skipped <= 0) {
fis.close();
throw new IOException("Could not skip to start position");
}
position += skipped;
}
res = new MusicMountResponse(Response.Status.PARTIAL_CONTENT, contentType, fis, newLen);
res.addHeader("Content-Range", "bytes " + startFrom + "-" + endAt + "/" + fileLen);
res.addHeader("ETag", etag);
}
} else {
if (etag.equals(headers.get("if-none-match"))) {
res = new MusicMountResponse(Response.Status.NOT_MODIFIED, contentType, null);
res.addHeader("ETag", etag);
} else {
FileInputStream fis = new FileInputStream(file);
res = new MusicMountResponse(Response.Status.OK, contentType, fis, fileLen);
res.addHeader("ETag", etag);
}
}
return res;
}
}