diff --git a/app/public/robots.txt b/app/public/robots.txt index f6e6d1d4..4baa86ee 100644 --- a/app/public/robots.txt +++ b/app/public/robots.txt @@ -1,2 +1,3 @@ User-Agent: * Allow: / +Disallow: /admin/ diff --git a/app/src/components/app/MenuBar.tsx b/app/src/components/app/MenuBar.tsx index dbf43196..2daa5b5c 100644 --- a/app/src/components/app/MenuBar.tsx +++ b/app/src/components/app/MenuBar.tsx @@ -18,12 +18,13 @@ import { BadgeCent, Boxes, CalendarPlus, - Cloud, + Cloud, Gift, ListStart, Plug, } from "lucide-react"; import { openDialog as openSub } from "../../store/subscription.ts"; import { openDialog as openPackageDialog } from "../../store/package.ts"; +import { openDialog as openInvitationDialog } from "../../store/invitation.ts"; import { openDialog as openSharingDialog } from "../../store/sharing.ts"; import { openDialog as openApiDialog } from "../../store/api.ts"; @@ -60,6 +61,10 @@ function MenuBar({ children, className }: MenuBarProps) { {t("pkg.title")} + dispatch(openInvitationDialog())}> + + {t("invitation.title")} + dispatch(openSharingDialog())}> {t("share.manage")} diff --git a/app/src/conf.ts b/app/src/conf.ts index c8521b57..73a04d1d 100644 --- a/app/src/conf.ts +++ b/app/src/conf.ts @@ -1,7 +1,7 @@ import axios from "axios"; import { Model } from "./conversation/types.ts"; -export const version = "3.5.9"; +export const version = "3.5.10"; export const dev: boolean = window.location.hostname === "localhost"; export const deploy: boolean = true; export let rest_api: string = "http://localhost:8094"; diff --git a/app/src/conversation/invitation.ts b/app/src/conversation/invitation.ts new file mode 100644 index 00000000..2c531d11 --- /dev/null +++ b/app/src/conversation/invitation.ts @@ -0,0 +1,17 @@ +import axios from "axios"; + +export type InvitationResponse = { + status: boolean; + error: string; + quota: number; +} + +export async function getInvitation(code: string): Promise { + try { + const resp = await axios.get(`/invite?code=${code}`); + return resp.data as InvitationResponse; + } catch (e) { + console.debug(e); + return { status: false, error: "network error", quota: 0 }; + } +} diff --git a/app/src/dialogs/ApiKey.tsx b/app/src/dialogs/ApiKey.tsx index 71dec299..3a565144 100644 --- a/app/src/dialogs/ApiKey.tsx +++ b/app/src/dialogs/ApiKey.tsx @@ -23,7 +23,7 @@ import { useToast } from "../components/ui/use-toast.ts"; import { copyClipboard, useEffectAsync } from "../utils.ts"; import { selectInit } from "../store/auth.ts"; -function Package() { +function ApiKey() { const { t } = useTranslation(); const dispatch = useDispatch(); const open = useSelector(dialogSelector); @@ -75,4 +75,4 @@ function Package() { ); } -export default Package; +export default ApiKey; diff --git a/app/src/dialogs/Invitation.tsx b/app/src/dialogs/Invitation.tsx index e69de29b..8e62a5e2 100644 --- a/app/src/dialogs/Invitation.tsx +++ b/app/src/dialogs/Invitation.tsx @@ -0,0 +1,69 @@ +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "../components/ui/dialog.tsx"; +import { Button } from "../components/ui/button.tsx"; +import { useTranslation } from "react-i18next"; +import { useDispatch, useSelector } from "react-redux"; +import { + closeDialog, + dialogSelector, + setDialog, +} from "../store/invitation.ts"; +import { Input } from "../components/ui/input.tsx"; +import { useToast } from "../components/ui/use-toast.ts"; +import {useState} from "react"; +import {getInvitation} from "../conversation/invitation.ts"; + +function Invitation() { + const { t } = useTranslation(); + const dispatch = useDispatch(); + const open = useSelector(dialogSelector); + const { toast } = useToast(); + const [code, setCode] = useState(""); + + return ( + dispatch(setDialog(open))}> + + + {t("invitation.title")} + + setCode(e.target.value)} + /> + + + + + + + + + ); +} + +export default Invitation; diff --git a/app/src/dialogs/index.tsx b/app/src/dialogs/index.tsx index d8195084..cb89d569 100644 --- a/app/src/dialogs/index.tsx +++ b/app/src/dialogs/index.tsx @@ -4,6 +4,7 @@ import ApiKey from "./ApiKey.tsx"; import Package from "./Package.tsx"; import Subscription from "./Subscription.tsx"; import ShareManagement from "./ShareManagement.tsx"; +import Invitation from "./Invitation.tsx"; function DialogManager() { return ( @@ -14,6 +15,7 @@ function DialogManager() { + ); } diff --git a/app/src/store/index.ts b/app/src/store/index.ts index d558532f..1fae2fbb 100644 --- a/app/src/store/index.ts +++ b/app/src/store/index.ts @@ -7,6 +7,7 @@ import packageReducer from "./package"; import subscriptionReducer from "./subscription"; import apiReducer from "./api"; import sharingReducer from "./sharing"; +import invitationReducer from "./invitation"; const store = configureStore({ reducer: { @@ -18,6 +19,7 @@ const store = configureStore({ subscription: subscriptionReducer, api: apiReducer, sharing: sharingReducer, + invitation: invitationReducer, }, }); diff --git a/app/src/store/invitation.ts b/app/src/store/invitation.ts new file mode 100644 index 00000000..117b063f --- /dev/null +++ b/app/src/store/invitation.ts @@ -0,0 +1,28 @@ +import {createSlice} from "@reduxjs/toolkit"; +import {RootState} from "./index.ts"; + +export const invitationSlice = createSlice({ + name: "invitation", + initialState: { + dialog: false, + }, + reducers: { + toggleDialog: (state) => { + state.dialog = !state.dialog; + }, + setDialog: (state, action) => { + state.dialog = action.payload as boolean; + }, + openDialog: (state) => { + state.dialog = true; + }, + closeDialog: (state) => { + state.dialog = false; + }, + } +}); + +export const {toggleDialog, setDialog, openDialog, closeDialog} = invitationSlice.actions; +export default invitationSlice.reducer; + +export const dialogSelector = (state: RootState): boolean => state.invitation.dialog; diff --git a/auth/invitation.go b/auth/invitation.go index 4c277c8a..e81db5ed 100644 --- a/auth/invitation.go +++ b/auth/invitation.go @@ -16,12 +16,12 @@ type Invitation struct { UsedId int64 `json:"used_id"` } -func GenerateCodes(db *sql.DB, num int, quota float32, t string) ([]string, error) { +func GenerateInvitations(db *sql.DB, num int, quota float32, t string) ([]string, error) { arr := make([]string, 0) idx := 0 for idx < num { code := fmt.Sprintf("%s-%s", t, utils.GenerateChar(24)) - if err := GenerateCode(db, code, quota, t); err != nil { + if err := CreateInvitationCode(db, code, quota, t); err != nil { // unique constraint if errors.Is(err, sql.ErrNoRows) { continue @@ -35,7 +35,7 @@ func GenerateCodes(db *sql.DB, num int, quota float32, t string) ([]string, erro return arr, nil } -func GenerateCode(db *sql.DB, code string, quota float32, t string) error { +func CreateInvitationCode(db *sql.DB, code string, quota float32, t string) error { _, err := db.Exec(` INSERT INTO invitation (code, quota, type) VALUES (?, ?, ?) @@ -50,7 +50,11 @@ func GetInvitation(db *sql.DB, code string) (*Invitation, error) { WHERE code = ? `, code) var invitation Invitation - err := row.Scan(&invitation.Id, &invitation.Code, &invitation.Quota, &invitation.Type, &invitation.Used, &invitation.UsedId) + var id sql.NullInt64 + err := row.Scan(&invitation.Id, &invitation.Code, &invitation.Quota, &invitation.Type, &invitation.Used, &id) + if id.Valid { + invitation.UsedId = id.Int64 + } if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, fmt.Errorf("invitation code not found") @@ -83,6 +87,8 @@ func (i *Invitation) UseInvitation(db *sql.DB, user User) error { if err := i.Use(db, user.GetID(db)); err != nil { if errors.Is(err, sql.ErrNoRows) { return fmt.Errorf("invitation code not found") + } else if errors.Is(err, sql.ErrTxDone) { + return fmt.Errorf("transaction has been closed") } return fmt.Errorf("failed to use invitation: %w", err) } diff --git a/cli/exec.go b/cli/exec.go new file mode 100644 index 00000000..6a33a7af --- /dev/null +++ b/cli/exec.go @@ -0,0 +1,19 @@ +package cli + +func Run() bool { + args := GetArgs() + if len(args) == 0 { + return false + } + + switch args[0] { + case "help": + Help() + return true + case "invite": + CreateInvitationCommand(args[1:]) + return true + default: + return false + } +} diff --git a/cli/help.go b/cli/help.go new file mode 100644 index 00000000..378f92c9 --- /dev/null +++ b/cli/help.go @@ -0,0 +1,13 @@ +package cli + +import "fmt" + +var Prompt = ` +Commands: + - help + - invite +` + +func Help() { + fmt.Println(fmt.Sprintf("%s", Prompt)) +} diff --git a/cli/invite.go b/cli/invite.go new file mode 100644 index 00000000..a09b791e --- /dev/null +++ b/cli/invite.go @@ -0,0 +1,25 @@ +package cli + +import ( + "chat/auth" + "chat/connection" + "fmt" + "strings" +) + +func CreateInvitationCommand(args []string) { + db := connection.ConnectMySQL() + + var ( + t = GetArgString(args, 0) + num = GetArgInt(args, 1) + quota = GetArgFloat32(args, 2) + ) + + resp, err := auth.GenerateInvitations(db, num, quota, t) + if err != nil { + panic(err) + } + + fmt.Println(strings.Join(resp, "\n")) +} diff --git a/cli/parser.go b/cli/parser.go new file mode 100644 index 00000000..3258f2fe --- /dev/null +++ b/cli/parser.go @@ -0,0 +1,63 @@ +package cli + +import ( + "fmt" + "log" + "os" + "strconv" +) + +func GetArgs() []string { + return os.Args[1:] +} + +func GetArg(args []string, idx int) string { + if len(args) <= idx { + log.Fatalln(fmt.Sprintf("not enough arguments: %d", idx)) + } + return args[idx] +} + +func GetArgInt(args []string, idx int) int { + i, err := strconv.Atoi(GetArg(args, idx)) + if err != nil { + log.Fatalln(fmt.Sprintf("invalid argument: %s", err.Error())) + } + return i +} + +func GetArgFloat(args []string, idx int, bitSize int) float64 { + f, err := strconv.ParseFloat(GetArg(args, idx), bitSize) + if err != nil { + log.Fatalln(fmt.Sprintf("invalid argument: %s", err.Error())) + } + return f +} + +func GetArgFloat32(args []string, idx int) float32 { + return float32(GetArgFloat(args, idx, 32)) +} + +func GetArgFloat64(args []string, idx int) float64 { + return GetArgFloat(args, idx, 64) +} + +func GetArgBool(args []string, idx int) bool { + b, err := strconv.ParseBool(GetArg(args, idx)) + if err != nil { + log.Fatalln(fmt.Sprintf("invalid argument: %s", err.Error())) + } + return b +} + +func GetArgInt64(args []string, idx int) int64 { + i, err := strconv.ParseInt(GetArg(args, idx), 10, 64) + if err != nil { + log.Fatalln(fmt.Sprintf("invalid argument: %s", err.Error())) + } + return i +} + +func GetArgString(args []string, idx int) string { + return GetArg(args, idx) +} diff --git a/main.go b/main.go index 8aa75050..caaca276 100644 --- a/main.go +++ b/main.go @@ -3,6 +3,7 @@ package main import ( "chat/addition" "chat/auth" + "chat/cli" "chat/manager" "chat/manager/conversation" "chat/middleware" @@ -17,6 +18,9 @@ func main() { if err := viper.ReadInConfig(); err != nil { panic(err) } + if cli.Run() { + return + } app := gin.Default() middleware.RegisterMiddleware(app)