From e8ffe6d75a99bea564967359e63f6581819afae7 Mon Sep 17 00:00:00 2001 From: Nicholas Molnar <65710+neekolas@users.noreply.github.com> Date: Wed, 14 Feb 2024 15:41:51 -0800 Subject: [PATCH] Create Open Frames Proxy --- .DS_Store | Bin 0 -> 6148 bytes .dockerignore | 135 + .github/workflows/publish.yaml | 38 + Dockerfile | 32 + LICENSE | 2 +- README.md | 20 +- bun.lockb | Bin 0 -> 9134 bytes fly.toml | 22 + package.json | 18 + src/constants.ts | 7 + src/errors.ts | 3 + src/handlers.ts | 150 ++ src/index.ts | 62 + src/parser.ts | 28 + src/test/fixtures/github.html | 4299 ++++++++++++++++++++++++++++++++ src/test/fixtures/ogp.html | 403 +++ src/test/fixtures/ogp.png | Bin 0 -> 12379 bytes src/test/handlers.test.ts | 115 + src/test/parser.test.ts | 64 + src/types.ts | 9 + src/utils.ts | 26 + tsconfig.json | 22 + 22 files changed, 5453 insertions(+), 2 deletions(-) create mode 100644 .DS_Store create mode 100644 .dockerignore create mode 100644 .github/workflows/publish.yaml create mode 100644 Dockerfile create mode 100755 bun.lockb create mode 100644 fly.toml create mode 100644 package.json create mode 100644 src/constants.ts create mode 100644 src/errors.ts create mode 100644 src/handlers.ts create mode 100644 src/index.ts create mode 100644 src/parser.ts create mode 100644 src/test/fixtures/github.html create mode 100644 src/test/fixtures/ogp.html create mode 100644 src/test/fixtures/ogp.png create mode 100644 src/test/handlers.test.ts create mode 100644 src/test/parser.test.ts create mode 100644 src/types.ts create mode 100644 src/utils.ts create mode 100644 tsconfig.json diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..f6eb2b5f2a20cf18e33bec140133b153c82f3a50 GIT binary patch literal 6148 zcmeHKJ5Iwu5S<|bS)xe^-CiL#Fp)VyE`Z`lP$Y5$qIXBZDYyey;2s=+f+O(e12{5J zQotK&_IYRK?aHt4ctk`Ox9hpcOhhWUq1@RrHp`oj>>wjkpxPPpW!r6c9z7gYe;qLH zL~i6-+Rf#V|8Vq~*>+8{+H@;Ks{8i!<@E9CY?xjDklkGkPkjcBMg^z<6`%rC;9o0% zo^4hg2XdtXRDcS6C}7`*0ynG^`#}G6VDS+EXut7pc=lNWSS$dn6Z=3!U>a0lP&G#k z4Laf_>*~ZlFzBLwGtSAIH76AH+Yv8bE?Nh2r2v`y}qD02TOC z3h1<1HVeE`_SVtMS+6bd3;4UC*2}SYD+YQi#>QIl$)K*-GxBv}ALw+%oet!WfayY` I0>7ca8^s$bO8@`> literal 0 HcmV?d00001 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..2968fdc --- /dev/null +++ b/.dockerignore @@ -0,0 +1,135 @@ +# flyctl launch added from .gitignore +# Logs +**/logs +**/*.log +**/npm-debug.log* +**/yarn-debug.log* +**/yarn-error.log* +**/lerna-debug.log* +**/.pnpm-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +**/report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +**/pids +**/*.pid +**/*.seed +**/*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +**/lib-cov + +# Coverage directory used by tools like istanbul +**/coverage +**/*.lcov + +# nyc test coverage +**/.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +**/.grunt + +# Bower dependency directory (https://bower.io/) +**/bower_components + +# node-waf configuration +**/.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +**/build/Release + +# Dependency directories +**/node_modules +**/jspm_packages + +# Snowpack dependency directory (https://snowpack.dev/) +**/web_modules + +# TypeScript cache +**/*.tsbuildinfo + +# Optional npm cache directory +**/.npm + +# Optional eslint cache +**/.eslintcache + +# Optional stylelint cache +**/.stylelintcache + +# Microbundle cache +**/.rpt2_cache +**/.rts2_cache_cjs +**/.rts2_cache_es +**/.rts2_cache_umd + +# Optional REPL history +**/.node_repl_history + +# Output of 'npm pack' +**/*.tgz + +# Yarn Integrity file +**/.yarn-integrity + +# dotenv environment variable files +**/.env +**/.env.development.local +**/.env.test.local +**/.env.production.local +**/.env.local + +# parcel-bundler cache (https://parceljs.org/) +**/.cache +**/.parcel-cache + +# Next.js build output +**/.next +**/out + +# Nuxt.js build / generate output +**/.nuxt +**/dist + +# Gatsby files +**/.cache +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +**/.vuepress/dist + +# vuepress v2.x temp and cache directory +**/.temp +**/.cache + +# Docusaurus cache and generated files +**/.docusaurus + +# Serverless directories +**/.serverless + +# FuseBox cache +**/.fusebox + +# DynamoDB Local files +**/.dynamodb + +# TernJS port file +**/.tern-port + +# Stores VSCode versions used for testing VSCode extensions +**/.vscode-test + +# yarn v2 +**/.yarn/cache +**/.yarn/unplugged +**/.yarn/build-state.yml +**/.yarn/install-state.gz +**/.pnp.* + +# Mac OS +**/.DS_STORE +fly.toml diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml new file mode 100644 index 0000000..bd2cc44 --- /dev/null +++ b/.github/workflows/publish.yaml @@ -0,0 +1,38 @@ +name: Publish Docker image + +on: + push: + branches: + - main + +jobs: + push_to_registry: + name: Push Docker image to GitHub Packages + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - name: Check out the repo + uses: actions/checkout@v2 + + - name: Log in to the Container registry + uses: docker/login-action@v1 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v3 + with: + images: ghcr.io/${{ github.repository }} + + - name: Build and push Docker image + uses: docker/build-push-action@v2 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2f09ad9 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,32 @@ +# use the official Bun image +# see all versions at https://hub.docker.com/r/oven/bun/tags +FROM oven/bun:1 as base +WORKDIR /usr/src/app + +# install dependencies into temp directory +# this will cache them and speed up future builds +FROM base AS install +# install with --production (exclude devDependencies) +RUN mkdir -p /temp/prod +COPY package.json bun.lockb /temp/prod/ +RUN cd /temp/prod && bun install --frozen-lockfile --production + +# copy node_modules from temp directory +# then copy all (non-ignored) project files into the image +FROM base AS prerelease +COPY --from=install /temp/prod/node_modules node_modules +COPY . . + +# [optional] tests & build +ENV NODE_ENV=production + +# copy production dependencies and source code into final image +FROM base AS release +COPY --from=install /temp/prod/node_modules node_modules +COPY --from=prerelease /usr/src/app/src ./src +COPY --from=prerelease /usr/src/app/package.json . + +# run the app +USER bun +EXPOSE 8080/tcp +ENTRYPOINT [ "bun", "run", "src/index.ts" ] \ No newline at end of file diff --git a/LICENSE b/LICENSE index b0fb491..4b0457a 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2024 open-frames +Copyright (c) 2024 Nicholas Molnar Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index c7e7131..d56a6b6 100644 --- a/README.md +++ b/README.md @@ -1 +1,19 @@ -# proxy \ No newline at end of file +# OpenGraph Proxy + +A simple proxy server to read OpenGraph tags from a URL without revealing client IP addresses. Designed to also support Farcaster Frames. + +## Setup + +To install dependencies: + +```bash +bun install +``` + +To run: + +```bash +bun run src/index.ts +``` + +This project was created using `bun init` in bun v1.0.25. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime. diff --git a/bun.lockb b/bun.lockb new file mode 100755 index 0000000000000000000000000000000000000000..3268ab468c46801b53b58487c20d9ff6c0217561 GIT binary patch literal 9134 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 literal 0 HcmV?d00001 diff --git a/fly.toml b/fly.toml new file mode 100644 index 0000000..ad6ff02 --- /dev/null +++ b/fly.toml @@ -0,0 +1,22 @@ +# fly.toml app configuration file generated for open-frames-proxy on 2024-02-14T15:21:00-08:00 +# +# See https://fly.io/docs/reference/configuration/ for information about how to use this file. +# + +app = 'open-frames-proxy' +primary_region = 'sea' + +[build] + +[http_service] + internal_port = 8080 + force_https = true + auto_stop_machines = true + auto_start_machines = true + min_machines_running = 0 + processes = ['app'] + +[[vm]] + memory = '1gb' + cpu_kind = 'shared' + cpus = 1 diff --git a/package.json b/package.json new file mode 100644 index 0000000..73825f8 --- /dev/null +++ b/package.json @@ -0,0 +1,18 @@ +{ + "name": "@xmtp/link-preview-server", + "module": "src/index.ts", + "type": "module", + "scripts": { + "start": "bun run src/index.ts", + "test": "bun test" + }, + "devDependencies": { + "@types/bun": "latest" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "dependencies": { + "cheerio": "^1.0.0-rc.12" + } +} diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 0000000..d9021e7 --- /dev/null +++ b/src/constants.ts @@ -0,0 +1,7 @@ +export const TAG_PREFIXES = ["og:", "fc:", "of:", "xmtp:"] as const; +export const PORT = 8080; +export const CORS_HEADERS = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "OPTIONS, GET, POST", + "Access-Control-Allow-Headers": "*", +}; diff --git a/src/errors.ts b/src/errors.ts new file mode 100644 index 0000000..7dd5d31 --- /dev/null +++ b/src/errors.ts @@ -0,0 +1,3 @@ +export class NoRedirectError extends Error {} + +export class InvalidRequestError extends Error {} diff --git a/src/handlers.ts b/src/handlers.ts new file mode 100644 index 0000000..3e5b698 --- /dev/null +++ b/src/handlers.ts @@ -0,0 +1,150 @@ +import { CORS_HEADERS } from "./constants"; +import { InvalidRequestError, NoRedirectError } from "./errors"; +import { extractMetaTags } from "./parser"; +import type { GetMetadataResponse, PostRedirectResponse } from "./types"; +import { getUrl, handleError } from "./utils"; + +export async function handleGet(req: Request) { + try { + const url = getUrl(req); + console.log(`Processing get metadata request for ${url}`); + if (!url) { + return new Response("Missing url query param", { status: 400 }); + } + const data = await downloadAndExtract(url); + const res: GetMetadataResponse = { + url, + metaTags: data, + }; + + return Response.json(res, { + headers: { + "content-type": "application/json", + ...CORS_HEADERS, + }, + }); + } catch (e) { + return handleError(e as Error); + } +} + +export async function handlePost(req: Request) { + try { + const url = getUrl(req); + const body = await req.json(); + console.log(`Processing POST request for ${url}`); + if (!url) { + return new Response("Missing url query param", { status: 400 }); + } + const data = await postAndExtract(url, body); + + const res: GetMetadataResponse = { + url, + metaTags: data, + }; + + return Response.json(res, { + headers: { + "content-type": "application/json", + ...CORS_HEADERS, + }, + }); + } catch (e) { + console.error(e); + if (e instanceof InvalidRequestError) { + return new Response("Missing url query param", { status: 400 }); + } + + return new Response("Internal server error", { status: 500 }); + } +} + +export async function handleRedirect(req: Request) { + try { + const url = getUrl(req); + console.log(`Processing handleRedirect request for ${url}`); + + const res = await findRedirect(url); + + return Response.json(res, { + headers: { + "content-type": "application/json", + ...CORS_HEADERS, + }, + }); + } catch (e) { + return handleError(e as Error); + } +} + +export async function handleMedia(req: Request) { + try { + const url = getUrl(req); + console.log(`Processing handleImage request for ${url}`); + + const headers = new Headers(); + const acceptHeader = req.headers.get("accept"); + if (acceptHeader) { + headers.set("accept", acceptHeader); + } + const response = await fetch(url, { + headers, + }); + + return new Response(response.body, { + headers: { + "content-type": response.headers.get("content-type") || "image/png", + ...CORS_HEADERS, + }, + }); + } catch (e) { + return handleError(e as Error); + } +} + +export async function postAndExtract(url: string, body: any) { + const signal = AbortSignal.timeout(10000); + const response = await fetch(url, { + method: "POST", + redirect: "follow", + body: JSON.stringify(body), + headers: { + "content-type": "application/json", + }, + signal, + }); + + if (response.status >= 400) { + throw new Error(`Request failed with status ${response.status}`); + } + + const text = await response.text(); + return extractMetaTags(text); +} + +export async function downloadAndExtract(url: string) { + const signal = AbortSignal.timeout(10000); + const response = await fetch(url, { redirect: "follow", signal }); + // TODO: Better error handling + if (response.status >= 400) { + throw new Error(`Request failed with status ${response.status}`); + } + + // TODO: Stream response until you see and then stop + const text = await response.text(); + return extractMetaTags(text); +} + +export async function findRedirect(url: string): Promise { + const signal = AbortSignal.timeout(10000); + const response = await fetch(url, { signal, redirect: "manual" }); + const location = response.headers.get("location"); + if (response.status !== 302 || !location) { + throw new NoRedirectError(); + } + + return { + originalUrl: url, + redirectedTo: location, + }; +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..5c46fa1 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,62 @@ +import type { Server } from "bun"; +import { CORS_HEADERS, PORT } from "./constants.ts"; +import { + handleGet, + handleMedia, + handlePost, + handleRedirect, +} from "./handlers.ts"; +import { getRequestPath } from "./utils.ts"; + +console.log(`Starting server on port ${PORT}`); + +/** + * Start the server on a given port + * @param port Server port + * @returns [Server](https://bun.sh/docs/api/http) + */ +export function start(port: number): Server { + return Bun.serve({ + port, + fetch(req) { + if (req.method === "OPTIONS") { + return new Response("ok", { headers: CORS_HEADERS }); + } + + const path = getRequestPath(req); + if (req.method === "GET") { + if (path === "/redirect") { + return handleRedirect(req); + } + + if (path === "/media") { + return handleMedia(req); + } + + if (path === "/") { + return handleGet(req); + } + } + + if (req.method === "POST") { + return handlePost(req); + } + + return new Response("Not found", { status: 404 }); + }, + }); +} + +function getPort() { + if (process.env.PORT) { + return parseInt(process.env.PORT); + } + return PORT; +} + +start(getPort()); + +process.on("SIGINT", () => { + console.log("Received SIGINT"); + process.exit(0); +}); diff --git a/src/parser.ts b/src/parser.ts new file mode 100644 index 0000000..31e14c7 --- /dev/null +++ b/src/parser.ts @@ -0,0 +1,28 @@ +import { load } from "cheerio"; +import { TAG_PREFIXES } from "./constants"; + +export function extractMetaTags(html: string, tagPrefixes = TAG_PREFIXES) { + const $ = load(html); + const metaTags = $("meta"); + const metaTagsArray = Array.from(metaTags); + + return metaTagsArray.reduce((acc: { [k: string]: string }, metaTag) => { + const metaName = metaTag.attribs.name; + const property = metaTag.attribs.property || metaName; + const content = metaTag.attribs.content; + + if (!property || !content) { + return acc; + } + + const hasPrefix = tagPrefixes.some((prefix) => + property.trim().startsWith(prefix) + ); + + if (!hasPrefix) { + return acc; + } + + return { ...acc, [property]: content }; + }, {}); +} diff --git a/src/test/fixtures/github.html b/src/test/fixtures/github.html new file mode 100644 index 0000000..cd8470d --- /dev/null +++ b/src/test/fixtures/github.html @@ -0,0 +1,4299 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + oven-sh/bun: Incredibly fast JavaScript runtime, bundler, test runner, and package manager – all in one + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + +
+ Skip to content + + + + + + + + + + + + + + + + +
+
+ + + + + + + + + + +
+ + +
+
+ + + + +
+ +
+
+
+

