Merge pull request #977 from trheyi/main

Update dependencies in go.mod and go.sum; refactor file handling in n…
This commit is contained in:
Max 2025-05-31 10:07:56 +08:00 committed by GitHub
commit 1cedd5ef4c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 3235 additions and 176 deletions

4
go.mod
View file

@ -23,6 +23,7 @@ require (
github.com/hashicorp/go-multierror v1.1.1
github.com/joho/godotenv v1.5.1
github.com/json-iterator/go v1.1.12
github.com/kaptinlin/jsonrepair v0.1.1
github.com/mozillazg/go-pinyin v0.20.0
github.com/pkoukk/tiktoken-go v0.1.7
github.com/rhysd/go-github-selfupdate v1.2.3
@ -59,7 +60,6 @@ require (
github.com/bytedance/sonic/loader v0.2.4 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.5 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect
@ -86,7 +86,6 @@ require (
github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jmoiron/sqlx v1.4.0 // indirect
github.com/kaptinlin/jsonrepair v0.1.1 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
@ -97,7 +96,6 @@ require (
github.com/miekg/dns v1.1.66 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
github.com/montanaflynn/stats v0.7.1 // indirect
github.com/oklog/run v1.1.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect

132
go.sum
View file

@ -1,59 +1,33 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/PuerkitoBio/goquery v1.10.1 h1:Y8JGYUkXWTGRB6Ars3+j3kN0xg1YqqlwvdTV8WTFQcU=
github.com/PuerkitoBio/goquery v1.10.1/go.mod h1:IYiHrOMps66ag56LEH7QYDDupKXyo5A8qrjIx3ZtujY=
github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo=
github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y=
github.com/TylerBrock/colorjson v0.0.0-20200706003622-8a50f05110d2 h1:ZBbLwSJqkHBuFDA6DUhhse0IGJ7T5bemHyNILUjvOq4=
github.com/TylerBrock/colorjson v0.0.0-20200706003622-8a50f05110d2/go.mod h1:VSw57q4QFiWDbRnjdX8Cb3Ow0SFncRw+bA/ofY6Q83w=
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
github.com/aws/aws-sdk-go-v2 v1.32.7 h1:ky5o35oENWi0JYWUZkB7WYvVPP+bcRF5/Iq7JWSb5Rw=
github.com/aws/aws-sdk-go-v2 v1.32.7/go.mod h1:P5WJBrYqqbWVaOxgH0X/FYYD47/nooaPOZPlQdmiN2U=
github.com/aws/aws-sdk-go-v2 v1.36.3 h1:mJoei2CxPutQVxaATCzDUjcZEjVRdpsiiXi2o38yqWM=
github.com/aws/aws-sdk-go-v2 v1.36.3/go.mod h1:LLXuLpgzEbD766Z5ECcRmi8AzSwfZItDtmABVkRLGzg=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.7 h1:lL7IfaFzngfx0ZwUGOZdsFFnQ5uLvR0hWqqhyE7Q9M8=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.7/go.mod h1:QraP0UcVlQJsmHfioCrveWOC1nbiWUl3ej08h4mXWoc=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10 h1:zAybnyUQXIZ5mok5Jqwlf58/TFE7uvd3IAsa1aF9cXs=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10/go.mod h1:qqvMj6gHLR/EXWZw4ZbqlPbQUyenf4h82UQUlKc+l14=
github.com/aws/aws-sdk-go-v2/credentials v1.17.48 h1:IYdLD1qTJ0zanRavulofmqut4afs45mOWEI+MzZtTfQ=
github.com/aws/aws-sdk-go-v2/credentials v1.17.48/go.mod h1:tOscxHN3CGmuX9idQ3+qbkzrjVIx32lqDSU1/0d/qXs=
github.com/aws/aws-sdk-go-v2/credentials v1.17.67 h1:9KxtdcIA/5xPNQyZRgUSpYOE6j9Bc4+D7nZua0KGYOM=
github.com/aws/aws-sdk-go-v2/credentials v1.17.67/go.mod h1:p3C44m+cfnbv763s52gCqrjaqyPikj9Sg47kUVaNZQQ=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.26 h1:I/5wmGMffY4happ8NOCuIUEWGUvvFp5NSeQcXl9RHcI=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.26/go.mod h1:FR8f4turZtNy6baO0KJ5FJUmXH/cSkI9fOngs0yl6mA=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 h1:ZK5jHhnrioRkUNOc+hOgQKlUL5JeC3S6JgLxtQ+Rm0Q=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34/go.mod h1:p4VfIceZokChbA9FzMbRGz5OV+lekcVtHlPKEO0gSZY=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.26 h1:zXFLuEuMMUOvEARXFUVJdfqZ4bvvSgdGRq/ATcrQxzM=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.26/go.mod h1:3o2Wpy0bogG1kyOPrgkXA8pgIfEEv0+m19O9D5+W8y8=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 h1:SZwFm17ZUNNg5Np0ioo/gq8Mn6u9w19Mri8DnJ15Jf0=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34/go.mod h1:dFZsC0BLo346mvKQLWmoJxT+Sjp+qcVR1tRVHQGOH9Q=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.26 h1:GeNJsIFHB+WW5ap2Tec4K6dzcVTsRbsT1Lra46Hv9ME=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.26/go.mod h1:zfgMpwHDXX2WGoG84xG2H+ZlPTkJUU4YUvx2svLQYWo=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.34 h1:ZNTqv4nIdE/DiBfUUfXcLZ/Spcuz+RjeziUtNJackkM=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.34/go.mod h1:zf7Vcd1ViW7cPqYWEHLHJkS50X0JS2IKz9Cgaj6ugrs=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.1 h1:iXtILhvDxB6kPvEXgsDhGaZCSC6LQET5ZHSdJozeI0Y=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.1/go.mod h1:9nu0fVANtYiAePIBh2/pFUSwtJ402hLnp854CNoDOeE=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 h1:eAh2A4b5IzM/lum78bZ590jy36+d/aFLgKF/4Vd1xPE=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3/go.mod h1:0yKJC/kb8sAnmlYa6Zs3QVYqaC8ug2AbnNChv5Ox3uA=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.4.7 h1:tB4tNw83KcajNAzaIMhkhVI2Nt8fAZd5A5ro113FEMY=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.4.7/go.mod h1:lvpyBGkZ3tZ9iSsUIcC2EWp+0ywa7aK3BLT+FwZi+mQ=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.1 h1:4nm2G6A4pV9rdlWzGMPv4BNtQp22v1hg3yrtkYpeLl8=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.1/go.mod h1:iu6FSzgt+M2/x3Dk8zhycdIcHjEFb36IS8HVUVFoMg0=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.7 h1:8eUsivBQzZHqe/3FE+cqwfH+0p5Jo8PFM/QYQSmeZ+M=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.7/go.mod h1:kLPQvGUmxn/fqiCrDeohwG33bq2pQpGeY62yRO6Nrh0=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 h1:dM9/92u2F1JbDaGooxTq18wmmFzbJRfXfVfy96/1CXM=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15/go.mod h1:SwFBy2vjtA0vZbjjaFtfN045boopadnoVPhu4Fv66vY=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.7 h1:Hi0KGbrnr57bEHWM0bJ1QcBzxLrL/k2DHvGYhb8+W1w=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.7/go.mod h1:wKNgWgExdjjrm4qvfbTorkvocEstaoDl4WCvGfeCy9c=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.15 h1:moLQUoVq91LiqT1nbvzDukyqAlCv89ZmwaHw/ZFlFZg=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.15/go.mod h1:ZH34PJUc8ApjBIfgQCFvkWcUDBtl/WTD+uiYHjd8igA=
github.com/aws/aws-sdk-go-v2/service/s3 v1.71.1 h1:aOVVZJgWbaH+EJYPvEgkNhCEbXXvH7+oML36oaPK3zE=
github.com/aws/aws-sdk-go-v2/service/s3 v1.71.1/go.mod h1:r+xl5yzMk9083rMR+sJ5TYj9Tihvf/l1oxzZXDgGj2Q=
github.com/aws/aws-sdk-go-v2/service/s3 v1.79.3 h1:BRXS0U76Z8wfF+bnkilA2QwpIch6URlm++yPUt9QPmQ=
github.com/aws/aws-sdk-go-v2/service/s3 v1.79.3/go.mod h1:bNXKFFyaiVvWuR6O16h/I1724+aXe/tAkA9/QS01t5k=
github.com/aws/smithy-go v1.22.1 h1:/HPHZQ0g7f4eUeK6HKglFz8uwVfZKgoI25rb/J+dnro=
github.com/aws/smithy-go v1.22.1/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg=
github.com/aws/smithy-go v1.22.3 h1:Z//5NuZCSW6R4PhQ93hShNbyBbn8BWCmCVCt+Q8Io5k=
github.com/aws/smithy-go v1.22.3/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI=
github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ=
@ -62,26 +36,18 @@ github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM
github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ=
github.com/bufbuild/protocompile v0.4.0 h1:LbFKd2XowZvQ/kajzguUp2DC9UEIQhIq77fZZlaQsNA=
github.com/bufbuild/protocompile v0.4.0/go.mod h1:3v93+mbWn/v3xzN+31nwkJfrEpAUwp+BagBSZWx+TP8=
github.com/bytedance/sonic v1.12.6 h1:/isNmCUF2x3Sh8RAp/4mh4ZGkcFAX/hLrzrK3AvpRzk=
github.com/bytedance/sonic v1.12.6/go.mod h1:B8Gt/XvtZ3Fqj+iSKMypzymZxw/FVwgIGKzMzT9r/rk=
github.com/bytedance/sonic v1.13.2 h1:8/H1FempDZqC4VqjptGo14QQlJx8VdZJegxs6wwfqpQ=
github.com/bytedance/sonic v1.13.2/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/bytedance/sonic/loader v0.2.1 h1:1GgorWTqf12TA8mma4DDSbaQigE2wOgQo7iCjjJv3+E=
github.com/bytedance/sonic/loader v0.2.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY=
github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/caarlos0/env/v6 v6.10.1 h1:t1mPSxNpei6M5yAeu1qtRdPAK29Nbcf/n3G7x+b3/II=
github.com/caarlos0/env/v6 v6.10.1/go.mod h1:hvp/ryKXKipEkcuYjs9mI4bBCg+UI0Yhgm5Zu0ddvwc=
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/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
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=
@ -90,18 +56,12 @@ github.com/dchest/captcha v1.1.0 h1:2kt47EoYUUkaISobUdTbqwx55xvKOJxyScVfw25xzhQ=
github.com/dchest/captcha v1.1.0/go.mod h1:7zoElIawLp7GUMLcj54K9kbw+jEyvz2K0FDdRRYhvWo=
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/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/elazarl/go-bindata-assetfs v1.0.1 h1:m0kkaHRKEu7tUIUFVwhGGGYClXvyl4RE03qmvRTNfbw=
github.com/elazarl/go-bindata-assetfs v1.0.1/go.mod h1:v+YaWX3bdea5J/mo8dSETolEo7R71Vk1u8bnjau5yw4=
github.com/evanw/esbuild v0.24.2 h1:PQExybVBrjHjN6/JJiShRGIXh1hWVm6NepVnhZhrt0A=
github.com/evanw/esbuild v0.24.2/go.mod h1:D2vIQZqV/vIf/VRHtViaUtViZmG7o+kKmlBfVQuRi48=
github.com/evanw/esbuild v0.25.4 h1:k1bTSim+usBG27w7BfOCorhgx3tO+6bAfMj5pR+6SKg=
github.com/evanw/esbuild v0.25.4/go.mod h1:D2vIQZqV/vIf/VRHtViaUtViZmG7o+kKmlBfVQuRi48=
github.com/expr-lang/expr v1.17.2 h1:o0A99O/Px+/DTjEnQiodAgOIK9PPxL8DtXhBRKC+Iso=
github.com/expr-lang/expr v1.17.2/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4=
github.com/expr-lang/expr v1.17.3 h1:myeTTuDFz7k6eFe/JPlep/UsiIjVhG61FMHFu63U7j0=
github.com/expr-lang/expr v1.17.3/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4=
github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
@ -110,20 +70,12 @@ github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/
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.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
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.7 h1:SKFKl7kD0RiPdbht0s7hFtjl489WcQ1VyPW8ZzUMYCA=
github.com/gabriel-vasile/mimetype v1.4.7/go.mod h1:GDlAgAyIRT27BhFl53XNAFtfjzOkLaF35JdEG0P7LtU=
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
github.com/gin-contrib/sse v1.0.0 h1:y3bT1mUWUxDpW4JLQg/HnTqV4rozuW4tC9eFKTxYI9E=
github.com/gin-contrib/sse v1.0.0/go.mod h1:zNuFdwarAygJBht0NTKiSi3jRf6RbqeILZ9Sp6Slhe0=
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.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-errors/errors v1.5.1 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8bk=
@ -138,20 +90,15 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o
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=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.23.0 h1:/PwmTwZhS0dPkav3cdK9kV1FsAmrL8sThn8IHr/sO+o=
github.com/go-playground/validator/v10 v10.23.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k=
github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI=
github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo=
github.com/go-sourcemap/sourcemap v2.1.4+incompatible h1:a+iTbH5auLKxaNwQFg0B+TCYl6lbukKPc7b5x0n1s6Q=
github.com/go-sourcemap/sourcemap v2.1.4+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/go-sql-driver/mysql v1.9.2 h1:4cNKDYQ1I84SXslGddlsrMhc8k4LeDVj6Ad6WRjiHuU=
github.com/go-sql-driver/mysql v1.9.2/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/goccy/go-json v0.10.4 h1:JSwxQzIqKfmFX1swYPpUThQZp/Ka4wzJdK0LWVytLPM=
github.com/goccy/go-json v0.10.4/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
@ -160,8 +107,6 @@ github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5y
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
@ -184,8 +129,6 @@ github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB1
github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
github.com/hashicorp/go-plugin v1.6.2 h1:zdGAEd0V1lCaU0u+MxWQhtSDQmahpkwOun8U8EiRVog=
github.com/hashicorp/go-plugin v1.6.2/go.mod h1:CkgLQ5CZqNmdL9U9JzM532t8ZiYQ35+pj3b1FD37R0Q=
github.com/hashicorp/go-plugin v1.6.3 h1:xgHB+ZUSYeuJi96WtxEjzi23uh7YQpznjGh0U0UUrwg=
github.com/hashicorp/go-plugin v1.6.3/go.mod h1:MRobyh+Wc/nYy1V4KAXUiYfzxoYhs7V1mlH1Z7iY2h0=
github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c=
@ -209,13 +152,9 @@ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnr
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/kaptinlin/jsonrepair v0.1.1 h1:Ddn1sN1cZXuXeKA9vpaHAtBETnGSFBZFaaYfoN2Uo8c=
github.com/kaptinlin/jsonrepair v0.1.1/go.mod h1:SivjE7np/GsSrk7UX/9mibH6VF8cVpD2aUmg7vceg2k=
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY=
github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8=
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
@ -239,12 +178,8 @@ github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27k
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/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A=
github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/miekg/dns v1.1.62 h1:cN8OuEF1/x5Rq6Np+h1epln8OiyPWV+lROx9LxcGgIQ=
github.com/miekg/dns v1.1.62/go.mod h1:mvDlcItzm+br7MToIKqkglaGhlFMHJ9DTNNWONWXbNQ=
github.com/miekg/dns v1.1.66 h1:FeZXOS3VCVsKnEAd+wBkjMC3D2K+ww66Cq3VnCINuJE=
github.com/miekg/dns v1.1.66/go.mod h1:jGFzBsSNbJw6z1HYut1RKBKHA9PBdxeHrZG8J+gC2WE=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@ -252,8 +187,6 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
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/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE=
github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
github.com/mozillazg/go-pinyin v0.20.0 h1:BtR3DsxpApHfKReaPO1fCqF4pThRwH9uwvXzm+GnMFQ=
@ -268,16 +201,12 @@ github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042
github.com/onsi/gomega v1.4.2/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
github.com/onsi/gomega v1.18.1 h1:M1GfJqGRrBrrGGsbxzV5dqM2U2ApXefZCQpkukxYRLE=
github.com/onsi/gomega v1.18.1/go.mod h1:0q+aL8jAiMXy9hbwj2mr5GziHiwhAIQpFmmtT5hitRs=
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
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/pkoukk/tiktoken-go v0.1.7 h1:qOBHXX4PHtvIvmOtyg1EeKlwFRiMKAcoMp4Q+bLQDmw=
github.com/pkoukk/tiktoken-go v0.1.7/go.mod h1:9NiV+i9mJKGj1rYOT+njbv+ZwA/zJxYdewGl6qVatpg=
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/qdrant/go-client v1.12.0 h1:KqsIKDAw5iQmxDzRjbzRjhvQ+Igyr7Y84vDCinf1T4M=
github.com/qdrant/go-client v1.12.0/go.mod h1:zFa6t5Y3Oqecoa0aSsGWhMqQWq3x3kTPvm0sMf5qplw=
github.com/qdrant/go-client v1.14.0 h1:cyz9OOooAexudw5w69LRe9vKCQFYJvaFvt9icOciI1U=
github.com/qdrant/go-client v1.14.0/go.mod h1:iO8ts78jL4x6LDHFOViyYWELVtIBDTjOykBmiOTHLnQ=
github.com/rhysd/go-github-selfupdate v1.2.3 h1:iaa+J202f+Nc+A8zi75uccC8Wg3omaM7HDeimXA22Ag=
@ -296,22 +225,15 @@ github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/cast v1.8.0 h1:gEN9K4b8Xws4EX0+a0reLmhq8moKn7ntRlQYgjPeCDk=
github.com/spf13/cast v1.8.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
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.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
@ -319,7 +241,6 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tcnksm/go-gitconfig v0.1.2 h1:iiDhRitByXAEyjgBqsKi9QU4o2TNtv9kPP3RgPgXBPw=
@ -355,50 +276,35 @@ github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZ
github.com/ulikunitz/xz v0.5.9/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/ulikunitz/xz v0.5.12 h1:37Nm15o69RwBkXM0J6A5OlE67RZTfzUxTj8fB3dfcsc=
github.com/ulikunitz/xz v0.5.12/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/watchfultele/jsonrepair v0.0.0-20250207052432-e4397ed42611 h1:CWCIUJ4cqhc3ct+HbV4EJtBdRFjUVOBne8mmIHXxU6A=
github.com/watchfultele/jsonrepair v0.0.0-20250207052432-e4397ed42611/go.mod h1:63urp6bG6c9cw3DuFtNEr5g9tbjFXaPTn0sBiBDVB7g=
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY=
github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4=
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
github.com/xuri/efp v0.0.0-20241211021726-c4e992084aa6 h1:8m6DWBG+dlFNbx5ynvrE7NgI+Y7OlZVMVTpayoW+rCc=
github.com/xuri/efp v0.0.0-20241211021726-c4e992084aa6/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8=
github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
github.com/xuri/excelize/v2 v2.9.0 h1:1tgOaEq92IOEumR1/JfYS/eR0KHOCsRv/rYXXh6YJQE=
github.com/xuri/excelize/v2 v2.9.0/go.mod h1:uqey4QBZ9gdMeWApPLdhm9x+9o2lq4iVmjiLfBS5hdE=
github.com/xuri/excelize/v2 v2.9.1 h1:VdSGk+rraGmgLHGFaGG9/9IWu1nj4ufjJ7uwMDtj8Qw=
github.com/xuri/excelize/v2 v2.9.1/go.mod h1:x7L6pKz2dvo9ejrRuD8Lnl98z4JLt0TGAwjhW+EiP8s=
github.com/xuri/nfp v0.0.0-20240318013403-ab9948c2c4a7 h1:hPVCafDV85blFTabnqKgNhDCkJX25eik94Si9cTER4A=
github.com/xuri/nfp v0.0.0-20240318013403-ab9948c2c4a7/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
github.com/xuri/nfp v0.0.1 h1:MDamSGatIvp8uOmDP8FnmjuQpu90NzdJxo7242ANR9Q=
github.com/xuri/nfp v0.0.1/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.mongodb.org/mongo-driver v1.17.1 h1:Wic5cJIwJgSpBhe3lx3+/RybR5PiYRMpVFgO7cOHyIM=
go.mongodb.org/mongo-driver v1.17.1/go.mod h1:wwWm/+BuOddhcq3n68LKRmgk2wXzmF6s0SFOa0GINL4=
go.mongodb.org/mongo-driver v1.17.3 h1:TQyXhnsWfWtgAhMtOgtYHMTkZIfBTpMTsMnd9ZBeHxQ=
go.mongodb.org/mongo-driver v1.17.3/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ=
go.opentelemetry.io/otel v1.31.0 h1:NsJcKPIW0D0H3NgzPDHmo0WW6SptzPdqg/L1zsIm2hY=
go.opentelemetry.io/otel v1.31.0/go.mod h1:O0C14Yl9FgkjqcCZAsE053C13OaddMYr/hz6clDkEJE=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY=
go.opentelemetry.io/otel/metric v1.31.0 h1:FSErL0ATQAmYHUIzSezZibnyVlft1ybhy4ozRPcF2fE=
go.opentelemetry.io/otel/metric v1.31.0/go.mod h1:C3dEloVbLuYoX41KpmAhOqNriGbA+qqH6PQ5E5mUfnY=
go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI=
go.opentelemetry.io/otel/metric v1.34.0 h1:+eTR3U0MyfWjRDhmFMxe2SsW64QrZ84AOhvqS7Y+PoQ=
go.opentelemetry.io/otel/sdk v1.31.0 h1:xLY3abVHYZ5HSfOg3l2E5LUj2Cwva5Y7yGxnSW9H5Gk=
go.opentelemetry.io/otel/sdk v1.31.0/go.mod h1:TfRbMdhvxIIr/B2N2LQW2S5v9m3gOQ/08KsbbO5BPT0=
go.opentelemetry.io/otel/metric v1.34.0/go.mod h1:CEDrp0fy2D0MvkXE+dPV7cMi8tWZwX3dmaIhwPOaqHE=
go.opentelemetry.io/otel/sdk v1.34.0 h1:95zS4k/2GOy069d321O8jWgYsW3MzVV+KuSPKp7Wr1A=
go.opentelemetry.io/otel/sdk/metric v1.31.0 h1:i9hxxLJF/9kkvfHppyLL55aW7iIJz4JjxTeYusH7zMc=
go.opentelemetry.io/otel/sdk/metric v1.31.0/go.mod h1:CRInTMVvNhUKgSAMbKyTMxqOBC0zgyxzW55lZzX43Y8=
go.opentelemetry.io/otel/sdk v1.34.0/go.mod h1:0e/pNiaMAqaykJGKbi+tSjWfNNHMTxoC9qANsCzbyxU=
go.opentelemetry.io/otel/sdk/metric v1.34.0 h1:5CeK9ujjbFVL5c1PhLuStg1wxA7vQv7ce1EK0Gyvahk=
go.opentelemetry.io/otel/trace v1.31.0 h1:ffjsj1aRouKewfr85U2aGagJ46+MvodynlQ1HYdmJys=
go.opentelemetry.io/otel/trace v1.31.0/go.mod h1:TXZkRk7SM2ZQLtR6eoAWQFIHPvzQ06FJAsO1tJg480A=
go.opentelemetry.io/otel/sdk/metric v1.34.0/go.mod h1:jQ/r8Ze28zRKoNRdkjCZxfs6YvBTG1+YIqyFVFYec5w=
go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k=
golang.org/x/arch v0.12.0 h1:UsYJhbzPYGsT0HbEdmYcqtCv8UNGvnaL561NnIUvaKg=
golang.org/x/arch v0.12.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE=
golang.org/x/arch v0.17.0 h1:4O3dfLzd+lQewptAHqjewQZQDyEdejz3VwgeYwkZneU=
golang.org/x/arch v0.17.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
@ -408,12 +314,8 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68=
golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY=
golang.org/x/image v0.27.0 h1:C8gA4oWU/tKkdCfYT6T2u4faJu3MeNS5O8UPWlPF61w=
golang.org/x/image v0.27.0/go.mod h1:xbdrClrAUway1MUTEZDq9mz/UpRwYAkFFNUslZtcB+g=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
@ -421,8 +323,6 @@ 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/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4=
golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@ -438,14 +338,10 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.24.0 h1:KTBBxWqUa0ykRPLtV69rRto9TLXcqYkeswu48x/gvNE=
golang.org/x/oauth2 v0.24.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@ -456,8 +352,6 @@ 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.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@ -480,8 +374,6 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.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.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
@ -505,8 +397,6 @@ 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.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@ -515,24 +405,16 @@ 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/tools v0.28.0 h1:WuB6qZ4RPCQo5aP3WdKZS7i595EdWqWR8vqJTlwTVK8=
golang.org/x/tools v0.28.0/go.mod h1:dcIOrVd3mfQKTgrDVQHqCPMWy6lnhfhtX3hLXYVLfRw=
golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc=
golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/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.3.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/genproto/googleapis/rpc v0.0.0-20241230172942-26aa7a208def h1:4P81qv5JXI/sDNae2ClVx88cgDDA6DPilADkG9tYKz8=
google.golang.org/genproto/googleapis/rpc v0.0.0-20241230172942-26aa7a208def/go.mod h1:bdAgzvd4kFrpykc5/AC2eLUiegK9T/qxZHD4hXYf/ho=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250519155744-55703ea1f237 h1:cJfm9zPbe1e873mHJzmQ1nwVEeRDU/T1wXDK2kUSU34=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250519155744-55703ea1f237/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
google.golang.org/grpc v1.69.2 h1:U3S9QEtbXC0bYNvRtcoklF3xGtLViumSYxWykJS+7AU=
google.golang.org/grpc v1.69.2/go.mod h1:vyjdE6jLBI76dgpDojsFGNaHlxdjXN9ghpnd2o7JGZ4=
google.golang.org/grpc v1.72.1 h1:HR03wO6eyZ7lknl75XlxABNVLLFc2PAb6mHlYh756mA=
google.golang.org/grpc v1.72.1/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM=
google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk=
google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View file

@ -3,7 +3,6 @@ package assistant
import (
"context"
"io"
"mime/multipart"
"github.com/gin-gonic/gin"
"github.com/yaoapp/gou/rag/driver"
@ -22,9 +21,9 @@ const (
// API the assistant API interface
type API interface {
Chat(ctx context.Context, messages []message.Message, option map[string]interface{}, cb func(data []byte) int) error
Upload(ctx context.Context, file *multipart.FileHeader, reader io.Reader, option map[string]interface{}) (*File, error)
Download(ctx context.Context, fileID string) (*FileResponse, error)
ReadBase64(ctx context.Context, fileID string) (string, error)
// Upload(ctx context.Context, file *multipart.FileHeader, reader io.Reader, option map[string]interface{}) (*File, error)
// Download(ctx context.Context, fileID string) (*FileResponse, error)
// ReadBase64(ctx context.Context, fileID string) (string, error)
GetPlaceholder(locale string) *Placeholder
Execute(c *gin.Context, ctx chatctx.Context, input interface{}, options map[string]interface{}, callback ...interface{}) (interface{}, error)

450
neo/attachment/README.md Normal file
View file

@ -0,0 +1,450 @@
# Attachment Package
A comprehensive file upload package for Go that supports chunked uploads, file format validation, compression, and multiple storage backends.
## Features
- **Multiple Storage Backends**: Local filesystem and S3-compatible storage
- **Chunked Upload Support**: Handle large files with standard HTTP Content-Range headers
- **File Compression**:
- Gzip compression for any file type
- Image compression with configurable size limits
- **File Validation**:
- File size limits
- MIME type and extension validation
- Wildcard pattern support (e.g., `image/*`, `text/*`)
- **Flexible File Organization**: Hierarchical storage with user/chat/assistant organization
- **Multiple Read Methods**: Stream, bytes, and base64 encoding
- **Global Manager Registry**: Support for registering and accessing managers globally
## Installation
```bash
go get github.com/yaoapp/yao/neo/attachment
```
## Quick Start
### Basic Usage
```go
package main
import (
"context"
"strings"
"mime/multipart"
"github.com/yaoapp/yao/neo/attachment"
)
func main() {
// Create a manager
manager, err := attachment.New(attachment.ManagerOption{
Driver: "local",
MaxSize: "20M",
ChunkSize: "2M",
AllowedTypes: []string{"text/*", "image/*", ".pdf"},
Options: map[string]interface{}{
"path": "/var/uploads",
},
})
if err != nil {
panic(err)
}
// Upload a file
content := "Hello, World!"
fileHeader := &attachment.FileHeader{
FileHeader: &multipart.FileHeader{
Filename: "hello.txt",
Size: int64(len(content)),
Header: make(map[string][]string),
},
}
fileHeader.Header.Set("Content-Type", "text/plain")
option := attachment.UploadOption{
UserID: "user123",
ChatID: "chat456",
}
file, err := manager.Upload(context.Background(), fileHeader, strings.NewReader(content), option)
if err != nil {
panic(err)
}
// Read the file back
data, err := manager.Read(context.Background(), file.ID)
if err != nil {
panic(err)
}
println(string(data)) // Output: Hello, World!
}
```
### Storage Backends
#### Local Storage
```go
manager, err := attachment.New(attachment.ManagerOption{
Driver: "local",
MaxSize: "20M",
Options: map[string]interface{}{
"path": "/var/uploads",
"base_url": "https://example.com/files",
},
})
```
#### S3 Storage
```go
manager, err := attachment.New(attachment.ManagerOption{
Driver: "s3",
MaxSize: "100M",
Options: map[string]interface{}{
"endpoint": "https://s3.amazonaws.com",
"region": "us-east-1",
"key": "your-access-key",
"secret": "your-secret-key",
"bucket": "your-bucket-name",
"prefix": "attachments/",
},
})
```
### Chunked Upload
For large files, you can upload in chunks using standard HTTP Content-Range headers:
```go
// Upload chunks
totalSize := int64(1024000) // 1MB file
chunkSize := int64(1024) // 1KB chunks
uid := "unique-file-id-123"
for start := int64(0); start < totalSize; start += chunkSize {
end := start + chunkSize - 1
if end >= totalSize {
end = totalSize - 1
}
chunkData := make([]byte, end-start+1)
// ... fill chunkData with actual data ...
chunkHeader := &attachment.FileHeader{
FileHeader: &multipart.FileHeader{
Filename: "large_file.zip",
Size: end - start + 1,
Header: make(map[string][]string),
},
}
chunkHeader.Header.Set("Content-Type", "application/zip")
chunkHeader.Header.Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, totalSize))
chunkHeader.Header.Set("Content-Uid", uid)
file, err := manager.Upload(ctx, chunkHeader, bytes.NewReader(chunkData), option)
if err != nil {
return err
}
// File is complete when the last chunk is uploaded
if chunkHeader.Complete() {
fmt.Printf("Upload complete: %s\n", file.ID)
break
}
}
```
### Compression
#### Gzip Compression
```go
option := attachment.UploadOption{
Gzip: true, // Enable gzip compression
}
file, err := manager.Upload(ctx, fileHeader, reader, option)
```
#### Image Compression
```go
option := attachment.UploadOption{
CompressImage: true,
CompressSize: 1920, // Max dimension in pixels (default: 1920)
}
file, err := manager.Upload(ctx, imageHeader, imageReader, option)
```
### File Validation
#### Size Limits
```go
manager, err := attachment.New(attachment.ManagerOption{
MaxSize: "20M", // Maximum file size
// Supports: B, K, M, G (e.g., "1024B", "2K", "10M", "1G")
})
```
#### Type Validation
```go
manager, err := attachment.New(attachment.ManagerOption{
AllowedTypes: []string{
"text/*", // All text types
"image/*", // All image types
"application/pdf", // Specific MIME type
".txt", // File extension
".jpg", // File extension
},
})
```
### Reading Files
#### Stream Reading
```go
response, err := manager.Download(ctx, fileID)
if err != nil {
return err
}
defer response.Reader.Close()
// Use response.Reader as io.ReadCloser
// response.ContentType contains the MIME type
// response.Extension contains the file extension
```
#### Read as Bytes
```go
data, err := manager.Read(ctx, fileID)
if err != nil {
return err
}
// data is []byte
```
#### Read as Base64
```go
base64Data, err := manager.ReadBase64(ctx, fileID)
if err != nil {
return err
}
// base64Data is string
```
### Global Managers
You can register managers globally for easy access:
```go
// Register managers
attachment.Register("local", "local", attachment.ManagerOption{
Driver: "local",
Options: map[string]interface{}{
"path": "/var/uploads",
},
})
attachment.Register("s3", "s3", attachment.ManagerOption{
Driver: "s3",
Options: map[string]interface{}{
"bucket": "my-bucket",
"key": "access-key",
"secret": "secret-key",
},
})
// Use global managers
localManager := attachment.Managers["local"]
s3Manager := attachment.Managers["s3"]
```
## File Organization
Files are organized in a hierarchical structure:
```
attachments/
├── 20240101/ # Date (YYYYMMDD)
│ └── user123/ # User ID (optional)
│ └── chat456/ # Chat ID (optional)
│ └── assistant789/ # Assistant ID (optional)
│ └── ab/ # First 2 chars of hash
│ └── cd/ # Next 2 chars of hash
│ └── abcdef12.txt # Hash + extension
```
The file ID generation includes:
- Date prefix for organization
- User/Chat/Assistant IDs for access control
- Content hash for deduplication
- Original file extension
## API Reference
### Manager
#### `New(option ManagerOption) (*Manager, error)`
Creates a new attachment manager.
#### `Register(name string, driver string, option ManagerOption) (*Manager, error)`
Registers a global attachment manager.
#### `Upload(ctx context.Context, fileheader *FileHeader, reader io.Reader, option UploadOption) (*File, error)`
Uploads a file (supports chunked upload).
#### `Download(ctx context.Context, fileID string) (*FileResponse, error)`
Downloads a file as a stream.
#### `Read(ctx context.Context, fileID string) ([]byte, error)`
Reads a file as bytes.
#### `ReadBase64(ctx context.Context, fileID string) (string, error)`
Reads a file as base64 encoded string.
### Storage Interface
All storage backends implement the following interface:
```go
type Storage interface {
Upload(ctx context.Context, fileID string, reader io.Reader, contentType string) (string, error)
UploadChunk(ctx context.Context, fileID string, chunkIndex int, reader io.Reader, contentType string) error
MergeChunks(ctx context.Context, fileID string, totalChunks int) error
Download(ctx context.Context, fileID string) (io.ReadCloser, string, error)
Reader(ctx context.Context, fileID string) (io.ReadCloser, error)
URL(ctx context.Context, fileID string) string
Exists(ctx context.Context, fileID string) bool
Delete(ctx context.Context, fileID string) error
}
```
### Types
#### `ManagerOption`
Configuration for creating a manager:
- `Driver`: "local" or "s3"
- `MaxSize`: Maximum file size (e.g., "20M")
- `ChunkSize`: Chunk size for uploads (e.g., "2M")
- `AllowedTypes`: Array of allowed MIME types/extensions
- `Options`: Driver-specific options
#### `UploadOption`
Options for file upload:
- `CompressImage`: Enable image compression
- `CompressSize`: Maximum image dimension (default: 1920)
- `Gzip`: Enable gzip compression
- `UserID`, `ChatID`, `AssistantID`: Organization IDs
#### `File`
Uploaded file information:
- `ID`: Unique file identifier
- `Filename`: Original filename
- `ContentType`: MIME type
- `Bytes`: File size
- `CreatedAt`: Upload timestamp
#### `FileResponse`
Download response:
- `Reader`: io.ReadCloser for file content
- `ContentType`: MIME type
- `Extension`: File extension
## Chunked Upload Details
The package supports chunked uploads using standard HTTP headers:
- `Content-Range`: Specifies byte range (e.g., "bytes 0-1023/2048")
- `Content-Uid`: Unique identifier for the file being uploaded
### Chunk Index Calculation
The package uses a standard chunk size (1024 bytes by default) to calculate chunk indices consistently. This ensures proper chunk ordering during merge operations.
### Content Type Preservation
For chunked uploads, the content type is preserved from the first chunk and applied to the final merged file, ensuring proper MIME type handling across all storage backends.
## Error Handling
The package returns descriptive errors for common issues:
- File size exceeds limit
- Unsupported file type
- Storage backend errors
- Invalid chunk information
- Missing required configuration
## Testing
Run the tests:
```bash
# Run all tests
go test ./...
# Run with S3 credentials (optional)
export S3_ACCESS_KEY="your-key"
export S3_SECRET_KEY="your-secret"
export S3_BUCKET="your-bucket"
export S3_API="https://your-s3-endpoint"
go test ./...
```
The package includes comprehensive tests for:
- Basic file upload/download
- Chunked uploads with content type preservation
- Compression (gzip and image)
- File validation (size, type, wildcards)
- Multiple storage backends (local and S3)
- Error handling and edge cases
### Test Coverage
- **Manager Tests**: Upload, download, validation, compression
- **Local Storage Tests**: File operations, chunked uploads, directory management
- **S3 Storage Tests**: S3 operations, chunked uploads, presigned URLs (requires credentials)
## Performance Considerations
- **Chunked Uploads**: Use appropriate chunk sizes (1-5MB) for optimal performance
- **Image Compression**: Automatically resizes large images to reduce storage costs
- **Gzip Compression**: Reduces storage size for text-based files
- **Content Type Detection**: Efficient MIME type detection and preservation
## Security Features
- **File Type Validation**: Prevents upload of unauthorized file types
- **Size Limits**: Configurable file size restrictions
- **Path Sanitization**: Secure file path generation
- **Access Control**: User/Chat/Assistant-based file organization
## License
This package is part of the Yao project and follows the same license terms.

View file

@ -0,0 +1,76 @@
package attachment
import (
"bytes"
"fmt"
"image"
"image/jpeg"
"image/png"
"io"
)
// CompressImage compresses the image while maintaining aspect ratio
func CompressImage(reader io.Reader, contentType string, maxSize int) ([]byte, error) {
// Read all data first
data, err := io.ReadAll(reader)
if err != nil {
return nil, fmt.Errorf("failed to read image data: %w", err)
}
// Decode image
img, _, err := image.Decode(bytes.NewReader(data))
if err != nil {
return nil, fmt.Errorf("failed to decode image: %w", err)
}
// Calculate new dimensions
bounds := img.Bounds()
width := bounds.Dx()
height := bounds.Dy()
var newWidth, newHeight int
if width > height {
if width > maxSize {
newWidth = maxSize
newHeight = int(float64(height) * (float64(maxSize) / float64(width)))
} else {
return data, nil // No need to resize, return original data
}
} else {
if height > maxSize {
newHeight = maxSize
newWidth = int(float64(width) * (float64(maxSize) / float64(height)))
} else {
return data, nil // No need to resize, return original data
}
}
// Create new image with new dimensions
newImg := image.NewRGBA(image.Rect(0, 0, newWidth, newHeight))
// Scale the image using bilinear interpolation
for y := 0; y < newHeight; y++ {
for x := 0; x < newWidth; x++ {
srcX := float64(x) * float64(width) / float64(newWidth)
srcY := float64(y) * float64(height) / float64(newHeight)
newImg.Set(x, y, img.At(int(srcX), int(srcY)))
}
}
// Encode image
var buf bytes.Buffer
switch contentType {
case "image/jpeg":
err = jpeg.Encode(&buf, newImg, &jpeg.Options{Quality: 85})
case "image/png":
err = png.Encode(&buf, newImg)
default:
return data, nil // Unsupported format, return original data
}
if err != nil {
return nil, fmt.Errorf("failed to encode image: %w", err)
}
return buf.Bytes(), nil
}

View file

@ -0,0 +1,445 @@
package attachment
import (
"bytes"
"context"
"fmt"
"log"
"mime/multipart"
"strings"
"github.com/yaoapp/yao/neo/attachment/s3"
)
// ExampleUsage demonstrates how to use the attachment package
func ExampleUsage() {
// 1. Create a local storage manager
localManager, err := New(ManagerOption{
Driver: "local",
MaxSize: "20M",
ChunkSize: "2M",
AllowedTypes: []string{"text/*", "image/*", "application/pdf", ".txt", ".jpg", ".png", ".pdf"},
Options: map[string]interface{}{
"path": "/var/uploads",
"base_url": "https://example.com/files",
},
})
if err != nil {
log.Fatalf("Failed to create local manager: %v", err)
}
// 2. Create an S3 storage manager
s3Manager, err := New(ManagerOption{
Driver: "s3",
MaxSize: "100M",
ChunkSize: "5M",
AllowedTypes: []string{"*"}, // Allow all types
Options: map[string]interface{}{
"endpoint": "https://s3.amazonaws.com",
"region": "us-east-1",
"key": "your-access-key",
"secret": "your-secret-key",
"bucket": "your-bucket-name",
"prefix": "attachments/",
},
})
if err != nil {
log.Printf("Failed to create S3 manager (this is expected without credentials): %v", err)
} else {
fmt.Printf("Created S3 manager successfully\n")
// Demonstrate S3 manager usage if credentials are available
if s3Manager != nil {
fmt.Printf("S3 manager is ready for use with bucket: %s\n",
s3Manager.storage.(*s3.Storage).Bucket)
}
}
// 3. Register managers globally
_, err = Register("local", "local", ManagerOption{
Driver: "local",
MaxSize: "20M",
AllowedTypes: []string{"text/*", "image/*"},
Options: map[string]interface{}{
"path": "/var/uploads",
},
})
if err != nil {
log.Printf("Failed to register local manager: %v", err)
}
// Try to register S3 manager (will fail without credentials)
_, err = Register("s3", "s3", ManagerOption{
Driver: "s3",
MaxSize: "100M",
Options: map[string]interface{}{
"bucket": "your-bucket",
"key": "your-key",
"secret": "your-secret",
},
})
if err != nil {
log.Printf("Failed to register S3 manager (expected without credentials): %v", err)
}
ctx := context.Background()
// 4. Example: Simple file upload
content := "Hello, World! This is a test file with some content to demonstrate the attachment package."
fileHeader := &FileHeader{
FileHeader: &multipart.FileHeader{
Filename: "hello.txt",
Size: int64(len(content)),
Header: make(map[string][]string),
},
}
fileHeader.Header.Set("Content-Type", "text/plain")
uploadOption := UploadOption{
UserID: "user123",
ChatID: "chat456",
AssistantID: "assistant789",
Gzip: false, // No compression for small text files
}
file, err := localManager.Upload(ctx, fileHeader, strings.NewReader(content), uploadOption)
if err != nil {
log.Fatalf("Failed to upload file: %v", err)
}
fmt.Printf("Uploaded file: %s (ID: %s, Size: %d bytes)\n", file.Filename, file.ID, file.Bytes)
// 5. Example: File upload with gzip compression
largeContent := strings.Repeat("This is a large text file that benefits from compression. ", 100)
gzipFileHeader := &FileHeader{
FileHeader: &multipart.FileHeader{
Filename: "large_text.txt",
Size: int64(len(largeContent)),
Header: make(map[string][]string),
},
}
gzipFileHeader.Header.Set("Content-Type", "text/plain")
gzipOption := UploadOption{
UserID: "user123",
Gzip: true, // Enable compression
}
gzipFile, err := localManager.Upload(ctx, gzipFileHeader, strings.NewReader(largeContent), gzipOption)
if err != nil {
log.Fatalf("Failed to upload gzipped file: %v", err)
}
fmt.Printf("Uploaded compressed file: %s (ID: %s)\n", gzipFile.Filename, gzipFile.ID)
// 6. Example: Image upload with compression
imageUploadOption := UploadOption{
UserID: "user123",
CompressImage: true,
CompressSize: 1920, // Resize to max 1920px
Gzip: false,
}
// Simulate image upload (you would get this from multipart form)
imageHeader := &FileHeader{
FileHeader: &multipart.FileHeader{
Filename: "photo.jpg",
Size: 1024000, // 1MB
Header: make(map[string][]string),
},
}
imageHeader.Header.Set("Content-Type", "image/jpeg")
fmt.Printf("Image upload option configured: compress=%v, size=%d\n",
imageUploadOption.CompressImage, imageUploadOption.CompressSize)
// 7. Example: Chunked upload
largeContent = strings.Repeat("This is a large file content that will be uploaded in chunks. ", 1000)
chunkSize := 1024
totalSize := len(largeContent)
uid := "unique-large-file-123"
fmt.Printf("Starting chunked upload: total size=%d, chunk size=%d\n", totalSize, chunkSize)
var lastFile *File
chunkCount := 0
// Split into chunks and upload
for i := 0; i < totalSize; i += chunkSize {
end := i + chunkSize
if end > totalSize {
end = totalSize
}
chunk := largeContent[i:end]
chunkHeader := &FileHeader{
FileHeader: &multipart.FileHeader{
Filename: "large_file.txt",
Size: int64(len(chunk)),
Header: make(map[string][]string),
},
}
chunkHeader.Header.Set("Content-Type", "text/plain")
chunkHeader.Header.Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", i, end-1, totalSize))
chunkHeader.Header.Set("Content-Uid", uid)
chunkOption := UploadOption{
UserID: "user123",
Gzip: true, // Compress chunks
}
chunkFile, err := localManager.Upload(ctx, chunkHeader, strings.NewReader(chunk), chunkOption)
if err != nil {
log.Fatalf("Failed to upload chunk %d: %v", chunkCount, err)
}
chunkCount++
lastFile = chunkFile
// Check if this is the last chunk
if chunkHeader.Complete() {
fmt.Printf("Uploaded large file in %d chunks: %s (ID: %s)\n", chunkCount, chunkFile.Filename, chunkFile.ID)
break
}
}
// 8. Example: Download and read files
if file != nil {
// Download as stream
response, err := localManager.Download(ctx, file.ID)
if err != nil {
log.Fatalf("Failed to download file: %v", err)
}
defer response.Reader.Close()
fmt.Printf("Downloaded file content type: %s, extension: %s\n", response.ContentType, response.Extension)
// Read as bytes
data, err := localManager.Read(ctx, file.ID)
if err != nil {
log.Fatalf("Failed to read file: %v", err)
}
fmt.Printf("File content length: %d bytes\n", len(data))
if len(data) < 100 {
fmt.Printf("File content: %s\n", string(data))
} else {
fmt.Printf("File content preview: %s...\n", string(data[:100]))
}
// Read as base64
base64Data, err := localManager.ReadBase64(ctx, file.ID)
if err != nil {
log.Fatalf("Failed to read file as base64: %v", err)
}
fmt.Printf("File as base64 (first 50 chars): %s...\n", base64Data[:min(50, len(base64Data))])
}
// 9. Example: Read chunked file
if lastFile != nil {
chunkData, err := localManager.Read(ctx, lastFile.ID)
if err != nil {
log.Fatalf("Failed to read chunked file: %v", err)
}
// Since the chunks were compressed, we need to decompress
decompressed, err := Gunzip(chunkData)
if err != nil {
log.Fatalf("Failed to decompress chunked file: %v", err)
}
fmt.Printf("Chunked file content length: %d bytes (decompressed)\n", len(decompressed))
if len(decompressed) < 200 {
fmt.Printf("Chunked file content: %s\n", string(decompressed))
} else {
fmt.Printf("Chunked file content preview: %s...\n", string(decompressed[:200]))
}
}
// 10. Example: Using global managers
globalManager := Managers["local"]
if globalManager != nil {
fmt.Println("Using global manager for local storage")
// Test a simple upload with global manager
testContent := "Test content using global manager"
testHeader := &FileHeader{
FileHeader: &multipart.FileHeader{
Filename: "global_test.txt",
Size: int64(len(testContent)),
Header: make(map[string][]string),
},
}
testHeader.Header.Set("Content-Type", "text/plain")
testFile, err := globalManager.Upload(ctx, testHeader, strings.NewReader(testContent), UploadOption{
UserID: "global_user",
})
if err != nil {
log.Printf("Failed to upload with global manager: %v", err)
} else {
fmt.Printf("Global manager upload successful: %s\n", testFile.ID)
}
}
// 11. Example: File validation
fmt.Println("\n--- File Validation Examples ---")
// Test file size validation
tooLargeContent := strings.Repeat("x", 25*1024*1024) // 25MB, exceeds 20MB limit
largeFileHeader := &FileHeader{
FileHeader: &multipart.FileHeader{
Filename: "too_large.txt",
Size: int64(len(tooLargeContent)),
Header: make(map[string][]string),
},
}
largeFileHeader.Header.Set("Content-Type", "text/plain")
_, err = localManager.Upload(ctx, largeFileHeader, strings.NewReader(tooLargeContent), UploadOption{})
if err != nil {
fmt.Printf("Expected error for large file: %v\n", err)
}
// Test file type validation
invalidFileHeader := &FileHeader{
FileHeader: &multipart.FileHeader{
Filename: "script.exe",
Size: 1024,
Header: make(map[string][]string),
},
}
invalidFileHeader.Header.Set("Content-Type", "application/x-executable")
_, err = localManager.Upload(ctx, invalidFileHeader, strings.NewReader("fake exe content"), UploadOption{})
if err != nil {
fmt.Printf("Expected error for invalid file type: %v\n", err)
}
fmt.Println("\n--- Example Usage Complete ---")
}
// ExampleChunkedUpload demonstrates how to handle chunked uploads properly
func ExampleChunkedUpload(manager *Manager, filename string, totalSize int64, contentType string) error {
ctx := context.Background()
chunkSize := int64(1024 * 1024) // 1MB chunks
uid := "unique-file-" + filename
fmt.Printf("Starting chunked upload: file=%s, total=%d bytes, chunks=%d\n",
filename, totalSize, (totalSize+chunkSize-1)/chunkSize)
for offset := int64(0); offset < totalSize; offset += chunkSize {
end := offset + chunkSize - 1
if end >= totalSize {
end = totalSize - 1
}
chunkSize := end - offset + 1
// Create chunk header
chunkHeader := &FileHeader{
FileHeader: &multipart.FileHeader{
Filename: filename,
Size: chunkSize,
Header: make(map[string][]string),
},
}
chunkHeader.Header.Set("Content-Type", contentType)
chunkHeader.Header.Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", offset, end, totalSize))
chunkHeader.Header.Set("Content-Uid", uid)
// In real usage, you would read the actual chunk data from the source
chunkData := make([]byte, chunkSize)
// Fill with sample data for demonstration
for i := range chunkData {
chunkData[i] = byte('A' + (i % 26))
}
option := UploadOption{
UserID: "user123",
Gzip: false, // Disable compression for this example
}
file, err := manager.Upload(ctx, chunkHeader, bytes.NewReader(chunkData), option)
if err != nil {
return fmt.Errorf("failed to upload chunk at offset %d: %w", offset, err)
}
fmt.Printf("Uploaded chunk %d-%d/%d\n", offset, end, totalSize)
// Check if this was the last chunk
if chunkHeader.Complete() {
fmt.Printf("File upload completed: %s (ID: %s)\n", file.Filename, file.ID)
// Verify the uploaded file
data, err := manager.Read(ctx, file.ID)
if err != nil {
return fmt.Errorf("failed to read uploaded file: %w", err)
}
if int64(len(data)) != totalSize {
return fmt.Errorf("uploaded file size mismatch: expected %d, got %d", totalSize, len(data))
}
fmt.Printf("File verification successful: %d bytes\n", len(data))
break
}
}
return nil
}
// ExampleS3Upload demonstrates S3-specific features
func ExampleS3Upload() {
// This example requires actual S3 credentials
s3Manager, err := New(ManagerOption{
Driver: "s3",
MaxSize: "50M",
Options: map[string]interface{}{
"endpoint": "https://s3.amazonaws.com",
"region": "us-east-1",
"key": "your-access-key",
"secret": "your-secret-key",
"bucket": "your-bucket",
"prefix": "test-uploads/",
},
})
if err != nil {
log.Printf("S3 manager creation failed (expected without credentials): %v", err)
return
}
ctx := context.Background()
content := "Test content for S3 upload"
fileHeader := &FileHeader{
FileHeader: &multipart.FileHeader{
Filename: "s3_test.txt",
Size: int64(len(content)),
Header: make(map[string][]string),
},
}
fileHeader.Header.Set("Content-Type", "text/plain")
file, err := s3Manager.Upload(ctx, fileHeader, strings.NewReader(content), UploadOption{
UserID: "s3_user",
})
if err != nil {
log.Printf("S3 upload failed: %v", err)
return
}
fmt.Printf("S3 upload successful: %s\n", file.ID)
// Get presigned URL
url := s3Manager.storage.URL(ctx, file.ID)
fmt.Printf("Presigned URL: %s\n", url)
}
// Helper function for min
func min(a, b int) int {
if a < b {
return a
}
return b
}

View file

@ -0,0 +1,134 @@
package attachment
import (
"strconv"
"strings"
)
// UID is the uid of the file, it is the unique identifier of the file
func (fileheader *FileHeader) UID() string {
return fileheader.Header.Get("Content-Uid")
}
// Range is the range of the file, it is the start and end of the file
func (fileheader *FileHeader) Range() string {
return fileheader.Header.Get("Content-Range")
}
// Sync is the sync of the file, it is the sync of the file
func (fileheader *FileHeader) Sync() bool {
return fileheader.Header.Get("Content-Sync") == "true"
}
// IsChunk is the chunk of the file, it is the chunk of the file
func (fileheader *FileHeader) IsChunk() bool {
return fileheader.Range() != ""
}
// Complete checks if the chunk upload is completed
// For non-chunk files, it returns true
// For chunk files, it parses the Content-Range header to determine if this is the last chunk
func (fileheader *FileHeader) Complete() bool {
if !fileheader.IsChunk() {
return true
}
// Parse Content-Range header: "bytes start-end/total"
rangeHeader := fileheader.Range()
if rangeHeader == "" {
return false
}
// Remove "bytes " prefix
rangeStr := strings.TrimPrefix(rangeHeader, "bytes ")
// Split by "/"
parts := strings.Split(rangeStr, "/")
if len(parts) != 2 {
return false
}
// Parse total size
total, err := strconv.ParseInt(parts[1], 10, 64)
if err != nil {
return false
}
// Parse range "start-end"
rangeParts := strings.Split(parts[0], "-")
if len(rangeParts) != 2 {
return false
}
end, err := strconv.ParseInt(rangeParts[1], 10, 64)
if err != nil {
return false
}
// Check if this is the last chunk: end + 1 == total
return end+1 == total
}
// GetChunkInfo returns the chunk information parsed from Content-Range header
// Returns start, end, total, and error
func (fileheader *FileHeader) GetChunkInfo() (start, end, total int64, err error) {
if !fileheader.IsChunk() {
return 0, 0, 0, nil
}
rangeHeader := fileheader.Range()
if rangeHeader == "" {
return 0, 0, 0, nil
}
// Remove "bytes " prefix
rangeStr := strings.TrimPrefix(rangeHeader, "bytes ")
// Split by "/"
parts := strings.Split(rangeStr, "/")
if len(parts) != 2 {
return 0, 0, 0, nil
}
// Parse total size
total, err = strconv.ParseInt(parts[1], 10, 64)
if err != nil {
return 0, 0, 0, err
}
// Parse range "start-end"
rangeParts := strings.Split(parts[0], "-")
if len(rangeParts) != 2 {
return 0, 0, 0, nil
}
start, err = strconv.ParseInt(rangeParts[0], 10, 64)
if err != nil {
return 0, 0, 0, err
}
end, err = strconv.ParseInt(rangeParts[1], 10, 64)
if err != nil {
return 0, 0, 0, err
}
return start, end, total, nil
}
// GetTotalSize returns the total file size from Content-Range header
func (fileheader *FileHeader) GetTotalSize() int64 {
_, _, total, err := fileheader.GetChunkInfo()
if err != nil {
return 0
}
return total
}
// GetChunkSize returns the current chunk size
func (fileheader *FileHeader) GetChunkSize() int64 {
start, end, _, err := fileheader.GetChunkInfo()
if err != nil {
return 0
}
return end - start + 1
}

285
neo/attachment/gzip.go Normal file
View file

@ -0,0 +1,285 @@
package attachment
import (
"bytes"
"compress/gzip"
"fmt"
"io"
"os"
)
// GzipCompressor supports chunked compression for Gzip
type GzipCompressor struct {
writer *gzip.Writer
buffer *bytes.Buffer
file *os.File // optional file handle
}
// NewGzipCompressor creates a new Gzip compressor
func NewGzipCompressor() *GzipCompressor {
buf := &bytes.Buffer{}
gz := gzip.NewWriter(buf)
return &GzipCompressor{
writer: gz,
buffer: buf,
file: nil,
}
}
// NewGzipCompressorFromFile creates a Gzip compressor from file, supports streaming read
func NewGzipCompressorFromFile(filePath string) (*GzipCompressor, error) {
file, err := os.Open(filePath)
if err != nil {
return nil, fmt.Errorf("failed to open file %s: %w", filePath, err)
}
buf := &bytes.Buffer{}
gz := gzip.NewWriter(buf)
return &GzipCompressor{
writer: gz,
buffer: buf,
file: file,
}, nil
}
// ReadChunk reads a chunk of specified size from file and compresses it
func (gc *GzipCompressor) ReadChunk(chunkSize int) (bool, error) {
if gc.file == nil {
return false, fmt.Errorf("no file associated with this compressor")
}
chunk := make([]byte, chunkSize)
n, err := gc.file.Read(chunk)
if err != nil && err != io.EOF {
return false, fmt.Errorf("failed to read from file: %w", err)
}
if n > 0 {
if err := gc.Write(chunk[:n]); err != nil {
return false, err
}
}
// return whether there is more data
return err != io.EOF, nil
}
// CompressFileInChunks compresses the entire file in chunks
func (gc *GzipCompressor) CompressFileInChunks(chunkSize int) error {
if gc.file == nil {
return fmt.Errorf("no file associated with this compressor")
}
for {
hasMore, err := gc.ReadChunk(chunkSize)
if err != nil {
return err
}
if !hasMore {
break
}
}
return nil
}
// Write writes data for compression (supports chunked writing)
func (gc *GzipCompressor) Write(data []byte) error {
_, err := gc.writer.Write(data)
if err != nil {
return fmt.Errorf("failed to write data to gzip: %w", err)
}
return nil
}
// Flush flushes the buffer but does not close the compressor
func (gc *GzipCompressor) Flush() error {
return gc.writer.Flush()
}
// Close closes the compressor and returns the final compressed data
func (gc *GzipCompressor) Close() ([]byte, error) {
err := gc.writer.Close()
if err != nil {
return nil, fmt.Errorf("failed to close gzip writer: %w", err)
}
// if there is an associated file, close it too
if gc.file != nil {
gc.file.Close()
gc.file = nil
}
return gc.buffer.Bytes(), nil
}
// GetCompressedData gets the current compressed data (without closing the compressor)
func (gc *GzipCompressor) GetCompressedData() []byte {
// flush the buffer first
gc.writer.Flush()
return gc.buffer.Bytes()
}
// Reset resets the compressor for reuse
func (gc *GzipCompressor) Reset() {
gc.buffer.Reset()
gc.writer.Reset(gc.buffer)
if gc.file != nil {
gc.file.Close()
gc.file = nil
}
}
// Gzip compresses data in one go
func Gzip(data []byte) ([]byte, error) {
var buf bytes.Buffer
gz := gzip.NewWriter(&buf)
_, err := gz.Write(data)
if err != nil {
return nil, fmt.Errorf("failed to gzip data: %w", err)
}
err = gz.Close()
if err != nil {
return nil, fmt.Errorf("failed to close gzip writer: %w", err)
}
return buf.Bytes(), nil
}
// GzipChunks compresses multiple data chunks
func GzipChunks(chunks [][]byte) ([]byte, error) {
compressor := NewGzipCompressor()
for _, chunk := range chunks {
if err := compressor.Write(chunk); err != nil {
return nil, err
}
}
return compressor.Close()
}
// GzipFromReader compresses data from Reader stream
func GzipFromReader(reader io.Reader) ([]byte, error) {
var buf bytes.Buffer
gz := gzip.NewWriter(&buf)
_, err := io.Copy(gz, reader)
if err != nil {
return nil, fmt.Errorf("failed to copy data to gzip: %w", err)
}
err = gz.Close()
if err != nil {
return nil, fmt.Errorf("failed to close gzip writer: %w", err)
}
return buf.Bytes(), nil
}
// Gunzip decompresses gzip data
func Gunzip(data []byte) ([]byte, error) {
reader, err := gzip.NewReader(bytes.NewReader(data))
if err != nil {
return nil, fmt.Errorf("failed to create gzip reader: %w", err)
}
defer reader.Close()
var buf bytes.Buffer
_, err = io.Copy(&buf, reader)
if err != nil {
return nil, fmt.Errorf("failed to decompress data: %w", err)
}
return buf.Bytes(), nil
}
// GzipToWriter writes compressed data to Writer
func GzipToWriter(data []byte, writer io.Writer) error {
gz := gzip.NewWriter(writer)
defer gz.Close()
_, err := gz.Write(data)
if err != nil {
return fmt.Errorf("failed to write gzip data: %w", err)
}
return nil
}
// GzipFromReaderToWriter reads data from Reader and writes compressed data to Writer
func GzipFromReaderToWriter(reader io.Reader, writer io.Writer) error {
gz := gzip.NewWriter(writer)
defer gz.Close()
_, err := io.Copy(gz, reader)
if err != nil {
return fmt.Errorf("failed to copy and compress data: %w", err)
}
return nil
}
// GzipFile compresses entire file (loads into memory at once)
func GzipFile(filePath string) ([]byte, error) {
data, err := os.ReadFile(filePath)
if err != nil {
return nil, fmt.Errorf("failed to read file %s: %w", filePath, err)
}
return Gzip(data)
}
// GzipFileInChunks compresses file in chunks (memory friendly)
func GzipFileInChunks(filePath string, chunkSize int) ([]byte, error) {
compressor, err := NewGzipCompressorFromFile(filePath)
if err != nil {
return nil, err
}
defer compressor.Close()
err = compressor.CompressFileInChunks(chunkSize)
if err != nil {
return nil, err
}
return compressor.Close()
}
// GzipFileToFile compresses file and saves to another file
func GzipFileToFile(srcPath, dstPath string, chunkSize int) error {
srcFile, err := os.Open(srcPath)
if err != nil {
return fmt.Errorf("failed to open source file %s: %w", srcPath, err)
}
defer srcFile.Close()
dstFile, err := os.Create(dstPath)
if err != nil {
return fmt.Errorf("failed to create destination file %s: %w", dstPath, err)
}
defer dstFile.Close()
return GzipFromReaderToWriter(srcFile, dstFile)
}
// GzipFileStream compresses file in streaming mode, returns Reader interface
func GzipFileStream(filePath string) (io.Reader, error) {
file, err := os.Open(filePath)
if err != nil {
return nil, fmt.Errorf("failed to open file %s: %w", filePath, err)
}
pr, pw := io.Pipe()
go func() {
defer pw.Close()
defer file.Close()
gz := gzip.NewWriter(pw)
defer gz.Close()
_, err := io.Copy(gz, file)
if err != nil {
pw.CloseWithError(err)
}
}()
return pr, nil
}

View file

@ -0,0 +1,216 @@
package local
import (
"context"
"crypto/sha256"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
)
// MaxImageSize maximum image size (1920x1080)
const MaxImageSize = 1920
// Storage the local storage driver
type Storage struct {
Path string `json:"path" yaml:"path"`
Compression bool `json:"compression" yaml:"compression"`
BaseURL string `json:"base_url" yaml:"base_url"`
PreviewURL func(fileID string) string `json:"-" yaml:"-"`
}
// New create a new local storage
func New(options map[string]interface{}) (*Storage, error) {
storage := &Storage{
Compression: true,
}
if path, ok := options["path"].(string); ok {
storage.Path = path
}
if compression, ok := options["compression"].(bool); ok {
storage.Compression = compression
}
if baseURL, ok := options["base_url"].(string); ok {
storage.BaseURL = baseURL
}
if previewURL, ok := options["preview_url"].(func(string) string); ok {
storage.PreviewURL = previewURL
}
if storage.Path == "" {
return nil, fmt.Errorf("path is required")
}
// Ensure the base path exists
if err := os.MkdirAll(storage.Path, 0755); err != nil {
return nil, fmt.Errorf("failed to create base path: %w", err)
}
return storage, nil
}
// Upload upload file to local storage
func (storage *Storage) Upload(ctx context.Context, fileID string, reader io.Reader, contentType string) (string, error) {
path := filepath.Join(storage.Path, fileID)
// Create directory if not exists
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0755); err != nil {
return "", err
}
// Create and write file
file, err := os.Create(path)
if err != nil {
return "", err
}
defer file.Close()
_, err = io.Copy(file, reader)
if err != nil {
return "", err
}
return fileID, nil
}
// UploadChunk uploads a chunk of a file
func (storage *Storage) UploadChunk(ctx context.Context, fileID string, chunkIndex int, reader io.Reader, contentType string) error {
// Create chunks directory
chunksDir := filepath.Join(storage.Path, ".chunks", fileID)
if err := os.MkdirAll(chunksDir, 0755); err != nil {
return err
}
// Write chunk file
chunkPath := filepath.Join(chunksDir, fmt.Sprintf("chunk_%d", chunkIndex))
file, err := os.Create(chunkPath)
if err != nil {
return err
}
defer file.Close()
_, err = io.Copy(file, reader)
return err
}
// MergeChunks merges all chunks into the final file
func (storage *Storage) MergeChunks(ctx context.Context, fileID string, totalChunks int) error {
chunksDir := filepath.Join(storage.Path, ".chunks", fileID)
finalPath := filepath.Join(storage.Path, fileID)
// Create directory for final file
dir := filepath.Dir(finalPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
// Create final file
finalFile, err := os.Create(finalPath)
if err != nil {
return err
}
defer finalFile.Close()
// Read and merge chunks in order
for i := 0; i < totalChunks; i++ {
chunkPath := filepath.Join(chunksDir, fmt.Sprintf("chunk_%d", i))
chunkFile, err := os.Open(chunkPath)
if err != nil {
return fmt.Errorf("failed to read chunk %d: %w", i, err)
}
_, err = io.Copy(finalFile, chunkFile)
chunkFile.Close()
if err != nil {
return fmt.Errorf("failed to copy chunk %d: %w", i, err)
}
}
// Clean up chunks directory
os.RemoveAll(chunksDir)
return nil
}
// Reader read file from local storage
func (storage *Storage) Reader(ctx context.Context, fileID string) (io.ReadCloser, error) {
fullpath := filepath.Join(storage.Path, fileID)
reader, err := os.Open(fullpath)
if err != nil {
return nil, err
}
return reader, nil
}
// Download download file from local storage
func (storage *Storage) Download(ctx context.Context, fileID string) (io.ReadCloser, string, error) {
path := filepath.Join(storage.Path, fileID)
reader, err := os.Open(path)
if err != nil {
return nil, "", err
}
// Try to detect content type from file extension
contentType := "application/octet-stream"
ext := filepath.Ext(path)
switch strings.ToLower(ext) {
case ".txt":
contentType = "text/plain"
case ".html":
contentType = "text/html"
case ".css":
contentType = "text/css"
case ".js":
contentType = "application/javascript"
case ".json":
contentType = "application/json"
case ".jpg", ".jpeg":
contentType = "image/jpeg"
case ".png":
contentType = "image/png"
case ".gif":
contentType = "image/gif"
case ".pdf":
contentType = "application/pdf"
}
return reader, contentType, nil
}
// URL get file url
func (storage *Storage) URL(ctx context.Context, fileID string) string {
if storage.PreviewURL != nil {
return storage.PreviewURL(fileID)
}
if storage.BaseURL != "" {
return fmt.Sprintf("%s/%s", strings.TrimRight(storage.BaseURL, "/"), fileID)
}
return fmt.Sprintf("%s/%s", storage.Path, fileID)
}
// Exists checks if a file exists
func (storage *Storage) Exists(ctx context.Context, fileID string) bool {
fullpath := filepath.Join(storage.Path, fileID)
_, err := os.Stat(fullpath)
return err == nil
}
// Delete deletes a file
func (storage *Storage) Delete(ctx context.Context, fileID string) error {
fullpath := filepath.Join(storage.Path, fileID)
return os.Remove(fullpath)
}
func (storage *Storage) makeID(filename string, ext string) string {
date := time.Now().Format("20060102")
hash := fmt.Sprintf("%x", sha256.Sum256([]byte(filename)))[:8]
name := strings.TrimSuffix(filepath.Base(filename), ext)
return fmt.Sprintf("%s/%s-%s%s", date, name, hash, ext)
}

View file

@ -0,0 +1,221 @@
package local
import (
"bytes"
"context"
"image"
"image/png"
"io"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
)
func TestLocalStorage(t *testing.T) {
// Create a temporary directory for testing
tempDir, err := os.MkdirTemp("", "local_storage_test")
assert.NoError(t, err)
defer os.RemoveAll(tempDir)
testPath := filepath.Join(tempDir, "test_storage")
t.Run("Create Storage", func(t *testing.T) {
storage, err := New(map[string]interface{}{
"path": testPath,
"compression": true,
})
assert.NoError(t, err)
assert.NotNil(t, storage)
assert.Equal(t, testPath, storage.Path)
assert.True(t, storage.Compression)
})
t.Run("Upload and Download", func(t *testing.T) {
storage, err := New(map[string]interface{}{
"path": testPath,
"compression": true,
})
assert.NoError(t, err)
content := []byte("test content")
reader := bytes.NewReader(content)
fileID, err := storage.Upload(context.Background(), "test.txt", reader, "text/plain")
assert.NoError(t, err)
assert.NotEmpty(t, fileID)
// Download
reader2, contentType, err := storage.Download(context.Background(), fileID)
assert.NoError(t, err)
assert.Contains(t, contentType, "text/plain")
downloaded, err := io.ReadAll(reader2)
assert.NoError(t, err)
assert.Equal(t, content, downloaded)
})
t.Run("Upload and Download Image with Compression", func(t *testing.T) {
storage, err := New(map[string]interface{}{
"path": testPath,
"compression": true,
})
assert.NoError(t, err)
// Create a test image (100x100 pixels - smaller for faster testing)
img := image.NewRGBA(image.Rect(0, 0, 100, 100))
var buf bytes.Buffer
err = png.Encode(&buf, img)
assert.NoError(t, err)
// Upload
reader := bytes.NewReader(buf.Bytes())
fileID, err := storage.Upload(context.Background(), "test.png", reader, "image/png")
assert.NoError(t, err)
assert.NotEmpty(t, fileID)
// Download and verify
reader2, contentType, err := storage.Download(context.Background(), fileID)
assert.NoError(t, err)
assert.Equal(t, "image/png", contentType)
downloaded, err := io.ReadAll(reader2)
assert.NoError(t, err)
// Decode the downloaded image
downloadedImg, _, err := image.Decode(bytes.NewReader(downloaded))
assert.NoError(t, err)
// Verify image was processed
bounds := downloadedImg.Bounds()
assert.True(t, bounds.Dx() > 0)
assert.True(t, bounds.Dy() > 0)
})
t.Run("Upload Image without Compression", func(t *testing.T) {
storage, err := New(map[string]interface{}{
"path": testPath,
"compression": false,
})
assert.NoError(t, err)
// Create a test image (100x100 pixels)
img := image.NewRGBA(image.Rect(0, 0, 100, 100))
var buf bytes.Buffer
err = png.Encode(&buf, img)
assert.NoError(t, err)
// Upload
reader := bytes.NewReader(buf.Bytes())
fileID, err := storage.Upload(context.Background(), "test.png", reader, "image/png")
assert.NoError(t, err)
assert.NotEmpty(t, fileID)
// Download and verify
reader2, contentType, err := storage.Download(context.Background(), fileID)
assert.NoError(t, err)
assert.Equal(t, "image/png", contentType)
downloaded, err := io.ReadAll(reader2)
assert.NoError(t, err)
// Decode the downloaded image
downloadedImg, _, err := image.Decode(bytes.NewReader(downloaded))
assert.NoError(t, err)
// Verify dimensions are unchanged
bounds := downloadedImg.Bounds()
assert.Equal(t, 100, bounds.Dx())
assert.Equal(t, 100, bounds.Dy())
})
t.Run("URL Generation", func(t *testing.T) {
storage, err := New(map[string]interface{}{
"path": testPath,
"compression": true,
})
assert.NoError(t, err)
fileID := "20240101/test-12345678.txt"
url := storage.URL(context.Background(), fileID)
expected := filepath.Join(testPath, fileID)
assert.Equal(t, expected, url)
})
t.Run("Download Non-existent File", func(t *testing.T) {
storage, err := New(map[string]interface{}{
"path": testPath,
"compression": true,
})
assert.NoError(t, err)
_, _, err = storage.Download(context.Background(), "non-existent.txt")
assert.Error(t, err)
})
t.Run("Chunked Upload", func(t *testing.T) {
storage, err := New(map[string]interface{}{
"path": testPath,
})
assert.NoError(t, err)
fileID := "test-chunked.txt"
content1 := []byte("chunk1")
content2 := []byte("chunk2")
// Upload chunks
err = storage.UploadChunk(context.Background(), fileID, 0, bytes.NewReader(content1), "text/plain")
assert.NoError(t, err)
err = storage.UploadChunk(context.Background(), fileID, 1, bytes.NewReader(content2), "text/plain")
assert.NoError(t, err)
// Merge chunks
err = storage.MergeChunks(context.Background(), fileID, 2)
assert.NoError(t, err)
// Download and verify
reader, contentType, err := storage.Download(context.Background(), fileID)
assert.NoError(t, err)
assert.Equal(t, "text/plain", contentType)
downloaded, err := io.ReadAll(reader)
assert.NoError(t, err)
assert.Equal(t, append(content1, content2...), downloaded)
})
t.Run("File Operations", func(t *testing.T) {
storage, err := New(map[string]interface{}{
"path": testPath,
})
assert.NoError(t, err)
fileID := "test-ops.txt"
content := []byte("test content")
// Upload file
_, err = storage.Upload(context.Background(), fileID, bytes.NewReader(content), "text/plain")
assert.NoError(t, err)
// Check if file exists
exists := storage.Exists(context.Background(), fileID)
assert.True(t, exists)
// Read file
reader, err := storage.Reader(context.Background(), fileID)
assert.NoError(t, err)
defer reader.Close()
data, err := io.ReadAll(reader)
assert.NoError(t, err)
assert.Equal(t, content, data)
// Delete file
err = storage.Delete(context.Background(), fileID)
assert.NoError(t, err)
// Check if file no longer exists
exists = storage.Exists(context.Background(), fileID)
assert.False(t, exists)
})
}

452
neo/attachment/manager.go Normal file
View file

@ -0,0 +1,452 @@
package attachment
import (
"bytes"
"context"
"crypto/sha256"
"encoding/base64"
"fmt"
"io"
"mime"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/yaoapp/yao/neo/attachment/local"
"github.com/yaoapp/yao/neo/attachment/s3"
)
// Managers the managers
var Managers = map[string]*Manager{}
// Register registers a global attachment manager
func Register(name string, driver string, option ManagerOption) (*Manager, error) {
// Check if the manager already exists
if _, ok := Managers[name]; ok {
return nil, fmt.Errorf("manager %s already exists", name)
}
// Create a new manager
manager, err := New(option)
if err != nil {
return nil, err
}
// Register the manager
Managers[name] = manager
return manager, nil
}
// New creates a new attachment manager
func New(option ManagerOption) (*Manager, error) {
manager := &Manager{
ManagerOption: option,
allowedTypes: allowedType{mapping: make(map[string]bool),
wildcards: []string{},
}}
switch strings.ToLower(option.Driver) {
case "local":
storage, err := local.New(option.Options)
if err != nil {
return nil, err
}
manager.storage = storage
break
case "s3":
storage, err := s3.New(option.Options)
if err != nil {
return nil, err
}
manager.storage = storage
break
default:
return nil, fmt.Errorf("driver %s does not support", option.Driver)
}
// Max size
if option.MaxSize != "" {
maxsize, err := getSize(option.MaxSize)
if err != nil {
return nil, err
}
manager.maxsize = maxsize
}
// Chunk size
if option.ChunkSize != "" {
chunsize, err := getSize(option.ChunkSize)
if err != nil {
return nil, err
}
manager.chunsize = chunsize
}
// init allowedTypes
if option.AllowedTypes != nil && len(option.AllowedTypes) > 0 {
for _, t := range option.AllowedTypes {
t = strings.TrimSpace(t)
if strings.HasSuffix(t, "*") {
manager.allowedTypes.wildcards = append(manager.allowedTypes.wildcards, t)
continue
}
manager.allowedTypes.mapping[t] = true
}
}
return manager, nil
}
// Upload uploads a file
func (manager Manager) Upload(ctx context.Context, fileheader *FileHeader, reader io.Reader, option UploadOption) (*File, error) {
file, err := manager.makeFile(fileheader, option)
if err != nil {
return nil, err
}
// Handle chunked upload
if fileheader.IsChunk() {
start, _, total, err := fileheader.GetChunkInfo()
if err != nil {
return nil, fmt.Errorf("invalid chunk info: %w", err)
}
// Calculate chunk index based on start position and a standard chunk size
// We need to determine the standard chunk size from the first few chunks
standardChunkSize := int64(1024) // Default chunk size
if start > 0 {
// For non-first chunks, we can infer the standard chunk size
// by looking at the start position
if start%1024 == 0 {
standardChunkSize = 1024
} else if start%2048 == 0 {
standardChunkSize = 2048
} else if start%4096 == 0 {
standardChunkSize = 4096
} else {
// Try to infer from the start position
for size := int64(512); size <= 8192; size *= 2 {
if start%size == 0 {
standardChunkSize = size
break
}
}
}
}
chunkIndex := int(start / standardChunkSize)
totalChunks := int((total + standardChunkSize - 1) / standardChunkSize) // Ceiling division
// Apply gzip compression if requested
if option.Gzip {
compressed, err := GzipFromReader(reader)
if err != nil {
return nil, fmt.Errorf("failed to gzip chunk: %w", err)
}
reader = bytes.NewReader(compressed)
}
// Upload chunk
err = manager.storage.UploadChunk(ctx, file.ID, chunkIndex, reader, file.ContentType)
if err != nil {
return nil, err
}
// If this is the last chunk, merge all chunks
if fileheader.Complete() {
err = manager.storage.MergeChunks(ctx, file.ID, totalChunks)
if err != nil {
return nil, err
}
// Apply image compression if requested and it's the final file
if option.CompressImage && strings.HasPrefix(file.ContentType, "image/") {
err = manager.compressStoredImage(ctx, file, option)
if err != nil {
return nil, err
}
}
}
return file, nil
}
// Handle single file upload
var finalReader io.Reader = reader
// Apply gzip compression if requested
if option.Gzip {
compressed, err := GzipFromReader(reader)
if err != nil {
return nil, fmt.Errorf("failed to gzip file: %w", err)
}
finalReader = bytes.NewReader(compressed)
}
// Apply image compression if requested
if option.CompressImage && strings.HasPrefix(file.ContentType, "image/") {
size := option.CompressSize
if size == 0 {
size = 1920
}
// If gzip was applied, we need to decompress first
if option.Gzip {
data, err := io.ReadAll(finalReader)
if err != nil {
return nil, err
}
decompressed, err := Gunzip(data)
if err != nil {
return nil, err
}
finalReader = bytes.NewReader(decompressed)
}
compressed, err := CompressImage(finalReader, file.ContentType, size)
if err != nil {
return nil, err
}
// Re-apply gzip if it was requested
if option.Gzip {
gzipped, err := Gzip(compressed)
if err != nil {
return nil, err
}
finalReader = bytes.NewReader(gzipped)
} else {
finalReader = bytes.NewReader(compressed)
}
}
// Upload the file to storage
id, err := manager.storage.Upload(ctx, file.ID, finalReader, file.ContentType)
if err != nil {
return nil, err
}
// Update the file ID
file.ID = id
return file, nil
}
// compressStoredImage compresses an already stored image
func (manager Manager) compressStoredImage(ctx context.Context, file *File, option UploadOption) error {
// Download the stored file
reader, err := manager.storage.Reader(ctx, file.ID)
if err != nil {
return err
}
defer reader.Close()
size := option.CompressSize
if size == 0 {
size = 1920
}
// Compress the image
compressed, err := CompressImage(reader, file.ContentType, size)
if err != nil {
return err
}
// Re-upload the compressed image
_, err = manager.storage.Upload(ctx, file.ID, bytes.NewReader(compressed), file.ContentType)
return err
}
// Download downloads a file
func (manager Manager) Download(ctx context.Context, fileID string) (*FileResponse, error) {
reader, contentType, err := manager.storage.Download(ctx, fileID)
if err != nil {
return nil, err
}
extension := filepath.Ext(fileID)
if extension == "" {
// Try to get extension from content type
extensions, err := mime.ExtensionsByType(contentType)
if err == nil && len(extensions) > 0 {
extension = extensions[0]
}
}
return &FileResponse{
Reader: reader,
ContentType: contentType,
Extension: extension,
}, nil
}
// Read reads a file and returns the content as bytes
func (manager Manager) Read(ctx context.Context, fileID string) ([]byte, error) {
reader, err := manager.storage.Reader(ctx, fileID)
if err != nil {
return nil, err
}
defer reader.Close()
return io.ReadAll(reader)
}
// ReadBase64 reads a file and returns the content as base64 encoded string
func (manager Manager) ReadBase64(ctx context.Context, fileID string) (string, error) {
data, err := manager.Read(ctx, fileID)
if err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(data), nil
}
// validate validates the file and option
func (manager Manager) makeFile(file *FileHeader, option UploadOption) (*File, error) {
// Validate max size
if manager.maxsize > 0 && file.Size > manager.maxsize {
return nil, fmt.Errorf("file size %d exceeds the maximum size of %d", file.Size, manager.maxsize)
}
// Get the content type
contentType := file.Header.Get("Content-Type")
extension := filepath.Ext(file.Filename)
// Get the extension from the content type if not available from filename
if extension == "" {
// Special handling for common types
switch contentType {
case "text/plain":
extension = ".txt"
case "image/jpeg":
extension = ".jpg"
case "image/png":
extension = ".png"
case "application/pdf":
extension = ".pdf"
default:
extensions, err := mime.ExtensionsByType(contentType)
if err == nil && len(extensions) > 0 {
// For text/plain, prefer .txt over .conf
if contentType == "text/plain" {
for _, ext := range extensions {
if ext == ".txt" {
extension = ext
break
}
}
if extension == "" {
extension = ".txt"
}
} else {
extension = extensions[0]
}
}
}
}
// Validate allowed types
if !manager.allowed(contentType, extension) {
return nil, fmt.Errorf("%s type %s is not allowed", file.Filename, contentType)
}
// Generate file ID
id, err := manager.generateFileID(file, extension, option)
if err != nil {
return nil, err
}
return &File{
ID: id,
Filename: file.Filename,
ContentType: contentType,
Bytes: int(file.Size),
CreatedAt: int(time.Now().Unix()),
}, nil
}
func (manager Manager) allowed(contentType string, extension string) bool {
// text/*, image/*, audio/*, video/*, application/yao-*, ...
for _, t := range manager.allowedTypes.wildcards {
prefix := strings.TrimSuffix(t, "*")
if strings.HasPrefix(contentType, prefix) {
return true
}
}
// Accepted types
if _, ok := manager.allowedTypes.mapping[contentType]; ok {
return true
}
// Accepted extensions
if _, ok := manager.allowedTypes.mapping[extension]; ok {
return true
}
// Not allowed
return false
}
// generateFileID generates a file ID with proper namespace
func (manager Manager) generateFileID(file *FileHeader, extension string, option UploadOption) (string, error) {
filename := file.Filename
if file.IsChunk() {
filename = file.UID()
}
hash := fmt.Sprintf("%x", sha256.Sum256([]byte(filename)))[:8]
date := time.Now().Format("20060102")
path := filepath.Join("attachments", date)
if option.UserID != "" {
path = filepath.Join(path, option.UserID)
}
if option.ChatID != "" {
path = filepath.Join(path, option.ChatID)
}
if option.AssistantID != "" {
path = filepath.Join(path, option.AssistantID)
}
return filepath.Join(path, hash[:2], hash[2:4], hash, extension), nil
}
// getSize converts the size to bytes
func getSize(size string) (int64, error) {
if size == "" || size == "0" {
return 0, fmt.Errorf("size is empty")
}
unit := strings.ToUpper(size[len(size)-1:])
str := size[:len(size)-1]
if unit != "B" && unit != "K" && unit != "M" && unit != "G" {
unit = "B"
str = size
}
value, err := strconv.ParseInt(str, 10, 64)
if err != nil {
return 0, fmt.Errorf("invalid size: %s %s", size, err)
}
switch unit {
case "B":
return value, nil
case "K":
return value * 1024, nil
case "M":
return value * 1024 * 1024, nil
case "G":
return value * 1024 * 1024 * 1024, nil
}
return 0, fmt.Errorf("invalid size: %s", size)
}

View file

@ -0,0 +1,244 @@
package attachment
import (
"bytes"
"context"
"encoding/base64"
"fmt"
"mime/multipart"
"strings"
"testing"
)
func TestManagerUpload(t *testing.T) {
// Create a local storage manager
manager, err := New(ManagerOption{
Driver: "local",
MaxSize: "10M",
ChunkSize: "2M",
AllowedTypes: []string{"text/*", "image/*", ".txt", ".jpg", ".png"},
Options: map[string]interface{}{
"path": "/tmp/test_attachments",
},
})
if err != nil {
t.Fatalf("Failed to create manager: %v", err)
}
// Test simple text file upload
t.Run("SimpleTextUpload", func(t *testing.T) {
content := "Hello, World!"
reader := strings.NewReader(content)
// Create a mock file header
fileHeader := &FileHeader{
FileHeader: &multipart.FileHeader{
Filename: "test.txt",
Size: int64(len(content)),
Header: make(map[string][]string),
},
}
fileHeader.Header.Set("Content-Type", "text/plain")
option := UploadOption{
UserID: "user123",
ChatID: "chat456",
AssistantID: "assistant789",
}
file, err := manager.Upload(context.Background(), fileHeader, reader, option)
if err != nil {
t.Fatalf("Failed to upload file: %v", err)
}
if file.Filename != "test.txt" {
t.Errorf("Expected filename 'test.txt', got '%s'", file.Filename)
}
if file.ContentType != "text/plain" {
t.Errorf("Expected content type 'text/plain', got '%s'", file.ContentType)
}
// Test download
response, err := manager.Download(context.Background(), file.ID)
if err != nil {
t.Fatalf("Failed to download file: %v", err)
}
defer response.Reader.Close()
downloadedContent, err := manager.Read(context.Background(), file.ID)
if err != nil {
t.Fatalf("Failed to read file: %v", err)
}
if string(downloadedContent) != content {
t.Errorf("Expected content '%s', got '%s'", content, string(downloadedContent))
}
// Test ReadBase64
base64Content, err := manager.ReadBase64(context.Background(), file.ID)
if err != nil {
t.Fatalf("Failed to read file as base64: %v", err)
}
expectedBase64 := base64.StdEncoding.EncodeToString([]byte(content))
if base64Content != expectedBase64 {
t.Errorf("Expected base64 '%s', got '%s'", expectedBase64, base64Content)
}
})
// Test gzip compression
t.Run("GzipUpload", func(t *testing.T) {
content := "This is a test file that will be compressed with gzip."
reader := strings.NewReader(content)
fileHeader := &FileHeader{
FileHeader: &multipart.FileHeader{
Filename: "test_gzip.txt",
Size: int64(len(content)),
Header: make(map[string][]string),
},
}
fileHeader.Header.Set("Content-Type", "text/plain")
option := UploadOption{
Gzip: true,
UserID: "user123",
}
file, err := manager.Upload(context.Background(), fileHeader, reader, option)
if err != nil {
t.Fatalf("Failed to upload gzipped file: %v", err)
}
// The stored file should be compressed, but when we read it back,
// we should get the original content (if the storage handles decompression)
downloadedContent, err := manager.Read(context.Background(), file.ID)
if err != nil {
t.Fatalf("Failed to read gzipped file: %v", err)
}
// Since we're storing compressed data, we need to decompress it
decompressed, err := Gunzip(downloadedContent)
if err != nil {
t.Fatalf("Failed to decompress file: %v", err)
}
if string(decompressed) != content {
t.Errorf("Expected content '%s', got '%s'", content, string(decompressed))
}
})
// Test chunked upload
t.Run("ChunkedUpload", func(t *testing.T) {
content := "This is a large file that will be uploaded in chunks. " +
strings.Repeat("Lorem ipsum dolor sit amet, consectetur adipiscing elit. ", 100)
chunkSize := 1024
totalSize := len(content)
var lastFile *File
for start := 0; start < totalSize; start += chunkSize {
end := start + chunkSize - 1
if end >= totalSize {
end = totalSize - 1
}
chunk := []byte(content[start : end+1])
fileHeader := &FileHeader{
FileHeader: &multipart.FileHeader{
Filename: "large_file.txt",
Size: int64(len(chunk)),
Header: make(map[string][]string),
},
}
fileHeader.Header.Set("Content-Type", "text/plain")
fileHeader.Header.Set("Content-Range",
fmt.Sprintf("bytes %d-%d/%d", start, end, totalSize))
fileHeader.Header.Set("Content-Uid", "unique-file-id-123")
option := UploadOption{
UserID: "user123",
}
file, err := manager.Upload(context.Background(), fileHeader, bytes.NewReader(chunk), option)
if err != nil {
t.Fatalf("Failed to upload chunk starting at %d: %v", start, err)
}
lastFile = file
}
// After uploading all chunks, read the complete file
if lastFile != nil {
downloadedContent, err := manager.Read(context.Background(), lastFile.ID)
if err != nil {
t.Fatalf("Failed to read chunked file: %v", err)
}
if string(downloadedContent) != content {
t.Errorf("Chunked upload content mismatch. Expected length %d, got %d",
len(content), len(downloadedContent))
}
}
})
}
func TestManagerValidation(t *testing.T) {
manager, err := New(ManagerOption{
Driver: "local",
MaxSize: "1K", // Very small max size for testing
AllowedTypes: []string{"text/plain"},
Options: map[string]interface{}{
"path": "/tmp/test_attachments",
},
})
if err != nil {
t.Fatalf("Failed to create manager: %v", err)
}
// Test file size validation
t.Run("FileSizeValidation", func(t *testing.T) {
content := strings.Repeat("a", 2048) // 2KB, exceeds 1KB limit
reader := strings.NewReader(content)
fileHeader := &FileHeader{
FileHeader: &multipart.FileHeader{
Filename: "large.txt",
Size: int64(len(content)),
Header: make(map[string][]string),
},
}
fileHeader.Header.Set("Content-Type", "text/plain")
option := UploadOption{}
_, err := manager.Upload(context.Background(), fileHeader, reader, option)
if err == nil {
t.Error("Expected error for file size exceeding limit")
}
})
// Test file type validation
t.Run("FileTypeValidation", func(t *testing.T) {
content := "test"
reader := strings.NewReader(content)
fileHeader := &FileHeader{
FileHeader: &multipart.FileHeader{
Filename: "test.jpg",
Size: int64(len(content)),
Header: make(map[string][]string),
},
}
fileHeader.Header.Set("Content-Type", "image/jpeg") // Not allowed
option := UploadOption{}
_, err := manager.Upload(context.Background(), fileHeader, reader, option)
if err == nil {
t.Error("Expected error for disallowed file type")
}
})
}

View file

@ -0,0 +1,381 @@
package s3
import (
"bytes"
"context"
"fmt"
"image"
"image/jpeg"
"image/png"
"io"
"path/filepath"
"strings"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
)
// DefaultExpiration default expiration time for presigned URLs (5 minutes)
const DefaultExpiration = 5 * time.Minute
// MaxImageSize maximum image size (1920x1080)
const MaxImageSize = 1920
// Storage the S3 storage driver
type Storage struct {
Endpoint string `json:"endpoint" yaml:"endpoint"`
Region string `json:"region" yaml:"region"`
Key string `json:"key" yaml:"key"`
Secret string `json:"secret" yaml:"secret"`
Bucket string `json:"bucket" yaml:"bucket"`
Expiration time.Duration `json:"expiration" yaml:"expiration"`
client *s3.Client
prefix string
compression bool
}
// New create a new S3 storage
func New(options map[string]interface{}) (*Storage, error) {
storage := &Storage{
Region: "auto",
Expiration: DefaultExpiration,
compression: true,
}
if endpoint, ok := options["endpoint"].(string); ok {
storage.Endpoint = endpoint
}
if region, ok := options["region"].(string); ok {
storage.Region = region
}
if key, ok := options["key"].(string); ok {
storage.Key = key
}
if secret, ok := options["secret"].(string); ok {
storage.Secret = secret
}
if bucket, ok := options["bucket"].(string); ok {
storage.Bucket = bucket
}
if prefix, ok := options["prefix"].(string); ok {
storage.prefix = prefix
}
if exp, ok := options["expiration"].(time.Duration); ok {
storage.Expiration = exp
}
if compression, ok := options["compression"].(bool); ok {
storage.compression = compression
}
// Validate required fields
if storage.Key == "" || storage.Secret == "" {
return nil, fmt.Errorf("key and secret are required")
}
if storage.Bucket == "" {
return nil, fmt.Errorf("bucket is required")
}
// Create S3 client
opts := s3.Options{
Region: storage.Region,
Credentials: credentials.NewStaticCredentialsProvider(storage.Key, storage.Secret, ""),
UsePathStyle: true,
}
if storage.Endpoint != "" {
// Remove bucket name from endpoint if present
endpoint := storage.Endpoint
if strings.Contains(endpoint, "/"+storage.Bucket) {
endpoint = strings.TrimSuffix(endpoint, "/"+storage.Bucket)
}
opts.BaseEndpoint = aws.String(endpoint)
}
storage.client = s3.New(opts)
return storage, nil
}
// Upload upload file to S3
func (storage *Storage) Upload(ctx context.Context, fileID string, reader io.Reader, contentType string) (string, error) {
if storage.client == nil {
return "", fmt.Errorf("s3 client not initialized")
}
key := filepath.Join(storage.prefix, fileID)
// Upload file
_, err := storage.client.PutObject(ctx, &s3.PutObjectInput{
Bucket: aws.String(storage.Bucket),
Key: aws.String(key),
Body: reader,
ContentType: aws.String(contentType),
})
if err != nil {
return "", fmt.Errorf("failed to upload file: %w", err)
}
return fileID, nil
}
// UploadChunk uploads a chunk of a file to S3
func (storage *Storage) UploadChunk(ctx context.Context, fileID string, chunkIndex int, reader io.Reader, contentType string) error {
if storage.client == nil {
return fmt.Errorf("s3 client not initialized")
}
// Store chunks with a special prefix
chunkKey := filepath.Join(storage.prefix, ".chunks", fileID, fmt.Sprintf("chunk_%d", chunkIndex))
_, err := storage.client.PutObject(ctx, &s3.PutObjectInput{
Bucket: aws.String(storage.Bucket),
Key: aws.String(chunkKey),
Body: reader,
ContentType: aws.String(contentType),
})
if err != nil {
return fmt.Errorf("failed to upload chunk %d: %w", chunkIndex, err)
}
return nil
}
// MergeChunks merges all chunks into the final file in S3
func (storage *Storage) MergeChunks(ctx context.Context, fileID string, totalChunks int) error {
if storage.client == nil {
return fmt.Errorf("s3 client not initialized")
}
finalKey := filepath.Join(storage.prefix, fileID)
// Create a buffer to hold the merged content
var mergedContent bytes.Buffer
var contentType string
// Download and merge chunks in order
for i := 0; i < totalChunks; i++ {
chunkKey := filepath.Join(storage.prefix, ".chunks", fileID, fmt.Sprintf("chunk_%d", i))
result, err := storage.client.GetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(storage.Bucket),
Key: aws.String(chunkKey),
})
if err != nil {
return fmt.Errorf("failed to get chunk %d: %w", i, err)
}
// Get content type from the first chunk
if i == 0 && result.ContentType != nil {
contentType = *result.ContentType
}
_, err = io.Copy(&mergedContent, result.Body)
result.Body.Close()
if err != nil {
return fmt.Errorf("failed to copy chunk %d: %w", i, err)
}
}
// Default content type if not found
if contentType == "" {
contentType = "application/octet-stream"
}
// Upload the merged content as the final file with proper content type
_, err := storage.client.PutObject(ctx, &s3.PutObjectInput{
Bucket: aws.String(storage.Bucket),
Key: aws.String(finalKey),
Body: bytes.NewReader(mergedContent.Bytes()),
ContentType: aws.String(contentType),
})
if err != nil {
return fmt.Errorf("failed to upload merged file: %w", err)
}
// Clean up chunks
for i := 0; i < totalChunks; i++ {
chunkKey := filepath.Join(storage.prefix, ".chunks", fileID, fmt.Sprintf("chunk_%d", i))
storage.client.DeleteObject(ctx, &s3.DeleteObjectInput{
Bucket: aws.String(storage.Bucket),
Key: aws.String(chunkKey),
})
}
return nil
}
// Reader read file from S3
func (storage *Storage) Reader(ctx context.Context, fileID string) (io.ReadCloser, error) {
if storage.client == nil {
return nil, fmt.Errorf("s3 client not initialized")
}
key := filepath.Join(storage.prefix, fileID)
result, err := storage.client.GetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(storage.Bucket),
Key: aws.String(key),
})
if err != nil {
return nil, fmt.Errorf("failed to get file: %w", err)
}
return result.Body, nil
}
// Download download file from S3
func (storage *Storage) Download(ctx context.Context, fileID string) (io.ReadCloser, string, error) {
if storage.client == nil {
return nil, "", fmt.Errorf("s3 client not initialized")
}
key := filepath.Join(storage.prefix, fileID)
// Get object
result, err := storage.client.GetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(storage.Bucket),
Key: aws.String(key),
})
if err != nil {
return nil, "", fmt.Errorf("failed to download file: %w", err)
}
contentType := "application/octet-stream"
if result.ContentType != nil {
contentType = *result.ContentType
}
return result.Body, contentType, nil
}
// URL get file url with expiration
func (storage *Storage) URL(ctx context.Context, fileID string) string {
if storage.client == nil {
return ""
}
key := filepath.Join(storage.prefix, fileID)
presignClient := s3.NewPresignClient(storage.client)
request, err := presignClient.PresignGetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(storage.Bucket),
Key: aws.String(key),
}, s3.WithPresignExpires(storage.Expiration))
if err != nil {
return ""
}
return request.URL
}
// Exists checks if a file exists in S3
func (storage *Storage) Exists(ctx context.Context, fileID string) bool {
if storage.client == nil {
return false
}
key := filepath.Join(storage.prefix, fileID)
_, err := storage.client.HeadObject(ctx, &s3.HeadObjectInput{
Bucket: aws.String(storage.Bucket),
Key: aws.String(key),
})
return err == nil
}
// Delete deletes a file from S3
func (storage *Storage) Delete(ctx context.Context, fileID string) error {
if storage.client == nil {
return fmt.Errorf("s3 client not initialized")
}
key := filepath.Join(storage.prefix, fileID)
_, err := storage.client.DeleteObject(ctx, &s3.DeleteObjectInput{
Bucket: aws.String(storage.Bucket),
Key: aws.String(key),
})
if err != nil {
return fmt.Errorf("failed to delete file: %w", err)
}
return nil
}
func (storage *Storage) makeID(filename string, ext string) string {
date := time.Now().Format("20060102")
name := strings.TrimSuffix(filepath.Base(filename), ext)
return fmt.Sprintf("%s/%s-%d%s", date, name, time.Now().UnixNano(), ext)
}
// isImage checks if the content type is an image
func isImage(contentType string) bool {
return strings.HasPrefix(contentType, "image/")
}
// compressImage compresses the image while maintaining aspect ratio
func compressImage(data []byte, contentType string) ([]byte, error) {
// Decode image
img, _, err := image.Decode(bytes.NewReader(data))
if err != nil {
return nil, fmt.Errorf("failed to decode image: %w", err)
}
// Calculate new dimensions
bounds := img.Bounds()
width := bounds.Dx()
height := bounds.Dy()
var newWidth, newHeight int
if width > height {
if width > MaxImageSize {
newWidth = MaxImageSize
newHeight = int(float64(height) * (float64(MaxImageSize) / float64(width)))
} else {
return data, nil // No need to resize
}
} else {
if height > MaxImageSize {
newHeight = MaxImageSize
newWidth = int(float64(width) * (float64(MaxImageSize) / float64(height)))
} else {
return data, nil // No need to resize
}
}
// Create new image with new dimensions
newImg := image.NewRGBA(image.Rect(0, 0, newWidth, newHeight))
// Scale the image using bilinear interpolation
for y := 0; y < newHeight; y++ {
for x := 0; x < newWidth; x++ {
srcX := float64(x) * float64(width) / float64(newWidth)
srcY := float64(y) * float64(height) / float64(newHeight)
newImg.Set(x, y, img.At(int(srcX), int(srcY)))
}
}
// Encode image
var buf bytes.Buffer
switch contentType {
case "image/jpeg":
err = jpeg.Encode(&buf, newImg, &jpeg.Options{Quality: 85})
case "image/png":
err = png.Encode(&buf, newImg)
default:
return data, nil // Unsupported format, return original
}
if err != nil {
return nil, fmt.Errorf("failed to encode image: %w", err)
}
return buf.Bytes(), nil
}

