wip: alpha version

This commit is contained in:
loveuer
2024-03-31 20:09:20 +08:00
commit 195fbcd308
145 changed files with 16872 additions and 0 deletions

60
internal/api/api.go Normal file
View File

@ -0,0 +1,60 @@
package api
import (
"context"
"github.com/loveuer/nf"
"github.com/loveuer/nfflow/internal/handler"
"github.com/loveuer/nfflow/internal/middleware/auth"
"github.com/loveuer/nfflow/internal/middleware/front"
"github.com/loveuer/nfflow/internal/middleware/oplog"
"github.com/loveuer/nfflow/internal/middleware/privilege"
"github.com/loveuer/nfflow/internal/model"
"time"
)
func initApp(ctx context.Context) *nf.App {
engine := nf.New(nf.Config{})
app := engine.Group("/api")
app.Get("/available", func(c *nf.Ctx) error {
return c.JSON(nf.Map{"status": 200, "ok": true, "time": time.Now()})
})
{
api := app.Group("/user")
api.Post("/auth/login", oplog.NewOpLog(ctx), handler.AuthLogin)
api.Get("/auth/login", auth.NewAuth(), handler.AuthVerify)
api.Post("/auth/logout", auth.NewAuth(), oplog.NewOpLog(ctx), handler.AuthLogout)
mng := api.Group("/manage")
mng.Use(auth.NewAuth(), privilege.Verify(
privilege.RelationAnd,
model.PrivilegeUserManage,
))
mng.Get("/user/list", handler.ManageUserList)
mng.Post("/user/create", oplog.NewOpLog(ctx), handler.ManageUserCreate)
mng.Post("/user/update", oplog.NewOpLog(ctx), handler.ManageUserUpdate)
mng.Post("/user/delete", oplog.NewOpLog(ctx), handler.ManageUserDelete)
}
{
api := app.Group("/log")
api.Use(auth.NewAuth(), privilege.Verify(privilege.RelationAnd, model.PrivilegeOpLog))
api.Get("/category/list", handler.LogCategories())
api.Get("/content/list", handler.LogList)
}
{
api := app.Group("/task")
api.Get("/list", handler.TaskList)
api.Post("/create", handler.TaskCreate)
api.Post("/input/create", handler.TaskInputCreate)
api.Post("/input/update", handler.TaskInputUpdate)
}
engine.Use(front.NewFront(&front.DefaultFront, "dist/front/browser"))
return engine
}

40
internal/api/start.go Normal file
View File

@ -0,0 +1,40 @@
package api
import (
"context"
"github.com/loveuer/nfflow/internal/opt"
"github.com/loveuer/nfflow/internal/util"
"github.com/sirupsen/logrus"
"net"
)
func MustStart(ctx context.Context) {
app := initApp(ctx)
ready := make(chan bool)
ln, err := net.Listen("tcp", opt.Cfg.Address)
if err != nil {
logrus.Panicf("api.MustStart: net listen tcp address=%v err=%v", opt.Cfg.Address, err)
}
go func() {
ready <- true
if err = app.RunListener(ln); err != nil {
logrus.Panicf("api.MustStart: app run err=%v", err)
}
}()
<-ready
go func() {
ready <- true
<-ctx.Done()
if err = app.Shutdown(util.Timeout(1)); err != nil {
logrus.Errorf("api.MustStart: app shutdown err=%v", err)
}
}()
<-ready
}

24
internal/cmd/execute.go Normal file
View File

@ -0,0 +1,24 @@
package cmd
import (
"context"
"github.com/loveuer/nfflow/internal/api"
"github.com/loveuer/nfflow/internal/controller"
"github.com/loveuer/nfflow/internal/database"
"github.com/loveuer/nfflow/internal/model"
"github.com/loveuer/nfflow/internal/opt"
)
func Execute(ctx context.Context) error {
opt.MustInitConfig()
database.MustInitClient()
model.MustInit(database.DB)
controller.Init(database.DB, database.Cache)
api.MustStart(ctx)
<-ctx.Done()
return nil
}

11
internal/cmd/init.go Normal file
View File

@ -0,0 +1,11 @@
package cmd
import (
"flag"
"github.com/loveuer/nfflow/internal/opt"
)
func init() {
flag.StringVar(&opt.ConfigFile, "c", "etc/config.json", "config json file path")
flag.IntVar(&opt.Debug, "debug", 0, "")
}

View File

@ -0,0 +1,19 @@
package controller
import "github.com/loveuer/nfflow/internal/database"
type uc struct {
db database.Store
c database.Caches
}
var (
_ userController = uc{}
// UserController todo: 可以实现自己的 controller
UserController userController
)
func Init(db database.Store, cache database.Caches) {
UserController = uc{db: db, c: cache}
}

View File

@ -0,0 +1,108 @@
package es7
import (
"context"
"crypto/tls"
"fmt"
elastic "github.com/elastic/go-elasticsearch/v7"
"github.com/elastic/go-elasticsearch/v7/esapi"
"github.com/sirupsen/logrus"
"net/http"
)
type Client struct {
Endpoints []string `json:"endpoints"`
Username string `json:"username"`
Password string `json:"password"`
CA string `json:"ca"`
cli *elastic.Client
}
func (c *Client) InitClient(ctx context.Context) error {
var (
errCh = make(chan error)
cliCh = make(chan *elastic.Client)
hiddenCa = func(cs string) string {
if len(cs) > 0 {
return "******"
}
return "nil"
}
)
logrus.Debugf("es7.NewClient: endpoints=%v (username=%s password=%s ca=%s)", c.Endpoints, c.Username, c.Password, hiddenCa(c.CA))
ncFunc := func(endpoints []string, username, password, ca string) {
var (
err error
cli *elastic.Client
infoResp *esapi.Response
)
if cli, err = elastic.NewClient(
elastic.Config{
Addresses: endpoints,
Username: username,
Password: password,
CACert: []byte(c.CA),
RetryOnStatus: []int{429},
MaxRetries: 3,
RetryBackoff: nil,
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
},
},
); err != nil {
logrus.Debugf("es7.NewClient: elastic new client with endponts=%v err=%v", endpoints, err)
errCh <- err
return
}
if infoResp, err = cli.Info(); err != nil {
logrus.Debugf("es7.NewClient: ping err=%v", err)
errCh <- err
return
}
if infoResp.StatusCode != 200 {
err = fmt.Errorf("info es status=%d", infoResp.StatusCode)
logrus.Debugf("es7.NewClient: status err=%v", err)
errCh <- err
return
}
cliCh <- cli
}
go ncFunc(c.Endpoints, c.Username, c.Password, c.CA)
select {
case <-ctx.Done():
return fmt.Errorf("dial es=%s err=%v", c.Endpoints, context.DeadlineExceeded)
case c.cli = <-cliCh:
return nil
case e := <-errCh:
return e
}
}
func (c *Client) Ping(ctx context.Context) error {
rr, err := c.cli.Info(
c.cli.Info.WithContext(ctx),
)
if err != nil {
return err
}
if rr.StatusCode != 200 {
return fmt.Errorf("ping status=%d msg=%s", rr.StatusCode, rr.String())
}
return nil
}
func (c *Client) Save(ctx context.Context) error {
return nil
}

View File

@ -0,0 +1,12 @@
package controller
import "github.com/loveuer/nfflow/internal/model"
type userController interface {
GetUser(id uint64) (*model.User, error)
GetUserByToken(token string) (*model.User, error)
CacheUser(user *model.User) error
CacheToken(token string, user *model.User) error
RmUserCache(id uint64) error
DeleteUser(id uint64) error
}

139
internal/controller/user.go Normal file
View File

@ -0,0 +1,139 @@
package controller
import (
"encoding/json"
"errors"
"fmt"
"github.com/loveuer/nf/nft/resp"
"github.com/loveuer/nfflow/internal/database"
"github.com/loveuer/nfflow/internal/model"
"github.com/loveuer/nfflow/internal/opt"
"github.com/loveuer/nfflow/internal/util"
"github.com/sirupsen/logrus"
"github.com/spf13/cast"
"gorm.io/gorm"
"strings"
"time"
)
func (u uc) GetUser(id uint64) (*model.User, error) {
var (
err error
target = new(model.User)
key = fmt.Sprintf("%s:user:id:%d", opt.CachePrefix, id)
bs []byte
)
if opt.EnableUserCache {
if bs, err = u.c.Get(util.Timeout(3), key); err != nil {
logrus.Warnf("controller.GetUser: get user by cache key=%s err=%v", key, err)
goto ByDB
}
if err = json.Unmarshal(bs, target); err != nil {
logrus.Warnf("controller.GetUser: json unmarshal key=%s by=%s err=%v", key, string(bs), err)
goto ByDB
}
return target, nil
}
ByDB:
if err = u.db.Session(util.Timeout(3)).
Model(&model.User{}).
Where("id = ?", id).
Take(target).
Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
// tips: 公开项目需要考虑击穿处理
return target, resp.NewError(400, "目标不存在", err, nil)
}
return target, resp.NewError(500, "", err, nil)
}
if opt.EnableUserCache {
if err = u.CacheUser(target); err != nil {
logrus.Warnf("controller.GetUser: cache user key=%s err=%v", key, err)
}
}
return target, nil
}
func (u uc) GetUserByToken(token string) (*model.User, error) {
strs := strings.Split(token, ".")
if len(strs) != 3 {
return nil, fmt.Errorf("controller.GetUserByToken: jwt token invalid, token=%s", token)
}
key := fmt.Sprintf("%s:user:token:%s", opt.CachePrefix, strs[2])
bs, err := u.c.Get(util.Timeout(3), key)
if err != nil {
return nil, err
}
logrus.Tracef("controller.GetUserByToken: key=%s cache bytes=%s", key, string(bs))
userId := cast.ToUint64(string(bs))
if userId == 0 {
return nil, fmt.Errorf("controller.GetUserByToken: bs=%s cast to uint64 err", string(bs))
}
var op *model.User
if op, err = u.GetUser(userId); err != nil {
return nil, err
}
return op, nil
}
func (u uc) CacheUser(target *model.User) error {
key := fmt.Sprintf("%s:user:id:%d", opt.CachePrefix, target.Id)
return u.c.Set(util.Timeout(3), key, target)
}
func (u uc) CacheToken(token string, user *model.User) error {
strs := strings.Split(token, ".")
if len(strs) != 3 {
return fmt.Errorf("controller.CacheToken: jwt token invalid")
}
key := fmt.Sprintf("%s:user:token:%s", opt.CachePrefix, strs[2])
return u.c.SetEx(util.Timeout(3), key, user.Id, opt.TokenTimeout)
}
func (u uc) RmUserCache(id uint64) error {
key := fmt.Sprintf("%s:user:id:%d", opt.CachePrefix, id)
return u.c.Del(util.Timeout(3), key)
}
func (u uc) DeleteUser(id uint64) error {
var (
err error
now = time.Now()
username = "CONCAT(username, '@del')"
)
if opt.Cfg.Database.Type == "sqlite" {
username = "username || '@del'"
}
if err = database.DB.Session(util.Timeout(5)).
Model(&model.User{}).
Where("id = ?", id).
Updates(map[string]any{
"deleted_at": now.UnixMilli(),
"username": gorm.Expr(username),
}).Error; err != nil {
return resp.NewError(500, "", err, nil)
}
if opt.EnableUserCache {
if err = u.RmUserCache(id); err != nil {
logrus.Warnf("controller.DeleteUser: rm user=%d cache err=%v", id, err)
}
}
return nil
}

