wheatandcatの開発ブログ

技術系記事を投稿してます

Supabase Edge FunctionsでAPI Keyの生成とAPI Key経由でのwebページ登録の仕組みを実装

概要

前に自作した Chrome 拡張の紹介記事を書いたが、そのアプリに API Key の生成API Key 経由での Web ページ登録 API を追加した。

www.wheatandcat.me

実装には Supabase Edge Functions を採用。設計と実装手順を残す。

PR

github.com

自作した Chrome 拡張

chromewebstore.google.com

モチベーション

MarkyLinky は Web ページを保存/削除し、Markdown のリンクに変換してコピーできる Chrome 拡張。ログインすれば別ブラウザでも共有可能。仕事で技術記事のストック→共有に使っている。共有後すぐ削除できる UX が便利。

課題は Chrome拡張機能前提 な点。iPhone/Android で閲覧中にワンステップで保存できない。
そこで API Key の生成 + API Key 経由登録 と、以前書いた ショートカット経由で直接 API 実行 を組み合わせ、iPhone からもワンステップ保存を狙った。

www.wheatandcat.me

本記事ではその中核である 「API Key の生成」と「API Key 経由登録」Supabase Edge Functions で実装した部分に絞って解説。

Supabase Edge Functions

supabase.com

Supabase が提供するサーバーレス関数。ランタイムは Supabase Edge Runtime(Deno 互換)。今回は Auth と DB を Supabase に寄せているため、API も同じ基盤で統一。Supabase CLI でローカル検証が容易。

設計

作る API は 2 つ。

  • API Key 生成 API
    • Supabase Auth で認証済みのときのみ実行可能
    • db: api_tokensユーザーIDトークン を保存
    • トークンは重複なしのランダム文字列
  • API Key 経由でアイテム登録する API
    • 認証不要(トークン + URL を受け付け)
    • 受け取ったトークンから api_tokens を引いてユーザーを特定し items に登録
    • URL から titlefavicon URL を解析して保存

実装

Supabase のローカル環境構築

プロジェクト初期化。

supabase init

スタック起動(Postgres, Kong, Studio などが立ち上がる)。

supabase start

Studio へのアクセス(出力ログの URL を開く)。

open http://127.0.0.1:54323

マイグレーション作成

マイグレーション雛形を作成。

supabase migration new api_tokens

生成物。

supabase
  └── migrations
      └── 20251215140600_api_tokens.sql

api_tokens テーブルと RLS ポリシーを定義。

supabase/migrations/20251215140600_api_tokens.sql

create table if not exists api_tokens (
  id bigint primary key generated always as identity,
  uuid uuid not null,
  title text not null,
  token text not null unique,
  created timestamptz default now()
);

CREATE POLICY "Users can select their own api tokens"
ON public.api_tokens
FOR SELECT
USING (auth.uid() = uuid);

CREATE POLICY "Users can insert their own api tokens"
ON public.api_tokens
FOR INSERT
WITH CHECK (auth.uid() = uuid);

CREATE POLICY "Users can update their own api tokens"
ON public.api_tokens
FOR UPDATE
WITH CHECK (auth.uid() = uuid);

CREATE POLICY "Users can delete their own api tokens"
ON public.api_tokens
FOR DELETE
USING (auth.uid() = uuid);

ローカルに反映。

supabase migration up

Studio でテーブル作成を確認。

本番へ反映。

supabase db push

型の自動生成。

supabase gen types typescript --local > schema.ts

Edge Functions(API Key 生成)

関数雛形を作成。

supabase functions new create-token

構成。

supabase
  └── functions
        └── create-token
            ├── deno.json
            └── index.ts

index.ts に実装。ユニークキー生成(Base64URL)、重複時リトライ、Auth ヘッダー連携を含む。

supabase/functions/create-token/index.ts

import "jsr:@supabase/functions-js/edge-runtime.d.ts";
import { createClient } from "npm:@supabase/supabase-js@2";

