reading images api

This commit is contained in:
Maxim Devaev 2022-07-23 18:34:58 +03:00
parent de14053725
commit 0e3ebac362
10 changed files with 145 additions and 2 deletions

View File

@ -59,6 +59,15 @@ location /api/ws {
auth_request off; auth_request off;
} }
location /api/msd/read {
rewrite ^/api/msd/read$ /msd/read break;
rewrite ^/api/msd/read\?(.*)$ /msd/read?$1 break;
proxy_pass http://kvmd;
include /etc/kvmd/nginx/loc-proxy.conf;
proxy_read_timeout 7d;
auth_request off;
}
location /api/msd/write_remote { location /api/msd/write_remote {
rewrite ^/api/msd/write_remote$ /msd/write_remote break; rewrite ^/api/msd/write_remote$ /msd/write_remote break;
rewrite ^/api/msd/write_remote\?(.*)$ /msd/write_remote?$1 break; rewrite ^/api/msd/write_remote\?(.*)$ /msd/write_remote?$1 break;

View File

@ -84,6 +84,18 @@ class MsdApi:
# ===== # =====
@exposed_http("GET", "/msd/read")
async def __read_handler(self, request: Request) -> StreamResponse:
name = valid_msd_image_name(request.query.get("image"))
async with self.__msd.read_image(name) as size:
response = await start_streaming(request, "application/octet-stream", size, name)
while True:
chunk = await self.__msd.read_image_chunk()
if not chunk:
return response
await response.write(chunk)
return response
@exposed_http("POST", "/msd/write") @exposed_http("POST", "/msd/write")
async def __write_handler(self, request: Request) -> Response: async def __write_handler(self, request: Request) -> Response:
name = valid_msd_image_name(request.query.get("image")) name = valid_msd_image_name(request.query.get("image"))

View File

@ -26,6 +26,7 @@ import asyncio
import contextlib import contextlib
import dataclasses import dataclasses
import inspect import inspect
import urllib.parse
import json import json
from typing import Tuple from typing import Tuple
@ -187,8 +188,20 @@ def make_json_exception(err: Exception, status: Optional[int]=None) -> Response:
}, status=status) }, status=status)
async def start_streaming(request: Request, content_type: str) -> StreamResponse: async def start_streaming(
response = StreamResponse(status=200, reason="OK", headers={"Content-Type": content_type}) request: Request,
content_type: str,
content_length: int=-1,
file_name: str="",
) -> StreamResponse:
response = StreamResponse(status=200, reason="OK")
response.content_type = content_type
if content_length >= 0:
response.content_length = content_length
if file_name:
file_name = urllib.parse.quote(file_name, safe="")
response.headers["Content-Disposition"] = f"attachment; filename*=UTF-8''{file_name}"
await response.prepare(request) await response.prepare(request)
return response return response

View File

