|
|
|
|
@@ -6,7 +6,6 @@ import fi.iki.elonen.NanoHTTPD.IHTTPSession
|
|
|
|
|
import kotlinx.coroutines.runBlocking
|
|
|
|
|
import java.io.File
|
|
|
|
|
import java.util.UUID
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* NanoHTTPD-based REST bridge implementing the restic REST backend API.
|
|
|
|
|
*
|
|
|
|
|
@@ -14,10 +13,15 @@ import java.util.UUID
|
|
|
|
|
* can read/write blobs directly to SMB/WebDAV without a local staging repo.
|
|
|
|
|
*
|
|
|
|
|
* Port is auto-assigned (0); use [listeningPort] after start().
|
|
|
|
|
*
|
|
|
|
|
* @param repoPath repository path from the bridge URL (e.g. "backup").
|
|
|
|
|
* Stripped from incoming URIs so that the remoteBase SMB path
|
|
|
|
|
* does not get double-nested with the repo prefix.
|
|
|
|
|
*/
|
|
|
|
|
class ResticRestBridge(
|
|
|
|
|
private val transport: RemoteTransport,
|
|
|
|
|
private val remoteBase: String,
|
|
|
|
|
private val repoPath: String,
|
|
|
|
|
private val cacheDir: File
|
|
|
|
|
) : NanoHTTPD(0) {
|
|
|
|
|
|
|
|
|
|
@@ -56,6 +60,14 @@ class ResticRestBridge(
|
|
|
|
|
session: IHTTPSession
|
|
|
|
|
): Response {
|
|
|
|
|
val path = uri.trimEnd('/')
|
|
|
|
|
// Strip the repoPath prefix (/backup/...) from the URI so that type/name
|
|
|
|
|
// parsing sees only the restic REST API segment.
|
|
|
|
|
val stripPrefix = if (repoPath.isNotEmpty()) "/${repoPath.trim('/')}" else ""
|
|
|
|
|
val strippedPath = if (stripPrefix.isNotEmpty() && path.startsWith(stripPrefix)) {
|
|
|
|
|
path.removePrefix(stripPrefix).ifEmpty { "/" }
|
|
|
|
|
} else {
|
|
|
|
|
path
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// POST {path}?create=true -> mkdirs
|
|
|
|
|
if (method == NanoHTTPD.Method.POST && params["create"] == "true") {
|
|
|
|
|
@@ -71,7 +83,7 @@ class ResticRestBridge(
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
val segments = path.split("/").filter { it.isNotEmpty() }
|
|
|
|
|
val segments = strippedPath.split("/").filter { it.isNotEmpty() }
|
|
|
|
|
|
|
|
|
|
if (segments.isEmpty()) {
|
|
|
|
|
return newFixedLengthResponse(Response.Status.NOT_FOUND, "text/plain", "Invalid path")
|
|
|
|
|
@@ -110,13 +122,39 @@ class ResticRestBridge(
|
|
|
|
|
* Returns the temp file (caller must delete).
|
|
|
|
|
*/
|
|
|
|
|
private fun streamBodyToFile(session: IHTTPSession, tmpDir: File): Result<File> {
|
|
|
|
|
val started = System.currentTimeMillis()
|
|
|
|
|
return try {
|
|
|
|
|
val tmpFile = File(tmpDir, "restic_blob_${UUID.randomUUID()}")
|
|
|
|
|
val contentLength = session.headers["content-length"]?.toLongOrNull() ?: -1L
|
|
|
|
|
val input = (session as NanoHTTPD.HTTPSession).inputStream
|
|
|
|
|
tmpFile.outputStream().use { output -> input.copyTo(output) }
|
|
|
|
|
Log.d(TAG, "streamBodyToFile: reading body (content-length=$contentLength)...")
|
|
|
|
|
tmpFile.outputStream().use { output ->
|
|
|
|
|
if (contentLength > 0) {
|
|
|
|
|
// Read exactly Content-Length bytes to avoid blocking on keep-alive
|
|
|
|
|
val buf = ByteArray(8192)
|
|
|
|
|
var remaining = contentLength
|
|
|
|
|
while (remaining > 0) {
|
|
|
|
|
val toRead = minOf(buf.size.toLong(), remaining).toInt()
|
|
|
|
|
val n = input.read(buf, 0, toRead)
|
|
|
|
|
if (n == -1) break
|
|
|
|
|
output.write(buf, 0, n)
|
|
|
|
|
remaining -= n
|
|
|
|
|
}
|
|
|
|
|
if (remaining > 0) {
|
|
|
|
|
Log.w(TAG, "streamBodyToFile: body truncated, expected $contentLength bytes but got EOF after ${contentLength - remaining}")
|
|
|
|
|
}
|
|
|
|
|
Unit
|
|
|
|
|
} else {
|
|
|
|
|
input.copyTo(output)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
val elapsed = System.currentTimeMillis() - started
|
|
|
|
|
val bytes = tmpFile.length()
|
|
|
|
|
Log.i(TAG, "streamBodyToFile: read $bytes bytes in ${elapsed}ms")
|
|
|
|
|
Result.success(tmpFile)
|
|
|
|
|
} catch (e: Exception) {
|
|
|
|
|
Log.w(TAG, "stream body to file failed", e)
|
|
|
|
|
val elapsed = System.currentTimeMillis() - started
|
|
|
|
|
Log.w(TAG, "streamBodyToFile failed after ${elapsed}ms", e)
|
|
|
|
|
Result.failure(e)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
@@ -148,7 +186,8 @@ class ResticRestBridge(
|
|
|
|
|
try {
|
|
|
|
|
when (transport.download(remotePath, tempFile.absolutePath)) {
|
|
|
|
|
is AppResult.Success -> {
|
|
|
|
|
newChunkedResponse(Response.Status.OK, "application/octet-stream", tempFile.inputStream())
|
|
|
|
|
val data = tempFile.readBytes()
|
|
|
|
|
newFixedLengthResponse(Response.Status.OK, "application/octet-stream", data.inputStream(), data.size.toLong())
|
|
|
|
|
}
|
|
|
|
|
is AppResult.Failure -> newFixedLengthResponse(
|
|
|
|
|
Response.Status.NOT_FOUND, "text/plain", ""
|
|
|
|
|
@@ -279,14 +318,14 @@ class ResticRestBridge(
|
|
|
|
|
response.addHeader("Content-Length", chunkSize.toString())
|
|
|
|
|
return@runBlocking response
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Full file — stream directly without loading into memory
|
|
|
|
|
// Full file — read into memory (blobs are typically small)
|
|
|
|
|
val data = tempFile.readBytes()
|
|
|
|
|
val response = newChunkedResponse(
|
|
|
|
|
Response.Status.OK,
|
|
|
|
|
"application/octet-stream",
|
|
|
|
|
tempFile.inputStream()
|
|
|
|
|
data.inputStream()
|
|
|
|
|
)
|
|
|
|
|
response.addHeader("Content-Length", tempFile.length().toString())
|
|
|
|
|
response.addHeader("Content-Length", data.size.toString())
|
|
|
|
|
response
|
|
|
|
|
}
|
|
|
|
|
is AppResult.Failure -> newFixedLengthResponse(
|
|
|
|
|
|