+ Global navigation +

+
+
+
+
+ +
+
+
+ +
+ + +
+ + + + +
+
+
+ + +

© 2024 GitHub, Inc.

+ +
+
+
+ +
+ +
+
+ + + +
+
+ + +
+ +
+
+
+

+ Navigate back to +

+
+
+ +
+
+
+ + + + +
+
+ +
+ +
+
+ +
+
+ + +
+ + + Create new... + + + + + + + + +Issues + + +Pull requests + +
+ + + + + + + Notifications + + + + +
+ + + + + +
+ +
+
+
+

+ Account menu +

+
+
+ + + neekolas + + + + Nicholas Molnar + +
+ + + Create new... + + + + + + +
+
+
+
+ +
+
+
+ +
+ + + +
+
+
+ +
+
+ +
+
+ +
+ +
+ + + + + +
+
+
+ + + +
+ + + + +
+ +
+ + + + + + + + +
+ + + + + + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + +
+ +
+ + + + oven-sh  /   + bun  /   + +
+
+ + + +
+ + +
+
+ Clear Command Palette +
+
+ + + +
+
+ Tip: + Type # to search pull requests +
+
+ Type ? for help and tips +
+
+
+ +
+
+ Tip: + Type # to search issues +
+
+ Type ? for help and tips +
+
+
+ +
+
+ Tip: + Type # to search discussions +
+
+ Type ? for help and tips +
+
+
+ +
+
+ Tip: + Type ! to search projects +
+
+ Type ? for help and tips +
+
+
+ +
+
+ Tip: + Type @ to search teams +
+
+ Type ? for help and tips +
+
+
+ +
+
+ Tip: + Type @ to search people and organizations +
+
+ Type ? for help and tips +
+
+
+ +
+
+ Tip: + Type > to activate command mode +
+
+ Type ? for help and tips +
+
+
+ +
+
+ Tip: + Go to your accessibility settings to change your keyboard shortcuts +
+
+ Type ? for help and tips +
+
+
+ +
+
+ Tip: + Type author:@me to search your content +
+
+ Type ? for help and tips +
+
+
+ +
+
+ Tip: + Type is:pr to filter to pull requests +
+
+ Type ? for help and tips +
+
+
+ +
+
+ Tip: + Type is:issue to filter to issues +
+
+ Type ? for help and tips +
+
+
+ +
+
+ Tip: + Type is:project to filter to projects +
+
+ Type ? for help and tips +
+
+
+ +
+
+ Tip: + Type is:open to filter to open content +
+
+ Type ? for help and tips +
+
+
+ +
+ +
+
+ We’ve encountered an error and some results aren't available at this time. Type a new search or try again later. +
+
+ + No results matched your search + + + + + + + + + + +
+ + + + + Search for issues and pull requests + + # + + + + Search for issues, pull requests, discussions, and projects + + # + + + + Search for organizations, repositories, and users + + @ + + + + Search for projects + + ! + + + + Search for files + + / + + + + Activate command mode + + > + + + + Search your issues, pull requests, and discussions + + # author:@me + + + + Search your issues, pull requests, and discussions + + # author:@me + + + + Filter to pull requests + + # is:pr + + + + Filter to issues + + # is:issue + + + + Filter to discussions + + # is:discussion + + + + Filter to projects + + # is:project + + + + Filter to open issues, pull requests, and discussions + + # is:open + + + + + + + + + + + + + + + + +
+
+
+ +
+ + + + + + + + + + +
+ + +
+
+
+ + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ Owner avatar + + + + bun + + + Public +
+ +
+
+ + +
+ +
+
    + + + +
  • + +
    + + + +
    + +
    + +
  • + +
  • +
    +
    + Fork + 2.3k + Fork your own copy of oven-sh/bun +
    +
    + + + +
    + +
    +
    + + + + + + + +
    + +
    +
    +
    +
    +
  • + +
  • + + +
    +
    +
    + + +
    + + + +
    +
    +

    Lists

    + + +
    +
    +
    + + + + + + + +
    + +
    +
    +
    +
    +
    +
    +
    + +
    + + + +
    +
    +

    Lists

    + + +
    +
    +
    + + + + + + + +
    + +
    +
    +
    +
    +
    +
  • + +
  • + + +
  • +
