🎉 Initial Commit
Some checks failed
release-nightly / build-docker (push) Has been cancelled

This commit is contained in:
2024-07-14 20:25:30 +08:00
commit 466b240e95
41 changed files with 4312 additions and 0 deletions

View File

@ -0,0 +1,29 @@
package directory
import (
"context"
"fmt"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
health "google.golang.org/grpc/health/grpc_health_v1"
"time"
)
func ConnectService(in *ServiceInstance) (*grpc.ClientConn, error) {
conn, err := grpc.NewClient(
in.GrpcAddr,
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
if err != nil {
return nil, fmt.Errorf("failed to create grpc connection: %v", err)
}
client := health.NewHealthClient(conn)
ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
defer cancel()
if _, err = client.Check(ctx, &health.HealthCheckRequest{}); err != nil {
return conn, fmt.Errorf("grpc service is down: %v", err)
}
return conn, nil
}

View File

@ -0,0 +1,29 @@
package directory
import "google.golang.org/grpc"
type ServiceInstance struct {
ID string `json:"id"`
Type string `json:"type"`
Label string `json:"label"`
GrpcAddr string `json:"grpc_addr"`
HttpAddr *string `json:"http_addr"`
grpcConn *grpc.ClientConn
retryCount int
}
func (v *ServiceInstance) GetGrpcConn() (*grpc.ClientConn, error) {
if v.grpcConn != nil {
return v.grpcConn, nil
}
var err error
v.grpcConn, err = ConnectService(v)
if err != nil {
RemoveServiceInstance(v.ID)
return nil, err
}
return v.grpcConn, nil
}

View File

@ -0,0 +1,61 @@
package directory
import (
"sync"
)
const (
ServiceTypeAuthProvider = "passport"
ServiceTypeFileProvider = "paperclip"
)
var serviceDirectory sync.Map
func GetServiceInstance(id string) *ServiceInstance {
val, ok := serviceDirectory.Load(id)
if ok {
return val.(*ServiceInstance)
} else {
return nil
}
}
func GetServiceInstanceByType(t string) *ServiceInstance {
var result *ServiceInstance
serviceDirectory.Range(func(key, value any) bool {
if value.(*ServiceInstance).Type == t {
result = value.(*ServiceInstance)
return false
}
return true
})
return result
}
func ListServiceInstance() []*ServiceInstance {
var result []*ServiceInstance
serviceDirectory.Range(func(key, value interface{}) bool {
result = append(result, value.(*ServiceInstance))
return true
})
return result
}
func ListServiceInstanceByType(t string) []*ServiceInstance {
var result []*ServiceInstance
serviceDirectory.Range(func(key, value interface{}) bool {
if value.(*ServiceInstance).Type == t {
result = append(result, value.(*ServiceInstance))
}
return true
})
return result
}
func AddServiceInstance(in *ServiceInstance) {
serviceDirectory.Store(in.ID, in)
}
func RemoveServiceInstance(id string) {
serviceDirectory.Delete(id)
}

View File

@ -0,0 +1,15 @@
package gap
import (
"git.solsynth.dev/hydrogen/dealer/pkg/hyper"
"git.solsynth.dev/hydrogen/dealer/pkg/proto"
"github.com/spf13/viper"
)
var H *hyper.HyperConn
func NewHyperClient(info *proto.ServiceInfo) (err error) {
H, err = hyper.NewHyperConn(viper.GetString("dealer.addr"), info)
H.KeepRegisterService()
return
}

15
pkg/internal/gap/net.go Normal file
View File

@ -0,0 +1,15 @@
package gap
import "net"
func GetOutboundIP() (net.IP, error) {
conn, err := net.Dial("udp", "1.1.1.1:80")
if err != nil {
return nil, err
} else {
defer conn.Close()
}
localAddr := conn.LocalAddr().(*net.UDPAddr)
return localAddr.IP, nil
}

45
pkg/internal/grpc/auth.go Normal file
View File

@ -0,0 +1,45 @@
package grpc
import (
"context"
"fmt"
"git.solsynth.dev/hydrogen/dealer/pkg/internal/directory"
"git.solsynth.dev/hydrogen/dealer/pkg/proto"
"time"
)
func (v *Server) Authenticate(ctx context.Context, request *proto.AuthRequest) (*proto.AuthReply, error) {
instance := directory.GetServiceInstanceByType(directory.ServiceTypeAuthProvider)
if instance == nil {
return &proto.AuthReply{}, fmt.Errorf("no available service %s found", directory.ServiceTypeAuthProvider)
}
conn, err := instance.GetGrpcConn()
if err != nil {
return nil, fmt.Errorf("service is down: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
defer cancel()
out, err := proto.NewAuthClient(conn).Authenticate(ctx, request)
return out, err
}
func (v *Server) EnsurePermGranted(ctx context.Context, request *proto.CheckPermRequest) (*proto.CheckPermReply, error) {
instance := directory.GetServiceInstanceByType(directory.ServiceTypeAuthProvider)
if instance == nil {
return &proto.CheckPermReply{}, fmt.Errorf("no available service %s found", directory.ServiceTypeAuthProvider)
}
conn, err := instance.GetGrpcConn()
if err != nil {
return nil, fmt.Errorf("service is down: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
defer cancel()
out, err := proto.NewAuthClient(conn).EnsurePermGranted(ctx, request)
return out, err
}

View File

@ -0,0 +1,26 @@
package grpc
import (
"context"
health "google.golang.org/grpc/health/grpc_health_v1"
"time"
)
func (v *Server) Check(ctx context.Context, request *health.HealthCheckRequest) (*health.HealthCheckResponse, error) {
return &health.HealthCheckResponse{
Status: health.HealthCheckResponse_SERVING,
}, nil
}
func (v *Server) Watch(request *health.HealthCheckRequest, server health.Health_WatchServer) error {
for {
if server.Send(&health.HealthCheckResponse{
Status: health.HealthCheckResponse_SERVING,
}) != nil {
break
}
time.Sleep(1000 * time.Millisecond)
}
return nil
}

View File

@ -0,0 +1,43 @@
package grpc
import (
"git.solsynth.dev/hydrogen/dealer/pkg/proto"
"net"
"google.golang.org/grpc/reflection"
"github.com/spf13/viper"
"google.golang.org/grpc"
health "google.golang.org/grpc/health/grpc_health_v1"
)
type Server struct {
proto.UnimplementedServiceDirectoryServer
proto.UnimplementedAuthServer
srv *grpc.Server
}
func NewServer() *Server {
server := &Server{
srv: grpc.NewServer(),
}
proto.RegisterServiceDirectoryServer(server.srv, &Server{})
proto.RegisterAuthServer(server.srv, &Server{})
health.RegisterHealthServer(server.srv, &Server{})
reflection.Register(server.srv)
return server
}
func (v *Server) Listen() error {
listener, err := net.Listen("tcp", viper.GetString("grpc_bind"))
if err != nil {
return err
}
return v.srv.Serve(listener)
}

View File

@ -0,0 +1,70 @@
package grpc
import (
"context"
"fmt"
"git.solsynth.dev/hydrogen/dealer/pkg/internal/directory"
"git.solsynth.dev/hydrogen/dealer/pkg/proto"
"github.com/samber/lo"
)
func warpServiceInstanceToInfo(in *directory.ServiceInstance) *proto.ServiceInfo {
return &proto.ServiceInfo{
Id: in.ID,
Type: in.Type,
Label: in.Label,
GrpcAddr: in.GrpcAddr,
HttpAddr: in.HttpAddr,
}
}
func (v *Server) GetService(ctx context.Context, request *proto.GetServiceRequest) (*proto.GetServiceResponse, error) {
if request.Id != nil {
out := directory.GetServiceInstance(request.GetId())
return &proto.GetServiceResponse{
Data: warpServiceInstanceToInfo(out),
}, nil
}
if request.Type != nil {
out := directory.GetServiceInstanceByType(request.GetType())
return &proto.GetServiceResponse{
Data: warpServiceInstanceToInfo(out),
}, nil
}
return nil, fmt.Errorf("no filter condition is provided")
}
func (v *Server) ListService(ctx context.Context, request *proto.ListServiceRequest) (*proto.ListServiceResponse, error) {
var out []*directory.ServiceInstance
if request.Type != nil {
out = directory.ListServiceInstanceByType(request.GetType())
} else {
out = directory.ListServiceInstance()
}
return &proto.ListServiceResponse{
Data: lo.Map(out, func(item *directory.ServiceInstance, index int) *proto.ServiceInfo {
return warpServiceInstanceToInfo(item)
}),
}, nil
}
func (v *Server) AddService(ctx context.Context, info *proto.ServiceInfo) (*proto.AddServiceResponse, error) {
in := &directory.ServiceInstance{
ID: info.GetId(),
Type: info.GetType(),
Label: info.GetLabel(),
GrpcAddr: info.GetGrpcAddr(),
HttpAddr: info.HttpAddr,
}
directory.AddServiceInstance(in)
return &proto.AddServiceResponse{
IsSuccess: true,
}, nil
}
func (v *Server) RemoveService(ctx context.Context, request *proto.RemoveServiceRequest) (*proto.RemoveServiceResponse, error) {
directory.RemoveServiceInstance(request.GetId())
return &proto.RemoveServiceResponse{
IsSuccess: true,
}, nil
}

5
pkg/internal/meta.go Normal file
View File

@ -0,0 +1,5 @@
package pkg
const (
AppVersion = "1.0.0"
)

View File

@ -0,0 +1,63 @@
package models
import (
"fmt"
"time"
"github.com/samber/lo"
"github.com/spf13/viper"
"gorm.io/datatypes"
)
type Account struct {
BaseModel
Name string `json:"name" gorm:"uniqueIndex"`
Nick string `json:"nick"`
Description string `json:"description"`
Avatar *uint `json:"avatar"`
Banner *uint `json:"banner"`
ConfirmedAt *time.Time `json:"confirmed_at"`
SuspendedAt *time.Time `json:"suspended_at"`
PermNodes datatypes.JSONMap `json:"perm_nodes"`
Contacts []AccountContact `json:"contacts"`
}
func (v Account) GetAvatar() *string {
if v.Avatar != nil {
return lo.ToPtr(fmt.Sprintf("%s/api/attachments/%d", viper.GetString("content_endpoint"), *v.Avatar))
}
return nil
}
func (v Account) GetBanner() *string {
if v.Banner != nil {
return lo.ToPtr(fmt.Sprintf("%s/api/attachments/%d", viper.GetString("content_endpoint"), *v.Banner))
}
return nil
}
func (v Account) GetPrimaryEmail() AccountContact {
val, _ := lo.Find(v.Contacts, func(item AccountContact) bool {
return item.Type == EmailAccountContact && item.IsPrimary
})
return val
}
type AccountContactType = int8
const (
EmailAccountContact = AccountContactType(iota)
)
type AccountContact struct {
BaseModel
Type int8 `json:"type"`
Content string `json:"content" gorm:"uniqueIndex"`
IsPublic bool `json:"is_public"`
IsPrimary bool `json:"is_primary"`
VerifiedAt *time.Time `json:"verified_at"`
AccountID uint `json:"account_id"`
}

View File

@ -0,0 +1,17 @@
package models
import (
"time"
"gorm.io/datatypes"
"gorm.io/gorm"
)
type JSONMap = datatypes.JSONType[map[string]any]
type BaseModel struct {
ID uint `json:"id" gorm:"primaryKey"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"`
}

View File

@ -0,0 +1,19 @@
package api
import (
"git.solsynth.dev/hydrogen/dealer/pkg/internal/directory"
"github.com/gofiber/fiber/v2"
"github.com/samber/lo"
)
func listExistsService(c *fiber.Ctx) error {
services := directory.ListServiceInstance()
return c.JSON(lo.Map(services, func(item *directory.ServiceInstance, index int) map[string]any {
return map[string]any{
"id": item.ID,
"type": item.Type,
"label": item.Label,
}
}))
}

View File

@ -0,0 +1,28 @@
package api
import (
"git.solsynth.dev/hydrogen/dealer/pkg/internal/server/exts"
"github.com/gofiber/contrib/websocket"
"github.com/gofiber/fiber/v2"
)
func MapAPIs(app *fiber.App) {
wellKnown := app.Group("/.well-known").Name("Well Known")
{
wellKnown.Get("/directory/services", listExistsService)
}
api := app.Group("/api").Name("API")
{
api.Use(func(c *fiber.Ctx) error {
if err := exts.EnsureAuthenticated(c); err != nil {
return err
}
return c.Next()
}).Get("/ws", websocket.New(listenWebsocket))
api.All("/*", func(c *fiber.Ctx) error {
return fiber.ErrNotFound
})
}
}

View File

@ -0,0 +1,29 @@
package api
import (
"git.solsynth.dev/hydrogen/dealer/pkg/internal/models"
"git.solsynth.dev/hydrogen/dealer/pkg/internal/services"
"github.com/gofiber/contrib/websocket"
"github.com/rs/zerolog/log"
)
func listenWebsocket(c *websocket.Conn) {
user := c.Locals("user").(models.Account)
// Push connection
services.ClientRegister(user, c)
log.Debug().Uint("user", user.ID).Msg("New websocket connection established...")
// Event loop
var err error
for {
if _, _, err = c.ReadMessage(); err != nil {
break
}
}
// Pop connection
services.ClientUnregister(user, c)
log.Debug().Uint("user", user.ID).Msg("A websocket connection disconnected...")
}

View File

@ -0,0 +1,118 @@
package exts
import (
"context"
"fmt"
"git.solsynth.dev/hydrogen/dealer/pkg/hyper"
"git.solsynth.dev/hydrogen/dealer/pkg/internal/directory"
"git.solsynth.dev/hydrogen/dealer/pkg/proto"
"github.com/gofiber/fiber/v2"
jsoniter "github.com/json-iterator/go"
"strings"
"time"
)
func AuthMiddleware(c *fiber.Ctx) error {
var atk string
if cookie := c.Cookies(hyper.CookieAtk); len(cookie) > 0 {
atk = cookie
}
if header := c.Get(fiber.HeaderAuthorization); len(header) > 0 {
tk := strings.Replace(header, "Bearer", "", 1)
atk = strings.TrimSpace(tk)
}
if tk := c.Query("tk"); len(tk) > 0 {
atk = strings.TrimSpace(tk)
}
c.Locals("p_token", atk)
rtk := c.Cookies(hyper.CookieRtk)
if acc, newAtk, newRtk, err := DoAuthenticate(atk, rtk); err == nil {
if newAtk != atk {
SetAuthCookies(c, newAtk, newRtk)
}
c.Locals("user", acc)
}
return c.Next()
}
func DoAuthenticate(atk, rtk string) (acc *proto.UserInfo, accessTk string, refreshTk string, err error) {
ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
defer cancel()
in := directory.GetServiceInstanceByType(directory.ServiceTypeAuthProvider)
if in == nil {
return
}
conn, err := in.GetGrpcConn()
if err != nil {
return
}
var reply *proto.AuthReply
reply, err = proto.NewAuthClient(conn).Authenticate(ctx, &proto.AuthRequest{
AccessToken: atk,
RefreshToken: &rtk,
})
if err != nil {
return
}
if reply != nil {
acc = reply.GetInfo().GetInfo()
accessTk = reply.GetInfo().GetNewAccessToken()
refreshTk = reply.GetInfo().GetNewRefreshToken()
if !reply.IsValid {
err = fmt.Errorf("invalid authorization context")
return
}
}
return
}
func CheckPermGranted(atk string, key string, val []byte) error {
ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
defer cancel()
in := directory.GetServiceInstanceByType(directory.ServiceTypeAuthProvider)
if in == nil {
return fmt.Errorf("no auth provider found")
}
conn, err := in.GetGrpcConn()
if err != nil {
return err
}
reply, err := proto.NewAuthClient(conn).EnsurePermGranted(ctx, &proto.CheckPermRequest{
Token: atk,
Key: key,
Value: val,
})
if err != nil {
return err
} else if !reply.GetIsValid() {
return fmt.Errorf("missing permission: %s", key)
}
return nil
}
func EnsureAuthenticated(c *fiber.Ctx) error {
if _, ok := c.Locals("p_user").(*proto.UserInfo); !ok {
return fiber.NewError(fiber.StatusUnauthorized)
}
return nil
}
func EnsureGrantedPerm(c *fiber.Ctx, key string, val any) error {
if err := EnsureAuthenticated(c); err != nil {
return err
}
encodedVal, _ := jsoniter.Marshal(val)
if err := CheckPermGranted(c.Locals("p_token").(string), key, encodedVal); err != nil {
return fiber.NewError(fiber.StatusForbidden, err.Error())
}
return nil
}

View File

@ -0,0 +1,27 @@
package exts
import (
"git.solsynth.dev/hydrogen/dealer/pkg/hyper"
"github.com/gofiber/fiber/v2"
"github.com/spf13/viper"
"time"
)
func SetAuthCookies(c *fiber.Ctx, atk, rtk string) {
c.Cookie(&fiber.Cookie{
Name: hyper.CookieAtk,
Value: atk,
Domain: viper.GetString("security.cookie_domain"),
SameSite: viper.GetString("security.cookie_samesite"),
Expires: time.Now().Add(60 * time.Minute),
Path: "/",
})
c.Cookie(&fiber.Cookie{
Name: hyper.CookieRtk,
Value: rtk,
Domain: viper.GetString("security.cookie_domain"),
SameSite: viper.GetString("security.cookie_samesite"),
Expires: time.Now().Add(24 * 30 * time.Hour),
Path: "/",
})
}

View File

@ -0,0 +1,18 @@
package exts
import (
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
)
var validation = validator.New(validator.WithRequiredStructEnabled())
func BindAndValidate(c *fiber.Ctx, out any) error {
if err := c.BodyParser(out); err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
} else if err := validation.Struct(out); err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
}
return nil
}

View File

@ -0,0 +1,66 @@
package server
import (
"strings"
"git.solsynth.dev/hydrogen/dealer/pkg/internal/server/api"
"git.solsynth.dev/hydrogen/dealer/pkg/internal/server/exts"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/cors"
"github.com/gofiber/fiber/v2/middleware/idempotency"
"github.com/gofiber/fiber/v2/middleware/logger"
jsoniter "github.com/json-iterator/go"
"github.com/rs/zerolog/log"
"github.com/spf13/viper"
)
type HTTPApp struct {
app *fiber.App
}
func NewServer() *HTTPApp {
app := fiber.New(fiber.Config{
DisableStartupMessage: true,
EnableIPValidation: true,
ServerHeader: "Hydrogen.Dealer",
AppName: "Hydrogen.Dealer",
ProxyHeader: fiber.HeaderXForwardedFor,
JSONEncoder: jsoniter.ConfigCompatibleWithStandardLibrary.Marshal,
JSONDecoder: jsoniter.ConfigCompatibleWithStandardLibrary.Unmarshal,
EnablePrintRoutes: viper.GetBool("debug.print_routes"),
})
app.Use(idempotency.New())
app.Use(cors.New(cors.Config{
AllowCredentials: true,
AllowMethods: strings.Join([]string{
fiber.MethodGet,
fiber.MethodPost,
fiber.MethodHead,
fiber.MethodOptions,
fiber.MethodPut,
fiber.MethodDelete,
fiber.MethodPatch,
}, ","),
AllowOriginsFunc: func(origin string) bool {
return true
},
}))
app.Use(logger.New(logger.Config{
Format: "${status} | ${latency} | ${method} ${path}\n",
Output: log.Logger,
}))
app.Use(exts.AuthMiddleware)
api.MapAPIs(app)
return &HTTPApp{app}
}
func (v *HTTPApp) Listen() {
if err := v.app.Listen(viper.GetString("bind")); err != nil {
log.Fatal().Err(err).Msg("An error occurred when starting server...")
}
}

View File

@ -0,0 +1,30 @@
package services
import (
"git.solsynth.dev/hydrogen/dealer/pkg/internal/models"
"github.com/gofiber/contrib/websocket"
"sync"
)
var (
wsMutex sync.Mutex
wsConn = make(map[uint]map[*websocket.Conn]bool)
)
func ClientRegister(user models.Account, conn *websocket.Conn) {
wsMutex.Lock()
if wsConn[user.ID] == nil {
wsConn[user.ID] = make(map[*websocket.Conn]bool)
}
wsConn[user.ID][conn] = true
wsMutex.Unlock()
}
func ClientUnregister(user models.Account, conn *websocket.Conn) {
wsMutex.Lock()
if wsConn[user.ID] == nil {
wsConn[user.ID] = make(map[*websocket.Conn]bool)
}
delete(wsConn[user.ID], conn)
wsMutex.Unlock()
}