feat: support custom article/generation permission groups

This commit is contained in:
Zhang Minghan 2024-02-12 23:22:39 +08:00
parent c13c65ccbf
commit 831a20b13a
23 changed files with 277 additions and 67 deletions

View File

@ -2,6 +2,7 @@ package article
import (
"chat/auth"
"chat/globals"
"chat/utils"
"fmt"
"github.com/gin-gonic/gin"
@ -48,7 +49,7 @@ func GenerateAPI(c *gin.Context) {
user := auth.ParseToken(c, form.Token)
db := utils.GetDBFromContext(c)
if !(user != nil && user.IsSubscribe(db)) {
if !auth.HitGroups(db, user, globals.ArticlePermissionGroup) {
return
}

View File

@ -6,7 +6,6 @@ import (
"chat/utils"
"fmt"
"github.com/gin-gonic/gin"
"strconv"
"strings"
)
@ -41,22 +40,15 @@ func GenerateAPI(c *gin.Context) {
}
user := auth.ParseToken(c, form.Token)
authenticated := user != nil
db := utils.GetDBFromContext(c)
cache := utils.GetCacheFromContext(c)
id := auth.GetId(db, user)
if !utils.IncrWithLimit(cache,
fmt.Sprintf(":generation:%s", utils.Multi[string](authenticated, strconv.FormatInt(id, 10), c.ClientIP())),
1,
30,
3600,
) {
if !auth.HitGroups(db, user, globals.GenerationPermissionGroup) {
conn.Send(globals.GenerationSegmentResponse{
End: true,
Error: "generation rate limit exceeded, the max generation rate is 30 per hour.",
Message: "permission denied",
Quota: 0,
End: true,
})
return
}
@ -76,7 +68,6 @@ func GenerateAPI(c *gin.Context) {
auth.GetGroup(db, user),
form.Model,
form.Prompt,
plan,
func(buffer *utils.Buffer, data string) {
instance = buffer
conn.Send(globals.GenerationSegmentResponse{

View File

@ -6,10 +6,10 @@ import (
"fmt"
)
func CreateGenerationWithCache(group, model, prompt string, enableReverse bool, hook func(buffer *utils.Buffer, data string)) (string, error) {
func CreateGenerationWithCache(group, model, prompt string, hook func(buffer *utils.Buffer, data string)) (string, error) {
hash, path := GetFolderByHash(model, prompt)
if !utils.Exists(path) {
if err := CreateGeneration(group, model, prompt, path, enableReverse, hook); err != nil {
if err := CreateGeneration(group, model, prompt, path, hook); err != nil {
globals.Info(fmt.Sprintf("[project] error during generation %s (model %s): %s", prompt, model, err.Error()))
return "", fmt.Errorf("error during generate project: %s", err.Error())
}

View File

@ -13,7 +13,7 @@ type ProjectResult struct {
Result map[string]interface{} `json:"result"`
}
func CreateGeneration(group, model, prompt, path string, plan bool, hook func(buffer *utils.Buffer, data string)) error {
func CreateGeneration(group, model, prompt, path string, hook func(buffer *utils.Buffer, data string)) error {
message := GenerateMessage(prompt)
buffer := utils.NewBuffer(model, message, channel.ChargeInstance.GetCharge(model))

View File

@ -18,6 +18,8 @@ export type SiteInfo = {
buy_link: string;
mail: boolean;
contact: string;
article: string[];
generation: string[];
};
export async function getSiteInfo(): Promise<SiteInfo> {
@ -35,6 +37,8 @@ export async function getSiteInfo(): Promise<SiteInfo> {
buy_link: "",
contact: "",
mail: false,
article: [],
generation: [],
};
}
}
@ -50,10 +54,6 @@ export function syncSiteInfo() {
setAnnouncement(info.announcement);
setBuyLink(info.buy_link);
infoEvent.emit({
mail: info.mail,
contact: info.contact,
} as InfoForm);
console.log(info);
infoEvent.emit(info as InfoForm);
}, 25);
}

View File

@ -37,11 +37,17 @@ export type SiteState = {
contact: string;
};
export type CommonState = {
article: string[];
generation: string[];
};
export type SystemProps = {
general: GeneralState;
site: SiteState;
mail: MailState;
search: SearchState;
common: CommonState;
};
export type SystemResponse = CommonResponse & {
@ -126,4 +132,8 @@ export const initialSystemState: SystemProps = {
endpoint: "https://duckduckgo-api.vercel.app",
query: 5,
},
common: {
article: [],
generation: [],
},
};

View File

@ -1,4 +1,11 @@
import { getUniqueList } from "@/utils/base.ts";
import {
AnonymousType,
BasicType,
NormalType,
ProType,
StandardType,
} from "@/utils/groups.ts";
export type Channel = {
id: number;
@ -213,11 +220,11 @@ export const channelModels: string[] = getUniqueList(
);
export const channelGroups: string[] = [
"anonymous",
"normal",
"basic",
"standard",
"pro",
AnonymousType,
NormalType,
BasicType,
StandardType,
ProType,
];
export function getChannelInfo(type?: string): ChannelInfo {

View File

@ -40,12 +40,7 @@ function FileViewer({ filename, content, children, asChild }: FileViewerProps) {
</DialogTitle>
</DialogHeader>
<div className={`file-viewer-action`}>
<ToggleGroup
variant={`outline`}
type={`single`}
value={renderedType}
onValueChange={console.log}
>
<ToggleGroup variant={`outline`} type={`single`} value={renderedType}>
<ToggleGroupItem
value={viewerType.Text}
onClick={() => setRenderedType(viewerType.Text)}

View File

@ -15,8 +15,13 @@ import {
import { getLanguage } from "@/i18n.ts";
import { selectAuthenticated } from "@/store/auth.ts";
import { appLogo } from "@/conf/env.ts";
import { infoContactSelector } from "@/store/info.ts";
import {
infoArticleSelector,
infoContactSelector,
infoGenerationSelector,
} from "@/store/info.ts";
import Markdown from "@/components/Markdown.tsx";
import { hitGroup } from "@/utils/groups.ts";
function ChatSpace() {
const [open, setOpen] = useState(false);
@ -27,6 +32,12 @@ function ChatSpace() {
const cn = getLanguage() === "cn";
const auth = useSelector(selectAuthenticated);
const generationGroup = useSelector(infoGenerationSelector);
const generation = hitGroup(generationGroup);
const articleGroup = useSelector(infoArticleSelector);
const article = hitGroup(articleGroup);
return (
<div className={`chat-product`}>
<img
@ -42,18 +53,25 @@ function ChatSpace() {
<ChevronRight className={`h-4 w-4 ml-2`} />
</Button>
)}
{subscription && (
{article && (
<Button variant={`outline`} onClick={() => router.navigate("/article")}>
<Newspaper className={`h-4 w-4 mr-1.5`} />
{t("article.title")}
<ChevronRight className={`h-4 w-4 ml-2`} />
</Button>
)}
<Button variant={`outline`} onClick={() => router.navigate("/generate")}>
<FolderKanban className={`h-4 w-4 mr-1.5`} />
{t("generate.title")}
<ChevronRight className={`h-4 w-4 ml-2`} />
</Button>
{generation && (
<Button
variant={`outline`}
onClick={() => router.navigate("/generate")}
>
<FolderKanban className={`h-4 w-4 mr-1.5`} />
{t("generate.title")}
<ChevronRight className={`h-4 w-4 ml-2`} />
</Button>
)}
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className={`flex-dialog`}>

View File

@ -50,6 +50,8 @@ export function MultiCombobox({
return [...set];
}, [list]);
const v = value ?? [];
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
@ -61,7 +63,7 @@ export function MultiCombobox({
disabled={disabled}
>
<Check className="mr-2 h-4 w-4 shrink-0 opacity-50" />
{placeholder ?? `${value.length} Items Selected`}
{placeholder ?? `${v.length} Items Selected`}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
@ -75,17 +77,17 @@ export function MultiCombobox({
key={key}
value={key}
onSelect={(current) => {
if (value.includes(current)) {
onChange(value.filter((item) => item !== current));
if (v.includes(current)) {
onChange(v.filter((item) => item !== current));
} else {
onChange([...value, current]);
onChange([...v, current]);
}
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
value.includes(key) ? "opacity-100" : "opacity-0",
v.includes(key) ? "opacity-100" : "opacity-0",
)}
/>
{listTranslate ? t(`${listTranslate}.${key}`) : key}

View File

@ -3,6 +3,8 @@ import { EventCommitter } from "@/events/struct.ts";
export type InfoForm = {
mail: boolean;
contact: string;
article: string[];
generation: string[];
};
export const infoEvent = new EventCommitter<InfoForm>({

View File

@ -583,7 +583,8 @@
"normal": "普通用户",
"basic": "基础版订阅用户",
"standard": "标准版订阅用户",
"pro": "专业版订阅用户"
"pro": "专业版订阅用户",
"admin": "管理员用户"
}
},
"charge": {
@ -622,6 +623,7 @@
"search": "联网搜索",
"site": "站点设置",
"mail": "SMTP 发件设置",
"common": "通用设置",
"save": "保存",
"updateRoot": "修改 Root 密码",
"updateRootTip": "请谨慎操作,修改 Root 密码后,您需要重新登录。",
@ -661,7 +663,12 @@
"announcement": "站点公告",
"announcementPlaceholder": "请输入站点公告 (支持 Markdown / HTML 格式)",
"contact": "联系信息",
"contactPlaceholder": "请输入联系信息 (支持 Markdown / HTML 格式)"
"contactPlaceholder": "请输入联系信息 (支持 Markdown / HTML 格式)",
"article": "批量文章生成功能分组",
"articleTip": "批量文章生成功能分组,勾选后当前用户组可使用批量文章生成功能",
"generate": "AI 项目生成器分组",
"generateTip": "AI 项目生成器分组,勾选后当前用户组可使用 AI 项目生成器",
"groupPlaceholder": "已选 {{length}} 个分组"
},
"logger": {
"title": "服务日志",

View File

@ -422,7 +422,8 @@
"normal": "Normal",
"basic": "Basic Subscribers",
"standard": "Standard Subscribers",
"pro": "Pro Subscribers"
"pro": "Pro Subscribers",
"admin": "Admin user"
},
"joint": "Dock upstream",
"joint-endpoint": "Upstream address",
@ -509,7 +510,13 @@
"buyLinkPlaceholder": "Please enter the card secret purchase link, leave blank to not show the purchase button",
"mailConfNotValid": "SMTP send parameters are not configured correctly, mailbox verification is disabled",
"contact": "Contact Information",
"contactPlaceholder": "Please enter contact information (Markdown/HTML supported)"
"contactPlaceholder": "Please enter contact information (Markdown/HTML supported)",
"common": "General Settings",
"article": "Batch Post Generation Feature Grouping",
"articleTip": "Batch post generation function grouping, after checking the current user group can use batch post generation function",
"generate": "AI Project Builder Grouping",
"generateTip": "AI project generator grouping, after checking the current user group can use AI project generator",
"groupPlaceholder": "{{length}} groups selected"
},
"user": "User Management",
"invitation-code": "Invitation Code",

View File

@ -422,7 +422,8 @@
"normal": "一般ユーザー",
"basic": "ベーシックサブスクライバー",
"standard": "標準サブスクライバー",
"pro": "Pro Subscribers"
"pro": "Pro Subscribers",
"admin": "管理者ユーザー"
},
"joint": "上流にドッキング",
"joint-endpoint": "アップストリームアドレス",
@ -509,7 +510,13 @@
"buyLinkPlaceholder": "カードシークレット購入リンクを入力してください。購入ボタンを表示しない場合は空白のままにしてください",
"mailConfNotValid": "SMTP送信パラメータが正しく設定されていません。メールボックスの検証が無効になっています",
"contact": "コンタクト&インフォメーション",
"contactPlaceholder": "連絡先情報を入力してください( Markdown/HTML対応"
"contactPlaceholder": "連絡先情報を入力してください( Markdown/HTML対応",
"common": "基本設定",
"article": "一括ポストジェネレーションフィーチャーグループ",
"articleTip": "バッチポストジェネレーション機能のグループ化、現在のユーザーグループを確認した後、バッチポストジェネレーション機能を使用することができます",
"generate": "AIプロジェクトビルダーグループ",
"generateTip": "AIプロジェクトジェネレータグループ、現在のユーザーグループを確認した後、AIプロジェクトジェネレータを使用することができます",
"groupPlaceholder": "{{length}}グループが選択されました"
},
"user": "ユーザー管理",
"invitation-code": "招待コード",

View File

@ -422,7 +422,8 @@
"normal": "обычный пользователь",
"basic": "Базовые подписчики",
"standard": "Стандартные подписчики",
"pro": "Подписчики Pro"
"pro": "Подписчики Pro",
"admin": "Пользователь-администратор"
},
"joint": "Док-станция выше по течению",
"joint-endpoint": "Адрес выше по потоку",
@ -509,7 +510,13 @@
"buyLinkPlaceholder": "Введите ссылку на секретную покупку карты, оставьте поле пустым, чтобы не показывать кнопку покупки",
"mailConfNotValid": "Параметры отправки SMTP настроены неправильно, проверка почтового ящика отключена",
"contact": "shops|Контактные данные",
"contactPlaceholder": "Введите контактную информацию (поддерживается Markdown/HTML)"
"contactPlaceholder": "Введите контактную информацию (поддерживается Markdown/HTML)",
"common": "Основные параметры",
"article": "Группировка функций генерации пакетной записи",
"articleTip": "Группировка функций пакетного пост-генерации, после проверки текущей группы пользователей можно использовать функцию пакетного пост-генерации",
"generate": "Группировка конструкторов ИИ-проектов",
"generateTip": "Группировка генераторов ИИ-проектов, после проверки текущей группы пользователей можно использовать генератор ИИ-проектов",
"groupPlaceholder": "Выбрано групп: {{length}}"
},
"user": "Управление пользователями",
"invitation-code": "Код приглашения",

View File

@ -18,6 +18,7 @@ import { useMemo, useReducer, useState } from "react";
import { formReducer } from "@/utils/form.ts";
import { NumberInput } from "@/components/ui/number-input.tsx";
import {
CommonState,
commonWhiteList,
GeneralState,
getConfig,
@ -49,6 +50,7 @@ import Tips from "@/components/Tips.tsx";
import { cn } from "@/components/ui/lib/utils.ts";
import { Switch } from "@/components/ui/switch.tsx";
import { MultiCombobox } from "@/components/ui/multi-combobox.tsx";
import { allGroups } from "@/utils/groups.ts";
type CompProps<T> = {
data: T;
@ -464,12 +466,9 @@ function Site({ data, dispatch, onChange }: CompProps<SiteState>) {
isCollapsed={true}
>
<ParagraphItem>
<Label>
<Label className={`flex flex-row items-center`}>
{t("admin.system.quota")}
<Tips
className={`inline-block`}
content={t("admin.system.quotaTip")}
/>
<Tips content={t("admin.system.quotaTip")} />
</Label>
<NumberInput
value={data.quota}
@ -535,6 +534,63 @@ function Site({ data, dispatch, onChange }: CompProps<SiteState>) {
);
}
function Common({ data, dispatch, onChange }: CompProps<CommonState>) {
const { t } = useTranslation();
return (
<Paragraph
title={t("admin.system.common")}
configParagraph={true}
isCollapsed={true}
>
<ParagraphItem>
<Label className={`flex flex-row items-center`}>
{t("admin.system.article")}
<Tips content={t("admin.system.articleTip")} />
</Label>
<MultiCombobox
value={data.article}
onChange={(value) => {
dispatch({ type: "update:common.article", value });
}}
list={allGroups}
listTranslate={`admin.channels.groups`}
placeholder={t("admin.system.groupPlaceholder", {
length: (data.article ?? []).length,
})}
/>
</ParagraphItem>
<ParagraphItem>
<Label className={`flex flex-row items-center`}>
{t("admin.system.generate")}
<Tips content={t("admin.system.generateTip")} />
</Label>
<MultiCombobox
value={data.generation}
onChange={(value) => {
dispatch({ type: "update:common.generation", value });
}}
list={allGroups}
listTranslate={`admin.channels.groups`}
placeholder={t("admin.system.groupPlaceholder", {
length: (data.generation ?? []).length,
})}
/>
</ParagraphItem>
<ParagraphFooter>
<div className={`grow`} />
<Button
size={`sm`}
loading={true}
onClick={async () => await onChange()}
>
{t("admin.system.save")}
</Button>
</ParagraphFooter>
</Paragraph>
);
}
function Search({ data, dispatch, onChange }: CompProps<SearchState>) {
const { t } = useTranslation();
@ -624,6 +680,7 @@ function System() {
<Site data={data.site} dispatch={setData} onChange={doSaving} />
<Mail data={data.mail} dispatch={setData} onChange={doSaving} />
<Search data={data.search} dispatch={setData} onChange={doSaving} />
<Common data={data.common} dispatch={setData} onChange={doSaving} />
</CardContent>
</Card>
</div>

View File

@ -2,8 +2,10 @@ import { createSlice } from "@reduxjs/toolkit";
import { InfoForm } from "@/events/info.ts";
import { RootState } from "@/store/index.ts";
import {
getArrayMemory,
getBooleanMemory,
getMemory,
setArrayMemory,
setBooleanMemory,
setMemory,
} from "@/utils/memory.ts";
@ -13,15 +15,21 @@ export const infoSlice = createSlice({
initialState: {
mail: getBooleanMemory("mail", false),
contact: getMemory("contact"),
article: getArrayMemory("article"),
generation: getArrayMemory("generation"),
} as InfoForm,
reducers: {
setForm: (state, action) => {
const form = action.payload as InfoForm;
state.mail = form.mail ?? false;
state.contact = form.contact ?? "";
state.article = form.article ?? [];
state.generation = form.generation ?? [];
setBooleanMemory("mail", state.mail);
setMemory("contact", state.contact);
setArrayMemory("article", state.article);
setArrayMemory("generation", state.generation);
},
},
});
@ -34,3 +42,7 @@ export const infoDataSelector = (state: RootState): InfoForm => state.info;
export const infoMailSelector = (state: RootState): boolean => state.info.mail;
export const infoContactSelector = (state: RootState): string =>
state.info.contact;
export const infoArticleSelector = (state: RootState): string[] =>
state.info.article;
export const infoGenerationSelector = (state: RootState): string[] =>
state.info.generation;

49
app/src/utils/groups.ts Normal file
View File

@ -0,0 +1,49 @@
import { useSelector } from "react-redux";
import { selectAdmin, selectAuthenticated } from "@/store/auth.ts";
import { levelSelector } from "@/store/subscription.ts";
import { useMemo } from "react";
export const AnonymousType = "anonymous";
export const NormalType = "normal";
export const BasicType = "basic";
export const StandardType = "standard";
export const ProType = "pro";
export const AdminType = "admin";
export const allGroups: string[] = [
AnonymousType,
NormalType,
BasicType,
StandardType,
ProType,
AdminType,
];
export function useGroup(): string {
const auth = useSelector(selectAuthenticated);
const level = useSelector(levelSelector);
return useMemo(() => {
if (!auth) return AnonymousType;
switch (level) {
case 1:
return BasicType;
case 2:
return StandardType;
case 3:
return ProType;
default:
return NormalType;
}
}, [auth, level]);
}
export function hitGroup(group: string[]): boolean {
const current = useGroup();
const admin = useSelector(selectAdmin);
return useMemo(() => {
if (group.includes(AdminType) && admin) return true;
return group.includes(current);
}, [group, current, admin]);
}

View File

@ -7,7 +7,7 @@ export function useEffectAsync<T>(effect: () => Promise<T>, deps?: any[]) {
* @example
* useEffectAsync(async () => {
* const result = await fetch("https://api.example.com");
* console.log(result);
* console.debug(result);
* }, []);
*/

View File

@ -2,6 +2,7 @@ package auth
import (
"chat/globals"
"chat/utils"
"database/sql"
"time"
)
@ -149,3 +150,22 @@ func GetGroup(db *sql.DB, user *User) string {
return globals.NormalType
}
}
func HitGroup(db *sql.DB, user *User, group string) bool {
if group == globals.AdminType {
return user != nil && user.IsAdmin(db)
}
return GetGroup(db, user) == group
}
func HitGroups(db *sql.DB, user *User, groups []string) bool {
if utils.Contains(globals.AdminType, groups) {
if user != nil && user.IsAdmin(db) {
return true
}
}
group := GetGroup(db, user)
return utils.Contains(group, groups)
}

View File

@ -9,14 +9,16 @@ import (
)
type ApiInfo struct {
Title string `json:"title"`
Logo string `json:"logo"`
File string `json:"file"`
Docs string `json:"docs"`
Announcement string `json:"announcement"`
BuyLink string `json:"buy_link"`
Contact string `json:"contact"`
Mail bool `json:"mail"`
Title string `json:"title"`
Logo string `json:"logo"`
File string `json:"file"`
Docs string `json:"docs"`
Announcement string `json:"announcement"`
BuyLink string `json:"buy_link"`
Contact string `json:"contact"`
Mail bool `json:"mail"`
Article []string `json:"article"`
Generation []string `json:"generation"`
}
type generalState struct {
@ -54,11 +56,18 @@ type searchState struct {
Query int `json:"query" mapstructure:"query"`
}
type commonState struct {
Cache []string `json:"cache" mapstructure:"cache"`
Article []string `json:"article" mapstructure:"article"`
Generation []string `json:"generation" mapstructure:"generation"`
}
type SystemConfig struct {
General generalState `json:"general" mapstructure:"general"`
Site siteState `json:"site" mapstructure:"site"`
Mail mailState `json:"mail" mapstructure:"mail"`
Search searchState `json:"search" mapstructure:"search"`
Common commonState `json:"common" mapstructure:"common"`
}
func NewSystemConfig() *SystemConfig {
@ -73,6 +82,9 @@ func NewSystemConfig() *SystemConfig {
func (c *SystemConfig) Load() {
globals.NotifyUrl = c.GetBackend()
globals.ArticlePermissionGroup = c.Common.Article
globals.GenerationPermissionGroup = c.Common.Generation
}
func (c *SystemConfig) SaveConfig() error {
@ -92,6 +104,8 @@ func (c *SystemConfig) AsInfo() ApiInfo {
Contact: c.Site.Contact,
BuyLink: c.Site.BuyLink,
Mail: c.IsMailValid(),
Article: c.Common.Article,
Generation: c.Common.Generation,
}
}
@ -100,6 +114,7 @@ func (c *SystemConfig) UpdateConfig(data *SystemConfig) error {
c.Site = data.Site
c.Mail = data.Mail
c.Search = data.Search
c.Common = data.Common
return c.SaveConfig()
}

View File

@ -37,4 +37,5 @@ const (
BasicType = "basic" // basic subscription
StandardType = "standard" // standard subscription
ProType = "pro" // pro subscription
AdminType = "admin"
)

View File

@ -17,6 +17,8 @@ var AllowedOrigins = []string{
}
var NotifyUrl = ""
var ArticlePermissionGroup []string
var GenerationPermissionGroup []string
func OriginIsAllowed(uri string) bool {
instance, _ := url.Parse(uri)