View File

@ -0,0 +1,26 @@
package database
import "encoding/json"
type encoded_value interface {
MarshalBinary() ([]byte, error)
}
type decoded_value interface {
UnmarshalBinary(bs []byte) error
}
func handleValue(value any) ([]byte, error) {
var (
bs []byte
err error
)
if imp, ok := value.(encoded_value); ok {
bs, err = imp.MarshalBinary()
} else {
bs, err = json.Marshal(value)
}
return bs, err
}

View File

@ -0,0 +1,63 @@
package database
import (
"context"
"fmt"
"gitea.com/taozitaozi/gredis"
"time"
)
var _ Caches = (*_mem)(nil)
type _mem struct {
client *gredis.Gredis
}
func (m *_mem) Get(ctx context.Context, key string) ([]byte, error) {
v, err := m.client.Get(key)
if err != nil {
return nil, err
}
bs, ok := v.([]byte)
if !ok {
return nil, fmt.Errorf("invalid value type=%T", v)
}
return bs, nil
}
func (m *_mem) GetEx(ctx context.Context, key string, duration time.Duration) ([]byte, error) {
v, err := m.client.GetEx(key, duration)
if err != nil {
return nil, err
}
bs, ok := v.([]byte)
if !ok {
return nil, fmt.Errorf("invalid value type=%T", v)
}
return bs, nil
}
func (m *_mem) Set(ctx context.Context, key string, value any) error {
bs, err := handleValue(value)
if err != nil {
return err
}
return m.client.Set(key, bs)
}
func (m *_mem) SetEx(ctx context.Context, key string, value any, duration time.Duration) error {
bs, err := handleValue(value)
if err != nil {
return err
}
return m.client.SetEx(key, bs, duration)
}
func (m *_mem) Del(ctx context.Context, keys ...string) error {
m.client.Delete(keys...)
return nil
}

View File

@ -0,0 +1,54 @@
package database
import (
"context"
"github.com/go-redis/redis/v8"
"time"
)
type _redis struct {
client *redis.Client
}
func (r *_redis) Get(ctx context.Context, key string) ([]byte, error) {
result, err := r.client.Get(ctx, key).Result()
if err != nil {
return nil, err
}
return []byte(result), nil
}
func (r *_redis) GetEx(ctx context.Context, key string, duration time.Duration) ([]byte, error) {
result, err := r.client.GetEx(ctx, key, duration).Result()
if err != nil {
return nil, err
}
return []byte(result), nil
}
func (r *_redis) Set(ctx context.Context, key string, value any) error {
bs, err := handleValue(value)
if err != nil {
return err
}
_, err = r.client.Set(ctx, key, bs, redis.KeepTTL).Result()
return err
}
func (r *_redis) SetEx(ctx context.Context, key string, value any, duration time.Duration) error {
bs, err := handleValue(value)
if err != nil {
return err
}
_, err = r.client.SetEX(ctx, key, bs, duration).Result()
return err
}
func (r *_redis) Del(ctx context.Context, keys ...string) error {
return r.client.Del(ctx, keys...).Err()
}

View File

@ -0,0 +1,6 @@
package database
var (
DB Store
Cache Caches
)

89
internal/database/init.go Normal file
View File

@ -0,0 +1,89 @@
package database
import (
"fmt"
"gitea.com/taozitaozi/gredis"
"github.com/glebarez/sqlite"
"github.com/go-redis/redis/v8"
"github.com/loveuer/nfflow/internal/opt"
"github.com/loveuer/nfflow/internal/util"
"gorm.io/driver/mysql"
"gorm.io/driver/postgres"
"gorm.io/gorm"
)
func MustInitClient() {
var (
err error
)
// todo: 可以实现自己的 "Store" sql_db, like sqlite, postgresql
if DB, err = initSql(); err != nil {
panic(fmt.Errorf("database.MustInitClient: init sql err=%v", err))
}
// todo: 可以实现自己的 "Caches" 缓存, like redis
if Cache, err = initCacher(); err != nil {
panic(fmt.Errorf("database.MustInitCache: init cache err=%v", err))
}
}
func initSql() (Store, error) {
var (
err error
client *gorm.DB
)
switch opt.Cfg.Database.Type {
case "postgresql":
dsn := fmt.Sprintf("host=%s user=%s password=%s dbname=%s port=%d sslmode=disable TimeZone=Asia/Shanghai",
opt.Cfg.Database.Host, opt.Cfg.Database.Username, opt.Cfg.Database.Password, opt.Cfg.Database.DB, opt.Cfg.Database.Port)
if client, err = gorm.Open(postgres.Open(dsn)); err != nil {
return nil, err
}
case "mysql":
dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local",
opt.Cfg.Database.Username, opt.Cfg.Database.Password, opt.Cfg.Database.Host, opt.Cfg.Database.Port, opt.Cfg.Database.DB)
if client, err = gorm.Open(mysql.Open(dsn)); err != nil {
return nil, err
}
case "sqlite":
if client, err = gorm.Open(sqlite.Open(opt.Cfg.Database.Path)); err != nil {
return nil, err
}
default:
return nil, fmt.Errorf("unsupportted databsae type=%s", opt.Cfg.Database.Type)
}
return &_db{client: client}, nil
}
func initCacher() (Caches, error) {
var (
err error
)
switch opt.Cfg.Cache.Type {
case "redis":
var rc *redis.Client
rc = redis.NewClient(&redis.Options{
Addr: fmt.Sprintf("%s:%d", opt.Cfg.Cache.Host, opt.Cfg.Cache.Port),
Username: opt.Cfg.Cache.Username,
Password: opt.Cfg.Cache.Password,
})
if err = rc.Ping(util.Timeout(5)).Err(); err != nil {
return nil, fmt.Errorf("redis ping err=%v", err)
}
return &_redis{client: rc}, nil
case "memory":
var mc *gredis.Gredis
mc = gredis.NewGredis(-1)
return &_mem{client: mc}, nil
default:
return nil, fmt.Errorf("unsupportted cache type=%s", opt.Cfg.Cache.Type)
}
}

View File

@ -0,0 +1,21 @@
package database
import (
"context"
"gorm.io/gorm"
"time"
)
type Caches interface {
Get(ctx context.Context, key string) ([]byte, error)
GetEx(ctx context.Context, key string, duration time.Duration) ([]byte, error)
// Set value 会被序列化, 优先使用 MarshalBinary 方法, 没有则执行 json.Marshal
Set(ctx context.Context, key string, value any) error
// SetEx value 会被序列化, 优先使用 MarshalBinary 方法, 没有则执行 json.Marshal
SetEx(ctx context.Context, key string, value any, duration time.Duration) error
Del(ctx context.Context, keys ...string) error
}
type Store interface {
Session(ctx context.Context) *gorm.DB
}

20
internal/database/sql.go Normal file
View File

@ -0,0 +1,20 @@
package database
import (
"context"
"github.com/loveuer/nfflow/internal/opt"
"gorm.io/gorm"
)
type _db struct {
client *gorm.DB
}
func (d *_db) Session(ctx context.Context) *gorm.DB {
s := d.client.Session(&gorm.Session{})
if opt.Debug > 0 || opt.DBDebug {
s = s.Debug()
}
return s
}

102
internal/handler/log.go Normal file
View File

@ -0,0 +1,102 @@
package handler
import (
"github.com/loveuer/nf"
"github.com/loveuer/nf/nft/resp"
"github.com/loveuer/nfflow/internal/database"
"github.com/loveuer/nfflow/internal/model"
"github.com/loveuer/nfflow/internal/opt"
"github.com/loveuer/nfflow/internal/sqlType"
"github.com/loveuer/nfflow/internal/util"
"github.com/sirupsen/logrus"
)
func LogCategories() nf.HandlerFunc {
return func(c *nf.Ctx) error {
return resp.Resp200(c, model.OpLogType(0).All())
}
}
func LogList(c *nf.Ctx) error {
type Req struct {
Page int `query:"page"`
Size int `query:"size"`
UserIds []uint64 `query:"user_ids"`
Types sqlType.NumSlice[model.OpLogType] `query:"types"`
}
var (
ok bool
op *model.User
err error
req = new(Req)
list = make([]*model.OpLog, 0)
total int
)
if op, ok = c.Locals("user").(*model.User); !ok {
return resp.Resp401(c, nil)
}
if err = c.QueryParser(req); err != nil {
return resp.Resp400(c, err.Error())
}
if req.Size <= 0 {
req.Size = opt.DefaultSize
}
if req.Size > opt.MaxSize {
return resp.Resp400(c, req, "参数过大")
}
txCount := op.Role.Where(database.DB.Session(util.Timeout(3)).
Model(&model.OpLog{}).
Select("COUNT(`op_logs`.`id`)").
Joins("LEFT JOIN users ON `users`.`id` = `op_logs`.`user_id`"))
txGet := op.Role.Where(database.DB.Session(util.Timeout(10)).
Model(&model.OpLog{}).
Joins("LEFT JOIN users ON `users`.`id` = `op_logs`.`user_id`"))
if len(req.UserIds) != 0 {
txCount = txCount.Where("op_logs.user_id IN ?", req.UserIds)
txGet = txGet.Where("op_logs.user_id IN ?", req.UserIds)
}
if len(req.Types) != 0 {
txCount = txCount.Where("op_logs.type IN ?", req.Types)
txGet = txGet.Where("op_logs.type IN ?", req.Types)
}
if err = txCount.
Find(&total).
Error; err != nil {
return resp.Resp500(c, err.Error())
}
if err = txGet.
Offset(req.Page * req.Size).
Limit(req.Size).
Order("`op_logs`.`created_at` DESC").
Find(&list).
Error; err != nil {
return resp.Resp500(c, err.Error())
}
for _, log := range list {
m := make(map[string]any)
if err = log.Content.Bind(&m); err != nil {
logrus.Warnf("handler.LogList: log=%d content=%v bind map[string]any err=%v", log.Id, log.Content, err)
continue
}
if log.HTML, err = log.Type.Render(m); err != nil {
logrus.Warnf("handler.LogList: log=%d template=%s render map=%+v err=%v", log.Id, log.Type.Template(), m, err)
continue
}
logrus.Tracef("handler.LogList: log=%d render map=%+v string=%s", log.Id, m, log.HTML)
}
return resp.Resp200(c, nf.Map{"list": list, "total": total})
}

125
internal/handler/task.go Normal file
View File

