From 4fa9fcd232c84fa940f731b563f2a195fa111c97 Mon Sep 17 00:00:00 2001 From: shanshanzhong Date: Wed, 13 May 2026 11:24:13 -0700 Subject: [PATCH] feat: add direct s3 upload flow --- apis/public/file.api | 58 +++++++ etc/ppanel.yaml | 17 +- go.mod | 20 ++- go.sum | 36 ++++ internal/config/config.go | 17 ++ .../public/file/fileUploadCompleteHandler.go | 26 +++ .../public/file/fileUploadInitHandler.go | 26 +++ internal/handler/routes.go | 12 ++ internal/logic/public/file/common.go | 148 ++++++++++++++++ .../public/file/fileuploadcompletelogic.go | 71 ++++++++ .../logic/public/file/fileuploadinitlogic.go | 80 +++++++++ internal/svc/serviceContext.go | 9 + internal/types/compat_types.go | 69 -------- internal/types/types.go | 160 ++++++++++++++---- pkg/storage/s3.go | 128 ++++++++++++++ ppanel.api | 2 + 16 files changed, 773 insertions(+), 106 deletions(-) create mode 100644 apis/public/file.api create mode 100644 internal/handler/public/file/fileUploadCompleteHandler.go create mode 100644 internal/handler/public/file/fileUploadInitHandler.go create mode 100644 internal/logic/public/file/common.go create mode 100644 internal/logic/public/file/fileuploadcompletelogic.go create mode 100644 internal/logic/public/file/fileuploadinitlogic.go delete mode 100644 internal/types/compat_types.go create mode 100644 pkg/storage/s3.go diff --git a/apis/public/file.api b/apis/public/file.api new file mode 100644 index 0000000..e05b40c --- /dev/null +++ b/apis/public/file.api @@ -0,0 +1,58 @@ +syntax = "v1" + +info ( + title: "File API" + desc: "API for ppanel file upload" + author: "Codex" + email: "codex@openai.com" + version: "0.0.1" +) + +import "../types.api" + +type ( + FileUploadInitRequest { + BizType string `json:"biz_type" validate:"required"` + FileName string `json:"file_name" validate:"required"` + ContentType string `json:"content_type" validate:"required"` + Size int64 `json:"size" validate:"required"` + Sha256 string `json:"sha256"` + } + + FileUploadInitResponse { + FileId string `json:"file_id"` + ObjectKey string `json:"object_key"` + UploadURL string `json:"upload_url"` + Method string `json:"method"` + Headers map[string]string `json:"headers"` + ExpiredAt int64 `json:"expired_at"` + } + + FileUploadCompleteRequest { + FileId string `json:"file_id" validate:"required"` + } + + FileUploadCompleteResponse { + FileId string `json:"file_id"` + ObjectKey string `json:"object_key"` + Size int64 `json:"size"` + ContentType string `json:"content_type"` + Etag string `json:"etag"` + Status string `json:"status"` + } +) + +@server ( + prefix: v1/public/file + group: public/file + middleware: AuthMiddleware,DeviceMiddleware +) +service ppanel { + @doc "Init file upload" + @handler FileUploadInit + post /upload/init (FileUploadInitRequest) returns (FileUploadInitResponse) + + @doc "Complete file upload" + @handler FileUploadComplete + post /upload/complete (FileUploadCompleteRequest) returns (FileUploadCompleteResponse) +} diff --git a/etc/ppanel.yaml b/etc/ppanel.yaml index 8e21654..a2423cf 100644 --- a/etc/ppanel.yaml +++ b/etc/ppanel.yaml @@ -61,6 +61,21 @@ Trace: # 链路追踪配置 (OpenTelemetry) Batcher: otlpgrpc # 本地开发留空""; 生产填 otlpgrpc Endpoint: "127.0.0.1:4317" # host 网络模式; bridge 模式改为 tempo:4317 +S3: + Enable: false + Region: "" + Bucket: "" + Endpoint: "" + AccessKey: "" + SecretKey: "" + SessionToken: "" + Prefix: "app-upload" + PublicBaseURL: "" + UsePathStyle: false + PresignExpireSeconds: 300 + MaxUploadSize: 104857600 + AllowedContentTypes: "application/zip,application/x-zip-compressed,application/gzip,application/x-gzip,application/octet-stream,text/plain,application/json" + device: enable: true # 开启设备加密通信 security_secret: "" # AES加密密钥,需要和App端一致,key=SHA256(security_secret)[:32] @@ -72,4 +87,4 @@ Administrator: Register: EnableTrial: true EnableTrialEmailWhitelist: true - TrialEmailDomainWhitelist: "facebook.com,yahoo.com,qq.com,live.com,outlook.com,msn.com,example.com,go.com,aol.com,free.fr,aliyun.com,163.com,yandex.ru,indiatimes.com,alibaba.com,geocities.com,about.com,naver.com,netscape.com,yahoo.co.jp,earthlink.net,zoho.com,sky.com,mail.ru,angelfire.com,uol.com.br,spb.ru,yandex.com,globo.com,gmail.com,medscape.com,space.com,discovery.com,t-online.de,mac.com,icloud.com,homestead.com,lycos.com,web.id,nus.edu.sg,altavista.com,berlin.de,rambler.ru,pp.ua,comcast.net,sapo.pt,msk.ru,ancestry.com,daum.net,proton.me,law.com,bt.com,techspot.com,icq.com,sina.cn,libero.it,test.com,yourdomain.com,docomo.ne.jp,wp.pl,hotmail.com,orange.fr,onet.pl,me.com,india.com,kansascity.com,wanadoo.fr,fortunecity.com,web.de,terra.com.br,att.net,canada.com,skynet.be,ya.ru,excite.com,detik.com,compuserve.com,ig.com.br,zp.ua,gmx.net,xoom.com,mindspring.com,freeserve.co.uk,interia.pl,excite.co.jp,test.de,shaw.ca,virgilio.it,chez.com,rr.com,freenet.de,ntlworld.com,seznam.cz,arcor.de,tiscali.it,sympatico.ca,sina.com,gazeta.pl,care2.com,yam.com,r7.com,telenet.be,rcn.com,geek.com,sfr.fr,hotbot.com,cox.net,blueyonder.co.uk,tom.com,virginmedia.com,btinternet.com,iinet.net.au,rogers.com,ireland.com,pochta.ru,ozemail.com.au,catholic.org,bluewin.ch,chat.ru,virgin.net,verizon.net,erols.com,lycos.de,lycos.co.uk,protonmail.com,doityourself.com,home.nl,nate.com,casino.com,o2.co.uk,terra.es,mail.com,albawaba.com,126.com,www.com,planet.nl,sanook.com,21cn.com,online.de,name.com,i.ua,centrum.cz,rin.ru,aol.co.uk,voila.fr,walla.co.il,poste.it,netcom.com,parrot.com,charter.net,mydomain.com,mail-tester.com,myway.com,chello.nl,club-internet.fr,sdf.org,tiscali.co.uk,freeuk.com,unican.es,sci.fi,anonymize.com,sify.com,metacrawler.com,go.ro,ivillage.com,telus.net,dailypioneer.com,iespana.es,lycos.es,hey.com,sweb.cz,optusnet.com.au,alice.it,tpg.com.au,hamptonroads.com,saudia.com,lycos.nl,blackplanet.com,frontier.com,looksmart.com,pobox.com,prodigy.net,i.am,freeyellow.com,gmx.com,bigpond.com,crosswinds.net,dejanews.com,wanadoo.es,foxmail.com,eircom.net,islamonline.net,webindia123.com,oath.com,frontiernet.net,hetnet.nl,onmilwaukee.com,ukr.net,bugmenot.com,neuf.fr,kiwibox.com,za.com,iol.it,zonnet.nl,newmail.ru,pacbell.net,cogeco.ca,depechemode.com,concentric.net,aim.com,f5.si,yahoo.jp,terra.com,hot.ee,netzero.net,netins.net,sprynet.com,mailbox.org,mail2web.com,o2.pl,idirect.com,bigfoot.com,netspace.net.au,masrawy.com,supereva.it,yahoo.de,lycos.it,yeah.net,montevideo.com.uy,gmx.de,yahoo.co.uk,yahoofs.com,scubadiving.com,hushmail.com,iprimus.com.au,gportal.hu,swissinfo.org,inbox.com,bolt.com,telstra.com,bellsouth.net,spray.se,c3.hu,attbi.com,talktalk.co.uk,dynu.net,juno.com,yahoo.fr,msn.co.uk,fr.nf,pe.hu,bigpond.net.au,incredimail.com,adelphia.net,elvis.com,interfree.it,starmedia.com,seanet.com,yahoo.com.tw,zip.net,tds.net,she.com,forthnet.gr,land.ru,wow.com,dnsmadeeasy.com,webjump.com,singnet.com.sg,spacewar.com,tin.it,4mg.com,sp.nl,wowway.com,dmv.com,bangkok.com,fastmail.fm,sbcglobal.net,bright.net,usa.com,37.com,aeiou.pt,terra.cl,thirdage.com,btconnect.com,optimum.net,cableone.net,talkcity.com,blogos.com,c2i.net,iwon.com,aver.com,barcelona.com,ddnsfree.com,oi.com.br,lex.bg,roadrunner.com,airmail.net,lawyer.com,yahoo.com.cn,cu.cc,ananzi.co.za,au.ru,pipeline.com,cs.com,3ammagazine.com,gmx.at,qwest.net,btopenworld.com,easypost.com,westnet.com.au,nyc.com,korea.com,front.ru,inbox.lv,yahoo.com.br,ny.com,hispavista.com,abv.bg,mchsi.com,apollo.lv,everyone.net,terra.com.ar,singpost.com,doctor.com,garbage.com,bizhosting.com,go2net.com,clerk.com,games.com,charm.net,onlinehome.de,laposte.net" # 填你的白名单域名,逗号分隔 \ No newline at end of file + TrialEmailDomainWhitelist: "facebook.com,yahoo.com,qq.com,live.com,outlook.com,msn.com,example.com,go.com,aol.com,free.fr,aliyun.com,163.com,yandex.ru,indiatimes.com,alibaba.com,geocities.com,about.com,naver.com,netscape.com,yahoo.co.jp,earthlink.net,zoho.com,sky.com,mail.ru,angelfire.com,uol.com.br,spb.ru,yandex.com,globo.com,gmail.com,medscape.com,space.com,discovery.com,t-online.de,mac.com,icloud.com,homestead.com,lycos.com,web.id,nus.edu.sg,altavista.com,berlin.de,rambler.ru,pp.ua,comcast.net,sapo.pt,msk.ru,ancestry.com,daum.net,proton.me,law.com,bt.com,techspot.com,icq.com,sina.cn,libero.it,test.com,yourdomain.com,docomo.ne.jp,wp.pl,hotmail.com,orange.fr,onet.pl,me.com,india.com,kansascity.com,wanadoo.fr,fortunecity.com,web.de,terra.com.br,att.net,canada.com,skynet.be,ya.ru,excite.com,detik.com,compuserve.com,ig.com.br,zp.ua,gmx.net,xoom.com,mindspring.com,freeserve.co.uk,interia.pl,excite.co.jp,test.de,shaw.ca,virgilio.it,chez.com,rr.com,freenet.de,ntlworld.com,seznam.cz,arcor.de,tiscali.it,sympatico.ca,sina.com,gazeta.pl,care2.com,yam.com,r7.com,telenet.be,rcn.com,geek.com,sfr.fr,hotbot.com,cox.net,blueyonder.co.uk,tom.com,virginmedia.com,btinternet.com,iinet.net.au,rogers.com,ireland.com,pochta.ru,ozemail.com.au,catholic.org,bluewin.ch,chat.ru,virgin.net,verizon.net,erols.com,lycos.de,lycos.co.uk,protonmail.com,doityourself.com,home.nl,nate.com,casino.com,o2.co.uk,terra.es,mail.com,albawaba.com,126.com,www.com,planet.nl,sanook.com,21cn.com,online.de,name.com,i.ua,centrum.cz,rin.ru,aol.co.uk,voila.fr,walla.co.il,poste.it,netcom.com,parrot.com,charter.net,mydomain.com,mail-tester.com,myway.com,chello.nl,club-internet.fr,sdf.org,tiscali.co.uk,freeuk.com,unican.es,sci.fi,anonymize.com,sify.com,metacrawler.com,go.ro,ivillage.com,telus.net,dailypioneer.com,iespana.es,lycos.es,hey.com,sweb.cz,optusnet.com.au,alice.it,tpg.com.au,hamptonroads.com,saudia.com,lycos.nl,blackplanet.com,frontier.com,looksmart.com,pobox.com,prodigy.net,i.am,freeyellow.com,gmx.com,bigpond.com,crosswinds.net,dejanews.com,wanadoo.es,foxmail.com,eircom.net,islamonline.net,webindia123.com,oath.com,frontiernet.net,hetnet.nl,onmilwaukee.com,ukr.net,bugmenot.com,neuf.fr,kiwibox.com,za.com,iol.it,zonnet.nl,newmail.ru,pacbell.net,cogeco.ca,depechemode.com,concentric.net,aim.com,f5.si,yahoo.jp,terra.com,hot.ee,netzero.net,netins.net,sprynet.com,mailbox.org,mail2web.com,o2.pl,idirect.com,bigfoot.com,netspace.net.au,masrawy.com,supereva.it,yahoo.de,lycos.it,yeah.net,montevideo.com.uy,gmx.de,yahoo.co.uk,yahoofs.com,scubadiving.com,hushmail.com,iprimus.com.au,gportal.hu,swissinfo.org,inbox.com,bolt.com,telstra.com,bellsouth.net,spray.se,c3.hu,attbi.com,talktalk.co.uk,dynu.net,juno.com,yahoo.fr,msn.co.uk,fr.nf,pe.hu,bigpond.net.au,incredimail.com,adelphia.net,elvis.com,interfree.it,starmedia.com,seanet.com,yahoo.com.tw,zip.net,tds.net,she.com,forthnet.gr,land.ru,wow.com,dnsmadeeasy.com,webjump.com,singnet.com.sg,spacewar.com,tin.it,4mg.com,sp.nl,wowway.com,dmv.com,bangkok.com,fastmail.fm,sbcglobal.net,bright.net,usa.com,37.com,aeiou.pt,terra.cl,thirdage.com,btconnect.com,optimum.net,cableone.net,talkcity.com,blogos.com,c2i.net,iwon.com,aver.com,barcelona.com,ddnsfree.com,oi.com.br,lex.bg,roadrunner.com,airmail.net,lawyer.com,yahoo.com.cn,cu.cc,ananzi.co.za,au.ru,pipeline.com,cs.com,3ammagazine.com,gmx.at,qwest.net,btopenworld.com,easypost.com,westnet.com.au,nyc.com,korea.com,front.ru,inbox.lv,yahoo.com.br,ny.com,hispavista.com,abv.bg,mchsi.com,apollo.lv,everyone.net,terra.com.ar,singpost.com,doctor.com,garbage.com,bizhosting.com,go2net.com,clerk.com,games.com,charm.net,onlinehome.de,laposte.net" # 填你的白名单域名,逗号分隔 diff --git a/go.mod b/go.mod index 8157fa3..ca67325 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/perfect-panel/server -go 1.23.3 +go 1.24 require ( github.com/GUAIK-ORG/go-snowflake v0.0.0-20200116064823-220c4260e85f @@ -54,6 +54,10 @@ require ( require ( github.com/Masterminds/sprig/v3 v3.3.0 + github.com/aws/aws-sdk-go-v2 v1.41.7 + github.com/aws/aws-sdk-go-v2/config v1.32.17 + github.com/aws/aws-sdk-go-v2/credentials v1.19.16 + github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0 github.com/fatih/color v1.18.0 github.com/goccy/go-json v0.10.4 github.com/golang-migrate/migrate/v4 v4.18.2 @@ -78,6 +82,20 @@ require ( github.com/alibabacloud-go/tea-utils/v2 v2.0.7 // indirect github.com/alibabacloud-go/tea-xml v1.1.3 // indirect github.com/aliyun/credentials-go v1.3.10 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 // indirect + github.com/aws/smithy-go v1.25.1 // indirect github.com/boj/redistore v0.0.0-20180917114910-cd5dcc76aeff // indirect github.com/bytedance/sonic v1.12.7 // indirect github.com/bytedance/sonic/loader v0.2.3 // indirect diff --git a/go.sum b/go.sum index 0e49d98..df90cc5 100644 --- a/go.sum +++ b/go.sum @@ -60,6 +60,42 @@ github.com/anaskhan96/go-password-encoder v0.0.0-20201010210601-c765b799fd72 h1: github.com/anaskhan96/go-password-encoder v0.0.0-20201010210601-c765b799fd72/go.mod h1:PsJICrlruG9QcJDYuZ0dO/2KtMDALzRbony8NkxZ2nE= github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA= github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA= +github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8= +github.com/aws/aws-sdk-go-v2 v1.41.7/go.mod h1:4LAfZOPHNVNQEckOACQx60Y8pSRjIkNZQz1w92xpMJc= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 h1:gx1AwW1Iyk9Z9dD9F4akX5gnN3QZwUB20GGKH/I+Rho= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10/go.mod h1:qqY157uZoqm5OXq/amuaBJyC9hgBCBQnsaWnPe905GY= +github.com/aws/aws-sdk-go-v2/config v1.32.17 h1:FpL4/758/diKwqbytU0prpuiu60fgXKUWCpDJtApclU= +github.com/aws/aws-sdk-go-v2/config v1.32.17/go.mod h1:OXqUMzgXytfoF9JaKkhrOYsyh72t9G+MJH8mMRaexOE= +github.com/aws/aws-sdk-go-v2/credentials v1.19.16 h1:r3RJBuU7X9ibt8RHbMjWE6y60QbKBiII6wSrXnapxSU= +github.com/aws/aws-sdk-go-v2/credentials v1.19.16/go.mod h1:6cx7zqDENJDbBIIWX6P8s0h6hqHC8Avbjh9Dseo27ug= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 h1:UuSfcORqNSz/ey3VPRS8TcVH2Ikf0/sC+Hdj400QI6U= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23/go.mod h1:+G/OSGiOFnSOkYloKj/9M35s74LgVAdJBSD5lsFfqKg= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 h1:GpT/TrnBYuE5gan2cZbTtvP+JlHsutdmlV2YfEyNde0= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23/go.mod h1:xYWD6BS9ywC5bS3sz9Xh04whO/hzK2plt2Zkyrp4JuA= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 h1:bpd8vxhlQi2r1hiueOw02f/duEPTMK59Q4QMAoTTtTo= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23/go.mod h1:15DfR2nw+CRHIk0tqNyifu3G1YdAOy68RftkhMDDwYk= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 h1:OQqn11BtaYv1WLUowvcA30MpzIu8Ti4pcLPIIyoKZrA= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24/go.mod h1:X5ZJyfwVrWA96GzPmUCWFQaEARPR7gCrpq2E92PJwAE= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 h1:FLudkZLt5ci0ozzgkVo8BJGwvqNaZbTWb3UcucAateA= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9/go.mod h1:w7wZ/s9qK7c8g4al+UyoF1Sp/Z45UwMGcqIzLWVQHWk= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15 h1:ieLCO1JxUWuxTZ1cRd0GAaeX7O6cIxnwk7tc1LsQhC4= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15/go.mod h1:e3IzZvQ3kAWNykvE0Tr0RDZCMFInMvhku3qNpcIQXhM= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 h1:pbrxO/kuIwgEsOPLkaHu0O+m4fNgLU8B3vxQ+72jTPw= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23/go.mod h1:/CMNUqoj46HpS3MNRDEDIwcgEnrtZlKRaHNaHxIFpNA= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23 h1:03xatSQO4+AM1lTAbnRg5OK528EUg744nW7F73U8DKw= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23/go.mod h1:M8l3mwgx5ToK7wot2sBBce/ojzgnPzZXUV445gTSyE8= +github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0 h1:etqBTKY581iwLL/H/S2sVgk3C9lAsTJFeXWFDsDcWOU= +github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0/go.mod h1:L2dcoOgS2VSgbPLvpak2NyUPsO1TBN7M45Z4H7DlRc4= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 h1:TdJ+HdzOBhU8+iVAOGUTU63VXopcumCOF1paFulHWZc= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.11/go.mod h1:R82ZRExE/nheo0N+T8zHPcLRTcH8MGsnR3BiVGX0TwI= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 h1:7byT8HUWrgoRp6sXjxtZwgOKfhss5fW6SkLBtqzgRoE= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.17/go.mod h1:xNWknVi4Ezm1vg1QsB/5EWpAJURq22uqd38U8qKvOJc= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 h1:+1Kl1zx6bWi4X7cKi3VYh29h8BvsCoHQEQ6ST9X8w7w= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21/go.mod h1:4vIRDq+CJB2xFAXZ+YgGUTiEft7oAQlhIs71xcSeuVg= +github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 h1:F/M5Y9I3nwr2IEpshZgh1GeHpOItExNM9L1euNuh/fk= +github.com/aws/aws-sdk-go-v2/service/sts v1.42.1/go.mod h1:mTNxImtovCOEEuD65mKW7DCsL+2gjEH+RPEAexAzAio= +github.com/aws/smithy-go v1.25.1 h1:J8ERsGSU7d+aCmdQur5Txg6bVoYelvQJgtZehD12GkI= +github.com/aws/smithy-go v1.25.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/beevik/etree v1.1.0/go.mod h1:r8Aw8JqVegEf0w2fDnATrX9VpkMcyFeM0FhwO62wh+A= github.com/boj/redistore v0.0.0-20180917114910-cd5dcc76aeff h1:RmdPFa+slIr4SCBg4st/l/vZWVe9QJKMXGO60Bxbe04= github.com/boj/redistore v0.0.0-20180917114910-cd5dcc76aeff/go.mod h1:+RTT1BOk5P97fT2CiHkbFQwkK3mjsFAP6zCYV2aXtjw= diff --git a/internal/config/config.go b/internal/config/config.go index 2d6718b..4e6ebe5 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -38,12 +38,29 @@ type Config struct { Log Log `yaml:"Log"` Currency Currency `yaml:"Currency"` Trace trace.Config `yaml:"Trace"` + S3 S3Config `yaml:"S3"` Administrator struct { Email string `yaml:"Email" default:"admin@ppanel.dev"` Password string `yaml:"Password" default:"password"` } `yaml:"Administrator"` } +type S3Config struct { + Enable bool `yaml:"Enable" default:"false"` + Region string `yaml:"Region" default:""` + Bucket string `yaml:"Bucket" default:""` + Endpoint string `yaml:"Endpoint" default:""` + AccessKey string `yaml:"AccessKey" default:""` + SecretKey string `yaml:"SecretKey" default:""` + SessionToken string `yaml:"SessionToken" default:""` + Prefix string `yaml:"Prefix" default:"app-upload"` + PublicBaseURL string `yaml:"PublicBaseURL" default:""` + UsePathStyle bool `yaml:"UsePathStyle" default:"false"` + PresignExpireSeconds int64 `yaml:"PresignExpireSeconds" default:"300"` + MaxUploadSize int64 `yaml:"MaxUploadSize" default:"104857600"` + AllowedContentTypes string `yaml:"AllowedContentTypes" default:"application/zip,application/x-zip-compressed,application/gzip,application/x-gzip,application/octet-stream,text/plain,application/json"` +} + type RedisConfig struct { Host string `yaml:"Host" default:"localhost:6379"` Pass string `yaml:"Pass" default:""` diff --git a/internal/handler/public/file/fileUploadCompleteHandler.go b/internal/handler/public/file/fileUploadCompleteHandler.go new file mode 100644 index 0000000..482a7fe --- /dev/null +++ b/internal/handler/public/file/fileUploadCompleteHandler.go @@ -0,0 +1,26 @@ +package file + +import ( + "github.com/gin-gonic/gin" + "github.com/perfect-panel/server/internal/logic/public/file" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/result" +) + +// Complete file upload +func FileUploadCompleteHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) { + return func(c *gin.Context) { + var req types.FileUploadCompleteRequest + _ = c.ShouldBind(&req) + validateErr := svcCtx.Validate(&req) + if validateErr != nil { + result.ParamErrorResult(c, validateErr) + return + } + + l := file.NewFileUploadCompleteLogic(c.Request.Context(), svcCtx) + resp, err := l.FileUploadComplete(&req) + result.HttpResult(c, resp, err) + } +} diff --git a/internal/handler/public/file/fileUploadInitHandler.go b/internal/handler/public/file/fileUploadInitHandler.go new file mode 100644 index 0000000..0f419b8 --- /dev/null +++ b/internal/handler/public/file/fileUploadInitHandler.go @@ -0,0 +1,26 @@ +package file + +import ( + "github.com/gin-gonic/gin" + "github.com/perfect-panel/server/internal/logic/public/file" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/result" +) + +// Init file upload +func FileUploadInitHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) { + return func(c *gin.Context) { + var req types.FileUploadInitRequest + _ = c.ShouldBind(&req) + validateErr := svcCtx.Validate(&req) + if validateErr != nil { + result.ParamErrorResult(c, validateErr) + return + } + + l := file.NewFileUploadInitLogic(c.Request.Context(), svcCtx) + resp, err := l.FileUploadInit(&req) + result.HttpResult(c, resp, err) + } +} diff --git a/internal/handler/routes.go b/internal/handler/routes.go index 7ba4ef5..4475d8a 100644 --- a/internal/handler/routes.go +++ b/internal/handler/routes.go @@ -30,6 +30,7 @@ import ( common "github.com/perfect-panel/server/internal/handler/common" publicAnnouncement "github.com/perfect-panel/server/internal/handler/public/announcement" publicDocument "github.com/perfect-panel/server/internal/handler/public/document" + publicFile "github.com/perfect-panel/server/internal/handler/public/file" publicIapApple "github.com/perfect-panel/server/internal/handler/public/iap/apple" publicOrder "github.com/perfect-panel/server/internal/handler/public/order" publicPayment "github.com/perfect-panel/server/internal/handler/public/payment" @@ -865,6 +866,17 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) { publicDocumentGroupRouter.GET("/list", publicDocument.QueryDocumentListHandler(serverCtx)) } + publicFileGroupRouter := router.Group("/v1/public/file") + publicFileGroupRouter.Use(middleware.AuthMiddleware(serverCtx), middleware.DeviceMiddleware(serverCtx)) + + { + // Init file upload + publicFileGroupRouter.POST("/upload/init", publicFile.FileUploadInitHandler(serverCtx)) + + // Complete file upload + publicFileGroupRouter.POST("/upload/complete", publicFile.FileUploadCompleteHandler(serverCtx)) + } + publicOrderGroupRouter := router.Group("/v1/public/order") publicOrderGroupRouter.Use(middleware.AuthMiddleware(serverCtx), middleware.DeviceMiddleware(serverCtx)) diff --git a/internal/logic/public/file/common.go b/internal/logic/public/file/common.go new file mode 100644 index 0000000..c7e51da --- /dev/null +++ b/internal/logic/public/file/common.go @@ -0,0 +1,148 @@ +package file + +import ( + "context" + "crypto/sha1" + "encoding/hex" + "encoding/json" + "fmt" + "path/filepath" + "regexp" + "strings" + "time" + + "github.com/perfect-panel/server/internal/model/user" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/pkg/constant" + "github.com/perfect-panel/server/pkg/xerr" + "github.com/pkg/errors" +) + +const ( + fileUploadInitStatus = "init" + fileUploadCompleteStatus = "completed" + fileUploadRedisPrefix = "file:upload:" +) + +var safeFileNameRegexp = regexp.MustCompile(`[^a-zA-Z0-9._-]+`) + +type uploadMeta struct { + FileID string `json:"file_id"` + UserID int64 `json:"user_id"` + BizType string `json:"biz_type"` + FileName string `json:"file_name"` + ContentType string `json:"content_type"` + ObjectKey string `json:"object_key"` + Size int64 `json:"size"` + Sha256 string `json:"sha256"` + Status string `json:"status"` + Etag string `json:"etag,omitempty"` + CreatedAt int64 `json:"created_at"` + CompletedAt int64 `json:"completed_at,omitempty"` +} + +func currentUserFromContext(ctx context.Context) (*user.User, error) { + u, ok := ctx.Value(constant.CtxKeyUser).(*user.User) + if !ok || u == nil { + return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access") + } + return u, nil +} + +func allowedContentTypeSet(csv string) map[string]struct{} { + set := make(map[string]struct{}) + for _, item := range strings.Split(csv, ",") { + item = strings.TrimSpace(strings.ToLower(item)) + if item == "" { + continue + } + set[item] = struct{}{} + } + return set +} + +func validateInitRequest(svcCtx *svc.ServiceContext, bizType, fileName, contentType string, size int64) error { + if svcCtx.S3Store == nil || !svcCtx.Config.S3.Enable { + return errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "S3 upload is not enabled") + } + if strings.TrimSpace(bizType) == "" { + return errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "biz_type is required") + } + if strings.TrimSpace(fileName) == "" { + return errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "file_name is required") + } + if size <= 0 { + return errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "size must be greater than 0") + } + if svcCtx.Config.S3.MaxUploadSize > 0 && size > svcCtx.Config.S3.MaxUploadSize { + return errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "file size exceeds max limit") + } + allowed := allowedContentTypeSet(svcCtx.Config.S3.AllowedContentTypes) + if len(allowed) > 0 { + if _, ok := allowed[strings.ToLower(strings.TrimSpace(contentType))]; !ok { + return errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "content_type is not allowed") + } + } + return nil +} + +func buildFileID(userID int64, bizType, fileName string) string { + h := sha1.New() + _, _ = h.Write([]byte(fmt.Sprintf("%d:%s:%s:%d", userID, bizType, fileName, time.Now().UnixNano()))) + return hex.EncodeToString(h.Sum(nil))[:24] +} + +func safeFileName(name string) string { + base := filepath.Base(strings.TrimSpace(name)) + if base == "." || base == "/" || base == "" { + return "file.bin" + } + safe := safeFileNameRegexp.ReplaceAllString(base, "_") + if safe == "" { + return "file.bin" + } + return safe +} + +func buildObjectKey(prefix string, userID int64, bizType, fileID, fileName string, now time.Time) string { + prefix = strings.Trim(prefix, "/") + if prefix == "" { + prefix = "app-upload" + } + return fmt.Sprintf("%s/%s/%d/%04d/%02d/%s_%s", + prefix, + strings.Trim(safeFileNameRegexp.ReplaceAllString(strings.ToLower(strings.TrimSpace(bizType)), "-"), "-"), + userID, + now.Year(), + int(now.Month()), + fileID, + safeFileName(fileName), + ) +} + +func uploadRedisKey(fileID string) string { + return fileUploadRedisPrefix + fileID +} + +func saveUploadMeta(ctx context.Context, svcCtx *svc.ServiceContext, meta *uploadMeta, ttl time.Duration) error { + data, err := json.Marshal(meta) + if err != nil { + return errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "marshal upload meta failed") + } + if err := svcCtx.Redis.Set(ctx, uploadRedisKey(meta.FileID), data, ttl).Err(); err != nil { + return errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "save upload meta failed") + } + return nil +} + +func loadUploadMeta(ctx context.Context, svcCtx *svc.ServiceContext, fileID string) (*uploadMeta, error) { + val, err := svcCtx.Redis.Get(ctx, uploadRedisKey(fileID)).Result() + if err != nil { + return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "upload meta not found") + } + var meta uploadMeta + if err := json.Unmarshal([]byte(val), &meta); err != nil { + return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "parse upload meta failed") + } + return &meta, nil +} diff --git a/internal/logic/public/file/fileuploadcompletelogic.go b/internal/logic/public/file/fileuploadcompletelogic.go new file mode 100644 index 0000000..490c000 --- /dev/null +++ b/internal/logic/public/file/fileuploadcompletelogic.go @@ -0,0 +1,71 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.9.2 + +package file + +import ( + "context" + "time" + + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/logger" + "github.com/perfect-panel/server/pkg/xerr" + "github.com/pkg/errors" +) + +type FileUploadCompleteLogic struct { + logger.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +// Complete file upload +func NewFileUploadCompleteLogic(ctx context.Context, svcCtx *svc.ServiceContext) *FileUploadCompleteLogic { + return &FileUploadCompleteLogic{ + Logger: logger.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *FileUploadCompleteLogic) FileUploadComplete(req *types.FileUploadCompleteRequest) (resp *types.FileUploadCompleteResponse, err error) { + u, err := currentUserFromContext(l.ctx) + if err != nil { + return nil, err + } + + meta, err := loadUploadMeta(l.ctx, l.svcCtx, req.FileId) + if err != nil { + return nil, err + } + if meta.UserID != u.Id { + return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access") + } + + head, err := l.svcCtx.S3Store.HeadObject(l.ctx, meta.ObjectKey) + if err != nil { + l.Errorw("head object failed", logger.Field("error", err.Error()), logger.Field("file_id", meta.FileID)) + return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "uploaded file not found") + } + if meta.Size > 0 && head.ContentLength != meta.Size { + return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "uploaded file size mismatch") + } + + meta.Status = fileUploadCompleteStatus + meta.Etag = head.ETag + meta.ContentType = head.ContentType + meta.CompletedAt = time.Now().Unix() + if err := saveUploadMeta(l.ctx, l.svcCtx, meta, 7*24*time.Hour); err != nil { + return nil, err + } + + return &types.FileUploadCompleteResponse{ + FileId: meta.FileID, + ObjectKey: meta.ObjectKey, + Size: head.ContentLength, + ContentType: head.ContentType, + Etag: head.ETag, + Status: meta.Status, + }, nil +} diff --git a/internal/logic/public/file/fileuploadinitlogic.go b/internal/logic/public/file/fileuploadinitlogic.go new file mode 100644 index 0000000..7edaae6 --- /dev/null +++ b/internal/logic/public/file/fileuploadinitlogic.go @@ -0,0 +1,80 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.9.2 + +package file + +import ( + "context" + "time" + + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/logger" +) + +type FileUploadInitLogic struct { + logger.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +// Init file upload +func NewFileUploadInitLogic(ctx context.Context, svcCtx *svc.ServiceContext) *FileUploadInitLogic { + return &FileUploadInitLogic{ + Logger: logger.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *FileUploadInitLogic) FileUploadInit(req *types.FileUploadInitRequest) (resp *types.FileUploadInitResponse, err error) { + u, err := currentUserFromContext(l.ctx) + if err != nil { + return nil, err + } + + if err := validateInitRequest(l.svcCtx, req.BizType, req.FileName, req.ContentType, req.Size); err != nil { + return nil, err + } + + now := time.Now() + fileID := buildFileID(u.Id, req.BizType, req.FileName) + objectKey := buildObjectKey(l.svcCtx.Config.S3.Prefix, u.Id, req.BizType, fileID, req.FileName, now) + expireSeconds := l.svcCtx.Config.S3.PresignExpireSeconds + if expireSeconds <= 0 { + expireSeconds = 300 + } + + presigned, err := l.svcCtx.S3Store.PresignPutObject(l.ctx, objectKey, req.ContentType, time.Duration(expireSeconds)*time.Second) + if err != nil { + l.Errorw("presign put object failed", logger.Field("error", err.Error()), logger.Field("user_id", u.Id)) + return nil, err + } + + meta := &uploadMeta{ + FileID: fileID, + UserID: u.Id, + BizType: req.BizType, + FileName: req.FileName, + ContentType: req.ContentType, + ObjectKey: objectKey, + Size: req.Size, + Sha256: req.Sha256, + Status: fileUploadInitStatus, + CreatedAt: now.Unix(), + } + if err := saveUploadMeta(l.ctx, l.svcCtx, meta, 24*time.Hour); err != nil { + return nil, err + } + + return &types.FileUploadInitResponse{ + FileId: fileID, + ObjectKey: objectKey, + UploadURL: presigned.URL, + Method: presigned.Method, + Headers: map[string]string{ + "Content-Type": req.ContentType, + }, + ExpiredAt: presigned.ExpiresAt, + }, nil +} diff --git a/internal/svc/serviceContext.go b/internal/svc/serviceContext.go index bd26660..e1c5aaa 100644 --- a/internal/svc/serviceContext.go +++ b/internal/svc/serviceContext.go @@ -8,6 +8,7 @@ import ( "github.com/perfect-panel/server/internal/model/node" "github.com/perfect-panel/server/internal/model/redemption" "github.com/perfect-panel/server/pkg/device" + "github.com/perfect-panel/server/pkg/storage" "github.com/perfect-panel/server/internal/config" "github.com/perfect-panel/server/internal/model/ads" @@ -44,6 +45,7 @@ type ServiceContext struct { ExchangeRate float64 GeoIP *IPLocation SignatureValidator *signature.Validator + S3Store *storage.S3Store //NodeCache *cache.NodeCacheClient AuthModel auth.Model @@ -143,6 +145,13 @@ func NewServiceContext(c config.Config) *ServiceContext { } srv.IAPAppleTransactionModel = iapapple.NewModel(db, rds) srv.DeviceManager = NewDeviceManager(srv) + if c.S3.Enable { + s3Store, err := storage.NewS3Store(context.Background(), c.S3) + if err != nil { + panic(err.Error()) + } + srv.S3Store = s3Store + } return srv } diff --git a/internal/types/compat_types.go b/internal/types/compat_types.go deleted file mode 100644 index fc6b4e9..0000000 --- a/internal/types/compat_types.go +++ /dev/null @@ -1,69 +0,0 @@ -package types - -// compat_types.go — 手动补充的类型,已同步到 .api 定义 -// 下次用 goctl 重新生成 types.go 后,以下类型会自动包含在 types.go 中,届时可以删除本文件中对应的定义: -// - ContactRequest (已加入 common.api) -// - GetDownloadLinkRequest / GetDownloadLinkResponse (已加入 common.api) -// - EmailLoginRequest (已更新 auth.api, 加入 form tag) -// - ReportLogMessageRequest / ReportLogMessageResponse (已加入 common.api, 路由 POST /v1/common/log/report) -// - LegacyCheckVerificationCodeRequest / LegacyCheckVerificationCodeResponse (已加入 common.api, 路由 POST /v1/common/check_code) - -type ContactRequest struct { - Name string `json:"name" validate:"required,max=100"` - Email string `json:"email" validate:"required,email"` - OtherContact string `json:"other_contact" validate:"max=200"` - Notes string `json:"notes" validate:"max=2000"` -} - -type GetDownloadLinkRequest struct { - InviteCode string `form:"invite_code,optional"` - Platform string `form:"platform" validate:"required,oneof=windows mac ios android"` -} - -type GetDownloadLinkResponse struct { - Url string `json:"url"` -} - -type ReportLogMessageRequest struct { - Platform string `json:"platform" validate:"required,max=32"` - AppVersion string `json:"app_version" validate:"required,max=64"` - OsName string `json:"os_name" validate:"max=64"` - OsVersion string `json:"os_version" validate:"max=64"` - DeviceId string `json:"device_id" validate:"required,max=255"` - UserId int64 `json:"user_id"` - SessionId string `json:"session_id" validate:"max=255"` - Level uint8 `json:"level"` - ErrorCode string `json:"error_code" validate:"max=128"` - Message string `json:"message" validate:"required,max=65535"` - Stack string `json:"stack" validate:"max=1048576"` - Context interface{} `json:"context"` - OccurredAt int64 `json:"occurred_at"` -} - -type ReportLogMessageResponse struct { - Id int64 `json:"id"` -} - -type LegacyCheckVerificationCodeRequest struct { - Method string `json:"method" form:"method" validate:"omitempty,oneof=email mobile"` - Account string `json:"account" form:"account"` - Email string `json:"email" form:"email"` - Code string `json:"code" form:"code" validate:"required"` - Type uint8 `json:"type" form:"type" validate:"required"` -} - -type LegacyCheckVerificationCodeResponse struct { - Status bool `json:"status"` - Exist bool `json:"exist"` -} - -type EmailLoginRequest struct { - Identifier string `json:"identifier" form:"identifier"` - Email string `json:"email" form:"email" validate:"required,email"` - Code string `json:"code" form:"code" validate:"required"` - Invite string `json:"invite" form:"invite"` - IP string `header:"X-Original-Forwarded-For"` - UserAgent string `header:"User-Agent"` - LoginType string `header:"Login-Type"` - CfToken string `json:"cf_token,optional" form:"cf_token"` -} diff --git a/internal/types/types.go b/internal/types/types.go index a15a87d..0251a24 100644 --- a/internal/types/types.go +++ b/internal/types/types.go @@ -1,8 +1,12 @@ // Code generated by goctl. DO NOT EDIT. -// goctl 1.7.2 +// goctl 1.9.2 package types +type ActivateOrderRequest struct { + OrderNo string `json:"order_no" validate:"required"` +} + type Ads struct { Id int `json:"id"` Title string `json:"title"` @@ -295,6 +299,13 @@ type ConnectionRecords struct { LongestSingleConnection int64 `json:"longest_single_connection"` } +type ContactRequest struct { + Name string `json:"name" validate:"required,max=100"` + Email string `json:"email" validate:"required,email"` + OtherContact string `json:"other_contact" validate:"max=200"` + Notes string `json:"notes" validate:"max=2000"` +} + type Coupon struct { Id int64 `json:"id"` Name string `json:"name"` @@ -549,6 +560,13 @@ type CurrencyConfig struct { CurrencySymbol string `json:"currency_symbol"` } +type DailyTrafficStats struct { + Date string `json:"date"` + Upload int64 `json:"upload"` + Download int64 `json:"download"` + Total int64 `json:"total"` +} + type DeleteAccountRequest struct { Email string `json:"email" validate:"required,email"` Code string `json:"code" validate:"required"` @@ -561,13 +579,6 @@ type DeleteAccountResponse struct { Code int64 `json:"code"` } -type DailyTrafficStats struct { - Date string `json:"date"` - Upload int64 `json:"upload"` - Download int64 `json:"download"` - Total int64 `json:"total"` -} - type DeleteAdsRequest struct { Id int64 `json:"id"` } @@ -689,8 +700,15 @@ type EmailAuthticateConfig struct { DomainSuffixList string `json:"domain_suffix_list"` } -type ExportGroupResultRequest struct { - HistoryId *int64 `form:"history_id,omitempty"` +type EmailLoginRequest struct { + Identifier string `json:"identifier" form:"identifier"` + Email string `json:"email" form:"email" validate:"required,email"` + Code string `json:"code" form:"code" validate:"required"` + Invite string `json:"invite,optional" form:"invite"` + IP string `header:"X-Original-Forwarded-For"` + UserAgent string `header:"User-Agent"` + LoginType string `header:"Login-Type"` + CfToken string `json:"cf_token,optional" form:"cf_token"` } type ErrorLogMessage struct { @@ -708,6 +726,10 @@ type ErrorLogMessage struct { CreatedAt int64 `json:"created_at"` } +type ExportGroupResultRequest struct { + HistoryId *int64 `form:"history_id,omitempty"` +} + type FamilyDetail struct { Summary FamilySummary `json:"summary"` Members []FamilyMemberItem `json:"members"` @@ -740,6 +762,36 @@ type FamilySummary struct { UpdatedAt int64 `json:"updated_at"` } +type FileUploadCompleteRequest struct { + FileId string `json:"file_id" validate:"required"` +} + +type FileUploadCompleteResponse struct { + FileId string `json:"file_id"` + ObjectKey string `json:"object_key"` + Size int64 `json:"size"` + ContentType string `json:"content_type"` + Etag string `json:"etag"` + Status string `json:"status"` +} + +type FileUploadInitRequest struct { + BizType string `json:"biz_type" validate:"required"` + FileName string `json:"file_name" validate:"required"` + ContentType string `json:"content_type" validate:"required"` + Size int64 `json:"size" validate:"required"` + Sha256 string `json:"sha256"` +} + +type FileUploadInitResponse struct { + FileId string `json:"file_id"` + ObjectKey string `json:"object_key"` + UploadURL string `json:"upload_url"` + Method string `json:"method"` + Headers map[string]string `json:"headers"` + ExpiredAt int64 `json:"expired_at"` +} + type FilterBalanceLogRequest struct { FilterLogParams UserId int64 `form:"user_id,optional"` @@ -1048,6 +1100,15 @@ type GetDocumentListResponse struct { List []Document `json:"list"` } +type GetDownloadLinkRequest struct { + InviteCode string `form:"invite_code,optional"` + Platform string `form:"platform" validate:"required,oneof=windows mac ios android"` +} + +type GetDownloadLinkResponse struct { + Url string `json:"url"` +} + type GetErrorLogMessageDetailResponse struct { Id int64 `json:"id"` Platform string `json:"platform"` @@ -1118,18 +1179,6 @@ type GetGlobalConfigResponse struct { WebAd bool `json:"web_ad"` } -type GetInviteSalesRequest struct { - Page int `form:"page"` - Size int `form:"size"` - StartTime int64 `form:"start_time"` - EndTime int64 `form:"end_time"` -} - -type GetInviteSalesResponse struct { - Total int64 `json:"total"` - List []InvitedUserSale `json:"list"` -} - type GetGroupConfigRequest struct { Keys []string `form:"keys,omitempty"` } @@ -1161,6 +1210,18 @@ type GetGroupHistoryResponse struct { List []GroupHistory `json:"list"` } +type GetInviteSalesRequest struct { + Page int `form:"page"` + Size int `form:"size"` + StartTime int64 `form:"start_time"` + EndTime int64 `form:"end_time"` +} + +type GetInviteSalesResponse struct { + Total int64 `json:"total"` + List []InvitedUserSale `json:"list"` +} + type GetLoginLogRequest struct { Page int `form:"page"` Size int `form:"size"` @@ -1419,7 +1480,7 @@ type GetUserListRequest struct { FamilyStatus string `form:"family_status,omitempty"` FamilyOwnerUserId *int64 `form:"family_owner_user_id,omitempty"` FamilyId *int64 `form:"family_id,omitempty"` - SortOrder string `form:"sort_order,omitempty"` // asc or desc, default desc + SortOrder string `form:"sort_order,omitempty"` } type GetUserListResponse struct { @@ -1601,6 +1662,19 @@ type KickOfflineRequest struct { Id int64 `json:"id"` } +type LegacyCheckVerificationCodeRequest struct { + Method string `json:"method" form:"method" validate:"omitempty,oneof=email mobile"` + Account string `json:"account" form:"account"` + Email string `json:"email" form:"email"` + Code string `json:"code" form:"code" validate:"required"` + Type uint8 `json:"type" form:"type" validate:"required"` +} + +type LegacyCheckVerificationCodeResponse struct { + Status bool `json:"status"` + Exist bool `json:"exist"` +} + type LogResponse struct { List interface{} `json:"list"` } @@ -1765,8 +1839,8 @@ type Order struct { TradeNo string `json:"trade_no"` Status uint8 `json:"status"` SubscribeId int64 `json:"subscribe_id"` - CreatedAt int64 `json:"created_at"` // Unix milliseconds - UpdatedAt int64 `json:"updated_at"` // Unix milliseconds + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` } type OrderDetail struct { @@ -1789,8 +1863,8 @@ type OrderDetail struct { Status uint8 `json:"status"` SubscribeId int64 `json:"subscribe_id"` Subscribe Subscribe `json:"subscribe"` - CreatedAt int64 `json:"created_at"` // Unix milliseconds - UpdatedAt int64 `json:"updated_at"` // Unix milliseconds + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` } type OrdersStatistics struct { @@ -2246,6 +2320,10 @@ type RecoverOrderResponse struct { Message string `json:"message"` } +type RecoverySendCodeRequest struct { + Email string `json:"email" validate:"required,email"` +} + type RedeemCodeRequest struct { Code string `json:"code" validate:"required"` } @@ -2319,6 +2397,26 @@ type RenewalOrderResponse struct { AppAccountToken string `json:"app_account_token"` } +type ReportLogMessageRequest struct { + Platform string `json:"platform" validate:"required,max=32"` + AppVersion string `json:"app_version" validate:"required,max=64"` + OsName string `json:"os_name" validate:"max=64"` + OsVersion string `json:"os_version" validate:"max=64"` + DeviceId string `json:"device_id" validate:"required,max=255"` + UserId int64 `json:"user_id"` + SessionId string `json:"session_id" validate:"max=255"` + Level uint8 `json:"level"` + ErrorCode string `json:"error_code" validate:"max=128"` + Message string `json:"message" validate:"required,max=65535"` + Stack string `json:"stack" validate:"max=1048576"` + Context interface{} `json:"context"` + OccurredAt int64 `json:"occurred_at"` +} + +type ReportLogMessageResponse struct { + Id int64 `json:"id"` +} + type ResetAllSubscribeTokenResponse struct { Success bool `json:"success"` } @@ -2370,10 +2468,6 @@ type ResetTrafficOrderResponse struct { OrderNo string `json:"order_no"` } -type RecoverySendCodeRequest struct { - Email string `json:"email" validate:"required,email"` -} - type ResetUserSubscribeTokenRequest struct { UserSubscribeId int64 `json:"user_subscribe_id"` } @@ -3000,10 +3094,6 @@ type UpdateOrderStatusRequest struct { TradeNo string `json:"trade_no,omitempty"` } -type ActivateOrderRequest struct { - OrderNo string `json:"order_no" validate:"required"` -} - type UpdatePaymentMethodRequest struct { Id int64 `json:"id" validate:"required"` Name string `json:"name" validate:"required"` diff --git a/pkg/storage/s3.go b/pkg/storage/s3.go new file mode 100644 index 0000000..3306496 --- /dev/null +++ b/pkg/storage/s3.go @@ -0,0 +1,128 @@ +package storage + +import ( + "context" + "fmt" + "net/url" + "strings" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + awsconfig "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/aws/aws-sdk-go-v2/service/s3/types" + "github.com/perfect-panel/server/internal/config" +) + +type PresignUploadResult struct { + URL string + Method string + ExpiresAt int64 +} + +type ObjectMeta struct { + ETag string + ContentType string + ContentLength int64 +} + +type S3Store struct { + cfg config.S3Config + client *s3.Client + presign *s3.PresignClient +} + +func NewS3Store(ctx context.Context, cfg config.S3Config) (*S3Store, error) { + loaders := make([]func(*awsconfig.LoadOptions) error, 0, 3) + if cfg.Region != "" { + loaders = append(loaders, awsconfig.WithRegion(cfg.Region)) + } + if cfg.AccessKey != "" || cfg.SecretKey != "" || cfg.SessionToken != "" { + loaders = append(loaders, awsconfig.WithCredentialsProvider( + credentials.NewStaticCredentialsProvider(cfg.AccessKey, cfg.SecretKey, cfg.SessionToken), + )) + } + + awsCfg, err := awsconfig.LoadDefaultConfig(ctx, loaders...) + if err != nil { + return nil, err + } + + client := s3.NewFromConfig(awsCfg, func(o *s3.Options) { + o.UsePathStyle = cfg.UsePathStyle + if cfg.Endpoint != "" { + o.BaseEndpoint = aws.String(cfg.Endpoint) + } + }) + + return &S3Store{ + cfg: cfg, + client: client, + presign: s3.NewPresignClient(client), + }, nil +} + +func (s *S3Store) PresignPutObject(ctx context.Context, objectKey string, contentType string, expires time.Duration) (*PresignUploadResult, error) { + req, err := s.presign.PresignPutObject(ctx, &s3.PutObjectInput{ + Bucket: aws.String(s.cfg.Bucket), + Key: aws.String(objectKey), + ContentType: aws.String(contentType), + }, s3.WithPresignExpires(expires)) + if err != nil { + return nil, err + } + + return &PresignUploadResult{ + URL: req.URL, + Method: req.Method, + ExpiresAt: time.Now().Add(expires).Unix(), + }, nil +} + +func (s *S3Store) HeadObject(ctx context.Context, objectKey string) (*ObjectMeta, error) { + resp, err := s.client.HeadObject(ctx, &s3.HeadObjectInput{ + Bucket: aws.String(s.cfg.Bucket), + Key: aws.String(objectKey), + }) + if err != nil { + return nil, err + } + + return &ObjectMeta{ + ETag: strings.Trim(aws.ToString(resp.ETag), "\""), + ContentType: aws.ToString(resp.ContentType), + ContentLength: aws.ToInt64(resp.ContentLength), + }, nil +} + +func (s *S3Store) BuildObjectURL(objectKey string) string { + if s.cfg.PublicBaseURL != "" { + return strings.TrimRight(s.cfg.PublicBaseURL, "/") + "/" + strings.TrimLeft(objectKey, "/") + } + + if s.cfg.Endpoint != "" { + base := strings.TrimRight(s.cfg.Endpoint, "/") + return fmt.Sprintf("%s/%s/%s", base, url.PathEscape(s.cfg.Bucket), strings.TrimLeft(objectKey, "/")) + } + + return fmt.Sprintf("https://%s.s3.%s.amazonaws.com/%s", s.cfg.Bucket, s.cfg.Region, strings.TrimLeft(objectKey, "/")) +} + +func (s *S3Store) PutJSONTagging(ctx context.Context, objectKey string, values map[string]string) error { + tagging := make([]types.Tag, 0, len(values)) + for k, v := range values { + key := k + value := v + tagging = append(tagging, types.Tag{Key: &key, Value: &value}) + } + + _, err := s.client.PutObjectTagging(ctx, &s3.PutObjectTaggingInput{ + Bucket: aws.String(s.cfg.Bucket), + Key: aws.String(objectKey), + Tagging: &types.Tagging{ + TagSet: tagging, + }, + }) + return err +} diff --git a/ppanel.api b/ppanel.api index 661a8c8..b6d8726 100644 --- a/ppanel.api +++ b/ppanel.api @@ -40,6 +40,8 @@ import ( "apis/public/ticket.api" "apis/public/payment.api" "apis/public/document.api" + "apis/public/file.api" "apis/public/portal.api" "apis/public/iap.api" ) +