Compare commits

..

No commits in common. "master" and "prod" have entirely different histories.
master ... prod

11 changed files with 78 additions and 118 deletions

View File

@ -2,5 +2,3 @@ dist/
db.sqlite db.sqlite
build/ build/
.git .git
.DS_Store
screenshots/

1
.gitignore vendored
View File

@ -1,4 +1,3 @@
dist/ dist/
db.sqlite db.sqlite
build/ build/
.DS_Store

View File

@ -3,6 +3,8 @@ name = "pypi"
url = "https://pypi.org/simple" url = "https://pypi.org/simple"
verify_ssl = true verify_ssl = true
[dev-packages]
[packages] [packages]
bottle = "*" bottle = "*"
pprint = "*" pprint = "*"

View File

@ -1,23 +1,13 @@
# tvid # tvid
## Problem This is an app that lets you set all the kiosk/display TVs in your
organization to the same URL. Each will be cookied with a unique ID that
will display on each display in big letters, with no preconfiguration
required.
You want to deploy a bunch of cheap $150 giant TVs to display dashboards and Log in to the admin panel and enter the URL for that TV ID, and within 60
status stuff, but you don't want to image the Raspberry Pis separately or seconds, that display will bounce to that URL, or any other time it turns
hook up keyboards/mouse. on.
## Solution
Make a single OS image for all your display-driving SBCs that goes to a
non-unique URL: this app.
This app lets you set all the kiosk/display TVs in your organization to the
same URL. Each will be cookied with a unique ID that will display on each
display in big letters, with no preconfiguration required.
Log in to the admin panel (`/admin`) and enter the URL target for that
display, and within 60 seconds, that display will bounce to that URL, or any
other time it turns on.
You can reconfigure the target URL at any time, and the next time that You can reconfigure the target URL at any time, and the next time that
display reboots or reloads (you should be rebooting your displays daily) it display reboots or reloads (you should be rebooting your displays daily) it
@ -35,51 +25,15 @@ will get redirected to the new target.
# todo # todo
* fix CSRF bug * fix CSRF
* fix FIXMEs * fix FIXMEs
* fix logging output (that is, make it log anything) * fix logging output
* put git short id into version string * put git short id into version string
* make sure cookie expiration is correct
* sessions maybe
* configuration in db to support password changes
* perhaps load the target in a fullsize iframe so that the target can be
changed by the js on server update instead of having to reboot the display
box
# screenshots
## Display ID screen
(This is what gets displayed on a TV. It animates to avoid screenburn.)
![screenshot one](/sneak/tvid/raw/branch/master/screenshots/1.png)
## Login Page
![screenshot two](/sneak/tvid/raw/branch/master/screenshots/2.png)
## Admin Panel
![screenshot three](/sneak/tvid/raw/branch/master/screenshots/3.png)
# license # license
WTFPL WTFPL
# status
This is not enterprise-production-grade software yet.
It has a CSRF bug (low severity) and it stores your unhashed auth password
in a cookie. I didn't even check how long the per-device cookie expirations
are, so it might have a big painful bug there too. I made it to scratch an
itch and I am running it in production; I may improve it further but it
works for my purposes (being able to flash a half-dozen display driver
raspberry pis with the same image) as-is.
Patches and improvements and bug reports are welcome. Send me an email at
the address below.
# author # author
sneak <[sneak@sneak.berlin](mailto:sneak@sneak.berlin)> sneak <[sneak@sneak.berlin](mailto:sneak@sneak.berlin)>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

View File

@ -4,10 +4,15 @@ from setuptools import setup, find_packages
# and i have no idea why so there is a bin in bin/ # and i have no idea why so there is a bin in bin/
setup( setup(
name="tvid", name='tvid',
version="1.0.0", version='1.0.0',
packages=find_packages(), packages=find_packages(),
license="WTFPL", license='WTFPL',
long_description=open("README.md").read(), long_description=open('README.md').read(),
entry_points={"console_scripts": ["tvidd = tvid.tvid:serve"],}, entry_points = {
'console_scripts': [
'tvidd = tvid.tvid:serve'
],
},
) )

View File

@ -1 +1,2 @@
from .server import serve from .server import serve

View File

@ -6,12 +6,11 @@ from .server import SQLBASE
Base = SQLBASE Base = SQLBASE
# YAGNI just set admin pw in an env var for now and dont support changing it # YAGNI just set admin pw in an env var for now and dont support changing it
# yet # yet
# class Settings(Base): #class Settings(Base):
class TV(Base): class TV(Base):
__tablename__ = "tvs" __tablename__ = 'tvs'
id = Column(Integer, Sequence("id_seq"), primary_key=True) id = Column(Integer, Sequence('id_seq'), primary_key=True)
displayid = Column(String(20)) displayid = Column(String(20))
lastSeen = Column(DateTime) lastSeen = Column(DateTime)
target = Column(String(255)) target = Column(String(255))

