从谷歌那里偷东西
Stealing from Google

原始链接: https://taqib.dev/blog/stealing-from-google/

现代 Web 框架为了性能优化图片,但需要开发者显式允许外部域名以防止滥用。作者不喜欢需要信任像 Google 和 GitHub 这样的第三方图片服务器,更倾向于控制整个流程。 他们的解决方案是在身份验证期间“替换”用户头像。利用 Better Auth 的钩子,Next.js 服务器操作在用户创建时从 OAuth 提供商(Google 或 GitHub)下载头像。然后验证来源,将图片上传到 Cloudflare R2(一种经济高效的存储解决方案),并将 R2 托管的 URL 存储在用户的数据库资料中。 这确保所有图片都从作者自己的域名提供,简化了信任和安全性。该过程利用服务器操作、AWS SDK 用于 R2 交互以及验证,以确保仅处理批准的头像 URL,从而实现更安全、更可控的图片服务系统。

一位开发者在Hacker News分享了一个项目(“从谷歌那里偷取”),该项目通过自己的域名提供资源——特别是用户头像。 这篇文章引发了关于这种方法伦理和实用性的讨论。 评论强调了潜在问题,例如缓存(头像在谷歌端更改时不会自动更新),更重要的是,数据隐私。 一位用户指出,虽然用户同意谷歌存储他们的数据,但他们并未同意第三方这样做。 其他人建议关注不同的项目,并开玩笑地提到了GDPR问题。 有人建议创建一个带有缓存的自定义API端点来代理谷歌的图片,但用户同意的核心问题仍然没有解决。
相关文章

原文

Modern frameworks like Next.js and Astro come with their own <Image> component. It’s great — you get optimizations, fewer layout shifts, and better performance for free.

But there’s a catch: anyone can abuse your app to optimize their own images, which costs you compute.

That’s why these frameworks require you to explicitly allowlist remote domains.

In Next.js, that looks like:

import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  images: {
    remotePatterns: [
      {
        protocol: "https",
        hostname: "lh3.googleusercontent.com",
      },
      {
        protocol: "https",
        hostname: "images.marblecms.com",
      },
    ],
  },
};

export default nextConfig;

And in Astro:

export default defineConfig({
  image: {
    domains: ["images.marblecms.com", "avatars.githubusercontent.com"],
  },
});

So to display a user’s avatar from Google or GitHub, you’d normally need to allowlist their domains too.

But thats the part I didn’t like;

it felt wrong to ask users to trust Google and GitHub’s image servers, when they could simply just allow marble and be done.

The Idea

Instead of making users allow external domains, why not just take the avatar Google or GitHub gives me, upload it to my own bucket, and serve it from there?

That way, users only trust one domain: mine.

My Solution

I’m using Better Auth for authentication. which has a concept of hooks, which let you run code before or after certain events (like when a user is created).

That’s the perfect place to swipe the avatar.

Here’s the core of it — a Next.js server action that:

  1. Verifies the image is indeed from Google or Github

  2. Fetches the users image from the oAuth provider

  3. Uploads it to Cloudflare r2 (which serves images from our custom domain).

  4. Updates the users profile in the database with the url from cloudflare.

"use server";

import { PutObjectCommand } from "@aws-sdk/client-s3";
import { db } from "@marble/db";
import type { User } from "better-auth";
import { nanoid } from "nanoid";
import { isAllowedAvatarUrl } from "@/lib/constants";
import { R2_BUCKET_NAME, R2_PUBLIC_URL, r2 } from "@/lib/r2";

export async function storeUserImageAction(user: User) {
  if (!user.image) {
    return;
  }

  try {
    if (!isAllowedAvatarUrl(user.image)) {
      console.warn(`Avatar URL not from allowed host: ${user.image}`);
      return;
    }
    const response = await fetch(user.image);
    if (!response.ok) {
      throw new Error(`Failed to fetch image: ${response.statusText}`);
    }

    const contentType = response.headers.get("content-type") || "image/png";

    const arrayBuffer = await response.arrayBuffer();
    const buffer = Buffer.from(arrayBuffer);

    const extension = contentType.split("/")[1];
    const key = `avatars/${nanoid()}.${extension}`;

    await r2.send(
      new PutObjectCommand({
        Bucket: R2_BUCKET_NAME,
        Key: key,
        Body: buffer,
        ContentType: contentType,
        ContentLength: buffer.length,
      })
    );

    const avatarUrl = `${R2_PUBLIC_URL}/${key}`;

    await db.user.update({
      where: {
        id: user.id,
      },
      data: {
        image: avatarUrl,
      },
    });

    return { avatarUrl };
  } catch (error) {
    console.error("Failed to store user avatar:", error);
  }
}

So when a user signs up with Google or GitHub, their avatar gets downloaded, re-uploaded to R2, and served from my own domain.

The Result

And that’s how I swiped avatars from Google.

If you’re building an app with OAuth logins, try it — it's quite fun.

联系我们 contact @ memedata.com