+ +
+
+ +
+
+
+
+
+ +
+ + + + + + + + + + + + +
+
+ +

+ Notifications +

+
+ + +
+ + +
+ +
+ Notification settings +
+ + + +
+ +Fork your own copy of oven-sh/bun + +
+
+
+ + Unstar this repository + +
+
+ + Star this repository + +
+
+
+ + +
+
+

+ Incredibly fast JavaScript runtime, bundler, test runner, and package manager – all in one +

+ + + + +
+

Code of conduct

+ + +

Security policy

+ + + + +
+
+ +
+ + Public repository + +
+ +
+ +
+ +
+ +
+
+ +
+ + + + + +
+ Open in github.dev + Open in a new github.dev tab + Open in codespace + + + + + +

oven-sh/bun

+
+
+ + + + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + +
+
+ +
+
+
+
+

About

+ +

+ Incredibly fast JavaScript runtime, bundler, test runner, and package manager – all in one +

+
+ + + bun.sh + +
+ +

Topics

+ + +

Resources

+ + + + +

Code of conduct

+ + +

Security policy

+ + + + + + + + + +

Stars

+ + +

Watchers

+ + +

Forks

+ + + +
+ +
+
+ + + + + + +
+
+

+ + Packages + 2 +

+ + + +
+
+
 