@ -131,6 +131,15 @@ class BaseMsd(BasePlugin):
async def set_connected(self, connected: bool) -> None: async def set_connected(self, connected: bool) -> None:
raise NotImplementedError() raise NotImplementedError()
@contextlib.asynccontextmanager
async def read_image(self, name: str) -> AsyncGenerator[int, None]: # pylint: disable=unused-argument
if self is not None: # XXX: Vulture and pylint hack
raise NotImplementedError()
yield 1
async def read_image_chunk(self) -> bytes:
raise NotImplementedError()
@contextlib.asynccontextmanager @contextlib.asynccontextmanager
async def write_image(self, name: str, size: int) -> AsyncGenerator[int, None]: # pylint: disable=unused-argument async def write_image(self, name: str, size: int) -> AsyncGenerator[int, None]: # pylint: disable=unused-argument
if self is not None: # XXX: Vulture and pylint hack if self is not None: # XXX: Vulture and pylint hack
@ -144,6 +153,40 @@ class BaseMsd(BasePlugin):
raise NotImplementedError() raise NotImplementedError()
class MsdImageReader:
def __init__(self, path: str, chunk_size: int) -> None:
self.__name = os.path.basename(path)
self.__path = path
self.__chunk_size = chunk_size
self.__file: Optional[aiofiles.base.AiofilesContextManager] = None
self.__file_size: int = 0
async def open(self) -> "MsdImageReader":
assert self.__file is None
get_logger(1).info("Reading %r image from MSD ...", self.__name)
self.__file_size = os.stat(self.__path).st_size
self.__file = await aiofiles.open(self.__path, mode="rb") # type: ignore
return self
def get_size(self) -> int:
assert self.__file is not None
return self.__file_size
async def read(self) -> bytes:
assert self.__file is not None
return (await self.__file.read(self.__chunk_size)) # type: ignore
async def close(self) -> None:
assert self.__file is not None
logger = get_logger()
logger.info("Closed image reader ...")
try:
await self.__file.close() # type: ignore
except Exception:
logger.exception("Can't close image reader")
class MsdImageWriter: class MsdImageWriter:
def __init__(self, path: str, size: int, sync: int) -> None: def __init__(self, path: str, size: int, sync: int) -> None:
self.__name = os.path.basename(path) self.__name = os.path.basename(path)

View File

@ -76,6 +76,15 @@ class Plugin(BaseMsd):
async def set_connected(self, connected: bool) -> None: async def set_connected(self, connected: bool) -> None:
raise MsdDisabledError() raise MsdDisabledError()
@contextlib.asynccontextmanager
async def read_image(self, name: str) -> AsyncGenerator[int, None]:
if self is not None: # XXX: Vulture and pylint hack
raise MsdDisabledError()
yield 1
async def read_image_chunk(self) -> bytes:
raise MsdDisabledError()
@contextlib.asynccontextmanager @contextlib.asynccontextmanager
async def write_image(self, name: str, size: int) -> AsyncGenerator[int, None]: async def write_image(self, name: str, size: int) -> AsyncGenerator[int, None]:
if self is not None: # XXX: Vulture and pylint hack if self is not None: # XXX: Vulture and pylint hack

View File