const corsHeaders = {
  "Access-Control-Allow-Origin": "*",
  "Access-Control-Allow-Headers":
    "authorization, x-client-info, apikey, content-type",
};

function toBase64Url(bytes: Uint8Array): string {
  const b64 = btoa(String.fromCharCode(...bytes));
  return b64.replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
}

function generateApiKey(): string {
  const bytes = new Uint8Array(32);
  crypto.getRandomValues(bytes);
  return toBase64Url(bytes);
}

function isPostgrestError(err: unknown): err is {
  code?: string;
  message?: string;
  details?: string;
  hint?: string;
} {
  return typeof err === "object" && err !== null && "message" in err;
}

function getErrorMessage(err: unknown): string {
  if (isPostgrestError(err) && typeof err.message === "string") {
    return err.message;
  }
  if (err instanceof Error) return err.message;
  return String(err);
}

function isUniqueViolation(err: unknown): boolean {
  return isPostgrestError(err) && err.code === "23505";
}

Deno.serve(async (req) => {
  if (req.method === "OPTIONS") {
    return new Response("ok", { headers: corsHeaders });
  }

  const authorizationHeader = req.headers.get("Authorization");
  if (!authorizationHeader) {
    return new Response("Unauthorized", { status: 401, headers: corsHeaders });
  }
  const token = authorizationHeader.replace("Bearer ", "");

  try {
    const { title } = await req.json();

    if (!title) {
      return new Response(
        JSON.stringify({
          error: "title is required and must be a non-empty string",
        }),
        {
          headers: { ...corsHeaders, "Content-Type": "application/json" },
          status: 400,
        },
      );
    }

    const supabaseClient = createClient(
      Deno.env.get("SUPABASE_URL") ?? "",
      Deno.env.get("SUPABASE_ANON_KEY") ?? "",
      {
        global: { headers: { Authorization: authorizationHeader } },
      },
    );

    const { data: userData, error: userError } = await supabaseClient.auth
      .getUser(token);
    if (userError) throw userError;

    const user = userData?.user;
    if (!user) {
      return new Response("Unauthorized", {
        status: 401,
        headers: corsHeaders,
      });
    }

    const maxRetries = 5;

    for (let i = 0; i < maxRetries; i++) {
      const apiKey = generateApiKey();

      try {
        const { error } = await supabaseClient
          .from("api_tokens")
          .insert({
            uuid: user.id,
            title,
            token: apiKey,
          })
          .select("token")
          .single();

        if (error) throw error;

        return new Response(
          JSON.stringify({ token: apiKey }),
          {
            headers: { ...corsHeaders, "Content-Type": "application/json" },
            status: 200,
          },
        );
      } catch (err) {
        if (isUniqueViolation(err)) continue;
        return new Response(
          JSON.stringify({ error: getErrorMessage(err) }),
          {
            headers: { ...corsHeaders, "Content-Type": "application/json" },
            status: 400,
          },
        );
      }
    }

    return new Response(
      JSON.stringify({ error: "could_not_generate_unique_key" }),
      {
        headers: { ...corsHeaders, "Content-Type": "application/json" },
        status: 500,
      },
    );
  } catch (error) {
    return new Response(
      JSON.stringify({ error: getErrorMessage(error) }),
      {
        headers: { ...corsHeaders, "Content-Type": "application/json" },
        status: 400,
      },
    );
  }
});

ローカル起動。

supabase functions serve --no-verify-jwt

Studio から認証済みユーザーを作成し、アクセストークンを取得。

USER=xxxxx PASSWORD=xxxxx
ACCESS_TOKEN=$(curl -s -X POST "$(supabase status --output json | jq -r '.API_URL')/auth/v1/token?grant_type=password"   -H "apikey: $(supabase status --output json | jq -r '.ANON_KEY')"   -H "Content-Type: application/json"   -d '{"email":"'"${USER}"'","password":"'"${PASSWORD}"'"}' | jq -r '.access_token')