+
+
+
+
 
+
+
+ + +
+
+ + + + + +
+
+

+ + Contributors + 533 +

+ + + + + + + + + +
+
+ + + +
+
+

Languages

+
+ + + + + + + + +
+ + +
+
+ +
+
+ +
+ +
+ + +
+ +
+ + +
+
+ +
+ +
+

Footer

+ + + + +
+
+ + + + + © 2024 GitHub, Inc. + +
+ + +
+
+ + + + + + + + + + + + + + + + + + + + +
+ +
+
+ + + diff --git a/src/test/fixtures/ogp.html b/src/test/fixtures/ogp.html new file mode 100644 index 0000000..8971e66 --- /dev/null +++ b/src/test/fixtures/ogp.html @@ -0,0 +1,403 @@ + + + + + + The Open Graph protocol + + + + + + + + + + + + + + + + + +
+ +
+

Introduction

+

The Open Graph protocol enables any web page to become a +rich object in a social graph. For instance, this is used on Facebook to allow +any web page to have the same functionality as any other object on Facebook.

+

While many different technologies and schemas exist and could be combined +together, there isn't a single technology which provides enough information to +richly represent any web page within the social graph. The Open Graph protocol +builds on these existing technologies and gives developers one thing to +implement. Developer simplicity is a key goal of the Open Graph protocol which +has informed many of the technical design decisions. +

