name: audiobookshelf-alexa

services:
  skill:
    image: local/audiobookshelf-alexa-web:de3c4d5
    build:
      context: "https://github.com/jonas-dev/audiobookshelf-alexa.git#de3c4d5d56ec4da2713cf5a2128073ab26efd527"
      dockerfile_inline: |
        # syntax=docker/dockerfile:1.7
        FROM node:22-alpine

        WORKDIR /app

        COPY lambda/package.json lambda/package-lock.json ./
        RUN npm ci --omit=dev --no-audit --no-fund \
            && npm install --omit=dev --no-audit --no-fund --save-exact \
              express@4.21.2 \
              ask-sdk-express-adapter@2.14.0

        COPY lambda/index.js ./index.js
        COPY lambda/lib ./lib

        RUN cat <<'EOF' > /app/server.js
        'use strict';

        const express = require('express');
        const {
          SkillRequestSignatureVerifier,
          TimestampVerifier,
        } = require('ask-sdk-express-adapter');
        const { handler } = require('./index');

        const port = Number.parseInt(process.env.PORT || '3000', 10);
        const skillId = process.env.ALEXA_SKILL_ID;

        if (!skillId) {
          throw new Error('ALEXA_SKILL_ID is required');
        }

        const signatureVerifier = new SkillRequestSignatureVerifier();
        const timestampVerifier = new TimestampVerifier();
        const app = express();

        app.disable('x-powered-by');

        app.get('/', (_request, response) => {
          response.status(200).json({
            service: 'audiobookshelf-alexa',
            alexaEndpoint: '/alexa',
            healthEndpoint: '/healthz',
          });
        });

        app.get('/healthz', (_request, response) => {
          response.status(200).json({ status: 'ok' });
        });

        function get_application_id(requestEnvelope) {
          return requestEnvelope?.context?.System?.application?.applicationId
            || requestEnvelope?.session?.application?.applicationId
            || null;
        }

        function invoke_lambda_handler(requestEnvelope) {
          return new Promise((resolve, reject) => {
            handler(requestEnvelope, {}, (error, result) => {
              if (error) {
                reject(error);
                return;
              }
              resolve(result);
            });
          });
        }

        app.post(
          '/alexa',
          express.raw({ type: () => true, limit: '1mb' }),
          async (request, response) => {
            if (!Buffer.isBuffer(request.body)) {
              response.status(400).json({ error: 'Expected a raw request body' });
              return;
            }

            const rawBody = request.body.toString('utf8');
            let requestEnvelope;

            try {
              await signatureVerifier.verify(rawBody, request.headers);
              await timestampVerifier.verify(rawBody);
              requestEnvelope = JSON.parse(rawBody);
            } catch (error) {
              console.warn('Rejected invalid Alexa request:', error.message);
              response.status(400).json({ error: 'Invalid Alexa request' });
              return;
            }

            if (get_application_id(requestEnvelope) !== skillId) {
              console.warn('Rejected Alexa request for an unexpected Skill ID');
              response.status(403).json({ error: 'Unexpected Alexa Skill ID' });
              return;
            }

            try {
              const result = await invoke_lambda_handler(requestEnvelope);
              response.status(200).type('application/json').send(JSON.stringify(result));
            } catch (error) {
              console.error('Alexa skill invocation failed:', error);
              response.status(500).json({ error: 'Skill invocation failed' });
            }
          },
        );

        app.use((_request, response) => {
          response.status(404).json({ error: 'Not found' });
        });

        const server = app.listen(port, '0.0.0.0', () => {
          console.log('Audiobookshelf Alexa endpoint listening on port ' + port);
        });

        server.requestTimeout = 12000;
        server.headersTimeout = 13000;
        server.keepAliveTimeout = 5000;

        function shut_down() {
          server.close(() => process.exit(0));
          setTimeout(() => process.exit(1), 10000).unref();
        }

        process.on('SIGTERM', shut_down);
        process.on('SIGINT', shut_down);
        EOF

        RUN chown -R node:node /app

        USER node
        EXPOSE 3000
        CMD ["node", "server.js"]

    restart: unless-stopped
    init: true

    environment:
      NODE_ENV: production
      PORT: "3000"
      ABS_BASE_URL: "${ABS_BASE_URL:?Set ABS_BASE_URL to the public HTTPS URL of your Audiobookshelf server}"
      ABS_API_KEY: "${ABS_API_KEY:?Set ABS_API_KEY to a dedicated Audiobookshelf API key}"
      ABS_DEFAULT_LIBRARY_ID: "${ABS_DEFAULT_LIBRARY_ID:-}"
      ALEXA_SKILL_ID: "${ALEXA_SKILL_ID:?Set ALEXA_SKILL_ID to the amzn1.ask.skill value from the Alexa developer console}"

    ports:
      - "${SKILL_BIND_ADDRESS:-0.0.0.0}:${SKILL_PORT:-3000}:3000"

    read_only: true
    tmpfs:
      - /tmp:size=16m,mode=1777

    security_opt:
      - no-new-privileges:true

    cap_drop:
      - ALL

    healthcheck:
      test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:3000/healthz | grep -q '\"status\":\"ok\"'"]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 15s

    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"