@ -0,0 +1,125 @@
package handler
import (
"fmt"
"github.com/loveuer/nf"
"github.com/loveuer/nf/nft/resp"
"github.com/loveuer/nfflow/internal/database"
"github.com/loveuer/nfflow/internal/model"
"github.com/loveuer/nfflow/internal/opt"
"github.com/loveuer/nfflow/internal/sqlType"
"github.com/loveuer/nfflow/internal/util"
"time"
)
func TaskList(c *nf.Ctx) error {
type Req struct {
Keyword string `query:"keyword,omitempty"`
Page int `query:"page,omitempty"`
Size int `query:"size,omitempty"`
Targets sqlType.NumSlice[uint64] `query:"targets,omitempty"`
}
var (
err error
req = new(Req)
total int
list = make([]*model.Task, 0)
)
if err = c.QueryParser(req); err != nil {
return resp.Resp400(c, err.Error())
}
if req.Size <= 0 {
req.Size = 20
}
txGet := database.DB.Session(util.Timeout(10)).
Model(&model.Task{})
txCount := database.DB.Session(util.Timeout(5)).
Model(&model.Task{}).
Select("COUNT(id)")
if req.Keyword != "" {
key := fmt.Sprintf("%%%s%%", req.Keyword)
txGet = txGet.Where("task_name", key)
txCount = txCount.Where("task_name", key)
}
if err = txCount.Find(&total).Error; err != nil {
return resp.Resp500(c, err.Error())
}
if err = txGet.
Order("updated_at DESC").
Offset(req.Page * req.Size).
Limit(req.Size).
Find(&list).
Error; err != nil {
return resp.Resp500(c, err.Error())
}
return resp.Resp200(c, nf.Map{"list": list, "total": total})
}
func TaskCreate(c *nf.Ctx) error {
type Req struct {
TaskName string `json:"task_name"`
TaskType string `json:"task_type"`
Timeout int `json:"timeout"`
Cron string `json:"cron"`
RunAt int64 `json:"run_at"`
}
var (
err error
req = new(Req)
now = time.Now()
)
if err = c.BodyParser(req); err != nil {
return resp.Resp400(c, err.Error())
}
if req.TaskName != "" {
return resp.Resp400(c, req)
}
task := &model.Task{TaskName: req.TaskName}
if req.Timeout < opt.TaskMinTimeout || req.Timeout > opt.TaskMaxTimeout {
return resp.Resp400(c, req, fmt.Sprintf("timeout 时长过短(%d - %d)", opt.TaskMinTimeout, opt.TaskMaxTimeout))
}
task.TimeoutSecond = req.Timeout
switch req.TaskType {
case "once":
case "timing":
rt := time.UnixMilli(req.RunAt)
if rt.Sub(now).Seconds() > opt.TaskFetchInterval {
return resp.Resp400(c, req, "任务执行时间距离当前时间太短")
}
task.TaskRunType = fmt.Sprintf("T-%d", req.RunAt)
case "cron":
task.TaskRunType = fmt.Sprintf("C-%s", req.TaskType)
default:
return resp.Resp400(c, req, "任务执行类型: once/timing/cron")
}
if err = database.DB.Session(util.Timeout(5)).
Create(task).
Error; err != nil {
return resp.Resp500(c, err.Error())
}
return resp.Resp200(c, task)
}
func TaskInputCreate(c *nf.Ctx) error {
panic("impl")
}
func TaskInputUpdate(c *nf.Ctx) error {
panic("impl")
}

485
internal/handler/user.go Normal file
View File

@ -0,0 +1,485 @@
package handler
import (
"errors"
"fmt"
"github.com/loveuer/nf"
"github.com/loveuer/nf/nft/resp"
"github.com/loveuer/nfflow/internal/controller"
"github.com/loveuer/nfflow/internal/database"
"github.com/loveuer/nfflow/internal/middleware/oplog"
"github.com/loveuer/nfflow/internal/model"
"github.com/loveuer/nfflow/internal/opt"
"github.com/loveuer/nfflow/internal/sqlType"
"github.com/loveuer/nfflow/internal/util"
"github.com/samber/lo"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"time"
)
func AuthLogin(c *nf.Ctx) error {
type Req struct {
Username string `json:"username"`
Password string `json:"password"`
}
var (
err error
req = new(Req)
target = new(model.User)
token string
now = time.Now()
)
if err = c.BodyParser(req); err != nil {
return resp.Resp400(c, err.Error())
}
if err = database.DB.Session(util.Timeout(3)).
Model(&model.User{}).
Where("username = ?", req.Username).
Where("deleted_at = 0").
Take(target).
Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return resp.Resp400(c, err.Error(), "用户名或密码错误")
}
return resp.Resp500(c, err.Error())
}
if !util.ComparePassword(req.Password, target.Password) {
return resp.Resp400(c, nil, "用户名或密码错误")
}
if err = target.IsValid(true); err != nil {
return resp.Resp401(c, nil, err.Error())
}
if err = controller.UserController.CacheUser(target); err != nil {
return resp.RespError(c, err)
}
if token, err = target.JwtEncode(); err != nil {
return resp.Resp500(c, err.Error())
}
if err = controller.UserController.CacheToken(token, target); err != nil {
return resp.RespError(c, err)
}
if !opt.MultiLogin {
var (
last = fmt.Sprintf("%s:user:last_token:%d", opt.CachePrefix, target.Id)
bs []byte
)
if bs, err = database.Cache.Get(util.Timeout(3), last); err == nil {
key := fmt.Sprintf("%s:user:token:%s", opt.CachePrefix, string(bs))
_ = database.Cache.Del(util.Timeout(3), key)
}
if err = database.Cache.Set(util.Timeout(3), last, token); err != nil {
return resp.Resp500(c, err.Error())
}
}
c.Set("Set-Cookie", fmt.Sprintf("%s=%s; Path=/", opt.CookieName, token))
c.Locals("user", target)
c.Locals(opt.OpLogLocalKey, &oplog.OpLog{Type: model.OpLogTypeLogin, Content: map[string]any{
"time": now.UnixMilli(),
"ip": c.IP(),
}})
return resp.Resp200(c, nf.Map{"token": token, "user": target})
}
func AuthVerify(c *nf.Ctx) error {
op, ok := c.Locals("user").(*model.User)
if !ok {
return resp.Resp401(c, nil)
}
token, ok := c.Locals("token").(string)
if !ok {
return resp.Resp401(c, nil)
}
return resp.Resp200(c, nf.Map{"token": token, "user": op})
}
func AuthLogout(c *nf.Ctx) error {
op, ok := c.Locals("user").(*model.User)
if !ok {
return resp.Resp401(c, nil)
}
_ = controller.UserController.RmUserCache(op.Id)
c.Locals(opt.OpLogLocalKey, &oplog.OpLog{
Type: model.OpLogTypeLogout,
Content: map[string]any{
"time": time.Now().UnixMilli(),
"ip": c.IP(),
},
})
return resp.Resp200(c, nil)
}
func ManageUserList(c *nf.Ctx) error {
type Req struct {
Page int `query:"page"`
Size int `query:"size"`
Keyword string `query:"keyword"`
}
var (
err error
ok bool
op *model.User
req = new(Req)
list = make([]*model.User, 0)
total = 0
)
if op, ok = c.Locals("user").(*model.User); !ok {
return resp.Resp401(c, nil)
}
if err = c.QueryParser(req); err != nil {
return resp.Resp400(c, err.Error())
}
if req.Size == 0 {
req.Size = opt.DefaultSize
}
if req.Size > opt.MaxSize {
return resp.Resp400(c, nf.Map{"msg": "size over max", "max": opt.MaxSize})
}
if err = op.Role.Where(database.DB.Session(util.Timeout(10)).
Model(&model.User{}).
Where("deleted_at = 0")).
Order("updated_at DESC").
Offset(req.Page * req.Size).
Limit(req.Size).
Find(&list).
Error; err != nil {
return resp.Resp500(c, err.Error())
}
if err = op.Role.Where(database.DB.Session(util.Timeout(5)).
Model(&model.User{}).
Select("COUNT(id)").
Where("deleted_at = 0")).
Find(&total).
Error; err != nil {
return resp.Resp500(c, err.Error())
}
return resp.Resp200(c, nf.Map{"list": list, "total": total})
}
func ManageUserCreate(c *nf.Ctx) error {
type Req struct {
Username string `json:"username"`
Nickname string `json:"nickname"`
Password string `json:"password"`
Status model.Status `json:"status"`
Role model.Role `json:"role"`
Privileges sqlType.NumSlice[model.Privilege] `json:"privileges"`
Comment string `json:"comment"`
ActiveAt int64 `json:"active_at"`
Deadline int64 `json:"deadline"`
}
var (
err error
ok bool
op *model.User
req = new(Req)
now = time.Now()
)
if op, ok = c.Locals("user").(*model.User); !ok {
return resp.Resp401(c, nil)
}
if err = c.BodyParser(req); err != nil {
return resp.Resp400(c, err.Error())
}
if req.Username == "" || req.Password == "" {
return resp.Resp400(c, req)
}
if err = util.CheckPassword(req.Password); err != nil {
return resp.Resp400(c, req, err.Error())
}
if req.Nickname == "" {
req.Nickname = req.Username
}
if req.Status.Code() == "unknown" {
return resp.Resp400(c, req, "用户状态不正常")
}
if req.Role == 0 {
req.Role = model.RoleUser
}
if req.ActiveAt == 0 {
req.ActiveAt = now.UnixMilli()
}
if req.Deadline == 0 {
req.Deadline = now.AddDate(99, 0, 0).UnixMilli()
}
newUser := &model.User{
CreatedAt: now.UnixMilli(),
UpdatedAt: now.UnixMilli(),
Username: req.Username,
Password: util.NewPassword(req.Password),
Status: req.Status,
Nickname: req.Nickname,
Comment: req.Comment,
Role: req.Role,
Privileges: req.Privileges,
CreatedById: op.Id,
CreatedByName: op.CreatedByName,
ActiveAt: op.ActiveAt,
Deadline: op.Deadline,
}
if err = newUser.IsValid(false); err != nil {
return resp.Resp400(c, newUser, err.Error())
}
if !newUser.Role.CanOP(op) {
return resp.Resp403(c, newUser, "角色不符合")
}
if err = database.DB.Session(util.Timeout(5)).
Create(newUser).
Error; err != nil {
return resp.Resp500(c, err.Error())
}
c.Locals(opt.OpLogLocalKey, &oplog.OpLog{Type: model.OpLogTypeCreateUser, Content: map[string]any{
"target_id": newUser.Id,
"target_username": newUser.Username,
"target_nickname": newUser.Nickname,
"target_status": newUser.Status.Label(),
"target_role": newUser.Role.Label(),
"target_privileges": lo.Map(newUser.Privileges, func(item model.Privilege, index int) string {
return item.Label()
}),
"target_active_at": op.ActiveAt,
"target_deadline": op.Deadline,
}})
return resp.Resp200(c, newUser)
}
func ManageUserUpdate(c *nf.Ctx) error {
type Req struct {
Id uint64 `json:"id"`
Nickname string `json:"nickname"`
Password string `json:"password"`
Status model.Status `json:"status"`
Comment string `json:"comment"`
Role model.Role `json:"role"`
Privileges sqlType.NumSlice[model.Privilege] `json:"privileges"`
ActiveAt int64 `json:"active_at"`
Deadline int64 `json:"deadline"`
}
type Change struct {
Old any `json:"old"`
New any `json:"new"`
}
var (
ok bool
op *model.User
target *model.User
err error
req = new(Req)
rm = make(map[string]any)
updates = make(map[string]any)
changes = make(map[string]Change)
)
if op, ok = c.Locals("user").(*model.User); !ok {
return resp.Resp401(c, nil)
}
if err = c.BodyParser(req); err != nil {
return resp.Resp400(c, err.Error())
}
if err = c.BodyParser(&rm); err != nil {
return resp.Resp400(c, err.Error())
}
if req.Id == 0 {
return resp.Resp400(c, "未指定目标用户")
}
if req.Id == op.Id {
return resp.Resp400(c, "无法修改自己")
}
if target, err = controller.UserController.GetUser(req.Id); err != nil {
return resp.RespError(c, err)
}
if op.Role < target.Role || ((op.Role == target.Role) && opt.RoleMustLess) {
return resp.Resp403(c, req)
}
if _, ok = rm["nickname"]; ok {
if req.Nickname == "" {
return resp.Resp400(c, req)
}
updates["nickname"] = req.Nickname
changes["昵称"] = Change{Old: target.Nickname, New: req.Nickname}
}
if _, ok = rm["password"]; ok {
if err = util.CheckPassword(req.Password); err != nil {
return resp.Resp400(c, err.Error())
}
updates["password"] = util.NewPassword(req.Password)
changes["密码"] = Change{Old: "******", New: "******"}
}
if _, ok = rm["status"]; ok {
if req.Status.Code() == "unknown" {
return resp.Resp400(c, req, "用户状态不符合")
}
updates["status"] = req.Status
changes["状态"] = Change{Old: target.Status.Label(), New: req.Status.Label()}
}
if _, ok = rm["comment"]; ok {
updates["comment"] = req.Comment
changes["备注"] = Change{Old: target.Comment, New: req.Comment}
}
if _, ok = rm["role"]; ok {
if op.Role < req.Role || ((op.Role == req.Role) && opt.RoleMustLess) {
return resp.Resp400(c, req, "用户角色不符合")
}
updates["role"] = req.Role
changes["角色"] = Change{Old: target.Role.Label(), New: req.Role.Label()}
}
if _, ok = rm["privileges"]; ok {
for _, val := range req.Privileges {
if lo.IndexOf(op.Privileges, val) < 0 {
return resp.Resp400(c, req, fmt.Sprintf("权限: %s 不符合", val.Label()))
}
}
changes["权限"] = Change{
Old: lo.Map(target.Privileges, func(item model.Privilege, index int) string {
return item.Label()
}),
New: lo.Map(req.Privileges, func(item model.Privilege, index int) string {
return item.Label()
}),
}
updates["privileges"] = req.Privileges
}
if _, ok = rm["active_at"]; ok {
updates["active_at"] = time.UnixMilli(req.ActiveAt).UnixMilli()
changes["激活时间"] = Change{Old: target.ActiveAt, New: req.ActiveAt}
}
if _, ok = rm["deadline"]; ok {
updates["deadline"] = time.UnixMilli(req.Deadline).UnixMilli()
changes["到期时间"] = Change{Old: target.Deadline, New: req.Deadline}
}
updated := new(model.User)
if err = database.DB.Session(util.Timeout(5)).
Model(updated).
Clauses(clause.Returning{}).
Where("id = ?", req.Id).
Updates(updates).
Error; err != nil {
return resp.Resp500(c, err.Error())
}
if err = controller.UserController.RmUserCache(req.Id); err != nil {
return resp.RespError(c, err)
}
c.Locals(opt.OpLogLocalKey, &oplog.OpLog{Type: model.OpLogTypeUpdateUser, Content: map[string]any{
"target_id": target.Id,
"target_username": target.Username,
"changes": changes,
}})
return resp.Resp200(c, updated)
}
func ManageUserDelete(c *nf.Ctx) error {
type Req struct {
Id uint64 `json:"id"`
}
var (
ok bool
op *model.User
target *model.User
err error
req = new(Req)
)
if err = c.BodyParser(req); err != nil {
return resp.Resp400(c, err.Error())
}
if req.Id == 0 {
return resp.Resp400(c, req)
}
if op, ok = c.Locals("user").(*model.User); !ok {
return resp.Resp401(c, nil)
}
if req.Id == op.Id {
return resp.Resp400(c, nil, "无法删除自己")
}
if target, err = controller.UserController.GetUser(req.Id); err != nil {
return resp.RespError(c, err)
}
if op.Role < target.Role || (op.Role == target.Role && opt.RoleMustLess) {
return resp.Resp403(c, nil)
}
if err = controller.UserController.DeleteUser(target.Id); err != nil {
return resp.RespError(c, err)
}
c.Locals(opt.OpLogLocalKey, &oplog.OpLog{Type: model.OpLogTypeDeleteUser, Content: map[string]any{
"target_id": target.Id,
"target_username": target.Username,
}})
return resp.Resp200(c, nil, "删除成功")
}

