feat: system settings page and add event source max timeout

This commit is contained in:
Zhang Minghan 2023-12-15 11:14:13 +08:00
parent d496635ccf
commit 8990c7cf2b
24 changed files with 784 additions and 23 deletions

View File

@ -8,7 +8,7 @@
},
"package": {
"productName": "chatnio",
"version": "3.7.5"
"version": "3.7.6"
},
"tauri": {
"allowlist": {

View File

@ -4,6 +4,7 @@
@import "broadcast";
@import "channel";
@import "charge";
@import "system";
.admin-page {
position: relative;

View File

@ -0,0 +1,13 @@
.system {
width: 100%;
height: max-content;
padding: 2rem;
display: flex;
flex-direction: column;
.system-card {
width: 100%;
height: 100%;
min-height: 20vh;
}
}

View File

@ -123,3 +123,144 @@ input[type="number"] {
background: hsl(var(--selection));
}
}
.paragraph {
display: flex;
flex-direction: column;
margin: 0.5rem 0;
padding: 1.5rem;
border-radius: var(--radius);
background: hsl(var(--background));
color: hsl(var(--text));
border: 1px solid hsl(var(--border));
transition: .25s;
cursor: pointer;
&.collapsable {
.paragraph-content {
max-height: var(--max-height);
will-change: max-height;
overflow: hidden;
transition: .5s;
}
&.collapsed {
padding-bottom: 1rem;
.paragraph-content {
max-height: 0;
}
}
}
.paragraph-content {
& > * {
margin-bottom: 1rem;
&:last-child {
margin-bottom: 0;
}
}
}
.paragraph-header {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
margin-bottom: 1.5rem;
align-items: center;
transform: translateY(-0.25rem);
}
.paragraph-title {
position: relative;
display: flex;
flex-direction: row;
font-size: 1.05rem;
user-select: none;
line-height: 1.1rem;
color: hsl(var(--text-secondary));
transition: .25s;
&:before {
content: '';
margin-right: 0.5rem;
height: 1.25rem;
width: 2px;
border-radius: 1px;
background: hsl(var(--text-secondary));
transition: .25s;
}
}
.paragraph-item {
display: flex;
flex-direction: row;
white-space: nowrap;
align-items: center;
font-size: 0.9rem;
label {
font-size: 0.9rem;
font-weight: normal;
}
& > * {
margin-right: 1rem;
&:last-child {
margin-right: 0;
}
}
}
.paragraph-description {
display: flex;
flex-direction: row;
align-items: center;
color: hsl(var(--text-secondary));
width: 100%;
height: max-content;
font-size: 0.9rem;
margin: 0.75rem 0;
svg {
margin-right: 0.5rem;
flex-shrink: 0;
}
}
.paragraph-footer {
display: flex;
flex-direction: row;
margin-top: 1rem;
& > * {
margin-right: 1rem;
&:last-child {
margin-right: 0;
}
}
}
&:hover {
border-color: hsl(var(--border-hover));
.paragraph-title {
color: hsl(var(--text));
&:before {
background: hsl(var(--text));
}
}
}
&.config-paragraph {
.paragraph-content {
input {
margin-left: auto;
}
}
}
}

View File

@ -0,0 +1,86 @@
import React from "react";
import { ChevronDown, Info } from "lucide-react";
import { cn } from "@/components/ui/lib/utils.ts";
import { Button } from "@/components/ui/button.tsx";
import Markdown from "@/components/Markdown.tsx";
export type ParagraphProps = {
title?: string;
children: React.ReactNode;
configParagraph?: boolean;
isCollapsed?: boolean;
onCollapse?: () => void;
defaultCollapsed?: boolean;
};
function Paragraph({
title,
children,
configParagraph,
isCollapsed,
onCollapse,
defaultCollapsed,
}: ParagraphProps) {
const [collapsed, setCollapsed] = React.useState(defaultCollapsed ?? false);
React.useEffect(() => onCollapse && onCollapse(), [collapsed]);
return (
<div
className={cn(
`paragraph`,
configParagraph && `config-paragraph`,
isCollapsed && `collapsable`,
collapsed && `collapsed`,
)}
>
<div
className={`paragraph-header`}
onClick={() => setCollapsed(!collapsed)}
>
{title && <div className={`paragraph-title`}>{title}</div>}
<div className={`grow`} />
{isCollapsed && (
<Button size={`icon`} variant={`ghost`} className={`w-8 h-8`}>
<ChevronDown
className={cn(
`w-4 h-4 transition-transform duration-300`,
collapsed && `transform rotate-180`,
)}
/>
</Button>
)}
</div>
<div
className={`paragraph-content`}
style={
{
"--max-height": collapsed ? "0px" : "1000px",
} as React.CSSProperties
}
>
{children}
</div>
</div>
);
}
function ParagraphItem({ children }: { children: React.ReactNode }) {
return <div className={`paragraph-item`}>{children}</div>;
}
export function ParagraphDescription({ children }: { children: string }) {
return (
<div className={`paragraph-description`}>
<Info size={16} />
<Markdown children={children} />
</div>
);
}
function ParagraphFooter({ children }: { children: React.ReactNode }) {
return <div className={`paragraph-footer`}>{children}</div>;
}
export default Paragraph;
export { ParagraphItem, ParagraphFooter };

View File

@ -399,7 +399,7 @@ function ChargeTable({ data, dispatch, onRefresh }: ChargeTableProps) {
<TableCell>
<Badge>{charge.type.split("-")[0]}</Badge>
</TableCell>
<TableCell className={`select-none`}>
<TableCell>
<pre>{charge.models.join("\n")}</pre>
</TableCell>
<TableCell>

View File

@ -3,6 +3,7 @@ import { closeMenu, selectMenu } from "@/store/menu.ts";
import React, { useMemo } from "react";
import {
CandlestickChart,
GitFork,
LayoutDashboard,
Radio,
Settings,
@ -60,7 +61,7 @@ function MenuBar() {
/>
<MenuItem
title={t("admin.channel")}
icon={<Settings />}
icon={<GitFork />}
path={"/channel"}
/>
<MenuItem
@ -68,6 +69,11 @@ function MenuBar() {
icon={<CandlestickChart />}
path={"/charge"}
/>
<MenuItem
title={t("admin.settings")}
icon={<Settings />}
path={"/system"}
/>
</div>
);
}

View File

@ -88,7 +88,7 @@ function CreateBroadcastDialog(props: CreateBroadcastDialogProps) {
<DialogClose asChild>
<Button variant={`outline`}>{t("admin.cancel")}</Button>
</DialogClose>
<Button variant={`default`} onClick={postBroadcast}>
<Button variant={`default`} onClick={postBroadcast} loading={true}>
{t("admin.post")}
</Button>
</DialogFooter>

View File

@ -35,6 +35,7 @@ import ActionButton from "@/components/home/assemblies/ActionButton.tsx";
import ChatInput from "@/components/home/assemblies/ChatInput.tsx";
import ScrollAction from "@/components/home/assemblies/ScrollAction.tsx";
import { connectionEvent } from "@/events/connection.ts";
import { chatEvent } from "@/events/chat.ts";
type InterfaceProps = {
setWorking: (working: boolean) => void;
@ -68,6 +69,7 @@ function ChatWrapper() {
const [instance, setInstance] = useState<HTMLElement | null>(null);
manager.setDispatch(dispatch);
chatEvent.addEventListener(() => setWorking(false));
function clearFile() {
setFiles([]);

View File

@ -29,8 +29,8 @@ export const ChatAction = React.forwardRef<HTMLDivElement, ChatActionProps>(
<div
className={`action chat-action ${className}`}
onClick={onClick}
ref={ref} // @ts-ignore
style={{ "--width": `${labelWidth}px` }}
ref={ref}
style={{ "--width": `${labelWidth}px` } as React.CSSProperties}
>
{children}
<div className="text" ref={label}>

View File

@ -3,6 +3,8 @@ import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "./lib/utils";
import { useState } from "react";
import { Loader2 } from "lucide-react";
const buttonVariants = cva(
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
@ -37,17 +39,58 @@ export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
loading?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
(
{
className,
variant,
size,
onClick,
disabled,
children,
asChild = false,
loading = false,
...props
},
ref,
) => {
const Comp = asChild ? Slot : "button";
const [working, setWorking] = useState<boolean>(false);
const onTrigger =
loading && onClick
? (e: React.MouseEvent<HTMLButtonElement>) => {
if (disabled) return;
e.preventDefault();
e.stopPropagation();
if (working) return;
setWorking(true);
// execute the onClick handler (detecting if it's a promise or not)
const result: Promise<any> | any = onClick && onClick(e);
if (result instanceof Promise)
result.finally(() => setWorking(false));
else setWorking(false);
}
: onClick;
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
onClick={onTrigger}
disabled={disabled || working}
{...props}
/>
>
{loading && working && (
<Loader2 className={`animate-spin w-4 h-4 mr-2`} />
)}
{children}
</Comp>
);
},
);

View File

@ -0,0 +1,53 @@
import * as React from "react";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import { cn } from "@/components/ui/lib/utils";
const Tabs = TabsPrimitive.Root;
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
className,
)}
{...props}
/>
));
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
className,
)}
{...props}
/>
));
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className,
)}
{...props}
/>
));
TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsList, TabsTrigger, TabsContent };

