Jak automaticky přidávat příspěvky na statický web pomocí Github Actions

Zveřejněno: 2023-02-22
#repo#serverless#GitHubActions

Při práci na open-source projektu Deníku sráčů jsem čelil výzvě umožnit přidávání dat do webové aplikace a zároveň hostovat aplikaci jako statický web tak, abych nemusel utratit ani korunu. Koncept se mi povedlo vymyslet a rád bych se s vámi o něj podělil.

Představení konceptu

Webovou aplikaci jsem tvořil v Next.js se statický exportem a hostoval ji pomocí služby Cloudflare Pages. Vím, že existuje mnoho jiných alternativ jako je například Vercel, Netlify či Heroku. Cloudflare jsem zvolil především z důvodu pokročilého cachováníochraně na úrovni DNS. A v aplikaci jsem měl již připravený HTML form, který mi sbíral data a uměl z nich vytvořit JSON soubor.

Rozhodl jsem se tedy infrastrukturu postavit následujícím způsobem:

Scéma infrastrukutry

  1. Na webu uživatel vyplní HTML formulář, který po stisknutí tlačítka vytvoří JSON souborPOST request, který obsahuje požadovaná data. Web je chráněn proti návštěvě robotů a pro navštívení, je potřeba splňovat nejvyšší bezpečnostní kritérium.
  2. Post request se zpracuje v proxy serveru, který běží jako serverless funkce na službe Cloudflare Workers. Proxy server data ověří a pošle POST request na GitHub API, které spustí GitHub Actions s daty jako vstupními parmatery.
  3. GitHub Actions vytvoří pull request, který čeká na schválení a ověření dat.
  4. Po schválení se mergnou branche a nová verze se automaticky nahraje s novými daty.

Limity

Toto řešení není vhodné pro veškeré aplikace a to především kvůli několika limitům. Mezi ty největší z mého pohledu patří především nutnost každou změnu manuálně schválit a doba procesu vytvoření změny.

Nástřel implementace

Následující kódy mají pouze ilustrovat implenetaci v projetku. Nejedná se o funkční kód.

HTML form request

async function createRequest(exportObj, fileName) {
  const body = `
  {
    "filename": "${fileName}",
    "content": ${JSON.stringify(exportObj)},
    "title": "${exportObj.placeName}"
}
  `;

  const response = await fetch("https://api.deniksracu.cz/", {
    method: "POST",
    headers: {
      Accept: "application/json",
      "Content-Type": "application/json",
    },
    body: body,
  });

  response.json().then((data) => {
    if (data.status === "OK") {
      Swal.fire({
        title: "Rulička!",
        text: "BOT Rulička úspěšně vytvořil žádost o přidání nového trůnu. Díky",
        icon: "success",
        confirmButtonColor: "#0078D4",
      });
    } else {
      Swal.fire({
        title: "Rulička!",
        text: "V procesu přidání nového trůnu zaznamenal BOT Rulička problém.",
        icon: "error",
        confirmButtonColor: "#0078D4",
      });
    }
  });
}

Proxy server

import { Octokit } from "@octokit/core";

const octokit = new Octokit({
  auth: PAT_TOKEN,
});

async function readRequestBody(request) {
  const { headers } = request;
  const contentType = headers.get("content-type") || "";

  if (contentType.includes("application/json")) {
    return JSON.stringify(await request.json());
  }
  return `ERROR: The wrong data format.`;
}

async function handlePost(request) {
  const reqBody = await readRequestBody(request);
  const retBody = JSON.parse(reqBody);

  console.log(JSON.stringify(retBody));

  await octokit.request(
    "POST /repos/{owner}/{repo}/actions/workflows/{workflow_id}/dispatches",
    {
      owner: "owner",
      repo: "repo",
      workflow_id: "workflow_id",
      ref: "main",
      inputs: {
        filename: `${retBody.filename}`,
        content: `${JSON.stringify(retBody.content)}`,
        title: `${retBody.title}`,
      },
    }
  );

  return new Response(`{"status":"OK"}`, {
    headers: {
      "Content-Type": "application/json",
      ...corsHeaders,
    },
    status: 200,
    statusText: "OK",
  });
}