@ -57,6 +57,7 @@ from .. import MsdImageNotSelected
from .. import MsdUnknownImageError from .. import MsdUnknownImageError
from .. import MsdImageExistsError from .. import MsdImageExistsError
from .. import BaseMsd from .. import BaseMsd
from .. import MsdImageReader
from .. import MsdImageWriter from .. import MsdImageWriter
from . import fs from . import fs
@ -136,6 +137,7 @@ class _State:
class Plugin(BaseMsd): # pylint: disable=too-many-instance-attributes class Plugin(BaseMsd): # pylint: disable=too-many-instance-attributes
def __init__( # pylint: disable=super-init-not-called def __init__( # pylint: disable=super-init-not-called
self, self,
read_chunk_size: int,
write_chunk_size: int, write_chunk_size: int,
sync_chunk_size: int, sync_chunk_size: int,
@ -148,6 +150,7 @@ class Plugin(BaseMsd): # pylint: disable=too-many-instance-attributes
gadget: str, # XXX: Not from options, see /kvmd/apps/kvmd/__init__.py for details gadget: str, # XXX: Not from options, see /kvmd/apps/kvmd/__init__.py for details
) -> None: ) -> None:
self.__read_chunk_size = read_chunk_size
self.__write_chunk_size = write_chunk_size self.__write_chunk_size = write_chunk_size
self.__sync_chunk_size = sync_chunk_size self.__sync_chunk_size = sync_chunk_size
@ -162,6 +165,7 @@ class Plugin(BaseMsd): # pylint: disable=too-many-instance-attributes
self.__drive = Drive(gadget, instance=0, lun=0) self.__drive = Drive(gadget, instance=0, lun=0)
self.__reader: Optional[MsdImageReader] = None
self.__writer: Optional[MsdImageWriter] = None self.__writer: Optional[MsdImageWriter] = None
self.__writer_tick = 0.0 self.__writer_tick = 0.0
@ -175,6 +179,7 @@ class Plugin(BaseMsd): # pylint: disable=too-many-instance-attributes
@classmethod @classmethod
def get_plugin_options(cls) -> Dict: def get_plugin_options(cls) -> Dict:
return { return {
"read_chunk_size": Option(65536, type=functools.partial(valid_number, min=1024)),
"write_chunk_size": Option(65536, type=functools.partial(valid_number, min=1024)), "write_chunk_size": Option(65536, type=functools.partial(valid_number, min=1024)),
"sync_chunk_size": Option(4194304, type=functools.partial(valid_number, min=1024)), "sync_chunk_size": Option(4194304, type=functools.partial(valid_number, min=1024)),
@ -253,6 +258,7 @@ class Plugin(BaseMsd): # pylint: disable=too-many-instance-attributes
@aiotools.atomic @aiotools.atomic
async def cleanup(self) -> None: async def cleanup(self) -> None:
await self.__close_reader()
await self.__close_writer() await self.__close_writer()
# ===== # =====
@ -317,6 +323,29 @@ class Plugin(BaseMsd): # pylint: disable=too-many-instance-attributes
self.__state.vd.connected = connected self.__state.vd.connected = connected
@contextlib.asynccontextmanager
async def read_image(self, name: str) -> AsyncGenerator[int, None]:
async with self.__state.busy():
assert self.__state.storage
assert self.__state.vd
if self.__state.vd.connected or self.__drive.get_image_path():
raise MsdConnectedError()
path = os.path.join(self.__images_path, name)
if name not in self.__state.storage.images or not os.path.exists(path):
raise MsdUnknownImageError()
try:
self.__reader = await MsdImageReader(path, self.__read_chunk_size).open()
yield self.__reader.get_size()
finally:
await self.__close_reader()
async def read_image_chunk(self) -> bytes:
assert self.__reader
return (await self.__reader.read())
@contextlib.asynccontextmanager @contextlib.asynccontextmanager
async def write_image(self, name: str, size: int) -> AsyncGenerator[int, None]: async def write_image(self, name: str, size: int) -> AsyncGenerator[int, None]:
try: try:
@ -387,6 +416,11 @@ class Plugin(BaseMsd): # pylint: disable=too-many-instance-attributes
# ===== # =====
async def __close_reader(self) -> None:
if self.__reader:
await self.__reader.close()
self.__reader = None
async def __close_writer(self) -> None: async def __close_writer(self) -> None:
if self.__writer: if self.__writer:
await self.__writer.close() await self.__writer.close()

View File

@ -217,6 +217,17 @@ class Plugin(BaseMsd): # pylint: disable=too-many-instance-attributes
get_logger(0).info("MSD switched to KVM: %s", self.__device_info) get_logger(0).info("MSD switched to KVM: %s", self.__device_info)
self.__connected = connected self.__connected = connected
@contextlib.asynccontextmanager
async def read_image(self, name: str) -> AsyncGenerator[int, None]:
async with self.__working():
if self is not None: # XXX: Vulture and pylint hack
raise MsdMultiNotSupported()
yield 1
async def read_image_chunk(self) -> bytes:
async with self.__working():
raise MsdMultiNotSupported()
@contextlib.asynccontextmanager @contextlib.asynccontextmanager
async def write_image(self, name: str, size: int) -> AsyncGenerator[int, None]: async def write_image(self, name: str, size: int) -> AsyncGenerator[int, None]:
async with self.__working(): async with self.__working():

View File

@ -419,6 +419,9 @@
<td width="100%"> <td width="100%">
<select disabled id="msd-image-selector"></select> <select disabled id="msd-image-selector"></select>
</td> </td>
<td>
<button disabled id="msd-download-button" title="Download image">&nbsp;&nbsp;&#128426;&nbsp;&nbsp;</button>
</td>
<td> <td>
<button disabled id="msd-remove-button" title="Remove image"><b>&nbsp;&nbsp;&times;&nbsp;&nbsp;</b></button> <button disabled id="msd-remove-button" title="Remove image"><b>&nbsp;&nbsp;&times;&nbsp;&nbsp;</b></button>
</td> </td>

View File

@ -37,6 +37,7 @@ li(id="msd-dropdown" class="right feature-disabled")
tr tr
td Image: td Image:
td(width="100%") #[select(disabled id="msd-image-selector")] td(width="100%") #[select(disabled id="msd-image-selector")]
td #[button(disabled id="msd-download-button" title="Download image") &nbsp;&nbsp;&#128426;&nbsp;&nbsp;]
td #[button(disabled id="msd-remove-button" title="Remove image") #[b &nbsp;&nbsp;&times;&nbsp;&nbsp;]] td #[button(disabled id="msd-remove-button" title="Remove image") #[b &nbsp;&nbsp;&times;&nbsp;&nbsp;]]
table(class="kv msd-cdrom-emulation feature-disabled") table(class="kv msd-cdrom-emulation feature-disabled")
tr tr

View File

@ -39,6 +39,7 @@ export function Msd() {
$("msd-led").title = "Unknown state"; $("msd-led").title = "Unknown state";
$("msd-image-selector").onchange = __selectImage; $("msd-image-selector").onchange = __selectImage;
tools.el.setOnClick($("msd-download-button"), __clickDownloadButton);
tools.el.setOnClick($("msd-remove-button"), __clickRemoveButton); tools.el.setOnClick($("msd-remove-button"), __clickRemoveButton);
tools.radio.setOnClick("msd-mode-radio", __clickModeRadio); tools.radio.setOnClick("msd-mode-radio", __clickModeRadio);
@ -67,10 +68,16 @@ export function Msd() {
var __selectImage = function() { var __selectImage = function() {
tools.el.setEnabled($("msd-image-selector"), false); tools.el.setEnabled($("msd-image-selector"), false);
tools.el.setEnabled($("msd-download-button"), false);
tools.el.setEnabled($("msd-remove-button"), false); tools.el.setEnabled($("msd-remove-button"), false);
__sendParam("image", $("msd-image-selector").value); __sendParam("image", $("msd-image-selector").value);
}; };
var __clickDownloadButton = function() {
let name = $("msd-image-selector").value;
window.open(`/api/msd/read?image=${name}`);
};
var __clickRemoveButton = function() { var __clickRemoveButton = function() {
let name = $("msd-image-selector").value; let name = $("msd-image-selector").value;
wm.confirm(`Are you sure you want to remove the image<br><b>${name}</b> from PiKVM?`).then(function(ok) { wm.confirm(`Are you sure you want to remove the image<br><b>${name}</b> from PiKVM?`).then(function(ok) {
@ -244,6 +251,7 @@ export function Msd() {
tools.el.setEnabled($("msd-image-selector"), (online && s.features.multi && !s.drive.connected && !s.busy)); tools.el.setEnabled($("msd-image-selector"), (online && s.features.multi && !s.drive.connected && !s.busy));
__applyStateImageSelector(); __applyStateImageSelector();
tools.el.setEnabled($("msd-download-button"), (online && s.features.multi && s.drive.image && !s.drive.connected && !s.busy));
tools.el.setEnabled($("msd-remove-button"), (online && s.features.multi && s.drive.image && !s.drive.connected && !s.busy)); tools.el.setEnabled($("msd-remove-button"), (online && s.features.multi && s.drive.image && !s.drive.connected && !s.busy));
tools.radio.setEnabled("msd-mode-radio", (online && s.features.cdrom && !s.drive.connected && !s.busy)); tools.radio.setEnabled("msd-mode-radio", (online && s.features.cdrom && !s.drive.connected && !s.busy));