curl -H "Authorization: Bearer $ACCESS_TOKEN"   "$(supabase status --output json | jq -r '.API_URL')/functions/v1/create-token"   -H "Content-Type: application/json"   -d '{"title":"test"}'

テーブルにレコードが入ることを確認。

Edge Functions(API Key 経由登録)

雛形。

supabase functions new create-item

api_tokens を引き、URL のメタを解析して items に登録する関数を実装。プライベートネットやメタデータエンドポイント等への SSRF を簡易ブロック。

create-item の実装例
supabase/functions/create-item/index.ts

import "jsr:@supabase/functions-js/edge-runtime.d.ts";
import { createClient } from "npm:@supabase/supabase-js@2";

function isHttpUrl(input: string): boolean {
  try {
    const u = new URL(input);
    return u.protocol === "http:" || u.protocol === "https:";
  } catch {
    return false;
  }
}

function isBlockedHost(hostname: string): boolean {
  const h = hostname.toLowerCase();
  if (h === "localhost" || h.endsWith(".localhost")) return true;
  if (h === "127.0.0.1" || h === "0.0.0.0") return true;
  if (h.startsWith("10.")) return true;
  if (h.startsWith("192.168.")) return true;
  if (h.startsWith("172.")) {
    const parts = h.split(".");
       const second = Number(parts[1]);
    if (!Number.isNaN(second) && second >= 16 && second <= 31) return true;
  }
  if (h === "::1") return true;
  if (h === "169.254.169.254") return true;
  return false;
}

function extractTitle(html: string): string | null {
  const m = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
  if (!m) return null;
  return decodeHtmlEntities(m[1].trim()).slice(0, 200);
}

function extractFaviconHref(html: string): string | null {
  const relPriority = [
    "icon",
    "shortcut icon",
    "apple-touch-icon",
    "apple-touch-icon-precomposed",
  ];
  const links = [...html.matchAll(/<link\b[^>]*>/gi)].map((m) => m[0]);
  type Candidate = { rel: string; href: string };
  const candidates: Candidate[] = [];
  for (const tag of links) {
    const rel = (tag.match(/\brel\s*=\s*["']([^"']+)["']/i)?.[1] ?? "")
      .toLowerCase().trim();
    const href = (tag.match(/\bhref\s*=\s*["']([^"']+)["']/i)?.[1] ?? "")
      .trim();
    if (!rel || !href) continue;
    if (rel.includes("icon")) {
      candidates.push({ rel, href });
      continue;
    }
    if (relPriority.includes(rel)) {
      candidates.push({ rel, href });
      continue;
    }
  }
  candidates.sort((a, b) => {
    const ai = relPriority.indexOf(a.rel);
    const bi = relPriority.indexOf(b.rel);
    return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi);
  });
  return candidates[0]?.href ?? null;
}

function resolveUrl(baseUrl: string, maybeRelative: string): string {
  try {
    return new URL(maybeRelative, baseUrl).toString();
  } catch {
    return maybeRelative;
  }
}

function decodeHtmlEntities(s: string): string {
  return s
    .replaceAll("&amp;", "&")
    .replaceAll("&lt;", "<")
    .replaceAll("&gt;", ">")
    .replaceAll("&quot;", '"')
    .replaceAll("&#39;", "'");
}

