From 8990c7cf2bbd3f233506cff0307b141d01a6e3e0 Mon Sep 17 00:00:00 2001 From: Zhang Minghan Date: Fri, 15 Dec 2023 11:14:13 +0800 Subject: [PATCH] feat: system settings page and add event source max timeout --- app/src-tauri/tauri.conf.json | 2 +- app/src/assets/admin/all.less | 1 + app/src/assets/admin/system.less | 13 ++ app/src/assets/ui.less | 141 +++++++++++ app/src/components/Paragraph.tsx | 86 +++++++ app/src/components/admin/ChargeWidget.tsx | 2 +- app/src/components/admin/MenuBar.tsx | 8 +- .../admin/assemblies/BroadcastTable.tsx | 2 +- app/src/components/home/ChatWrapper.tsx | 2 + .../components/home/assemblies/ChatAction.tsx | 4 +- app/src/components/ui/button.tsx | 47 +++- app/src/components/ui/tabs.tsx | 53 +++++ app/src/conf.ts | 2 +- app/src/i18n.ts | 59 ++++- app/src/router.tsx | 10 + app/src/routes/Auth.tsx | 2 +- app/src/routes/admin/System.tsx | 218 ++++++++++++++++++ app/src/utils/form.ts | 17 ++ manager/chat.go | 3 +- utils/buffer.go | 8 + utils/cache.go | 11 +- utils/net.go | 17 +- utils/smtp.go | 92 ++++++++ utils/sse.go | 7 +- 24 files changed, 784 insertions(+), 23 deletions(-) create mode 100644 app/src/assets/admin/system.less create mode 100644 app/src/components/Paragraph.tsx create mode 100644 app/src/components/ui/tabs.tsx create mode 100644 app/src/routes/admin/System.tsx create mode 100644 app/src/utils/form.ts create mode 100644 utils/smtp.go diff --git a/app/src-tauri/tauri.conf.json b/app/src-tauri/tauri.conf.json index 87f05a6..98d8d1d 100644 --- a/app/src-tauri/tauri.conf.json +++ b/app/src-tauri/tauri.conf.json @@ -8,7 +8,7 @@ }, "package": { "productName": "chatnio", - "version": "3.7.5" + "version": "3.7.6" }, "tauri": { "allowlist": { diff --git a/app/src/assets/admin/all.less b/app/src/assets/admin/all.less index 3dbc8c4..6ad3bf5 100644 --- a/app/src/assets/admin/all.less +++ b/app/src/assets/admin/all.less @@ -4,6 +4,7 @@ @import "broadcast"; @import "channel"; @import "charge"; +@import "system"; .admin-page { position: relative; diff --git a/app/src/assets/admin/system.less b/app/src/assets/admin/system.less new file mode 100644 index 0000000..6959dd7 --- /dev/null +++ b/app/src/assets/admin/system.less @@ -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; + } +} diff --git a/app/src/assets/ui.less b/app/src/assets/ui.less index 10bd83e..b215619 100644 --- a/app/src/assets/ui.less +++ b/app/src/assets/ui.less @@ -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; + } + } + } +} diff --git a/app/src/components/Paragraph.tsx b/app/src/components/Paragraph.tsx new file mode 100644 index 0000000..05d5ec3 --- /dev/null +++ b/app/src/components/Paragraph.tsx @@ -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 ( +
+
setCollapsed(!collapsed)} + > + {title &&
{title}
} +
+ {isCollapsed && ( + + )} +
+
+ {children} +
+
+ ); +} + +function ParagraphItem({ children }: { children: React.ReactNode }) { + return
{children}
; +} + +export function ParagraphDescription({ children }: { children: string }) { + return ( +
+ + +
+ ); +} + +function ParagraphFooter({ children }: { children: React.ReactNode }) { + return
{children}
; +} + +export default Paragraph; +export { ParagraphItem, ParagraphFooter }; diff --git a/app/src/components/admin/ChargeWidget.tsx b/app/src/components/admin/ChargeWidget.tsx index 3927412..f27160e 100644 --- a/app/src/components/admin/ChargeWidget.tsx +++ b/app/src/components/admin/ChargeWidget.tsx @@ -399,7 +399,7 @@ function ChargeTable({ data, dispatch, onRefresh }: ChargeTableProps) { {charge.type.split("-")[0]} - +
{charge.models.join("\n")}
diff --git a/app/src/components/admin/MenuBar.tsx b/app/src/components/admin/MenuBar.tsx index a2223d8..f4f3083 100644 --- a/app/src/components/admin/MenuBar.tsx +++ b/app/src/components/admin/MenuBar.tsx @@ -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() { /> } + icon={} path={"/channel"} /> } path={"/charge"} /> + } + path={"/system"} + />
); } diff --git a/app/src/components/admin/assemblies/BroadcastTable.tsx b/app/src/components/admin/assemblies/BroadcastTable.tsx index 10046fd..8040712 100644 --- a/app/src/components/admin/assemblies/BroadcastTable.tsx +++ b/app/src/components/admin/assemblies/BroadcastTable.tsx @@ -88,7 +88,7 @@ function CreateBroadcastDialog(props: CreateBroadcastDialogProps) { - diff --git a/app/src/components/home/ChatWrapper.tsx b/app/src/components/home/ChatWrapper.tsx index 05525dc..ca7f809 100644 --- a/app/src/components/home/ChatWrapper.tsx +++ b/app/src/components/home/ChatWrapper.tsx @@ -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(null); manager.setDispatch(dispatch); + chatEvent.addEventListener(() => setWorking(false)); function clearFile() { setFiles([]); diff --git a/app/src/components/home/assemblies/ChatAction.tsx b/app/src/components/home/assemblies/ChatAction.tsx index e314abb..3f9b848 100644 --- a/app/src/components/home/assemblies/ChatAction.tsx +++ b/app/src/components/home/assemblies/ChatAction.tsx @@ -29,8 +29,8 @@ export const ChatAction = React.forwardRef(
{children}
diff --git a/app/src/components/ui/button.tsx b/app/src/components/ui/button.tsx index f068a07..ea85476 100644 --- a/app/src/components/ui/button.tsx +++ b/app/src/components/ui/button.tsx @@ -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, VariantProps { asChild?: boolean; + loading?: boolean; } const Button = React.forwardRef( - ({ 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(false); + const onTrigger = + loading && onClick + ? (e: React.MouseEvent) => { + 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 = onClick && onClick(e); + if (result instanceof Promise) + result.finally(() => setWorking(false)); + else setWorking(false); + } + : onClick; + return ( + > + {loading && working && ( + + )} + + {children} + ); }, ); diff --git a/app/src/components/ui/tabs.tsx b/app/src/components/ui/tabs.tsx new file mode 100644 index 0000000..1a6bc1b --- /dev/null +++ b/app/src/components/ui/tabs.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +TabsList.displayName = TabsPrimitive.List.displayName; + +const TabsTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +TabsTrigger.displayName = TabsPrimitive.Trigger.displayName; + +const TabsContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +TabsContent.displayName = TabsPrimitive.Content.displayName; + +export { Tabs, TabsList, TabsTrigger, TabsContent }; diff --git a/app/src/conf.ts b/app/src/conf.ts index b73e26b..8126092 100644 --- a/app/src/conf.ts +++ b/app/src/conf.ts @@ -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); diff --git a/app/src/i18n.ts b/app/src/i18n.ts index d0d21bf..36a6314 100644 --- a/app/src/i18n.ts +++ b/app/src/i18n.ts @@ -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: "Настройки маски", diff --git a/app/src/router.tsx b/app/src/router.tsx index 0eb7a5b..f95d8ce 100644 --- a/app/src/router.tsx +++ b/app/src/router.tsx @@ -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([ ), }, + { + id: "admin-system", + path: "system", + element: ( + + + + ), + }, { id: "admin-charge", path: "charge", diff --git a/app/src/routes/Auth.tsx b/app/src/routes/Auth.tsx index e2157c6..9e5db5a 100644 --- a/app/src/routes/Auth.tsx +++ b/app/src/routes/Auth.tsx @@ -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: ( {t("try-again")} diff --git a/app/src/routes/admin/System.tsx b/app/src/routes/admin/System.tsx new file mode 100644 index 0000000..245e19d --- /dev/null +++ b/app/src/routes/admin/System.tsx @@ -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(), { + backend: "", + } as GeneralState); + + return ( + + + + + generalDispatch({ + type: "update:backend", + value: e.target.value, + }) + } + placeholder={`${window.location.protocol}//${window.location.host}/api`} + /> + + + {t("admin.system.backendTip")} + + +
+ + + + ); +} + +type MailState = { + host: string; + port: number; + username: string; + password: string; +}; + +function Mail() { + const { t } = useTranslation(); + const [mail, mailDispatch] = useReducer(formReducer(), { + host: "", + port: 465, + username: "", + password: "", + } as MailState); + + return ( + + + + + mailDispatch({ + type: "update:host", + value: e.target.value, + }) + } + placeholder={`smtp.qcloudmail.com`} + /> + + + + + mailDispatch({ type: "update:port", value }) + } + placeholder={`465`} + min={0} + max={65535} + /> + + + + + mailDispatch({ + type: "update:username", + value: e.target.value, + }) + } + placeholder={t("admin.system.mailUser")} + /> + + + + + mailDispatch({ + type: "update:password", + value: e.target.value, + }) + } + placeholder={t("admin.system.mailPass")} + /> + + +
+ + + + ); +} + +type SearchState = { + endpoint: string; + query: number; +}; + +function Search() { + const { t } = useTranslation(); + const [search, searchDispatch] = useReducer(formReducer(), { + endpoint: "https://duckduckgo-api.vercel.app", + query: 5, + } as SearchState); + + return ( + + + + + searchDispatch({ + type: "update:endpoint", + value: e.target.value, + }) + } + placeholder={`DuckDuckGo API Endpoint`} + /> + + + + + searchDispatch({ type: "update:query", value }) + } + placeholder={`5`} + min={0} + max={50} + /> + + {t("admin.system.searchTip")} + +
+ + + + ); +} + +function System() { + const { t } = useTranslation(); + + return ( +
+ + + {t("admin.settings")} + + + + + + + +
+ ); +} + +export default System; diff --git a/app/src/utils/form.ts b/app/src/utils/form.ts new file mode 100644 index 0000000..f73d22c --- /dev/null +++ b/app/src/utils/form.ts @@ -0,0 +1,17 @@ +export const formReducer = () => { + 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 }; + } + } + }; +}; diff --git a/manager/chat.go b/manager/chat.go index 823a101..1e8f5a8 100644 --- a/manager/chat.go +++ b/manager/chat.go @@ -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) } diff --git a/utils/buffer.go b/utils/buffer.go index 02c1b2a..e823c4d 100644 --- a/utils/buffer.go +++ b/utils/buffer.go @@ -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 } diff --git a/utils/cache.go b/utils/cache.go index 9d0e683..d5139e8 100644 --- a/utils/cache.go +++ b/utils/cache.go @@ -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) } } diff --git a/utils/net.go b/utils/net.go index 6a30eb7..824410b 100644 --- a/utils/net.go +++ b/utils/net.go @@ -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 diff --git a/utils/smtp.go b/utils/smtp.go new file mode 100644 index 0000000..95178cb --- /dev/null +++ b/utils/smtp.go @@ -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() +} diff --git a/utils/sse.go b/utils/sse.go index 47a99a8..56c2715 100644 --- a/utils/sse.go +++ b/utils/sse.go @@ -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