+
+

Basic Metadata

+

To turn your web pages into graph objects, you need to add basic metadata to +your page. We've based the initial version of the protocol on +RDFa which means that you'll place +additional <meta> tags in the <head> of your web page. The four required +properties for every page are:

+
    +
  • og:title - The title of your object as it should appear within the graph, +e.g., "The Rock".
  • +
  • og:type - The type of your object, e.g., "video.movie". Depending on +the type you specify, other properties may also be required.
  • +
  • og:image - An image URL which should represent your object within the +graph.
  • +
  • og:url - The canonical URL of your object that will be used as its +permanent ID in the graph, e.g., "https://www.imdb.com/title/tt0117500/".
  • +
+

As an example, the following is the Open Graph protocol markup for The Rock on +IMDB:

+
<html prefix="og: https://ogp.me/ns#">
+<head>
+<title>The Rock (1996)</title>
+<meta property="og:title" content="The Rock" />
+<meta property="og:type" content="video.movie" />
+<meta property="og:url" content="https://www.imdb.com/title/tt0117500/" />
+<meta property="og:image" content="https://ia.media-imdb.com/images/rock.jpg" />
+...
+</head>
+...
+</html>
+
+

Optional Metadata

+

The following properties are optional for any object and are generally +recommended:

+
    +
  • og:audio - A URL to an audio file to accompany this object.
  • +
  • og:description - A one to two sentence description of your object.
  • +
  • og:determiner - The word that appears before this object's title +in a sentence. An enum of (a, an, the, "", auto). If auto is +chosen, the consumer of your data should chose between "a" or "an". +Default is "" (blank).
  • +
  • og:locale - The locale these tags are marked up in. +Of the format language_TERRITORY. Default is en_US.
  • +
  • og:locale:alternate - An array of other locales this page is +available in.
  • +
  • og:site_name - If your object is part of a larger web site, the name which +should be displayed for the overall site. e.g., "IMDb".
  • +
  • og:video - A URL to a video file that complements this object.
  • +
+

For example (line-break solely for display purposes):

+
<meta property="og:audio" content="https://example.com/bond/theme.mp3" />
+<meta property="og:description" 
+  content="Sean Connery found fame and fortune as the
+           suave, sophisticated British agent, James Bond." />
+<meta property="og:determiner" content="the" />
+<meta property="og:locale" content="en_GB" />
+<meta property="og:locale:alternate" content="fr_FR" />
+<meta property="og:locale:alternate" content="es_ES" />
+<meta property="og:site_name" content="IMDb" />
+<meta property="og:video" content="https://example.com/bond/trailer.swf" />
+
+