View File

@ -21,27 +21,25 @@ import random
import string import string
from datetime import datetime, timedelta from datetime import datetime, timedelta
VERSION = "1.0.0" VERSION = '1.0.0'
PORT = os.environ.get("PORT", 8080) PORT = os.environ.get('PORT', 8080)
DEBUG = os.environ.get("DEBUG", False) DEBUG = os.environ.get('DEBUG', False)
SQLITE_FILENAME = os.environ.get("SQLITE_FILENAME", "/data/db.sqlite") SQLITE_FILENAME = os.environ.get('SQLITE_FILENAME','/data/db.sqlite')
DATABASE_URL = os.environ.get("DATABASE_URL", "sqlite:///" + SQLITE_FILENAME) DATABASE_URL = os.environ.get('DATABASE_URL','sqlite:///' + SQLITE_FILENAME)
ADMIN_PSK = os.environ.get("ADMIN_PSK", "hunter2") ADMIN_PSK = os.environ.get('ADMIN_PSK','hunter2')
# sorry for global # sorry for global
SQLBASE = declarative_base() SQLBASE = declarative_base()
# FIXME make this skip letters not in base58 # FIXME make this skip letters not in base58
def randomUpper(count): def randomUpper(count):
return "".join(random.choice(string.ascii_uppercase) for _ in range(count)) return ''.join(random.choice(string.ascii_uppercase) for _ in range(count))
# FIXME maybe make the IDs longer # FIXME maybe make the IDs longer
def genRandomTVID(): def genRandomTVID():
tvid = str(randomUpper(3) + "-" + randomUpper(3)) tvid = str(randomUpper(3) + "-" + randomUpper(3))
return tvid return tvid
def serve(): def serve():
app = bottle.Bottle() app = bottle.Bottle()
@ -53,40 +51,40 @@ def serve():
plugin = sqlalchemy.Plugin( plugin = sqlalchemy.Plugin(
engine, engine,
SQLBASE.metadata, SQLBASE.metadata,
keyword="db", keyword='db',
create=True, create=True,
commit=True, commit=True,
use_kwargs=False, use_kwargs=False
) )
app.install(plugin) app.install(plugin)
# here we cookie them if they have no cookie, then redirect them to the # here we cookie them if they have no cookie, then redirect them to the
# cookie'd value (whether preexisting or new). # cookie'd value (whether preexisting or new).
@app.get("/") @app.get('/')
def indexpage(): def indexpage():
c = request.get_cookie("displayid") c = request.get_cookie("displayid")
if c: if c:
# redirect # redirect
return redirect("/tv/" + c) return redirect('/tv/' + c)
else: else:
newid = genRandomTVID() newid = genRandomTVID()
response.set_cookie("displayid", newid) response.set_cookie("displayid", newid)
return redirect("/tv/" + newid) return redirect('/tv/' + newid)
@app.get("/style.css") @app.get('/style.css')
def stylesheet(): def stylesheet():
response.content_type = "text/css" response.content_type = 'text/css'
return template("style") return template('style')
# here we check to see if they have a redirect URL in the db. if they do # here we check to see if they have a redirect URL in the db. if they do
# we send them there. if they don't, we display their ID really big, # we send them there. if they don't, we display their ID really big,
# reloading the page once per hour. # reloading the page once per hour.
@app.get("/tv/<displayid>") @app.get('/tv/<displayid>')
def tvpage(db, displayid=None): def tvpage(db, displayid=None):
# FIXME check for cookie, this is broken # FIXME check for cookie, this is broken
if displayid is None: if displayid is None:
return template("nocookie") return template('nocookie')
# check db for tv id # check db for tv id
tv = db.query(TV).filter_by(displayid=displayid).first() tv = db.query(TV).filter_by(displayid=displayid).first()
@ -96,60 +94,61 @@ def serve():
if tv.target: if tv.target:
return redirect(tv.target) return redirect(tv.target)
else: else:
return template("displayid", id=displayid, version=VERSION) return template('displayid', id=displayid, version=VERSION)
else: else:
# otherwise, just show their display ID bigly and keep them # otherwise, just show their display ID bigly and keep them
# bouncing periodically until some admin configures them # bouncing periodically until some admin configures them
# a target: # a target:
# update lastseen here: # update lastseen here:
newtv = TV(displayid=displayid, lastSeen=datetime.now()) newtv = TV(displayid=displayid,lastSeen=datetime.now())
db.add(newtv) db.add(newtv)
return template("displayid", id=displayid, version=VERSION) return template('displayid', id=displayid, version=VERSION)
@app.get("/admin/edit/<displayid>")
@app.get('/admin/edit/<displayid>')
def displayeditform(db, displayid=None): def displayeditform(db, displayid=None):
c = request.get_cookie("psk") c = request.get_cookie("psk")
if not c: if not c:
return redirect("/login") return redirect('/login')
if c != ADMIN_PSK: if c != ADMIN_PSK:
return redirect("/logout") return redirect('/logout')
if not displayid: if not displayid:
return redirect("/admin") return redirect('/admin')
tv = db.query(TV).filter_by(displayid=displayid).first() tv = db.query(TV).filter_by(displayid=displayid).first()
if tv is None: if tv is None:
return redirect("/admin") return redirect('/admin')
return template("displayeditform", tv=tv, version=VERSION) return template('displayeditform', tv=tv, version=VERSION)
@app.post("/admin/edit") @app.post('/admin/edit')
def displayedithandler(db): def displayedithandler(db):
# FIXME SECURITY csrf issue # FIXME SECURITY csrf issue
c = request.get_cookie("psk") c = request.get_cookie("psk")
if not c: if not c:
return redirect("/login") return redirect('/login')
if c != ADMIN_PSK: if c != ADMIN_PSK:
return redirect("/logout") return redirect('/logout')
displayid = request.forms.get("displayid") displayid = request.forms.get('displayid')
tv = db.query(TV).filter_by(displayid=displayid).first() tv = db.query(TV).filter_by(displayid=displayid).first()
if tv is None: if tv is None:
return redirect("/admin") return redirect('/admin')
# FIXME make sure this is a valid URL # FIXME make sure this is a valid URL
tv.target = request.forms.get("target") tv.target = request.forms.get('target')
tv.memo = request.forms.get("formmemo") tv.memo = request.forms.get('formmemo')
db.add(tv) db.add(tv)
db.commit() db.commit()
return redirect("/admin") return redirect('/admin')
# here we display the administration list of TVs if logged in # here we display the administration list of TVs if logged in
# if logged out then redirect to /login # if logged out then redirect to /login
# FIXME make this use sessions instead of just storing PSK in a cookie # FIXME make this use sessions instead of just storing PSK in a cookie
# https://bottlepy.org/docs/dev/recipes.html # https://bottlepy.org/docs/dev/recipes.html
@app.get("/admin") @app.get('/admin')
def adminpage(db): def adminpage(db):
c = request.get_cookie("psk") c = request.get_cookie("psk")
if not c: if not c:
return redirect("/login") return redirect('/login')
if c != ADMIN_PSK: if c != ADMIN_PSK:
return redirect("/logout") return redirect('/logout')
# first, cleanup db of old entries: # first, cleanup db of old entries:
week_ago = datetime.now() - timedelta(days=7) week_ago = datetime.now() - timedelta(days=7)
@ -157,33 +156,36 @@ def serve():
db.commit() db.commit()
tvs = db.query(TV).order_by(TV.lastSeen.desc()) tvs = db.query(TV).order_by(TV.lastSeen.desc())
response.headers["Cache-Control"] = "no-cache" response.headers['Cache-Control'] = 'no-cache'
return template("adminpanel", tvs=tvs, version=VERSION) return template('adminpanel', tvs=tvs, version=VERSION)
# here we ask for a password: # here we ask for a password:
@app.get("/login") @app.get('/login')
def loginform(): def loginform():
msg = request.GET.msg msg = request.GET.msg
return template("loginform", version=VERSION, msg=msg) return template('loginform', version=VERSION, msg=msg)
@app.post("/checklogin") @app.post('/checklogin')
def checklogin(): def checklogin():
attemptedPass = request.forms.get("password") attemptedPass = request.forms.get('password')
if not attemptedPass: if not attemptedPass:
return redirect( return redirect(
"/login?msg=" + urllib.parse.quote_plus(u"Incorrect password.") '/login?msg=' +
urllib.parse.quote_plus(u"Incorrect password.")
) )
if attemptedPass != ADMIN_PSK: if attemptedPass != ADMIN_PSK:
return redirect( return redirect(
"/login?msg=" + urllib.parse.quote_plus(u"Incorrect password.") '/login?msg=' +
urllib.parse.quote_plus(u"Incorrect password.")
) )
# password is right, cookie them: # password is right, cookie them:
response.set_cookie("psk", attemptedPass) response.set_cookie("psk", attemptedPass)
return redirect("/admin") return redirect('/admin')
@app.get("/logout") @app.get('/logout')
def logout(): def logout():
response.set_cookie("psk", "") response.set_cookie("psk", "")
return redirect("/login") return redirect('/login')
app.run(host="0.0.0.0", port=PORT, debug=DEBUG) app.run(host='0.0.0.0', port=PORT, debug=DEBUG)