indipyweb 0.0.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. indipyweb-0.0.2/LICENSE +21 -0
  2. indipyweb-0.0.2/PKG-INFO +49 -0
  3. indipyweb-0.0.2/README.md +31 -0
  4. indipyweb-0.0.2/indipyweb/__init__.py +0 -0
  5. indipyweb-0.0.2/indipyweb/__main__.py +69 -0
  6. indipyweb-0.0.2/indipyweb/iclient.py +73 -0
  7. indipyweb-0.0.2/indipyweb/web/__init__.py +0 -0
  8. indipyweb-0.0.2/indipyweb/web/app.py +391 -0
  9. indipyweb-0.0.2/indipyweb/web/device.py +166 -0
  10. indipyweb-0.0.2/indipyweb/web/edit.py +322 -0
  11. indipyweb-0.0.2/indipyweb/web/setup.py +164 -0
  12. indipyweb-0.0.2/indipyweb/web/static/htmx.min.js +1 -0
  13. indipyweb-0.0.2/indipyweb/web/static/indipyweb.css +68 -0
  14. indipyweb-0.0.2/indipyweb/web/static/indipyweb.js +37 -0
  15. indipyweb-0.0.2/indipyweb/web/static/sse.js +290 -0
  16. indipyweb-0.0.2/indipyweb/web/static/w3-colors-flat.css +40 -0
  17. indipyweb-0.0.2/indipyweb/web/static/w3.css +251 -0
  18. indipyweb-0.0.2/indipyweb/web/templates/blobs.html +125 -0
  19. indipyweb-0.0.2/indipyweb/web/templates/devicepage.html +49 -0
  20. indipyweb-0.0.2/indipyweb/web/templates/edit/admin/adminedit.html +73 -0
  21. indipyweb-0.0.2/indipyweb/web/templates/edit/admin/editoptions.html +62 -0
  22. indipyweb-0.0.2/indipyweb/web/templates/edit/admin/edituser.html +61 -0
  23. indipyweb-0.0.2/indipyweb/web/templates/edit/admin/listusers.html +62 -0
  24. indipyweb-0.0.2/indipyweb/web/templates/edit/admin/optionsdelete.html +10 -0
  25. indipyweb-0.0.2/indipyweb/web/templates/edit/loggedout.html +29 -0
  26. indipyweb-0.0.2/indipyweb/web/templates/edit/login.html +51 -0
  27. indipyweb-0.0.2/indipyweb/web/templates/edit/max_header.html +5 -0
  28. indipyweb-0.0.2/indipyweb/web/templates/edit/min_header.html +4 -0
  29. indipyweb-0.0.2/indipyweb/web/templates/edit/user/userdeleted.html +27 -0
  30. indipyweb-0.0.2/indipyweb/web/templates/edit/user/useredit.html +83 -0
  31. indipyweb-0.0.2/indipyweb/web/templates/group.html +29 -0
  32. indipyweb-0.0.2/indipyweb/web/templates/instruments.html +17 -0
  33. indipyweb-0.0.2/indipyweb/web/templates/landing.html +78 -0
  34. indipyweb-0.0.2/indipyweb/web/templates/messages.html +13 -0
  35. indipyweb-0.0.2/indipyweb/web/templates/notfound.html +32 -0
  36. indipyweb-0.0.2/indipyweb/web/templates/setup/blobfolder.html +9 -0
  37. indipyweb-0.0.2/indipyweb/web/templates/setup/indihost.html +9 -0
  38. indipyweb-0.0.2/indipyweb/web/templates/setup/indiport.html +9 -0
  39. indipyweb-0.0.2/indipyweb/web/templates/setup/setuppage.html +165 -0
  40. indipyweb-0.0.2/indipyweb/web/templates/setup/webhost.html +9 -0
  41. indipyweb-0.0.2/indipyweb/web/templates/setup/webport.html +9 -0
  42. indipyweb-0.0.2/indipyweb/web/templates/vector/blobmember.html +53 -0
  43. indipyweb-0.0.2/indipyweb/web/templates/vector/getvector.html +71 -0
  44. indipyweb-0.0.2/indipyweb/web/templates/vector/lightmember.html +23 -0
  45. indipyweb-0.0.2/indipyweb/web/templates/vector/numbermember.html +35 -0
  46. indipyweb-0.0.2/indipyweb/web/templates/vector/result.html +17 -0
  47. indipyweb-0.0.2/indipyweb/web/templates/vector/switchmember.html +91 -0
  48. indipyweb-0.0.2/indipyweb/web/templates/vector/textmember.html +36 -0
  49. indipyweb-0.0.2/indipyweb/web/userdata.py +573 -0
  50. indipyweb-0.0.2/indipyweb/web/vector.py +269 -0
  51. indipyweb-0.0.2/pyproject.toml +21 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 bernie-skipole
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,49 @@
1
+ Metadata-Version: 2.4
2
+ Name: indipyweb
3
+ Version: 0.0.2
4
+ Summary: Web server to communicate to an INDI service.
5
+ Keywords: indi,client,astronomy,instrument
6
+ Author-email: Bernard Czenkusz <bernie@skipole.co.uk>
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Topic :: Scientific/Engineering :: Astronomy
11
+ Classifier: Topic :: Scientific/Engineering :: Interface Engine/Protocol Translator
12
+ License-File: LICENSE
13
+ Requires-Dist: indipyclient>=0.8.6
14
+ Requires-Dist: litestar[standard]>=2.18.0
15
+ Requires-Dist: litestar[mako]>=2.18.0
16
+ Project-URL: Source, https://github.com/bernie-skipole/indipyweb
17
+
18
+ # indipyweb
19
+ Web server, providing browser client connections to an INDI service.
20
+
21
+ This does not include the INDI server, this is an INDI client.
22
+
23
+ Requires Python >=3.10 and a virtual environment with:
24
+
25
+ pip install indipyweb
26
+
27
+ Then to run the server:
28
+
29
+ python -m indipyweb
30
+
31
+ This will create a database file holding user information in the working directory, and will run a web server on localhost:8000. Connect with a browser, and initially use the default created user, with username admin and password password! - note the exclamation mark.
32
+
33
+ This server will attempt to connect to an INDI service on localhost:7624, and the user browser should be able to view and set devices, vectors and member values.
34
+
35
+ The package help is:
36
+
37
+ usage: indipyweb [options]
38
+
39
+ Web server to communicate to an INDI service.
40
+
41
+ options:
42
+ -h, --help show this help message and exit
43
+ --port PORT Listening port of the web server.
44
+ --host HOST Hostname/IP of the web server.
45
+ --db DB Folder where the database will be set.
46
+ --version show program's version number and exit
47
+
48
+ Having logged in as admin, choose edit and change your password, you can also choose the system setup to set web and INDI hosts, ports and a folder where any BLOBs sent by the INDI service will be saved. These values will be saved in the database file and read on startup.
49
+
@@ -0,0 +1,31 @@
1
+ # indipyweb
2
+ Web server, providing browser client connections to an INDI service.
3
+
4
+ This does not include the INDI server, this is an INDI client.
5
+
6
+ Requires Python >=3.10 and a virtual environment with:
7
+
8
+ pip install indipyweb
9
+
10
+ Then to run the server:
11
+
12
+ python -m indipyweb
13
+
14
+ This will create a database file holding user information in the working directory, and will run a web server on localhost:8000. Connect with a browser, and initially use the default created user, with username admin and password password! - note the exclamation mark.
15
+
16
+ This server will attempt to connect to an INDI service on localhost:7624, and the user browser should be able to view and set devices, vectors and member values.
17
+
18
+ The package help is:
19
+
20
+ usage: indipyweb [options]
21
+
22
+ Web server to communicate to an INDI service.
23
+
24
+ options:
25
+ -h, --help show this help message and exit
26
+ --port PORT Listening port of the web server.
27
+ --host HOST Hostname/IP of the web server.
28
+ --db DB Folder where the database will be set.
29
+ --version show program's version number and exit
30
+
31
+ Having logged in as admin, choose edit and change your password, you can also choose the system setup to set web and INDI hosts, ports and a folder where any BLOBs sent by the INDI service will be saved. These values will be saved in the database file and read on startup.
File without changes
@@ -0,0 +1,69 @@
1
+
2
+
3
+ import sys, argparse, pathlib, asyncio
4
+
5
+ import uvicorn
6
+
7
+ from .iclient import ipywebclient, version
8
+
9
+ from .web.app import ipywebapp
10
+
11
+ from .web.userdata import setupdbase, setconfig, getconfig, get_indiclient
12
+
13
+
14
+ if sys.version_info < (3, 10):
15
+ raise ImportError('indipyweb requires Python >= 3.10')
16
+
17
+
18
+ def readconfig():
19
+
20
+ parser = argparse.ArgumentParser(usage="indipyweb [options]",
21
+ description="Web server to communicate to an INDI service.")
22
+ parser.add_argument("--port", type=int, help="Listening port of the web server.")
23
+ parser.add_argument("--host", help="Hostname/IP of the web server.")
24
+ parser.add_argument("--db", help="Folder where the database will be set.")
25
+ parser.add_argument("--version", action="version", version=version)
26
+ args = parser.parse_args()
27
+
28
+
29
+ if args.db:
30
+ try:
31
+ dbfolder = pathlib.Path(args.db).expanduser().resolve()
32
+ except Exception:
33
+ print("Error: If given, the database folder should be an existing directory")
34
+ sys.exit(1)
35
+ else:
36
+ if not dbfolder.is_dir():
37
+ print("Error: If given, the database folder should be an existing directory")
38
+ sys.exit(1)
39
+ else:
40
+ dbfolder = pathlib.Path.cwd()
41
+
42
+ setupdbase(args.host, args.port, dbfolder)
43
+
44
+ # create the client, store it for later access with get_indiclient()
45
+ ipywebclient()
46
+
47
+ host = getconfig('host')
48
+ port = getconfig('port')
49
+ app = ipywebapp()
50
+
51
+ return app, host, port
52
+
53
+
54
+ async def indipywebrun():
55
+ "Read the program arguments, setup the database and run the webserver"
56
+ app, host, port = readconfig()
57
+ config = uvicorn.Config(app=app, host=host, port=port, log_level="info")
58
+ server = uvicorn.Server(config)
59
+ await server.serve()
60
+
61
+
62
+ def main():
63
+ "Run the program"
64
+ asyncio.run(indipywebrun())
65
+
66
+
67
+ if __name__ == "__main__":
68
+ # And run main
69
+ main()
@@ -0,0 +1,73 @@
1
+
2
+ """
3
+ Provides ipywebclient, version
4
+ """
5
+
6
+ import asyncio
7
+
8
+ import indipyclient as ipc
9
+
10
+ from .web.userdata import DEFINE_EVENT, MESSAGE_EVENT, get_indiclient, getconfig, setconfig, get_device_event, get_vector_event
11
+
12
+ version = "0.0.2"
13
+
14
+
15
+
16
+ def ipywebclient():
17
+ "Create and store an instance of IPyWebClient"
18
+
19
+ indihost = getconfig("indihost")
20
+ indiport = getconfig("indiport")
21
+ indiclient = IPyWebClient(indihost=indihost, indiport=indiport)
22
+ indiclient.BLOBfolder = getconfig("blobfolder")
23
+ setconfig("indiclient", indiclient)
24
+
25
+
26
+ def do_startup():
27
+ """Start the client, called from Litestar app, the task is set into
28
+ the global config to ensure a strong reference to it remains"""
29
+ iclient = get_indiclient()
30
+ runclient = asyncio.create_task(iclient.asyncrun())
31
+ setconfig("runclient", runclient)
32
+
33
+ async def do_shutdown():
34
+ "Stop the client, called from Litestar app"
35
+ iclient = get_indiclient()
36
+ iclient.shutdown()
37
+ await iclient.stopped.wait()
38
+
39
+
40
+ class IPyWebClient(ipc.IPyClient):
41
+
42
+ async def rxevent(self, event):
43
+
44
+ if event.eventtype in ("Define", "Delete", "ConnectionMade", "ConnectionLost"):
45
+ DEFINE_EVENT.set()
46
+ DEFINE_EVENT.clear()
47
+ elif event.eventtype == "Message":
48
+ MESSAGE_EVENT.set()
49
+ MESSAGE_EVENT.clear()
50
+ if event.devicename:
51
+ dme = get_device_event(event.devicename)
52
+ dme.set()
53
+ dme.clear()
54
+
55
+ if event.eventtype == "Delete":
56
+ if event.devicename:
57
+ # set a message event, so if the device is deleted
58
+ # when client sends an updatemessages it forces a redirect
59
+ dme = get_device_event(event.devicename)
60
+ dme.set()
61
+ dme.clear()
62
+
63
+ if event.vector:
64
+ if event.eventtype == "TimeOut":
65
+ event.vector.user_string = "Response has timed out"
66
+ event.vector.state = 'Alert'
67
+ event.vector.timestamp = event.timestamp
68
+ else:
69
+ event.vector.user_string = ""
70
+ # set vector event when a vector is updated
71
+ ve = get_vector_event(event.devicename, event.vectorname)
72
+ ve.set()
73
+ ve.clear()
File without changes
@@ -0,0 +1,391 @@
1
+ """
2
+ Creates the main litestar app with the top level routes
3
+ and authentication functions, including setting and testing cookies
4
+
5
+ Note, edit routes are set under edit.edit_router
6
+
7
+ """
8
+
9
+ import asyncio
10
+
11
+ from os import listdir, remove
12
+ from os.path import isfile, join
13
+
14
+ from pathlib import Path
15
+
16
+ from collections.abc import AsyncGenerator
17
+
18
+ from asyncio.exceptions import TimeoutError
19
+
20
+ from litestar import Litestar, get, post, Request
21
+ from litestar.plugins.htmx import HTMXPlugin, HTMXTemplate, ClientRedirect, ClientRefresh
22
+ from litestar.contrib.mako import MakoTemplateEngine
23
+ from litestar.template.config import TemplateConfig
24
+ from litestar.response import Template, Redirect, File
25
+ from litestar.static_files import create_static_files_router
26
+ from litestar.datastructures import Cookie, State
27
+
28
+ from litestar.middleware import AbstractAuthenticationMiddleware, AuthenticationResult, DefineMiddleware
29
+ from litestar.connection import ASGIConnection
30
+ from litestar.exceptions import NotAuthorizedException, NotFoundException
31
+
32
+ from litestar.response import ServerSentEvent, ServerSentEventMessage
33
+
34
+ from . import userdata, edit, device, vector, setup
35
+
36
+ from ..iclient import do_startup, do_shutdown
37
+
38
+
39
+ # location of static files, for CSS and javascript
40
+ STATICFILES = Path(__file__).parent.resolve() / "static"
41
+
42
+ # location of template files
43
+ TEMPLATEFILES = Path(__file__).parent.resolve() / "templates"
44
+
45
+
46
+ class ShowInstruments:
47
+ """Iterate with instruments table whenever an instrument change happens."""
48
+
49
+ def __init__(self):
50
+ self.instruments = set()
51
+ self.connected = False
52
+ self.iclient = userdata.get_indiclient()
53
+
54
+ def __aiter__(self):
55
+ return self
56
+
57
+ async def __anext__(self):
58
+ "Whenever there is a change in devices, return a ServerSentEventMessage message"
59
+ while True:
60
+ if self.iclient.stop:
61
+ raise StopAsyncIteration
62
+ if self.iclient.connected != self.connected:
63
+ self.connected = self.iclient.connected
64
+ return ServerSentEventMessage(event="newinstruments")
65
+ # get a set of instrument names for enabled devices
66
+ newinstruments = set(name for name,value in self.iclient.items() if value.enable)
67
+ if newinstruments == self.instruments:
68
+ # No change, wait, at most 5 seconds, for a DEFINE_EVENT
69
+ try:
70
+ await asyncio.wait_for(userdata.DEFINE_EVENT.wait(), timeout=5.0)
71
+ except TimeoutError:
72
+ pass
73
+ # either a DEFINE_EVENT has occurred, or 5 seconds since the last has passed
74
+ # so continue the while loop to check for any new devices
75
+ continue
76
+ # There has been a change, send a newinstruments to the users browser
77
+ self.instruments = newinstruments
78
+ return ServerSentEventMessage(event="newinstruments")
79
+
80
+
81
+ # SSE Handler
82
+ @get(path="/instruments", exclude_from_auth=True, sync_to_thread=False)
83
+ def instruments() -> ServerSentEvent:
84
+ return ServerSentEvent(ShowInstruments())
85
+
86
+
87
+ class ShowMessages:
88
+ """Iterate with messages whenever a message change happens."""
89
+
90
+ def __init__(self):
91
+ self.lasttimestamp = None
92
+ self.connected = False
93
+ self.iclient = userdata.get_indiclient()
94
+
95
+ def __aiter__(self):
96
+ return self
97
+
98
+ async def __anext__(self):
99
+ "Whenever there is a new message, return a ServerSentEventMessage message"
100
+ while True:
101
+ if self.iclient.stop:
102
+ asyncio.sleep(2)
103
+ return ServerSentEventMessage(event="newmessage")
104
+
105
+ if self.iclient.connected != self.connected:
106
+ self.connected = self.iclient.connected
107
+ return ServerSentEventMessage(event="newmessage")
108
+ # get new message
109
+ if self.iclient.messages:
110
+ lasttimestamp = self.iclient.messages[0][0]
111
+ if (self.lasttimestamp is None) or (lasttimestamp != self.lasttimestamp):
112
+ # a new message is received
113
+ self.lasttimestamp = lasttimestamp
114
+ return ServerSentEventMessage(event="newmessages")
115
+ elif self.lasttimestamp is not None:
116
+ # There are no self.iclient.messages, but self.lasttimestamp
117
+ # has a value, so there has been a change
118
+ self.lasttimestamp = None
119
+ return ServerSentEventMessage(event="newmessages")
120
+ # No change, wait, at most 5 seconds, for a MESSAGE_EVENT
121
+ try:
122
+ await asyncio.wait_for(userdata.MESSAGE_EVENT.wait(), timeout=5.0)
123
+ except TimeoutError:
124
+ pass
125
+ # either a MESSAGE_EVENT has occurred, or 5 seconds since the last has passed
126
+ # so continue the while loop to check for any new messages
127
+
128
+
129
+ # SSE Handler
130
+ @get(path="/messages", exclude_from_auth=True, sync_to_thread=False)
131
+ def messages() -> ServerSentEvent:
132
+ return ServerSentEvent(ShowMessages())
133
+
134
+
135
+ class LoggedInAuth(AbstractAuthenticationMiddleware):
136
+ """Checks if a logged-in cookie is present, and verifies it
137
+ If ok, returns an AuthenticationResult with the user, and the users
138
+ authorisation level. If not ok raises a NotAuthorizedException"""
139
+ async def authenticate_request(self, connection: ASGIConnection ) -> AuthenticationResult:
140
+ # retrieve the cookie
141
+ auth_cookie = connection.cookies
142
+ if not auth_cookie:
143
+ raise NotAuthorizedException()
144
+ token = auth_cookie.get('token')
145
+ if not token:
146
+ raise NotAuthorizedException()
147
+ # the userdata.verify function looks up a dictionary of logged in users
148
+ userinfo = userdata.verify(token)
149
+ # If not verified, userinfo will be None
150
+ # If verified userinfo will be a userdata.UserInfo object
151
+ if userinfo is None:
152
+ raise NotAuthorizedException()
153
+ # Return an AuthenticationResult which will be
154
+ # made available to route handlers as request: Request[str, str, State]
155
+ return AuthenticationResult(user=userinfo.user, auth=userinfo.auth)
156
+
157
+
158
+ def gotologin_error_handler(request: Request, exc: Exception) -> ClientRedirect|Redirect:
159
+ """If a NotAuthorizedException is raised, this handles it, and redirects
160
+ the caller to the login page"""
161
+ if request.htmx:
162
+ return ClientRedirect("/login")
163
+ return Redirect("/login")
164
+
165
+
166
+ def gotonotfound_error_handler(request: Request, exc: Exception) -> ClientRedirect|Redirect:
167
+ """If a NotFoundException is raised, this handles it, and redirects
168
+ the caller to the not found page"""
169
+ if request.htmx:
170
+ return ClientRedirect("/notfound")
171
+ return Redirect("/notfound")
172
+
173
+
174
+ @get("/notfound", exclude_from_auth=True)
175
+ async def notfound(request: Request) -> Template:
176
+ "This is the public root page of your site"
177
+ iclient = userdata.get_indiclient()
178
+ # Check if user is looged in
179
+ loggedin = False
180
+ cookie = request.cookies.get('token', '')
181
+ if cookie:
182
+ userauth = userdata.getuserauth(cookie)
183
+ if userauth is not None:
184
+ loggedin = True
185
+ return Template("notfound.html", context={"hostname":userdata.connectedtext(),
186
+ "loggedin":loggedin})
187
+
188
+
189
+
190
+ # Note, all routes with 'exclude_from_auth=True' do not have cookie checked
191
+ # and are not authenticated
192
+
193
+ @get("/", exclude_from_auth=True)
194
+ async def publicroot(request: Request) -> Template:
195
+ "This is the public root page of your site"
196
+ iclient = userdata.get_indiclient()
197
+ # Check if user is looged in
198
+ loggedin = False
199
+ cookie = request.cookies.get('token', '')
200
+ if cookie:
201
+ userauth = userdata.getuserauth(cookie)
202
+ if userauth is not None:
203
+ loggedin = True
204
+ blobfolder = True if iclient.BLOBfolder else False
205
+
206
+ return Template("landing.html", context={"hostname":userdata.connectedtext(),
207
+ "instruments":None,
208
+ "messages":None,
209
+ "loggedin":loggedin,
210
+ "blobfolder":blobfolder})
211
+
212
+
213
+ @get("/updateinstruments", exclude_from_auth=True)
214
+ async def updateinstruments(request: Request) -> Template:
215
+ "Updates the instruments on the main public page"
216
+ iclient = userdata.get_indiclient()
217
+ instruments = list(name for name,value in iclient.items() if value.enable)
218
+ instruments.sort()
219
+ return HTMXTemplate(template_name="instruments.html", context={"instruments":instruments})
220
+
221
+
222
+ @get("/updatemessages", exclude_from_auth=True)
223
+ async def updatemessages() -> Template:
224
+ "Updates the messages on the main public page"
225
+ iclient = userdata.get_indiclient()
226
+ if iclient.stop:
227
+ return HTMXTemplate(template_name="messages.html", context={"messages":["Error: client application has stopped"]})
228
+ messages = list(iclient.messages)
229
+ messagelist = list(userdata.localtimestring(t) + " " + m for t,m in messages)
230
+ messagelist.reverse()
231
+ return HTMXTemplate(template_name="messages.html", context={"messages":messagelist})
232
+
233
+
234
+ @get("/login", exclude_from_auth=True)
235
+ async def login_page(request: Request[str, str, State]) -> Template:
236
+ "Render the login page"
237
+ cookie = request.cookies.get('token')
238
+ # log the user out
239
+ if cookie:
240
+ userdata.logout(request.cookies['token'])
241
+ return Template("edit/login.html", context={"hostname":userdata.connectedtext()})
242
+
243
+
244
+ @post("/login", exclude_from_auth=True)
245
+ async def login(request: Request) -> Template|ClientRedirect:
246
+ """This is a handler for the login post, in which the caller is setting their
247
+ username and password into a form.
248
+ Checks the user has logged in correctly, and if so creates a logged-in cookie
249
+ for the caller and redirects the caller to / the root application page"""
250
+ form_data = await request.form()
251
+ username = form_data.get("username")
252
+ password = form_data.get("password")
253
+ # check these on the database of users, this checkuserpassword returns a userdata.UserInfo object
254
+ # if the user exists, and the password is correct, otherwise it returns None
255
+ userinfo = userdata.checkuserpassword(username, password)
256
+ if userinfo is None:
257
+ # sleep to force a time delay to annoy anyone trying to guess a password
258
+ await asyncio.sleep(1.0)
259
+ # unable to find a matching username/password
260
+ # returns an 'Invalid' template which the htmx javascript
261
+ # puts in the right place on the login page
262
+ return HTMXTemplate(None,
263
+ template_str="<p id=\"result\" class=\"vanish\" style=\"color:red\">Invalid</p>")
264
+ # The user checks out ok, create a cookie for this user and set redirect to the /,
265
+ loggedincookie = userdata.createcookie(userinfo.user)
266
+ # redirect with the loggedincookie
267
+ response = ClientRedirect("/")
268
+ response.set_cookie(key = 'token', value=loggedincookie)
269
+ return response
270
+
271
+
272
+ @get("/logout")
273
+ async def logout(request: Request[str, str, State]) -> Template:
274
+ "Logs the user out, and render the logout page"
275
+ cookie = request.cookies.get('token')
276
+ # log the user out
277
+ if cookie:
278
+ userdata.logout(request.cookies['token'])
279
+ return Template("edit/loggedout.html", context={"hostname":userdata.connectedtext()})
280
+
281
+
282
+ @get("/blobs")
283
+ async def blobs(request: Request[str, str, State]) -> Template:
284
+ "Shows a page of blob files"
285
+ iclient = userdata.get_indiclient()
286
+ blobfolder = iclient.BLOBfolder
287
+ if blobfolder:
288
+ blobfiles = [f for f in listdir(blobfolder) if isfile(join(blobfolder, f))]
289
+ blobfiles.sort()
290
+ else:
291
+ blobfiles = []
292
+ admin = True if request.auth == "admin" else False
293
+ context = {'blobfiles':blobfiles,
294
+ 'admin':admin}
295
+ return Template("blobs.html", context=context)
296
+
297
+
298
+ @get("/getblob/{blobfile:str}", media_type="application/octet")
299
+ async def getblob(blobfile:str, request: Request[str, str, State]) -> File:
300
+ "Download a BLOB to the browser client"
301
+ iclient = userdata.get_indiclient()
302
+ blobfolder = iclient.BLOBfolder
303
+ if not blobfolder:
304
+ raise NotFoundException()
305
+ blobpath = iclient.BLOBfolder / blobfile
306
+ if not blobpath.is_file():
307
+ raise NotFoundException()
308
+ return File(
309
+ path=blobpath,
310
+ filename=blobfile
311
+ )
312
+
313
+ @get("/delblob/{blobfile:str}")
314
+ async def delblob(blobfile:str, request: Request[str, str, State]) -> ClientRefresh:
315
+ "Deletes a blob"
316
+ auth = request.auth
317
+ if auth != "admin":
318
+ raise NotAuthorizedException()
319
+ iclient = userdata.get_indiclient()
320
+ blobfolder = iclient.BLOBfolder
321
+ if not blobfolder:
322
+ raise NotFoundException()
323
+ blobpath = iclient.BLOBfolder / blobfile
324
+ if not blobpath.is_file():
325
+ raise NotFoundException()
326
+ remove(blobpath)
327
+ return ClientRefresh()
328
+
329
+
330
+
331
+ @get(["/api", "/api/{device:str}", "/api/{device:str}/{vector:str}"], exclude_from_auth=True, sync_to_thread=False)
332
+ def api(device:str="", vector:str="") -> dict:
333
+ iclient = userdata.get_indiclient()
334
+ if not device:
335
+ # return whole client dict
336
+ shot = iclient.snapshot()
337
+ return shot.dictdump()
338
+ deviceobj = iclient.get(device)
339
+ if deviceobj is None:
340
+ return {}
341
+ if vector:
342
+ vectorobj = deviceobj.data.get(vector)
343
+ if vectorobj is None:
344
+ return {}
345
+ shot = vectorobj.snapshot()
346
+ return shot.dictdump()
347
+ shot = deviceobj.snapshot()
348
+ return shot.dictdump()
349
+
350
+
351
+ # This defines LoggedInAuth as middleware and also
352
+ # excludes certain paths from authentication.
353
+ # In this case it excludes all routes mounted at or under `/static*`
354
+ # This allows CSS and javascript libraries to be placed there, which
355
+ # therefore do not need authentication to be accessed
356
+ auth_mw = DefineMiddleware(LoggedInAuth, exclude="static")
357
+
358
+
359
+ def ipywebapp():
360
+ # Initialize the Litestar app with a Mako template engine and register the routes
361
+ iclient = userdata.get_indiclient()
362
+ app = Litestar(
363
+ route_handlers=[publicroot,
364
+ updateinstruments,
365
+ updatemessages,
366
+ notfound,
367
+ login_page,
368
+ login,
369
+ logout,
370
+ instruments,
371
+ messages,
372
+ blobs,
373
+ getblob,
374
+ delblob,
375
+ api,
376
+ edit.edit_router, # This router in edit.py deals with routes below /edit
377
+ device.device_router, # This router in device.py deals with routes below /device
378
+ vector.vector_router, # This router in vector.py deals with routes below /vector
379
+ setup.setup_router, # This router in setup.py deals with routes below /setup
380
+ create_static_files_router(path="/static", directories=[STATICFILES]),
381
+ ],
382
+ exception_handlers={ NotAuthorizedException: gotologin_error_handler, NotFoundException: gotonotfound_error_handler},
383
+ plugins=[HTMXPlugin()],
384
+ middleware=[auth_mw],
385
+ template_config=TemplateConfig(directory=TEMPLATEFILES,
386
+ engine=MakoTemplateEngine,
387
+ ),
388
+ on_startup=[do_startup],
389
+ on_shutdown=[do_shutdown]
390
+ )
391
+ return app