View File

@ -10,7 +10,7 @@ import {
} from "@/utils/env.ts";
import { getMemory } from "@/utils/memory.ts";
export const version = "3.7.5";
export const version = "3.7.6";
export const dev: boolean = getDev();
export const deploy: boolean = true;
export let rest_api: string = getRestApi(deploy);

View File

@ -29,7 +29,7 @@ const resources = {
"login-failed-prompt": "登录失败!原因: {{reason}}",
"login-success": "登录成功",
"login-success-prompt": "您已成功登录。",
"server-error": "服务器错误",
"server-error": "登录错误",
"server-error-prompt": "登录出错,请重试。",
error: "请求失败",
"request-failed": "请求失败,请检查您的网络并重试。",
@ -313,6 +313,7 @@ const resources = {
users: "用户管理",
broadcast: "公告管理",
channel: "渠道设置",
settings: "系统设置",
prize: "价格设定",
"billing-today": "今日入账",
"billing-month": "本月入账",
@ -423,6 +424,24 @@ const resources = {
"add-rule": "添加规则",
"update-rule": "更新规则",
},
system: {
general: "常规设置",
search: "联网搜索",
mail: "SMTP 发件设置",
save: "保存",
backend: "后端域名",
backendTip:
"后端回调域名docker 安装默认路径为 /api接收回调参数。",
mailHost: "发件域名",
mailPort: "SMTP 端口",
mailUser: "用户名",
mailPass: "密码",
searchEndpoint: "搜索接入点",
searchQuery: "最大搜索结果数",
searchTip:
"DuckDuckGo 搜索接入点,如不填写自动使用 WebPilot 和 New Bing 逆向进行搜索功能。\n" +
"DuckDuckGo API 项目搭建:[duckduckgo-api](https://github.com/binjie09/duckduckgo-api)。",
},
},
mask: {
title: "预设设置",
@ -748,6 +767,7 @@ const resources = {
users: "User Management",
broadcast: "Broadcast Management",
channel: "Channel Settings",
settings: "System Settings",
prize: "Price Settings",
"billing-today": "Billing Today",
"billing-month": "Billing Month",
@ -865,6 +885,24 @@ const resources = {
"add-rule": "Add Rule",
"update-rule": "Update Rule",
},
system: {
general: "General Settings",
search: "Web Search",
mail: "SMTP Settings",
save: "Save",
backend: "Backend Domain",
backendTip:
"Backend callback domain (docker installation default path is /api), receive callback parameters.",
mailHost: "Mail Host",
mailPort: "SMTP Port",
mailUser: "Username",
mailPass: "Password",
searchEndpoint: "Search Endpoint",
searchQuery: "Max Search Results",
searchTip:
"DuckDuckGo search endpoint, if not filled in, use WebPilot and New Bing reverse search function by default.\n" +
"DuckDuckGo API project build: [duckduckgo-api](https://github.com/binjie09/duckduckgo-api).",
},
},
mask: {
title: "Mask Settings",
@ -1191,6 +1229,7 @@ const resources = {
users: "Управление пользователями",
broadcast: "Управление объявлениями",
channel: "Настройки канала",
settings: "Настройки системы",
prize: "Настройки цен",
"billing-today": "Сегодняшний доход",
"billing-month": "Доход за месяц",
@ -1310,6 +1349,24 @@ const resources = {
"add-rule": "Добавить правило",
"update-rule": "Обновить правило",
},
system: {
general: "Общие настройки",
search: "Веб-поиск",
mail: "SMTP Настройки почты",
save: "Сохранить",
backend: "Домен бэкэнда",
backendTip:
"Домен обратного вызова бэкэнда (путь по умолчанию для установки docker - /api), получает параметры обратного вызова.",
mailHost: "Почтовый хост",
mailPort: "Порт SMTP",
mailUser: "Имя пользователя",
mailPass: "Пароль",
searchEndpoint: "Конечная точка поиска",
searchQuery: "Максимальное количество результатов поиска",
searchTip:
"Конечная точка поиска DuckDuckGo, если она не заполнена, по умолчанию используется функция обратного поиска WebPilot и New Bing.\n" +
"Сборка проекта DuckDuckGo API: [duckduckgo-api](https://github.com/binjie09/duckduckgo-api).",
},
},
mask: {
title: "Настройки маски",

View File

@ -11,6 +11,7 @@ const Article = lazy(() => import("@/routes/Article.tsx"));
const Admin = lazy(() => import("@/routes/Admin.tsx"));
const Dashboard = lazy(() => import("@/routes/admin/DashBoard.tsx"));
const Channel = lazy(() => import("@/routes/admin/Channel.tsx"));
const System = lazy(() => import("@/routes/admin/System.tsx"));
const Charge = lazy(() => import("@/routes/admin/Charge.tsx"));
const Users = lazy(() => import("@/routes/admin/Users.tsx"));
const Broadcast = lazy(() => import("@/routes/admin/Broadcast.tsx"));
@ -94,6 +95,15 @@ const router = createBrowserRouter([
</Suspense>
),
},
{
id: "admin-system",
path: "system",
element: (
<Suspense>
<System />
</Suspense>
),
},
{
id: "admin-charge",
path: "charge",

View File

@ -63,7 +63,7 @@ function Auth() {
console.debug(err);
toast({
title: t("server-error"),
description: t("server-error-prompt"),
description: `${t("server-error-prompt")}\n${err.message}`,
action: (
<ToastAction altText={t("try-again")} onClick={login}>
{t("try-again")}

View File

@ -0,0 +1,218 @@
import { useTranslation } from "react-i18next";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card.tsx";
import Paragraph, {
ParagraphDescription,
ParagraphFooter,
ParagraphItem,
} from "@/components/Paragraph.tsx";
import { Button } from "@/components/ui/button.tsx";
import { Label } from "@/components/ui/label.tsx";
import { Input } from "@/components/ui/input.tsx";
import { useReducer } from "react";
import { formReducer } from "@/utils/form.ts";
import { NumberInput } from "@/components/ui/number-input.tsx";
type GeneralState = {
backend: string;
};
function General() {
const { t } = useTranslation();
const [general, generalDispatch] = useReducer(formReducer<GeneralState>(), {
backend: "",
} as GeneralState);
return (
<Paragraph
title={t("admin.system.general")}
configParagraph={true}
isCollapsed={true}
>
<ParagraphItem>
<Label>{t("admin.system.backend")}</Label>
<Input
value={general.backend}
onChange={(e) =>
generalDispatch({
type: "update:backend",
value: e.target.value,
})
}
placeholder={`${window.location.protocol}//${window.location.host}/api`}
/>
</ParagraphItem>
<ParagraphDescription>
{t("admin.system.backendTip")}
</ParagraphDescription>
<ParagraphFooter>
<div className={`grow`} />
<Button size={`sm`} loading={true}>
{t("admin.system.save")}
</Button>
</ParagraphFooter>
</Paragraph>
);
}
type MailState = {
host: string;
port: number;
username: string;
password: string;
};
function Mail() {
const { t } = useTranslation();
const [mail, mailDispatch] = useReducer(formReducer<MailState>(), {
host: "",
port: 465,
username: "",
password: "",
} as MailState);
return (
<Paragraph
title={t("admin.system.mail")}
configParagraph={true}
isCollapsed={true}
>
<ParagraphItem>
<Label>{t("admin.system.mailHost")}</Label>
<Input
value={mail.host}
onChange={(e) =>
mailDispatch({
type: "update:host",
value: e.target.value,
})
}
placeholder={`smtp.qcloudmail.com`}
/>
</ParagraphItem>
<ParagraphItem>
<Label>{t("admin.system.mailPort")}</Label>
<NumberInput
value={mail.port}
onValueChange={(value) =>
mailDispatch({ type: "update:port", value })
}
placeholder={`465`}
min={0}
max={65535}
/>
</ParagraphItem>
<ParagraphItem>
<Label>{t("admin.system.mailUser")}</Label>
<Input
value={mail.username}
onChange={(e) =>
mailDispatch({
type: "update:username",
value: e.target.value,
})
}
placeholder={t("admin.system.mailUser")}
/>
</ParagraphItem>
<ParagraphItem>
<Label>{t("admin.system.mailPass")}</Label>
<Input
value={mail.password}
onChange={(e) =>
mailDispatch({
type: "update:password",
value: e.target.value,
})
}
placeholder={t("admin.system.mailPass")}
/>
</ParagraphItem>
<ParagraphFooter>
<div className={`grow`} />
<Button size={`sm`} loading={true}>
{t("admin.system.save")}
</Button>
</ParagraphFooter>
</Paragraph>
);
}
type SearchState = {
endpoint: string;
query: number;
};
function Search() {
const { t } = useTranslation();
const [search, searchDispatch] = useReducer(formReducer<SearchState>(), {
endpoint: "https://duckduckgo-api.vercel.app",
query: 5,
} as SearchState);
return (
<Paragraph
title={t("admin.system.search")}
configParagraph={true}
isCollapsed={true}
>
<ParagraphItem>
<Label>{t("admin.system.searchEndpoint")}</Label>
<Input
value={search.endpoint}
onChange={(e) =>
searchDispatch({
type: "update:endpoint",
value: e.target.value,
})
}
placeholder={`DuckDuckGo API Endpoint`}
/>
</ParagraphItem>
<ParagraphItem>
<Label>{t("admin.system.searchQuery")}</Label>
<NumberInput
value={search.query}
onValueChange={(value) =>
searchDispatch({ type: "update:query", value })
}
placeholder={`5`}
min={0}
max={50}
/>
</ParagraphItem>
<ParagraphDescription>{t("admin.system.searchTip")}</ParagraphDescription>
<ParagraphFooter>
<div className={`grow`} />
<Button size={`sm`} loading={true}>
{t("admin.system.save")}
</Button>
</ParagraphFooter>
</Paragraph>
);
}
function System() {
const { t } = useTranslation();
return (
<div className={`system`}>
<Card className={`system-card`}>
<CardHeader className={`select-none`}>
<CardTitle>{t("admin.settings")}</CardTitle>
</CardHeader>
<CardContent className={`flex flex-col gap-1`}>
<General />
<Mail />
<Search />
</CardContent>
</Card>
</div>
);
}
export default System;

17
app/src/utils/form.ts Normal file
View File

@ -0,0 +1,17 @@
export const formReducer = <T>() => {
return (state: T, action: any) => {
action.payload = action.payload ?? action.value;
switch (action.type) {
case "update":
return { ...state, ...action.payload };
case "reset":
return { ...action.payload };
default:
if (action.type.startsWith("update:")) {
const key = action.type.slice(7);
return { ...state, [key]: action.payload };
}
}
};
};

View File

@ -20,9 +20,10 @@ const defaultQuotaMessage = "You don't have enough quota to use this model. plea
func CollectQuota(c *gin.Context, user *auth.User, buffer *utils.Buffer, uncountable bool) {
db := utils.GetDBFromContext(c)
quota := buffer.GetQuota()
if buffer.IsEmpty() {
if buffer.IsEmpty() || buffer.GetCharge().IsBillingType(globals.TimesBilling) {
return
}
if !uncountable && quota > 0 && user != nil {
user.UseQuota(db, quota)
}

View File

@ -93,6 +93,14 @@ func (b *Buffer) IsEmpty() bool {
return b.Cursor == 0 && !b.IsFunctionCalling()
}
func (b *Buffer) GetModel() string {
return b.Model
}
func (b *Buffer) GetCharge() Charge {
return b.Charge
}
func (b *Buffer) Read() string {
return b.Data
}

View File

@ -72,17 +72,20 @@ func DecrInt(cache *redis.Client, key string, delta int64) bool {
}
func IncrIP(cache *redis.Client, ip string) int64 {
val, err := Incr(cache, fmt.Sprintf(":ip-rate:%s", ip), 1)
if err != nil && err == redis.Nil {
cache.Set(context.Background(), fmt.Sprintf(":ip-rate:%s", ip), 1, time.Minute*20)
key := fmt.Sprintf(":ip-rate:%s", ip)
val, err := Incr(cache, key, 1)
if err != nil && errors.Is(err, redis.Nil) {
cache.Set(context.Background(), key, 1, time.Minute*20)
return 1
}
cache.Expire(context.Background(), key, time.Minute*20)
return val
}
func IncrWithExpire(cache *redis.Client, key string, delta int64, expiration time.Duration) {
_, err := Incr(cache, key, delta)
if err != nil && err == redis.Nil {
if err != nil && errors.Is(err, redis.Nil) {
cache.Set(context.Background(), key, delta, expiration)
}
}

View File

@ -11,8 +11,17 @@ import (
"net/http"
"net/url"
"strings"
"time"
)
var maxTimeout = 30 * time.Minute
func newClient() *http.Client {
return &http.Client{
Timeout: maxTimeout,
}
}
func Http(uri string, method string, ptr interface{}, headers map[string]string, body io.Reader) (err error) {
req, err := http.NewRequest(method, uri, body)
if err != nil {
@ -22,7 +31,7 @@ func Http(uri string, method string, ptr interface{}, headers map[string]string,
req.Header.Set(key, value)
}
client := &http.Client{}
client := newClient()
resp, err := client.Do(req)
if err != nil {
return err
@ -45,7 +54,7 @@ func HttpRaw(uri string, method string, headers map[string]string, body io.Reade
req.Header.Set(key, value)
}
client := &http.Client{}
client := newClient()
resp, err := client.Do(req)
if err != nil {
return nil, err
@ -85,7 +94,7 @@ func ConvertBody(body interface{}) (form io.Reader) {
}
func PostForm(uri string, body map[string]interface{}) (data map[string]interface{}, err error) {
client := &http.Client{}
client := newClient()
form := make(url.Values)
for key, value := range body {
form[key] = []string{value.(string)}
@ -116,7 +125,7 @@ func EventSource(method string, uri string, headers map[string]string, body inte
http.DefaultTransport.(*http.Transport).TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
client := &http.Client{}
client := newClient()
req, err := http.NewRequest(method, uri, ConvertBody(body))
if err != nil {
return nil

92
utils/smtp.go Normal file
View File

@ -0,0 +1,92 @@
package utils
import (
"crypto/tls"
"fmt"
"net"
"net/smtp"
)
type SmtpPoster struct {
Host string
Port int
Username string
Password string
From string
}
func NewSmtpPoster(host string, port int, username string, password string, from string) *SmtpPoster {
return &SmtpPoster{
Host: host,
Port: port,
Username: username,
Password: password,
From: from,
}
}
func (s *SmtpPoster) SendMail(to string, subject string, body string) {
addr := fmt.Sprintf("%s:%d", s.Host, s.Port)
auth := smtp.PlainAuth("", s.From, s.Password, s.Host)
err := smtpRequestWithTLS(addr, auth, s.From, []string{to},
[]byte(formatMail(map[string]string{
"From": fmt.Sprintf("%s <%s>", s.Username, s.From),
"To": to,
"Subject": subject,
"Content-Type": "text/html; charset=utf-8",
}, body)))
if err != nil {
return
}
}
func dial(addr string) (*smtp.Client, error) {
conn, err := tls.Dial("tcp", addr, nil)
if err != nil {
return nil, err
}
host, _, _ := net.SplitHostPort(addr)
return smtp.NewClient(conn, host)
}
func formatMail(headers map[string]string, body string) (result string) {
for k, v := range headers {
result += fmt.Sprintf("%s: %s\r\n", k, v)
}
return fmt.Sprintf("%s\r\n%s", result, body)
}
func smtpRequestWithTLS(addr string, auth smtp.Auth, from string, to []string, msg []byte) (err error) {
client, err := dial(addr)
if err != nil {
return err
}
defer client.Close()
if auth != nil {
if ok, _ := client.Extension("AUTH"); ok {
if err = client.Auth(auth); err != nil {
return err
}
}
}
if err = client.Mail(from); err != nil {
return err
}
for _, addr := range to {
if err = client.Rcpt(addr); err != nil {
return err
}
}
writer, err := client.Data()
if err != nil {
return err
}
if _, err = writer.Write(msg); err != nil {
return err
}
if err = writer.Close(); err != nil {
return err
}
return client.Quit()
}

View File

@ -86,7 +86,7 @@ func NewEndEvent() StreamEvent {
func SSEClient(method string, uri string, headers map[string]string, body interface{}, callback func(string) error) error {
http.DefaultTransport.(*http.Transport).TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
client := &http.Client{}
client := newClient()
req, err := http.NewRequest(method, uri, ConvertBody(body))
if err != nil {
return nil
@ -99,12 +99,13 @@ func SSEClient(method string, uri string, headers map[string]string, body interf
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode >= 400 {
return fmt.Errorf("request failed with status: %s", res.Status)
}
defer res.Body.Close()
events, err := CreateSSEInstance(res)
if err != nil {
return err