View File

@ -0,0 +1,55 @@
package auth
import (
"errors"
"gitea.com/taozitaozi/gredis"
"github.com/go-redis/redis/v8"
"github.com/loveuer/nf"
"github.com/loveuer/nf/nft/resp"
"github.com/loveuer/nfflow/internal/controller"
"github.com/loveuer/nfflow/internal/opt"
"github.com/sirupsen/logrus"
"strings"
)
var (
tokenFunc = func(c *nf.Ctx) string {
token := c.Get("Authorization")
if token == "" {
token = c.Cookies(opt.CookieName)
}
return token
}
)
func NewAuth() nf.HandlerFunc {
return func(c *nf.Ctx) error {
token := tokenFunc(c)
if token = strings.TrimPrefix(token, "Bearer "); token == "" {
return resp.Resp401(c, token)
}
logrus.Tracef("middleware.NewAuth: token=%s", token)
target, err := controller.UserController.GetUserByToken(token)
if err != nil {
logrus.Errorf("middleware.NewAuth: get user by token=%s err=%v", token, err)
if errors.Is(err, redis.Nil) || errors.Is(err, gredis.ErrKeyNotFound) {
return resp.Resp401(c, err)
}
return resp.RespError(c, err)
}
if err = target.IsValid(true); err != nil {
return resp.Resp401(c, err.Error(), err.Error())
}
c.Locals("user", target)
c.Locals("token", token)
return c.Next()
}
}

View File

