⬆️ Upgrade to support the latest version Hydrogen Project standard

This commit is contained in:
2024-06-22 18:05:41 +08:00
parent fa50baf927
commit 9366f6e56e
55 changed files with 603 additions and 385 deletions

View File

@ -0,0 +1,23 @@
package database
import (
"git.solsynth.dev/hydrogen/messaging/pkg/internal/models"
"gorm.io/gorm"
)
var DatabaseAutoActionRange = []any{
&models.Account{},
&models.Realm{},
&models.Channel{},
&models.ChannelMember{},
&models.Call{},
&models.Message{},
}
func RunMigration(source *gorm.DB) error {
if err := source.AutoMigrate(DatabaseAutoActionRange...); err != nil {
return err
}
return nil
}

View File

@ -0,0 +1,28 @@
package database
import (
"github.com/rs/zerolog/log"
"github.com/samber/lo"
"github.com/spf13/viper"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"gorm.io/gorm/schema"
)
var C *gorm.DB
func NewSource() error {
var err error
dialector := postgres.Open(viper.GetString("database.dsn"))
C, err = gorm.Open(dialector, &gorm.Config{NamingStrategy: schema.NamingStrategy{
TablePrefix: viper.GetString("database.prefix"),
}, Logger: logger.New(&log.Logger, logger.Config{
Colorful: true,
IgnoreRecordNotFoundError: true,
LogLevel: lo.Ternary(viper.GetBool("debug.database"), logger.Info, logger.Silent),
})})
return err
}

6
pkg/internal/embed.go Normal file
View File

@ -0,0 +1,6 @@
package pkg
import "embed"
//go:embed views/*
var FS embed.FS

View File

@ -0,0 +1,12 @@
package gap
import (
"git.solsynth.dev/hydrogen/passport/pkg/hyper"
"github.com/spf13/viper"
)
var H *hyper.HyperConn
func NewHyperClient() {
H = hyper.NewHyperConn(viper.GetString("consul.addr"))
}

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
}

View File