View file

@ -0,0 +1,191 @@
package s3
import (
"bytes"
"context"
"io"
"os"
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func getS3Config() map[string]interface{} {
return map[string]interface{}{
"endpoint": os.Getenv("S3_API"),
"region": "auto",
"key": os.Getenv("S3_ACCESS_KEY"),
"secret": os.Getenv("S3_SECRET_KEY"),
"bucket": os.Getenv("S3_BUCKET"),
"prefix": "attachment-test",
"expiration": 5 * time.Minute,
"compression": true,
}
}
func skipIfNoS3Config(t *testing.T) {
if os.Getenv("S3_ACCESS_KEY") == "" || os.Getenv("S3_SECRET_KEY") == "" || os.Getenv("S3_BUCKET") == "" {
t.Skip("S3 configuration not available (set S3_ACCESS_KEY, S3_SECRET_KEY, S3_BUCKET environment variables)")
}
}
func TestS3Storage(t *testing.T) {
t.Run("Create Storage", func(t *testing.T) {
options := getS3Config()
storage, err := New(options)
if os.Getenv("S3_ACCESS_KEY") == "" || os.Getenv("S3_SECRET_KEY") == "" || os.Getenv("S3_BUCKET") == "" {
// Should fail without credentials
assert.Error(t, err)
return
}
assert.NoError(t, err)
assert.NotNil(t, storage)
if storage != nil {
assert.Equal(t, os.Getenv("S3_API"), storage.Endpoint)
assert.Equal(t, "auto", storage.Region)
assert.Equal(t, os.Getenv("S3_ACCESS_KEY"), storage.Key)
assert.Equal(t, os.Getenv("S3_SECRET_KEY"), storage.Secret)
assert.Equal(t, os.Getenv("S3_BUCKET"), storage.Bucket)
assert.Equal(t, "attachment-test", storage.prefix)
assert.Equal(t, 5*time.Minute, storage.Expiration)
assert.True(t, storage.compression)
}
})
t.Run("Upload and Download Text File", func(t *testing.T) {
skipIfNoS3Config(t)
storage, err := New(getS3Config())
assert.NoError(t, err)
content := []byte("test content")
reader := bytes.NewReader(content)
fileID, err := storage.Upload(context.Background(), "test.txt", reader, "text/plain")
assert.NoError(t, err)
assert.NotEmpty(t, fileID)
// Get presigned URL
url := storage.URL(context.Background(), fileID)
assert.NotEmpty(t, url)
assert.Contains(t, url, "X-Amz-Signature")
assert.Contains(t, url, "X-Amz-Expires")
// Download
reader2, contentType, err := storage.Download(context.Background(), fileID)
assert.NoError(t, err)
assert.Contains(t, contentType, "text/plain")
downloaded, err := io.ReadAll(reader2)
assert.NoError(t, err)
assert.Equal(t, content, downloaded)
reader2.Close()
// Clean up
storage.Delete(context.Background(), fileID)
})
t.Run("Chunked Upload", func(t *testing.T) {
skipIfNoS3Config(t)
storage, err := New(getS3Config())
assert.NoError(t, err)
fileID := "test-chunked.txt"
content1 := []byte("chunk1")
content2 := []byte("chunk2")
// Upload chunks
err = storage.UploadChunk(context.Background(), fileID, 0, bytes.NewReader(content1), "text/plain")
assert.NoError(t, err)
err = storage.UploadChunk(context.Background(), fileID, 1, bytes.NewReader(content2), "text/plain")
assert.NoError(t, err)
// Merge chunks
err = storage.MergeChunks(context.Background(), fileID, 2)
assert.NoError(t, err)
// Download and verify
reader, contentType, err := storage.Download(context.Background(), fileID)
assert.NoError(t, err)
assert.Equal(t, "text/plain", contentType)
downloaded, err := io.ReadAll(reader)
assert.NoError(t, err)
assert.Equal(t, append(content1, content2...), downloaded)
reader.Close()
// Clean up
storage.Delete(context.Background(), fileID)
})
t.Run("File Operations", func(t *testing.T) {
skipIfNoS3Config(t)
storage, err := New(getS3Config())
assert.NoError(t, err)
fileID := "test-ops.txt"
content := []byte("test content")
// Upload file
_, err = storage.Upload(context.Background(), fileID, bytes.NewReader(content), "text/plain")
assert.NoError(t, err)
// Check if file exists
exists := storage.Exists(context.Background(), fileID)
assert.True(t, exists)
// Read file
reader, err := storage.Reader(context.Background(), fileID)
assert.NoError(t, err)
defer reader.Close()
data, err := io.ReadAll(reader)
assert.NoError(t, err)
assert.Equal(t, content, data)
// Delete file
err = storage.Delete(context.Background(), fileID)
assert.NoError(t, err)
// Check if file no longer exists
exists = storage.Exists(context.Background(), fileID)
assert.False(t, exists)
})
t.Run("Download Non-existent File", func(t *testing.T) {
skipIfNoS3Config(t)
storage, err := New(getS3Config())
assert.NoError(t, err)
_, _, err = storage.Download(context.Background(), "non-existent.txt")
assert.Error(t, err)
})
t.Run("Invalid Configuration", func(t *testing.T) {
// Test with missing required fields
_, err := New(map[string]interface{}{
"endpoint": "https://s3.amazonaws.com",
"region": "us-east-1",
// Missing key and secret
})
assert.Error(t, err)
assert.Contains(t, err.Error(), "key and secret are required")
// Test with missing bucket
_, err = New(map[string]interface{}{
"endpoint": "https://s3.amazonaws.com",
"region": "us-east-1",
"key": "test-key",
"secret": "test-secret",
// Missing bucket
})
assert.Error(t, err)
assert.Contains(t, err.Error(), "bucket is required")
})
}

93
neo/attachment/types.go Normal file
View file

@ -0,0 +1,93 @@
package attachment
import (
"context"
"io"
"mime/multipart"
)
// File the file
type File struct {
ID string `json:"file_id"`
Bytes int `json:"bytes"`
CreatedAt int `json:"created_at"`
Filename string `json:"filename"`
ContentType string `json:"content_type"`
Description string `json:"description,omitempty"` // Vision analysis result or other description
URL string `json:"url,omitempty"` // Vision URL for vision-capable models
DocIDs []string `json:"doc_ids,omitempty"` // RAG document IDs
}
// FileResponse represents a file download response
type FileResponse struct {
Reader io.ReadCloser
ContentType string
Extension string
}
// Attachment represents a file attachment
type Attachment struct {
Name string `json:"name,omitempty"`
URL string `json:"url,omitempty"`
Description string `json:"description,omitempty"`
Type string `json:"type,omitempty"`
ContentType string `json:"content_type,omitempty"`
Bytes int64 `json:"bytes,omitempty"`
CreatedAt int64 `json:"created_at,omitempty"`
FileID string `json:"file_id,omitempty"`
ChatID string `json:"chat_id,omitempty"`
AssistantID string `json:"assistant_id,omitempty"`
Gzip bool `json:"gzip,omitempty"` // Gzip the file, Optional, default is false
}
// Manager the manager struct
type Manager struct {
ManagerOption
storage Storage
maxsize int64
chunsize int64
allowedTypes allowedType
}
// Storage the storage interface
type Storage interface {
Upload(ctx context.Context, fileID string, reader io.Reader, contentType string) (string, error)
UploadChunk(ctx context.Context, fileID string, chunkIndex int, reader io.Reader, contentType string) error
MergeChunks(ctx context.Context, fileID string, totalChunks int) error
Download(ctx context.Context, fileID string) (io.ReadCloser, string, error)
Reader(ctx context.Context, fileID string) (io.ReadCloser, error)
URL(ctx context.Context, fileID string) string
Exists(ctx context.Context, fileID string) bool
Delete(ctx context.Context, fileID string) error
}
// ManagerOption the manager option
type ManagerOption struct {
MaxSize string `json:"max_size,omitempty"` // Max size of the file, Optional, default is 20M
ChunkSize string `json:"chunk_size,omitempty"` // Chunk size of the file, Optional, default is 2M
AllowedTypes []string `json:"allowed_types,omitempty"` // Allowed types of the file, Optional, default is all
Driver string `json:"driver,omitempty"` // Driver, Optional, default is local
Options map[string]interface{} `json:"options,omitempty"` // Options, Optional
}
type allowedType struct {
mapping map[string]bool
wildcards []string // Wildcard patterns for file types (e.g., "image/*", "text/*")
}
// UploadOption the upload option
type UploadOption struct {
CompressImage bool `json:"compress_image,omitempty"` // Compress the file, Optional, default is true
CompressSize int `json:"compress_size,omitempty"` // Compress the file size, Optional, default is 1920, if compress_image is true, the file size will be compressed to the compress_size
Gzip bool `json:"gzip,omitempty"` // Gzip the file, Optional, default is false
Knowledge bool `json:"knowledge,omitempty"` // Push to knowledge base, Optional, default is false
ChatID string `json:"chat_id,omitempty"` // Chat ID, Optional
AssistantID string `json:"assistant_id,omitempty"` // Assistant ID, Optional
UserID string `json:"user_id,omitempty"` // User ID, Optional
ChunkSize string `json:"chunk_size,omitempty"` // Chunk size of the file, Optional, default is 2M, default is 2M
}
// FileHeader the file header
type FileHeader struct {
*multipart.FileHeader
}

View file

@ -13,6 +13,7 @@ import (
"github.com/yaoapp/kun/exception"
"github.com/yaoapp/kun/log"
"github.com/yaoapp/kun/maps"
"github.com/yaoapp/yao/neo/attachment"
"github.com/yaoapp/yao/openai"
)
@ -20,33 +21,33 @@ var locker = sync.Mutex{}
// Message the message
type Message struct {
ID string `json:"id,omitempty"` // id for the message
ToolID string `json:"tool_id,omitempty"` // tool_id for the message
Text string `json:"text,omitempty"` // text content
Type string `json:"type,omitempty"` // error, text, plan, table, form, page, file, video, audio, image, markdown, json ...
Props map[string]interface{} `json:"props,omitempty"` // props for the types
IsDone bool `json:"done,omitempty"` // Mark as a done message from neo
IsNew bool `json:"new,omitempty"` // Mark as a new message from neo
IsDelta bool `json:"delta,omitempty"` // Mark as a delta message from neo
Actions []Action `json:"actions,omitempty"` // Conversation Actions for frontend
Attachments []Attachment `json:"attachments,omitempty"` // File attachments
Role string `json:"role,omitempty"` // user, assistant, system ...
Name string `json:"name,omitempty"` // name for the message
AssistantID string `json:"assistant_id,omitempty"` // assistant_id (for assistant role = assistant )
AssistantName string `json:"assistant_name,omitempty"` // assistant_name (for assistant role = assistant )
AssistantAvatar string `json:"assistant_avatar,omitempty"` // assistant_avatar (for assistant role = assistant )
Mentions []Mention `json:"menions,omitempty"` // Mentions for the message ( for user role = user )
Data map[string]interface{} `json:"-"` // data for the message
Pending bool `json:"-"` // pending for the message
Hidden bool `json:"hidden,omitempty"` // hidden for the message (not show in the UI and history)
Retry bool `json:"retry,omitempty"` // retry for the message
Silent bool `json:"silent,omitempty"` // silent for the message (not show in the UI and history)
IsTool bool `json:"-"` // is tool for the message for native tool_calls
IsBeginTool bool `json:"-"` // is new tool for the message for native tool_calls
IsEndTool bool `json:"-"` // is end tool for the message for native tool_calls
Result any `json:"result,omitempty"` // result for the message
Begin int64 `json:"begin,omitempty"` // begin at for the message // timestamp
End int64 `json:"end,omitempty"` // end at for the message // timestamp
ID string `json:"id,omitempty"` // id for the message
ToolID string `json:"tool_id,omitempty"` // tool_id for the message
Text string `json:"text,omitempty"` // text content
Type string `json:"type,omitempty"` // error, text, plan, table, form, page, file, video, audio, image, markdown, json ...
Props map[string]interface{} `json:"props,omitempty"` // props for the types
IsDone bool `json:"done,omitempty"` // Mark as a done message from neo
IsNew bool `json:"new,omitempty"` // Mark as a new message from neo
IsDelta bool `json:"delta,omitempty"` // Mark as a delta message from neo
Actions []Action `json:"actions,omitempty"` // Conversation Actions for frontend
Attachments []attachment.Attachment `json:"attachments,omitempty"` // File attachments
Role string `json:"role,omitempty"` // user, assistant, system ...
Name string `json:"name,omitempty"` // name for the message
AssistantID string `json:"assistant_id,omitempty"` // assistant_id (for assistant role = assistant )
AssistantName string `json:"assistant_name,omitempty"` // assistant_name (for assistant role = assistant )
AssistantAvatar string `json:"assistant_avatar,omitempty"` // assistant_avatar (for assistant role = assistant )
Mentions []Mention `json:"menions,omitempty"` // Mentions for the message ( for user role = user )
Data map[string]interface{} `json:"-"` // data for the message
Pending bool `json:"-"` // pending for the message
Hidden bool `json:"hidden,omitempty"` // hidden for the message (not show in the UI and history)
Retry bool `json:"retry,omitempty"` // retry for the message
Silent bool `json:"silent,omitempty"` // silent for the message (not show in the UI and history)
IsTool bool `json:"-"` // is tool for the message for native tool_calls
IsBeginTool bool `json:"-"` // is new tool for the message for native tool_calls
IsEndTool bool `json:"-"` // is end tool for the message for native tool_calls
Result any `json:"result,omitempty"` // result for the message
Begin int64 `json:"begin,omitempty"` // begin at for the message // timestamp
End int64 `json:"end,omitempty"` // end at for the message // timestamp
}
// Mention represents a mention
@ -56,20 +57,6 @@ type Mention struct {
Avatar string `json:"avatar,omitempty"` // avatar
}
// Attachment represents a file attachment
type Attachment struct {
Name string `json:"name,omitempty"`
URL string `json:"url,omitempty"`
Description string `json:"description,omitempty"`
Type string `json:"type,omitempty"`
ContentType string `json:"content_type,omitempty"`
Bytes int64 `json:"bytes,omitempty"`
CreatedAt int64 `json:"created_at,omitempty"`
FileID string `json:"file_id,omitempty"`
ChatID string `json:"chat_id,omitempty"`
AssistantID string `json:"assistant_id,omitempty"`
}
// Action the action
type Action struct {
Name string `json:"name,omitempty"`
@ -500,7 +487,7 @@ func (m *Message) Map(msg map[string]interface{}) *Message {
// attachments
if attachments, has := msg["attachments"]; has {
raw, _ := jsoniter.Marshal(attachments)
m.Attachments = []Attachment{}
m.Attachments = []attachment.Attachment{}
if err := jsoniter.Unmarshal(raw, &m.Attachments); err != nil {
color.Red("JSON parse error: %s", err.Error())
color.White(string(raw))

View file

@ -68,8 +68,11 @@ func (neo *DSL) Upload(ctx chatctx.Context, c *gin.Context) (*assistant.File, er
return nil, err
}
}
fmt.Println(ast)
return ast.Upload(ctx, tmpfile, reader, option)
return nil, nil
// return ast.Upload(ctx, tmpfile, reader, option)
}
// Download downloads a file
@ -93,5 +96,7 @@ func (neo *DSL) Download(ctx chatctx.Context, c *gin.Context) (*assistant.FileRe
}
// Download file using the assistant
return ast.Download(ctx.Context, fileID)
// return ast.Download(ctx.Context, fileID)
fmt.Println(ast)
return nil, nil
}