From 6d32d9e881404c3fb258f97ef67e78cdd5b0288c Mon Sep 17 00:00:00 2001 From: movsb Date: Thu, 20 Jun 2024 03:26:08 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E5=86=99/=E6=8B=86=E5=88=86=20Gateway?= =?UTF-8?q?=20=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/client/main.go | 3 +- gateway/handlers/api/api.go | 40 ++++ gateway/handlers/assets/files.go | 11 +- .../handlers}/avatar/github.go | 0 .../handlers}/avatar/gravatar.go | 0 gateway/handlers/avatar/handler.go | 87 ++++++++ gateway/handlers/rss/rss.go | 13 +- .../handlers/webhooks/github}/github.go | 8 +- .../handlers/webhooks/github}/github_test.go | 2 +- gateway/handlers/webhooks/github/handler.go | 7 + gateway/handlers/webhooks/grafana/all_test.go | 1 + gateway/handlers/webhooks/grafana/grafana.go | 35 ++++ gateway/main.go | 194 +++++++----------- modules/auth/auth.go | 2 +- protocols/clients/client.go | 68 ++++-- protocols/comment.proto | 8 + protocols/service.proto | 8 + service/avatar.go | 129 ------------ service/comment.go | 13 +- service/main.go | 6 +- service/modules/avatar/main.go | 25 --- service/modules/cache/avatar_hash.go | 76 +++++++ theme/blog/styles/home.scss | 2 +- 23 files changed, 420 insertions(+), 318 deletions(-) create mode 100644 gateway/handlers/api/api.go rename {service/modules => gateway/handlers}/avatar/github.go (100%) rename {service/modules => gateway/handlers}/avatar/gravatar.go (100%) create mode 100644 gateway/handlers/avatar/handler.go rename {service/modules/webhooks => gateway/handlers/webhooks/github}/github.go (90%) rename {service/modules/webhooks => gateway/handlers/webhooks/github}/github_test.go (96%) create mode 100644 gateway/handlers/webhooks/github/handler.go create mode 100644 gateway/handlers/webhooks/grafana/all_test.go create mode 100644 gateway/handlers/webhooks/grafana/grafana.go delete mode 100644 service/avatar.go delete mode 100644 service/modules/avatar/main.go create mode 100644 service/modules/cache/avatar_hash.go diff --git a/cmd/client/main.go b/cmd/client/main.go index 874e9485..850103d2 100644 --- a/cmd/client/main.go +++ b/cmd/client/main.go @@ -1,7 +1,6 @@ package client import ( - "context" "fmt" "io/fs" "io/ioutil" @@ -177,7 +176,7 @@ func AddCommands(rootCmd *cobra.Command) { Args: cobra.NoArgs, PreRun: preRun, Run: func(cmd *cobra.Command, args []string) { - resp, err := client.Blog.Ping(context.Background(), &proto.PingRequest{}) + resp, err := client.Blog.Ping(client.Context(), &proto.PingRequest{}) if err != nil { panic(err) } diff --git a/gateway/handlers/api/api.go b/gateway/handlers/api/api.go new file mode 100644 index 00000000..77d677cc --- /dev/null +++ b/gateway/handlers/api/api.go @@ -0,0 +1,40 @@ +package api + +import ( + "context" + "net/http" + + "github.com/grpc-ecosystem/grpc-gateway/v2/runtime" + "github.com/movsb/taoblog/modules/utils" + "github.com/movsb/taoblog/protocols/clients" + "github.com/movsb/taoblog/protocols/go/proto" + "google.golang.org/protobuf/encoding/protojson" +) + +type _Protos struct { + mux *runtime.ServeMux + http.Handler +} + +func New(ctx context.Context, client clients.Client) http.Handler { + mux := runtime.NewServeMux( + runtime.WithMarshalerOption( + runtime.MIMEWildcard, + &runtime.JSONPb{ + MarshalOptions: protojson.MarshalOptions{ + UseProtoNames: true, + EmitUnpopulated: true, + }, + }, + ), + ) + + utils.Must(proto.RegisterUtilsHandlerClient(ctx, mux, client)) + utils.Must(proto.RegisterTaoBlogHandlerClient(ctx, mux, client)) + utils.Must(proto.RegisterSearchHandlerClient(ctx, mux, client)) + + return &_Protos{ + mux: mux, + Handler: mux, + } +} diff --git a/gateway/handlers/assets/files.go b/gateway/handlers/assets/files.go index e75b9fe6..421b65c0 100644 --- a/gateway/handlers/assets/files.go +++ b/gateway/handlers/assets/files.go @@ -5,12 +5,12 @@ import ( "net/http" "github.com/movsb/taoblog/modules/utils" + "github.com/movsb/taoblog/protocols/clients" "github.com/movsb/taoblog/protocols/go/proto" - "google.golang.org/grpc" "nhooyr.io/websocket" ) -func New(kind string, addr string) http.Handler { +func New(kind string, client clients.Client) http.Handler { if kind != `post` { panic(`only for post currently`) } @@ -27,13 +27,6 @@ func New(kind string, addr string) http.Handler { id := utils.MustToInt64(r.PathValue(`id`)) - conn, err := grpc.DialContext(r.Context(), addr, grpc.WithInsecure()) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - defer conn.Close() - client := proto.NewManagementClient(conn) fs, err := client.FileSystem(r.Context()) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) diff --git a/service/modules/avatar/github.go b/gateway/handlers/avatar/github.go similarity index 100% rename from service/modules/avatar/github.go rename to gateway/handlers/avatar/github.go diff --git a/service/modules/avatar/gravatar.go b/gateway/handlers/avatar/gravatar.go similarity index 100% rename from service/modules/avatar/gravatar.go rename to gateway/handlers/avatar/gravatar.go diff --git a/gateway/handlers/avatar/handler.go b/gateway/handlers/avatar/handler.go new file mode 100644 index 00000000..b006f0a7 --- /dev/null +++ b/gateway/handlers/avatar/handler.go @@ -0,0 +1,87 @@ +package avatar + +import ( + "context" + "io" + "net/http" + + "github.com/grpc-ecosystem/grpc-gateway/v2/runtime" + "github.com/movsb/taoblog/modules/utils" + "github.com/movsb/taoblog/protocols/go/proto" + "google.golang.org/grpc/status" +) + +func New(server proto.TaoBlogServer) http.Handler { + return &_Avatar{ + server: server, + } +} + +type _Avatar struct { + server proto.TaoBlogServer +} + +// Params ... +type Params struct { + Headers http.Header +} + +func (h *_Avatar) ServeHTTP(w http.ResponseWriter, r *http.Request) { + emailRsp, err := h.server.GetCommentEmailById( + r.Context(), + &proto.GetCommentEmailByIdRequest{ + Id: int32(utils.MustToInt64(r.PathValue(`id`))), + }, + ) + if err != nil { + http.Error(w, err.Error(), runtime.HTTPStatusFromCode(status.Code(err))) + return + } + + p := Params{ + Headers: make(http.Header), + } + + for _, name := range []string{ + `If-Modified-Since`, + `If-None-Match`, + } { + if h := r.Header.Get(name); h != "" { + p.Headers.Add(name, h) + } + } + + // TODO 并没有限制获取未公开发表文章的评论。 + rsp, err := github(context.TODO(), emailRsp.Email, &p) + if err != nil { + rsp, err = gravatar(context.TODO(), emailRsp.Email, &p) + } + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + defer rsp.Body.Close() + + // TODO:内部缓存,只正向代理 body。 + for _, k := range knownHeaders { + if v := rsp.Header.Get(k); v != "" { + w.Header().Set(k, v) + } + } + + // 客户端缓存失效了也可以继续用,后台慢慢刷新就行。 + w.Header().Set(`Cache-Control`, `max-age=604800, stale-while-revalidate=604800`) + + w.WriteHeader(rsp.StatusCode) + io.Copy(w, rsp.Body) +} + +// 不再提供以下字段,官方更新太频繁,意义不大。 +// `Expires`, +// `Cache-Control`, +var knownHeaders = []string{ + `Content-Length`, + `Content-Type`, + `Last-Modified`, +} diff --git a/gateway/handlers/rss/rss.go b/gateway/handlers/rss/rss.go index 9e4c7978..b57fc908 100644 --- a/gateway/handlers/rss/rss.go +++ b/gateway/handlers/rss/rss.go @@ -13,6 +13,7 @@ import ( "time" "github.com/movsb/taoblog/modules/utils" + "github.com/movsb/taoblog/protocols/clients" co "github.com/movsb/taoblog/protocols/go/handy/content_options" "github.com/movsb/taoblog/protocols/go/proto" ) @@ -44,8 +45,8 @@ type RSS struct { LastBuildDate Date - tmpl *template.Template - svc proto.TaoBlogServer + tmpl *template.Template + client clients.Client } type Option func(r *RSS) @@ -60,8 +61,8 @@ type _Config struct { articleCount int } -func New(svc proto.TaoBlogServer, options ...Option) http.Handler { - info := utils.Must1(svc.GetInfo(context.Background(), &proto.GetInfoRequest{})) +func New(client clients.Client, options ...Option) http.Handler { + info := utils.Must1(client.GetInfo(context.Background(), &proto.GetInfoRequest{})) r := &RSS{ config: _Config{ @@ -73,7 +74,7 @@ func New(svc proto.TaoBlogServer, options ...Option) http.Handler { Home: info.Home, LastBuildDate: Date(info.LastPostedAt), - svc: svc, + client: client, } for _, opt := range options { @@ -87,7 +88,7 @@ func New(svc proto.TaoBlogServer, options ...Option) http.Handler { // ServeHTTP ... func (r *RSS) ServeHTTP(w http.ResponseWriter, req *http.Request) { - rsp, err := r.svc.ListPosts(req.Context(), &proto.ListPostsRequest{ + rsp, err := r.client.ListPosts(req.Context(), &proto.ListPostsRequest{ Limit: int32(r.config.articleCount), OrderBy: `date desc`, Kinds: []string{`post`}, diff --git a/service/modules/webhooks/github.go b/gateway/handlers/webhooks/github/github.go similarity index 90% rename from service/modules/webhooks/github.go rename to gateway/handlers/webhooks/github/github.go index adbfa871..022119ea 100644 --- a/service/modules/webhooks/github.go +++ b/gateway/handlers/webhooks/github/github.go @@ -1,4 +1,4 @@ -package webhooks +package github import ( "crypto/hmac" @@ -47,8 +47,8 @@ func decode(r io.Reader, secret string, sum string) (*Payload, error) { return &p, nil } -func CreateHandler(secret string, reloaderPath string, sendNotify func(content string)) func(w http.ResponseWriter, r *http.Request) { - return func(w http.ResponseWriter, r *http.Request) { +func handler(secret string, reloaderPath string, sendNotify func(content string)) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { sum := strings.TrimPrefix(r.Header.Get(`X-Hub-Signature-256`), `sha256=`) payload, err := decode(r.Body, secret, sum) if err != nil { @@ -85,5 +85,5 @@ func CreateHandler(secret string, reloaderPath string, sendNotify func(content s sendNotify(fmt.Sprintf("结果未知:%s", w.Conclusion)) } } - } + }) } diff --git a/service/modules/webhooks/github_test.go b/gateway/handlers/webhooks/github/github_test.go similarity index 96% rename from service/modules/webhooks/github_test.go rename to gateway/handlers/webhooks/github/github_test.go index 482934c4..8ca18cdd 100644 --- a/service/modules/webhooks/github_test.go +++ b/gateway/handlers/webhooks/github/github_test.go @@ -1,4 +1,4 @@ -package webhooks +package github import "testing" diff --git a/gateway/handlers/webhooks/github/handler.go b/gateway/handlers/webhooks/github/handler.go new file mode 100644 index 00000000..1906f47e --- /dev/null +++ b/gateway/handlers/webhooks/github/handler.go @@ -0,0 +1,7 @@ +package github + +import "net/http" + +func New(secret, reloaderPath string, sendNotify func(content string)) http.Handler { + return handler(secret, reloaderPath, sendNotify) +} diff --git a/gateway/handlers/webhooks/grafana/all_test.go b/gateway/handlers/webhooks/grafana/all_test.go new file mode 100644 index 00000000..0b89c281 --- /dev/null +++ b/gateway/handlers/webhooks/grafana/all_test.go @@ -0,0 +1 @@ +package grafana_test diff --git a/gateway/handlers/webhooks/grafana/grafana.go b/gateway/handlers/webhooks/grafana/grafana.go new file mode 100644 index 00000000..a65f2df1 --- /dev/null +++ b/gateway/handlers/webhooks/grafana/grafana.go @@ -0,0 +1,35 @@ +package grafana + +import ( + "encoding/json" + "io" + "net/http" + + "github.com/grpc-ecosystem/grpc-gateway/v2/runtime" + "github.com/movsb/taoblog/modules/utils" + "github.com/movsb/taoblog/protocols/go/proto" + "google.golang.org/grpc/status" +) + +func New(client proto.UtilsServer) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + rc := http.MaxBytesReader(w, r.Body, 1<<20) + defer rc.Close() + body := utils.DropLast1(io.ReadAll(rc)) + var m map[string]any + json.Unmarshal(body, &m) + var message string + if x, ok := m[`message`]; ok { + message, _ = x.(string) + } + _, err := client.InstantNotify(r.Context(), &proto.InstantNotifyRequest{ + Title: `监控告警`, + // https://grafana.com/docs/grafana/latest/alerting/configure-notifications/manage-contact-points/integrations/webhook-notifier/ + Message: message, + }) + if err != nil { + http.Error(w, err.Error(), runtime.HTTPStatusFromCode(status.Code(err))) + return + } + }) +} diff --git a/gateway/main.go b/gateway/main.go index f06439bc..c23f86c7 100644 --- a/gateway/main.go +++ b/gateway/main.go @@ -2,34 +2,32 @@ package gateway import ( "context" - _ "embed" - "encoding/json" - "io" "net/http" "net/url" - "strconv" "time" - "github.com/grpc-ecosystem/grpc-gateway/v2/runtime" + _ "embed" + + "github.com/movsb/taoblog/gateway/handlers/api" "github.com/movsb/taoblog/gateway/handlers/apidoc" "github.com/movsb/taoblog/gateway/handlers/assets" + "github.com/movsb/taoblog/gateway/handlers/avatar" "github.com/movsb/taoblog/gateway/handlers/features" "github.com/movsb/taoblog/gateway/handlers/robots" "github.com/movsb/taoblog/gateway/handlers/rss" "github.com/movsb/taoblog/gateway/handlers/sitemap" + "github.com/movsb/taoblog/gateway/handlers/webhooks/github" + "github.com/movsb/taoblog/gateway/handlers/webhooks/grafana" "github.com/movsb/taoblog/modules/auth" "github.com/movsb/taoblog/modules/notify" "github.com/movsb/taoblog/modules/utils" "github.com/movsb/taoblog/modules/version" - "github.com/movsb/taoblog/protocols/go/handy" + "github.com/movsb/taoblog/protocols/clients" "github.com/movsb/taoblog/protocols/go/proto" "github.com/movsb/taoblog/service" - dynamic "github.com/movsb/taoblog/service/modules/renderers/_dynamic" - "github.com/movsb/taoblog/service/modules/webhooks" "github.com/movsb/taoblog/theme/modules/handle304" - "google.golang.org/grpc" - "google.golang.org/grpc/metadata" - "google.golang.org/protobuf/encoding/protojson" + + dynamic "github.com/movsb/taoblog/service/modules/renderers/_dynamic" ) type Gateway struct { @@ -37,153 +35,119 @@ type Gateway struct { service *service.Service auther *auth.Auth + client clients.Client + server _Server + instantNotifier notify.InstantNotifier } +type _Server interface { + proto.UtilsServer + proto.TaoBlogServer +} + func NewGateway(service *service.Service, auther *auth.Auth, mux *http.ServeMux, instantNotifier notify.InstantNotifier) *Gateway { g := &Gateway{ mux: mux, service: service, auther: auther, + client: clients.NewFromGrpcAddr(service.GrpcAddress()), + server: service, + instantNotifier: instantNotifier, } - mux2 := runtime.NewServeMux( - runtime.WithMarshalerOption( - runtime.MIMEWildcard, - &runtime.JSONPb{ - MarshalOptions: protojson.MarshalOptions{ - UseProtoNames: true, - EmitUnpopulated: true, - }, - }, - ), - ) - - mux.Handle(`/v3/`, mux2) - - if err := g.register(context.TODO(), mux, mux2); err != nil { + if err := g.register(context.TODO(), mux); err != nil { panic(err) } return g } -func (g *Gateway) register(ctx context.Context, mux *http.ServeMux, mux2 *runtime.ServeMux) error { +func (g *Gateway) register(ctx context.Context, mux *http.ServeMux) error { mc := utils.ServeMuxChain{ServeMux: mux} - dialOptions := []grpc.DialOption{ - grpc.WithInsecure(), - grpc.WithDefaultCallOptions( - grpc.MaxCallRecvMsgSize(100<<20), - grpc.MaxCallSendMsgSize(100<<20), - ), - } + info := utils.Must1(g.client.GetInfo(ctx, &proto.GetInfoRequest{})) - proto.RegisterUtilsHandlerFromEndpoint(ctx, mux2, g.service.GrpcAddress(), dialOptions) - proto.RegisterTaoBlogHandlerFromEndpoint(ctx, mux2, g.service.GrpcAddress(), dialOptions) - proto.RegisterSearchHandlerFromEndpoint(ctx, mux2, g.service.GrpcAddress(), dialOptions) + // 无需鉴权的部分 + // 可跨进程使用。 + { + // 扩展功能动态生成的样式、脚本、文件。 + mc.Handle(`GET /v3/dynamic/`, http.StripPrefix(`/v3/dynamic`, &dynamic.Handler{})) - mux2.HandlePath(`GET`, `/v3/avatar/{id}`, g.getAvatar) + // 博客功能集 + mc.Handle(`GET /v3/features/{theme}`, features.New()) - mux2.HandlePath(`POST`, `/v3/webhooks/github`, g.githubWebhook()) - mux2.HandlePath(`POST`, `/v3/webhooks/grafana/notify`, g.grafanaNotify) + // API 文档 + mc.Handle(`GET /v3/api/`, http.StripPrefix(`/v3/api`, apidoc.New())) - mux.Handle(`GET /v3/dynamic/`, http.StripPrefix(`/v3/dynamic`, &dynamic.Handler{})) + // 机器人控制:robots.txt + sitemapFullURL := utils.Must1(url.Parse(info.Home)).JoinPath(`sitemap.xml`).String() + mux.Handle(`GET /robots.txt`, robots.NewDefaults(sitemapFullURL)) + } - info := utils.Must1(g.service.GetInfo(ctx, &proto.GetInfoRequest{})) + // 无需鉴权但本身自带鉴权的部分 + { + // GitHub Workflow 完成回调。 + mc.Handle(`POST /v3/webhooks/github`, github.New( + g.service.Config().Maintenance.Webhook.GitHub.Secret, + g.service.Config().Maintenance.Webhook.ReloaderPath, + func(content string) { + g.instantNotifier.InstantNotify(`GitHub Webhooks`, content) + }, + )) - // 博客功能集 - mc.Handle(`GET /v3/features/{theme}`, features.New()) + // Grafana 监控告警通知。 + mc.Handle(`POST /v3/webhooks/grafana/notify`, grafana.New(g.server), g.localAuthenticate) + } - // API 文档 - mc.Handle(`GET /v3/api/`, http.StripPrefix(`/v3/api`, apidoc.New())) + // 使用系统帐号鉴权的部分 + // 只能在进程内使用。 + { + // 头像服务 + mc.Handle(`GET /v3/avatar/{id}`, avatar.New(g.server), g.systemAdmin) + } - // 文件服务:/123/a.txt - mc.Handle(`GET /v3/posts/{id}/files`, assets.New(`post`, g.service.GrpcAddress()), g.mimicGateway) + // 使用登录身份鉴权的部分 + // 可跨进程使用。 + { + // GRPC API 转接层 + mc.Handle(`/v3/`, api.New(ctx, g.client)) - // 站点地图:sitemap.xml - mc.Handle(`GET /sitemap.xml`, sitemap.New(g.service, g.service), g.lastPostTimeHandler) + // 文件服务:/123/a.txt + mc.Handle(`GET /v3/posts/{id}/files`, assets.New(`post`, g.client)) - // 订阅:rss - mc.Handle(`GET /rss`, rss.New(g.service, rss.WithArticleCount(10)), g.lastPostTimeHandler) + // 站点地图:sitemap.xml + mc.Handle(`GET /sitemap.xml`, sitemap.New(g.service, g.service), g.lastPostTimeHandler) - // 机器人控制:robots.txt - sitemapFullURL := utils.Must1(url.Parse(info.Home)).JoinPath(`sitemap.xml`).String() - mux.Handle(`GET /robots.txt`, robots.NewDefaults(sitemapFullURL)) + // 订阅:rss + mc.Handle(`GET /rss`, rss.New(g.client, rss.WithArticleCount(10)), g.lastPostTimeHandler) + } return nil } -func (g *Gateway) lastPostTimeHandler(h http.Handler) http.Handler { +func (g *Gateway) localAuthenticate(h http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - info := utils.Must1(g.service.GetInfo(r.Context(), &proto.GetInfoRequest{})) - handle304.New(h, - handle304.WithNotModified(time.Unix(int64(info.LastPostedAt), 0)), - handle304.WithEntityTag(version.GitCommit, version.Time, info.LastPostedAt), - ).ServeHTTP(w, r) + r = r.WithContext(g.auther.NewContextForRequest(r)) + h.ServeHTTP(w, r) }) } -func (g *Gateway) mimicGateway(h http.Handler) http.Handler { +func (g *Gateway) systemAdmin(h http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - md := metadata.Pairs() - for _, cookie := range r.Header.Values(`cookie`) { - md.Append(auth.GatewayCookie, cookie) - } - for _, userAgent := range r.Header.Values(`user-agent`) { - md.Append(auth.GatewayUserAgent, userAgent) - } - ctx := metadata.NewOutgoingContext(r.Context(), md) + ctx := auth.SystemAdmin(r.Context()) h.ServeHTTP(w, r.WithContext(ctx)) }) } -func (g *Gateway) githubWebhook() runtime.HandlerFunc { - h := webhooks.CreateHandler( - g.service.Config().Maintenance.Webhook.GitHub.Secret, - g.service.Config().Maintenance.Webhook.ReloaderPath, - func(content string) { - g.instantNotifier.InstantNotify("博客更新", content) - }, - ) - return func(w http.ResponseWriter, req *http.Request, params map[string]string) { - h(w, req) - } -} - -func (g *Gateway) grafanaNotify(w http.ResponseWriter, req *http.Request, params map[string]string) { - body := utils.DropLast1(io.ReadAll(io.LimitReader(req.Body, 1<<20))) - var m map[string]any - json.Unmarshal(body, &m) - var message string - if x, ok := m[`message`]; ok { - message, _ = x.(string) - } - g.service.UtilsServer.InstantNotify(g.auther.NewContextForRequest(req), &proto.InstantNotifyRequest{ - Title: `监控告警`, - // https://grafana.com/docs/grafana/latest/alerting/configure-notifications/manage-contact-points/integrations/webhook-notifier/ - Message: message, +func (g *Gateway) lastPostTimeHandler(h http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + info := utils.Must1(g.client.GetInfo(r.Context(), &proto.GetInfoRequest{})) + handle304.New(h, + handle304.WithNotModified(time.Unix(int64(info.LastPostedAt), 0)), + handle304.WithEntityTag(version.GitCommit, version.Time, info.LastPostedAt), + ).ServeHTTP(w, r) }) } - -func (g *Gateway) getAvatar(w http.ResponseWriter, req *http.Request, params map[string]string) { - ephemeral, err := strconv.Atoi(params[`id`]) - if err != nil { - panic(err) - } - in := &handy.GetAvatarRequest{ - Ephemeral: ephemeral, - IfModifiedSince: req.Header.Get("If-Modified-Since"), - IfNoneMatch: req.Header.Get("If-None-Match"), - SetStatus: func(statusCode int) { - w.WriteHeader(statusCode) - }, - SetHeader: func(name string, value string) { - w.Header().Add(name, value) - }, - W: w, - } - g.service.GetAvatar(in) -} diff --git a/modules/auth/auth.go b/modules/auth/auth.go index 6409fc34..ff5e2197 100644 --- a/modules/auth/auth.go +++ b/modules/auth/auth.go @@ -59,7 +59,7 @@ func (a *Auth) Config() *config.AuthConfig { } // 找不到返回空。 -// NOTE:系统管理员不允许查找。 +// NOTE:系统管理员因为不因为登录所以不允许查找。 func (o *Auth) GetUserByID(id int64) *User { if id == admin.ID { return admin diff --git a/protocols/clients/client.go b/protocols/clients/client.go index 49fe3f11..09c0f9d6 100644 --- a/protocols/clients/client.go +++ b/protocols/clients/client.go @@ -7,18 +7,47 @@ import ( "net/url" "github.com/movsb/taoblog/modules/auth" + "github.com/movsb/taoblog/modules/utils" "github.com/movsb/taoblog/protocols/go/proto" "google.golang.org/grpc" "google.golang.org/grpc/credentials" + "google.golang.org/grpc/credentials/insecure" "google.golang.org/grpc/metadata" ) +type Client interface { + proto.UtilsClient + proto.TaoBlogClient + proto.ManagementClient + proto.SearchClient +} + +type _Client struct { + proto.UtilsClient + proto.TaoBlogClient + proto.ManagementClient + proto.SearchClient +} + +func NewFromGrpcAddr(addr string) Client { + cc := NewConn("", addr) + pc := NewProtoClient(cc, "") + return &_Client{ + UtilsClient: pc.Utils, + TaoBlogClient: pc.Blog, + ManagementClient: pc.Management, + SearchClient: pc.Search, + } +} + func NewProtoClient(cc *grpc.ClientConn, token string) *ProtoClient { return &ProtoClient{ cc: cc, token: token, + Utils: proto.NewUtilsClient(cc), Blog: proto.NewTaoBlogClient(cc), Management: proto.NewManagementClient(cc), + Search: proto.NewSearchClient(cc), } } @@ -26,19 +55,25 @@ type ProtoClient struct { cc *grpc.ClientConn token string + Utils proto.UtilsClient Blog proto.TaoBlogClient Management proto.ManagementClient + Search proto.SearchClient } func (c *ProtoClient) Context() context.Context { - return c.ContextFrom(context.Background()) + return c.contextFrom(context.Background()) } -func (c *ProtoClient) ContextFrom(parent context.Context) context.Context { +func (c *ProtoClient) contextFrom(parent context.Context) context.Context { + if c.token == "" { + return parent + } return metadata.NewOutgoingContext(parent, metadata.Pairs(`Authorization`, fmt.Sprintf("%s %d:%s", auth.TokenName, auth.AdminID, c.token))) } // grpcAddress 可以为空,表示使用 api 同一地址。 +// TODO 私有化,并把 token 设置到 default call options func NewConn(api, grpcAddress string) *grpc.ClientConn { secure := false if grpcAddress == `` { @@ -60,24 +95,17 @@ func NewConn(api, grpcAddress string) *grpc.ClientConn { grpcAddress = fmt.Sprintf(`%s:%s`, grpcAddress, grpcPort) } - var conn *grpc.ClientConn - var err error - if secure { - if conn, err = grpc.Dial( - grpcAddress, - grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{})), - grpc.WithDefaultCallOptions(grpc.MaxCallRecvMsgSize(100<<20)), - ); err != nil { - panic(err) - } - } else { - if conn, err = grpc.Dial( - grpcAddress, - grpc.WithInsecure(), - grpc.WithDefaultCallOptions(grpc.MaxCallRecvMsgSize(100<<20)), - ); err != nil { - panic(err) - } + options := []grpc.DialOption{ + grpc.WithDefaultCallOptions( + grpc.MaxCallRecvMsgSize(100<<20), + grpc.MaxCallSendMsgSize(100<<20), + ), + grpc.WithTransportCredentials(utils.IIF(secure, credentials.NewTLS(&tls.Config{}), insecure.NewCredentials())), + } + + conn, err := grpc.Dial(grpcAddress, options...) + if err != nil { + panic(err) } return conn diff --git a/protocols/comment.proto b/protocols/comment.proto index 7fab93b0..4014e944 100644 --- a/protocols/comment.proto +++ b/protocols/comment.proto @@ -123,3 +123,11 @@ message PreviewCommentRequest { message PreviewCommentResponse { string html = 1; } + +message GetCommentEmailByIdRequest { + int32 id = 1; +} + +message GetCommentEmailByIdResponse { + string email = 1; +} diff --git a/protocols/service.proto b/protocols/service.proto index d19605f4..0e9551e2 100644 --- a/protocols/service.proto +++ b/protocols/service.proto @@ -280,6 +280,14 @@ service TaoBlog { description: "完成/取消完成任务。"; }; } + rpc GetCommentEmailById (GetCommentEmailByIdRequest) returns (GetCommentEmailByIdResponse) { + option (google.api.http) = { + get: "/v3/comments/{id=*}/email"; + }; + option (grpc.gateway.protoc_gen_swagger.options.openapiv2_operation) = { + description: "获取某条评论的评论者邮箱"; + }; + } } message PingRequest { diff --git a/service/avatar.go b/service/avatar.go deleted file mode 100644 index 8581d77e..00000000 --- a/service/avatar.go +++ /dev/null @@ -1,129 +0,0 @@ -package service - -import ( - "fmt" - "hash/fnv" - "io" - "math" - "net/http" - "strings" - "sync" - - "github.com/movsb/taoblog/protocols/go/handy" - "github.com/movsb/taoblog/service/modules/avatar" -) - -type AvatarCache struct { - id2email map[int]string - email2id map[string]int - lock sync.RWMutex -} - -func NewAvatarCache() *AvatarCache { - return &AvatarCache{ - id2email: make(map[int]string), - email2id: make(map[string]int), - } -} - -// 简单的“一致性”哈希生成算法。 -// 此“一致性”与分布式中的“一致性”不是同一种事物。 -// &math.MaxIn32:不是必须的,只是简单地为了数值更小、不要负数。 -func (c *AvatarCache) id(email string) int { - hash := fnv.New32() - hash.Write([]byte(email)) - sum := hash.Sum32() & math.MaxInt32 - for { - e, ok := c.id2email[int(sum)] - if ok && e != email { - sum++ - sum &= math.MaxInt32 - continue - } - break - } - return int(sum) -} - -func (c *AvatarCache) ID(email string) int { - c.lock.Lock() - defer c.lock.Unlock() - - if email == "" { - panic("错误的邮箱。") - } - - email = strings.ToLower(email) - - if id, ok := c.email2id[email]; ok { - return id - } - - next := c.id(email) - - c.email2id[email] = next - c.id2email[next] = email - - return next -} - -func (c *AvatarCache) Email(id int) string { - c.lock.RLock() - defer c.lock.RUnlock() - - return c.id2email[id] -} - -// GetAvatar ... -func (s *Service) GetAvatar(in *handy.GetAvatarRequest) { - email := s.avatarCache.Email(in.Ephemeral) - if email == "" { - in.SetStatus(http.StatusNotFound) - return - } - - p := avatar.Params{ - Headers: make(http.Header), - } - - if in.IfModifiedSince != "" { - p.Headers.Add("If-Modified-Since", in.IfModifiedSince) - } - if in.IfNoneMatch != "" { - p.Headers.Add("If-None-Match", in.IfNoneMatch) - } - - // TODO 并没有限制获取未公开发表文章的评论。 - resp, err := avatar.Get(email, &p) - if err != nil { - in.SetStatus(500) - fmt.Fprint(in.W, err) - return - } - - defer resp.Body.Close() - - // 删除可能有隐私的头部字段。 - // TODO:内部缓存,只正向代理 body。 - for _, k := range knownHeaders { - if v := resp.Header.Get(k); v != "" { - in.SetHeader(k, v) - } - } - - // 客户端缓存失效了也可以继续用,后台慢慢刷新就行。 - in.SetHeader(`Cache-Control`, `max-age=604800, stale-while-revalidate=604800`) - - in.SetStatus(resp.StatusCode) - - io.Copy(in.W, resp.Body) -} - -// 不再提供以下字段,官方更新太频繁,意义不大。 -// `Expires`, -// `Cache-Control`, -var knownHeaders = []string{ - `Content-Length`, - `Content-Type`, - `Last-Modified`, -} diff --git a/service/comment.go b/service/comment.go index b52a86ea..c67c203c 100644 --- a/service/comment.go +++ b/service/comment.go @@ -428,8 +428,6 @@ func (s *Service) CreateComment(ctx context.Context, in *proto.Comment) (*proto. } } - // NOTE:这里是用后台管理员的身份获取。 - // 所以为了不要 impersonate,应该提供系统帐号。 post, err := s.GetPost(auth.SystemAdmin(context.Background()), &proto.GetPostRequest{ Id: int32(in.PostId), WithLink: proto.LinkKind_LinkKindFull, @@ -640,3 +638,14 @@ func (s *Service) CheckCommentTaskListItems(ctx context.Context, in *proto.Check ModificationTime: updatedComment.Modified, }, nil } + +func (s *Service) GetCommentEmailById(ctx context.Context, in *proto.GetCommentEmailByIdRequest) (*proto.GetCommentEmailByIdResponse, error) { + s.MustBeAdmin(ctx) + + email := s.avatarCache.Email(int(in.Id)) + if email == "" { + return nil, status.Error(codes.NotFound, `找不到对应的邮箱地址。`) + } + + return &proto.GetCommentEmailByIdResponse{Email: email}, nil +} diff --git a/service/main.go b/service/main.go index a63bbe85..a5e5b698 100644 --- a/service/main.go +++ b/service/main.go @@ -88,7 +88,7 @@ type Service struct { // 请求节流器。 throttler *utils.Throttler[_RequestThrottlerKey] - avatarCache *AvatarCache + avatarCache *cache.AvatarHash // 搜索引擎启动需要时间,所以如果网站一运行即搜索,则可能出现引擎不可用 // 的情况,此时此值为空。 @@ -183,7 +183,7 @@ func newService(ctx context.Context, cancel context.CancelFunc, cfg *config.Conf } s.cmtntf.Init() - s.avatarCache = NewAvatarCache() + s.avatarCache = cache.NewAvatarHash() s.cmtgeo = commentgeo.New(context.TODO()) s.cacheAllCommenterData() @@ -231,7 +231,7 @@ func newService(ctx context.Context, cancel context.CancelFunc, cfg *config.Conf return s } -// 从 Context 中取出用户并且必须为 Admin,否则 panic。 +// 从 Context 中取出用户并且必须为 Admin/System,否则 panic。 func (s *Service) MustBeAdmin(ctx context.Context) *auth.AuthContext { ac := auth.Context(ctx) if ac == nil { diff --git a/service/modules/avatar/main.go b/service/modules/avatar/main.go deleted file mode 100644 index c30a5344..00000000 --- a/service/modules/avatar/main.go +++ /dev/null @@ -1,25 +0,0 @@ -package avatar - -import ( - "context" - "net/http" -) - -// Params ... -type Params struct { - Headers http.Header -} - -// Get ... -func Get(email string, p *Params) (*http.Response, error) { - var err error - var resp *http.Response - resp, err = github(context.TODO(), email, p) - if err != nil { - resp, err = gravatar(context.TODO(), email, p) - } - if err != nil { - return nil, err - } - return resp, nil -} diff --git a/service/modules/cache/avatar_hash.go b/service/modules/cache/avatar_hash.go new file mode 100644 index 00000000..b5355f6f --- /dev/null +++ b/service/modules/cache/avatar_hash.go @@ -0,0 +1,76 @@ +package cache + +import ( + "hash/fnv" + "math" + "strings" + "sync" +) + +type AvatarHasher interface { + ID(email string) int + Email(id int) string +} + +type AvatarHash struct { + id2email map[int]string + email2id map[string]int + lock sync.RWMutex +} + +func NewAvatarHash() *AvatarHash { + return &AvatarHash{ + id2email: make(map[int]string), + email2id: make(map[string]int), + } +} + +// 简单的“一致性”哈希生成算法。 +// 此“一致性”与分布式中的“一致性”不是同一种事物。 +// &math.MaxIn32:不是必须的,只是简单地为了数值更小、不要负数。 +func (c *AvatarHash) id(email string) int { + hash := fnv.New32() + hash.Write([]byte(email)) + sum := hash.Sum32() & math.MaxInt32 + for { + e, ok := c.id2email[int(sum)] + if ok && e != email { + sum++ + sum &= math.MaxInt32 + continue + } + break + } + return int(sum) +} + +func (c *AvatarHash) ID(email string) int { + c.lock.Lock() + defer c.lock.Unlock() + + if email == "" { + panic("错误的邮箱。") + } + + email = strings.ToLower(email) + + if id, ok := c.email2id[email]; ok { + return id + } + + next := c.id(email) + + c.email2id[email] = next + c.id2email[next] = email + + return next +} + +// 根据 ID 获取 Email。 +// 如果不存在,返回空。 +func (c *AvatarHash) Email(id int) string { + c.lock.RLock() + defer c.lock.RUnlock() + + return c.id2email[id] +} diff --git a/theme/blog/styles/home.scss b/theme/blog/styles/home.scss index 48e7fe38..2d1d18d9 100644 --- a/theme/blog/styles/home.scss +++ b/theme/blog/styles/home.scss @@ -25,7 +25,7 @@ } @media screen and (max-width: $max_width) { - #latest-comment-list, #latest-post-list, #latest-tweet-list, #status-list, #all-posts { + #latest-comment-list, #latest-post-list, #latest-tweet-list, #status-list, #all-posts, #all-tweets { padding-left: 1em; } }