The RDF schema (in Turtle) +can be found at ogp.me/ns.

+
+

Structured Properties

+

Some properties can have extra metadata attached to them. +These are specified in the same way as other metadata with property and +content, but the property will have extra :.

+

The og:image property has some optional structured properties:

+
    +
  • og:image:url - Identical to og:image.
  • +
  • og:image:secure_url - An alternate url to use if the webpage requires +HTTPS.
  • +
  • og:image:type - A MIME type for this image.
  • +
  • og:image:width - The number of pixels wide.
  • +
  • og:image:height - The number of pixels high.
  • +
  • og:image:alt - A description of what is in the image (not a caption). If the page specifies an og:image it should specify og:image:alt.
  • +
+

A full image example:

+
<meta property="og:image" content="https://example.com/ogp.jpg" />
+<meta property="og:image:secure_url" content="https://secure.example.com/ogp.jpg" />
+<meta property="og:image:type" content="image/jpeg" />
+<meta property="og:image:width" content="400" />
+<meta property="og:image:height" content="300" />
+<meta property="og:image:alt" content="A shiny red apple with a bite taken out" />
+
+

The og:video tag has the identical tags as og:image. Here is an example:

+
<meta property="og:video" content="https://example.com/movie.swf" />
+<meta property="og:video:secure_url" content="https://secure.example.com/movie.swf" />
+<meta property="og:video:type" content="application/x-shockwave-flash" />
+<meta property="og:video:width" content="400" />
+<meta property="og:video:height" content="300" />
+
+