@ -0,0 +1,40 @@
package gap
import (
"fmt"
"strconv"
"strings"
"github.com/hashicorp/consul/api"
"github.com/spf13/viper"
)
func Register() error {
cfg := api.DefaultConfig()
cfg.Address = viper.GetString("consul.addr")
client, err := api.NewClient(cfg)
if err != nil {
return err
}
httpBind := strings.SplitN(viper.GetString("bind"), ":", 2)
grpcBind := strings.SplitN(viper.GetString("grpc_bind"), ":", 2)
outboundIp, _ := GetOutboundIP()
port, _ := strconv.Atoi(httpBind[1])
registration := new(api.AgentServiceRegistration)
registration.ID = viper.GetString("id")
registration.Name = "Hydrogen.Messaging"
registration.Address = outboundIp.String()
registration.Port = port
registration.Check = &api.AgentServiceCheck{
GRPC: fmt.Sprintf("%s:%s", outboundIp, grpcBind[1]),
Timeout: "5s",
Interval: "1m",
DeregisterCriticalServiceAfter: "3m",
}
return client.Agent().ServiceRegister(registration)
}

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,33 @@
package grpc
import (
"git.solsynth.dev/hydrogen/paperclip/pkg/proto"
"github.com/spf13/viper"
"google.golang.org/grpc"
health "google.golang.org/grpc/health/grpc_health_v1"
"google.golang.org/grpc/reflection"
"net"
)
type Server struct {
proto.UnimplementedAttachmentsServer
}
var S *grpc.Server
func NewGRPC() {
S = grpc.NewServer()
health.RegisterHealthServer(S, &Server{})
reflection.Register(S)
}
func ListenGRPC() error {
listener, err := net.Listen("tcp", viper.GetString("grpc_bind"))
if err != nil {
return err
}
return S.Serve(listener)
}

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,18 @@
package models
// Account profiles basically fetched from Hydrogen.Identity
// But cache at here for better usage
// At the same time this model can make relations between local models
type Account struct {
BaseModel
Name string `json:"name"`
Nick string `json:"nick"`
Avatar string `json:"avatar"`
Banner string `json:"banner"`
Description string `json:"description"`
EmailAddress string `json:"email_address"`
PowerLevel int `json:"power_level"`
Channels []Channel `json:"channels"`
ExternalID uint `json:"external_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,15 @@
package models
import "time"
type Call struct {
BaseModel
EndedAt *time.Time `json:"ended_at"`
ExternalID string `json:"external_id"`
FounderID uint `json:"founder_id"`
ChannelID uint `json:"channel_id"`
Founder ChannelMember `json:"founder"`
Channel Channel `json:"channel"`
}

View File

@ -0,0 +1,49 @@
package models
type ChannelType = uint8
const (
ChannelTypeCommon = ChannelType(iota)
ChannelTypeDirect
)
type Channel struct {
BaseModel
Alias string `json:"alias"`
Name string `json:"name"`
Description string `json:"description"`
Members []ChannelMember `json:"members"`
Messages []Message `json:"messages"`
Calls []Call `json:"calls"`
Type ChannelType `json:"type"`
Account Account `json:"account"`
AccountID uint `json:"account_id"`
IsEncrypted bool `json:"is_encrypted"`
Realm Realm `json:"realm"`
RealmID *uint `json:"realm_id"`
}
type NotifyLevel = int8
const (
NotifyLevelAll = NotifyLevel(iota)
NotifyLevelMentioned
NotifyLevelNone
)
type ChannelMember struct {
BaseModel
ChannelID uint `json:"channel_id"`
AccountID uint `json:"account_id"`
Nick *string `json:"nick"`
Channel Channel `json:"channel"`
Account Account `json:"account"`
Notify NotifyLevel `json:"notify"`
PowerLevel int `json:"power_level"`
Calls []Call `json:"calls" gorm:"foreignKey:FounderID"`
Messages []Message `json:"messages" gorm:"foreignKey:SenderID"`
}

View File

@ -0,0 +1,22 @@
package models
import "gorm.io/datatypes"
const (
MessageTextType = "m.text"
)
type Message struct {
BaseModel
Uuid string `json:"uuid"`
Content datatypes.JSONMap `json:"content"`
Type string `json:"type"`
Attachments datatypes.JSONSlice[uint] `json:"attachments"`
Channel Channel `json:"channel"`
Sender ChannelMember `json:"sender"`
ReplyID *uint `json:"reply_id"`
ReplyTo *Message `json:"reply_to" gorm:"foreignKey:ReplyID"`
ChannelID uint `json:"channel_id"`
SenderID uint `json:"sender_id"`
}

View File

@ -0,0 +1,15 @@
package models
// Realm profiles basically fetched from Hydrogen.Passport
// But cache at here for better usage and database relations
type Realm struct {
BaseModel
Alias string `json:"alias"`
Name string `json:"name"`
Description string `json:"description"`
Channels []Channel `json:"channels"`
IsPublic bool `json:"is_public"`
IsCommunity bool `json:"is_community"`
ExternalID uint `json:"external_id"`
}

View File

@ -0,0 +1,21 @@
package models
import jsoniter "github.com/json-iterator/go"
type UnifiedCommand struct {
Action string `json:"w"`
Message string `json:"m"`
Payload any `json:"p"`
}
func UnifiedCommandFromError(err error) UnifiedCommand {
return UnifiedCommand{
Action: "error",
Message: err.Error(),
}
}
func (v UnifiedCommand) Marshal() []byte {
data, _ := jsoniter.Marshal(v)
return data
}

View File

@ -0,0 +1,8 @@
package models
import jsoniter "github.com/json-iterator/go"
func FitStruct(src any, out any) {
raw, _ := jsoniter.Marshal(src)
_ = jsoniter.Unmarshal(raw, out)
}

View File

@ -0,0 +1,140 @@
package api
import (
"git.solsynth.dev/hydrogen/messaging/pkg/internal/database"
"git.solsynth.dev/hydrogen/messaging/pkg/internal/models"
"git.solsynth.dev/hydrogen/messaging/pkg/internal/services"
"github.com/gofiber/fiber/v2"
"github.com/spf13/viper"
)
func listCall(c *fiber.Ctx) error {
take := c.QueryInt("take", 0)
offset := c.QueryInt("offset", 0)
alias := c.Params("channel")
var channel models.Channel
if err := database.C.Where(&models.Channel{
Alias: alias,
}).First(&channel).Error; err != nil {
return fiber.NewError(fiber.StatusNotFound, err.Error())
}
if calls, err := services.ListCall(channel, take, offset); err != nil {
return fiber.NewError(fiber.StatusNotFound, err.Error())
} else {
return c.JSON(calls)
}
}
func getOngoingCall(c *fiber.Ctx) error {
alias := c.Params("channel")
var channel models.Channel
if err := database.C.Where(&models.Channel{
Alias: alias,
}).First(&channel).Error; err != nil {
return fiber.NewError(fiber.StatusNotFound, err.Error())
}
if call, err := services.GetOngoingCall(channel); err != nil {
return fiber.NewError(fiber.StatusNotFound, err.Error())
} else {
return c.JSON(call)
}
}
func startCall(c *fiber.Ctx) error {
user := c.Locals("principal").(models.Account)
alias := c.Params("channel")
var channel models.Channel
if err := database.C.Where(&models.Channel{
Alias: alias,
}).First(&channel).Error; err != nil {
return fiber.NewError(fiber.StatusNotFound, err.Error())
}
var membership models.ChannelMember
if err := database.C.Where(&models.ChannelMember{
ChannelID: channel.ID,
AccountID: user.ID,
}).Find(&membership).Error; err != nil {
return fiber.NewError(fiber.StatusNotFound, err.Error())
}
call, err := services.NewCall(channel, membership)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
} else {
return c.JSON(call)
}
}
func endCall(c *fiber.Ctx) error {
user := c.Locals("principal").(models.Account)
alias := c.Params("channel")
var channel models.Channel
if err := database.C.Where(&models.Channel{
Alias: alias,
}).First(&channel).Error; err != nil {
return fiber.NewError(fiber.StatusNotFound, err.Error())
}
var membership models.ChannelMember
if err := database.C.Where(&models.ChannelMember{
ChannelID: channel.ID,
AccountID: user.ID,
}).Find(&membership).Error; err != nil {
return fiber.NewError(fiber.StatusNotFound, err.Error())
}
call, err := services.GetOngoingCall(channel)
if err != nil {
return fiber.NewError(fiber.StatusNotFound, err.Error())
} else if call.FounderID != user.ID && channel.AccountID != user.ID {
return fiber.NewError(fiber.StatusBadRequest, "only call founder or channel owner can end this call")
}
if call, err := services.EndCall(call); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, err.Error())
} else {
return c.JSON(call)
}
}
func exchangeCallToken(c *fiber.Ctx) error {
user := c.Locals("principal").(models.Account)
alias := c.Params("channel")
var channel models.Channel
if err := database.C.Where(&models.Channel{
Alias: alias,
}).First(&channel).Error; err != nil {
return fiber.NewError(fiber.StatusNotFound, err.Error())
}
var membership models.ChannelMember
if err := database.C.Where(&models.ChannelMember{
ChannelID: channel.ID,
AccountID: user.ID,
}).Find(&membership).Error; err != nil {
return fiber.NewError(fiber.StatusNotFound, err.Error())
}
call, err := services.GetOngoingCall(channel)
if err != nil {
return fiber.NewError(fiber.StatusNotFound, err.Error())
}
tk, err := services.EncodeCallToken(user, call)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, err.Error())
} else {
return c.JSON(fiber.Map{
"token": tk,
"endpoint": viper.GetString("calling.endpoint"),
})
}
}

View File

@ -0,0 +1,231 @@
package api
import (
"fmt"
"git.solsynth.dev/hydrogen/messaging/pkg/internal/server/exts"
"git.solsynth.dev/hydrogen/messaging/pkg/internal/database"
"git.solsynth.dev/hydrogen/messaging/pkg/internal/models"
"git.solsynth.dev/hydrogen/messaging/pkg/internal/services"
"github.com/gofiber/fiber/v2"
)
func listChannelMembers(c *fiber.Ctx) error {
alias := c.Params("channel")
var err error
var channel models.Channel
if val, ok := c.Locals("realm").(models.Realm); ok {
channel, err = services.GetChannelWithAlias(alias, val.ID)
} else {
channel, err = services.GetChannelWithAlias(alias)
}
if err != nil {
return fiber.NewError(fiber.StatusNotFound, err.Error())
}
if members, err := services.ListChannelMember(channel.ID); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, err.Error())
} else {
return c.JSON(members)
}
}
func getMyChannelMembership(c *fiber.Ctx) error {
alias := c.Params("channel")
user := c.Locals("principal").(models.Account)
var err error
var channel models.Channel
if val, ok := c.Locals("realm").(models.Realm); ok {
channel, err = services.GetChannelWithAlias(alias, val.ID)
} else {
channel, err = services.GetChannelWithAlias(alias)
}
if err != nil {
return fiber.NewError(fiber.StatusNotFound, err.Error())
}
if member, err := services.GetChannelMember(user, channel.ID); err != nil {
return fiber.NewError(fiber.StatusNotFound, err.Error())
} else {
return c.JSON(member)
}
}
func addChannelMember(c *fiber.Ctx) error {
user := c.Locals("principal").(models.Account)
alias := c.Params("channel")
var data struct {
Target string `json:"target" validate:"required"`
}
if err := exts.BindAndValidate(c, &data); err != nil {
return err
}
var channel models.Channel
if err := database.C.Where(&models.Channel{
Alias: alias,
}).First(&channel).Error; err != nil {
return fiber.NewError(fiber.StatusNotFound, err.Error())
} else if channel.Type == models.ChannelTypeDirect {
return fiber.NewError(fiber.StatusBadRequest, "direct message member changes was not allowed")
}
if member, err := services.GetChannelMember(user, channel.ID); err != nil {
return fiber.NewError(fiber.StatusForbidden, err.Error())
} else if member.PowerLevel < 50 {
return fiber.NewError(fiber.StatusForbidden, "you must be a moderator of a channel to add member into it")
}
var account models.Account
if err := database.C.Where(&models.Account{
Name: data.Target,
}).First(&account).Error; err != nil {
return fiber.NewError(fiber.StatusNotFound, err.Error())
}
if err := services.AddChannelMemberWithCheck(account, channel); err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
} else {
return c.SendStatus(fiber.StatusOK)
}
}
func removeChannelMember(c *fiber.Ctx) error {
user := c.Locals("principal").(models.Account)
alias := c.Params("channel")
var data struct {
Target string `json:"target" validate:"required"`
}
if err := exts.BindAndValidate(c, &data); err != nil {
return err
}
var channel models.Channel
if err := database.C.Where(&models.Channel{
Alias: alias,
AccountID: user.ID,
}).First(&channel).Error; err != nil {
return fiber.NewError(fiber.StatusNotFound, err.Error())
} else if channel.Type == models.ChannelTypeDirect {
return fiber.NewError(fiber.StatusBadRequest, "direct message member changes was not allowed")
}
if member, err := services.GetChannelMember(user, channel.ID); err != nil {
return fiber.NewError(fiber.StatusForbidden, err.Error())
} else if member.PowerLevel < 50 {
return fiber.NewError(fiber.StatusForbidden, "you must be a moderator of a channel to remove member into it")
}
var account models.Account
if err := database.C.Where(&models.Account{
Name: data.Target,
}).First(&account).Error; err != nil {
return fiber.NewError(fiber.StatusNotFound, err.Error())
}
if err := services.RemoveChannelMember(account, channel); err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
} else {
return c.SendStatus(fiber.StatusOK)
}
}
func editMyChannelMembership(c *fiber.Ctx) error {
user := c.Locals("principal").(models.Account)
alias := c.Params("channel")
var data struct {
Nick string `json:"nick"`
NotifyLevel int8 `json:"notify_level"`
}
if err := exts.BindAndValidate(c, &data); err != nil {
return err
}
var err error
var channel models.Channel
if val, ok := c.Locals("realm").(models.Realm); ok {
channel, err = services.GetChannelWithAlias(alias, val.ID)
} else {
channel, err = services.GetChannelWithAlias(alias)
}
if err != nil {
return fiber.NewError(fiber.StatusNotFound, err.Error())
}
var membership models.ChannelMember
if err := database.C.Where(&models.ChannelMember{
ChannelID: channel.ID,
AccountID: user.ID,
}).First(&membership).Error; err != nil {
return fiber.NewError(fiber.StatusNotFound, err.Error())
}
membership.Notify = data.NotifyLevel
if len(data.Nick) > 0 {
membership.Nick = &data.Nick
} else {
membership.Nick = nil
}
if membership, err := services.EditChannelMember(membership); err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
} else {
return c.JSON(membership)
}
}
func joinChannel(c *fiber.Ctx) error {
user := c.Locals("principal").(models.Account)
alias := c.Params("channel")
var channel models.Channel
if err := database.C.Where(&models.Channel{
Alias: alias,
}).Preload("Realm").First(&channel).Error; err != nil {
return fiber.NewError(fiber.StatusNotFound, err.Error())
} else if _, _, err := services.GetAvailableChannel(channel.ID, user); err == nil {
return fiber.NewError(fiber.StatusBadRequest, "you already joined the channel")
} else if channel.RealmID == nil {
return fiber.NewError(fiber.StatusBadRequest, "you was impossible to join a channel without related realm")
}
if realm, err := services.GetRealm(channel.Realm.ExternalID); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("invalid channel, related realm was not found: %v", err))
} else if _, err := services.GetRealmMember(realm.ExternalID, user.ExternalID); err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("you are not a part of the realm: %v", err))
}
if err := services.AddChannelMember(user, channel); err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
} else {
return c.SendStatus(fiber.StatusOK)
}
}
func leaveChannel(c *fiber.Ctx) error {
user := c.Locals("principal").(models.Account)
alias := c.Params("channel")
var channel models.Channel
if err := database.C.Where(&models.Channel{
Alias: alias,
}).First(&channel).Error; err != nil {
return fiber.NewError(fiber.StatusNotFound, err.Error())
} else if user.ID == channel.AccountID {
return fiber.NewError(fiber.StatusBadRequest, "you cannot leave your own channel")
}
if err := services.RemoveChannelMember(user, channel); err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
} else {
return c.SendStatus(fiber.StatusOK)
}
}

View File

@ -0,0 +1,234 @@
package api
import (
"fmt"
"git.solsynth.dev/hydrogen/messaging/pkg/internal/server/exts"
"git.solsynth.dev/hydrogen/messaging/pkg/internal/database"
"git.solsynth.dev/hydrogen/messaging/pkg/internal/models"
"git.solsynth.dev/hydrogen/messaging/pkg/internal/services"
"github.com/gofiber/fiber/v2"
)
func getChannel(c *fiber.Ctx) error {
alias := c.Params("channel")
var err error
var channel models.Channel
if val, ok := c.Locals("realm").(models.Realm); ok {
channel, err = services.GetChannelWithAlias(alias, val.ID)
} else {
channel, err = services.GetChannelWithAlias(alias)
}
if err != nil {
return fiber.NewError(fiber.StatusNotFound, err.Error())
}
return c.JSON(channel)
}
func getChannelIdentity(c *fiber.Ctx) error {
user := c.Locals("principal").(models.Account)
alias := c.Params("channel")
var err error
var member models.ChannelMember
if val, ok := c.Locals("realm").(models.Realm); ok {
_, member, err = services.GetAvailableChannelWithAlias(alias, user, val.ID)
} else {
_, member, err = services.GetAvailableChannelWithAlias(alias, user)
}
if err != nil {
return c.SendStatus(fiber.StatusForbidden)
}
return c.JSON(member)
}
func listChannel(c *fiber.Ctx) error {
var err error
var channels []models.Channel
if val, ok := c.Locals("realm").(models.Realm); ok {
channels, err = services.ListChannel(val.ID)
} else {
channels, err = services.ListChannel()
}
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
}
return c.JSON(channels)
}
func listOwnedChannel(c *fiber.Ctx) error {
user := c.Locals("principal").(models.Account)
var err error
var channels []models.Channel
if val, ok := c.Locals("realm").(models.Realm); ok {
channels, err = services.ListChannelWithUser(user, val.ID)
} else {
channels, err = services.ListChannelWithUser(user)
}
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
}
return c.JSON(channels)
}
func listAvailableChannel(c *fiber.Ctx) error {
user := c.Locals("principal").(models.Account)
var err error
var channels []models.Channel
if val, ok := c.Locals("realm").(models.Realm); ok {
channels, err = services.ListAvailableChannel(user, val.ID)
} else {
channels, err = services.ListAvailableChannel(user)
}
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
}
return c.JSON(channels)
}
func createChannel(c *fiber.Ctx) error {
user := c.Locals("principal").(models.Account)
var data struct {
Alias string `json:"alias" validate:"required,lowercase,min=4,max=32"`
Name string `json:"name" validate:"required"`
Description string `json:"description"`
IsEncrypted bool `json:"is_encrypted"`
}
if err := exts.BindAndValidate(c, &data); err != nil {
return err
} else if err = services.GetChannelAliasAvailability(data.Alias); err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
}
var realm *models.Realm
if val, ok := c.Locals("realm").(models.Realm); ok {
if info, err := services.GetRealmMember(val.ExternalID, user.ExternalID); err != nil {
return fiber.NewError(fiber.StatusForbidden, "you must be a part of that realm then can create channel related to it")
} else if info.GetPowerLevel() < 50 {
return fiber.NewError(fiber.StatusForbidden, "you must be a moderator of that realm then can create channel related to it")
} else {
realm = &val
}
}
channel := models.Channel{
Alias: data.Alias,
Name: data.Name,
Description: data.Description,
IsEncrypted: data.IsEncrypted,
AccountID: user.ID,
Type: models.ChannelTypeCommon,
Members: []models.ChannelMember{
{AccountID: user.ID, PowerLevel: 100},
},
}
if realm != nil {
channel.RealmID = &realm.ID
}
channel, err := services.NewChannel(channel)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
}
return c.JSON(channel)
}
func editChannel(c *fiber.Ctx) error {
user := c.Locals("principal").(models.Account)
id, _ := c.ParamsInt("channelId", 0)
var data struct {
Alias string `json:"alias" validate:"required,min=4,max=32"`
Name string `json:"name" validate:"required"`
Description string `json:"description"`
IsEncrypted bool `json:"is_encrypted"`
}
if err := exts.BindAndValidate(c, &data); err != nil {
return err
}
tx := database.C.Where(&models.Channel{BaseModel: models.BaseModel{ID: uint(id)}})
if val, ok := c.Locals("realm").(models.Realm); ok {
if info, err := services.GetRealmMember(val.ExternalID, user.ExternalID); err != nil {
return fiber.NewError(fiber.StatusForbidden, "you must be a part of that realm then can edit channel related to it")
} else if info.GetPowerLevel() < 50 {
return fiber.NewError(fiber.StatusForbidden, "you must be a moderator of that realm then can edit channel related to it")
} else {
tx = tx.Where("realm_id = ?", val.ID)
}
} else {
tx = tx.Where("realm_id IS NULL")
}
var channel models.Channel
if err := tx.First(&channel).Error; err != nil {
return fiber.NewError(fiber.StatusNotFound, err.Error())
}
if channel.RealmID != nil {
if member, err := services.GetChannelMember(user, channel.ID); err != nil {
return fiber.NewError(fiber.StatusForbidden, "you must be a part of this channel to edit it")
} else if member.PowerLevel < 100 {
return fiber.NewError(fiber.StatusForbidden, "you must be channel admin to edit it")
}
}
channel, err := services.EditChannel(channel, data.Alias, data.Name, data.Description, data.IsEncrypted)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
}
return c.JSON(channel)
}
func deleteChannel(c *fiber.Ctx) error {
user := c.Locals("principal").(models.Account)
id, _ := c.ParamsInt("channelId", 0)
tx := database.C.Where(&models.Channel{BaseModel: models.BaseModel{ID: uint(id)}})
if val, ok := c.Locals("realm").(models.Realm); ok {
if info, err := services.GetRealmMember(val.ExternalID, user.ExternalID); err != nil {
return fmt.Errorf("you must be a part of that realm then can delete channel related to it")
} else if info.GetPowerLevel() < 50 {
return fmt.Errorf("you must be a moderator of that realm then can delete channel related to it")
} else {
tx = tx.Where("realm_id = ?", val.ID)
}
} else {
tx = tx.Where("(account_id = ? OR type = ?) AND realm_id IS NULL", user.ID, models.ChannelTypeDirect)
}
var channel models.Channel
if err := tx.First(&channel).Error; err != nil {
return fiber.NewError(fiber.StatusNotFound, err.Error())
}
if channel.Type == models.ChannelTypeDirect {
if member, err := services.GetChannelMember(user, channel.ID); err != nil {
return fiber.NewError(fiber.StatusForbidden, "you must related to this direct message if you want delete it")
} else if member.PowerLevel < 100 {
return fiber.NewError(fiber.StatusForbidden, "you must be a moderator of this direct message if you want delete it")
}
}
if err := services.DeleteChannel(channel); err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
}
return c.SendStatus(fiber.StatusOK)
}

View File

@ -0,0 +1,71 @@
package api
import (
"fmt"
"git.solsynth.dev/hydrogen/messaging/pkg/internal/server/exts"
"git.solsynth.dev/hydrogen/messaging/pkg/internal/database"
"git.solsynth.dev/hydrogen/messaging/pkg/internal/models"
"git.solsynth.dev/hydrogen/messaging/pkg/internal/services"
"github.com/gofiber/fiber/v2"
)
func createDirectChannel(c *fiber.Ctx) error {
user := c.Locals("principal").(models.Account)
var data struct {
Alias string `json:"alias" validate:"required,lowercase,min=4,max=32"`
Name string `json:"name" validate:"required"`
Description string `json:"description"`
RelatedUser uint `json:"related_user"`
IsEncrypted bool `json:"is_encrypted"`
}
if err := exts.BindAndValidate(c, &data); err != nil {
return err
} else if err = services.GetChannelAliasAvailability(data.Alias); err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
}
var realm *models.Realm
if val, ok := c.Locals("realm").(models.Realm); ok {
if info, err := services.GetRealmMember(val.ExternalID, user.ExternalID); err != nil {
return fiber.NewError(fiber.StatusForbidden, "you must be a part of that realm then can create channel related to it")
} else if info.GetPowerLevel() < 50 {
return fiber.NewError(fiber.StatusForbidden, "you must be a moderator of that realm then can create channel related to it")
} else {
realm = &val
}
}
var relatedUser models.Account
if err := database.C.
Where("external_id = ?", data.RelatedUser).
First(&relatedUser).Error; err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("unable to find related user: %v", err))
}
channel := models.Channel{
Alias: data.Alias,
Name: data.Name,
Description: data.Description,
IsEncrypted: data.IsEncrypted,
AccountID: user.ID,
Type: models.ChannelTypeDirect,
Members: []models.ChannelMember{
{AccountID: user.ID, PowerLevel: 100},
{AccountID: relatedUser.ID, PowerLevel: 100},
},
}
if realm != nil {
channel.RealmID = &realm.ID
}
channel, err := services.NewChannel(channel)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
}
return c.JSON(channel)
}

View File

@ -0,0 +1,49 @@
package api
import (
"github.com/gofiber/contrib/websocket"
"github.com/gofiber/fiber/v2"
)
func MapAPIs(app *fiber.App) {
api := app.Group("/api").Name("API")
{
api.Get("/users/me", getUserinfo)
api.Get("/users/:accountId", getOthersInfo)
channels := api.Group("/channels/:realm").Use(realmMiddleware).Name("Channels API")
{
channels.Get("/", listChannel)
channels.Get("/me", listOwnedChannel)
channels.Get("/me/available", listAvailableChannel)
channels.Get("/:channel", getChannel)
channels.Get("/:channel/me", getChannelIdentity)
channels.Post("/", createChannel)
channels.Post("/dm", createDirectChannel)
channels.Put("/:channelId", editChannel)
channels.Delete("/:channelId", deleteChannel)
channels.Get("/:channel/members", listChannelMembers)
channels.Get("/:channel/members/me", getMyChannelMembership)
channels.Put("/:channel/members/me", editMyChannelMembership)
channels.Post("/:channel/members", addChannelMember)
channels.Post("/:channel/members/me", joinChannel)
channels.Delete("/:channel/members", removeChannelMember)
channels.Delete("/:channel/members/me", leaveChannel)
channels.Get("/:channel/messages", listMessage)
channels.Post("/:channel/messages", newMessage)
channels.Put("/:channel/messages/:messageId", editMessage)
channels.Delete("/:channel/messages/:messageId", deleteMessage)
channels.Get("/:channel/calls", listCall)
channels.Get("/:channel/calls/ongoing", getOngoingCall)
channels.Post("/:channel/calls", startCall)
channels.Delete("/:channel/calls/ongoing", endCall)
channels.Post("/:channel/calls/ongoing/token", exchangeCallToken)
}
api.Get("/ws", websocket.New(messageGateway))
}
}

View File

@ -0,0 +1,208 @@
package api
import (
"fmt"
"git.solsynth.dev/hydrogen/messaging/pkg/internal/database"
"git.solsynth.dev/hydrogen/messaging/pkg/internal/models"
"git.solsynth.dev/hydrogen/messaging/pkg/internal/server/exts"
"git.solsynth.dev/hydrogen/messaging/pkg/internal/services"
"github.com/gofiber/fiber/v2"
"strings"
)
func listMessage(c *fiber.Ctx) error {
user := c.Locals("principal").(models.Account)
take := c.QueryInt("take", 0)
offset := c.QueryInt("offset", 0)
alias := c.Params("channel")
var err error
var channel models.Channel
if val, ok := c.Locals("realm").(models.Realm); ok {
channel, err = services.GetChannelWithAlias(alias, val.ID)
} else {
channel, err = services.GetChannelWithAlias(alias)
}
if err != nil {
return fiber.NewError(fiber.StatusNotFound, err.Error())
} else if _, _, err := services.GetAvailableChannel(channel.ID, user); err != nil {
return fiber.NewError(fiber.StatusForbidden, fmt.Sprintf("you need join the channel before you read the messages: %v", err))
}
count := services.CountMessage(channel)
messages, err := services.ListMessage(channel, take, offset)
if err != nil {
return fiber.NewError(fiber.StatusNotFound, err.Error())
}
return c.JSON(fiber.Map{
"count": count,
"data": messages,
})
}
func newMessage(c *fiber.Ctx) error {
user := c.Locals("principal").(models.Account)
alias := c.Params("channel")
var data struct {
Uuid string `json:"uuid" validate:"required"`
Type string `json:"type" validate:"required"`
Content map[string]any `json:"content"`
Attachments []uint `json:"attachments"`
ReplyTo *uint `json:"reply_to"`
}
if err := exts.BindAndValidate(c, &data); err != nil {
return err
} else if len(data.Uuid) < 36 {
return fiber.NewError(fiber.StatusBadRequest, "message uuid was not valid")
}
if len(data.Attachments) == 0 {
if data.Type == models.MessageTextType {
if val, ok := data.Content["value"].(string); ok && len(strings.TrimSpace(val)) == 0 {
return fiber.NewError(fiber.StatusBadRequest, "you cannot send an empty message")
} else if !ok {
return fiber.NewError(fiber.StatusBadRequest, "invalid content of text message")
}
}
}
for _, attachment := range data.Attachments {
if !services.CheckAttachmentByIDExists(attachment, "m.attachment") {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("attachment %d not found", attachment))
}
}
var err error
var channel models.Channel
var member models.ChannelMember
if val, ok := c.Locals("realm").(models.Realm); ok {
channel, member, err = services.GetAvailableChannelWithAlias(alias, user, val.ID)
if err != nil {
return fiber.NewError(fiber.StatusNotFound, err.Error())
}
} else {
channel, member, err = services.GetAvailableChannelWithAlias(alias, user)
if err != nil {
return fiber.NewError(fiber.StatusNotFound, err.Error())
} else if member.PowerLevel < 0 {
return fiber.NewError(fiber.StatusForbidden, "you have not enough permission to send message")
}
}
message := models.Message{
Uuid: data.Uuid,
Content: data.Content,
Sender: member,
Channel: channel,
ChannelID: channel.ID,
SenderID: member.ID,
Attachments: data.Attachments,
Type: data.Type,
}
var replyTo models.Message
if data.ReplyTo != nil {
if err := database.C.Where("id = ?", data.ReplyTo).First(&replyTo).Error; err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("message to reply was not found: %v", err))
} else {
message.ReplyTo = &replyTo
message.ReplyID = &replyTo.ID
}
}
if message, err = services.NewMessage(message); err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
}
return c.JSON(message)
}
func editMessage(c *fiber.Ctx) error {
user := c.Locals("principal").(models.Account)
alias := c.Params("channel")
messageId, _ := c.ParamsInt("messageId", 0)
var data struct {
Type string `json:"type" validate:"required"`
Content map[string]any `json:"content"`
Attachments []uint `json:"attachments"`
ReplyTo *uint `json:"reply_to"`
}
if err := exts.BindAndValidate(c, &data); err != nil {
return err
}
for _, attachment := range data.Attachments {
if !services.CheckAttachmentByIDExists(attachment, "m.attachment") {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("attachment %d not found", attachment))
}
}
var err error
var channel models.Channel
var member models.ChannelMember
if val, ok := c.Locals("realm").(models.Realm); ok {
channel, member, err = services.GetAvailableChannelWithAlias(alias, user, val.ID)
if err != nil {
return fiber.NewError(fiber.StatusNotFound, err.Error())
}
} else {
channel, member, err = services.GetAvailableChannelWithAlias(alias, user)
if err != nil {
return fiber.NewError(fiber.StatusNotFound, err.Error())
}
}
var message models.Message
if message, err = services.GetMessageWithPrincipal(channel, member, uint(messageId)); err != nil {
return fiber.NewError(fiber.StatusNotFound, err.Error())
}
message.Attachments = data.Attachments
message.Content = data.Content
message.Type = data.Type
message, err = services.EditMessage(message)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
}
return c.JSON(message)
}
func deleteMessage(c *fiber.Ctx) error {
user := c.Locals("principal").(models.Account)
alias := c.Params("channel")
messageId, _ := c.ParamsInt("messageId", 0)
var err error
var channel models.Channel
var member models.ChannelMember
if val, ok := c.Locals("realm").(models.Realm); ok {
channel, member, err = services.GetAvailableChannelWithAlias(alias, user, val.ID)
if err != nil {
return fiber.NewError(fiber.StatusNotFound, err.Error())
}
} else {
channel, member, err = services.GetAvailableChannelWithAlias(alias, user)
if err != nil {
return fiber.NewError(fiber.StatusNotFound, err.Error())
}
}
var message models.Message
if message, err = services.GetMessageWithPrincipal(channel, member, uint(messageId)); err != nil {
return fiber.NewError(fiber.StatusNotFound, err.Error())
}
message, err = services.DeleteMessage(message)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
}
return c.JSON(message)
}

View File

@ -0,0 +1,48 @@
package api
import (
"git.solsynth.dev/hydrogen/messaging/pkg/internal/models"
"git.solsynth.dev/hydrogen/messaging/pkg/internal/services"
"github.com/gofiber/contrib/websocket"
jsoniter "github.com/json-iterator/go"
"github.com/rs/zerolog/log"
)
func messageGateway(c *websocket.Conn) {
user := c.Locals("principal").(models.Account)
// Push connection
services.ClientRegister(user, c)
log.Debug().Uint("user", user.ID).Msg("New websocket connection established...")
// Event loop
var task models.UnifiedCommand
var messageType int
var packet []byte
var err error
for {
if messageType, packet, err = c.ReadMessage(); err != nil {
break
} else if err := jsoniter.Unmarshal(packet, &task); err != nil {
_ = c.WriteMessage(messageType, models.UnifiedCommand{
Action: "error",
Message: "unable to unmarshal your command, requires json request",
}.Marshal())
continue
}
message := services.DealCommand(task, user)
if message != nil {
if err = c.WriteMessage(messageType, message.Marshal()); 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,21 @@
package api
import (
"fmt"
"git.solsynth.dev/hydrogen/messaging/pkg/internal/services"
"github.com/gofiber/fiber/v2"
)
func realmMiddleware(c *fiber.Ctx) error {
realmAlias := c.Params("realm")
if len(realmAlias) > 0 && realmAlias != "global" {
realm, err := services.GetRealmWithAlias(realmAlias)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("requested channel with realm, but realm was not found: %v", err))
} else {
c.Locals("realm", realm)
}
}
return c.Next()
}

View File

@ -0,0 +1,33 @@
package api
import (
"git.solsynth.dev/hydrogen/messaging/pkg/internal/database"
"git.solsynth.dev/hydrogen/messaging/pkg/internal/models"
"github.com/gofiber/fiber/v2"
)
func getUserinfo(c *fiber.Ctx) error {
user := c.Locals("principal").(models.Account)
var data models.Account
if err := database.C.
Where(&models.Account{BaseModel: models.BaseModel{ID: user.ID}}).
First(&data).Error; err != nil {
return fiber.NewError(fiber.StatusInternalServerError, err.Error())
}
return c.JSON(data)
}
func getOthersInfo(c *fiber.Ctx) error {
accountId := c.Params("accountId")
var data models.Account
if err := database.C.
Where(&models.Account{Name: accountId}).
First(&data).Error; err != nil {
return fiber.NewError(fiber.StatusInternalServerError, err.Error())
}
return c.JSON(data)
}

View File

@ -0,0 +1,19 @@
package exts
import (
"git.solsynth.dev/hydrogen/messaging/pkg/internal/services"
"git.solsynth.dev/hydrogen/passport/pkg/proto"
"github.com/gofiber/fiber/v2"
)
func LinkAccountMiddleware(c *fiber.Ctx) error {
if val, ok := c.Locals("p_user").(*proto.Userinfo); ok {
if account, err := services.LinkAccount(val); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, err.Error())
} else {
c.Locals("user", account)
}
}
return c.Next()
}

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,86 @@
package server
import (
"git.solsynth.dev/hydrogen/messaging/pkg/internal"
"git.solsynth.dev/hydrogen/messaging/pkg/internal/gap"
"git.solsynth.dev/hydrogen/messaging/pkg/internal/server/api"
"git.solsynth.dev/hydrogen/messaging/pkg/internal/server/exts"
"net/http"
"strings"
"github.com/gofiber/fiber/v2/middleware/favicon"
"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"
"github.com/gofiber/template/html/v2"
jsoniter "github.com/json-iterator/go"
"github.com/rs/zerolog/log"
"github.com/spf13/viper"
)
var A *fiber.App
func NewServer() {
templates := html.NewFileSystem(http.FS(pkg.FS), ".gohtml")
A = fiber.New(fiber.Config{
DisableStartupMessage: true,
EnableIPValidation: true,
ServerHeader: "Hydrogen.Messaging",
AppName: "Hydrogen.Messaging",
ProxyHeader: fiber.HeaderXForwardedFor,
JSONEncoder: jsoniter.ConfigCompatibleWithStandardLibrary.Marshal,
JSONDecoder: jsoniter.ConfigCompatibleWithStandardLibrary.Unmarshal,
BodyLimit: 50 * 1024 * 1024,
EnablePrintRoutes: viper.GetBool("debug.print_routes"),
Views: templates,
ViewsLayout: "views/index",
})
A.Use(idempotency.New())
A.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
},
}))
A.Use(logger.New(logger.Config{
Format: "${status} | ${latency} | ${method} ${path}\n",
Output: log.Logger,
}))
A.Use(gap.H.AuthMiddleware)
A.Use(exts.LinkAccountMiddleware)
A.Use(favicon.New(favicon.Config{
FileSystem: http.FS(pkg.FS),
File: "views/favicon.png",
URL: "/favicon.png",
}))
api.MapAPIs(A)
A.Get("/", func(c *fiber.Ctx) error {
return c.Render("views/open", fiber.Map{
"frontend": viper.GetString("frontend"),
})
})
}
func Listen() {
if err := A.Listen(viper.GetString("bind")); err != nil {
log.Fatal().Err(err).Msg("An error occurred when starting server...")
}
}

View File

@ -0,0 +1,60 @@
package services
import (
"context"
"fmt"
"git.solsynth.dev/hydrogen/messaging/pkg/internal/database"
"git.solsynth.dev/hydrogen/messaging/pkg/internal/gap"
"time"
"git.solsynth.dev/hydrogen/messaging/pkg/internal/models"
"git.solsynth.dev/hydrogen/passport/pkg/proto"
"github.com/spf13/viper"
)
func GetAccountFriend(userId, relatedId uint, status int) (*proto.FriendshipResponse, error) {
var user models.Account
if err := database.C.Where("id = ?", userId).First(&user).Error; err != nil {
return nil, err
}
var related models.Account
if err := database.C.Where("id = ?", relatedId).First(&related).Error; err != nil {
return nil, err
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
defer cancel()
pc, err := gap.H.DiscoverServiceGRPC("Hydrogen.Passport")
if err != nil {
return nil, err
}
return proto.NewFriendshipsClient(pc).GetFriendship(ctx, &proto.FriendshipTwoSideLookupRequest{
AccountId: uint64(user.ExternalID),
RelatedId: uint64(related.ExternalID),
Status: uint32(status),
})
}
func NotifyAccountMessager(user models.Account, t, s, c string, realtime bool, forcePush bool, links ...*proto.NotifyLink) error {
ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
defer cancel()
pc, err := gap.H.DiscoverServiceGRPC("Hydrogen.Passport")
if err != nil {
return err
}
_, err = proto.NewNotifyClient(pc).NotifyUser(ctx, &proto.NotifyRequest{
ClientId: viper.GetString("passport.client_id"),
ClientSecret: viper.GetString("passport.client_secret"),
Type: fmt.Sprintf("messaging.%s", t),
Subject: s,
Content: c,
Links: links,
RecipientId: uint64(user.ExternalID),
IsRealtime: realtime,
IsForcePush: forcePush,
})
return err
}

View File

@ -0,0 +1,22 @@
package services
import (
"context"
"git.solsynth.dev/hydrogen/messaging/pkg/internal/gap"
"git.solsynth.dev/hydrogen/paperclip/pkg/proto"
"github.com/samber/lo"
)
func CheckAttachmentByIDExists(id uint, usage string) bool {
pc, err := gap.H.DiscoverServiceGRPC("Hydrogen.Passport")
if err != nil {
return false
}
_, err = proto.NewAttachmentsClient(pc).CheckAttachmentExists(context.Background(), &proto.AttachmentLookupRequest{
Id: lo.ToPtr(uint64(id)),
Usage: &usage,
})
return err == nil
}

View File

@ -0,0 +1,51 @@
package services
import (
"errors"
"fmt"
"git.solsynth.dev/hydrogen/messaging/pkg/internal/database"
"git.solsynth.dev/hydrogen/messaging/pkg/internal/models"
"git.solsynth.dev/hydrogen/passport/pkg/proto"
"gorm.io/gorm"
"reflect"
)
func LinkAccount(userinfo *proto.Userinfo) (models.Account, error) {
var account models.Account
if userinfo == nil {
return account, fmt.Errorf("remote userinfo was not found")
}
if err := database.C.Where(&models.Account{
ExternalID: uint(userinfo.Id),
}).First(&account).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
account = models.Account{
Name: userinfo.Name,
Nick: userinfo.Nick,
Avatar: userinfo.Avatar,
Banner: userinfo.Banner,
Description: userinfo.GetDescription(),
EmailAddress: userinfo.Email,
PowerLevel: 0,
ExternalID: uint(userinfo.Id),
}
return account, database.C.Save(&account).Error
}
return account, err
}
prev := account
account.Name = userinfo.Name
account.Nick = userinfo.Nick
account.Avatar = userinfo.Avatar
account.Banner = userinfo.Banner
account.Description = userinfo.GetDescription()
account.EmailAddress = userinfo.Email
var err error
if !reflect.DeepEqual(account, prev) {
err = database.C.Save(&account).Error
}
return account, err
}

View File

@ -0,0 +1,172 @@
package services
import (
"context"
"errors"
"fmt"
"git.solsynth.dev/hydrogen/messaging/pkg/internal/database"
"git.solsynth.dev/hydrogen/messaging/pkg/internal/models"
jsoniter "github.com/json-iterator/go"
"github.com/livekit/protocol/auth"
"github.com/livekit/protocol/livekit"
"github.com/rs/zerolog/log"
"github.com/samber/lo"
"github.com/spf13/viper"
"gorm.io/gorm"
"time"
)
func ListCall(channel models.Channel, take, offset int) ([]models.Call, error) {
var calls []models.Call
if err := database.C.
Where(models.Call{ChannelID: channel.ID}).
Limit(take).
Offset(offset).
Preload("Founder").
Preload("Founder.Account").
Preload("Channel").
Order("created_at DESC").
Find(&calls).Error; err != nil {
return calls, err
} else {
return calls, nil
}
}
func GetCall(channel models.Channel, id uint) (models.Call, error) {
var call models.Call
if err := database.C.
Where(models.Call{
BaseModel: models.BaseModel{ID: id},
ChannelID: channel.ID,
}).
Preload("Founder").
Preload("Founder.Account").
Preload("Channel").
Order("created_at DESC").
First(&call).Error; err != nil {
return call, err
} else {
return call, nil
}
}
func GetOngoingCall(channel models.Channel) (models.Call, error) {
var call models.Call
if err := database.C.
Where(models.Call{ChannelID: channel.ID}).
Where("ended_at IS NULL").
Preload("Founder").
Preload("Channel").
Order("created_at DESC").
First(&call).Error; err != nil {
return call, err
} else {
return call, nil
}
}
func NewCall(channel models.Channel, founder models.ChannelMember) (models.Call, error) {
call := models.Call{
ExternalID: channel.Alias,
FounderID: founder.ID,
ChannelID: channel.ID,
Founder: founder,
Channel: channel,
}
if _, err := GetOngoingCall(channel); err == nil || !errors.Is(err, gorm.ErrRecordNotFound) {
return call, fmt.Errorf("this channel already has an ongoing call")
}
_, err := Lk.CreateRoom(context.Background(), &livekit.CreateRoomRequest{
Name: call.ExternalID,
EmptyTimeout: viper.GetUint32("calling.empty_timeout_duration"),
MaxParticipants: viper.GetUint32("calling.max_participants"),
})
if err != nil {
return call, fmt.Errorf("remote livekit error: %v", err)
}
var members []models.ChannelMember
if err := database.C.Save(&call).Error; err != nil {
return call, err
} else if err = database.C.Where(models.ChannelMember{
ChannelID: call.ChannelID,
}).Preload("Account").Find(&members).Error; err == nil {
channel := call.Channel
call, _ = GetCall(call.Channel, call.ID)
for _, member := range members {
if member.ID != call.Founder.ID {
err = NotifyAccountMessager(member.Account,
"incomingCall",
fmt.Sprintf("Call in #%s", channel.Alias),
fmt.Sprintf("%s started a new call", call.Founder.Account.Name),
false,
true,
)
if err != nil {
log.Warn().Err(err).Msg("An error occurred when trying notify user.")
}
}
PushCommand(member.AccountID, models.UnifiedCommand{
Action: "calls.new",
Payload: call,
})
}
}
return call, nil
}
func EndCall(call models.Call) (models.Call, error) {
call.EndedAt = lo.ToPtr(time.Now())
if _, err := Lk.DeleteRoom(context.Background(), &livekit.DeleteRoomRequest{
Room: call.ExternalID,
}); err != nil {
log.Error().Err(err).Msg("Unable to delete room at livekit side")
}
var members []models.ChannelMember
if err := database.C.Save(&call).Error; err != nil {
return call, err
} else if err = database.C.Where(models.ChannelMember{
ChannelID: call.ChannelID,
}).Preload("Account").Find(&members).Error; err == nil {
call, _ = GetCall(call.Channel, call.ID)
for _, member := range members {
PushCommand(member.AccountID, models.UnifiedCommand{
Action: "calls.end",
Payload: call,
})
}
}
return call, nil
}
func EncodeCallToken(user models.Account, call models.Call) (string, error) {
isAdmin := false
if user.ID == call.FounderID || user.ID == call.Channel.AccountID {
isAdmin = true
}
grant := &auth.VideoGrant{
Room: call.ExternalID,
RoomJoin: true,
RoomAdmin: isAdmin,
}
metadata, _ := jsoniter.Marshal(user)
duration := time.Second * time.Duration(viper.GetInt("calling.token_duration"))
tk := auth.NewAccessToken(viper.GetString("calling.api_key"), viper.GetString("calling.api_secret"))
tk.AddGrant(grant).
SetIdentity(user.Name).
SetName(user.Nick).
SetMetadata(string(metadata)).
SetValidFor(duration)
return tk.ToJWT()
}

View File

@ -0,0 +1,77 @@
package services
import (
"fmt"
"git.solsynth.dev/hydrogen/messaging/pkg/internal/database"
"git.solsynth.dev/hydrogen/messaging/pkg/internal/models"
)
func ListChannelMember(channelId uint) ([]models.ChannelMember, error) {
var members []models.ChannelMember
if err := database.C.
Where(&models.ChannelMember{ChannelID: channelId}).
Preload("Account").
Find(&members).Error; err != nil {
return members, err
}
return members, nil
}
func GetChannelMember(user models.Account, channelId uint) (models.ChannelMember, error) {
var member models.ChannelMember
if err := database.C.
Where(&models.ChannelMember{AccountID: user.ID, ChannelID: channelId}).
Find(&member).Error; err != nil {
return member, err
}
return member, nil
}
func AddChannelMemberWithCheck(user models.Account, target models.Channel) error {
if _, err := GetAccountFriend(user.ID, target.AccountID, 1); err != nil {
return fmt.Errorf("you only can invite your friends to your channel")
}
member := models.ChannelMember{
ChannelID: target.ID,
AccountID: user.ID,
}
err := database.C.Save(&member).Error
return err
}
func AddChannelMember(user models.Account, target models.Channel) error {
member := models.ChannelMember{
ChannelID: target.ID,
AccountID: user.ID,
}
err := database.C.Save(&member).Error
return err
}
func EditChannelMember(membership models.ChannelMember) (models.ChannelMember, error) {
if err := database.C.Save(&membership).Error; err != nil {
return membership, err
}
return membership, nil
}
func RemoveChannelMember(user models.Account, target models.Channel) error {
var member models.ChannelMember
if err := database.C.Where(&models.ChannelMember{
ChannelID: target.ID,
AccountID: user.ID,
}).First(&member).Error; err != nil {
return err
}
return database.C.Delete(&member).Error
}

View File

@ -0,0 +1,177 @@
package services
import (
"fmt"
"regexp"
"git.solsynth.dev/hydrogen/messaging/pkg/internal/database"
"git.solsynth.dev/hydrogen/messaging/pkg/internal/models"
"github.com/samber/lo"
"github.com/spf13/viper"
"gorm.io/gorm"
)
func GetChannelAliasAvailability(alias string) error {
if !regexp.MustCompile("^[a-z0-9-]+$").MatchString(alias) {
return fmt.Errorf("channel alias should only contains lowercase letters, numbers, and hyphens")
}
return nil
}
func GetChannel(id uint) (models.Channel, error) {
var channel models.Channel
tx := database.C.Where(models.Channel{
BaseModel: models.BaseModel{ID: id},
}).Preload("Account").Preload("Realm")
tx = PreloadDirectChannelMembers(tx)
if err := tx.First(&channel).Error; err != nil {
return channel, err
}
return channel, nil
}
func GetChannelWithAlias(alias string, realmId ...uint) (models.Channel, error) {
var channel models.Channel
tx := database.C.Where(models.Channel{Alias: alias}).Preload("Account").Preload("Realm")
if len(realmId) > 0 {
tx = tx.Where("realm_id = ?", realmId)
} else {
tx = tx.Where("realm_id IS NULL")
}
tx = PreloadDirectChannelMembers(tx)
if err := tx.First(&channel).Error; err != nil {
return channel, err
}
return channel, nil
}
func GetAvailableChannelWithAlias(alias string, user models.Account, realmId ...uint) (models.Channel, models.ChannelMember, error) {
var err error
var member models.ChannelMember
var channel models.Channel
if channel, err = GetChannelWithAlias(alias, realmId...); err != nil {
return channel, member, err
}
if err := database.C.Where(models.ChannelMember{
AccountID: user.ID,
ChannelID: channel.ID,
}).First(&member).Error; err != nil {
return channel, member, fmt.Errorf("channel principal not found: %v", err.Error())
}
return channel, member, nil
}
func GetAvailableChannel(id uint, user models.Account) (models.Channel, models.ChannelMember, error) {
var err error
var member models.ChannelMember
var channel models.Channel
if channel, err = GetChannel(id); err != nil {
return channel, member, err
}
tx := database.C.Where(models.ChannelMember{
AccountID: user.ID,
ChannelID: channel.ID,
})
if err := tx.First(&member).Error; err != nil {
return channel, member, fmt.Errorf("channel principal not found: %v", err.Error())
}
return channel, member, nil
}
func PreloadDirectChannelMembers(tx *gorm.DB) *gorm.DB {
return tx.Preload("Members", func(db *gorm.DB) *gorm.DB {
return db.Joins(
fmt.Sprintf(
"JOIN %schannels AS c ON c.type = ?",
viper.GetString("database.prefix"),
),
models.ChannelTypeDirect,
)
}).Preload("Members.Account")
}
func ListChannel(realmId ...uint) ([]models.Channel, error) {
var channels []models.Channel
tx := database.C.Preload("Account").Preload("Realm")
if len(realmId) > 0 {
tx = tx.Where("realm_id = ?", realmId)
}
tx = PreloadDirectChannelMembers(tx)
if err := tx.Find(&channels).Error; err != nil {
return channels, err
}
return channels, nil
}
func ListChannelWithUser(user models.Account, realmId ...uint) ([]models.Channel, error) {
var channels []models.Channel
tx := database.C.Where(&models.Channel{AccountID: user.ID}).Preload("Realm")
if len(realmId) > 0 {
tx = tx.Where("realm_id = ?", realmId)
}
tx = PreloadDirectChannelMembers(tx)
if err := tx.Find(&channels).Error; err != nil {
return channels, err
}
return channels, nil
}
func ListAvailableChannel(user models.Account, realmId ...uint) ([]models.Channel, error) {
var channels []models.Channel
var members []models.ChannelMember
if err := database.C.Where(&models.ChannelMember{
AccountID: user.ID,
}).Find(&members).Error; err != nil {
return channels, err
}
idx := lo.Map(members, func(item models.ChannelMember, index int) uint {
return item.ChannelID
})
tx := database.C.Preload("Realm").Where("id IN ?", idx)
if len(realmId) > 0 {
tx = tx.Where("realm_id = ?", realmId)
}
tx = PreloadDirectChannelMembers(tx)
if err := tx.Find(&channels).Error; err != nil {
return channels, err
}
return channels, nil
}
func NewChannel(channel models.Channel) (models.Channel, error) {
err := database.C.Save(&channel).Error
return channel, err
}
func EditChannel(channel models.Channel, alias, name, description string, isEncrypted bool) (models.Channel, error) {
channel.Alias = alias
channel.Name = name
channel.Description = description
if !channel.IsEncrypted {
channel.IsEncrypted = isEncrypted
}
err := database.C.Save(&channel).Error
return channel, err
}
func DeleteChannel(channel models.Channel) error {
return database.C.Delete(&channel).Error
}

View File

@ -0,0 +1,25 @@
package services
import (
"time"
"git.solsynth.dev/hydrogen/messaging/pkg/internal/database"
"github.com/rs/zerolog/log"
)
func DoAutoDatabaseCleanup() {
deadline := time.Now().Add(60 * time.Minute)
log.Debug().Time("deadline", deadline).Msg("Now cleaning up entire database...")
// Deal soft-deletion
var count int64
for _, model := range database.DatabaseAutoActionRange {
tx := database.C.Unscoped().Delete(model, "deleted_at >= ?", deadline)
if tx.Error != nil {
log.Error().Err(tx.Error).Msg("An error occurred when running database cleanup...")
}
count += tx.RowsAffected
}
log.Debug().Int64("affected", count).Msg("Clean up entire database accomplished.")
}

View File

@ -0,0 +1,47 @@
package services
import (
"sync"
"git.solsynth.dev/hydrogen/messaging/pkg/internal/models"
"github.com/gofiber/contrib/websocket"
)
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()
}
func PushCommand(userId uint, task models.UnifiedCommand) {
for conn := range wsConn[userId] {
_ = conn.WriteMessage(1, task.Marshal())
}
}
func DealCommand(task models.UnifiedCommand, user models.Account) *models.UnifiedCommand {
switch task.Action {
default:
return &models.UnifiedCommand{
Action: "error",
Message: "command not found",
}
}
}

View File

@ -0,0 +1,12 @@
package services
import "golang.org/x/crypto/bcrypt"
func HashPassword(raw string) string {
data, _ := bcrypt.GenerateFromPassword([]byte(raw), 12)
return string(data)
}
func VerifyPassword(text string, password string) bool {
return bcrypt.CompareHashAndPassword([]byte(password), []byte(text)) == nil
}

View File

@ -0,0 +1,81 @@
package services
import (
"fmt"
"github.com/gofiber/fiber/v2"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/spf13/viper"
)
type PayloadClaims struct {
jwt.RegisteredClaims
Type string `json:"typ"`
}
const (
JwtAccessType = "access"
JwtRefreshType = "refresh"
)
const (
CookieAccessKey = "identity_auth_key"
CookieRefreshKey = "identity_refresh_key"
)
func EncodeJwt(id string, typ, sub string, aud []string, exp time.Time) (string, error) {
tk := jwt.NewWithClaims(jwt.SigningMethodHS512, PayloadClaims{
jwt.RegisteredClaims{
Subject: sub,
Audience: aud,
Issuer: fmt.Sprintf("https://%s", viper.GetString("domain")),
ExpiresAt: jwt.NewNumericDate(exp),
NotBefore: jwt.NewNumericDate(time.Now()),
IssuedAt: jwt.NewNumericDate(time.Now()),
ID: id,
},
typ,
})
return tk.SignedString([]byte(viper.GetString("secret")))
}
func DecodeJwt(str string) (PayloadClaims, error) {
var claims PayloadClaims
tk, err := jwt.ParseWithClaims(str, &claims, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return []byte(viper.GetString("secret")), nil
})
if err != nil {
return claims, err
}
if data, ok := tk.Claims.(*PayloadClaims); ok {
return *data, nil
} else {
return claims, fmt.Errorf("unexpected token payload: not payload claims type")
}
}
func SetJwtCookieSet(c *fiber.Ctx, access, refresh string) {
c.Cookie(&fiber.Cookie{
Name: CookieAccessKey,
Value: access,
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: CookieRefreshKey,
Value: refresh,
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 services
import (
lksdk "github.com/livekit/server-sdk-go"
"github.com/spf13/viper"
)
var Lk *lksdk.RoomServiceClient
func SetupLiveKit() {
host := "https://" + viper.GetString("calling.endpoint")
Lk = lksdk.NewRoomServiceClient(
host,
viper.GetString("calling.api_key"),
viper.GetString("calling.api_secret"),
)
}

View File

@ -0,0 +1,51 @@
package services
import (
"crypto/tls"
"fmt"
"net/smtp"
"net/textproto"
"github.com/jordan-wright/email"
"github.com/spf13/viper"
)
func SendMail(target string, subject string, content string) error {
mail := &email.Email{
To: []string{target},
From: viper.GetString("mailer.name"),
Subject: subject,
Text: []byte(content),
Headers: textproto.MIMEHeader{},
}
return mail.SendWithTLS(
fmt.Sprintf("%s:%d", viper.GetString("mailer.smtp_host"), viper.GetInt("mailer.smtp_port")),
smtp.PlainAuth(
"",
viper.GetString("mailer.username"),
viper.GetString("mailer.password"),
viper.GetString("mailer.smtp_host"),
),
&tls.Config{ServerName: viper.GetString("mailer.smtp_host")},
)
}
func SendMailHTML(target string, subject string, content string) error {
mail := &email.Email{
To: []string{target},
From: viper.GetString("mailer.name"),
Subject: subject,
HTML: []byte(content),
Headers: textproto.MIMEHeader{},
}
return mail.SendWithTLS(
fmt.Sprintf("%s:%d", viper.GetString("mailer.smtp_host"), viper.GetInt("mailer.smtp_port")),
smtp.PlainAuth(
"",
viper.GetString("mailer.username"),
viper.GetString("mailer.password"),
viper.GetString("mailer.smtp_host"),
),
&tls.Config{ServerName: viper.GetString("mailer.smtp_host")},
)
}

View File

@ -0,0 +1,181 @@
package services
import (
"fmt"
"git.solsynth.dev/hydrogen/messaging/pkg/internal/database"
"git.solsynth.dev/hydrogen/messaging/pkg/internal/models"
"github.com/rs/zerolog/log"
"github.com/samber/lo"
)
func CountMessage(channel models.Channel) int64 {
var count int64
if err := database.C.Where(models.Message{
ChannelID: channel.ID,
}).Model(&models.Message{}).Count(&count).Error; err != nil {
return 0
} else {
return count
}
}
func ListMessage(channel models.Channel, take int, offset int) ([]models.Message, error) {
if take > 100 {
take = 100
}
var messages []models.Message
if err := database.C.
Where(models.Message{
ChannelID: channel.ID,
}).Limit(take).Offset(offset).
Order("created_at DESC").
Preload("ReplyTo").
Preload("ReplyTo.Sender").
Preload("ReplyTo.Sender.Account").
Preload("Sender").
Preload("Sender.Account").
Find(&messages).Error; err != nil {
return messages, err
} else {
return messages, nil
}
}
func GetMessage(channel models.Channel, id uint) (models.Message, error) {
var message models.Message
if err := database.C.
Where(models.Message{
BaseModel: models.BaseModel{ID: id},
ChannelID: channel.ID,
}).
Preload("ReplyTo").
Preload("ReplyTo.Sender").
Preload("ReplyTo.Sender.Account").
Preload("Sender").
Preload("Sender.Account").
First(&message).Error; err != nil {
return message, err
} else {
return message, nil
}
}
func GetMessageWithPrincipal(channel models.Channel, member models.ChannelMember, id uint) (models.Message, error) {
var message models.Message
if err := database.C.Where(models.Message{
BaseModel: models.BaseModel{ID: id},
ChannelID: channel.ID,
SenderID: member.ID,
}).First(&message).Error; err != nil {
return message, err
} else {
return message, nil
}
}
func NewMessage(message models.Message) (models.Message, error) {
var members []models.ChannelMember
if err := database.C.Save(&message).Error; err != nil {
return message, err
} else if err = database.C.Where(models.ChannelMember{
ChannelID: message.ChannelID,
}).Preload("Account").Find(&members).Error; err == nil {
channel := message.Channel
message, _ = GetMessage(message.Channel, message.ID)
for _, member := range members {
PushCommand(member.AccountID, models.UnifiedCommand{
Action: "messages.new",
Payload: message,
})
if member.ID != message.SenderID {
switch member.Notify {
case models.NotifyLevelNone:
continue
case models.NotifyLevelMentioned:
if message.ReplyTo != nil && member.ID == message.ReplyTo.SenderID {
break
}
if val, ok := message.Content["mentioned_users"]; ok {
if usernames, ok := val.([]string); ok {
if lo.Contains(usernames, member.Account.Name) {
break
}
}
}
default:
break
}
var displayText string
if message.Content["algorithm"] == "plain" {
displayText, _ = message.Content["value"].(string)
} else {
displayText = "*encrypted message*"
}
if len(displayText) == 0 {
displayText = fmt.Sprintf("%d attachment(s)", len(message.Attachments))
}
err = NotifyAccountMessager(member.Account,
"incomingMessage",
fmt.Sprintf("%s in #%s", message.Sender.Account.Nick, channel.Alias),
fmt.Sprintf("%s", displayText),
true,
false,
)
if err != nil {
log.Warn().Err(err).Msg("An error occurred when trying notify user.")
}
}
}
}
return message, nil
}
func EditMessage(message models.Message) (models.Message, error) {
var members []models.ChannelMember
if err := database.C.Save(&message).Error; err != nil {
return message, err
} else if err = database.C.Where(models.ChannelMember{
ChannelID: message.ChannelID,
}).Find(&members).Error; err == nil {
message, _ = GetMessage(models.Channel{
BaseModel: models.BaseModel{ID: message.Channel.ID},
}, message.ID)
for _, member := range members {
PushCommand(member.AccountID, models.UnifiedCommand{
Action: "messages.update",
Payload: message,
})
}
}
return message, nil
}
func DeleteMessage(message models.Message) (models.Message, error) {
prev, _ := GetMessage(models.Channel{
BaseModel: models.BaseModel{ID: message.Channel.ID},
}, message.ID)
var members []models.ChannelMember
if err := database.C.Delete(&message).Error; err != nil {
return message, err
} else if err = database.C.Where(models.ChannelMember{
ChannelID: message.ChannelID,
}).Find(&members).Error; err == nil {
for _, member := range members {
PushCommand(member.AccountID, models.UnifiedCommand{
Action: "messages.burnt",
Payload: prev,
})
}
}
return message, nil
}

View File

@ -0,0 +1,112 @@
package services
import (
"context"
"errors"
"fmt"
"git.solsynth.dev/hydrogen/messaging/pkg/internal/database"
"git.solsynth.dev/hydrogen/messaging/pkg/internal/gap"
"git.solsynth.dev/hydrogen/messaging/pkg/internal/models"
"git.solsynth.dev/hydrogen/passport/pkg/proto"
"github.com/samber/lo"
"gorm.io/gorm"
"reflect"
)
func GetRealm(id uint) (models.Realm, error) {
var realm models.Realm
pc, err := gap.H.DiscoverServiceGRPC("Hydrogen.Passport")
if err != nil {
return realm, err
}
response, err := proto.NewRealmsClient(pc).GetRealm(context.Background(), &proto.RealmLookupRequest{
Id: lo.ToPtr(uint64(id)),
})
if err != nil {
return realm, err
}
return LinkRealm(response)
}
func GetRealmWithAlias(alias string) (models.Realm, error) {
var realm models.Realm
pc, err := gap.H.DiscoverServiceGRPC("Hydrogen.Passport")
if err != nil {
return realm, err
}
response, err := proto.NewRealmsClient(pc).GetRealm(context.Background(), &proto.RealmLookupRequest{
Alias: &alias,
})
if err != nil {
return realm, err
}
return LinkRealm(response)
}
func GetRealmMember(realmId uint, userId uint) (*proto.RealmMemberResponse, error) {
pc, err := gap.H.DiscoverServiceGRPC("Hydrogen.Passport")
if err != nil {
return nil, err
}
response, err := proto.NewRealmsClient(pc).GetRealmMember(context.Background(), &proto.RealmMemberLookupRequest{
RealmId: uint64(realmId),
UserId: lo.ToPtr(uint64(userId)),
})
if err != nil {
return nil, err
} else {
return response, nil
}
}
func ListRealmMember(realmId uint) ([]*proto.RealmMemberResponse, error) {
pc, err := gap.H.DiscoverServiceGRPC("Hydrogen.Passport")
if err != nil {
return nil, err
}
response, err := proto.NewRealmsClient(pc).ListRealmMember(context.Background(), &proto.RealmMemberLookupRequest{
RealmId: uint64(realmId),
})
if err != nil {
return nil, err
} else {
return response.Data, nil
}
}
func LinkRealm(info *proto.RealmResponse) (models.Realm, error) {
var realm models.Realm
if info == nil {
return realm, fmt.Errorf("remote realm info was not found")
}
if err := database.C.Where(&models.Realm{
ExternalID: uint(info.Id),
}).First(&realm).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
realm = models.Realm{
Alias: info.Alias,
Name: info.Name,
Description: info.Description,
IsPublic: info.IsPublic,
IsCommunity: info.IsCommunity,
ExternalID: uint(info.Id),
}
return realm, database.C.Save(&realm).Error
}
return realm, err
}
prev := realm
realm.Alias = info.Alias
realm.Name = info.Name
realm.Description = info.Description
realm.IsPublic = info.IsPublic
realm.IsCommunity = info.IsCommunity
var err error
if !reflect.DeepEqual(prev, realm) {
err = database.C.Save(&realm).Error
}
return realm, err
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

View File

@ -0,0 +1,28 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<link rel="icon" type="image/png" href="favicon.png">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Roboto+Mono:ital,wght@0,100..700;1,100..700&display=swap" rel="stylesheet">
<title>Hydrogen.Interactive</title>
<style>
html, body {
padding: 0;
margin: 0;
font-family: Roboto Mono, monospace;
}
</style>
</head>
<body>
{{embed}}
</body>
</html>

View File

@ -0,0 +1,60 @@
<div class="container">
<div>
<img src="/favicon.png" width="128" height="128" alt="Icon"/>
<p class="caption text-blinking">Launching Solian... 🚀</p>
<p class="description">
Hold on a second... <br/>
We are redirecting you to our application...
</p>
</div>
</div>
<script>
function redirect() {
window.location.href = {{ .frontend }}
}
setTimeout(() => redirect(), 1850)
</script>
<style>
.container {
width: 100vw;
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
text-align: center;
}
.caption {
margin-top: 4px;
font-weight: 600;
}
.text-blinking {
animation: text-blinking ease-in-out infinite 1.5s;
}
.description {
margin-top: 4px;
font-size: 0.85rem;
}
p {
margin: 0;
}
@keyframes text-blinking {
0% {
opacity: 100%;
}
50% {
opacity: 10%;
}
100% {
opacity: 100%;
}
}
</style>