async function fetchHtmlWithLimits(
  url: string,
  timeoutMs = 8000,
  maxBytes = 512_000,
) {
  const controller = new AbortController();
  const t = setTimeout(() => controller.abort(), timeoutMs);
  try {
    const res = await fetch(url, {
      signal: controller.signal,
      redirect: "follow",
      headers: {
        "User-Agent": "meta-extractor/1.0",
        "Accept": "text/html,application/xhtml+xml",
      },
    });
    const finalUrl = res.url;
    const contentType = res.headers.get("content-type") ?? "";
    if (!contentType.toLowerCase().includes("text/html")) {
      throw new Error(`Not HTML content-type: ${contentType}`);
    }
    const reader = res.body?.getReader();
    if (!reader) throw new Error("No response body");
    const chunks: Uint8Array[] = [];
    let total = 0;
    while (true) {
      const { value, done } = await reader.read();
      if (done) break;
      if (!value) continue;
      total += value.byteLength;
      if (total > maxBytes) throw new Error(`HTML too large (> ${maxBytes} bytes)`);
      chunks.push(value);
    }
    const all = new Uint8Array(total);
    let offset = 0;
    for (const c of chunks) {
      all.set(c, offset);
      offset += c.byteLength;
    }
    const html = new TextDecoder().decode(all);
    return { html, finalUrl };
  } finally {
    clearTimeout(t);
  }
}

const corsHeaders = {
  "Access-Control-Allow-Origin": "*",
  "Access-Control-Allow-Headers":
    "authorization, x-client-info, apikey, content-type",
};

Deno.serve(async (req) => {
  const requestUrl = new URL(req.url);
  const url = requestUrl.searchParams.get("url");
  const token = requestUrl.searchParams.get("token");

  if (!url || !token) {
    return new Response(
      JSON.stringify({ error: "url and token are required" }),
      { headers: { "Content-Type": "application/json" } },
    );
  }

  if (!isHttpUrl(url)) {
    return new Response(
      JSON.stringify({ error: "url is required (http/https)" }),
      { headers: { "Content-Type": "application/json" } },
    );
  }

  const u = new URL(url);
  if (isBlockedHost(u.hostname)) {
    return new Response(JSON.stringify({ error: "blocked host" }), {
      headers: { "Content-Type": "application/json" },
    });
  }

  const supabaseClient = createClient(
    Deno.env.get("SUPABASE_URL") ?? "",
    Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") ?? "",
  );

  try {
    const { data, error } = await supabaseClient
      .from("api_tokens")
      .select("*")
      .eq("token", token)
      .single();

    if (error) throw error;
    if (!data) throw new Error("token not found");

    const { html, finalUrl } = await fetchHtmlWithLimits(url);
    const title = extractTitle(html) ?? "";
    const faviconHref = extractFaviconHref(html);
    const favIconUrl = faviconHref
      ? resolveUrl(finalUrl, faviconHref)
      : resolveUrl(finalUrl, "/favicon.ico");

    const { error: itemError } = await supabaseClient.from("items").insert({
      uuid: data.uuid,
      url,
      title,
      favIconUrl,
    });
    if (itemError) throw itemError;

    return new Response(
      JSON.stringify({ title, favIconUrl }),
      {
        headers: { ...corsHeaders, "Content-Type": "application/json" },
        status: 200,
      },
    );
  } catch (error: any) {
    return new Response(
      JSON.stringify({ error: String(error?.message ?? error) }),
      {
        headers: { ...corsHeaders, "Content-Type": "application/json" },
        status: 400,
      },
    );
  }
});

動作確認。

curl --location 'http://127.0.0.1:54321/functions/v1/create-item?url=https%3A%2F%2Fgithub.com%2Fwheatandcat%2FMarkyLinky%2Fissues%2F29&token=*****'

想定レスポンス。

{
  "title": "APIキーの生成と登録のAPIの仕組みを作成 · Issue #29 · wheatandcat/MarkyLinky · GitHub",
  "favIconUrl": "https://github.githubassets.com/favicons/favicon.svg"
}

デプロイ。

supabase functions deploy create-token
supabase functions deploy --no-verify-jwt create-item  # API Key 経由で叩くため --no-verify-jwt を付与

まとめ

  • Supabase はローカル構築が簡単で、Auth/DB/Functions を一体で扱える
  • Edge Functions で API Key 生成Key 経由登録 を最小構成で実装できた