The og:audio tag only has the first 3 properties available +(since size doesn't make sense for sound):

+
<meta property="og:audio" content="https://example.com/sound.mp3" />
+<meta property="og:audio:secure_url" content="https://secure.example.com/sound.mp3" />
+<meta property="og:audio:type" content="audio/mpeg" />
+
+
+

Arrays

+

If a tag can have multiple values, just put multiple versions of the same +<meta> tag on your page. The first tag (from top to bottom) is given +preference during conflicts.

+
<meta property="og:image" content="https://example.com/rock.jpg" />
+<meta property="og:image" content="https://example.com/rock2.jpg" />
+
+

Put structured properties after you declare their root tag. Whenever +another root element is parsed, that structured property +is considered to be done and another one is started.

+

For example:

+
<meta property="og:image" content="https://example.com/rock.jpg" />
+<meta property="og:image:width" content="300" />
+<meta property="og:image:height" content="300" />
+<meta property="og:image" content="https://example.com/rock2.jpg" />
+<meta property="og:image" content="https://example.com/rock3.jpg" />
+<meta property="og:image:height" content="1000" />
+
+

means there are 3 images on this page, the first image is 300x300, the middle +one has unspecified dimensions, and the last one is 1000px tall.

+
+

Object Types

+

In order for your object to be represented within the graph, you need to +specify its type. This is done using the og:type property:

+
<meta property="og:type" content="website" />
+
+

When the community agrees on the schema for a type, it is added to the list +of global types. All other objects in the type system are +CURIEs of the form

+
<head prefix="my_namespace: https://example.com/ns#">
+<meta property="og:type" content="my_namespace:my_type" />
+
+

The global types are grouped into verticals. Each vertical has its +own namespace. The og:type values for a namespace are always prefixed with +the namespace and then a period. +This is to reduce confusion with user-defined namespaced types which always +have colons in them.

+

Music

+ +

og:type values:

+

music.song

+
    +
  • music:duration - integer >=1 - The song's length in seconds.
  • +
  • music:album - music.album array - +The album this song is from.
  • +
  • music:album:disc - integer >=1 - +Which disc of the album this song is on.
  • +
  • music:album:track - integer >=1 - +Which track this song is.
  • +
  • music:musician - profile array - +The musician that made this song.
  • +
+

music.album

+
    +
  • music:song - music.song - The song on this album.
  • +
  • music:song:disc - integer >=1 - +The same as music:album:disc but in reverse.
  • +
  • music:song:track - integer >=1 - +The same as music:album:track but in reverse.
  • +
  • music:musician - profile - +The musician that made this song.
  • +
  • music:release_date - datetime - +The date the album was released.
  • +
+

music.playlist

+
    +
  • music:song - Identical to the ones on music.album
  • +
  • music:song:disc
  • +
  • music:song:track
  • +
  • music:creator - profile - The creator of this playlist.
  • +
+

music.radio_station

+
    +
  • music:creator - profile - The creator of this station.
  • +
+

Video

+ +

og:type values:

+

video.movie

+
    +
  • video:actor - profile array - +Actors in the movie.
  • +
  • video:actor:role - string - The role they played.
  • +
  • video:director - profile array - +Directors of the movie.
  • +
  • video:writer - profile array - +Writers of the movie.
  • +
  • video:duration - integer >=1 - +The movie's length in seconds.
  • +
  • video:release_date - datetime - +The date the movie was released.
  • +
  • video:tag - string array - +Tag words associated with this movie.
  • +
+

video.episode

+
    +
  • video:actor - Identical to video.movie
  • +
  • video:actor:role
  • +
  • video:director
  • +
  • video:writer
  • +
  • video:duration
  • +
  • video:release_date
  • +
  • video:tag
  • +
  • video:series - video.tv_show - +Which series this episode belongs to.
  • +
+

video.tv_show

+

A multi-episode TV show. +The metadata is identical to video.movie.

+

video.other

+

A video that doesn't belong in any other category. +The metadata is identical to video.movie.

+

No Vertical

+

These are globally defined objects that just don't fit into a vertical but +yet are broadly used and agreed upon.

+

og:type values:

+

article - Namespace URI: https://ogp.me/ns/article#

+
    +
  • article:published_time - datetime - +When the article was first published.
  • +
  • article:modified_time - datetime - +When the article was last changed.
  • +
  • article:expiration_time - datetime - +When the article is out of date after.
  • +
  • article:author - profile array - +Writers of the article.
  • +
  • article:section - string - A high-level section name. E.g. Technology
  • +
  • article:tag - string array - +Tag words associated with this article.
  • +
+

book - Namespace URI: https://ogp.me/ns/book#

+
    +
  • book:author - profile array - Who wrote this book.
  • +
  • book:isbn - string - +The ISBN
  • +
  • book:release_date - datetime - The date the book was released.
  • +
  • book:tag - string array - +Tag words associated with this book.
  • +
+

profile - Namespace URI: https://ogp.me/ns/profile#

+
    +
  • profile:first_name - string - A name normally given to an individual by a parent or self-chosen.
  • +
  • profile:last_name - string - A name inherited from a family or marriage and by which the individual is commonly known.
  • +
  • profile:username - string - A short unique string to identify them.
  • +
  • profile:gender - enum(male, female) - Their gender.
  • +
+

website - Namespace URI: https://ogp.me/ns/website#

+

No additional properties other than the basic ones. +Any non-marked up webpage should be treated as og:type website.

+
+

Types

+

The following types are used when defining attributes in Open Graph protocol.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
TypeDescriptionLiterals
BooleanA Boolean represents a true or false valuetrue, false, 1, 0
DateTimeA DateTime represents a temporal value composed of a date + (year, month, day) and an optional time component (hours, minutes)ISO 8601
EnumA type consisting of bounded set of constant string values + (enumeration members). + A string value that is a member of the enumeration
FloatA 64-bit signed floating point numberAll literals that conform to the following formats:

+1.234
+-1.234
+1.2e3
+-1.2e3
+7E-10
+
IntegerA 32-bit signed integer. In many languages integers over 32-bits become + floats, so we limit Open Graph protocol for easy multi-language use.All literals that conform to the following formats:

+1234
+-123
+
StringA sequence of Unicode charactersAll literals composed of Unicode characters with no escape characters
URLA sequence of Unicode characters that identify an Internet resource. + All valid URLs that utilize the https:// or https:// protocols
+
+

Discussion and support

+

You can discuss the Open Graph Protocol in +the Facebook group or on +the developer mailing list. +It is currently being consumed by Facebook (see their documentation), +Google (see their documentation), and +mixi. +It is being published by IMDb, Microsoft, NHL, Posterous, Rotten Tomatoes, +TIME, Yelp, and many many others.

+
+

Implementations

+

The open source community has developed a number of parsers and publishing +tools. Let the Facebook group know if you've built something awesome too!

+ +
+ +
+ + + diff --git a/src/test/fixtures/ogp.png b/src/test/fixtures/ogp.png new file mode 100644 index 0000000000000000000000000000000000000000..c4c1fecef2df876b66aac33475add9455af07ccb GIT binary patch literal 12379 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 literal 0 HcmV?d00001 diff --git a/src/test/handlers.test.ts b/src/test/handlers.test.ts new file mode 100644 index 0000000..9881f54 --- /dev/null +++ b/src/test/handlers.test.ts @@ -0,0 +1,115 @@ +import { beforeAll, afterAll, describe, expect, test } from "bun:test"; +import { findRedirect } from "../handlers"; +import type { Server } from "bun"; +import { NoRedirectError } from "../errors"; +import { start } from ".."; + +describe("postRedirect", () => { + const PORT = 7777; + const REDIRECT_DESTINATION = "https://example.com"; + + let server: Server; + beforeAll(() => { + server = Bun.serve({ + port: PORT, + fetch(req: Request) { + const path = new URL(req.url).pathname; + const statusCode = parseInt(path.split("/")[1]); + if (statusCode === 200) { + return new Response("ok"); + } + + return new Response("redirected", { + status: statusCode, + headers: { + location: REDIRECT_DESTINATION, + }, + }); + }, + }); + }); + + afterAll(() => { + server?.stop(); + }); + + test("should handle 302 correctly", async () => { + const url = `http://localhost:${PORT}/302`; + const data = await findRedirect(url); + expect(data).toEqual({ + originalUrl: url, + redirectedTo: REDIRECT_DESTINATION, + }); + }); + + test("should error on 301", async () => { + const url = `http://localhost:${PORT}/301`; + expect(findRedirect(url)).rejects.toThrow(new NoRedirectError()); + }); + + test("should error on 200", async () => { + const url = `http://localhost:${PORT}/200`; + expect(findRedirect(url)).rejects.toThrow(new NoRedirectError()); + }); +}); + +describe("media", () => { + const MEDIA_PORT = 7778; + const PROXY_PORT = 7779; + + let mediaServer: Server; + let proxyServer: Server; + + beforeAll(() => { + mediaServer = Bun.serve({ + port: MEDIA_PORT, + fetch(req: Request) { + const url = new URL(req.url); + const path = url.pathname.split("/")[1]; + const contentType = url.searchParams.get("content-type"); + console.log( + `Got a request for ${req.url} with content type ${contentType}` + ); + const file = Bun.file(`src/test/fixtures/${path}`); + + return new Response(file, { + status: 200, + headers: { + "content-type": contentType || "image/png", + }, + }); + }, + }); + + proxyServer = start(PROXY_PORT); + }); + + afterAll(() => { + mediaServer?.stop(); + proxyServer.stop(); + }); + + test("should stream image", async () => { + const mediaUrl = `http://localhost:${MEDIA_PORT}/ogp.png?content-type=image/png`; + const url = `http://localhost:${PROXY_PORT}/media?url=${encodeURIComponent( + mediaUrl + )}`; + const response = await fetch(url); + expect(response.headers.get("content-type")).toEqual("image/png"); + const blob = await response.arrayBuffer(); + const actualFile = await Bun.file( + `src/test/fixtures/ogp.png` + ).arrayBuffer(); + expect(blob).toEqual(actualFile); + }); + + test("should respect content type", async () => { + const mediaUrl = `http://localhost:${MEDIA_PORT}/ogp.png?content-type=image/jpeg`; + const url = `http://localhost:${PROXY_PORT}/media?url=${encodeURIComponent( + mediaUrl + )}`; + const response = await fetch(url); + // Use the content type from the media server, not just the file name + expect(response.headers.get("content-type")).toEqual("image/jpeg"); + }); +}); diff --git a/src/test/parser.test.ts b/src/test/parser.test.ts new file mode 100644 index 0000000..abf85ce --- /dev/null +++ b/src/test/parser.test.ts @@ -0,0 +1,64 @@ +import { beforeAll, afterAll, describe, expect, test } from "bun:test"; +import { downloadAndExtract } from "../handlers"; +import type { Server } from "bun"; + +async function serveHtml(filePath: string, port: number = 3000) { + const html = await Bun.file(filePath).text(); + return Bun.serve({ + port, + fetch(req) { + return new Response(html, { + headers: { + "content-type": "text/html", + }, + }); + }, + }); +} + +const testCases = [ + { + name: "github", + expectedTags: { + "og:title": + "oven-sh/bun: Incredibly fast JavaScript runtime, bundler, test runner, and package manager – all in one", + "og:image": + "https://opengraph.githubassets.com/14c49397fbfdc07e07d589d265396ddb65eda364617f14d1976937a842bb0983/oven-sh/bun", + "og:image:alt": + "Incredibly fast JavaScript runtime, bundler, test runner, and package manager – all in one - oven-sh/bun: Incredibly fast JavaScript runtime, bundler, test runner, and package manager – all in one", + "og:site_name": "Github", + }, + }, + { + name: "ogp", + expectedTags: { + "og:title": "Open Graph protocol", + "og:type": "website", + "og:url": "https://ogp.me/", + "og:image": "https://ogp.me/logo.png", + }, + }, +]; + +let port = 6000; +for (const testCase of testCases) { + port++; + describe(testCase.name, () => { + let server: Server | undefined; + + beforeAll(async () => { + server = await serveHtml(`src/test/fixtures/${testCase.name}.html`, port); + }); + + afterAll(() => { + server?.stop(); + }); + + test("can extract tags", async () => { + const data = await downloadAndExtract(`http://localhost:${server!.port}`); + expect(data).toMatchObject( + expect.objectContaining(testCase.expectedTags) + ); + }); + }); +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..aeda4cf --- /dev/null +++ b/src/types.ts @@ -0,0 +1,9 @@ +export type GetMetadataResponse = { + url: string; + metaTags: { [k: string]: string }; +}; + +export type PostRedirectResponse = { + originalUrl: string; + redirectedTo: string; +}; diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..9a5325e --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,26 @@ +import { InvalidRequestError, NoRedirectError } from "./errors"; + +export function getUrl(req: Request) { + const url = new URL(req.url).searchParams.get("url"); + if (!url) { + throw new InvalidRequestError(); + } + return url; +} + +export function getRequestPath(req: Request): string { + return new URL(req.url).pathname; +} + +export function handleError(e: Error) { + console.error(e); + if (e instanceof InvalidRequestError) { + return new Response("Missing url query param", { status: 400 }); + } + + if (e instanceof NoRedirectError) { + return new Response("No redirect found", { status: 404 }); + } + + return new Response("Internal server error", { status: 500 }); +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..dcd8fc5 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "lib": ["ESNext"], + "target": "ESNext", + "module": "ESNext", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + /* Linting */ + "skipLibCheck": true, + "strict": true, + "noFallthroughCasesInSwitch": true, + "forceConsistentCasingInFileNames": true + } +}