@ -0,0 +1,370 @@
--------------------------------------------------------------------------------
Package: @angular/core
License: "MIT"
--------------------------------------------------------------------------------
Package: rxjs
License: "Apache-2.0"
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright (c) 2015-2018 Google, Inc., Netflix, Inc., Microsoft Corp. and contributors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
--------------------------------------------------------------------------------
Package: tslib
License: "0BSD"
Copyright (c) Microsoft Corporation.
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
PERFORMANCE OF THIS SOFTWARE.
--------------------------------------------------------------------------------
Package: @angular/common
License: "MIT"
--------------------------------------------------------------------------------
Package: @angular/platform-browser
License: "MIT"
--------------------------------------------------------------------------------
Package: @angular/router
License: "MIT"
--------------------------------------------------------------------------------
Package: @angular/cdk
License: "MIT"
The MIT License
Copyright (c) 2024 Google LLC.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
--------------------------------------------------------------------------------
Package: @angular/material
License: "MIT"
The MIT License
Copyright (c) 2024 Google LLC.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
--------------------------------------------------------------------------------
Package: @angular/animations
License: "MIT"
--------------------------------------------------------------------------------
Package: @angular/forms
License: "MIT"
--------------------------------------------------------------------------------
Package: js-base64
License: "BSD-3-Clause"
Copyright (c) 2014, Dan Kogai
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
* Neither the name of {{{project}}} nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
--------------------------------------------------------------------------------
Package: @angular/service-worker
License: "MIT"
--------------------------------------------------------------------------------
Package: zone.js
License: "MIT"
The MIT License
Copyright (c) 2010-2023 Google LLC. https://angular.io/license
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
--------------------------------------------------------------------------------

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,59 @@
{
"name": "front",
"short_name": "front",
"theme_color": "#1976d2",
"background_color": "#fafafa",
"display": "standalone",
"scope": "./",
"start_url": "./",
"icons": [
{
"src": "assets/icons/icon-72x72.png",
"sizes": "72x72",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "assets/icons/icon-96x96.png",
"sizes": "96x96",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "assets/icons/icon-128x128.png",
"sizes": "128x128",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "assets/icons/icon-144x144.png",
"sizes": "144x144",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "assets/icons/icon-152x152.png",
"sizes": "152x152",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "assets/icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "assets/icons/icon-384x384.png",
"sizes": "384x384",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "assets/icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable any"
}
]
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,79 @@
{
"configVersion": 1,
"timestamp": 1710749227562,
"index": "/index.html",
"assetGroups": [
{
"name": "app",
"installMode": "prefetch",
"updateMode": "prefetch",
"cacheQueryOptions": {
"ignoreVary": true
},
"urls": [
"/favicon.ico",
"/index.html",
"/main-K7KCRENT.js",
"/manifest.webmanifest",
"/polyfills-TMVK3KFA.js",
"/styles-3T3ZIQFC.css"
],
"patterns": []
},
{
"name": "assets",
"installMode": "lazy",
"updateMode": "prefetch",
"cacheQueryOptions": {
"ignoreVary": true
},
"urls": [
"/assets/icons/icon-128x128.png",
"/assets/icons/icon-144x144.png",
"/assets/icons/icon-152x152.png",
"/assets/icons/icon-192x192.png",
"/assets/icons/icon-384x384.png",
"/assets/icons/icon-512x512.png",
"/assets/icons/icon-72x72.png",
"/assets/icons/icon-96x96.png"
],
"patterns": []
}
],
"dataGroups": [],
"hashTable": {
"/assets/icons/icon-128x128.png": "f912963bdc6d5a38d8f1dd0afbaab2ce8d657acb",
"/assets/icons/icon-144x144.png": "b155fd5f2fd5d2ea7760721063003c4cd95fd783",
"/assets/icons/icon-152x152.png": "eae6e0f0c8afb8aaae339e2a569b43e9238e5e6c",
"/assets/icons/icon-192x192.png": "15c4180454633880d98674aa7394656196c91584",
"/assets/icons/icon-384x384.png": "94a915751ef6c9282df9aea405ad679230631814",
"/assets/icons/icon-512x512.png": "1de182f76f7329dfa8f9fcdd1fdcdd695bea6a99",
"/assets/icons/icon-72x72.png": "aa88a6096bd973be7f6d7a5489bfa6bc2463f8c4",
"/assets/icons/icon-96x96.png": "7fb8f59c30ce2ff12c700321a0b39e14b3dc8f95",
"/favicon.ico": "9c39f434fe1261f68c5e3eefdf734630d99c5670",
"/index.html": "3b8d9a492329d3a6f13280951666e6c073c32071",
"/main-K7KCRENT.js": "b2c1bced1f984d3a635681ba3b28da91ac1b4dc4",
"/manifest.webmanifest": "4a17033d41b19f97cc660c6a603fdc95226d1704",
"/polyfills-TMVK3KFA.js": "fda1c82c5c620f1fa442f687b3df45a037f6fcc9",
"/styles-3T3ZIQFC.css": "e1406fd244b1e7e7ab47d07124fffd55ccc16178"
},
"navigationUrls": [
{
"positive": true,
"regex": "^\\/.*$"
},
{
"positive": false,
"regex": "^\\/(?:.+\\/)?[^/]*\\.[^/]*$"
},
{
"positive": false,
"regex": "^\\/(?:.+\\/)?[^/]*__[^/]*$"
},
{
"positive": false,
"regex": "^\\/(?:.+\\/)?[^/]*__[^/]*\\/.*$"
}
],
"navigationRequestStrategy": "performance"
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,26 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
// tslint:disable:no-console
self.addEventListener('install', event => {
self.skipWaiting();
});
self.addEventListener('activate', event => {
event.waitUntil(self.clients.claim());
event.waitUntil(self.registration.unregister().then(() => {
console.log('NGSW Safety Worker - unregistered old service worker');
}));
event.waitUntil(caches.keys().then(cacheNames => {
const ngswCacheNames = cacheNames.filter(name => /^ngsw:/.test(name));
return Promise.all(ngswCacheNames.map(name => caches.delete(name)));
}));
});

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,26 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
// tslint:disable:no-console
self.addEventListener('install', event => {
self.skipWaiting();
});
self.addEventListener('activate', event => {
event.waitUntil(self.clients.claim());
event.waitUntil(self.registration.unregister().then(() => {
console.log('NGSW Safety Worker - unregistered old service worker');
}));
event.waitUntil(caches.keys().then(cacheNames => {
const ngswCacheNames = cacheNames.filter(name => /^ngsw:/.test(name));
return Promise.all(ngswCacheNames.map(name => caches.delete(name)));
}));
});

View File

@ -0,0 +1,61 @@
package front
import (
"embed"
"fmt"
"github.com/loveuer/nf"
"github.com/sirupsen/logrus"
"net/http"
"strings"
)
//go:embed dist/front/browser
var DefaultFront embed.FS
func NewFront(ff *embed.FS, basePath string) nf.HandlerFunc {
var (
e error
indexBytes []byte
index string
)
index = fmt.Sprintf("%s/index.html", basePath)
if indexBytes, e = ff.ReadFile(index); e != nil {
logrus.Panicf("read index file err: %v", e)
}
return func(c *nf.Ctx) error {
var (
err error
bs []byte
path = c.Path()
)
if bs, err = ff.ReadFile(basePath + path); err != nil {
logrus.Debugf("embed read file [%s]%s err: %v", basePath, path, err)
c.Set("Content-Type", "text/html")
_, err = c.Write(indexBytes)
return err
}
var dbs []byte
if len(bs) > 512 {
dbs = bs[:512]
} else {
dbs = bs
}
switch {
case strings.HasSuffix(path, ".js"):
c.Set("Content-Type", "application/javascript")
case strings.HasSuffix(path, ".css"):
c.Set("Content-Type", "text/css")
default:
c.Set("Content-Type", http.DetectContentType(dbs))
}
_, err = c.Write(bs)
return err
}
}

View File

@ -0,0 +1,117 @@
package oplog
import (
"context"
"github.com/loveuer/nf"
"github.com/loveuer/nfflow/internal/database"
"github.com/loveuer/nfflow/internal/model"
"github.com/loveuer/nfflow/internal/opt"
"github.com/loveuer/nfflow/internal/sqlType"
"github.com/loveuer/nfflow/internal/util"
"github.com/sirupsen/logrus"
"sync"
"time"
)
var (
_once = &sync.Once{}
lc = make(chan *model.OpLog, 1024)
)
// NewOpLog
//
// * 记录操作日志的 中间件使用方法如下:
//
// app := nf.New()
// app.Post("/login", oplog.NewOpLog(ctx), HandleLog)
//
// func HandleLog(c *nf.Ctx) error {
// // 你的操作逻辑
// c.Local(opt.OpLogLocalKey, &oplog.OpLog{})
// // 剩下某些逻辑
// // return xxx
// }
func NewOpLog(ctx context.Context) nf.HandlerFunc {
_once.Do(func() {
go func() {
var (
err error
ticker = time.NewTicker(time.Duration(opt.OpLogWriteDurationSecond) * time.Second)
list = make([]*model.OpLog, 0, 1024)
write = func() {
if len(list) == 0 {
return
}
if err = database.DB.Session(util.Timeout(10)).
Model(&model.OpLog{}).
Create(&list).
Error; err != nil {
logrus.Errorf("middleware.NewOpLog: write logs err=%v", err)
}
list = list[:0]
}
)
Loop:
for {
select {
case <-ctx.Done():
break Loop
case <-ticker.C:
write()
case item, ok := <-lc:
if !ok {
return
}
list = append(list, item)
if len(list) >= 100 {
write()
}
}
}
write()
}()
})
return func(c *nf.Ctx) error {
now := time.Now()
err := c.Next()
op, ok := c.Locals("user").(*model.User)
opv := c.Locals(opt.OpLogLocalKey)
log, ok := opv.(*OpLog)
if !ok {
logrus.Warnf("middleware.NewOpLog: %s - %s local '%s' to [*OpLog] invalid", c.Method(), c.Path(), opt.OpLogLocalKey)
return err
}
log.Content["time"] = now.UnixMilli()
log.Content["user_id"] = op.Id
log.Content["username"] = op.Username
log.Content["created_at"] = now.UnixMilli()
select {
case lc <- &model.OpLog{
CreatedAt: now.UnixMilli(),
UpdatedAt: now.UnixMilli(),
UserId: op.Id,
Username: op.Username,
Type: log.Type,
Content: sqlType.NewJSONB(log.Content),
}:
case <-util.Timeout(3).Done():
logrus.Warnf("middleware.NewOpLog: %s - %s log -> chan timeout[3s]", c.Method, c.Path())
}
return err
}
}

View File

@ -0,0 +1,8 @@
package oplog
import "github.com/loveuer/nfflow/internal/model"
type OpLog struct {
Type model.OpLogType
Content map[string]any
}

View File

@ -0,0 +1,86 @@
package privilege
import (
"fmt"
"github.com/loveuer/nf"
"github.com/loveuer/nf/nft/resp"
"github.com/loveuer/nfflow/internal/model"
"github.com/samber/lo"
"github.com/sirupsen/logrus"
"strings"
)
type Relation int64
type vf func(user *model.User, ps ...model.Privilege) error
const (
RelationAnd Relation = iota + 1
RelationOr
)
var (
AndFunc vf = func(user *model.User, ps ...model.Privilege) error {
pm := lo.SliceToMap(user.Privileges, func(item model.Privilege) (int64, struct{}) {
return item.Value(), struct{}{}
})
for _, p := range ps {
if _, exist := pm[p.Value()]; !exist {
return fmt.Errorf("缺少权限: %d, %s, %s", p.Value(), p.Code(), p.Label())
}
}
return nil
}
OrFunc vf = func(user *model.User, ps ...model.Privilege) error {
pm := lo.SliceToMap(user.Privileges, func(item model.Privilege) (int64, struct{}) {
return item.Value(), struct{}{}
})
for _, p := range ps {
if _, exist := pm[p.Value()]; exist {
return nil
}
}
return fmt.Errorf("缺少权限: %s", strings.Join(
lo.Map(ps, func(item model.Privilege, index int) string {
return item.Code()
}),
", ",
))
}
)
func Verify(relation Relation, privileges ...model.Privilege) nf.HandlerFunc {
var _vf vf
switch relation {
case RelationAnd:
_vf = AndFunc
case RelationOr:
_vf = OrFunc
default:
logrus.Panicf("middleware.Verify: unknown relation")
}
return func(c *nf.Ctx) error {
if len(privileges) == 0 {
return c.Next()
}
op, ok := c.Locals("user").(*model.User)
if !ok {
return resp.Resp401(c, nil)
}
if err := _vf(op, privileges...); err != nil {
return resp.Resp403(c, err.Error())
}
return c.Next()
}
}

74
internal/model/init.go Normal file
View File

@ -0,0 +1,74 @@
package model
import (
"github.com/loveuer/nfflow/internal/database"
"github.com/loveuer/nfflow/internal/opt"
"github.com/loveuer/nfflow/internal/util"
"github.com/sirupsen/logrus"
"strings"
)
func MustInit(db database.Store) {
var err error
if err = initModel(db); err != nil {
logrus.Fatalf("model.MustInit: init models err=%v", err)
}
logrus.Info("MustInitModels: auto_migrate privilege model success")
if err = initData(db); err != nil {
logrus.Fatalf("model.MustInit: init datas err=%v", err)
}
}
func initModel(client database.Store) error {
if err := client.Session(util.Timeout(10)).AutoMigrate(
&User{},
&OpLog{},
&Task{},
); err != nil {
return err
}
logrus.Info("MustInitModels: auto_migrate user model success")
return nil
}
func initData(client database.Store) error {
var (
err error
)
{
count := 0
if err = client.Session(util.Timeout()).Model(&User{}).Select("count(id)").Take(&count).Error; err != nil {
return err
}
if count < len(initUsers) {
logrus.Warn("mustInitDatas: user count = 0, start init...")
for _, user := range initUsers {
if err = client.Session(util.Timeout(5)).Model(&User{}).Create(user).Error; err != nil {
if !strings.Contains(err.Error(), "SQLSTATE 23505") {
return err
}
}
}
if opt.Cfg.Database.Type == "postgresql" {
if err = client.Session(util.Timeout(5)).Exec(`SELECT setval('users_id_seq', (SELECT MAX(id) FROM users))`).Error; err != nil {
return err
}
}
logrus.Info("mustInitDatas: creat init users success")
}
}
return nil
}

View File

@ -0,0 +1,17 @@
package model
type Enum interface {
Value() int64
Code() string
Label() string
MarshalJSON() ([]byte, error)
All() []Enum
}
type OpLogger interface {
Enum
Render(content map[string]any) (string, error)
Template() string
}

311
internal/model/oplog.go Normal file
View File

@ -0,0 +1,311 @@
package model
import (
"bytes"
"encoding/base64"
"encoding/json"
"github.com/loveuer/nfflow/internal/sqlType"
"github.com/spf13/cast"
"github.com/tdewolff/minify/v2"
"github.com/tdewolff/minify/v2/html"
"html/template"
"time"
)
var (
FuncMap = template.FuncMap{
"time_format": func(mil any, format string) string {
return time.UnixMilli(cast.ToInt64(mil)).Format(format)
},
}
)
var (
_ OpLogger = OpLogType(0)
)
type OpLogType uint64
const (
OpLogTypeLogin OpLogType = iota + 1
OpLogTypeLogout
OpLogTypeCreateUser
OpLogTypeUpdateUser
OpLogTypeDeleteUser
// todo: 添加自己的操作日志 分类
)
func (o OpLogType) Value() int64 {
return int64(o)
}
func (o OpLogType) Code() string {
switch o {
case OpLogTypeLogin:
return "login"
case OpLogTypeLogout:
return "logout"
case OpLogTypeCreateUser:
return "create_user"
case OpLogTypeUpdateUser:
return "update_user"
case OpLogTypeDeleteUser:
return "delete_user"
default:
return "unknown"
}
}
func (o OpLogType) Label() string {
switch o {
case OpLogTypeLogin:
return "登入"
case OpLogTypeLogout:
return "登出"
case OpLogTypeCreateUser:
return "创建用户"
case OpLogTypeUpdateUser:
return "修改用户"
case OpLogTypeDeleteUser:
return "删除用户"
default:
return "未知"
}
}
func (o OpLogType) MarshalJSON() ([]byte, error) {
return json.Marshal(map[string]any{
"value": o.Value(),
"code": o.Code(),
"label": o.Label(),
})
}
func (o OpLogType) All() []Enum {
return []Enum{
OpLogTypeLogin,
OpLogTypeLogout,
OpLogTypeCreateUser,
OpLogTypeUpdateUser,
OpLogTypeDeleteUser,
}
}
func _trimHTML(v []byte) string {
//if len(v) == 0 {
// return ""
//}
//
//bs := make([]byte, 0, len(v))
//
//for _, b := range v {
// if b == '\t' || b == '\n' {
// continue
// }
//
// bs = append(bs, b)
//}
return base64.StdEncoding.EncodeToString(v)
//return string(bs)
}
var (
_mini = minify.New()
)
func init() {
_mini.AddFunc("text/html", html.Minify)
}
func (o OpLogType) Render(content map[string]any) (string, error) {
var (
err error
render *template.Template
buf bytes.Buffer
bs []byte
)
if render, err = template.New(o.Code()).
Funcs(FuncMap).
Parse(o.Template()); err != nil {
return "", err
}
if err = render.Execute(&buf, content); err != nil {
return "", err
}
if bs, err = _mini.Bytes("text/html", buf.Bytes()); err != nil {
return "", err
}
//return string(bs), nil
return _trimHTML(bs), nil
}
const (
oplogTemplateLogin = `
<div class="nf-op-log">
用户
<span
class="nf-op-log-user nf-op-log-keyword"
nf-op-log-user="{{ .user_id }}"
>{{ .username }}
</span>
<span
class="nf-op-log-time nf-op-log-keyword"
nf-op-log-time="{{ .time }}"
>{{ time_format .time "2006-01-02 15:04:05" }}
</span>
<span
class="nf-op-log-ip nf-op-log-keyword"
>{{ .ip }}
</span>
<span
class="nf-op-log-op nf-op-log-keyword"
>
登入
</span>
了系统
</div>
`
oplogTemplateLogout = `
<div class="nf-op-log">
用户
<span
class="nf-op-log-user nf-op-log-keyword"
nf-op-log-user="{{ .user_id }}"
>{{ .username }}
</span>
<span
class="nf-op-log-time nf-op-log-keyword"
nf-op-log-time="{{ .time }}"
>{{ time_format .time "2006-01-02 15:04:05" }}
</span>
<span
class="nf-op-log-ip nf-op-log-keyword"
>{{ .ip }}
</span>
<span
class="nf-op-log-op nf-op-log-keyword"
>
登出
</span>
了系统
</div>
`
oplogTemplateCreateUser = `
<div class="nf-op-log">
用户
<span
class="nf-op-log-user nf-op-log-keyword"
nf-op-log-user="{{ .user_id }}"
>{{ .username }}
</span>
<span
class="nf-op-log-time nf-op-log-keyword"
nf-op-log-time="{{ .time }}"
>{{ time_format .time "2006-01-02 15:04:05" }}
</span>
<span class="nf-op-log-keyword">
创建
</span>
了用户
<span
class="nf-op-log-target nf-op-log-keyword"
nf-op-log-target="{{ .target_id }}"
>{{ .target_username }}
</span>
</div>
`
oplogTemplateUpdateUser = `
<div class="nf-op-log">
用户
<span
class="nf-op-log-user nf-op-log-keyword"
nf-op-log-user='{{ .user_id }}'
>{{ .username }}
</span>
<span
class="nf-op-log-time nf-op-log-keyword"
nf-op-log-time='{{ .time }}'
>{{ time_format .time "2006-01-02 15:04:05" }}
</span>
<span class="nf-op-log-keyword">
编辑
</span>
了用户
<span
class="nf-op-log-target nf-op-log-keyword"
nf-op-log-target="{{ .target_id }}"
>{{ .target_username }}
</span>
</div>
`
oplogTemplateDeleteUser = `
<div class="nf-op-log">
用户
<span
class="nf-op-log-user nf-op-log-keyword"
nf-op-log-user="{{ .user_id }}"
>{{ .username }}
</span>
<span
class="nf-op-log-time nf-op-log-keyword"
nf-op-log-time="{{ .time }}"
>{{ time_format .time "2006-01-02 15:04:05" }}
</span>
<span class="nf-op-log-keyword">
删除
</span>
了用户
<span
class="nf-op-log-target nf-op-log-keyword"
nf-op-log-target="{{ .target_id }}"
>{{ .target_username }}
</span>
</div>
`
)
func (o OpLogType) Template() string {
switch o {
case OpLogTypeLogin:
return oplogTemplateLogin
case OpLogTypeLogout:
return oplogTemplateLogout
case OpLogTypeCreateUser:
return oplogTemplateCreateUser
case OpLogTypeUpdateUser:
return oplogTemplateUpdateUser
case OpLogTypeDeleteUser:
return oplogTemplateDeleteUser
default:
return `<div>错误的日志类型</div>`
}
}
type OpLog struct {
Id uint64 `json:"id" gorm:"primaryKey;column:id"`
CreatedAt int64 `json:"created_at" gorm:"column:created_at;autoCreateTime:milli"`
UpdatedAt int64 `json:"updated_at" gorm:"column:updated_at;autoUpdateTime:milli"`
DeletedAt int64 `json:"deleted_at" gorm:"index;column:deleted_at;default:0"`
UserId uint64 `json:"user_id" gorm:"column:user_id"`
Username string `json:"username" gorm:"column:username;varchar(128)"`
Type OpLogType `json:"type" gorm:"column:type;type:varchar(128)"`
Content sqlType.JSONB `json:"content" gorm:"column:content;type:jsonb"`
HTML string `json:"html" gorm:"-"`
}

View File

@ -0,0 +1,61 @@
package model
import "encoding/json"
type Privilege uint64
type _privilege struct {
Value int64 `json:"value"`
Code string `json:"code"`
Label string `json:"label"`
}
const (
PrivilegeUserManage Privilege = iota + 1
PrivilegeOpLog
)
func (p Privilege) Value() int64 {
return int64(p)
}
func (p Privilege) Code() string {
switch p {
case PrivilegeUserManage:
return "user_manage"
case PrivilegeOpLog:
return "oplog"
default:
return "unknown"
}
}
func (p Privilege) Label() string {
switch p {
case PrivilegeUserManage:
return "用户管理"
case PrivilegeOpLog:
return "操作日志"
default:
return "未知"
}
}
func (p Privilege) MarshalJSON() ([]byte, error) {
_p := &_privilege{
Value: int64(p),
Code: p.Code(),
Label: p.Label(),
}
return json.Marshal(_p)
}
func (p Privilege) All() []Enum {
return []Enum{
PrivilegeUserManage,
PrivilegeOpLog,
}
}
var _ Enum = (*Privilege)(nil)

85
internal/model/role.go Normal file
View File

@ -0,0 +1,85 @@
package model
import (
"encoding/json"
"github.com/loveuer/nfflow/internal/opt"
"gorm.io/gorm"
)
type _role struct {
Value uint8 `json:"value"`
Code string `json:"code"`
Label string `json:"label"`
}
type Role uint8
var _ Enum = Role(0)
func (u Role) MarshalJSON() ([]byte, error) {
m := _role{
Value: uint8(u),
Code: u.Code(),
Label: u.Label(),
}
return json.Marshal(m)
}
const (
RoleRoot Role = 255
RoleAdmin Role = 254
RoleUser Role = 100
)
func (u Role) Code() string {
switch u {
case RoleRoot:
return "root"
case RoleAdmin:
return "admin"
case RoleUser:
return "user"
default:
return "unknown"
}
}
func (u Role) Label() string {
switch u {
case RoleRoot:
return "根用户"
case RoleAdmin:
return "管理员"
case RoleUser:
return "用户"
default:
return "未知"
}
}
func (u Role) Value() int64 {
return int64(u)
}
func (u Role) All() []Enum {
return []Enum{
RoleAdmin,
RoleUser,
}
}
func (u Role) Where(db *gorm.DB) *gorm.DB {
if opt.RoleMustLess {
return db.Where("users.role < ?", u.Value())
} else {
return db.Where("users.role <= ?", u.Value())
}
}
func (u Role) CanOP(op *User) bool {
if opt.RoleMustLess {
return op.Role > u
}
return op.Role >= u
}

128
internal/model/task.go Normal file
View File

@ -0,0 +1,128 @@
package model
import (
"encoding/json"
"github.com/loveuer/nfflow/internal/sqlType"
)
type InputType int64
const (
InputTypeES InputType = iota + 1
InputTypePG
InputTypeMQ
)
type OutputType int64
const (
OutputTypeES OutputType = iota + 1
OutputTypeFile
OutputTypeMQ
)
type TaskStatus int64
func (t TaskStatus) MarshalJSON() ([]byte, error) {
return json.Marshal(map[string]any{
"value": t.Value(),
"code": t.Code(),
"label": t.Label(),
})
}
func (t TaskStatus) All() []Enum {
return []Enum{
TaskStatusNotReady,
TaskStatusReady,
TaskStatusRunning,
TaskStatusSucceed,
TaskStatusFailed,
}
}
var _ Enum = TaskStatus(0)
const (
TaskStatusNotReady TaskStatus = iota
TaskStatusReady
TaskStatusRunning
TaskStatusSucceed
TaskStatusFailed
)
func (t TaskStatus) Value() int64 {
return int64(t)
}
func (t TaskStatus) Code() string {
switch t {
case TaskStatusNotReady:
return "not_ready"
case TaskStatusReady:
return "ready"
case TaskStatusRunning:
return "running"
case TaskStatusSucceed:
return "succeed"
case TaskStatusFailed:
return "failed"
default:
return "unknown"
}
}
func (t TaskStatus) Label() string {
switch t {
case TaskStatusNotReady:
return "未完善"
case TaskStatusReady:
return "等待执行"
case TaskStatusRunning:
return "执行中"
case TaskStatusSucceed:
return "执行成功"
case TaskStatusFailed:
return "执行失败"
default:
return "未知"
}
}
type Task struct {
Id uint64 `json:"id" gorm:"primaryKey;column:id"`
CreatedAt int64 `json:"created_at" gorm:"column:created_at;autoCreateTime:milli"`
UpdatedAt int64 `json:"updated_at" gorm:"column:updated_at;autoUpdateTime:milli"`
DeletedAt int64 `json:"deleted_at" gorm:"index;column:deleted_at;default:0"`
TaskName string `json:"task_name" gorm:"column:task_name;type:varchar(256)"`
TaskRunType string `json:"task_run_type" gorm:"column:task_run_type;type:varchar(16);default:once"` // cron: C-"cron syntax", "once", timestamp: T-1234567890123 毫秒时间戳
TimeoutSecond int `json:"timeout_second" gorm:"column:timeout_second"`
TaskStatus TaskStatus `json:"task_status" gorm:"column:task_status"`
TaskLog string `json:"task_log" gorm:"task_log"`
}
type Input struct {
Id uint64 `json:"id" gorm:"primaryKey;column:id"`
CreatedAt int64 `json:"created_at" gorm:"column:created_at;autoCreateTime:milli"`
UpdatedAt int64 `json:"updated_at" gorm:"column:updated_at;autoUpdateTime:milli"`
DeletedAt int64 `json:"deleted_at" gorm:"index;column:deleted_at;default:0"`
InputType InputType `json:"input_type"`
InputConfig sqlType.JSONB `json:"input_config"`
}
type Output struct {
Id uint64 `json:"id" gorm:"primaryKey;column:id"`
CreatedAt int64 `json:"created_at" gorm:"column:created_at;autoCreateTime:milli"`
UpdatedAt int64 `json:"updated_at" gorm:"column:updated_at;autoUpdateTime:milli"`
DeletedAt int64 `json:"deleted_at" gorm:"index;column:deleted_at;default:0"`
OutputType OutputType
OutputConfig sqlType.JSONB
}
type Pipe struct {
Id uint64 `json:"id" gorm:"primaryKey;column:id"`
CreatedAt int64 `json:"created_at" gorm:"column:created_at;autoCreateTime:milli"`
UpdatedAt int64 `json:"updated_at" gorm:"column:updated_at;autoUpdateTime:milli"`
DeletedAt int64 `json:"deleted_at" gorm:"index;column:deleted_at;default:0"`
}

257
internal/model/user.go Normal file
View File

@ -0,0 +1,257 @@
package model
import (
"encoding/json"
"errors"
"fmt"
"github.com/golang-jwt/jwt/v5"
"github.com/loveuer/nfflow/internal/opt"
"github.com/loveuer/nfflow/internal/sqlType"
"github.com/loveuer/nfflow/internal/util"
"github.com/samber/lo"
"github.com/sirupsen/logrus"
"github.com/spf13/cast"
"strings"
"time"
)
var (
initUsers = []*User{
{
Id: 1,
Username: "root",
Password: util.NewPassword("404@Ro0t"),
Nickname: "admin",
Role: RoleRoot,
//Privileges: sqlType.NumSlice[Privilege]{
// PrivilegeUserManage,
//},
Privileges: lo.Map(Privilege(0).All(), func(item Enum, index int) Privilege {
return item.(Privilege)
}),
CreatedById: 1,
CreatedByName: "admin",
ActiveAt: time.Now().UnixMilli(),
Deadline: time.Now().AddDate(100, 0, 0).UnixMilli(),
},
{
Id: 2,
Username: "admin",
Password: util.NewPassword("Foobar123"),
Nickname: "admin",
Role: RoleAdmin,
Privileges: lo.Map(Privilege(0).All(), func(item Enum, index int) Privilege {
return item.(Privilege)
}),
CreatedById: 1,
CreatedByName: "admin",
ActiveAt: time.Now().UnixMilli(),
Deadline: time.Now().AddDate(100, 0, 0).UnixMilli(),
},
{
Id: 3,
Username: "user",
Password: util.NewPassword("Foobar123"),
Nickname: "user",
Role: RoleUser,
Privileges: sqlType.NumSlice[Privilege]{
PrivilegeOpLog,
},
CreatedById: 2,
CreatedByName: "admin",
ActiveAt: time.Now().UnixMilli(),
Deadline: time.Now().AddDate(100, 0, 0).UnixMilli(),
},
}
_ Enum = Status(0)
)
type Status uint64
const (
StatusNormal Status = iota
StatusFrozen
)
func (s Status) Value() int64 {
return int64(s)
}
func (s Status) Code() string {
switch s {
case StatusNormal:
return "normal"
case StatusFrozen:
return "frozen"
default:
return "unknown"
}
}
func (s Status) Label() string {
switch s {
case StatusNormal:
return "正常"
case StatusFrozen:
return "冻结"
default:
return "异常"
}
}
func (s Status) All() []Enum {
return []Enum{
StatusNormal,
StatusFrozen,
}
}
func (s Status) MarshalJSON() ([]byte, error) {
return json.Marshal(map[string]any{
"value": s.Value(),
"code": s.Code(),
"label": s.Label(),
})
}
type User struct {
Id uint64 `json:"id" gorm:"primaryKey;column:id"`
CreatedAt int64 `json:"created_at" gorm:"column:created_at;autoCreateTime:milli"`
UpdatedAt int64 `json:"updated_at" gorm:"column:updated_at;autoUpdateTime:milli"`
DeletedAt int64 `json:"deleted_at" gorm:"index;column:deleted_at;default:0"`
Username string `json:"username" gorm:"column:username;type:varchar(64);unique"`
Password string `json:"-" gorm:"column:password;type:varchar(256)"`
Status Status `json:"status" gorm:"column:status;default:0"`
Nickname string `json:"nickname" gorm:"column:nickname;type:varchar(64)"`
Comment string `json:"comment" gorm:"column:comment"`
Role Role `json:"role" gorm:"column:role"`
Privileges sqlType.NumSlice[Privilege] `json:"privileges" gorm:"column:privileges;type:bigint[]"`
CreatedById uint64 `json:"created_by_id" gorm:"column:created_by_id"`
CreatedByName string `json:"created_by_name" gorm:"column:created_by_name;type:varchar(64)"`
ActiveAt int64 `json:"active_at" gorm:"column:active_at"`
Deadline int64 `json:"deadline" gorm:"column:deadline"`
LoginAt int64 `json:"login_at" gorm:"-"`
}
func (u *User) CheckStatus(mustOk bool) error {
switch u.Status {
case StatusNormal:
case StatusFrozen:
if mustOk {
return errors.New("用户被冻结")
}
default:
return errors.New("用户状态未知")
}
return nil
}
func (u *User) IsValid(mustOk bool) error {
now := time.Now()
if now.UnixMilli() >= u.Deadline {
return errors.New("用户已过期")
}
if now.UnixMilli() < u.ActiveAt {
return errors.New("用户未启用")
}
if u.DeletedAt > 0 {
return errors.New("用户不存在")
}
return u.CheckStatus(mustOk)
}
func (u *User) JwtEncode() (token string, err error) {
now := time.Now()
jwtToken := jwt.NewWithClaims(jwt.SigningMethodHS512, jwt.MapClaims{
"id": u.Id,
"username": u.Username,
"status": u.Status,
"deadline": u.Deadline,
"login_at": now.UnixMilli(),
})
if token, err = jwtToken.SignedString([]byte(opt.JwtTokenSecret)); err != nil {
err = fmt.Errorf("JwtEncode: jwt token signed secret err: %v", err)
logrus.Error(err)
return "", nil
}
return
}
func (u *User) FromJwt(token string) *User {
var (
ok bool
err error
pt *jwt.Token
claims jwt.MapClaims
)
token = strings.TrimPrefix(token, "Bearer ")
if pt, err = jwt.Parse(token, func(t *jwt.Token) (interface{}, error) {
if _, ok = t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
}
return []byte(opt.JwtTokenSecret), nil
}); err != nil {
logrus.Errorf("jwt parse err: %v", err)
return nil
}
if !pt.Valid {
logrus.Warn("parsed jwt invalid")
return nil
}
if claims, ok = pt.Claims.(jwt.MapClaims); !ok {
logrus.Error("convert jwt claims err")
return nil
}
u.Id = cast.ToUint64(claims["user_id"])
u.Username = cast.ToString(claims["username"])
u.Status = Status(cast.ToInt64(claims["status"]))
u.Deadline = cast.ToInt64(claims["deadline"])
u.LoginAt = cast.ToInt64(claims["login_at"])
return u
}
func (u User) MarshalBinary() ([]byte, error) {
return json.Marshal(map[string]any{
"id": u.Id,
"created_at": u.CreatedAt,
"updated_at": u.UpdatedAt,
"deleted_at": u.DeletedAt,
"username": u.Username,
"status": u.Status.Value(),
"nickname": u.Nickname,
"comment": u.Comment,
"role": uint8(u.Role),
"privileges": lo.Map(u.Privileges, func(item Privilege, index int) int64 {
return item.Value()
}),
"created_by_id": u.CreatedById,
"created_by_name": u.CreatedByName,
"active_at": u.ActiveAt,
"deadline": u.Deadline,
"login_at": u.LoginAt,
})
}

84
internal/opt/opt.go Normal file
View File

@ -0,0 +1,84 @@
package opt
import (
"encoding/json"
"flag"
"github.com/sirupsen/logrus"
"io"
"os"
"path"
"runtime"
"strconv"
)
type database struct {
Type string `json:"type"` // postgresql, mysql, sqlite
Path string `json:"path"`
Host string `json:"host"`
Port int `json:"port"`
DB string `json:"db"`
Username string `json:"username"`
Password string `json:"password"`
}
type cache struct {
Type string `json:"type"` // redis, memor
Host string `json:"host"`
Port int `json:"port"`
Username string `json:"username"`
Password string `json:"password"`
}
type config struct {
Name string `json:"name"`
Address string `json:"address"`
Database database `json:"database"`
Cache cache `json:"cache"`
}
var (
ConfigFile string
Debug int
Cfg = &config{}
)
func MustInitConfig() {
flag.Parse()
var (
err error
cf *os.File
bs []byte
)
if cf, err = os.Open(ConfigFile); err != nil {
logrus.Panicf("MustInitConfig: open config file=%s err=%v", ConfigFile, err)
}
defer cf.Close()
if bs, err = io.ReadAll(cf); err != nil {
logrus.Panicf("MustInitConfig: read in config file err=%v", err)
}
if err = json.Unmarshal(bs, Cfg); err != nil {
logrus.Panicf("MustInitConfig: json marshal config=%s err=%v", string(bs), err)
}
if Debug > 0 {
logrus.SetLevel(logrus.DebugLevel)
if Debug >= 9999 {
logrus.SetLevel(logrus.TraceLevel)
logrus.SetReportCaller(true)
logrus.SetFormatter(&logrus.TextFormatter{
ForceColors: true,
CallerPrettyfier: func(frame *runtime.Frame) (string, string) {
file := path.Base(frame.File)
return "", " " + file + ":" + strconv.Itoa(frame.Line)
},
})
}
}
logrus.Infof("read in config detail: \n%s", string(bs))
}

46
internal/opt/var.go Normal file
View File

@ -0,0 +1,46 @@
package opt
import "time"
const (
Version = "v0.0.1"
// todo: 可以替换自己生生成的 secret
JwtTokenSecret = "7^D+UW3BPB2Mnz)bY3uVrAUyv&dj8Kdz"
// todo: 是否打开 gorm 的 debug 打印 (开发和 dev 环境时可以打开)
DBDebug = true
// todo: 同一个账号是否可以多 client 登录
MultiLogin = false
// todo: 用户量不大的情况, 并没有缓存用户具体信息, 如果需要可以打开
EnableUserCache = true
// todo: 缓存时, key 的前缀
CachePrefix = "ultone"
// todo: 登录颁发的 cookie 的 name
CookieName = "utlone-token"
// todo: 用户列表,日志列表 size 参数
DefaultSize, MaxSize = 20, 200
// todo: 操作用户时, role 相等时能否操作: 包括 列表, 能否新建,修改,删除同样 role 的用户
RoleMustLess = false
// todo: 通过 c.Local() 存入 oplog 时的 key 值
OpLogLocalKey = "oplog"
// todo: 操作日志 最多延迟多少秒写入(最多缓存多少秒的日志,然后 bulk 写入)
OpLogWriteDurationSecond = 5
TaskMinTimeout = 10
TaskMaxTimeout = 24 * 3600
TaskFetchInterval = 5 * 60
)
var (
// todo: 颁发的 token, (cookie) 在缓存中存在的时间 (每次请求该时间也会被刷新)
TokenTimeout = time.Duration(3600*12) * time.Second
)

9
internal/sqlType/err.go Normal file
View File

@ -0,0 +1,9 @@
package sqlType
import "errors"
var (
ErrConvertScanVal = errors.New("convert scan val to str err")
ErrInvalidScanVal = errors.New("scan val invalid")
ErrConvertVal = errors.New("convert err")
)

76
internal/sqlType/jsonb.go Normal file
View File

@ -0,0 +1,76 @@
package sqlType
import (
"database/sql/driver"
"encoding/json"
"github.com/jackc/pgtype"
)
type JSONB struct {
Val pgtype.JSONB
Valid bool
}
func NewJSONB(v interface{}) JSONB {
j := new(JSONB)
j.Val = pgtype.JSONB{}
if err := j.Val.Set(v); err == nil {
j.Valid = true
return *j
}
return *j
}
func (j *JSONB) Set(value interface{}) error {
if err := j.Val.Set(value); err != nil {
j.Valid = false
return err
}
j.Valid = true
return nil
}
func (j *JSONB) Bind(model interface{}) error {
return j.Val.AssignTo(model)
}
func (j *JSONB) Scan(value interface{}) error {
j.Val = pgtype.JSONB{}
if value == nil {
j.Valid = false
return nil
}
j.Valid = true
return j.Val.Scan(value)
}
func (j JSONB) Value() (driver.Value, error) {
if j.Valid {
return j.Val.Value()
}
return nil, nil
}
func (j JSONB) MarshalJSON() ([]byte, error) {
if j.Valid {
return j.Val.MarshalJSON()
}
return json.Marshal(nil)
}
func (j *JSONB) UnmarshalJSON(b []byte) error {
if string(b) == "null" {
j.Valid = false
return j.Val.UnmarshalJSON(b)
}
return j.Val.UnmarshalJSON(b)
}

View File

@ -0,0 +1,42 @@
package sqlType
import (
"database/sql"
"encoding/json"
)
// type NullString struct {
// sql.NullString
// }
type NullString struct{ sql.NullString }
func NewNullString(val string) NullString {
if val == "" {
return NullString{}
}
return NullString{sql.NullString{Valid: true, String: val}}
}
func (ns NullString) MarshalJSON() ([]byte, error) {
if !ns.Valid {
return json.Marshal(nil)
}
return json.Marshal(ns.String)
}
func (ns *NullString) UnmarshalJSON(data []byte) error {
if string(data) == "null" {
ns.Valid = false
return nil
}
if err := json.Unmarshal(data, &ns.String); err != nil {
ns.Valid = true
return err
}
return nil
}

53
internal/sqlType/set.go Normal file
View File

@ -0,0 +1,53 @@
package sqlType
import "encoding/json"
type Set map[string]struct{}
func (s Set) MarshalJSON() ([]byte, error) {
array := make([]string, 0)
for name := range s {
array = append(array, name)
}
return json.Marshal(array)
}
func (s *Set) UnmarshalJSON(b []byte) error {
array := make([]string, 0)
if err := json.Unmarshal(b, &array); err != nil {
return err
}
set := make(map[string]struct{})
for _, name := range array {
set[name] = struct{}{}
}
*s = set
return nil
}
func (s Set) ToStringSlice() []string {
var (
result = make([]string, 0, len(s))
)
for key := range s {
result = append(result, key)
}
return result
}
func (s *Set) FromStringSlice(ss *[]string) {
if s == nil {
m := make(Set)
s = &m
}
for idx := range *(ss) {
(*s)[(*ss)[idx]] = struct{}{}
}
}

View File

@ -0,0 +1,109 @@
package sqlType
import (
"bytes"
"database/sql/driver"
"encoding/json"
)
type StrSlice []string
func (s *StrSlice) Scan(val interface{}) error {
str, ok := val.(string)
if !ok {
return ErrConvertScanVal
}
if len(str) < 2 {
return nil
}
bs := make([]byte, 0, 128)
bss := make([]byte, 0, 2*len(str))
quoteCount := 0
for idx := 1; idx < len(str)-1; idx++ {
// 44: , 92: \ 34: "
quote := str[idx]
switch quote {
case 44:
if quote == 44 && str[idx-1] != 92 && quoteCount == 0 {
if len(bs) > 0 {
if !(bs[0] == 34 && bs[len(bs)-1] == 34) {
bs = append([]byte{34}, bs...)
bs = append(bs, 34)
}
bss = append(bss, bs...)
bss = append(bss, 44)
}
bs = bs[:0]
} else {
bs = append(bs, quote)
}
case 34:
if str[idx-1] != 92 {
quoteCount = (quoteCount + 1) % 2
}
bs = append(bs, quote)
default:
bs = append(bs, quote)
}
//bs = append(bs, str[idx])
}
if len(bs) > 0 {
if !(bs[0] == 34 && bs[len(bs)-1] == 34) {
bs = append([]byte{34}, bs...)
bs = append(bs, 34)
}
bss = append(bss, bs...)
} else {
if len(bss) > 2 {
bss = bss[:len(bss)-2]
}
}
bss = append([]byte{'['}, append(bss, ']')...)
if err := json.Unmarshal(bss, s); err != nil {
return err
}
return nil
}
func (s StrSlice) Value() (driver.Value, error) {
if s == nil {
return "{}", nil
}
if len(s) == 0 {
return "{}", nil
}
buf := &bytes.Buffer{}
encoder := json.NewEncoder(buf)
encoder.SetEscapeHTML(false)
if err := encoder.Encode(s); err != nil {
return "{}", err
}
bs := buf.Bytes()
bs[0] = '{'
if bs[len(bs)-1] == 10 {
bs = bs[:len(bs)-1]
}
bs[len(bs)-1] = '}'
return string(bs), nil
}

View File

@ -0,0 +1,71 @@
package sqlType
import (
"database/sql/driver"
"fmt"
"strconv"
"strings"
"github.com/spf13/cast"
)
type NumSlice[T ~int | ~int64 | ~uint | ~uint64] []T
func (n *NumSlice[T]) Scan(val interface{}) error {
str, ok := val.(string)
if !ok {
return ErrConvertScanVal
}
length := len(str)
if length <= 0 {
*n = make(NumSlice[T], 0)
return nil
}
if str[0] != '{' || str[length-1] != '}' {
return ErrInvalidScanVal
}
str = str[1 : length-1]
if len(str) == 0 {
*n = make(NumSlice[T], 0)
return nil
}
numStrs := strings.Split(str, ",")
nums := make([]T, len(numStrs))
for idx := range numStrs {
num, err := cast.ToInt64E(strings.TrimSpace(numStrs[idx]))
if err != nil {
return fmt.Errorf("%w: can't convert to %T", ErrConvertVal, T(0))
}
nums[idx] = T(num)
}
*n = nums
return nil
}
func (n NumSlice[T]) Value() (driver.Value, error) {
if n == nil {
return "{}", nil
}
if len(n) == 0 {
return "{}", nil
}
ss := make([]string, 0, len(n))
for idx := range n {
ss = append(ss, strconv.Itoa(int(n[idx])))
}
s := strings.Join(ss, ", ")
return fmt.Sprintf("{%s}", s), nil
}

22
internal/util/ctx.go Normal file
View File

@ -0,0 +1,22 @@
package util
import (
"context"
"time"
)
func Timeout(seconds ...int) (ctx context.Context) {
var (
duration time.Duration
)
if len(seconds) > 0 && seconds[0] > 0 {
duration = time.Duration(seconds[0]) * time.Second
} else {
duration = time.Duration(30) * time.Second
}
ctx, _ = context.WithTimeout(context.Background(), duration)
return
}

84
internal/util/password.go Normal file
View File

@ -0,0 +1,84 @@
package util
import (
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"github.com/sirupsen/logrus"
"golang.org/x/crypto/pbkdf2"
"regexp"
"strconv"
"strings"
)
const (
EncryptHeader string = "pbkdf2:sha256" // 用户密码加密
)
func NewPassword(password string) string {
return EncryptPassword(password, RandomString(8), int(RandomInt(50000)+100000))
}
func ComparePassword(in, db string) bool {
strs := strings.Split(db, "$")
if len(strs) != 3 {
logrus.Errorf("password in db invalid: %s", db)
return false
}
encs := strings.Split(strs[0], ":")
if len(encs) != 3 {
logrus.Errorf("password in db invalid: %s", db)
return false
}
encIteration, err := strconv.Atoi(encs[2])
if err != nil {
logrus.Errorf("password in db invalid: %s, convert iter err: %s", db, err)
return false
}
return EncryptPassword(in, strs[1], encIteration) == db
}
func EncryptPassword(password, salt string, iter int) string {
hash := pbkdf2.Key([]byte(password), []byte(salt), iter, 32, sha256.New)
encrypted := hex.EncodeToString(hash)
return fmt.Sprintf("%s:%d$%s$%s", EncryptHeader, iter, salt, encrypted)
}
func CheckPassword(password string) error {
if len(password) < 8 || len(password) > 32 {
return errors.New("密码长度不符合")
}
var (
err error
match bool
patternList = []string{`[0-9]+`, `[a-z]+`, `[A-Z]+`, `[!@#%]+`} //, `[~!@#$%^&*?_-]+`}
matchAccount = 0
tips = []string{"缺少数字", "缺少小写字母", "缺少大写字母", "缺少'!@#%'"}
locktips = make([]string, 0)
)
for idx, pattern := range patternList {
match, err = regexp.MatchString(pattern, password)
if err != nil {
logrus.Warnf("regex match string err, reg_str: %s, err: %v", pattern, err)
return errors.New("密码强度不够")
}
if match {
matchAccount++
} else {
locktips = append(locktips, tips[idx])
}
}
if matchAccount < 3 {
return fmt.Errorf("密码强度不够, 可能 %s", strings.Join(locktips, ", "))
}
return nil
}

54
internal/util/random.go Normal file
View File

@ -0,0 +1,54 @@
package util
import (
"crypto/rand"
"math/big"
)
var (
letters = []byte("0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
letterNum = []byte("0123456789")
letterLow = []byte("abcdefghijklmnopqrstuvwxyz")
letterCap = []byte("ABCDEFGHIJKLMNOPQRSTUVWXYZ")
letterSyb = []byte("!@#$%^&*()_+-=")
)
func RandomInt(max int64) int64 {
num, _ := rand.Int(rand.Reader, big.NewInt(max))
return num.Int64()
}
func RandomString(length int) string {
result := make([]byte, length)
for i := 0; i < length; i++ {
num, _ := rand.Int(rand.Reader, big.NewInt(int64(len(letters))))
result[i] = letters[num.Int64()]
}
return string(result)
}
func RandomPassword(length int, withSymbol bool) string {
result := make([]byte, length)
kind := 3
if withSymbol {
kind++
}
for i := 0; i < length; i++ {
switch i % kind {
case 0:
num, _ := rand.Int(rand.Reader, big.NewInt(int64(len(letterNum))))
result[i] = letterNum[num.Int64()]
case 1:
num, _ := rand.Int(rand.Reader, big.NewInt(int64(len(letterLow))))
result[i] = letterLow[num.Int64()]
case 2:
num, _ := rand.Int(rand.Reader, big.NewInt(int64(len(letterCap))))
result[i] = letterCap[num.Int64()]
case 3:
num, _ := rand.Int(rand.Reader, big.NewInt(int64(len(letterSyb))))
result[i] = letterSyb[num.Int64()]
}
}
return string(result)
}