const corsHeaders = {
  "Access-Control-Allow-Origin": "web_url",
  "Access-Control-Allow-Methods": "GET, HEAD, POST, OPTIONS",
  "Access-Control-Allow-Headers": "Content-Type, X-PINGOTHER",
};

function handleOptions(request) {
  if (
    request.headers.get("Origin") !== null &&
    request.headers.get("Access-Control-Request-Method") !== null &&
    request.headers.get("Access-Control-Request-Headers") !== null
  ) {
    // Handle CORS pre-flight request.
    return new Response(null, {
      headers: corsHeaders,
    });
  } else {
    // Handle standard OPTIONS request.
    return new Response(null, {
      headers: {
        Allow: "GET, HEAD, POST, OPTIONS",
      },
    });
  }
}

async function handle(request) {
  if (request.method === "OPTIONS") {
    return handleOptions(request);
  } else if (request.method === "POST") {
    return handlePost(request);
  } else if (request.method === "GET" || request.method == "HEAD") {
    // Pass-through to origin.
    return fetch(request);
  } else {
    return new Response(null, {
      status: 405,
      statusText: "Method Not Allowed",
      headers: corsHeaders,
    });
  }
}

addEventListener("fetch", (event) => {
  event.respondWith(
    handle(event.request)
      // For ease of debugging, we return exception stack
      // traces in response bodies. You are advised to
      // remove this .catch() in production.
      .catch(
        (e) =>
          new Response(e.stack, {
            status: 500,
            statusText: "Internal Server Error",
          })
      )
  );
});

GitHub Actions

name: 🧻 Rulička bot
on:
  workflow_dispatch:
    inputs:
      filename:
        description: 'Filename (without extension)'
        required: true
        default: ''
      content:
        description: 'Content (JSON data)'
        required: true
        default: ''
      title:
        description: 'Business name'
        required: true
        default: ''

jobs:
  create_json:
    runs-on: ubuntu-latest
    steps:
    - name: Checkout the repo
      uses: actions/checkout@v3
    
    - name: Create a json file
      run: |
        cd _toilets
        echo '${{ github.event.inputs.content }}' > ${{ github.event.inputs.filename }}.json
        cat ${{ github.event.inputs.filename }}.json
        
    - name: Create a pull request
      uses: peter-evans/create-pull-request@v4
      with:
        token: ${{ secrets.PAT }}
        commit-message: Add ${{ github.event.inputs.filename }}.json toilet 
        committer: Rulicka [Bot] <[email protected]>
        author: Rulica <[email protected]>
        signoff: false
        branch: new-toilet-${{ github.event.inputs.filename }}
        delete-branch: true
        title: 'A new toilet request:  ${{ github.event.inputs.title }}'
        body: |
          | KEY | VALUE |
          | ------ | ---------- |
          | placeName | **${{ fromJSON(github.event.inputs.content).placeName }}** |
          | coords | `${{ fromJSON(github.event.inputs.content).latitude }}` `${{ fromJSON(github.event.inputs.content).longtitude }}` |
          | wayDescription | ${{ fromJSON(github.event.inputs.content).wayDescription }} |
          | toiletType | ${{ fromJSON(github.event.inputs.content).toiletType }} |
          | comment | ${{ fromJSON(github.event.inputs.content).comment }} |
          | nickName | ${{ fromJSON(github.event.inputs.content).nickName }} |
          | timeStamp | ${{ fromJSON(github.event.inputs.content).timeStamp }} |
          
          Filename: ${{ github.event.inputs.filename }}.json
          
          ```json
          ${{ toJSON(fromJSON(github.event.inputs.content)) }}
          ```
        labels: |
          toilet
        reviewers: petrkucerak
        draft: false