Aggregator
CVE-2024-12071 | Evergreen Content Poster Plugin up to 1.4.4 on WordPress authorization
CVE-2024-13503 | Newtec iDirect NTC2218/NTC2250/NTC2299 up to 2.2.6.19 Network Packet buffer overflow
CVE-2024-13502 | Newtec iDirect NTC2218/NTC2250/NTC2299 up to 2.2.6.19 Web Administration Interface os command injection
【已支持暴露面风险排查】Rsync缓冲区溢出与信息泄露漏洞(CVE-2024-12084/CVE-2024-12085)
CVE-2024-50967 | Becon DATAGerry up to 2.2.0 REST API Endpoint /rest/rights/ information disclosure
TikTok 在美最高法院败诉,准备周日关闭美国服务
美国最高法院裁定除非字节跳动出售TikTok 否则禁用的相关条款是符合规定的
Фальшивые учителя охотятся за кодами доступа к Госуслугам
Lazarus Group Targets Developers in New Data Theft Campaign
How Russian hackers went after NGOs’ WhatsApp accounts
Star Blizzard, a threat actor tied to the Russian Federal Security Service (FSB), was spotted attempting to compromise targets’ WhatsApp accounts through a clever phishing campaign. The campaign The campaign started with a spear-phishing email that was made to look like it was sent by a US government official. “We have established a private WhatsApp group to facilitate discussions regarding the latest non-govermental initiatives aimed at supporting Ukraine. This platform will also serve as a … More →
The post How Russian hackers went after NGOs’ WhatsApp accounts appeared first on Help Net Security.
Advanced Persistent Threat (APT): Examples and Prevention
Advanced persistent threats (APTs) use sophisticated tools and techniques to breach systems and maintain access—all while remaining undetected. Unlike other cyberattacks, APTs work over an extended period, using more resources to achieve specific objectives, such as stealing sensitive data or bringing down operations.
The post Advanced Persistent Threat (APT): Examples and Prevention appeared first on Security Boulevard.
Advanced Persistent Threat (APT): Examples and Prevention
科学家发现爱阅读的人的大脑与不爱阅读的人有差别
Seeking Advice on Starting a Side Business in Computer Forensics
memos 源码阅读笔记
一直想要有一个平台,能够发些碎碎念之类,记录一下在食堂吃到的新菜式,或者分享一下有意思的事情。如果在 QQ 空间动态发,未免有些扰民了;如果在 Telegram 发,因为网络问题不是很方便;在知识星球发,很不幸我的知识星球账号莫名其妙地被停用了。
之前刷推特时偶然发现了 memos 这个项目,定位是一个 Self-hosted 的笔记应用,但看页面很像是一个精简版的 Twitter。memos 的功能很简单,令我感到惊讶的是,它的 Repo 居然有 36000+ 的 Stars 数,确实厉害。
碰巧 memos 也是用 Go 写,功能又这么简单,我便抽空阅读了下它的源码,也还算是小有收获,用这篇文章分享下我的心得体会。文中提到的内容可能你很早以前就知道了,还请多多包涵。
本文使用 commit edc3f1d 的代码进行演示。
语义化版本语义化版本(Semantic Versioning)在 Go 里面应该是用得很多了。几年前参加 GopherChina 的时候,就有人专门分享了这个。
memos 在 server/version/version.go 下记录了当前的版本号,并为使用 golang.org/x/mod/semver 实现了排序逻辑。值得注意的是,这里的版本号会被用于在数据库迁移(migration)中。每一个版本的数据库迁移 SQL 文件会被放置在以版本号命名的文件夹中,当执行数据库迁移时,会将这些版本号文件名进行排序,并与当前的版本号进行对比,从而选择要执行的迁移脚本。
打死都不用 ORMmemos 支持 MySQL、Postgres、SQLite 三种数据库。遇到这种需要支持多种数据库的场景,我们往往会使用 ORM,就算对 ORM 存在的副作用不信任,也会选择 SQL 查询构造器(SQL Query Builder)的库来辅助我们构造 SQL。但 memos 不知道在坚持什么,硬生生地对着三套数据库后端写了三套代码!他甚至只用 database/sql 和对应数据库的 Driver!他甚至手写 SQL!他甚至还各种拼 SQL 查询条件的字段!
各位可以体会下 store/db/mysql/activity.go#L23-L27
fields := []string{"`creator_id`", "`type`", "`level`", "`payload`"} placeholder := []string{"?", "?", "?", "?"} args := []any{create.CreatorID, create.Type.String(), create.Level.String(), payloadString} stmt := "INSERT INTO `activity` (" + strings.Join(fields, ", ") + ") VALUES (" + strings.Join(placeholder, ", ") + ")"这段 INSERT 真就硬生生地拼字段,硬生生的写死预编译占位符。
当然,有人提了 issue 问为什么不用 ORM,并且推荐了 sqlc 和 sqlbuilders 两个库。作者的回复是前者 looks a little weird (?),后者 pretty much the same as the existing way,综上所属作者认为保持现状啥也不改!😅
FYI:https://github.com/usememos/memos/issues/2517
玩出花的 gRPCmemos 项目中对 gRPC 的写法可谓是教科书级别的。我也算是对着它的代码入门了下 gRPC。说来惭愧,我以前除了拿 Protobuf 写过 Hello World 的 demo,就没有更深入的应用了。
BufBuf 是一个用来辅助使用 Protobuf 的工具。它相当于为 Protobuf 实现了“包管理”的功能,你可以使用 buf.yaml 来定义需要引用的第三方 Proto,还可以配置 Lint 之类的规则。运行 buf generate 后便会自动去帮我们完成运行 protoc-gen-go 等一切操作。memos 中就使用到了 Buf,可以在 proto/buf.yaml 找到。Buf 还会生成一个 buf.lock 文件,也就是包管理中常见的签名文件。
我们可以观察到 Buf 的 dep 依赖形如 buf.build/googleapis/googleapis 这样的 URL,访问便可跳转到 Buf Schema Registry 上对应 Package 的页面。
感觉用 Buf 来处理 Protobuf,操作简便,逼格一下就上去了,学到了。
目录结构memos 的 /proto 目录下,store 目录与数据库的表结构对应,为每张表对应的实例的 proto 定义。api/v1 目录中则是 service 的定义,这里则对应了 Web API 的路由。
service AuthService { // GetAuthStatus returns the current auth status of the user. rpc GetAuthStatus(GetAuthStatusRequest) returns (User) { option (google.api.http) = {post: "/api/v1/auth/status"}; } // SignIn signs in the user with the given username and password. rpc SignIn(SignInRequest) returns (User) { option (google.api.http) = {post: "/api/v1/auth/signin"}; } // SignInWithSSO signs in the user with the given SSO code. rpc SignInWithSSO(SignInWithSSORequest) returns (User) { option (google.api.http) = {post: "/api/v1/auth/signin/sso"}; } // SignUp signs up the user with the given username and password. rpc SignUp(SignUpRequest) returns (User) { option (google.api.http) = {post: "/api/v1/auth/signup"}; } // SignOut signs out the user. rpc SignOut(SignOutRequest) returns (google.protobuf.Empty) { option (google.api.http) = {post: "/api/v1/auth/signout"}; } }例如上述代码,service 中的每个 rpc 可以看作与一个 API 相对应。
例如 GetAuthStatusRequest 这些是在下面定义的 message ,相当于是接口的入参表单,returns 指定了返回值。没有返回值的接口则使用了 google.protobuf.Empty 。
option 指定了 HTTP 下的请求路由和请求方法。
对于动态路由,感觉会有些复杂:
rpc GetMemo(GetMemoRequest) returns (Memo) { option (google.api.http) = {get: "/api/v1/{name=memos/*}"}; option (google.api.method_signature) = "name"; } rpc UpdateMemo(UpdateMemoRequest) returns (Memo) { option (google.api.http) = { patch: "/api/v1/{memo.name=memos/*}" body: "memo" }; option (google.api.method_signature) = "memo,update_mask"; }第一个 GetMemo 中,限制了路由的必须要匹配到 /api/v1/memos/* ,后面的 method_signature 指定了必须要传 name 参数。
第二个 UpdateMemo 中,限制了路由必须匹配 /api/v1/memos/* 。大括号里有个很怪的 memo.name=,因为 proto 里参数都是在 rpc 的入参传入的(即 UpdateMemoRequest ),只是我们在通过 HTTP API 访问时才有 Path、Header、Query、Body 这些传参的方式。因此在 rpc 的定义里,路由中通配符的值来自于 UpdateMemoRequest 中的 memo.name 。而后面的 method_signature 指定了 memo 和 update_mask 为必须要传的参数。
Service 的具体实现上,其实跟正常写 HTTP 接口差不多,Service 结构体实现对应 interface 里定义的方法即可。我注意到方法的错误处理,使用的是 google.golang.org/grpc/status 构造的 error,状态码也是 grpc 包里自带的。
func (s *APIV1Service) GetMemo(ctx context.Context, request *v1pb.GetMemoRequest) (*v1pb.Memo, error) { ... return nil, status.Errorf(codes.PermissionDenied, "permission denied") }codes 包里定义了 17 种状态码,我开始还怀疑就这么点状态码类型真的能给所有的错误分类吗?事实证明还真可以。像 RESTful API 里常常表示的 403 没权限、404 不存在、400 格式不对、5xx 服务寄了 等状态,都可以找到状态码进行对应。
var strToCode = map[string]Code{ `"OK"`: OK, `"CANCELLED"`:/* [sic] */ Canceled, `"UNKNOWN"`: Unknown, `"INVALID_ARGUMENT"`: InvalidArgument, `"DEADLINE_EXCEEDED"`: DeadlineExceeded, `"NOT_FOUND"`: NotFound, `"ALREADY_EXISTS"`: AlreadyExists, `"PERMISSION_DENIED"`: PermissionDenied, `"RESOURCE_EXHAUSTED"`: ResourceExhausted, `"FAILED_PRECONDITION"`: FailedPrecondition, `"ABORTED"`: Aborted, `"OUT_OF_RANGE"`: OutOfRange, `"UNIMPLEMENTED"`: Unimplemented, `"INTERNAL"`: Internal, `"UNAVAILABLE"`: Unavailable, `"DATA_LOSS"`: DataLoss, `"UNAUTHENTICATED"`: Unauthenticated, } gRPC Server 和 RESTful API Servermemos 的 server/server.go 文件定义了 HTTP 服务。它的 HTTP 服务使用 echo 框架。
重点看下面的代码:
grpcServer := grpc.NewServer( // Override the maximum receiving message size to math.MaxInt32 for uploading large resources. grpc.MaxRecvMsgSize(math.MaxInt32), grpc.ChainUnaryInterceptor( apiv1.NewLoggerInterceptor().LoggerInterceptor, grpcrecovery.UnaryServerInterceptor(), apiv1.NewGRPCAuthInterceptor(store, secret).AuthenticationInterceptor, )) s.grpcServer = grpcServer apiV1Service := apiv1.NewAPIV1Service(s.Secret, profile, store, grpcServer) // Register gRPC gateway as api v1. if err := apiV1Service.RegisterGateway(ctx, echoServer); err != nil { return nil, errors.Wrap(err, "failed to register gRPC gateway") }这里首先声明了一个 gRPC Server,并加了些常见的 Recover 中间件、Logger 拦截器、ACL 鉴权拦截器等。
后面的 NewAPIV1Service 创建每一块接口的 ServiceServer。跟进去可以看到,它会向上述定义的 gRPC Server 注册所支持的服务。这些注册服务的 v1pb.RegisterXXXServiceServer 就是用 proto 文件自动生成的了。
func NewAPIV1Service(secret string, profile *profile.Profile, store *store.Store, grpcServer *grpc.Server) *APIV1Service { grpc.EnableTracing = true apiv1Service := &APIV1Service{ Secret: secret, Profile: profile, Store: store, grpcServer: grpcServer, } v1pb.RegisterWorkspaceServiceServer(grpcServer, apiv1Service) v1pb.RegisterWorkspaceSettingServiceServer(grpcServer, apiv1Service) v1pb.RegisterAuthServiceServer(grpcServer, apiv1Service) v1pb.RegisterUserServiceServer(grpcServer, apiv1Service) v1pb.RegisterMemoServiceServer(grpcServer, apiv1Service) v1pb.RegisterResourceServiceServer(grpcServer, apiv1Service) v1pb.RegisterInboxServiceServer(grpcServer, apiv1Service) v1pb.RegisterActivityServiceServer(grpcServer, apiv1Service) v1pb.RegisterWebhookServiceServer(grpcServer, apiv1Service) v1pb.RegisterMarkdownServiceServer(grpcServer, apiv1Service) v1pb.RegisterIdentityProviderServiceServer(grpcServer, apiv1Service) reflection.Register(grpcServer) return apiv1Service }最后的 reflection.Register(grpcServer) 用于注册 gRPC 的反射功能,让客户端在运行时能动态获取 gRPC 服务的相关信息,如服务列表、方法列表、方法的输入输出参数类型等,而不需要事先知道服务的具体定义。
向 gRPC Server 注册完服务后,下面是将 Echo 框架启动的 HTTP Server 作为 Gateway,以实现通过 HTTP 的方式来访问 gRPC Service。(echoServer 就是 echo.New() 出来的实例)
// Register gRPC gateway as api v1. if err := apiV1Service.RegisterGateway(ctx, echoServer); err != nil { return nil, errors.Wrap(err, "failed to register gRPC gateway") }跟进去看定义。这里居然新建了一个 gRPC 的客户端!
runtime.NewServeMux() 是 grpc-gateway 下的包,用于返回一个 HTTP Mux,后续就可以交给任意的 Go HTTP 框架去调用。下面自动生成的 v1pb.RegisterXXXServiceHandler 这些路由 Handler,就是来自于上文 proto 文件里的 google.api.http 注解。
最后将这个 HTTP Mux 包起来交给 echo 框架的 handler,放在了 /api/v1/* 路由下。这样我们就实现了 RESTful 风格的 API。
// RegisterGateway registers the gRPC-Gateway with the given Echo instance. func (s *APIV1Service) RegisterGateway(ctx context.Context, echoServer *echo.Echo) error { conn, err := grpc.NewClient( fmt.Sprintf("%s:%d", s.Profile.Addr, s.Profile.Port), grpc.WithTransportCredentials(insecure.NewCredentials()), grpc.WithDefaultCallOptions(grpc.MaxCallRecvMsgSize(math.MaxInt32)), ) if err != nil { return err } gwMux := runtime.NewServeMux() if err := v1pb.RegisterWorkspaceServiceHandler(ctx, gwMux, conn); err != nil { return err } // ... if err := v1pb.RegisterIdentityProviderServiceHandler(ctx, gwMux, conn); err != nil { return err } gwGroup := echoServer.Group("") gwGroup.Use(middleware.CORS()) handler := echo.WrapHandler(gwMux) gwGroup.Any("/api/v1/*", handler) gwGroup.Any("/file/*", handler) // GRPC web proxy. options := []grpcweb.Option{ grpcweb.WithCorsForRegisteredEndpointsOnly(false), grpcweb.WithOriginFunc(func(_ string) bool { return true }), } wrappedGrpc := grpcweb.WrapServer(s.grpcServer, options...) echoServer.Any("/memos.api.v1.*", echo.WrapHandler(wrappedGrpc)) return nil }下面还声明了一个 gRPC Web Proxy,这个是用 HTTP 的方式来调 gRPC。使用的 grpcweb 包,调用接口传参并不是用的 Query 或者 Body,而是 protobuf 将参数序列化后再发送那套。跟走纯 TCP 相比,仅仅只是这里走的是 HTTP 请求而已。换句话说,就是让浏览器能跟 gRPC Server 通信了。
而浏览器中调用会有同源跨域的问题,所以可以看到这里的 grpcweb.Option 也是逐重解决 CORS 和 Origin。
希望看到这里你没被绕晕。你会发现,memos 其实是用 HTTP 实现了两套服务:RESTful API 和 gRPC Server API。这两套背后的业务逻辑都是一样的,且都是使用 HTTP 协议,不同点在于路由和传参的方式不一样。
端口复用有个比较抽象的小细节不知道你发现了没有,gRPC Server -> gRPC Server API 只需要用 grpcweb 包一下就行了,但 RESTful API 需要再本地建一个 gRPC Client,然后这个 Client 自己请求本地的 Server。整条链路是 HTTP Mux -> Handler Func -> gRPC Client -> gRPC Server。而这个 gRPC Client 监听的端口,居然与对外的 HTTP 服务的端口是一样的!
换句话说,就是 gRPC Server 和 echo HTTP Server 复用了同一个端口。
这里是使用了 github.com/soheilhy/cmux 这个库来实现。这个库支持定义 Matcher 条件,哪个匹配上了就走哪个的 Serve。
像 gRPC Server 在通过 HTTP 调用时,通过 Body 发送 Protobuf 报文,Content-Type 为 application/grpc;而 RESTful API 则是常规的 HTTP 请求,除了 PATCH 方法外都会命中。
muxServer := cmux.New(listener) go func() { grpcListener := muxServer.MatchWithWriters(cmux.HTTP2MatchHeaderFieldSendSettings("content-type", "application/grpc")) if err := s.grpcServer.Serve(grpcListener); err != nil { slog.Error("failed to serve gRPC", "error", err) } }() go func() { httpListener := muxServer.Match(cmux.HTTP1Fast(http.MethodPatch)) s.echoServer.Listener = httpListener if err := s.echoServer.Start(address); err != nil { slog.Error("failed to start echo server", "error", err) } }() go func() { if err := muxServer.Serve(); err != nil { slog.Error("mux server listen error", "error", err) } }()这里对 gRPC 的操作属实妙哉!端口复用的操作更是一绝。想起我之前有个 Side Project,既需要跑对外的 Web Server 后端,又需要跑对内的 API Server 后端,当时的做法是监听两个不同端口,现在想来可以用 cmux 来实现端口复用了。
梦开始的地方那么请问,上述这种教科书级别的 Protobuf 和 gRPC 的用法,是来自于哪里的呢?
我观察到 memos 的作者居然也给 Bytebase 提交过代码,好家伙,老熟人啊。同时,我在 Bytebase 的仓库里,找到了 #3751 这个 PR。(万恶之源)
在 2022 年 12 月(好像就是 DevJoy 结束后一个月),Bytebase 仓库引入了第一个 proto 文件。从此便一发不可收拾,原先的 Web API 全都变成了 gRPC Server 的写法,同时也开始使用 Buf 来管理 proto 文件。memos 的作者作为后面加入 Bytebase 的员工,也是将 Bytebase 对于 gRPC 的最佳实践,用在了他的 Side Project,也就是 memos 中。
我想大概是这么个故事情节吧。😁
定时任务memos 内部自行实现了三个很基础的定时任务。为什么说很基础呢,因为就是使用 time.NewTicker 来做的。每个定时任务的 Runner 都会实现 Run() 和 RunOnce() 两个方法,这里可能可以定义成一个接口?
func (r *Runner) Run(ctx context.Context) { ticker := time.NewTicker(runnerInterval) defer ticker.Stop() for { select { case <-ticker.C: r.RunOnce(ctx) case <-ctx.Done(): return } } }三个定时任务分别是 s3presign version memopreperty 。
-
s3presign 每 12 个小时遍历一波数据库中存储的上传到 S3 的资源,将临时 URL 有效期不到一天的资源,重新调用 S3 SDK 中的 PreSign 签一个五天的临时 URL。memos 在数据库中存储图片等资源的临时 URL,感觉是为了防止私有笔记中的资源 URL 泄露。使用 PreSign URL 后,即使将公开笔记转为私有,之前的链接在五天后也就过期了。
-
version 每 8 个小时请求 memos 自己的 API 获取当前 memos 的最新版本。判断版本落后并且数据库中之前还没有过版本更新提醒的话,就新增一条 Activity 记录,并将该 Activity 加到管理员账号的 Inbox 收件箱中。让管理员收到版本更新的消息。
其中 GetLatestVersion 获取最新版本的函数,解析请求体这里,感觉可以进一步精简成一行。
BEFORE
buf := &bytes.Buffer{} _, err = buf.ReadFrom(response.Body) if err != nil { return "", errors.Wrap(err, "fail to read response body") } version := "" if err = json.Unmarshal(buf.Bytes(), &version); err != nil { return "", errors.Wrap(err, "fail to unmarshal get version response") }AFTER
json.NewDecoder(response.Body).Decode(&version) -
memopreperty 每 12 小时遍历一遍所有 Payload 为空的 memos 笔记,从它的内容中解析出 Tag、链接、代码块等属性,保存到 memos 的 Property 中。这个函数在创建、修改、更新 MemoTag 时都会调用。额外加到定时任务中出发,应该是为了兜底。
对于用户每一篇文本笔记,memos 都会使用 github.com/usememos/gomark 库来做结构化的解析。将文本内容解析成不同类型的 Go 结构体块,以实现将 Markdown 格式转纯文本、笔记 Tag 提取等功能。
这里简单拆解一下这个包的结构和原理,本质上又是把文本进行词法分析转换为 Tokens,构建 AST 抽象语法树,然后通过遍历 AST 实现上述提到的功能。gomark 好就好在他功能简单但全面,很适合像我这种从没学过编译原理的菜鸡。
parser/tokenizer/tokenizers.go 中定义了各种 Token 的类型,如下划线、星号、井号、空格、换行等,基本上就是在 Markdown 中含有语义成分的字符,都会作为一个 Token 类型。正文内容分为 Number 数字和 Text 文本两种 Token 类型。
Tokenize(text string) []*Token 函数就是很标准的传入 text 字符串,挨个字符 switch-case,然后转换为 Token 结构体添加到切片中。
var prevToken *Token if len(tokens) > 0 { prevToken = tokens[len(tokens)-1] } isNumber := c >= '0' && c <= '9' if prevToken != nil { if (prevToken.Type == Text && !isNumber) || (prevToken.Type == Number && isNumber) { prevToken.Value += string(c) continue } } if isNumber { tokens = append(tokens, NewToken(Number, string(c))) } else { tokens = append(tokens, NewToken(Text, string(c))) }对于不在上述 Markdown 语义中的字符,则判断是否为数字 0-9,如果是的话说明是一个 Number 数字 Token,同时还需要看下上一个 Token 是不是也是数字,如果是的话他俩就是挨一起的,共同组成了一个 Number Token。Text 文本 Token 也是一样的逻辑,将挨着的文本字符统一为一个 Text Token。
Token 拆分完后,就开始构建 AST 了。
ast 目录下有 inline.go 和 block.go 两个文件。前者定义了单个节点类型,如普通的文本节点、加粗、斜体、链接、井号标签等;后者定义了多个普通节点组成的集合节点,如段落、代码块、标题、有序无需列表、复选框等。
parser/parser.go 里定义的 ParseXXX 函数将第一步的 []*tokenizer.Token 解析成 []ast.Node 。
nodes := []ast.Node{} for len(tokens) > 0 { for _, blockParser := range blockParsers { node, size := blockParser.Match(tokens) if node != nil && size != 0 { // Consume matched tokens. tokens = tokens[size:] nodes = append(nodes, node) break } } }本质上也还是将 Tokens 丢给所有的 BlockParser 在 for 循环里过一遍, BlockParser 接口实现 Match() 方法,不同的 Node 会一次性读取不同数量的 Tokens,判断格式是否满足 Node 的要求,来确定这些 Tokens 是否组成了这个 Node。Match 上了则会返回生成的 Node 和匹配上的 Tokens 长度,截去这个 Node 匹配的 Tokens,剩下的 Tokens 继续轮一遍所有的 BlockParser。
var defaultInlineParsers = []InlineParser{ NewEscapingCharacterParser(), NewHTMLElementParser(), NewBoldItalicParser(), NewImageParser(), ... NewReferencedContentParser(), NewTagParser(), NewStrikethroughParser(), NewLineBreakParser(), NewTextParser(), }值得注意的是,这些 BlockParser 的顺序应该是有讲究的。像最普通的、最容易匹配上的 Text 纯文本类型,应该放在最后。当前面所有的 Parser 都没匹配上时,才说明这个 Token 是文本类型的 Node。如果把 TextParser 放最前面,那估计所有的 Tokens 都会被匹配成文本 Node。
将 Tokens 转换为 AST 上的 Nodes 后,最后还有个 mergeListItemNodes 函数,是用来特殊处理 List 列表节点的。如在列表的最后加上换行符,判断列表项是要拆成两个列表节点还是添加到末尾。
renderer 目录则是遍历上述 AST 中的节点,来将 AST 转换成 HTML 或者 String 纯文本。这里就很简单了,不同的节点调不同的函数 WriteString 即可。
综上,gomark 就完成了将 Markdown 格式文本,解析转换成 HTML 或 String 纯文本的工作。
其它的小细节最后再说些自己发现的小细节吧,就不单独分一块了。
前端 embed index.html随着 Go Embed 功能加入后,我很喜欢将 Vue 编译后的前端打包进 Go Binary 中。往往是会在 web 或者 frontend 前端代码路径下,保留放编译产物的 dist 目录,在里面放个 gitkeep 文件啥的。
memos 的做法是放置了一个 frontend/dist/index.html 文件:
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Memos</title> </head> <body> No embeddable frontend found. </body> </html>直接在 Body 中写明了前端嵌入文件不存在。这样既可以通过编译,如若用户访问时,前端真没有被打包进来,在 index.html 也会有一个错误提示,比我只放一个不会被读到的 gitkeep 好些。
JWT Token 解析memos 使用 JWT Token 鉴权。因此需要解析通过 Authorization 头传进来的形如 Bearer xxxx 内容。问题是用户可能在 Bearer 和 Token 之间传入不定数量的空格,甚至在 Bearer 前或者 xxx 后也会有空格。
要是我的话,可能就先 strings.TrimSpace ,再 strings.Split 按空格分隔,然后再取判断长度,取第一个元素和最后一个元素,即为 Bearer 和 Token。memos 里直接使用了 strings.Fields 包来做到这一点,直接解决了上述可能存在的问题。后面要做的仅仅只有判断切片长度是否为 2 即可。
总结以上便是我之前阅读 memos 源码的一些心得体会。由于时间关系,我并没有很仔细的去阅读每一个文件的每一行代码,也没去审是否有潜在的安全漏洞。memos 的前端是使用 React 编写的,由于我平时不怎么写 React,所以前端这块也只是粗略的翻了翻。
memos 还是有很多可圈可点之处的,学到很多。貌似作者其它的开源项目也都有使用 memos 这种黑白动物风格的 Logo,相当于是一套统一的品牌。我对 AI 生成产品 Logo 这方面也挺感兴趣的,因为自己实在设计不来一个好看的 Logo…… 之后这块可以多研究下。