forked from nixcn/nixcn-cms
Compare commits
50 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
67e22eb793
|
|||
|
aaedddfd2f
|
|||
|
f8a3d0ca45
|
|||
|
6a9c013799
|
|||
|
70846e0d1e
|
|||
|
0710ffce72
|
|||
|
9e840901d1
|
|||
|
0f1c8e327e
|
|||
|
ddffb0da23
|
|||
|
b4d0959de4
|
|||
|
c2fd1c5cc8
|
|||
|
eddfa9a884
|
|||
|
b0684492fa
|
|||
|
aea7fddef0
|
|||
|
ef64c29ea7
|
|||
|
5f7f078f02
|
|||
|
1adfda54a6
|
|||
|
3510d6c1f8
|
|||
|
1fa90b15c3
|
|||
|
aa8e57bd89
|
|||
|
d6acae1625
|
|||
|
8dbdb58327
|
|||
|
61d2d2aef3
|
|||
|
0b710fd538
|
|||
|
d70ade4907
|
|||
|
a98ab26fa4
|
|||
|
62da1e096e
|
|||
|
fd1c89392f
|
|||
|
ae93f49691
|
|||
|
743f8373b0
|
|||
|
4796653896
|
|||
|
4dfd4cd529
|
|||
|
bd8eecbc7d
|
|||
|
cbec9bf2b3
|
|||
|
3d685b5a86
|
|||
|
83fe326962
|
|||
|
5b6bc9ce42
|
|||
|
e0e1abab93
|
|||
|
9f927c907a
|
|||
|
27ba3b7bef
|
|||
|
63f71d3b81
|
|||
|
e40d175c8e
|
|||
|
304e1d95ed
|
|||
|
acd3c95c80
|
|||
| 8973d518a2 | |||
| b5b4bb9d66 | |||
|
4c438cf4e4
|
|||
|
d44eef6bb7
|
|||
|
a49450bf9e
|
|||
|
228d838c37
|
@@ -1,32 +1 @@
|
||||
|
||||
SERVER_APPLICATION=nixcn-cms
|
||||
SERVER_ADDRESS=:8000
|
||||
SERVER_EXTERNAL_URL=http://test.sne.moe:8080
|
||||
SERVER_DEBUG_MODE=true
|
||||
SERVER_FILE_LOGGER=false
|
||||
|
||||
DATABASE_TYPE=postgres
|
||||
DATABASE_HOST=localhost:5432
|
||||
DATABASE_NAME=postgres
|
||||
DATABASE_USERNAME=postgres
|
||||
DATABASE_PASSWORD=postgres
|
||||
|
||||
CACHE_HOSTS=localhost:6379
|
||||
CACHE_MASTER=
|
||||
CACHE_USERNAME=
|
||||
CACHE_PASSWORD=
|
||||
CACHE_DB=0
|
||||
|
||||
SEARCH_HOST=localhost
|
||||
SEARCH_API_KEY=
|
||||
|
||||
EMAIL_RESEND_API_KEY=re_BMJaPVVB_kgdf1Go7n3dWVywp6hp4WmSA
|
||||
EMAIL_FROM=NixCN CMS Email Verify <nixcn@violet.sne.moe>
|
||||
|
||||
SECRETS_JWT_SECRET=6Wd5xkDkF4XX5q2Ckq6TY6WX
|
||||
SECRETS_TURNSTILE_SECRET=0x4AAAAAACI5pgVONOZ0rzyAYsdUcoOBF8w
|
||||
|
||||
TTL_MAGIC_LINK_TTL=10m
|
||||
TTL_ACCESS_TTL=15s
|
||||
TTL_REFRESH_TTL=168h
|
||||
TTL_CHECKIN_COSE_TTL=10m
|
||||
TZ=Asia/Shanghai
|
||||
|
||||
23
README.md
23
README.md
@@ -1,2 +1,25 @@
|
||||
# nixcn-cms
|
||||
|
||||
## Contribution
|
||||
|
||||
1. **Root docs serve the zh-CN version** _[MUST]_
|
||||
2. **Use sign-off via `git commit -s`** _[MUST]_
|
||||
3. **Do not modify the `main` branch for any reason** _[MUST]_
|
||||
4. **Do not omit the commit subject for any reason** _[MUST]_
|
||||
5. **Describe all changes in the commit message** _[MUST]_
|
||||
6. **Rebase before submitting patches** _[MUST]_
|
||||
7. **Commit message written in english** _[MUST]_
|
||||
8. **Use OpenPGP/SSH for commit signing** _[MUST]_
|
||||
9. **Split commits for large or multi-part changes** _[OPTION]_
|
||||
10. **Have fun contributing :)** _[VERY NECESSARY]_
|
||||
|
||||
## Toolchain
|
||||
|
||||
- Nix
|
||||
- Devenv
|
||||
- Direnv
|
||||
|
||||
## Notice
|
||||
|
||||
1. Client and all nix files use 2 space tab.
|
||||
2. All Golang files and other configs use 4 space tab.
|
||||
|
||||
@@ -7,7 +7,6 @@ import * as React from 'react';
|
||||
import NixOSLogo from '@/assets/nixos.svg?react';
|
||||
import { NavMain } from '@/components/nav-main';
|
||||
import { NavSecondary } from '@/components/nav-secondary';
|
||||
import { NavUser } from '@/components/nav-user';
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
@@ -17,6 +16,7 @@ import {
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from '@/components/ui/sidebar';
|
||||
import { NavUser } from './nav-user';
|
||||
|
||||
const data = {
|
||||
user: {
|
||||
@@ -48,7 +48,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
className="data-[slot=sidebar-menu-button]:!p-1.5"
|
||||
className="data-[slot=sidebar-menu-button]:p-1.5!"
|
||||
>
|
||||
<a href="#">
|
||||
<NixOSLogo />
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -14,9 +15,9 @@ import { Button } from '../ui/button';
|
||||
export function QrDialog(
|
||||
{ eventId }: { eventId: string },
|
||||
) {
|
||||
const { data } = useCheckinCode(eventId);
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<Dialog>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="w-20">签到</Button>
|
||||
</DialogTrigger>
|
||||
@@ -27,21 +28,41 @@ export function QrDialog(
|
||||
请工作人员扫描下面的二维码为你签到。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<QrDialogContent checkinCode={data.data.checkin_code} />
|
||||
<QrSection eventId={eventId} enabled={open} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function QrDialogContent({ checkinCode }: { checkinCode: string }) {
|
||||
function QrSection({ eventId, enabled }: { eventId: string; enabled: boolean }) {
|
||||
const { data } = useCheckinCode(eventId, enabled);
|
||||
return data
|
||||
? (
|
||||
<>
|
||||
<div className="border-2 px-4 py-8 border-muted rounded-xl flex flex-col items-center justify-center p-4">
|
||||
<QRCode data={data.data.checkin_code} className="size-60" />
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<div className="flex flex-1 items-center ml-2 font-mono text-3xl tracking-widest text-primary/80 justify-center">
|
||||
{data.data.checkin_code}
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)
|
||||
: (
|
||||
<QrSectionSkeleton />
|
||||
);
|
||||
}
|
||||
|
||||
function QrSectionSkeleton() {
|
||||
return (
|
||||
<>
|
||||
<div className="border-2 px-4 py-8 border-muted rounded-xl flex flex-col items-center justify-center p-4">
|
||||
<QRCode data={checkinCode} className="size-60" />
|
||||
<QRCode data="114514" className="size-60 blur-xs" />
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<div className="flex flex-1 items-center ml-2 font-mono text-3xl tracking-widest text-primary/80 justify-center">
|
||||
{checkinCode}
|
||||
Loading...
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</>
|
||||
|
||||
20
client/src/components/hoc/with-fallback.tsx
Normal file
20
client/src/components/hoc/with-fallback.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import React, { Suspense } from 'react';
|
||||
|
||||
export function withFallback<P extends object>(
|
||||
Component: React.ComponentType<P>,
|
||||
fallback: ReactNode,
|
||||
) {
|
||||
const Wrapped: React.FC<P> = (props) => {
|
||||
return (
|
||||
<Suspense fallback={fallback}>
|
||||
<Component {...props} />
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
|
||||
Wrapped.displayName = `withFallback(${Component.displayName! || Component.name || 'Component'
|
||||
})`;
|
||||
|
||||
return Wrapped;
|
||||
}
|
||||
@@ -24,8 +24,10 @@ import {
|
||||
} from '@/components/ui/sidebar';
|
||||
import { useUserInfo } from '@/hooks/data/useUserInfo';
|
||||
import { useLogout } from '@/hooks/useLogout';
|
||||
import { withFallback } from './hoc/with-fallback';
|
||||
import { Skeleton } from './ui/skeleton';
|
||||
|
||||
export function NavUser() {
|
||||
function NavUser_() {
|
||||
const { isMobile } = useSidebar();
|
||||
const { data: user } = useUserInfo();
|
||||
const { logout } = useLogout();
|
||||
@@ -83,3 +85,20 @@ export function NavUser() {
|
||||
</SidebarMenu>
|
||||
);
|
||||
}
|
||||
|
||||
function NavUserSkeleton() {
|
||||
return (
|
||||
<SidebarMenuButton
|
||||
size="lg"
|
||||
>
|
||||
<Skeleton className="h-8 w-8 rounded-lg" />
|
||||
<div className="flex flex-col flex-1 gap-1">
|
||||
<Skeleton className="h-3 w-16" />
|
||||
<Skeleton className="h-3 w-24" />
|
||||
</div>
|
||||
<IconDotsVertical className="ml-auto size-4" />
|
||||
</SidebarMenuButton>
|
||||
);
|
||||
}
|
||||
|
||||
export const NavUser = withFallback(NavUser_, <NavUserSkeleton />);
|
||||
|
||||
9
client/src/components/workbenchCards/card-skeleton.tsx
Normal file
9
client/src/components/workbenchCards/card-skeleton.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Skeleton } from '../ui/skeleton';
|
||||
|
||||
export function CardSkeleton() {
|
||||
return (
|
||||
<Skeleton
|
||||
className="gap-6 rounded-xl py-6 h-full"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -2,29 +2,31 @@ import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardAction, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { useUserInfo } from '@/hooks/data/useUserInfo';
|
||||
import { QrDialog } from '../checkin/qr-dialog';
|
||||
import { withFallback } from '../hoc/with-fallback';
|
||||
import { CardSkeleton } from './card-skeleton';
|
||||
|
||||
export function CheckinCard() {
|
||||
function CheckinCard_() {
|
||||
const { data } = useUserInfo();
|
||||
return (
|
||||
<>
|
||||
<Card className="@container/card">
|
||||
<CardHeader>
|
||||
<CardDescription>签到状态</CardDescription>
|
||||
<CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
|
||||
{data.checkin !== null ? '已签到' : '未签到'}
|
||||
{data.checkin !== null && <span className="text-sm font-medium ml-2">{`${new Date(data.checkin).toLocaleString()}`}</span>}
|
||||
</CardTitle>
|
||||
<CardAction>
|
||||
<Badge variant="outline">Day 1</Badge>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardFooter className="flex-col items-start gap-1.5 text-sm">
|
||||
<QrDialog
|
||||
eventId="019b5bf2-90ce-75f5-93a4-a66914c2ef11"
|
||||
>
|
||||
</QrDialog>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</>
|
||||
<Card className="@container/card">
|
||||
<CardHeader>
|
||||
<CardDescription>签到状态</CardDescription>
|
||||
<CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
|
||||
{data.checkin !== null ? '已签到' : '未签到'}
|
||||
{data.checkin !== null && <span className="text-sm font-medium ml-2">{`${new Date(data.checkin).toLocaleString()}`}</span>}
|
||||
</CardTitle>
|
||||
<CardAction>
|
||||
<Badge variant="outline">Day 1</Badge>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardFooter className="flex-col items-start gap-1.5 text-sm">
|
||||
<QrDialog
|
||||
eventId="019b5bf2-90ce-75f5-93a4-a66914c2ef11"
|
||||
>
|
||||
</QrDialog>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export const CheckinCard = withFallback(CheckinCard_, <CardSkeleton />);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { axiosClient } from '@/lib/axios';
|
||||
|
||||
export function useCheckinCode(eventId: string) {
|
||||
return useSuspenseQuery({
|
||||
export function useCheckinCode(eventId: string, enabled: boolean) {
|
||||
return useQuery({
|
||||
queryKey: ['getCheckinCode', eventId],
|
||||
queryFn: async () => {
|
||||
return axiosClient.get<{
|
||||
@@ -13,5 +13,6 @@ export function useCheckinCode(eventId: string) {
|
||||
},
|
||||
});
|
||||
},
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,7 +4,24 @@ import { ThemeProvider } from '@/components/theme-provider';
|
||||
import { Toaster } from '@/components/ui/sonner';
|
||||
import '@/index.css';
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: (failureCount, error: any) => {
|
||||
// eslint-disable-next-line ts/no-unsafe-assignment
|
||||
const status
|
||||
// eslint-disable-next-line ts/no-unsafe-member-access
|
||||
= error?.response?.status ?? error?.status;
|
||||
|
||||
if (status >= 400 && status < 500) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return failureCount < 3;
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function RootLayout() {
|
||||
return (
|
||||
|
||||
@@ -13,18 +13,16 @@ export const Route = createFileRoute('/_sidebarLayout/')({
|
||||
},
|
||||
});
|
||||
|
||||
function SectionCards() {
|
||||
return (
|
||||
<div className="*:data-[slot=card]:from-primary/5 *:data-[slot=card]:to-card dark:*:data-[slot=card]:bg-card grid grid-cols-1 gap-4 px-4 *:data-[slot=card]:bg-gradient-to-t *:data-[slot=card]:shadow-xs lg:px-6 @xl/main:grid-cols-2 @5xl/main:grid-cols-4">
|
||||
<CheckinCard />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Index() {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">
|
||||
<SectionCards />
|
||||
{/* Section Cards */}
|
||||
<div className="*:data-[slot=card]:from-primary/5 *:data-[slot=card]:to-card dark:*:data-[slot=card]:bg-card
|
||||
grid grid-cols-1 gap-4 px-4 auto-rows-[minmax(175px,auto)] items-stretch
|
||||
*:data-[slot=card]:bg-linear-to-t *:data-[slot=card]:shadow-xs lg:px-6 @xl/main:grid-cols-2 @5xl/main:grid-cols-4"
|
||||
>
|
||||
<CheckinCard />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,13 +20,28 @@ search:
|
||||
host: 127.0.0.1
|
||||
api_key: ""
|
||||
email:
|
||||
resend_api_key: abc
|
||||
host:
|
||||
port:
|
||||
username:
|
||||
password:
|
||||
security:
|
||||
insecure_skip_verify:
|
||||
from:
|
||||
auth:
|
||||
oauth2:
|
||||
tenant_id:
|
||||
client_id:
|
||||
client_secret:
|
||||
scope:
|
||||
secrets:
|
||||
jwt_secret: example
|
||||
turnstile_secret: example
|
||||
client_secret_key: aes_32_byte_string
|
||||
kyc_info_key: aes_32_byte_string
|
||||
ttl:
|
||||
magic_link_ttl: 10m
|
||||
jwt_ttl: 15s
|
||||
refresh_ttl: 7d
|
||||
checkin_code_ttl: 5m
|
||||
auth_code_ttl: 10m
|
||||
access_ttl: 15s
|
||||
refresh_ttl: 168h
|
||||
checkin_code_ttl: 10m
|
||||
kyc:
|
||||
ali_access_key_id: example
|
||||
ali_access_key_secret: example
|
||||
|
||||
@@ -8,14 +8,15 @@ type config struct {
|
||||
Email email `yaml:"email"`
|
||||
Secrets secrets `yaml:"secrets"`
|
||||
TTL ttl `yaml:"ttl"`
|
||||
KYC kyc `yaml:"kyc"`
|
||||
}
|
||||
|
||||
type server struct {
|
||||
Application string `yaml:"application"`
|
||||
Address string `yaml:"address"`
|
||||
ExternalUrl string `yaml:"external_url"`
|
||||
DebugMode string `yaml:"debug_mode"`
|
||||
FileLogger string `yaml:"file_logger"`
|
||||
JwtSecret string `yaml:"jwt_secret"`
|
||||
}
|
||||
|
||||
type database struct {
|
||||
@@ -39,19 +40,39 @@ type search struct {
|
||||
ApiKey string `yaml:"api_key"`
|
||||
}
|
||||
|
||||
type _email_oauth2 struct {
|
||||
Tenantid string `yaml:"tenant_id"`
|
||||
ClientId string `yaml:"client_id"`
|
||||
ClientSecret string `yaml:"client_secret"`
|
||||
Scope string `yaml:"scope"`
|
||||
}
|
||||
|
||||
type email struct {
|
||||
ResendApiKey string `yaml:"resend_api_key"`
|
||||
From string `yaml:"from"`
|
||||
Host string `yaml:"host"`
|
||||
Port string `yaml:"port"`
|
||||
Username string `yaml:"username"`
|
||||
Password string `yaml:"password"`
|
||||
Security string `yaml:"security"`
|
||||
InsecureSkipVerify bool `yaml:"insecure_skip_verify"`
|
||||
From string `yaml:"from"`
|
||||
Auth string `yaml:"auth"`
|
||||
Oauth2 _email_oauth2 `yaml:"oauth2"`
|
||||
}
|
||||
|
||||
type secrets struct {
|
||||
JwtSecret string `yaml:"jwt_secret"`
|
||||
TurnstileSecret string `yaml:"turnstile_secret"`
|
||||
ClientSecretKey string `yaml:"client_secret_key"`
|
||||
KycInfoKey string `yaml:"kyc_info_key"`
|
||||
}
|
||||
|
||||
type ttl struct {
|
||||
MagicLinkTTL string `yaml:"magic_link_ttl"`
|
||||
AuthCodeTTL string `yaml:"auth_code_ttl"`
|
||||
AccessTTL string `yaml:"access_ttl"`
|
||||
RefreshTTL string `yaml:"refresh_ttl"`
|
||||
CheckinCodeTTL string `yaml:"checkin_code_ttl"`
|
||||
}
|
||||
|
||||
type kyc struct {
|
||||
AliAccessKeyId string `yaml:"ali_access_key_id"`
|
||||
AliAccessKeySecret string `yaml:"ali_access_key_secret"`
|
||||
}
|
||||
|
||||
13
data/agenda.go
Normal file
13
data/agenda.go
Normal file
@@ -0,0 +1,13 @@
|
||||
package data
|
||||
|
||||
import "github.com/google/uuid"
|
||||
|
||||
type Agenda struct {
|
||||
Id uint `json:"id" gorm:"primarykey;autoIncrement"`
|
||||
UUID uuid.UUID `json:"uuid" gorm:"type:uuid;uniqueIndex;not null"`
|
||||
AgendaId uuid.UUID `json:"agenda_id" gorm:"type:uuid;uniqueIndex;not null"`
|
||||
EventId uuid.UUID `json:"event_id" gorm:"type:uuid;uniqueIndex:unique_event_user;not null"`
|
||||
UserId uuid.UUID `json:"user_id" gorm:"type:uuid;uniqueIndex:unique_event_user;not null"`
|
||||
Name string `json:"name" gorm:"type:varchar(255);not null"`
|
||||
Description string `json:"description" gorm:"type:text;not null"`
|
||||
}
|
||||
265
data/attendance.go
Normal file
265
data/attendance.go
Normal file
@@ -0,0 +1,265 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/meilisearch/meilisearch-go"
|
||||
"github.com/spf13/viper"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Attendance struct {
|
||||
Id uint `json:"id" gorm:"primarykey;autoIncrement"`
|
||||
UUID uuid.UUID `json:"uuid" gorm:"type:uuid;uniqueIndex;not null"`
|
||||
AttendanceId uuid.UUID `json:"attendance_id" gorm:"type:uuid;uniqueIndex;not null"`
|
||||
EventId uuid.UUID `json:"event_id" gorm:"type:uuid;uniqueIndex:unique_event_user;not null"`
|
||||
UserId uuid.UUID `json:"user_id" gorm:"type:uuid;uniqueIndex:unique_event_user;not null"`
|
||||
Role string `json:"role" gorm:"type:varchar(255);not null"`
|
||||
State string `json:"state" gorm:"type:varchar(255);not null"`
|
||||
KycInfo string `json:"kyc_info" gorm:"type:text"`
|
||||
CheckinAt time.Time `json:"checkin_at"`
|
||||
}
|
||||
|
||||
type AttendanceSearchDoc struct {
|
||||
AttendanceId string `json:"attendance_id"`
|
||||
EventId string `json:"event_id"`
|
||||
UserId string `json:"user_id"`
|
||||
Role string `json:"role"`
|
||||
CheckinAt time.Time `json:"checkin_at"`
|
||||
}
|
||||
|
||||
func (self *Attendance) GetAttendance(userId, eventId uuid.UUID) (*Attendance, error) {
|
||||
var checkin Attendance
|
||||
|
||||
err := Database.
|
||||
Where("user_id = ? AND event_id = ?", userId, eventId).
|
||||
First(&checkin).Error
|
||||
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &checkin, err
|
||||
}
|
||||
|
||||
type AttendanceUsers struct {
|
||||
UserId uuid.UUID `json:"user_id"`
|
||||
Role string `json:"role"`
|
||||
CheckinAt time.Time `json:"checkin_at"`
|
||||
}
|
||||
|
||||
func (self *Attendance) GetUsersByEventID(eventID uuid.UUID) (*[]AttendanceUsers, error) {
|
||||
var result []AttendanceUsers
|
||||
|
||||
err := Database.
|
||||
Model(&Attendance{}).
|
||||
Select("user_id, checkin_at").
|
||||
Where("event_id = ?", eventID).
|
||||
Order("checkin_at ASC").
|
||||
Scan(&result).Error
|
||||
|
||||
return &result, err
|
||||
}
|
||||
|
||||
type AttendanceEvent struct {
|
||||
EventId uuid.UUID `json:"event_id"`
|
||||
CheckinAt time.Time `json:"checkin_at"`
|
||||
}
|
||||
|
||||
func (self *Attendance) GetEventsByUserID(userID uuid.UUID) (*[]AttendanceEvent, error) {
|
||||
var result []AttendanceEvent
|
||||
|
||||
err := Database.
|
||||
Model(&Attendance{}).
|
||||
Select("event_id, checkin_at").
|
||||
Where("user_id = ?", userID).
|
||||
Order("checkin_at ASC").
|
||||
Scan(&result).Error
|
||||
|
||||
return &result, err
|
||||
}
|
||||
|
||||
func (self *Attendance) Create() error {
|
||||
self.UUID = uuid.New()
|
||||
self.AttendanceId = uuid.New()
|
||||
|
||||
// DB transaction for strong consistency
|
||||
err := Database.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Create(&self).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := self.UpdateSearchIndex(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *Attendance) Update(attendanceId uuid.UUID, checkinTime *time.Time) (*Attendance, error) {
|
||||
var attendance Attendance
|
||||
|
||||
err := Database.Transaction(func(tx *gorm.DB) error {
|
||||
// Lock the row for update
|
||||
if err := tx.
|
||||
Where("attendance_id = ?", attendanceId).
|
||||
First(&attendance).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
updates := map[string]any{}
|
||||
|
||||
if checkinTime != nil {
|
||||
updates["checkin_at"] = *checkinTime
|
||||
}
|
||||
|
||||
if len(updates) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := tx.
|
||||
Model(&attendance).
|
||||
Updates(updates).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Reload to ensure struct is up to date
|
||||
return tx.
|
||||
Where("attendance_id = ?", attendanceId).
|
||||
First(&attendance).Error
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Sync to MeiliSearch (eventual consistency)
|
||||
if err := attendance.UpdateSearchIndex(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &attendance, nil
|
||||
}
|
||||
|
||||
func (self *Attendance) SearchUsersByEvent(eventID string) (*meilisearch.SearchResponse, error) {
|
||||
index := MeiliSearch.Index("attendance")
|
||||
|
||||
return index.Search("", &meilisearch.SearchRequest{
|
||||
Filter: "event_id = \"" + eventID + "\"",
|
||||
Sort: []string{"checkin_at:asc"},
|
||||
})
|
||||
}
|
||||
|
||||
func (self *Attendance) SearchEventsByUser(userID string) (*meilisearch.SearchResponse, error) {
|
||||
index := MeiliSearch.Index("attendance")
|
||||
|
||||
return index.Search("", &meilisearch.SearchRequest{
|
||||
Filter: "user_id = \"" + userID + "\"",
|
||||
Sort: []string{"checkin_at:asc"},
|
||||
})
|
||||
}
|
||||
|
||||
func (self *Attendance) UpdateSearchIndex() error {
|
||||
doc := AttendanceSearchDoc{
|
||||
AttendanceId: self.AttendanceId.String(),
|
||||
EventId: self.EventId.String(),
|
||||
UserId: self.UserId.String(),
|
||||
CheckinAt: self.CheckinAt,
|
||||
}
|
||||
|
||||
index := MeiliSearch.Index("attendance")
|
||||
|
||||
primaryKey := "attendance_id"
|
||||
opts := &meilisearch.DocumentOptions{
|
||||
PrimaryKey: &primaryKey,
|
||||
}
|
||||
|
||||
if _, err := index.UpdateDocuments([]AttendanceSearchDoc{doc}, opts); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *Attendance) DeleteSearchIndex() error {
|
||||
index := MeiliSearch.Index("attendance")
|
||||
_, err := index.DeleteDocument(self.AttendanceId.String(), nil)
|
||||
return err
|
||||
}
|
||||
|
||||
func (self *Attendance) GenCheckinCode(eventId uuid.UUID) (*string, error) {
|
||||
ctx := context.Background()
|
||||
ttl := viper.GetDuration("ttl.checkin_code_ttl")
|
||||
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
|
||||
|
||||
for {
|
||||
code := fmt.Sprintf("%06d", rng.Intn(900000)+100000)
|
||||
ok, err := Redis.SetNX(
|
||||
ctx,
|
||||
"checkin_code:"+code,
|
||||
"user_id:"+self.UserId.String()+":event_id:"+eventId.String(),
|
||||
ttl,
|
||||
).Result()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ok {
|
||||
return &code, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (self *Attendance) VerifyCheckinCode(checkinCode string) error {
|
||||
ctx := context.Background()
|
||||
|
||||
val, err := Redis.Get(ctx, "checkin_code:"+checkinCode).Result()
|
||||
if err != nil {
|
||||
return errors.New("invalid or expired checkin code")
|
||||
}
|
||||
|
||||
// Expected format: user_id:<uuid>:event_id:<uuid>
|
||||
parts := strings.Split(val, ":")
|
||||
if len(parts) != 4 {
|
||||
return errors.New("invalid checkin code format")
|
||||
}
|
||||
|
||||
userIdStr := parts[1]
|
||||
eventIdStr := parts[3]
|
||||
|
||||
userId, err := uuid.Parse(userIdStr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
eventId, err := uuid.Parse(eventIdStr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
attendanceData, err := self.GetAttendance(userId, eventId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
time := time.Now()
|
||||
_, err = self.Update(attendanceData.AttendanceId, &time)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
91
data/client.go
Normal file
91
data/client.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"nixcn-cms/internal/cryptography"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/spf13/viper"
|
||||
"gorm.io/datatypes"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
Id uint `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
UUID uuid.UUID `json:"uuid" gorm:"type:uuid;uniqueIndex;not null"`
|
||||
ClientId string `json:"client_id" gorm:"type:varchar(255);uniqueIndex;not null"`
|
||||
ClientSecret string `json:"client_secret" gorm:"type:varchar(255);not null"`
|
||||
ClientName string `json:"client_name" gorm:"type:varchar(255);uniqueIndex;not null"`
|
||||
RedirectUri datatypes.JSON `json:"redirect_uri" gorm:"type:json;not null"`
|
||||
}
|
||||
|
||||
func (self *Client) GetClientByClientId(clientId string) (*Client, error) {
|
||||
var client Client
|
||||
if err := Database.
|
||||
Where("client_id = ?", clientId).
|
||||
First(&client).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &client, nil
|
||||
}
|
||||
|
||||
func (self *Client) GetDecryptedSecret() (string, error) {
|
||||
secretKey := viper.GetString("secrets.client_secret_key")
|
||||
secret, err := cryptography.AESCBCDecrypt(self.ClientSecret, []byte(secretKey))
|
||||
return string(secret), err
|
||||
}
|
||||
|
||||
type ClientParams struct {
|
||||
ClientId string
|
||||
ClientName string
|
||||
RedirectUri []string
|
||||
}
|
||||
|
||||
func (self *Client) Create(params *ClientParams) (*Client, error) {
|
||||
jsonRedirectUri, err := json.Marshal(params.RedirectUri)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
encKey := viper.GetString("secrets.client_secret_key")
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
clientSecret := base64.RawURLEncoding.EncodeToString(b)
|
||||
encryptedSecret, err := cryptography.AESCBCEncrypt([]byte(clientSecret), []byte(encKey))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client := &Client{
|
||||
UUID: uuid.New(),
|
||||
ClientId: params.ClientId,
|
||||
ClientSecret: encryptedSecret,
|
||||
ClientName: params.ClientName,
|
||||
RedirectUri: jsonRedirectUri,
|
||||
}
|
||||
|
||||
if err := Database.Create(&client).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func (self *Client) ValidateRedirectURI(redirectURI string) error {
|
||||
var uris []string
|
||||
if err := json.Unmarshal(self.RedirectUri, &uris); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, prefix := range uris {
|
||||
if strings.HasPrefix(redirectURI, prefix) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return errors.New("redirect uri not match")
|
||||
}
|
||||
@@ -35,7 +35,7 @@ func Init() {
|
||||
}
|
||||
|
||||
// Auto migrate
|
||||
err = db.AutoMigrate(&User{}, &Event{})
|
||||
err = db.AutoMigrate(&User{}, &Event{}, &Attendance{}, &Client{})
|
||||
if err != nil {
|
||||
log.Error("[Database] Error migrating database: ", err)
|
||||
}
|
||||
|
||||
192
data/event.go
192
data/event.go
@@ -1,122 +1,101 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"github.com/go-viper/mapstructure/v2"
|
||||
"github.com/google/uuid"
|
||||
"github.com/meilisearch/meilisearch-go"
|
||||
"gorm.io/datatypes"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type Event struct {
|
||||
Id uint `json:"id" gorm:"primarykey;autoincrement"`
|
||||
UUID uuid.UUID `json:"uuid" gorm:"type:uuid;uniqueIndex;not null"`
|
||||
EventId uuid.UUID `json:"event_id" gorm:"type:uuid;uniqueIndex;not null"`
|
||||
Name string `json:"name" gorm:"type:varchar(255);index;not null"`
|
||||
StartTime time.Time `json:"start_time" gorm:"index"`
|
||||
EndTime time.Time `json:"end_time" gorm:"index"`
|
||||
JoinedUsers datatypes.JSONSlice[uuid.UUID] `json:"joined_users"`
|
||||
Id uint `json:"id" gorm:"primarykey;autoincrement"`
|
||||
UUID uuid.UUID `json:"uuid" gorm:"type:uuid;uniqueIndex;not null"`
|
||||
EventId uuid.UUID `json:"event_id" gorm:"type:uuid;uniqueIndex;not null"`
|
||||
Name string `json:"name" gorm:"type:varchar(255);index;not null"`
|
||||
Type string `json:"type" gotm:"type:varchar(255);index;not null"`
|
||||
Description string `json:"description" gorm:"type:text;not null"`
|
||||
StartTime time.Time `json:"start_time" gorm:"index"`
|
||||
EndTime time.Time `json:"end_time" gorm:"index"`
|
||||
Owner uuid.UUID `json:"owner" gorm:"type:uuid;index;not null"`
|
||||
EnableKYC bool `json:"enable_kyc" gorm:"not null"`
|
||||
}
|
||||
|
||||
type EventSearchDoc struct {
|
||||
EventId string `json:"event_id"`
|
||||
Name string `json:"name"`
|
||||
StartTime time.Time `json:"start_time"`
|
||||
EndTime time.Time `json:"end_time"`
|
||||
EventId string `json:"event_id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Description string `json:"description"`
|
||||
StartTime time.Time `json:"start_time"`
|
||||
EndTime time.Time `json:"end_time"`
|
||||
}
|
||||
|
||||
func (self *Event) GetEventById(eventId uuid.UUID) error {
|
||||
return Database.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Where("event_id = ?", eventId).First(&self).Error; err != nil {
|
||||
return err
|
||||
func (self *Event) GetEventById(eventId uuid.UUID) (*Event, error) {
|
||||
var event Event
|
||||
|
||||
err := Database.
|
||||
Where("event_id = ?", eventId).
|
||||
First(&event).Error
|
||||
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, nil
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &event, nil
|
||||
}
|
||||
|
||||
func (self *Event) UpdateEventById(eventId uuid.UUID) error {
|
||||
return Database.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Model(&Event{}).Where("event_id = ?", eventId).Updates(&self).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Update event to document index
|
||||
doc := EventSearchDoc{
|
||||
EventId: self.EventId.String(),
|
||||
Name: self.Name,
|
||||
StartTime: self.StartTime,
|
||||
EndTime: self.EndTime,
|
||||
}
|
||||
index := MeiliSearch.Index("event")
|
||||
docPrimaryKey := "event_id"
|
||||
meiliOptions := &meilisearch.DocumentOptions{PrimaryKey: &docPrimaryKey}
|
||||
if _, err := index.UpdateDocuments([]EventSearchDoc{doc}, meiliOptions); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (self *Event) CreateEvent() error {
|
||||
if self.UUID == uuid.Nil {
|
||||
self.UUID = uuid.New()
|
||||
}
|
||||
if self.EventId == uuid.Nil {
|
||||
self.EventId = uuid.New()
|
||||
}
|
||||
|
||||
return Database.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Create(&self).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Add event to document index
|
||||
doc := EventSearchDoc{
|
||||
EventId: self.EventId.String(),
|
||||
Name: self.Name,
|
||||
StartTime: self.StartTime,
|
||||
EndTime: self.EndTime,
|
||||
}
|
||||
index := MeiliSearch.Index("event")
|
||||
docPrimaryKey := "event_id"
|
||||
meiliOptions := &meilisearch.DocumentOptions{PrimaryKey: &docPrimaryKey}
|
||||
if _, err := index.AddDocuments([]EventSearchDoc{doc}, meiliOptions); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (self *Event) UserJoinEvent(userId, eventId uuid.UUID) error {
|
||||
return Database.Transaction(func(tx *gorm.DB) error {
|
||||
var event Event
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
// DB transaction
|
||||
if err := Database.Transaction(func(tx *gorm.DB) error {
|
||||
// Update by business key
|
||||
if err := tx.
|
||||
Model(&Event{}).
|
||||
Where("event_id = ?", eventId).
|
||||
First(&event).Error; err != nil {
|
||||
Updates(self).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check if user already joined
|
||||
if slices.Contains(event.JoinedUsers, userId) {
|
||||
return errors.New("user already joined")
|
||||
}
|
||||
// Reload to ensure struct is fresh
|
||||
return tx.
|
||||
Where("event_id = ?", eventId).
|
||||
First(self).Error
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Add user to list
|
||||
event.JoinedUsers = append(event.JoinedUsers, userId)
|
||||
if err := tx.Model(&Event{}).Where("event_id = ?", eventId).Update("joined_users", event.JoinedUsers).Error; err != nil {
|
||||
// Sync search index
|
||||
if err := self.UpdateSearchIndex(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *Event) Create() error {
|
||||
self.UUID = uuid.New()
|
||||
self.EventId = uuid.New()
|
||||
|
||||
// DB transaction only
|
||||
if err := Database.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Create(self).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
*self = event
|
||||
return nil
|
||||
})
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Search index (eventual consistency)
|
||||
if err := self.UpdateSearchIndex(); err != nil {
|
||||
// TODO: async retry / log
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *Event) GetFullTable() (*[]Event, error) {
|
||||
@@ -130,6 +109,8 @@ func (self *Event) GetFullTable() (*[]Event, error) {
|
||||
|
||||
func (self *Event) FastListEvents(limit, offset int64) (*[]EventSearchDoc, error) {
|
||||
index := MeiliSearch.Index("event")
|
||||
|
||||
// Fast read from MeiliSearch (no DB involved)
|
||||
result, err := index.Search("", &meilisearch.SearchRequest{
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
@@ -137,9 +118,40 @@ func (self *Event) FastListEvents(limit, offset int64) (*[]EventSearchDoc, error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var list []EventSearchDoc
|
||||
if err := mapstructure.Decode(result.Hits, &list); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &list, nil
|
||||
}
|
||||
|
||||
func (self *Event) UpdateSearchIndex() error {
|
||||
doc := EventSearchDoc{
|
||||
EventId: self.EventId.String(),
|
||||
Name: self.Name,
|
||||
Type: self.Type,
|
||||
Description: self.Description,
|
||||
StartTime: self.StartTime,
|
||||
EndTime: self.EndTime,
|
||||
}
|
||||
index := MeiliSearch.Index("event")
|
||||
|
||||
primaryKey := "event_id"
|
||||
opts := &meilisearch.DocumentOptions{
|
||||
PrimaryKey: &primaryKey,
|
||||
}
|
||||
|
||||
if _, err := index.UpdateDocuments([]EventSearchDoc{doc}, opts); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *Event) DeleteSearchIndex() error {
|
||||
index := MeiliSearch.Index("event")
|
||||
_, err := index.DeleteDocument(self.EventId.String(), nil)
|
||||
return err
|
||||
}
|
||||
|
||||
254
data/user.go
254
data/user.go
@@ -1,133 +1,91 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-viper/mapstructure/v2"
|
||||
"github.com/google/uuid"
|
||||
"github.com/meilisearch/meilisearch-go"
|
||||
"github.com/spf13/viper"
|
||||
"gorm.io/datatypes"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// Permission Level
|
||||
// Banned User: 0
|
||||
// Normal User: 10
|
||||
// Admin User: 20
|
||||
// Super User: 30
|
||||
|
||||
type User struct {
|
||||
Id uint `json:"id" gorm:"primarykey;autoincrement"`
|
||||
UUID uuid.UUID `json:"uuid" gorm:"type:uuid;uniqueindex;not null"`
|
||||
UserId uuid.UUID `json:"user_id" gorm:"type:uuid;uniqueindex;not null"`
|
||||
Email string `json:"email" gorm:"type:varchar(255);uniqueindex;not null"`
|
||||
Type string `json:"type" gorm:"type:varchar(32);index;not null"`
|
||||
Nickname string `json:"nickname"`
|
||||
Subtitle string `json:"subtitle"`
|
||||
Avatar string `json:"avatar"`
|
||||
Checkin datatypes.JSONMap `json:"checkin"`
|
||||
PermissionLevel uint `json:"permission_level" gorm:"default:10;not null"`
|
||||
Id uint `json:"id" gorm:"primarykey;autoincrement"`
|
||||
UUID uuid.UUID `json:"uuid" gorm:"type:uuid;uniqueindex;not null"`
|
||||
UserId uuid.UUID `json:"user_id" gorm:"type:uuid;uniqueindex;not null"`
|
||||
Email string `json:"email" gorm:"type:varchar(255);uniqueindex;not null"`
|
||||
Username string `json:"username" gorm:"type:varchar(255);uniqueindex;not null"`
|
||||
Nickname string `json:"nickname" gorm:"type:text"`
|
||||
Subtitle string `json:"subtitle" gorm:"type:text"`
|
||||
Avatar string `json:"avatar" gorm:"type:text"`
|
||||
Bio string `json:"bio" gorm:"type:text"`
|
||||
PermissionLevel uint `json:"permission_level" gorm:"default:10;not null"`
|
||||
AllowPublic bool `json:"allow_public" gorm:"default:false;not null"`
|
||||
}
|
||||
|
||||
type UserSearchDoc struct {
|
||||
UserId string `json:"user_id"`
|
||||
Email string `json:"email"`
|
||||
Type string `json:"type"`
|
||||
Nickname string `json:"nickname"`
|
||||
Subtitle string `json:"subtitle"`
|
||||
Avatar string `json:"avatar"`
|
||||
PermissionLevel uint `json:"permission_level"`
|
||||
UserId string `json:"user_id"`
|
||||
Email string `json:"email"`
|
||||
Username string `json:"username"`
|
||||
Type string `json:"type"`
|
||||
Nickname string `json:"nickname"`
|
||||
Subtitle string `json:"subtitle"`
|
||||
Avatar string `json:"avatar"`
|
||||
}
|
||||
|
||||
func (self *User) GetByEmail(email string) error {
|
||||
if err := Database.Where("email = ?", email).First(&self).Error; err != nil {
|
||||
return err
|
||||
func (self *User) GetByEmail(email string) (*User, error) {
|
||||
var user User
|
||||
|
||||
err := Database.
|
||||
Where("email = ?", email).
|
||||
First(&user).Error
|
||||
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return nil
|
||||
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func (self *User) GetByUserId(userId uuid.UUID) error {
|
||||
if err := Database.Where("user_id = ?", userId).First(&self).Error; err != nil {
|
||||
return err
|
||||
func (self *User) GetByUserId(userId uuid.UUID) (*User, error) {
|
||||
var user User
|
||||
|
||||
err := Database.
|
||||
Where("user_id = ?", userId).
|
||||
First(&user).Error
|
||||
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *User) UpdateCheckin(userId, eventId uuid.UUID, time time.Time) error {
|
||||
return Database.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.
|
||||
Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("user_id = ?", userId).
|
||||
First(self).Error; err != nil {
|
||||
return err // if error then rollback
|
||||
}
|
||||
|
||||
self.Checkin = datatypes.JSONMap{eventId.String(): time}
|
||||
|
||||
if err := tx.Save(self).Error; err != nil {
|
||||
return err // rollback
|
||||
}
|
||||
|
||||
// Update user to document index
|
||||
doc := UserSearchDoc{
|
||||
UserId: self.UserId.String(),
|
||||
Email: self.Email,
|
||||
Type: self.Type,
|
||||
Nickname: self.Nickname,
|
||||
Subtitle: self.Subtitle,
|
||||
Avatar: self.Avatar,
|
||||
PermissionLevel: self.PermissionLevel,
|
||||
}
|
||||
index := MeiliSearch.Index("user")
|
||||
docPrimaryKey := "user_id"
|
||||
meiliOptions := &meilisearch.DocumentOptions{PrimaryKey: &docPrimaryKey}
|
||||
if _, err := index.UpdateDocuments([]UserSearchDoc{doc}, meiliOptions); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil // commit
|
||||
})
|
||||
return &user, err
|
||||
}
|
||||
|
||||
func (self *User) Create() error {
|
||||
return Database.Transaction(func(tx *gorm.DB) error {
|
||||
if self.UUID == uuid.Nil {
|
||||
self.UUID = uuid.New()
|
||||
}
|
||||
if self.UserId == uuid.Nil {
|
||||
self.UserId = uuid.New()
|
||||
}
|
||||
self.UUID = uuid.New()
|
||||
self.UserId = uuid.New()
|
||||
|
||||
// DB transaction only
|
||||
if err := Database.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Create(self).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create user to document index
|
||||
doc := UserSearchDoc{
|
||||
UserId: self.UserId.String(),
|
||||
Email: self.Email,
|
||||
Type: self.Type,
|
||||
Nickname: self.Nickname,
|
||||
Subtitle: self.Subtitle,
|
||||
Avatar: self.Avatar,
|
||||
PermissionLevel: self.PermissionLevel,
|
||||
}
|
||||
index := MeiliSearch.Index("user")
|
||||
docPrimaryKey := "user_id"
|
||||
meiliOptions := &meilisearch.DocumentOptions{PrimaryKey: &docPrimaryKey}
|
||||
if _, err := index.AddDocuments([]UserSearchDoc{doc}, meiliOptions); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Search index (eventual consistency)
|
||||
if err := self.UpdateSearchIndex(); err != nil {
|
||||
// TODO: async retry / log
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *User) UpdateByUserID(userId uuid.UUID) error {
|
||||
@@ -150,6 +108,8 @@ func (self *User) GetFullTable() (*[]User, error) {
|
||||
|
||||
func (self *User) FastListUsers(limit, offset int64) (*[]UserSearchDoc, error) {
|
||||
index := MeiliSearch.Index("user")
|
||||
|
||||
// Fast read from MeiliSearch, no DB involved
|
||||
result, err := index.Search("", &meilisearch.SearchRequest{
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
@@ -157,75 +117,43 @@ func (self *User) FastListUsers(limit, offset int64) (*[]UserSearchDoc, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var list []UserSearchDoc
|
||||
if err := mapstructure.Decode(result.Hits, &list); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &list, nil
|
||||
}
|
||||
|
||||
func (self *User) GenCheckinCode(eventId uuid.UUID) (*string, error) {
|
||||
ctx := context.Background()
|
||||
ttl := viper.GetDuration("ttl.checkin_code_ttl")
|
||||
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
|
||||
|
||||
for {
|
||||
code := fmt.Sprintf("%06d", rng.Intn(900000)+100000)
|
||||
ok, err := Redis.SetNX(
|
||||
ctx,
|
||||
"checkin_code:"+code,
|
||||
"user_id:"+self.UserId.String()+":event_id:"+eventId.String(),
|
||||
ttl,
|
||||
).Result()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ok {
|
||||
return &code, nil
|
||||
}
|
||||
func (self *User) UpdateSearchIndex() error {
|
||||
doc := UserSearchDoc{
|
||||
UserId: self.UserId.String(),
|
||||
Email: self.Email,
|
||||
Username: self.Username,
|
||||
Nickname: self.Nickname,
|
||||
Subtitle: self.Subtitle,
|
||||
Avatar: self.Avatar,
|
||||
}
|
||||
index := MeiliSearch.Index("user")
|
||||
|
||||
primaryKey := "user_id"
|
||||
opts := &meilisearch.DocumentOptions{
|
||||
PrimaryKey: &primaryKey,
|
||||
}
|
||||
|
||||
if _, err := index.UpdateDocuments(
|
||||
[]UserSearchDoc{doc},
|
||||
opts,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *User) VerifyCheckinCode(checkinCode string) (*uuid.UUID, error) {
|
||||
ctx := context.Background()
|
||||
|
||||
result := Redis.Get(ctx, "checkin_code:"+checkinCode).String()
|
||||
|
||||
if result == "" {
|
||||
return nil, errors.New("invalid or expired checkin code")
|
||||
}
|
||||
|
||||
split := strings.Split(result, ":")
|
||||
if len(split) < 2 {
|
||||
return nil, errors.New("invalid checkin code format")
|
||||
}
|
||||
|
||||
userId := split[0]
|
||||
eventId := split[1]
|
||||
|
||||
var returnedUserId uuid.UUID
|
||||
err := Database.Transaction(func(tx *gorm.DB) error {
|
||||
checkinData := map[string]interface{}{
|
||||
eventId: time.Now(),
|
||||
}
|
||||
|
||||
if err := tx.Model(&User{}).Where("user_id = ?", userId).Updates(map[string]interface{}{
|
||||
"checkin": checkinData,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
parsedUserId, err := uuid.Parse(userId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
returnedUserId = parsedUserId
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &returnedUserId, nil
|
||||
func (self *User) DeleteSearchIndex() error {
|
||||
index := MeiliSearch.Index("user")
|
||||
_, err := index.DeleteDocument(self.UserId.String(), nil)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
packages = [
|
||||
pkgs.git
|
||||
pkgs.just
|
||||
pkgs.watchexec
|
||||
];
|
||||
|
||||
dotenv = {
|
||||
@@ -32,11 +33,7 @@
|
||||
exec = "bun run dev";
|
||||
cwd = "./client";
|
||||
};
|
||||
backend.exec = "just run";
|
||||
};
|
||||
|
||||
tasks = {
|
||||
"backend:build".exec = "just clean && just build";
|
||||
backend.exec = "just dev-back";
|
||||
};
|
||||
|
||||
services = {
|
||||
|
||||
44
go.mod
44
go.mod
@@ -2,70 +2,84 @@ module nixcn-cms
|
||||
|
||||
go 1.25.5
|
||||
|
||||
require (
|
||||
github.com/alibabacloud-go/cloudauth-20190307/v4 v4.12.1
|
||||
github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.13
|
||||
github.com/alibabacloud-go/tea v1.3.13
|
||||
github.com/alibabacloud-go/tea-utils/v2 v2.0.7
|
||||
github.com/aliyun/credentials-go v1.4.5
|
||||
github.com/gin-gonic/gin v1.11.0
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/meilisearch/meilisearch-go v0.35.0
|
||||
github.com/redis/go-redis/v9 v9.17.2
|
||||
github.com/sirupsen/logrus v1.9.3
|
||||
github.com/spf13/viper v1.21.0
|
||||
golang.org/x/crypto v0.46.0
|
||||
golang.org/x/oauth2 v0.34.0
|
||||
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df
|
||||
gorm.io/datatypes v1.2.7
|
||||
gorm.io/driver/postgres v1.6.0
|
||||
gorm.io/gorm v1.31.1
|
||||
)
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5 // indirect
|
||||
github.com/alibabacloud-go/debug v1.0.1 // indirect
|
||||
github.com/alibabacloud-go/openapi-util v0.1.1 // indirect
|
||||
github.com/andybalholm/brotli v1.1.1 // indirect
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/sonic v1.14.2 // indirect
|
||||
github.com/bytedance/sonic/loader v0.4.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/clbanning/mxj/v2 v2.7.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/gin-gonic/gin v1.11.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.29.0 // indirect
|
||||
github.com/go-sql-driver/mysql v1.8.1 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/goccy/go-yaml v1.19.1 // indirect
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/pgx/v5 v5.6.0 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/joho/godotenv v1.5.1 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/meilisearch/meilisearch-go v0.35.0 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.57.1 // indirect
|
||||
github.com/redis/go-redis/v9 v9.17.2 // indirect
|
||||
github.com/sagikazarmark/locafero v0.11.0 // indirect
|
||||
github.com/sirupsen/logrus v1.9.3 // indirect
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
|
||||
github.com/spf13/afero v1.15.0 // indirect
|
||||
github.com/spf13/cast v1.10.0 // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/spf13/viper v1.21.0 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
github.com/tjfoc/gmsm v1.4.1 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||
go.uber.org/mock v0.6.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/arch v0.23.0 // indirect
|
||||
golang.org/x/crypto v0.46.0 // indirect
|
||||
golang.org/x/mod v0.31.0 // indirect
|
||||
golang.org/x/net v0.48.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.39.0 // indirect
|
||||
golang.org/x/text v0.32.0 // indirect
|
||||
golang.org/x/tools v0.40.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gorm.io/datatypes v1.2.7 // indirect
|
||||
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
gorm.io/driver/mysql v1.5.6 // indirect
|
||||
gorm.io/driver/postgres v1.6.0 // indirect
|
||||
gorm.io/gorm v1.31.1 // indirect
|
||||
)
|
||||
|
||||
278
go.sum
278
go.sum
@@ -1,21 +1,85 @@
|
||||
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/alibabacloud-go/alibabacloud-gateway-pop v0.0.6 h1:eIf+iGJxdU4U9ypaUfbtOWCsZSbTb8AUHvyPrxu6mAA=
|
||||
github.com/alibabacloud-go/alibabacloud-gateway-pop v0.0.6/go.mod h1:4EUIoxs/do24zMOGGqYVWgw0s9NtiylnJglOeEB5UJo=
|
||||
github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.4/go.mod h1:sCavSAvdzOjul4cEqeVtvlSaSScfNsTQ+46HwlTL1hc=
|
||||
github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5 h1:zE8vH9C7JiZLNJJQ5OwjU9mSi4T9ef9u3BURT6LCLC8=
|
||||
github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5/go.mod h1:tWnyE9AjF8J8qqLk645oUmVUnFybApTQWklQmi5tY6g=
|
||||
github.com/alibabacloud-go/cloudauth-20190307/v4 v4.12.1 h1:bVUYai/CnqhFMcofU8TsJGnZEf9zCB1WakLxh0zmpjQ=
|
||||
github.com/alibabacloud-go/cloudauth-20190307/v4 v4.12.1/go.mod h1:m3NrP7nlncob5/2ATBk0thsZAozpDy/+vUG4ZuXwdR8=
|
||||
github.com/alibabacloud-go/darabonba-array v0.1.0 h1:vR8s7b1fWAQIjEjWnuF0JiKsCvclSRTfDzZHTYqfufY=
|
||||
github.com/alibabacloud-go/darabonba-array v0.1.0/go.mod h1:BLKxr0brnggqOJPqT09DFJ8g3fsDshapUD3C3aOEFaI=
|
||||
github.com/alibabacloud-go/darabonba-encode-util v0.0.2 h1:1uJGrbsGEVqWcWxrS9MyC2NG0Ax+GpOM5gtupki31XE=
|
||||
github.com/alibabacloud-go/darabonba-encode-util v0.0.2/go.mod h1:JiW9higWHYXm7F4PKuMgEUETNZasrDM6vqVr/Can7H8=
|
||||
github.com/alibabacloud-go/darabonba-map v0.0.2 h1:qvPnGB4+dJbJIxOOfawxzF3hzMnIpjmafa0qOTp6udc=
|
||||
github.com/alibabacloud-go/darabonba-map v0.0.2/go.mod h1:28AJaX8FOE/ym8OUFWga+MtEzBunJwQGceGQlvaPGPc=
|
||||
github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.13 h1:Q00FU3H94Ts0ZIHDmY+fYGgB7dV9D/YX6FGsgorQPgw=
|
||||
github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.13/go.mod h1:lxFGfobinVsQ49ntjpgWghXmIF0/Sm4+wvBJ1h5RtaE=
|
||||
github.com/alibabacloud-go/darabonba-signature-util v0.0.7 h1:UzCnKvsjPFzApvODDNEYqBHMFt1w98wC7FOo0InLyxg=
|
||||
github.com/alibabacloud-go/darabonba-signature-util v0.0.7/go.mod h1:oUzCYV2fcCH797xKdL6BDH8ADIHlzrtKVjeRtunBNTQ=
|
||||
github.com/alibabacloud-go/darabonba-string v1.0.2 h1:E714wms5ibdzCqGeYJ9JCFywE5nDyvIXIIQbZVFkkqo=
|
||||
github.com/alibabacloud-go/darabonba-string v1.0.2/go.mod h1:93cTfV3vuPhhEwGGpKKqhVW4jLe7tDpo3LUM0i0g6mA=
|
||||
github.com/alibabacloud-go/debug v0.0.0-20190504072949-9472017b5c68/go.mod h1:6pb/Qy8c+lqua8cFpEy7g39NRRqOWc3rOwAy8m5Y2BY=
|
||||
github.com/alibabacloud-go/debug v1.0.0/go.mod h1:8gfgZCCAC3+SCzjWtY053FrOcd4/qlH6IHTI4QyICOc=
|
||||
github.com/alibabacloud-go/debug v1.0.1 h1:MsW9SmUtbb1Fnt3ieC6NNZi6aEwrXfDksD4QA6GSbPg=
|
||||
github.com/alibabacloud-go/debug v1.0.1/go.mod h1:8gfgZCCAC3+SCzjWtY053FrOcd4/qlH6IHTI4QyICOc=
|
||||
github.com/alibabacloud-go/endpoint-util v1.1.0 h1:r/4D3VSw888XGaeNpP994zDUaxdgTSHBbVfZlzf6b5Q=
|
||||
github.com/alibabacloud-go/endpoint-util v1.1.0/go.mod h1:O5FuCALmCKs2Ff7JFJMudHs0I5EBgecXXxZRyswlEjE=
|
||||
github.com/alibabacloud-go/openapi-util v0.1.0/go.mod h1:sQuElr4ywwFRlCCberQwKRFhRzIyG4QTP/P4y1CJ6Ws=
|
||||
github.com/alibabacloud-go/openapi-util v0.1.1 h1:ujGErJjG8ncRW6XtBBMphzHTvCxn4DjrVw4m04HsS28=
|
||||
github.com/alibabacloud-go/openapi-util v0.1.1/go.mod h1:/UehBSE2cf1gYT43GV4E+RxTdLRzURImCYY0aRmlXpw=
|
||||
github.com/alibabacloud-go/tea v1.1.0/go.mod h1:IkGyUSX4Ba1V+k4pCtJUc6jDpZLFph9QMy2VUPTwukg=
|
||||
github.com/alibabacloud-go/tea v1.1.7/go.mod h1:/tmnEaQMyb4Ky1/5D+SE1BAsa5zj/KeGOFfwYm3N/p4=
|
||||
github.com/alibabacloud-go/tea v1.1.8/go.mod h1:/tmnEaQMyb4Ky1/5D+SE1BAsa5zj/KeGOFfwYm3N/p4=
|
||||
github.com/alibabacloud-go/tea v1.1.11/go.mod h1:/tmnEaQMyb4Ky1/5D+SE1BAsa5zj/KeGOFfwYm3N/p4=
|
||||
github.com/alibabacloud-go/tea v1.1.17/go.mod h1:nXxjm6CIFkBhwW4FQkNrolwbfon8Svy6cujmKFUq98A=
|
||||
github.com/alibabacloud-go/tea v1.1.20/go.mod h1:nXxjm6CIFkBhwW4FQkNrolwbfon8Svy6cujmKFUq98A=
|
||||
github.com/alibabacloud-go/tea v1.2.2/go.mod h1:CF3vOzEMAG+bR4WOql8gc2G9H3EkH3ZLAQdpmpXMgwk=
|
||||
github.com/alibabacloud-go/tea v1.3.13 h1:WhGy6LIXaMbBM6VBYcsDCz6K/TPsT1Ri2hPmmZffZ94=
|
||||
github.com/alibabacloud-go/tea v1.3.13/go.mod h1:A560v/JTQ1n5zklt2BEpurJzZTI8TUT+Psg2drWlxRg=
|
||||
github.com/alibabacloud-go/tea-utils v1.3.1/go.mod h1:EI/o33aBfj3hETm4RLiAxF/ThQdSngxrpF8rKUDJjPE=
|
||||
github.com/alibabacloud-go/tea-utils/v2 v2.0.5/go.mod h1:dL6vbUT35E4F4bFTHL845eUloqaerYBYPsdWR2/jhe4=
|
||||
github.com/alibabacloud-go/tea-utils/v2 v2.0.6/go.mod h1:qxn986l+q33J5VkialKMqT/TTs3E+U9MJpd001iWQ9I=
|
||||
github.com/alibabacloud-go/tea-utils/v2 v2.0.7 h1:WDx5qW3Xa5ZgJ1c8NfqJkF6w+AU5wB8835UdhPr6Ax0=
|
||||
github.com/alibabacloud-go/tea-utils/v2 v2.0.7/go.mod h1:qxn986l+q33J5VkialKMqT/TTs3E+U9MJpd001iWQ9I=
|
||||
github.com/aliyun/credentials-go v1.1.2/go.mod h1:ozcZaMR5kLM7pwtCMEpVmQ242suV6qTJya2bDq4X1Tw=
|
||||
github.com/aliyun/credentials-go v1.3.1/go.mod h1:8jKYhQuDawt8x2+fusqa1Y6mPxemTsBEN04dgcAcYz0=
|
||||
github.com/aliyun/credentials-go v1.3.6/go.mod h1:1LxUuX7L5YrZUWzBrRyk0SwSdH4OmPrib8NVePL3fxM=
|
||||
github.com/aliyun/credentials-go v1.4.5 h1:O76WYKgdy1oQYYiJkERjlA2dxGuvLRrzuO2ScrtGWSk=
|
||||
github.com/aliyun/credentials-go v1.4.5/go.mod h1:Jm6d+xIgwJVLVWT561vy67ZRP4lPTQxMbEYRuT2Ti1U=
|
||||
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
|
||||
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||
github.com/bytedance/sonic v1.14.2 h1:k1twIoe97C1DtYUo+fZQy865IuHia4PR5RPiuGPPIIE=
|
||||
github.com/bytedance/sonic v1.14.2/go.mod h1:T80iDELeHiHKSc0C9tubFygiuXoGzrkjKzX2quAx980=
|
||||
github.com/bytedance/sonic/loader v0.4.0 h1:olZ7lEqcxtZygCK9EKYKADnpQoYkRQxaeY2NYzevs+o=
|
||||
github.com/bytedance/sonic/loader v0.4.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/clbanning/mxj/v2 v2.7.0 h1:WA/La7UGCanFe5NpHF0Q3DNtnCsVoxbPKuyBNHWRyME=
|
||||
github.com/clbanning/mxj/v2 v2.7.0/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s=
|
||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||
@@ -24,6 +88,8 @@ github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
|
||||
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
@@ -43,9 +109,33 @@ github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXe
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA=
|
||||
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
|
||||
github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
|
||||
github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
|
||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
|
||||
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
||||
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
||||
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
|
||||
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
|
||||
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
|
||||
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
|
||||
github.com/gopherjs/gopherjs v0.0.0-20200217142428-fce0ec30dd00/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
@@ -58,36 +148,56 @@ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/meilisearch/meilisearch-go v0.35.0 h1:Gh4vO+PinVQZ58iiFdUX9Hld8uXKzKh+C7mSSsCDlI8=
|
||||
github.com/meilisearch/meilisearch-go v0.35.0/go.mod h1:cUVJZ2zMqTvvwIMEEAdsWH+zrHsrLpAw6gm8Lt1MXK0=
|
||||
github.com/microsoft/go-mssqldb v1.7.2 h1:CHkFJiObW7ItKTJfHo1QX7QBBD1iV+mn1eOyRP3b/PA=
|
||||
github.com/microsoft/go-mssqldb v1.7.2/go.mod h1:kOvZKUdrhhFQmxLZqbwUV0rHkNkZpthMITIb2Ko1IoA=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.57.1 h1:25KAAR9QR8KZrCZRThWMKVAwGoiHIrNbT72ULHTuI10=
|
||||
github.com/quic-go/quic-go v0.57.1/go.mod h1:ly4QBAjHA2VhdnxhojRsCUOeJwKYg+taDlos92xb1+s=
|
||||
github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI=
|
||||
github.com/redis/go-redis/v9 v9.17.2/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
|
||||
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
|
||||
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
|
||||
github.com/smartystreets/assertions v1.1.0/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYlVhC/LOxJk7iOWnoo=
|
||||
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
|
||||
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||
@@ -99,48 +209,198 @@ github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3A
|
||||
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
|
||||
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||
github.com/tjfoc/gmsm v1.3.2/go.mod h1:HaUcFuY0auTiaHB9MHFGCPx5IaLhTUd2atbCFBQXn9w=
|
||||
github.com/tjfoc/gmsm v1.4.1 h1:aMe1GlZb+0bLjn+cKTPEvvn9oUEBlJitaZiiBwsbgho=
|
||||
github.com/tjfoc/gmsm v1.4.1/go.mod h1:j4INPkHWMrhJb38G+J6W4Tw0AbuN8Thu3PbdVYhVcTE=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.1.30/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg=
|
||||
golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20191219195013-becbf705a915/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20200510223506-06a226fb4e37/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
|
||||
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
|
||||
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
||||
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
||||
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
|
||||
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||
golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
|
||||
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
|
||||
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
|
||||
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200509044756-6aff5f38e54f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
|
||||
golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
|
||||
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
|
||||
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
|
||||
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
|
||||
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20200509030707-2212a7e161a5/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
||||
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
|
||||
google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
|
||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
|
||||
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
|
||||
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk=
|
||||
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df h1:n7WqCuqOuCbNr617RXOY0AWRXxgwEyPp2z+p0+hgMuE=
|
||||
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df/go.mod h1:LRQQ+SO6ZHR7tOkpBDuZnXENFzX8qRjMDMyPD6BRkCw=
|
||||
gopkg.in/ini.v1 v1.56.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
|
||||
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/datatypes v1.2.7 h1:ww9GAhF1aGXZY3EB3cJPJ7//JiuQo7DlQA7NNlVaTdk=
|
||||
gorm.io/datatypes v1.2.7/go.mod h1:M2iO+6S3hhi4nAyYe444Pcb0dcIiOMJ7QHaUXxyiNZY=
|
||||
@@ -148,6 +408,12 @@ gorm.io/driver/mysql v1.5.6 h1:Ld4mkIickM+EliaQZQx3uOJDJHtrd70MxAUqWqlx3Y8=
|
||||
gorm.io/driver/mysql v1.5.6/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM=
|
||||
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
|
||||
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
|
||||
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
|
||||
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
|
||||
gorm.io/driver/sqlserver v1.6.0 h1:VZOBQVsVhkHU/NzNhRJKoANt5pZGQAS1Bwc6m6dgfnc=
|
||||
gorm.io/driver/sqlserver v1.6.0/go.mod h1:WQzt4IJo/WHKnckU9jXBLMJIVNMVeTu25dnOzehntWw=
|
||||
gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
||||
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
||||
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
|
||||
208
internal/cryptography/aes.go
Normal file
208
internal/cryptography/aes.go
Normal file
@@ -0,0 +1,208 @@
|
||||
package cryptography
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"io"
|
||||
)
|
||||
|
||||
func randomBytes(n int) ([]byte, error) {
|
||||
b := make([]byte, n)
|
||||
_, err := io.ReadFull(rand.Reader, b)
|
||||
return b, err
|
||||
}
|
||||
|
||||
func normalizeKey(key []byte) ([]byte, error) {
|
||||
switch len(key) {
|
||||
case 16, 24, 32:
|
||||
return key, nil
|
||||
default:
|
||||
return nil, errors.New("AES key length must be 16, 24, or 32 bytes")
|
||||
}
|
||||
}
|
||||
|
||||
func AESGCMEncrypt(plaintext, key []byte) (string, error) {
|
||||
key, err := normalizeKey(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
nonce, err := randomBytes(gcm.NonceSize())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
ciphertext := gcm.Seal(nil, nonce, plaintext, nil)
|
||||
out := append(nonce, ciphertext...)
|
||||
|
||||
return base64.RawURLEncoding.EncodeToString(out), nil
|
||||
}
|
||||
|
||||
func AESGCMDecrypt(encoded string, key []byte) ([]byte, error) {
|
||||
key, err := normalizeKey(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
data, err := base64.RawURLEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(data) < gcm.NonceSize() {
|
||||
return nil, errors.New("ciphertext too short")
|
||||
}
|
||||
|
||||
nonce := data[:gcm.NonceSize()]
|
||||
ciphertext := data[gcm.NonceSize():]
|
||||
|
||||
return gcm.Open(nil, nonce, ciphertext, nil)
|
||||
}
|
||||
|
||||
func pkcs7Pad(data []byte, blockSize int) []byte {
|
||||
padding := blockSize - len(data)%blockSize
|
||||
padtext := bytes.Repeat([]byte{byte(padding)}, padding)
|
||||
return append(data, padtext...)
|
||||
}
|
||||
|
||||
func pkcs7Unpad(data []byte) ([]byte, error) {
|
||||
length := len(data)
|
||||
if length == 0 {
|
||||
return nil, errors.New("invalid padding")
|
||||
}
|
||||
padding := int(data[length-1])
|
||||
if padding == 0 || padding > length {
|
||||
return nil, errors.New("invalid padding")
|
||||
}
|
||||
return data[:length-padding], nil
|
||||
}
|
||||
|
||||
func AESCBCEncrypt(plaintext, key []byte) (string, error) {
|
||||
key, err := normalizeKey(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
plaintext = pkcs7Pad(plaintext, block.BlockSize())
|
||||
|
||||
iv, err := randomBytes(block.BlockSize())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
mode := cipher.NewCBCEncrypter(block, iv)
|
||||
mode.CryptBlocks(plaintext, plaintext)
|
||||
|
||||
out := append(iv, plaintext...)
|
||||
return base64.RawURLEncoding.EncodeToString(out), nil
|
||||
}
|
||||
|
||||
func AESCBCDecrypt(encoded string, key []byte) ([]byte, error) {
|
||||
key, err := normalizeKey(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
data, err := base64.RawURLEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(data) < block.BlockSize() {
|
||||
return nil, errors.New("ciphertext too short")
|
||||
}
|
||||
|
||||
iv := data[:block.BlockSize()]
|
||||
data = data[block.BlockSize():]
|
||||
|
||||
mode := cipher.NewCBCDecrypter(block, iv)
|
||||
mode.CryptBlocks(data, data)
|
||||
|
||||
return pkcs7Unpad(data)
|
||||
}
|
||||
|
||||
func AESCFBEncrypt(plaintext, key []byte) (string, error) {
|
||||
key, err := normalizeKey(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
iv, err := randomBytes(block.BlockSize())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
stream := cipher.NewCFBEncrypter(block, iv)
|
||||
stream.XORKeyStream(plaintext, plaintext)
|
||||
|
||||
out := append(iv, plaintext...)
|
||||
return base64.RawURLEncoding.EncodeToString(out), nil
|
||||
}
|
||||
|
||||
func AESCFBDecrypt(encoded string, key []byte) ([]byte, error) {
|
||||
key, err := normalizeKey(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
data, err := base64.RawURLEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(data) < block.BlockSize() {
|
||||
return nil, errors.New("ciphertext too short")
|
||||
}
|
||||
|
||||
iv := data[:block.BlockSize()]
|
||||
data = data[block.BlockSize():]
|
||||
|
||||
stream := cipher.NewCFBDecrypter(block, iv)
|
||||
stream.XORKeyStream(data, data)
|
||||
|
||||
return data, nil
|
||||
}
|
||||
20
internal/cryptography/base64.go
Normal file
20
internal/cryptography/base64.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package cryptography
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func IsBase64Std(s string) bool {
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
s = strings.TrimSpace(s)
|
||||
if len(s)%4 != 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
_, err := base64.StdEncoding.Strict().DecodeString(s)
|
||||
return err == nil
|
||||
}
|
||||
22
internal/cryptography/bcrypt.go
Normal file
22
internal/cryptography/bcrypt.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package cryptography
|
||||
|
||||
import "golang.org/x/crypto/bcrypt"
|
||||
|
||||
func BcryptHash(input string) (string, error) {
|
||||
hash, err := bcrypt.GenerateFromPassword(
|
||||
[]byte(input),
|
||||
bcrypt.DefaultCost,
|
||||
)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(hash), nil
|
||||
}
|
||||
|
||||
func BcryptVerify(input string, hashed string) bool {
|
||||
err := bcrypt.CompareHashAndPassword(
|
||||
[]byte(hashed),
|
||||
[]byte(input),
|
||||
)
|
||||
return err == nil
|
||||
}
|
||||
@@ -1,189 +0,0 @@
|
||||
package cryptography
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"nixcn-cms/data"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/google/uuid"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
type Token struct {
|
||||
Application string
|
||||
}
|
||||
|
||||
type JwtClaims struct {
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
// Generate jwt clames
|
||||
func (self *Token) NewClaims(userId uuid.UUID) JwtClaims {
|
||||
return JwtClaims{
|
||||
UserID: userId,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(viper.GetDuration("ttl.access_ttl"))),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
Issuer: self.Application,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Generate access token
|
||||
func (self *Token) GenerateAccessToken(userId uuid.UUID) (string, error) {
|
||||
claims := self.NewClaims(userId)
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
secret := viper.GetString("secrets.jwt_secret")
|
||||
signedToken, err := token.SignedString([]byte(secret))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error signing token: %v", err)
|
||||
}
|
||||
return signedToken, nil
|
||||
}
|
||||
|
||||
// Generate refresh token
|
||||
func (self *Token) GenerateRefreshToken() (string, error) {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.URLEncoding.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
// Issue both access and refresh token
|
||||
func (self *Token) IssueTokens(userId uuid.UUID) (string, string, error) {
|
||||
// Gen atk
|
||||
access, err := self.GenerateAccessToken(userId)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
// Gen rtk
|
||||
refresh, err := self.GenerateRefreshToken()
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
// Store to redis
|
||||
ctx := context.Background()
|
||||
ttl := viper.GetDuration("ttl.refresh_ttl")
|
||||
|
||||
// refresh -> user
|
||||
if err := data.Redis.Set(
|
||||
ctx,
|
||||
"refresh:"+refresh,
|
||||
userId.String(),
|
||||
ttl,
|
||||
).Err(); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
// user -> refresh tokens
|
||||
userSetKey := "user:" + userId.String() + ":refresh_tokens"
|
||||
|
||||
if err := data.Redis.SAdd(
|
||||
ctx,
|
||||
userSetKey,
|
||||
refresh,
|
||||
).Err(); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
// set user ttl >= all refresh token
|
||||
_ = data.Redis.Expire(ctx, userSetKey, ttl).Err()
|
||||
|
||||
return access, refresh, nil
|
||||
}
|
||||
|
||||
// Refresh access token
|
||||
func (self *Token) RefreshAccessToken(refreshToken string) (string, error) {
|
||||
// Read rtk:userid from redis
|
||||
ctx := context.Background()
|
||||
key := "refresh:" + refreshToken
|
||||
|
||||
userIdStr, err := data.Redis.Get(ctx, key).Result()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
userId, err := uuid.Parse(userIdStr)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Generate access token
|
||||
return self.GenerateAccessToken(userId)
|
||||
}
|
||||
|
||||
func (self *Token) RenewRefreshToken(refreshToken string) (string, error) {
|
||||
ctx := context.Background()
|
||||
ttl := viper.GetDuration("ttl.refresh_ttl")
|
||||
|
||||
key := "refresh:" + refreshToken
|
||||
userIdStr, err := data.Redis.Get(ctx, key).Result()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
refresh, err := self.GenerateRefreshToken()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
err = self.RevokeRefreshToken(refreshToken)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// refresh -> user
|
||||
if err := data.Redis.Set(
|
||||
ctx,
|
||||
"refresh:"+refresh,
|
||||
userIdStr,
|
||||
ttl,
|
||||
).Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// user -> refresh tokens
|
||||
userSetKey := "user:" + userIdStr + ":refresh_tokens"
|
||||
|
||||
if err := data.Redis.SAdd(
|
||||
ctx,
|
||||
userSetKey,
|
||||
refresh,
|
||||
).Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// set user ttl >= all refresh token
|
||||
_ = data.Redis.Expire(ctx, userSetKey, ttl).Err()
|
||||
|
||||
return refresh, nil
|
||||
}
|
||||
|
||||
func (self *Token) RevokeRefreshToken(refreshToken string) error {
|
||||
ctx := context.Background()
|
||||
|
||||
key := "refresh:" + refreshToken
|
||||
|
||||
userIDStr, err := data.Redis.Get(ctx, key).Result()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
userSetKey := "user:" + userIDStr + ":refresh_tokens"
|
||||
|
||||
// Delete rtk from redis
|
||||
pipe := data.Redis.TxPipeline()
|
||||
pipe.Del(ctx, key) // rtk:userid index
|
||||
pipe.SRem(ctx, userSetKey, refreshToken) // userid:rtk index
|
||||
_, err = pipe.Exec(ctx)
|
||||
|
||||
return err
|
||||
}
|
||||
10
justfile
10
justfile
@@ -9,15 +9,14 @@ bun_cmd := `realpath $(which bun)`
|
||||
|
||||
default: install clean build-back build-client run-back
|
||||
|
||||
backend: install clean build-back run-back
|
||||
backend: clean build-back run-back
|
||||
|
||||
install:
|
||||
cd {{ client_dir }} && {{ bun_cmd }} install
|
||||
|
||||
clean:
|
||||
rm -rf {{ output_dir }}
|
||||
mkdir -p {{ output_dir }}
|
||||
cp {{ join(project_dir, "config.default.yaml") }} {{ join(output_dir, "config.yaml") }}
|
||||
mkdir -p .outputs
|
||||
find .outputs -mindepth 1 ! -path .outputs/config.yaml -exec rm -rf {} +
|
||||
|
||||
client:
|
||||
cd {{ client_dir }} && {{ bun_cmd }} dev
|
||||
@@ -34,5 +33,8 @@ run-back:
|
||||
test-back:
|
||||
cd {{ output_dir }} && CONFIG_PATH={{ output_dir }} GO_ENV=test go test -C .. ./...
|
||||
|
||||
dev-back: clean
|
||||
watchexec -r -e go,yaml,tpl -i '.devenv/**' -i '.direnv/**' -i 'client/**' -i 'vendor/**' 'go build -o {{ join(output_dir, "nixcn-cms") }} . && cd {{ output_dir }} && CONFIG_PATH={{ output_dir }} {{ exec_path }}'
|
||||
|
||||
dev:
|
||||
devenv up --verbose
|
||||
|
||||
@@ -1,57 +1,30 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"nixcn-cms/internal/cryptography"
|
||||
"nixcn-cms/pkgs/authtoken"
|
||||
"nixcn-cms/utils"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
func JWTAuth() gin.HandlerFunc {
|
||||
jwtSecret := []byte(viper.GetString("secrets.jwt_secret"))
|
||||
func JWTAuth(required bool) gin.HandlerFunc {
|
||||
|
||||
return func(c *gin.Context) {
|
||||
auth := c.GetHeader("Authorization")
|
||||
if auth == "" {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
|
||||
"error": "missing Authorization header",
|
||||
})
|
||||
|
||||
authtoken := new(authtoken.Token)
|
||||
uid, err := authtoken.HeaderVerify(auth)
|
||||
if err != nil {
|
||||
utils.HttpAbort(c, 401, "", "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
// Split header to 2
|
||||
parts := strings.SplitN(auth, " ", 2)
|
||||
if len(parts) != 2 || parts[0] != "Bearer" {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
|
||||
"error": "invalid Authorization header format",
|
||||
})
|
||||
if required == true && uid == "" {
|
||||
utils.HttpAbort(c, 401, "", "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
tokenStr := parts[1]
|
||||
|
||||
// Verify access token
|
||||
claims := &cryptography.JwtClaims{}
|
||||
token, err := jwt.ParseWithClaims(
|
||||
tokenStr,
|
||||
claims,
|
||||
func(token *jwt.Token) (any, error) {
|
||||
return jwtSecret, nil
|
||||
},
|
||||
)
|
||||
|
||||
if err != nil || !token.Valid {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
|
||||
"error": "invalid or expired token",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.Set("user_id", claims.UserID)
|
||||
c.Set("user_id", uid)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
47
middleware/permission.go
Normal file
47
middleware/permission.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"nixcn-cms/data"
|
||||
"nixcn-cms/utils"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func Permission(requiredLevel uint) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var permissionLevel uint
|
||||
permissionLevelPrev, ok := c.Get("permission_level")
|
||||
if !ok {
|
||||
userIdOrig, ok := c.Get("user_id")
|
||||
if !ok || userIdOrig.(string) == "" {
|
||||
utils.HttpAbort(c, 401, "", "missing user id")
|
||||
return
|
||||
}
|
||||
|
||||
userId, err := uuid.Parse(userIdOrig.(string))
|
||||
if err != nil {
|
||||
utils.HttpAbort(c, 500, "", "error parsing user id")
|
||||
return
|
||||
}
|
||||
|
||||
userData, err := new(data.User).GetByUserId(userId)
|
||||
if err != nil {
|
||||
utils.HttpAbort(c, 404, "", "user not found")
|
||||
return
|
||||
}
|
||||
|
||||
permissionLevel = userData.PermissionLevel
|
||||
c.Set("permission_level", userData.PermissionLevel)
|
||||
} else {
|
||||
permissionLevel = permissionLevelPrev.(uint)
|
||||
}
|
||||
|
||||
if permissionLevel < requiredLevel {
|
||||
utils.HttpAbort(c, 403, "", "permission denied")
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
68
pkgs/authcode/authcode.go
Normal file
68
pkgs/authcode/authcode.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package authcode
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"nixcn-cms/data"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
type Token struct {
|
||||
ClientId string
|
||||
Email string
|
||||
}
|
||||
|
||||
func NewAuthCode(clientId string, email string) (string, error) {
|
||||
ctx := context.Background()
|
||||
|
||||
// generate random code
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
code := base64.RawURLEncoding.EncodeToString(b)
|
||||
key := "auth_code:" + code
|
||||
|
||||
ttl := viper.GetDuration("ttl.auth_code_ttl")
|
||||
|
||||
// store auth code metadata in Redis
|
||||
if err := data.Redis.HSet(
|
||||
ctx,
|
||||
key,
|
||||
map[string]any{
|
||||
"client_id": clientId,
|
||||
"email": email,
|
||||
},
|
||||
).Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// set expiration (one-time auth code)
|
||||
if err := data.Redis.Expire(ctx, key, ttl).Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return code, nil
|
||||
}
|
||||
|
||||
func VerifyAuthCode(code string) (*Token, bool) {
|
||||
ctx := context.Background()
|
||||
key := "auth_code:" + code
|
||||
|
||||
// Read auth code payload
|
||||
dataMap, err := data.Redis.HGetAll(ctx, key).Result()
|
||||
if err != nil || len(dataMap) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// Delete auth code immediately (one-time use)
|
||||
_ = data.Redis.Del(ctx, key).Err()
|
||||
|
||||
return &Token{
|
||||
ClientId: dataMap["client_id"],
|
||||
Email: dataMap["email"],
|
||||
}, true
|
||||
}
|
||||
295
pkgs/authtoken/authtoken.go
Normal file
295
pkgs/authtoken/authtoken.go
Normal file
@@ -0,0 +1,295 @@
|
||||
package authtoken
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"nixcn-cms/data"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/google/uuid"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
type Token struct {
|
||||
Application string
|
||||
}
|
||||
|
||||
type JwtClaims struct {
|
||||
ClientId string `json:"client_id"`
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
// Generate jwt clames
|
||||
func (self *Token) NewClaims(clientId string, userId uuid.UUID) JwtClaims {
|
||||
return JwtClaims{
|
||||
ClientId: clientId,
|
||||
UserID: userId,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(viper.GetDuration("ttl.access_ttl"))),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
Issuer: self.Application,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Generate access token
|
||||
func (self *Token) GenerateAccessToken(clientId string, userId uuid.UUID) (string, error) {
|
||||
claims := self.NewClaims(clientId, userId)
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
|
||||
clientData, err := new(data.Client).GetClientByClientId(clientId)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error getting client data: %v", err)
|
||||
}
|
||||
|
||||
secret, err := clientData.GetDecryptedSecret()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error getting decrypted secret: %v", err)
|
||||
}
|
||||
|
||||
signedToken, err := token.SignedString([]byte(secret))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error signing token: %v", err)
|
||||
}
|
||||
return signedToken, nil
|
||||
}
|
||||
|
||||
// Generate refresh token
|
||||
func (self *Token) GenerateRefreshToken() (string, error) {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.URLEncoding.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
// Issue both access and refresh token
|
||||
func (self *Token) IssueTokens(clientId string, userId uuid.UUID) (string, string, error) {
|
||||
// access token
|
||||
access, err := self.GenerateAccessToken(clientId, userId)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
// refresh token
|
||||
refresh, err := self.GenerateRefreshToken()
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
ttl := viper.GetDuration("ttl.refresh_ttl")
|
||||
|
||||
refreshKey := "refresh:" + refresh
|
||||
|
||||
// refresh -> user + client
|
||||
if err := data.Redis.HSet(
|
||||
ctx,
|
||||
refreshKey,
|
||||
map[string]any{
|
||||
"user_id": userId.String(),
|
||||
"client_id": clientId,
|
||||
},
|
||||
).Err(); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
if err := data.Redis.Expire(ctx, refreshKey, ttl).Err(); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
// user -> refresh tokens
|
||||
userSetKey := "user:" + userId.String() + ":refresh_tokens"
|
||||
|
||||
if err := data.Redis.SAdd(ctx, userSetKey, refresh).Err(); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
_ = data.Redis.Expire(ctx, userSetKey, ttl).Err()
|
||||
|
||||
// client -> refresh tokens
|
||||
clientSetKey := "client:" + clientId + ":refresh_tokens"
|
||||
_ = data.Redis.SAdd(ctx, clientSetKey, refresh).Err()
|
||||
_ = data.Redis.Expire(ctx, clientSetKey, ttl).Err()
|
||||
|
||||
return access, refresh, nil
|
||||
}
|
||||
|
||||
// Refresh access token
|
||||
func (self *Token) RefreshAccessToken(refreshToken string) (string, error) {
|
||||
ctx := context.Background()
|
||||
key := "refresh:" + refreshToken
|
||||
|
||||
// read refresh token bind data
|
||||
dataMap, err := data.Redis.HGetAll(ctx, key).Result()
|
||||
if err != nil || len(dataMap) == 0 {
|
||||
return "", errors.New("invalid refresh token")
|
||||
}
|
||||
|
||||
userIdStr := dataMap["user_id"]
|
||||
clientId := dataMap["client_id"]
|
||||
|
||||
if userIdStr == "" || clientId == "" {
|
||||
return "", errors.New("refresh token corrupted")
|
||||
}
|
||||
|
||||
userId, err := uuid.Parse(userIdStr)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Generate new access token
|
||||
return self.GenerateAccessToken(clientId, userId)
|
||||
}
|
||||
|
||||
func (self *Token) RenewRefreshToken(refreshToken string) (string, error) {
|
||||
ctx := context.Background()
|
||||
ttl := viper.GetDuration("ttl.refresh_ttl")
|
||||
|
||||
oldKey := "refresh:" + refreshToken
|
||||
|
||||
// read old refresh token bind data
|
||||
dataMap, err := data.Redis.HGetAll(ctx, oldKey).Result()
|
||||
if err != nil || len(dataMap) == 0 {
|
||||
return "", errors.New("invalid refresh token")
|
||||
}
|
||||
|
||||
userIdStr := dataMap["user_id"]
|
||||
clientId := dataMap["client_id"]
|
||||
|
||||
if userIdStr == "" || clientId == "" {
|
||||
return "", errors.New("refresh token corrupted")
|
||||
}
|
||||
|
||||
// generate new refresh token
|
||||
newRefresh, err := self.GenerateRefreshToken()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// revoke old refresh token
|
||||
if err := self.RevokeRefreshToken(refreshToken); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
newKey := "refresh:" + newRefresh
|
||||
|
||||
// refresh -> user + client
|
||||
if err := data.Redis.HSet(
|
||||
ctx,
|
||||
newKey,
|
||||
map[string]any{
|
||||
"user_id": userIdStr,
|
||||
"client_id": clientId,
|
||||
},
|
||||
).Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if err := data.Redis.Expire(ctx, newKey, ttl).Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// user -> refresh tokens
|
||||
userSetKey := "user:" + userIdStr + ":refresh_tokens"
|
||||
if err := data.Redis.SAdd(ctx, userSetKey, newRefresh).Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
_ = data.Redis.Expire(ctx, userSetKey, ttl).Err()
|
||||
|
||||
// client -> refresh tokens
|
||||
clientSetKey := "client:" + clientId + ":refresh_tokens"
|
||||
_ = data.Redis.SAdd(ctx, clientSetKey, newRefresh).Err()
|
||||
_ = data.Redis.Expire(ctx, clientSetKey, ttl).Err()
|
||||
|
||||
return newRefresh, nil
|
||||
}
|
||||
|
||||
func (self *Token) RevokeRefreshToken(refreshToken string) error {
|
||||
ctx := context.Background()
|
||||
|
||||
refreshKey := "refresh:" + refreshToken
|
||||
|
||||
// read refresh token metadata (user_id, client_id)
|
||||
dataMap, err := data.Redis.HGetAll(ctx, refreshKey).Result()
|
||||
if err != nil || len(dataMap) == 0 {
|
||||
// Token already revoked or not found
|
||||
return nil
|
||||
}
|
||||
|
||||
userID := dataMap["user_id"]
|
||||
clientID := dataMap["client_id"]
|
||||
|
||||
// build index keys
|
||||
userSetKey := "user:" + userID + ":refresh_tokens"
|
||||
clientSetKey := "client:" + clientID + ":refresh_tokens"
|
||||
|
||||
// remove refresh token and all related indexes atomically
|
||||
pipe := data.Redis.TxPipeline()
|
||||
|
||||
// remove main refresh token record
|
||||
pipe.Del(ctx, refreshKey)
|
||||
|
||||
// remove refresh token from user's active refresh token set
|
||||
pipe.SRem(ctx, userSetKey, refreshToken)
|
||||
|
||||
// remove refresh token from client's active refresh token set
|
||||
pipe.SRem(ctx, clientSetKey, refreshToken)
|
||||
|
||||
_, err = pipe.Exec(ctx)
|
||||
return err
|
||||
}
|
||||
|
||||
func (self *Token) HeaderVerify(header string) (string, error) {
|
||||
if header == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// Split header to 2
|
||||
parts := strings.SplitN(header, " ", 2)
|
||||
if len(parts) != 2 || parts[0] != "Bearer" {
|
||||
return "", errors.New("invalid Authorization header format")
|
||||
}
|
||||
|
||||
tokenStr := parts[1]
|
||||
|
||||
// Verify access token
|
||||
claims := &JwtClaims{}
|
||||
token, err := jwt.ParseWithClaims(
|
||||
tokenStr,
|
||||
claims,
|
||||
func(token *jwt.Token) (any, error) {
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, errors.New("unexpected signing method")
|
||||
}
|
||||
|
||||
if claims.ClientId == "" {
|
||||
return nil, errors.New("client_id missing in token")
|
||||
}
|
||||
|
||||
clientData, err := new(data.Client).GetClientByClientId(claims.ClientId)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error getting client data: %v", err)
|
||||
}
|
||||
|
||||
secret, err := clientData.GetDecryptedSecret()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error getting decrypted secret: %v", err)
|
||||
}
|
||||
|
||||
return secret, nil
|
||||
},
|
||||
)
|
||||
|
||||
if err != nil || !token.Valid {
|
||||
return "", errors.New("invalid or expired token")
|
||||
}
|
||||
|
||||
return claims.UserID.String(), nil
|
||||
}
|
||||
313
pkgs/email/email.go
Normal file
313
pkgs/email/email.go
Normal file
@@ -0,0 +1,313 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/smtp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
"golang.org/x/oauth2"
|
||||
"golang.org/x/oauth2/clientcredentials"
|
||||
gomail "gopkg.in/gomail.v2"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
// basic smtp
|
||||
dialer *gomail.Dialer
|
||||
|
||||
// shared
|
||||
from string
|
||||
host string
|
||||
port int
|
||||
username string
|
||||
|
||||
security string
|
||||
insecure bool
|
||||
|
||||
// auth mode
|
||||
authMode string
|
||||
|
||||
// oauth2
|
||||
oauth *oauthTokenProvider
|
||||
}
|
||||
|
||||
type oauthTokenProvider struct {
|
||||
cfg clientcredentials.Config
|
||||
|
||||
mu sync.Mutex
|
||||
token *oauth2.Token
|
||||
fetchErr error
|
||||
}
|
||||
|
||||
func (p *oauthTokenProvider) getToken(ctx context.Context) (string, error) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
if p.token != nil && p.token.Valid() && time.Until(p.token.Expiry) > 60*time.Second {
|
||||
return p.token.AccessToken, nil
|
||||
}
|
||||
|
||||
tok, err := p.cfg.Token(ctx)
|
||||
if err != nil {
|
||||
p.fetchErr = err
|
||||
return "", err
|
||||
}
|
||||
p.token = tok
|
||||
p.fetchErr = nil
|
||||
return tok.AccessToken, nil
|
||||
}
|
||||
|
||||
func NewSMTPClient() (*Client, error) {
|
||||
host := viper.GetString("email.host")
|
||||
port := viper.GetInt("email.port")
|
||||
user := viper.GetString("email.username")
|
||||
pass := viper.GetString("email.password")
|
||||
from := viper.GetString("email.from")
|
||||
|
||||
security := strings.ToLower(viper.GetString("email.security"))
|
||||
insecure := viper.GetBool("email.insecure_skip_verify")
|
||||
|
||||
authMode := strings.ToLower(viper.GetString("email.auth"))
|
||||
if authMode == "" {
|
||||
authMode = "basic"
|
||||
}
|
||||
|
||||
if host == "" || port == 0 || user == "" {
|
||||
return nil, errors.New("SMTP config not set")
|
||||
}
|
||||
|
||||
c := &Client{
|
||||
from: from,
|
||||
host: host,
|
||||
port: port,
|
||||
username: user,
|
||||
security: security,
|
||||
insecure: insecure,
|
||||
authMode: authMode,
|
||||
}
|
||||
|
||||
switch authMode {
|
||||
case "basic":
|
||||
if pass == "" {
|
||||
return nil, errors.New("SMTP basic auth requires email.password")
|
||||
}
|
||||
|
||||
dialer := gomail.NewDialer(host, port, user, pass)
|
||||
dialer.TLSConfig = &tls.Config{
|
||||
ServerName: host,
|
||||
InsecureSkipVerify: insecure,
|
||||
}
|
||||
|
||||
switch security {
|
||||
case "ssl":
|
||||
dialer.SSL = true
|
||||
case "starttls":
|
||||
dialer.SSL = false
|
||||
case "plain", "":
|
||||
dialer.SSL = false
|
||||
dialer.TLSConfig = nil
|
||||
default:
|
||||
return nil, errors.New("unknown smtp security mode: " + security)
|
||||
}
|
||||
|
||||
c.dialer = dialer
|
||||
return c, nil
|
||||
|
||||
case "oauth2":
|
||||
if security == "" {
|
||||
security = "starttls"
|
||||
c.security = "starttls"
|
||||
}
|
||||
if security == "plain" {
|
||||
return nil, errors.New("oauth2 requires TLS (starttls or ssl); plain is not allowed")
|
||||
}
|
||||
|
||||
tenantID := viper.GetString("email.oauth2.tenant_id")
|
||||
clientID := viper.GetString("email.oauth2.client_id")
|
||||
clientSecret := viper.GetString("email.oauth2.client_secret")
|
||||
scope := viper.GetString("email.oauth2.scope")
|
||||
if scope == "" {
|
||||
// Microsoft Learn: client credentials for SMTP uses https://outlook.office365.com/.default :contentReference[oaicite:3]{index=3}
|
||||
scope = "https://outlook.office365.com/.default"
|
||||
}
|
||||
|
||||
if tenantID == "" || clientID == "" || clientSecret == "" {
|
||||
return nil, errors.New("oauth2 requires email.oauth2.tenant_id/client_id/client_secret")
|
||||
}
|
||||
|
||||
c.oauth = &oauthTokenProvider{
|
||||
cfg: clientcredentials.Config{
|
||||
ClientID: clientID,
|
||||
ClientSecret: clientSecret,
|
||||
TokenURL: fmt.Sprintf("https://login.microsoftonline.com/%s/oauth2/v2.0/token", tenantID),
|
||||
Scopes: []string{scope},
|
||||
},
|
||||
}
|
||||
return c, nil
|
||||
|
||||
default:
|
||||
return nil, errors.New("unknown email.auth: " + authMode)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) Send(to, subject, html string) (string, error) {
|
||||
m := gomail.NewMessage()
|
||||
m.SetHeader("From", c.from)
|
||||
m.SetHeader("To", to)
|
||||
m.SetHeader("Subject", subject)
|
||||
m.SetBody("text/html", html)
|
||||
|
||||
switch c.authMode {
|
||||
case "basic":
|
||||
if c.dialer == nil {
|
||||
return "", errors.New("basic dialer not initialized")
|
||||
}
|
||||
if err := c.dialer.DialAndSend(m); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return time.Now().Format(time.RFC3339Nano), nil
|
||||
|
||||
case "oauth2":
|
||||
if err := c.sendWithXOAUTH2(m, to); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return time.Now().Format(time.RFC3339Nano), nil
|
||||
|
||||
default:
|
||||
return "", errors.New("unsupported auth mode: " + c.authMode)
|
||||
}
|
||||
}
|
||||
|
||||
// XOAUTH2 auth for net/smtp
|
||||
type xoauth2Auth struct {
|
||||
username string
|
||||
token string
|
||||
}
|
||||
|
||||
func (a *xoauth2Auth) Start(server *smtp.ServerInfo) (string, []byte, error) {
|
||||
if !server.TLS {
|
||||
return "", nil, errors.New("refusing to authenticate over insecure connection")
|
||||
}
|
||||
|
||||
// Microsoft Learn XOAUTH2 Format: user=<user>\x01auth=Bearer <token>\x01\x01 :contentReference[oaicite:4]{index=4}
|
||||
resp := fmt.Sprintf("user=%s\x01auth=Bearer %s\x01\x01", a.username, a.token)
|
||||
return "XOAUTH2", []byte(resp), nil
|
||||
}
|
||||
|
||||
func (a *xoauth2Auth) Next(fromServer []byte, more bool) ([]byte, error) {
|
||||
if more {
|
||||
return nil, errors.New("unexpected server challenge during XOAUTH2 auth")
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (c *Client) sendWithXOAUTH2(m *gomail.Message, rcpt string) error {
|
||||
if c.oauth == nil {
|
||||
return errors.New("oauth2 provider not initialized")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
|
||||
token, err := c.oauth.getToken(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("oauth2 token error: %w", err)
|
||||
}
|
||||
|
||||
// write gomail.Message to RFC822
|
||||
var buf bytes.Buffer
|
||||
if _, err := m.WriteTo(&buf); err != nil {
|
||||
return err
|
||||
}
|
||||
msg := buf.Bytes()
|
||||
|
||||
addr := fmt.Sprintf("%s:%d", c.host, c.port)
|
||||
tlsCfg := &tls.Config{
|
||||
ServerName: c.host,
|
||||
InsecureSkipVerify: c.insecure,
|
||||
}
|
||||
|
||||
var (
|
||||
conn net.Conn
|
||||
cl *smtp.Client
|
||||
)
|
||||
|
||||
switch c.security {
|
||||
case "ssl":
|
||||
conn, err = tls.Dial("tcp", addr, tlsCfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cl, err = smtp.NewClient(conn, c.host)
|
||||
if err != nil {
|
||||
_ = conn.Close()
|
||||
return err
|
||||
}
|
||||
|
||||
case "starttls", "":
|
||||
conn, err = net.Dial("tcp", addr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cl, err = smtp.NewClient(conn, c.host)
|
||||
if err != nil {
|
||||
_ = conn.Close()
|
||||
return err
|
||||
}
|
||||
|
||||
// Upgrade with STARTTLS
|
||||
if ok, _ := cl.Extension("STARTTLS"); ok {
|
||||
if err := cl.StartTLS(tlsCfg); err != nil {
|
||||
_ = cl.Close()
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
_ = cl.Close()
|
||||
return errors.New("server does not support STARTTLS")
|
||||
}
|
||||
|
||||
default:
|
||||
return errors.New("unknown smtp security mode: " + c.security)
|
||||
}
|
||||
|
||||
defer func() { _ = cl.Quit() }()
|
||||
|
||||
// AUTH XOAUTH2
|
||||
if err := cl.Auth(&xoauth2Auth{username: c.username, token: token}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// MAIL FROM / RCPT TO / DATA
|
||||
if err := cl.Mail(extractAddress(c.from)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := cl.Rcpt(rcpt); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
w, err := cl.Data()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := w.Write(msg); err != nil {
|
||||
_ = w.Close()
|
||||
return err
|
||||
}
|
||||
return w.Close()
|
||||
}
|
||||
|
||||
func extractAddress(from string) string {
|
||||
if i := strings.LastIndex(from, "<"); i >= 0 {
|
||||
if j := strings.LastIndex(from, ">"); j > i {
|
||||
return strings.TrimSpace(from[i+1 : j])
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(from)
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
apiKey string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
// Resend service client
|
||||
func NewResendClient() (*Client, error) {
|
||||
key := viper.GetString("email.resend_api_key")
|
||||
if key == "" {
|
||||
return nil, errors.New("RESEND_API_KEY not set")
|
||||
}
|
||||
|
||||
return &Client{
|
||||
apiKey: key,
|
||||
http: &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
type sendEmailRequest struct {
|
||||
From string `json:"from"`
|
||||
To []string `json:"to"`
|
||||
Subject string `json:"subject"`
|
||||
HTML string `json:"html,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
}
|
||||
|
||||
type sendEmailResponse struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
// Send email by resend API
|
||||
func (c *Client) Send(to, subject, html string) (string, error) {
|
||||
reqBody := sendEmailRequest{
|
||||
From: viper.GetString("email.from"),
|
||||
To: []string{to},
|
||||
Subject: subject,
|
||||
HTML: html,
|
||||
}
|
||||
|
||||
body, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(
|
||||
http.MethodPost,
|
||||
"https://api.resend.com/emails",
|
||||
bytes.NewReader(body),
|
||||
)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+c.apiKey)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 300 {
|
||||
return "", errors.New("resend send failed")
|
||||
}
|
||||
|
||||
var res sendEmailResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&res); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return res.ID, nil
|
||||
}
|
||||
168
pkgs/kyc/kyc.go
Normal file
168
pkgs/kyc/kyc.go
Normal file
@@ -0,0 +1,168 @@
|
||||
package kyc
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"nixcn-cms/internal/cryptography"
|
||||
"unicode/utf8"
|
||||
|
||||
alicloudauth20190307 "github.com/alibabacloud-go/cloudauth-20190307/v4/client"
|
||||
aliopenapi "github.com/alibabacloud-go/darabonba-openapi/v2/client"
|
||||
aliutil "github.com/alibabacloud-go/tea-utils/v2/service"
|
||||
alitea "github.com/alibabacloud-go/tea/tea"
|
||||
alicredential "github.com/aliyun/credentials-go/credentials"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
func DecodeB64Json(b64Json string) (*KycInfo, error) {
|
||||
rawJson, err := base64.StdEncoding.DecodeString(b64Json)
|
||||
if err != nil {
|
||||
return nil, errors.New("invalid base64 json")
|
||||
}
|
||||
|
||||
var kyc KycInfo
|
||||
if err := json.Unmarshal(rawJson, &kyc); err != nil {
|
||||
return nil, errors.New("invalid json structure")
|
||||
}
|
||||
|
||||
return &kyc, nil
|
||||
}
|
||||
|
||||
func EncodeAES(kyc *KycInfo) (*string, error) {
|
||||
plainJson, err := json.Marshal(kyc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
aesKey := viper.GetString("secrets.kyc_info_key")
|
||||
encrypted, err := cryptography.AESCBCEncrypt(plainJson, []byte(aesKey))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &encrypted, nil
|
||||
}
|
||||
|
||||
func DecodeAES(cipherStr string) (*KycInfo, error) {
|
||||
aesKey := viper.GetString("secrets.kyc_info_key")
|
||||
plainBytes, err := cryptography.AESCBCDecrypt(cipherStr, []byte(aesKey))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var kyc KycInfo
|
||||
if err := json.Unmarshal(plainBytes, &kyc); err != nil {
|
||||
return nil, errors.New("invalid decrypted json")
|
||||
}
|
||||
|
||||
return &kyc, nil
|
||||
}
|
||||
|
||||
func MD5AliEnc(kyc *KycInfo) (*KycAli, error) {
|
||||
if kyc.Type != "Chinese" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// MD5 Legal Name rule: First Chinese char md5enc, remaining plain, at least 2 Chinese chars
|
||||
if len(kyc.LegalName) < 2 || utf8.RuneCountInString(kyc.LegalName) < 2 {
|
||||
return nil, fmt.Errorf("input string must have at least 2 Chinese characters")
|
||||
}
|
||||
|
||||
lnFirstRune, size := utf8.DecodeRuneInString(kyc.LegalName)
|
||||
if lnFirstRune == utf8.RuneError {
|
||||
return nil, fmt.Errorf("invalid first character")
|
||||
}
|
||||
|
||||
lnHash := md5.New()
|
||||
lnHash.Write([]byte(string(lnFirstRune)))
|
||||
lnFirstHash := hex.EncodeToString(lnHash.Sum(nil))
|
||||
|
||||
lnRemaining := kyc.LegalName[size:]
|
||||
|
||||
ln := lnFirstHash + lnRemaining
|
||||
|
||||
// MD5 Resident Id rule: First 6 char plain, middle birthdate md5enc, last 4 char plain, at least 18 chars
|
||||
if len(kyc.ResidentId) < 18 {
|
||||
return nil, fmt.Errorf("input string must have at least 18 characters")
|
||||
}
|
||||
|
||||
ridPrefix := kyc.ResidentId[:6]
|
||||
ridSuffix := kyc.ResidentId[len(kyc.ResidentId)-4:]
|
||||
ridMiddle := kyc.ResidentId[6 : len(kyc.ResidentId)-4]
|
||||
|
||||
ridHash := md5.New()
|
||||
ridHash.Write([]byte(ridMiddle))
|
||||
ridMiddleHash := hex.EncodeToString(ridHash.Sum(nil))
|
||||
|
||||
rid := ridPrefix + ridMiddleHash + ridSuffix
|
||||
|
||||
// Aliyun Id2MetaVerify API Params
|
||||
var kycAli KycAli
|
||||
kycAli.ParamType = "md5"
|
||||
kycAli.UserName = ln
|
||||
kycAli.IdentifyNum = rid
|
||||
|
||||
return &kycAli, nil
|
||||
}
|
||||
|
||||
func AliId2MetaVerify(kycAli *KycAli) (*string, error) {
|
||||
// Create aliyun openapi credential
|
||||
credentialConfig := new(alicredential.Config).
|
||||
SetType("access_key").
|
||||
SetAccessKeyId(viper.GetString("kyc.ali_access_key_id")).
|
||||
SetAccessKeySecret(viper.GetString("kyc.ali_access_key_secret"))
|
||||
credential, err := alicredential.NewCredential(credentialConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Create aliyun cloudauth client
|
||||
config := &aliopenapi.Config{
|
||||
Credential: credential,
|
||||
}
|
||||
config.Endpoint = alitea.String("cloudauth.aliyuncs.com")
|
||||
client := &alicloudauth20190307.Client{}
|
||||
client, err = alicloudauth20190307.NewClient(config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Create Id2MetaVerify request
|
||||
id2MetaVerifyRequest := &alicloudauth20190307.Id2MetaVerifyRequest{
|
||||
ParamType: &kycAli.ParamType,
|
||||
UserName: &kycAli.UserName,
|
||||
IdentifyNum: &kycAli.IdentifyNum,
|
||||
}
|
||||
|
||||
// Create client runtime request
|
||||
runtime := &aliutil.RuntimeOptions{}
|
||||
resp, tryErr := func() (*alicloudauth20190307.Id2MetaVerifyResponse, error) {
|
||||
defer func() {
|
||||
if r := alitea.Recover(recover()); r != nil {
|
||||
err = r
|
||||
}
|
||||
}()
|
||||
resp, err := client.Id2MetaVerifyWithOptions(id2MetaVerifyRequest, runtime)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resp, nil
|
||||
}()
|
||||
|
||||
// Try error handler ??? from ali generated sdk
|
||||
if tryErr != nil {
|
||||
var error = &alitea.SDKError{}
|
||||
if t, ok := tryErr.(*alitea.SDKError); ok {
|
||||
error = t
|
||||
} else {
|
||||
error.Message = alitea.String(tryErr.Error())
|
||||
}
|
||||
return nil, error
|
||||
}
|
||||
|
||||
return resp.Body.ResultObject.BizCode, err
|
||||
}
|
||||
13
pkgs/kyc/types.go
Normal file
13
pkgs/kyc/types.go
Normal file
@@ -0,0 +1,13 @@
|
||||
package kyc
|
||||
|
||||
type KycInfo struct {
|
||||
Type string `json:"type"` // Chinese / Foreigner
|
||||
LegalName string `json:"legal_name"`
|
||||
ResidentId string `json:"rsident_id"`
|
||||
}
|
||||
|
||||
type KycAli struct {
|
||||
ParamType string `json:"param_type"`
|
||||
IdentifyNum string `json:"identify_num"`
|
||||
UserName string `json:"user_name"`
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
package magiclink
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
type Token struct {
|
||||
Email string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
var (
|
||||
store = sync.Map{}
|
||||
)
|
||||
|
||||
// Generate magic token
|
||||
func NewMagicToken(email string) (string, error) {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
token := base64.RawURLEncoding.EncodeToString(b)
|
||||
|
||||
store.Store(token, Token{
|
||||
Email: email,
|
||||
ExpiresAt: time.Now().Add(viper.GetDuration("ttl.magic_link_ttl")),
|
||||
})
|
||||
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// Verify magic token
|
||||
func VerifyMagicToken(token string) (string, bool) {
|
||||
val, ok := store.Load(token)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
|
||||
t := val.(Token)
|
||||
if time.Now().After(t.ExpiresAt) {
|
||||
store.Delete(token)
|
||||
return "", false
|
||||
}
|
||||
|
||||
store.Delete(token)
|
||||
return t.Email, true
|
||||
}
|
||||
@@ -1,9 +1,14 @@
|
||||
package auth
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
import (
|
||||
"nixcn-cms/middleware"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func Handler(r *gin.RouterGroup) {
|
||||
r.POST("/magic", RequestMagicLink)
|
||||
r.GET("/magic/verify", VerifyMagicLink)
|
||||
r.GET("/redirect", Redirect, middleware.JWTAuth(false))
|
||||
r.POST("/magic", Magic)
|
||||
r.POST("/token", Token)
|
||||
r.POST("/refresh", Refresh)
|
||||
}
|
||||
|
||||
@@ -1,116 +1,81 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"nixcn-cms/data"
|
||||
"nixcn-cms/internal/cryptography"
|
||||
"net/url"
|
||||
"nixcn-cms/pkgs/authcode"
|
||||
"nixcn-cms/pkgs/email"
|
||||
"nixcn-cms/pkgs/magiclink"
|
||||
"nixcn-cms/pkgs/turnstile"
|
||||
|
||||
"github.com/google/uuid"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"gorm.io/gorm"
|
||||
"nixcn-cms/utils"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
type MagicLinkRequest struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
TurnstileToken string `json:"turnstile_token" binding:"required"`
|
||||
type MagicRequest struct {
|
||||
ClientId string `json:"client_id"`
|
||||
RedirectUri string `json:"redirect_uri"`
|
||||
State string `json:"state"`
|
||||
Email string `json:"email"`
|
||||
TurnstileToken string `json:"turnstile_token"`
|
||||
}
|
||||
|
||||
func RequestMagicLink(c *gin.Context) {
|
||||
func Magic(c *gin.Context) {
|
||||
// Parse request
|
||||
var req MagicLinkRequest
|
||||
var req MagicRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(400, gin.H{"error": "invalid request"})
|
||||
utils.HttpResponse(c, 400, "", "invalid request")
|
||||
return
|
||||
}
|
||||
|
||||
// Cloudflare turnstile
|
||||
ok, err := turnstile.VerifyTurnstile(req.TurnstileToken, c.ClientIP())
|
||||
if err != nil || !ok {
|
||||
c.JSON(403, gin.H{"error": "turnstile failed"})
|
||||
utils.HttpResponse(c, 403, "", "turnstile failed")
|
||||
return
|
||||
}
|
||||
|
||||
// Generate magic token
|
||||
token, err := magiclink.NewMagicToken(req.Email)
|
||||
code, err := authcode.NewAuthCode(req.ClientId, req.Email)
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"error": "internal error"})
|
||||
utils.HttpResponse(c, 500, "", "code gen failed")
|
||||
return
|
||||
}
|
||||
|
||||
link := viper.GetString("server.external_url") + "/login?ticket=" + token
|
||||
|
||||
// Send email using resend
|
||||
resend, err := email.NewResendClient()
|
||||
externalUrl := viper.GetString("server.external_url")
|
||||
url, err := url.Parse(externalUrl)
|
||||
if err != nil {
|
||||
log.Error(err)
|
||||
c.JSON(500, gin.H{"status": "invilad email config"})
|
||||
return
|
||||
}
|
||||
resend.Send(
|
||||
req.Email,
|
||||
"NixCN CMS Email Verify",
|
||||
"<p>Click the link below to verify your email. This link will expire in 10 minutes.</p><a href="+link+">"+link+"</a>",
|
||||
)
|
||||
|
||||
c.JSON(200, gin.H{"status": "magic link sent"})
|
||||
}
|
||||
|
||||
func VerifyMagicLink(c *gin.Context) {
|
||||
// Get token from url
|
||||
magicToken := c.Query("token")
|
||||
if magicToken == "" {
|
||||
c.JSON(400, gin.H{"error": "missing token"})
|
||||
utils.HttpResponse(c, 500, "", "invalid external url")
|
||||
return
|
||||
}
|
||||
|
||||
// Verify email token
|
||||
email, ok := magiclink.VerifyMagicToken(magicToken)
|
||||
if !ok {
|
||||
c.JSON(401, gin.H{"error": "invalid or expired token"})
|
||||
url.Path = "/api/v1/auth/redirect"
|
||||
query := url.Query()
|
||||
query.Set("code", code)
|
||||
query.Set("redirect_uri", req.RedirectUri)
|
||||
query.Set("state", req.State)
|
||||
query.Set("client_id", req.ClientId)
|
||||
url.RawQuery = query.Encode()
|
||||
|
||||
debugMode := viper.GetBool("server.debug_mode")
|
||||
if debugMode {
|
||||
uriData := struct {
|
||||
Uri string `json:"uri"`
|
||||
}{url.String()}
|
||||
|
||||
utils.HttpResponse(c, 200, "", "magiclink sent", uriData)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify if user exists
|
||||
user := new(data.User)
|
||||
err := user.GetByEmail(email)
|
||||
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
// Create user
|
||||
user.UUID = uuid.New()
|
||||
user.UserId = uuid.New()
|
||||
user.Email = email
|
||||
user.Type = "Normal"
|
||||
user.PermissionLevel = 10
|
||||
if err := user.Create(); err != nil {
|
||||
c.JSON(500, gin.H{"status": "internal server error"})
|
||||
return
|
||||
}
|
||||
} else {
|
||||
c.JSON(500, gin.H{"status": "internal server error"})
|
||||
} else {
|
||||
// Send email using resend
|
||||
emailClient, err := email.NewSMTPClient()
|
||||
if err != nil {
|
||||
utils.HttpResponse(c, 500, "", "invalid email config")
|
||||
return
|
||||
}
|
||||
emailClient.Send(
|
||||
req.Email,
|
||||
"NixCN CMS Email Verify",
|
||||
"<p>Click the link below to verify your email. This link will expire in 10 minutes.</p><a href="+url.String()+">"+url.String()+"</a>",
|
||||
)
|
||||
}
|
||||
|
||||
// Generate jwt
|
||||
JwtTool := cryptography.Token{
|
||||
Application: viper.GetString("server.application"),
|
||||
}
|
||||
accessToken, refreshToken, err := JwtTool.IssueTokens(user.UserId)
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{
|
||||
"status": "error generating tokens",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(200, gin.H{
|
||||
"access_token": accessToken,
|
||||
"refresh_token": refreshToken,
|
||||
})
|
||||
utils.HttpResponse(c, 200, "", "magic link sent")
|
||||
}
|
||||
|
||||
130
service/auth/redirect.go
Normal file
130
service/auth/redirect.go
Normal file
@@ -0,0 +1,130 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"nixcn-cms/data"
|
||||
"nixcn-cms/pkgs/authcode"
|
||||
"nixcn-cms/utils"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func Redirect(c *gin.Context) {
|
||||
clientId := c.Query("client_id")
|
||||
if clientId == "" {
|
||||
utils.HttpResponse(c, 400, "", "invalid request")
|
||||
return
|
||||
}
|
||||
|
||||
redirectUri := c.Query("redirect_uri")
|
||||
if redirectUri == "" {
|
||||
utils.HttpResponse(c, 400, "", "invalid request")
|
||||
return
|
||||
}
|
||||
|
||||
state := c.Query("state")
|
||||
if state == "" {
|
||||
utils.HttpResponse(c, 400, "", "invalid request")
|
||||
return
|
||||
}
|
||||
|
||||
code := c.Query("code")
|
||||
if code == "" {
|
||||
userIdOrig, ok := c.Get("user_id")
|
||||
if !ok || userIdOrig == "" {
|
||||
utils.HttpResponse(c, 401, "", "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
userId, err := uuid.Parse(userIdOrig.(string))
|
||||
if err != nil {
|
||||
utils.HttpResponse(c, 500, "", "failed to parse uuid")
|
||||
return
|
||||
}
|
||||
|
||||
userData := new(data.User)
|
||||
user, err := userData.GetByUserId(userId)
|
||||
if err != nil {
|
||||
utils.HttpResponse(c, 500, "", "failed to get user id")
|
||||
return
|
||||
}
|
||||
|
||||
code, err := authcode.NewAuthCode(clientId, user.Email)
|
||||
if err != nil {
|
||||
utils.HttpResponse(c, 500, "", "code gen failed")
|
||||
return
|
||||
}
|
||||
|
||||
url, err := url.Parse(redirectUri)
|
||||
if err != nil {
|
||||
utils.HttpResponse(c, 400, "", "invalid redirect uri")
|
||||
return
|
||||
}
|
||||
query := url.Query()
|
||||
query.Set("code", code)
|
||||
url.RawQuery = query.Encode()
|
||||
|
||||
c.Redirect(302, url.String())
|
||||
}
|
||||
|
||||
// Verify email token
|
||||
authCode, ok := authcode.VerifyAuthCode(code)
|
||||
if !ok {
|
||||
utils.HttpResponse(c, 403, "", "invalid or expired token")
|
||||
return
|
||||
}
|
||||
|
||||
// Verify if user exists
|
||||
userData := new(data.User)
|
||||
user, err := userData.GetByEmail(authCode.Email)
|
||||
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
// Create user
|
||||
user.UUID = uuid.New()
|
||||
user.UserId = uuid.New()
|
||||
user.Email = authCode.Email
|
||||
user.Username = user.UserId.String()
|
||||
user.PermissionLevel = 10
|
||||
if err := user.Create(); err != nil {
|
||||
utils.HttpResponse(c, 500, "", "internal server error")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
utils.HttpResponse(c, 500, "", "internal server error")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
clientData := new(data.Client)
|
||||
client, err := clientData.GetClientByClientId(clientId)
|
||||
if err != nil {
|
||||
utils.HttpResponse(c, 400, "", "client not found")
|
||||
return
|
||||
}
|
||||
|
||||
err = client.ValidateRedirectURI(redirectUri)
|
||||
if err != nil {
|
||||
utils.HttpResponse(c, 400, "", "redirect uri not match")
|
||||
return
|
||||
}
|
||||
|
||||
newCode, err := authcode.NewAuthCode(clientId, authCode.Email)
|
||||
if err != nil {
|
||||
utils.HttpResponse(c, 500, "", "internal server error")
|
||||
return
|
||||
}
|
||||
|
||||
url, err := url.Parse(redirectUri)
|
||||
if err != nil {
|
||||
utils.HttpResponse(c, 400, "", "invalid redirect uri")
|
||||
return
|
||||
}
|
||||
query := url.Query()
|
||||
query.Set("code", newCode)
|
||||
url.RawQuery = query.Encode()
|
||||
|
||||
c.Redirect(302, url.String())
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"nixcn-cms/internal/cryptography"
|
||||
"nixcn-cms/pkgs/authtoken"
|
||||
"nixcn-cms/utils"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/spf13/viper"
|
||||
@@ -13,27 +14,30 @@ func Refresh(c *gin.Context) {
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(400, gin.H{"status": "invalid request"})
|
||||
utils.HttpResponse(c, 400, "", "invalid request")
|
||||
return
|
||||
}
|
||||
|
||||
JwtTool := cryptography.Token{
|
||||
JwtTool := authtoken.Token{
|
||||
Application: viper.GetString("server.application"),
|
||||
}
|
||||
|
||||
access, err := JwtTool.RefreshAccessToken(req.RefreshToken)
|
||||
accessToken, err := JwtTool.RefreshAccessToken(req.RefreshToken)
|
||||
if err != nil {
|
||||
c.JSON(401, gin.H{"status": "invalid refresh token"})
|
||||
utils.HttpResponse(c, 401, "", "invalid refresh token")
|
||||
return
|
||||
}
|
||||
|
||||
refresh, err := JwtTool.RenewRefreshToken(req.RefreshToken)
|
||||
refreshToken, err := JwtTool.RenewRefreshToken(req.RefreshToken)
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"statis": "error renew refresh token"})
|
||||
utils.HttpResponse(c, 500, "", "error renew refresh token")
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(200, gin.H{
|
||||
"access_token": access,
|
||||
"refresh_token": refresh,
|
||||
})
|
||||
tokenResp := struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
}{accessToken, refreshToken}
|
||||
|
||||
utils.HttpResponse(c, 200, "", "success", tokenResp)
|
||||
}
|
||||
|
||||
55
service/auth/token.go
Normal file
55
service/auth/token.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"nixcn-cms/data"
|
||||
"nixcn-cms/pkgs/authcode"
|
||||
"nixcn-cms/pkgs/authtoken"
|
||||
"nixcn-cms/utils"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
type TokenRequest struct {
|
||||
Code string `json:"code"`
|
||||
}
|
||||
|
||||
func Token(c *gin.Context) {
|
||||
var req TokenRequest
|
||||
|
||||
err := c.ShouldBindJSON(&req)
|
||||
if err != nil {
|
||||
utils.HttpResponse(c, 400, "", "invalid request")
|
||||
return
|
||||
}
|
||||
|
||||
authCode, ok := authcode.VerifyAuthCode(req.Code)
|
||||
if !ok {
|
||||
utils.HttpResponse(c, 403, "", "invalid or expired token")
|
||||
return
|
||||
}
|
||||
|
||||
userData := new(data.User)
|
||||
user, err := userData.GetByEmail(authCode.Email)
|
||||
if err != nil {
|
||||
utils.HttpResponse(c, 500, "", "internal server error")
|
||||
return
|
||||
}
|
||||
|
||||
// Generate jwt
|
||||
JwtTool := authtoken.Token{
|
||||
Application: viper.GetString("server.application"),
|
||||
}
|
||||
accessToken, refreshToken, err := JwtTool.IssueTokens(authCode.ClientId, user.UserId)
|
||||
if err != nil {
|
||||
utils.HttpResponse(c, 500, "", "error generating tokens")
|
||||
return
|
||||
}
|
||||
|
||||
tokenResp := struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
}{accessToken, refreshToken}
|
||||
|
||||
utils.HttpResponse(c, 200, "", "success", tokenResp)
|
||||
}
|
||||
108
service/event/checkin.go
Normal file
108
service/event/checkin.go
Normal file
@@ -0,0 +1,108 @@
|
||||
package event
|
||||
|
||||
import (
|
||||
"nixcn-cms/data"
|
||||
"nixcn-cms/utils"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func Checkin(c *gin.Context) {
|
||||
data := new(data.Attendance)
|
||||
userIdOrig, ok := c.Get("user_id")
|
||||
if !ok {
|
||||
utils.HttpResponse(c, 403, "", "userid error")
|
||||
return
|
||||
}
|
||||
userId, err := uuid.Parse(userIdOrig.(string))
|
||||
if err != nil {
|
||||
utils.HttpResponse(c, 500, "", "failed to parse uuid")
|
||||
}
|
||||
|
||||
// Get event id from query
|
||||
eventIdOrig, ok := c.GetQuery("event_id")
|
||||
if !ok {
|
||||
utils.HttpResponse(c, 400, "", "undefinded event id")
|
||||
return
|
||||
}
|
||||
|
||||
// Parse event id to uuid
|
||||
eventId, err := uuid.Parse(eventIdOrig)
|
||||
if err != nil {
|
||||
utils.HttpResponse(c, 500, "", "error parsing string to uuid")
|
||||
return
|
||||
}
|
||||
data.UserId = userId
|
||||
code, err := data.GenCheckinCode(eventId)
|
||||
if err != nil {
|
||||
utils.HttpResponse(c, 500, "", "error generating code")
|
||||
return
|
||||
}
|
||||
|
||||
checkinCodeResp := struct {
|
||||
CheckinCode *string `json:"checkin_code"`
|
||||
}{code}
|
||||
utils.HttpResponse(c, 200, "", "success", checkinCodeResp)
|
||||
}
|
||||
|
||||
func CheckinSubmit(c *gin.Context) {
|
||||
var req struct {
|
||||
ChekinCode string `json:"checkin_code"`
|
||||
}
|
||||
c.ShouldBindJSON(&req)
|
||||
|
||||
attendanceData := new(data.Attendance)
|
||||
err := attendanceData.VerifyCheckinCode(req.ChekinCode)
|
||||
if err != nil {
|
||||
utils.HttpResponse(c, 400, "", "error verify checkin code")
|
||||
return
|
||||
}
|
||||
|
||||
utils.HttpResponse(c, 200, "", "success")
|
||||
}
|
||||
|
||||
func CheckinQuery(c *gin.Context) {
|
||||
userIdOrig, ok := c.Get("user_id")
|
||||
if !ok {
|
||||
utils.HttpResponse(c, 400, "", "userid error")
|
||||
return
|
||||
}
|
||||
userId, err := uuid.Parse(userIdOrig.(string))
|
||||
if err != nil {
|
||||
utils.HttpResponse(c, 500, "", "failed to parse uuid")
|
||||
return
|
||||
}
|
||||
|
||||
eventIdOrig, ok := c.GetQuery("event_id")
|
||||
if !ok {
|
||||
utils.HttpResponse(c, 400, "", "could not found event_id")
|
||||
return
|
||||
}
|
||||
eventId, err := uuid.Parse(eventIdOrig)
|
||||
if err != nil {
|
||||
utils.HttpResponse(c, 400, "", "event_id is not valid")
|
||||
return
|
||||
}
|
||||
|
||||
attendanceData := new(data.Attendance)
|
||||
attendance, err := attendanceData.GetAttendance(userId, eventId)
|
||||
|
||||
if err != nil {
|
||||
utils.HttpResponse(c, 500, "", "database error")
|
||||
return
|
||||
} else if attendance == nil {
|
||||
utils.HttpResponse(c, 404, "", "event checkin record not found")
|
||||
return
|
||||
} else if attendance.CheckinAt.IsZero() {
|
||||
utils.HttpResponse(c, 200, "", "success", gin.H{"checkin_at": nil})
|
||||
return
|
||||
}
|
||||
|
||||
checkInAtResp := struct {
|
||||
CheckinAt time.Time `json:"checkin_at"`
|
||||
}{attendance.CheckinAt}
|
||||
|
||||
utils.HttpResponse(c, 200, "", "success", checkInAtResp)
|
||||
}
|
||||
@@ -7,6 +7,9 @@ import (
|
||||
)
|
||||
|
||||
func Handler(r *gin.RouterGroup) {
|
||||
r.Use(middleware.JWTAuth())
|
||||
r.Use(middleware.JWTAuth(true), middleware.Permission(10))
|
||||
r.GET("/info", Info)
|
||||
r.GET("/checkin", Checkin)
|
||||
r.GET("/checkin/query", CheckinQuery)
|
||||
r.POST("/checkin/submit", CheckinSubmit, middleware.Permission(20))
|
||||
}
|
||||
|
||||
@@ -2,42 +2,39 @@ package event
|
||||
|
||||
import (
|
||||
"nixcn-cms/data"
|
||||
"nixcn-cms/utils"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func Info(c *gin.Context) {
|
||||
event := new(data.Event)
|
||||
eventData := new(data.Event)
|
||||
eventIdOrig, ok := c.GetQuery("event_id")
|
||||
if !ok {
|
||||
c.JSON(400, gin.H{
|
||||
"status": "undefinded event id",
|
||||
})
|
||||
utils.HttpResponse(c, 400, "", "undefinded event id")
|
||||
return
|
||||
}
|
||||
|
||||
// Parse event id
|
||||
eventId, err := uuid.Parse(eventIdOrig)
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{
|
||||
"status": "error parsing string to uuid",
|
||||
})
|
||||
utils.HttpResponse(c, 500, "", "error parsing string to uuid")
|
||||
return
|
||||
}
|
||||
|
||||
err = event.GetEventById(eventId)
|
||||
event, err := eventData.GetEventById(eventId)
|
||||
if err != nil {
|
||||
c.JSON(404, gin.H{
|
||||
"status": "event id not found",
|
||||
})
|
||||
utils.HttpResponse(c, 404, "", "event id not found")
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(200, gin.H{
|
||||
"name": event.Name,
|
||||
"start_time": event.StartTime,
|
||||
"end_time": event.EndTime,
|
||||
"joined_users": event.JoinedUsers,
|
||||
})
|
||||
eventInfoResp := struct {
|
||||
Name string `json:"name"`
|
||||
StartTime time.Time `json:"start_time"`
|
||||
EndTime time.Time `json:"end_time"`
|
||||
}{event.Name, event.StartTime, event.EndTime}
|
||||
|
||||
utils.HttpResponse(c, 200, "", "success", eventInfoResp)
|
||||
}
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"nixcn-cms/data"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func Checkin(c *gin.Context) {
|
||||
data := new(data.User)
|
||||
userId, ok := c.Get("user_id")
|
||||
if !ok {
|
||||
c.JSON(401, gin.H{
|
||||
"status": "unauthorized",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Get event id from query
|
||||
eventIdOrig, ok := c.GetQuery("event_id")
|
||||
if !ok {
|
||||
c.JSON(400, gin.H{
|
||||
"status": "undefinded event id",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Parse event id to uuid
|
||||
eventId, err := uuid.Parse(eventIdOrig)
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{
|
||||
"status": "error parsing string to uuid",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
data.UserId = userId.(uuid.UUID)
|
||||
code, err := data.GenCheckinCode(eventId)
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{
|
||||
"status": "error generating code",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(200, gin.H{
|
||||
"checkin_code": code,
|
||||
})
|
||||
}
|
||||
|
||||
func CheckinSubmit(c *gin.Context) {
|
||||
var req struct {
|
||||
ChekinCode string `json:"checkin_code"`
|
||||
}
|
||||
c.ShouldBindJSON(&req)
|
||||
|
||||
data := new(data.User)
|
||||
userId, err := data.VerifyCheckinCode(req.ChekinCode)
|
||||
if err != nil {
|
||||
c.JSON(400, gin.H{
|
||||
"status": "error verify checkin code",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
data.GetByUserId(*userId)
|
||||
if data.PermissionLevel <= 20 {
|
||||
c.JSON(403, gin.H{
|
||||
"status": "access denied",
|
||||
})
|
||||
}
|
||||
|
||||
c.JSON(200, gin.H{
|
||||
"status": "success",
|
||||
})
|
||||
}
|
||||
7
service/user/create.go
Normal file
7
service/user/create.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package user
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
func Create(c *gin.Context) {
|
||||
|
||||
}
|
||||
@@ -1 +1,39 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"nixcn-cms/data"
|
||||
"nixcn-cms/utils"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func Full(c *gin.Context) {
|
||||
userIdOrig, ok := c.Get("user_id")
|
||||
if !ok {
|
||||
utils.HttpResponse(c, 403, "", "userid error")
|
||||
return
|
||||
}
|
||||
userId, err := uuid.Parse(userIdOrig.(string))
|
||||
if err != nil {
|
||||
utils.HttpResponse(c, 500, "", "failed to parse uuid")
|
||||
return
|
||||
}
|
||||
|
||||
userData, err := new(data.User).GetByUserId(userId)
|
||||
if err != nil {
|
||||
utils.HttpResponse(c, 404, "", "user not found")
|
||||
return
|
||||
}
|
||||
|
||||
users, err := userData.GetFullTable()
|
||||
if err != nil {
|
||||
utils.HttpResponse(c, 500, "", "database error")
|
||||
return
|
||||
}
|
||||
|
||||
userFullResp := struct {
|
||||
UserTable *[]data.User `json:"user_table"`
|
||||
}{users}
|
||||
utils.HttpResponse(c, 200, "", "success", userFullResp)
|
||||
}
|
||||
|
||||
@@ -7,11 +7,10 @@ import (
|
||||
)
|
||||
|
||||
func Handler(r *gin.RouterGroup) {
|
||||
r.Use(middleware.JWTAuth())
|
||||
r.Use(middleware.JWTAuth(true), middleware.Permission(5))
|
||||
r.GET("/info", Info)
|
||||
r.GET("/checkin", Checkin)
|
||||
r.POST("/checkin/submit", CheckinSubmit)
|
||||
r.PATCH("/update", Update)
|
||||
r.GET("/list", List)
|
||||
r.GET("/query", Query)
|
||||
r.GET("/list", List, middleware.Permission(20))
|
||||
r.POST("/full", Full, middleware.Permission(40))
|
||||
r.POST("/create", Create, middleware.Permission(50))
|
||||
}
|
||||
|
||||
@@ -2,46 +2,42 @@ package user
|
||||
|
||||
import (
|
||||
"nixcn-cms/data"
|
||||
"time"
|
||||
"nixcn-cms/utils"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func Info(c *gin.Context) {
|
||||
data := new(data.User)
|
||||
userId, ok := c.Get("user_id")
|
||||
userData := new(data.User)
|
||||
userIdOrig, ok := c.Get("user_id")
|
||||
if !ok {
|
||||
c.JSON(404, gin.H{
|
||||
"status": "user not found",
|
||||
})
|
||||
utils.HttpResponse(c, 403, "", "userid error")
|
||||
return
|
||||
}
|
||||
userId, err := uuid.Parse(userIdOrig.(string))
|
||||
if err != nil {
|
||||
utils.HttpResponse(c, 500, "", "failed to parse uuid")
|
||||
return
|
||||
}
|
||||
|
||||
// Get user from database
|
||||
err := data.GetByUserId(userId.(uuid.UUID))
|
||||
user, err := userData.GetByUserId(userId)
|
||||
if err != nil {
|
||||
c.JSON(404, gin.H{
|
||||
"status": "user not found",
|
||||
})
|
||||
utils.HttpResponse(c, 404, "", "user not found")
|
||||
return
|
||||
}
|
||||
|
||||
// Set time nil if time is zero
|
||||
for k, v := range data.Checkin {
|
||||
if t, ok := v.(time.Time); ok && t.IsZero() {
|
||||
data.Checkin[k] = nil
|
||||
}
|
||||
}
|
||||
userInfoResp := struct {
|
||||
UserId uuid.UUID `json:"user_id"`
|
||||
Email string `json:"email"`
|
||||
Username string `json:"username"`
|
||||
Nickname string `json:"nickname"`
|
||||
Subtitle string `json:"subtitle"`
|
||||
Avatar string `json:"avatar"`
|
||||
Bio string `json:"bio"`
|
||||
PermissionLevel uint `json:"permission_level"`
|
||||
}{user.UserId, user.Email, user.Username, user.Nickname, user.Subtitle, user.Avatar, user.Bio, user.PermissionLevel}
|
||||
|
||||
c.JSON(200, gin.H{
|
||||
"user_id": data.UserId,
|
||||
"email": data.Email,
|
||||
"type": data.Type,
|
||||
"nickname": data.Nickname,
|
||||
"subtitle": data.Subtitle,
|
||||
"avatar": data.Avatar,
|
||||
"checkin": data.Checkin,
|
||||
"permission_level": data.PermissionLevel,
|
||||
})
|
||||
utils.HttpResponse(c, 200, "", "success", userInfoResp)
|
||||
}
|
||||
|
||||
@@ -2,14 +2,13 @@ package user
|
||||
|
||||
import (
|
||||
"nixcn-cms/data"
|
||||
"nixcn-cms/utils"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func List(c *gin.Context) {
|
||||
data := new(data.User)
|
||||
|
||||
// Get limit and offset from query
|
||||
limit, ok := c.GetQuery("limit")
|
||||
if !ok {
|
||||
@@ -17,34 +16,30 @@ func List(c *gin.Context) {
|
||||
}
|
||||
offset, ok := c.GetQuery("offset")
|
||||
if !ok {
|
||||
c.JSON(400, gin.H{
|
||||
"status": "offset not found",
|
||||
})
|
||||
utils.HttpResponse(c, 400, "", "offset not found")
|
||||
return
|
||||
}
|
||||
|
||||
// Parse string to int64
|
||||
limitNum, err := strconv.ParseInt(limit, 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(400, gin.H{
|
||||
"status": "parse string to int error",
|
||||
})
|
||||
utils.HttpResponse(c, 400, "", "parse string to int error")
|
||||
return
|
||||
}
|
||||
offsetNum, err := strconv.ParseInt(offset, 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(400, gin.H{
|
||||
"status": "parse string to int error",
|
||||
})
|
||||
utils.HttpResponse(c, 400, "", "parse string to int error")
|
||||
return
|
||||
}
|
||||
|
||||
// Get user list from search engine
|
||||
list, err := data.FastListUsers(limitNum, offsetNum)
|
||||
list, err := new(data.User).FastListUsers(limitNum, offsetNum)
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{
|
||||
"status": "failed list users from meilisearch",
|
||||
})
|
||||
utils.HttpResponse(c, 500, "", "failed list users from meilisearch")
|
||||
}
|
||||
c.JSON(200, list)
|
||||
|
||||
userListResp := struct {
|
||||
List *[]data.UserSearchDoc `json:"list"`
|
||||
}{list}
|
||||
utils.HttpResponse(c, 200, "", "success", userListResp)
|
||||
}
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"nixcn-cms/data"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func Query(c *gin.Context) {
|
||||
userId, ok := c.Get("user_id")
|
||||
if !ok {
|
||||
c.JSON(400, gin.H{"status": "could not found user_id"})
|
||||
return
|
||||
}
|
||||
eventId, ok := c.GetQuery("event_id")
|
||||
if !ok {
|
||||
c.JSON(400, gin.H{"status": "could not found event_id"})
|
||||
return
|
||||
}
|
||||
|
||||
data := new(data.User)
|
||||
err := data.GetByUserId(userId.(uuid.UUID))
|
||||
if err != nil {
|
||||
c.JSON(404, gin.H{"status": "cannot found user"})
|
||||
return
|
||||
}
|
||||
if data.Checkin[eventId] == nil {
|
||||
c.JSON(404, gin.H{"status": "cannot found user checked in"})
|
||||
return
|
||||
}
|
||||
|
||||
var checkinTime *time.Time
|
||||
if data.Checkin[eventId].(*time.Time).IsZero() {
|
||||
checkinTime = nil
|
||||
} else {
|
||||
checkinTime = data.Checkin[eventId].(*time.Time)
|
||||
}
|
||||
c.JSON(200, gin.H{
|
||||
"checkin_time": checkinTime,
|
||||
})
|
||||
}
|
||||
@@ -2,49 +2,61 @@ package user
|
||||
|
||||
import (
|
||||
"nixcn-cms/data"
|
||||
"nixcn-cms/internal/cryptography"
|
||||
"nixcn-cms/utils"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func Update(c *gin.Context) {
|
||||
// New user model
|
||||
userIdOrig, ok := c.Get("user_id")
|
||||
if !ok {
|
||||
utils.HttpResponse(c, 403, "", "userid error")
|
||||
return
|
||||
}
|
||||
userId, err := uuid.Parse(userIdOrig.(string))
|
||||
if err != nil {
|
||||
utils.HttpResponse(c, 500, "", "failed to parse uuid")
|
||||
return
|
||||
}
|
||||
|
||||
var ReqInfo data.User
|
||||
c.BindJSON(&ReqInfo)
|
||||
|
||||
// New user model
|
||||
user := new(data.User)
|
||||
userId, ok := c.Get("user_id")
|
||||
if !ok {
|
||||
c.JSON(403, gin.H{
|
||||
"status": "can not found user id",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Get user info
|
||||
user.GetByUserId(userId.(uuid.UUID))
|
||||
|
||||
// Reject permission 0 user
|
||||
if user.PermissionLevel == 0 {
|
||||
c.JSON(403, gin.H{
|
||||
"status": "premission denied",
|
||||
})
|
||||
userData, err := new(data.User).GetByUserId(userId)
|
||||
if err != nil {
|
||||
utils.HttpResponse(c, 500, "", "failed to find user")
|
||||
return
|
||||
}
|
||||
|
||||
user.Avatar = ReqInfo.Avatar
|
||||
user.Email = ReqInfo.Email
|
||||
user.Nickname = ReqInfo.Nickname
|
||||
user.Subtitle = ReqInfo.Subtitle
|
||||
// Cant change user type under permission 2
|
||||
if user.PermissionLevel >= 2 {
|
||||
user.Type = ReqInfo.Type
|
||||
if len(ReqInfo.Email) < 5 || len(ReqInfo.Email) >= 255 {
|
||||
utils.HttpResponse(c, 400, "", "invilad email")
|
||||
return
|
||||
}
|
||||
userData.Email = ReqInfo.Email
|
||||
|
||||
if len(ReqInfo.Username) < 5 || len(ReqInfo.Username) >= 255 {
|
||||
utils.HttpResponse(c, 400, "", "invilad user name")
|
||||
return
|
||||
}
|
||||
userData.Username = ReqInfo.Username
|
||||
|
||||
userData.Nickname = ReqInfo.Nickname
|
||||
userData.Subtitle = ReqInfo.Subtitle
|
||||
userData.Avatar = ReqInfo.Avatar
|
||||
|
||||
if ReqInfo.Bio != "" {
|
||||
if !cryptography.IsBase64Std(ReqInfo.Bio) {
|
||||
utils.HttpResponse(c, 400, "", "invalid base64")
|
||||
}
|
||||
}
|
||||
userData.Bio = ReqInfo.Bio
|
||||
|
||||
// Update user info
|
||||
user.UpdateByUserID(userId.(uuid.UUID))
|
||||
userData.UpdateByUserID(userId)
|
||||
|
||||
c.JSON(200, gin.H{
|
||||
"status": "success",
|
||||
})
|
||||
utils.HttpResponse(c, 200, "", "success")
|
||||
}
|
||||
|
||||
30
utils/response.go
Normal file
30
utils/response.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package utils
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
type RespStatus struct {
|
||||
Code int `json:"code"`
|
||||
ErrorId string `json:"error_id"`
|
||||
Status string `json:"status"`
|
||||
Data any `json:"data"`
|
||||
}
|
||||
|
||||
func HttpResponse(c *gin.Context, code int, errorId string, status string, data ...any) {
|
||||
var resp = RespStatus{
|
||||
Code: code,
|
||||
ErrorId: errorId,
|
||||
Status: status,
|
||||
Data: data,
|
||||
}
|
||||
c.JSON(code, resp)
|
||||
}
|
||||
|
||||
func HttpAbort(c *gin.Context, code int, errorId string, status string, data ...any) {
|
||||
var resp = RespStatus{
|
||||
Code: code,
|
||||
ErrorId: errorId,
|
||||
Status: status,
|
||||
Data: data,
|
||||
}
|
||||
c.AbortWithStatusJSON(code, resp)
|
||||
}
|
||||
Reference in New Issue
Block a user