diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..a6bcdbd --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +.git +.gitea +__pycache__ +config.json +users.json +sessions.json +progress*.json +*.tmp +README.md diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml new file mode 100644 index 0000000..a6e8c78 --- /dev/null +++ b/.gitea/workflows/build.yml @@ -0,0 +1,47 @@ +name: build + +on: + push: + branches: [main] + tags: ["v*"] + pull_request: + +env: + REGISTRY: git.necloud.us + IMAGE: git.necloud.us/nate/audiobook-player + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Syntax check + run: python3 -m py_compile server.py + - name: Check the page script parses + run: | + python3 - <<'PY' + import re + open("/tmp/page.js", "w").write(re.search(r"", open("index.html").read(), re.S).group(1)) + PY + node --check /tmp/page.js + + image: + needs: check + if: github.event_name != 'pull_request' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Log in to the Gitea container registry + run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login "$REGISTRY" -u "${{ gitea.actor }}" --password-stdin + - name: Build + run: docker build -t "$IMAGE:${{ gitea.sha }}" -t "$IMAGE:latest" . + - name: Smoke test (server refuses to start without accounts, but must import and print usage) + run: docker run --rm "$IMAGE:latest" python server.py users + - name: Push + run: | + docker push "$IMAGE:${{ gitea.sha }}" + docker push "$IMAGE:latest" + if [ "${{ gitea.ref_type }}" = "tag" ]; then + docker tag "$IMAGE:latest" "$IMAGE:${{ gitea.ref_name }}" + docker push "$IMAGE:${{ gitea.ref_name }}" + fi diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..bc7e87d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.13-slim + +ENV PYTHONUNBUFFERED=1 DATA_DIR=/data PORT=8080 +RUN useradd --system --uid 10001 --home /app app && mkdir /data && chown app /data +WORKDIR /app +COPY server.py index.html ./ +USER app +VOLUME /data +EXPOSE 8080 +HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ + CMD python -c "import urllib.request,os;urllib.request.urlopen('http://127.0.0.1:%s/login'%os.environ['PORT'])" || exit 1 +CMD ["python", "server.py", "serve"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..cb85d16 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,17 @@ +services: + audiobook-player: + image: git.necloud.us/nate/audiobook-player:latest + # build: . # uncomment to build locally instead of pulling + container_name: audiobook-player + restart: unless-stopped + ports: + - "8080:8080" + environment: + OPENAUDIBLE_URL: http://172.16.16.106:8000 + OPENAUDIBLE_USER: ${OPENAUDIBLE_USER} # put these in a .env file next to this one + OPENAUDIBLE_PASSWORD: ${OPENAUDIBLE_PASSWORD} + volumes: + - player-data:/data # accounts, sessions, listening progress + +volumes: + player-data: diff --git a/server.py b/server.py index 9a0470a..9f14362 100644 --- a/server.py +++ b/server.py @@ -29,11 +29,12 @@ from http.cookies import SimpleCookie from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer HERE = os.path.dirname(os.path.abspath(__file__)) -F_CONFIG = os.path.join(HERE, "config.json") # OpenAudible login (chmod 600) -F_USERS = os.path.join(HERE, "users.json") # name -> salted scrypt hash -F_SESSIONS = os.path.join(HERE, "sessions.json") # token hash -> {user, exp} -F_PROGRESS = os.path.join(HERE, "progress_users.json") -F_LEGACY = os.path.join(HERE, "progress.json") # single-user file from the first version +DATA = os.environ.get("DATA_DIR", HERE) # where accounts/progress live (a volume in Docker) +F_CONFIG = os.path.join(DATA, "config.json") # OpenAudible login (chmod 600) +F_USERS = os.path.join(DATA, "users.json") # name -> salted scrypt hash +F_SESSIONS = os.path.join(DATA, "sessions.json") # token hash -> {user, exp} +F_PROGRESS = os.path.join(DATA, "progress_users.json") +F_LEGACY = os.path.join(DATA, "progress.json") # single-user file from the first version COOKIE = "oa_session" SESSION_DAYS = 90 @@ -79,6 +80,12 @@ def check_pw(password, rec): return hmac.compare_digest(h, rec["hash"]) +def oa_login(): + """OpenAudible credentials: env vars win over config.json (handy for containers).""" + cfg = read(F_CONFIG, {}) + return os.environ.get("OPENAUDIBLE_USER") or cfg.get("user"), os.environ.get("OPENAUDIBLE_PASSWORD") or cfg.get("password", "") + + def sha(token): return hashlib.sha256(token.encode()).hexdigest() @@ -272,10 +279,10 @@ class Handler(BaseHTTPRequestHandler): self.end_headers() def proxy(self): - cfg = read(F_CONFIG, {}) + oa_user, oa_pass = oa_login() req = urllib.request.Request(self.upstream + self.path) - if cfg.get("user"): - cred = base64.b64encode(f"{cfg['user']}:{cfg.get('password', '')}".encode()).decode() + if oa_user: + cred = base64.b64encode(f"{oa_user}:{oa_pass}".encode()).decode() req.add_header("Authorization", "Basic " + cred) for h in PASS_HEADERS: if self.headers.get(h): @@ -306,14 +313,14 @@ class Handler(BaseHTTPRequestHandler): def serve(argv): ap = argparse.ArgumentParser() - ap.add_argument("--upstream", default="http://172.16.16.106:8000") - ap.add_argument("--port", type=int, default=8080) + ap.add_argument("--upstream", default=os.environ.get("OPENAUDIBLE_URL", "http://172.16.16.106:8000")) + ap.add_argument("--port", type=int, default=int(os.environ.get("PORT", 8080))) ap.add_argument("--host", default="0.0.0.0") a = ap.parse_args(argv) if not read(F_USERS, {}): sys.exit("No accounts yet. Run: python3 server.py adduser YOURNAME") - if not read(F_CONFIG, {}).get("user"): - print("Note: no OpenAudible login saved. Run `python3 server.py setup` first.") + if not oa_login()[0]: + print("Note: no OpenAudible login set. Run `python3 server.py setup` or set OPENAUDIBLE_USER/OPENAUDIBLE_PASSWORD.") Handler.upstream = a.upstream.rstrip("/") print(f"Player: http://localhost:{a.port} (OpenAudible at {Handler.upstream})") ThreadingHTTPServer((a.host, a.port), Handler).serve_forever()