Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b8265d77f | ||
|
|
ceecc95c29 | ||
|
|
27c48446ce | ||
|
|
1d65c0e8d6 | ||
|
|
6b09c1ffac | ||
|
|
07d8e7e0b1 | ||
|
|
e80e41266a | ||
|
|
640d543487 | ||
|
|
aef3cee03c | ||
|
|
a9d843d885 | ||
|
|
bcc9d19916 | ||
|
|
23d2d40613 | ||
|
|
f873a4aeb9 | ||
|
|
a49495268f | ||
|
|
389b1bdd2e | ||
|
|
09c903ec2d | ||
|
|
b4c0455f70 | ||
|
|
6f59fe2b37 | ||
|
|
365853fade | ||
|
|
837ce742e0 | ||
|
|
11bf29fad7 | ||
|
|
350e898bda | ||
|
|
3c29788980 | ||
|
|
09ff58362b | ||
|
|
7cb6c148dd | ||
|
|
e8cca6ea15 | ||
|
|
a067234c64 | ||
|
|
b74463d9e5 | ||
|
|
28f85b4d45 | ||
|
|
e8567524ac | ||
|
|
c0dcee45d4 | ||
|
|
d51343ba8d | ||
|
|
777a1961d0 | ||
|
|
706cb057bc | ||
|
|
31c0356acc | ||
|
|
47de9769d0 | ||
|
|
ebb7ad0207 | ||
|
|
750c0983f1 | ||
|
|
f005466293 | ||
|
|
aacf9e05b9 | ||
|
|
e19fdb2ad2 |
@@ -8,3 +8,5 @@ tmp
|
||||
tank
|
||||
tank.exe
|
||||
|
||||
# mac
|
||||
.DS_Store
|
||||
@@ -9,10 +9,13 @@
|
||||
2. build/pack/build.sh
|
||||
VERSION_NAME=tank-x.x.x
|
||||
|
||||
3. core/config.go
|
||||
3. build/pack/cross-build.sh
|
||||
VERSION_NAME=tank-x.x.x
|
||||
|
||||
4. core/config.go
|
||||
VERSION = x.x.x
|
||||
|
||||
4. readme.md 修改 eyeblue/tank:x.x.x
|
||||
5. readme.md 修改 eyeblue/tank:x.x.x
|
||||
eyeblue/tank:x.x.x
|
||||
|
||||
|
||||
@@ -51,6 +54,29 @@ c. 在github上发布新版本。
|
||||
go get xxx/xxx
|
||||
go mod tidy
|
||||
|
||||
tank-4.1.1 2024-10-6
|
||||
1. 修复已知问题
|
||||
|
||||
tank-4.1.0 2024-7-27
|
||||
1. 删除用户后,空间一并删除。 #222
|
||||
2. 增加批量绑定空间成员功能。 #227
|
||||
3. 共享空间增加回收站逻辑。 #232
|
||||
4. 修复用户修改空间单位问题。 #233 #253
|
||||
5. 增强搜索功能,会进行dfs深度优先搜索。 #239
|
||||
|
||||
|
||||
1. When delete user, it's space will be deleted #222
|
||||
2. Batch creating space member #227
|
||||
3. Add feature of recycle bin for space #232
|
||||
4. Fix issue of changing unit #233 #253
|
||||
5. Enhance the search ability, using dfs #239
|
||||
|
||||
|
||||
tank-4.0.4 2024-4-29
|
||||
1. 修复已知问题
|
||||
|
||||
tank-4.0.3 2024-3-26
|
||||
1. 修复已知问题
|
||||
|
||||
tank-4.0.2 2023-11-10
|
||||
1. 修复BUG https://github.com/eyebluecn/tank/issues/229
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# use golang 1.18
|
||||
FROM golang:1.18
|
||||
# use golang 1.22.5
|
||||
FROM golang:1.22.5
|
||||
|
||||
# maintainer. Author's name and email.
|
||||
MAINTAINER eyeblue "eyebluecn@126.com"
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
|
||||
[English Version](https://tank-doc.eyeblue.cn/en)
|
||||
|
||||
# 蓝眼云盘(4.0.2)
|
||||
# 蓝眼云盘(4.2.0)
|
||||
[在线Demo](https://tanker.eyeblue.cn) (体验账号: demo 密码:123456)
|
||||
|
||||
蓝眼云盘是蓝眼开源系列代表作品之一,致力于打造精致,优雅,简约的云盘。核心功能如下:
|
||||
1. 文件列表
|
||||
2. 文件/文件夹上传 + 拖拽上传
|
||||
2. 文件/文件夹上传 + 拖拽上传 + 分片/断点续传
|
||||
3. 共享空间
|
||||
3. 文件分享
|
||||
4. 文件监控统计
|
||||
@@ -56,7 +56,7 @@ go mod tidy
|
||||
|
||||
当然你可以加入钉钉群(一群已满员)一起直接交流,在钉钉群中可以获取最新beta版本。
|
||||
|
||||

|
||||

|
||||
|
||||
群号:44754005
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 188 KiB |
@@ -1,27 +1,29 @@
|
||||
{
|
||||
"files": {
|
||||
"main.css": "/static/css/main.884ffe67.css",
|
||||
"main.js": "/static/js/main.eea229b2.js",
|
||||
"main.css": "/static/css/main.3e2e293d.css",
|
||||
"main.js": "/static/js/main.1a935cc6.js",
|
||||
"static/js/833.2731ecbb.chunk.js": "/static/js/833.2731ecbb.chunk.js",
|
||||
"static/media/logo.png": "/static/media/logo.847e54ef7fb4b744fad4.png",
|
||||
"static/media/empty.svg": "/static/media/empty.087b383305fa7993cbf2fd9ccfd2cdaf.svg",
|
||||
"static/media/psd.svg": "/static/media/psd.d0769f3468941f83b2cd1491e6ac7d69.svg",
|
||||
"static/media/xls.svg": "/static/media/xls.7a882f471ff07b1015705abf4945d6a3.svg",
|
||||
"static/media/ppt.svg": "/static/media/ppt.b987e0e201d3a6ebb0f83c5444521626.svg",
|
||||
"static/media/doc.svg": "/static/media/doc.bc5d5331ff681915068a120f067a9dec.svg",
|
||||
"static/media/pdf.svg": "/static/media/pdf.c92b8a844f37db16d4a72bf62a73138c.svg",
|
||||
"static/media/video.svg": "/static/media/video.a1b07956cad84a3efc508d024286b30f.svg",
|
||||
"static/media/file.svg": "/static/media/file.9f6f3fda1dd60a03fe595532e845852c.svg",
|
||||
"static/media/text.svg": "/static/media/text.8aeb382b647dad745f0e9c2f11c349d2.svg",
|
||||
"static/media/audio.svg": "/static/media/audio.0d111f7f828e8ea78c83b47d23e771a2.svg",
|
||||
"static/media/image.svg": "/static/media/image.333c8c7c7df48b383c852de5ce99c33c.svg",
|
||||
"static/media/folder.svg": "/static/media/folder.fce5913565dc18a92c7bbbe19ba6c4d0.svg",
|
||||
"static/media/archive.svg": "/static/media/archive.982c48675c38139800a2f45d7c86a661.svg",
|
||||
"static/media/empty.svg": "/static/media/empty.1ac6f6fb3e1d8c985f32df632ded4fe1.svg",
|
||||
"static/media/psd.svg": "/static/media/psd.f2767f53369b5e75d7708a857d8381f4.svg",
|
||||
"static/media/xls.svg": "/static/media/xls.9f55e13113424a3cc3316acbf072f072.svg",
|
||||
"static/media/ppt.svg": "/static/media/ppt.8696321d8f3893a15681473769c8283c.svg",
|
||||
"static/media/doc.svg": "/static/media/doc.017d14ee10ef3d2427494d1b0e52b03e.svg",
|
||||
"static/media/pdf.svg": "/static/media/pdf.67d0061b2cef7912ed36b895cb8a328a.svg",
|
||||
"static/media/video.svg": "/static/media/video.09bf3324c0e699b3611bf6bb0fc1b50e.svg",
|
||||
"static/media/file.svg": "/static/media/file.b6be968e09dc04a45ec49a0dbcef53da.svg",
|
||||
"static/media/text.svg": "/static/media/text.fa66aadee4b7dd9d51f4d1c863e5f75e.svg",
|
||||
"static/media/audio.svg": "/static/media/audio.59dab6a9512494e5a34c8a424ab68f39.svg",
|
||||
"static/media/image.svg": "/static/media/image.ee35022875b37a7962afa08bce8bc1fd.svg",
|
||||
"static/media/folder.svg": "/static/media/folder.b4ce8a2960e8da846081c692d3065dff.svg",
|
||||
"static/media/archive.svg": "/static/media/archive.f58837716d1fc6f2e61dc1618dad27a6.svg",
|
||||
"index.html": "/index.html",
|
||||
"main.884ffe67.css.map": "/static/css/main.884ffe67.css.map",
|
||||
"main.eea229b2.js.map": "/static/js/main.eea229b2.js.map"
|
||||
"main.3e2e293d.css.map": "/static/css/main.3e2e293d.css.map",
|
||||
"main.1a935cc6.js.map": "/static/js/main.1a935cc6.js.map",
|
||||
"833.2731ecbb.chunk.js.map": "/static/js/833.2731ecbb.chunk.js.map"
|
||||
},
|
||||
"entrypoints": [
|
||||
"static/css/main.884ffe67.css",
|
||||
"static/js/main.eea229b2.js"
|
||||
"static/css/main.3e2e293d.css",
|
||||
"static/js/main.1a935cc6.js"
|
||||
]
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
<!doctype html><html lang="en"><head><meta charset="utf-8"/><link rel="icon" href="/favicon.ico"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#000000"/><meta name="description" content="eyeblue tank"/><title>EyeblueTank</title><script defer="defer" src="/static/js/main.eea229b2.js"></script><link href="/static/css/main.884ffe67.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run EyeblueTank.</noscript><div id="root"></div></body></html>
|
||||
<!doctype html><html lang="en"><head><meta charset="utf-8"/><link rel="icon" href="/favicon.ico"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#000000"/><meta name="description" content="eyeblue tank"/><title>EyeblueTank</title><script defer="defer" src="/static/js/main.1a935cc6.js"></script><link href="/static/css/main.3e2e293d.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run EyeblueTank.</noscript><div id="root"></div></body></html>
|
||||
|
Before Width: | Height: | Size: 855 B After Width: | Height: | Size: 855 B |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 5.7 KiB After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 949 B After Width: | Height: | Size: 949 B |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 2.6 KiB |
@@ -11,7 +11,7 @@
|
||||
@REM prepare the variables.
|
||||
|
||||
@REM version name
|
||||
SET VERSION_NAME=tank-4.0.2
|
||||
SET VERSION_NAME=tank-4.2.0
|
||||
ECHO VERSION_NAME: %VERSION_NAME%
|
||||
@REM golang proxy
|
||||
SET GOPROXY=https://goproxy.cn
|
||||
|
||||
@@ -10,7 +10,7 @@ set -e
|
||||
#prepare the variables.
|
||||
|
||||
# version name
|
||||
VERSION_NAME=tank-4.0.2
|
||||
VERSION_NAME=tank-4.2.0
|
||||
echo "VERSION_NAME: ${VERSION_NAME}"
|
||||
# golang proxy
|
||||
GOPROXY=https://goproxy.cn
|
||||
|
||||
@@ -0,0 +1,379 @@
|
||||
#!/bin/bash
|
||||
#set -e
|
||||
|
||||
###########################################################################
|
||||
#
|
||||
# Tank cross-compilation build script
|
||||
# Supports building for multiple platforms and architectures
|
||||
#
|
||||
###########################################################################
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Print usage information
|
||||
usage() {
|
||||
echo "Usage: $0 [OPTIONS]"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " -v, --version VERSION Version name (default: tank-4.2.0)"
|
||||
echo " -o, --os OS Target OS: linux, windows, darwin, or 'all'"
|
||||
echo " -a, --arch ARCH Target architecture: amd64, arm64, 386, or 'all'"
|
||||
echo " -p, --platform PLATFORM Specific platform (OS/ARCH format, e.g. linux/amd64)"
|
||||
echo " --list-platforms List all supported platforms"
|
||||
echo " --clean Clean dist directory before building"
|
||||
echo " -h, --help Show this help message"
|
||||
echo ""
|
||||
echo "Examples:"
|
||||
echo " $0 # Build for current platform"
|
||||
echo " $0 -o linux -a amd64 # Build for Linux AMD64"
|
||||
echo " $0 -p linux/arm64 # Build for Linux ARM64"
|
||||
echo " $0 -o all -a amd64 # Build for all OS with AMD64"
|
||||
echo " $0 -o all -a all # Build for all supported platforms"
|
||||
echo " $0 --clean -o all -a all # Clean and build all platforms"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# List supported platforms
|
||||
list_platforms() {
|
||||
echo "Supported platforms:"
|
||||
echo " linux/amd64 - Linux 64-bit"
|
||||
echo " linux/arm64 - Linux ARM64"
|
||||
echo " linux/386 - Linux 32-bit"
|
||||
echo " windows/amd64 - Windows 64-bit"
|
||||
echo " windows/arm64 - Windows ARM64"
|
||||
echo " windows/386 - Windows 32-bit"
|
||||
echo " darwin/amd64 - macOS Intel"
|
||||
echo " darwin/arm64 - macOS Apple Silicon"
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Log functions
|
||||
log_info() {
|
||||
echo -e "${BLUE}[INFO]${NC} $1"
|
||||
}
|
||||
|
||||
log_success() {
|
||||
echo -e "${GREEN}[SUCCESS]${NC} $1"
|
||||
}
|
||||
|
||||
log_warn() {
|
||||
echo -e "${YELLOW}[WARNING]${NC} $1"
|
||||
}
|
||||
|
||||
log_error() {
|
||||
echo -e "${RED}[ERROR]${NC} $1"
|
||||
}
|
||||
|
||||
# Default values
|
||||
VERSION_NAME="tank-4.2.0"
|
||||
TARGET_OS=""
|
||||
TARGET_ARCH=""
|
||||
SPECIFIC_PLATFORM=""
|
||||
BUILD_ALL_OS=false
|
||||
BUILD_ALL_ARCH=false
|
||||
CLEAN_DIST=false
|
||||
|
||||
# Parse command line arguments
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
-v|--version)
|
||||
VERSION_NAME="$2"
|
||||
shift 2
|
||||
;;
|
||||
-o|--os)
|
||||
if [[ "$2" == "all" ]]; then
|
||||
BUILD_ALL_OS=true
|
||||
else
|
||||
TARGET_OS="$2"
|
||||
fi
|
||||
shift 2
|
||||
;;
|
||||
-a|--arch)
|
||||
if [[ "$2" == "all" ]]; then
|
||||
BUILD_ALL_ARCH=true
|
||||
else
|
||||
TARGET_ARCH="$2"
|
||||
fi
|
||||
shift 2
|
||||
;;
|
||||
-p|--platform)
|
||||
SPECIFIC_PLATFORM="$2"
|
||||
shift 2
|
||||
;;
|
||||
--list-platforms)
|
||||
list_platforms
|
||||
;;
|
||||
--clean)
|
||||
CLEAN_DIST=true
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
;;
|
||||
*)
|
||||
log_error "Unknown option: $1"
|
||||
usage
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Parse specific platform
|
||||
if [[ -n "$SPECIFIC_PLATFORM" ]]; then
|
||||
if [[ "$SPECIFIC_PLATFORM" =~ ^([^/]+)/([^/]+)$ ]]; then
|
||||
TARGET_OS="${BASH_REMATCH[1]}"
|
||||
TARGET_ARCH="${BASH_REMATCH[2]}"
|
||||
else
|
||||
log_error "Invalid platform format. Use OS/ARCH format (e.g., linux/amd64)"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Set defaults if nothing specified
|
||||
if [[ -z "$TARGET_OS" && "$BUILD_ALL_OS" == false ]]; then
|
||||
TARGET_OS=$(go env GOOS)
|
||||
fi
|
||||
|
||||
if [[ -z "$TARGET_ARCH" && "$BUILD_ALL_ARCH" == false ]]; then
|
||||
TARGET_ARCH=$(go env GOARCH)
|
||||
fi
|
||||
|
||||
# Define supported platforms as a simple list
|
||||
SUPPORTED_PLATFORMS="linux/amd64 linux/arm64 linux/386 windows/amd64 windows/arm64 windows/386 darwin/amd64 darwin/arm64"
|
||||
|
||||
# Check if platform is supported
|
||||
is_platform_supported() {
|
||||
local platform="$1"
|
||||
echo "$SUPPORTED_PLATFORMS" | grep -q "\b$platform\b"
|
||||
}
|
||||
|
||||
# Validate platform
|
||||
validate_platform() {
|
||||
local os=$1
|
||||
local arch=$2
|
||||
local platform="${os}/${arch}"
|
||||
|
||||
if ! is_platform_supported "$platform"; then
|
||||
log_warn "Platform ${platform} may not be fully tested"
|
||||
return 1
|
||||
fi
|
||||
log_info "Platform ${platform} supported"
|
||||
return 0
|
||||
}
|
||||
|
||||
# Setup directories
|
||||
GOPROXY=https://goproxy.cn
|
||||
PACK_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )"
|
||||
BUILD_DIR=$(dirname ${PACK_DIR})
|
||||
PROJECT_DIR=$(dirname ${BUILD_DIR})
|
||||
DIST_DIR=${PROJECT_DIR}/tmp/dist
|
||||
|
||||
log_info "=== Tank Cross-Compilation Build ==="
|
||||
log_info "Version: ${VERSION_NAME}"
|
||||
log_info "GOPROXY: ${GOPROXY}"
|
||||
log_info "Project: ${PROJECT_DIR}"
|
||||
log_info "Dist: ${DIST_DIR}"
|
||||
|
||||
# Clean dist directory if requested
|
||||
if [[ "$CLEAN_DIST" == true ]]; then
|
||||
log_info "Cleaning dist directory..."
|
||||
rm -rf ${DIST_DIR}
|
||||
fi
|
||||
|
||||
# Create dist directory
|
||||
mkdir -p ${DIST_DIR}
|
||||
|
||||
# Build function for specific platform
|
||||
build_platform() {
|
||||
local os=$1
|
||||
local arch=$2
|
||||
local platform="${os}/${arch}"
|
||||
|
||||
log_info "Building ${platform}..."
|
||||
|
||||
# Validate platform
|
||||
validate_platform "$os" "$arch"
|
||||
|
||||
# Determine binary name and archive format
|
||||
local binary_name="tank"
|
||||
local archive_ext="tar.gz"
|
||||
if [[ "$os" == "windows" ]]; then
|
||||
binary_name="tank.exe"
|
||||
archive_ext="zip"
|
||||
fi
|
||||
|
||||
# Setup paths
|
||||
local file_name="${VERSION_NAME}.${os}-${arch}.${archive_ext}"
|
||||
local component_dir="${DIST_DIR}/${VERSION_NAME}-${os}-${arch}"
|
||||
local dist_path="${DIST_DIR}/${file_name}"
|
||||
|
||||
log_info " Target: ${platform}"
|
||||
log_info " Binary: ${binary_name}"
|
||||
log_info " Output: ${file_name}"
|
||||
|
||||
# Clean previous build
|
||||
if [[ -d "$component_dir" ]]; then
|
||||
rm -rf "$component_dir"
|
||||
fi
|
||||
mkdir -p "$component_dir"
|
||||
|
||||
# Change to project directory
|
||||
cd ${PROJECT_DIR}
|
||||
|
||||
# Cross-compile
|
||||
log_info " Cross-compiling..."
|
||||
export GOOS="$os"
|
||||
export GOARCH="$arch"
|
||||
export GOPROXY="$GOPROXY"
|
||||
export CGO_ENABLED=0 # Disable CGO for cross-compilation
|
||||
|
||||
log_info " Starting go build..." # 添加此行
|
||||
if ! go build -mod=readonly -ldflags="-s -w" -o "${binary_name}"; then
|
||||
log_error "Failed to build for ${platform}"
|
||||
return 1
|
||||
fi
|
||||
log_info " go build completed" # 添加此行
|
||||
|
||||
# Copy binary
|
||||
log_info " Copying binary..."
|
||||
cp "./${binary_name}" "${component_dir}/"
|
||||
|
||||
# Copy configuration and static files
|
||||
log_info " Copying build files..."
|
||||
if [[ -d "${BUILD_DIR}/conf" ]]; then
|
||||
cp -r "${BUILD_DIR}/conf" "${component_dir}/"
|
||||
fi
|
||||
if [[ -d "${BUILD_DIR}/html" ]]; then
|
||||
cp -r "${BUILD_DIR}/html" "${component_dir}/"
|
||||
fi
|
||||
if [[ -d "${BUILD_DIR}/service" ]]; then
|
||||
cp -r "${BUILD_DIR}/service" "${component_dir}/"
|
||||
fi
|
||||
|
||||
# Create archive
|
||||
log_info " Creating archive..."
|
||||
cd ${DIST_DIR}
|
||||
|
||||
if [[ "$archive_ext" == "zip" ]]; then
|
||||
# Use zip for Windows
|
||||
if command -v zip >/dev/null 2>&1; then
|
||||
zip -r "${file_name}" "$(basename "$component_dir")" >/dev/null 2>&1
|
||||
else
|
||||
log_warn "zip command not found, using tar.gz instead"
|
||||
tar -zcf "${VERSION_NAME}.${os}-${arch}.tar.gz" "$(basename "$component_dir")" >/dev/null 2>&1
|
||||
fi
|
||||
else
|
||||
# Use tar.gz for Unix-like systems
|
||||
tar -zcf "${file_name}" "$(basename "$component_dir")" >/dev/null 2>&1
|
||||
fi
|
||||
|
||||
if [[ $? -ne 0 ]]; then
|
||||
log_error "Failed to create archive for ${platform}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Cleanup
|
||||
cd ${PROJECT_DIR}
|
||||
rm -f "./${binary_name}"
|
||||
rm -rf "$component_dir"
|
||||
|
||||
# Get file size
|
||||
local file_size=$(ls -lh "${DIST_DIR}/${file_name}" | awk '{print $5}')
|
||||
log_success "Built ${file_name} (${file_size})"
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# Build execution
|
||||
build_count=0
|
||||
success_count=0
|
||||
failed_builds=()
|
||||
|
||||
if [[ "$BUILD_ALL_OS" == true && "$BUILD_ALL_ARCH" == true ]]; then
|
||||
log_info "Building for all supported platforms..."
|
||||
# Build all supported platforms explicitly
|
||||
for os in linux windows darwin; do
|
||||
for arch in amd64 arm64 386; do
|
||||
platform="${os}/${arch}"
|
||||
if is_platform_supported "$platform"; then
|
||||
((build_count++))
|
||||
if build_platform "$os" "$arch"; then
|
||||
((success_count++))
|
||||
else
|
||||
failed_builds+=("$platform")
|
||||
fi
|
||||
fi
|
||||
done
|
||||
done
|
||||
elif [[ "$BUILD_ALL_OS" == true ]]; then
|
||||
log_info "Building for all OS with architecture: ${TARGET_ARCH}"
|
||||
for os in linux windows darwin; do
|
||||
platform="${os}/${TARGET_ARCH}"
|
||||
if is_platform_supported "$platform"; then
|
||||
((build_count++))
|
||||
if build_platform "$os" "$TARGET_ARCH"; then
|
||||
((success_count++))
|
||||
else
|
||||
failed_builds+=("$platform")
|
||||
fi
|
||||
fi
|
||||
done
|
||||
elif [[ "$BUILD_ALL_ARCH" == true ]]; then
|
||||
log_info "Building for OS ${TARGET_OS} with all architectures"
|
||||
for arch in amd64 arm64 386; do
|
||||
platform="${TARGET_OS}/${arch}"
|
||||
if is_platform_supported "$platform"; then
|
||||
((build_count++))
|
||||
if build_platform "$TARGET_OS" "$arch"; then
|
||||
((success_count++))
|
||||
else
|
||||
failed_builds+=("$platform")
|
||||
fi
|
||||
fi
|
||||
done
|
||||
else
|
||||
log_info "Building for ${TARGET_OS}/${TARGET_ARCH}"
|
||||
((build_count++))
|
||||
if build_platform "$TARGET_OS" "$TARGET_ARCH"; then
|
||||
((success_count++))
|
||||
else
|
||||
failed_builds+=("${TARGET_OS}/${TARGET_ARCH}")
|
||||
fi
|
||||
fi
|
||||
|
||||
# Build summary
|
||||
echo ""
|
||||
log_info "=== Build Summary ==="
|
||||
echo "Total builds: ${build_count}"
|
||||
echo "Successful: ${success_count}"
|
||||
echo "Failed: $((build_count - success_count))"
|
||||
|
||||
if [[ ${#failed_builds[@]} -gt 0 ]]; then
|
||||
echo ""
|
||||
log_warn "Failed builds:"
|
||||
for platform in "${failed_builds[@]}"; do
|
||||
echo " - $platform"
|
||||
done
|
||||
fi
|
||||
|
||||
if [[ $success_count -gt 0 ]]; then
|
||||
echo ""
|
||||
log_success "Generated files:"
|
||||
ls -lh ${DIST_DIR}/*.{tar.gz,zip} 2>/dev/null | awk '{print " " $9 " (" $5 ")"}'
|
||||
|
||||
echo ""
|
||||
log_info "Files located in: ${DIST_DIR}"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
if [[ $((build_count - success_count)) -eq 0 ]]; then
|
||||
log_success "All builds completed successfully!"
|
||||
exit 0
|
||||
else
|
||||
log_error "Some builds failed!"
|
||||
exit 1
|
||||
fi
|
||||
@@ -11,10 +11,10 @@ const (
|
||||
|
||||
DEFAULT_SERVER_PORT = 6010
|
||||
|
||||
//db table's prefix. tank40_ means current version is tank:4.0.x
|
||||
TABLE_PREFIX = "tank40_"
|
||||
//db table's prefix. tank42_ means current version is tank:4.2.x
|
||||
TABLE_PREFIX = "tank42_"
|
||||
|
||||
VERSION = "4.0.2"
|
||||
VERSION = "4.2.0"
|
||||
)
|
||||
|
||||
type Config interface {
|
||||
|
||||
@@ -99,8 +99,10 @@ func (this *AlienService) ValidMatter(
|
||||
}
|
||||
|
||||
tokenUser := this.userDao.CheckByUuid(downloadToken.UserUuid)
|
||||
if matter.UserUuid != tokenUser.Uuid {
|
||||
panic(result.UNAUTHORIZED)
|
||||
|
||||
if matter.SpaceUuid != tokenUser.SpaceUuid {
|
||||
//whether user has the space's read auth.
|
||||
this.spaceService.CheckReadableByUuid(request, tokenUser, matter.SpaceUuid)
|
||||
}
|
||||
|
||||
//TODO: expire the download token. If download by chunk, do this later.
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
package rest
|
||||
|
||||
import (
|
||||
"github.com/eyebluecn/tank/code/core"
|
||||
"github.com/eyebluecn/tank/code/tool/result"
|
||||
"github.com/eyebluecn/tank/code/tool/util"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// ChunkController handles chunked upload API endpoints
|
||||
type ChunkController struct {
|
||||
BaseController
|
||||
chunkService *ChunkService
|
||||
matterDao *MatterDao
|
||||
uploadSessionDao *UploadSessionDao
|
||||
uploadChunkDao *UploadChunkDao
|
||||
}
|
||||
|
||||
func (this *ChunkController) Init() {
|
||||
this.BaseController.Init()
|
||||
|
||||
b := core.CONTEXT.GetBean(this.chunkService)
|
||||
if b, ok := b.(*ChunkService); ok {
|
||||
this.chunkService = b
|
||||
}
|
||||
|
||||
b = core.CONTEXT.GetBean(this.matterDao)
|
||||
if b, ok := b.(*MatterDao); ok {
|
||||
this.matterDao = b
|
||||
}
|
||||
|
||||
b = core.CONTEXT.GetBean(this.uploadSessionDao)
|
||||
if b, ok := b.(*UploadSessionDao); ok {
|
||||
this.uploadSessionDao = b
|
||||
}
|
||||
|
||||
b = core.CONTEXT.GetBean(this.uploadChunkDao)
|
||||
if b, ok := b.(*UploadChunkDao); ok {
|
||||
this.uploadChunkDao = b
|
||||
}
|
||||
}
|
||||
|
||||
func (this *ChunkController) RegisterRoutes() map[string]func(writer http.ResponseWriter, request *http.Request) {
|
||||
routeMap := make(map[string]func(writer http.ResponseWriter, request *http.Request))
|
||||
|
||||
// Chunked upload endpoints
|
||||
routeMap["/api/chunk/create/session"] = this.Wrap(this.CreateSession, USER_ROLE_USER)
|
||||
routeMap["/api/chunk/upload"] = this.Wrap(this.Upload, USER_ROLE_USER)
|
||||
routeMap["/api/chunk/merge"] = this.Wrap(this.Merge, USER_ROLE_USER)
|
||||
routeMap["/api/chunk/session/info"] = this.Wrap(this.SessionInfo, USER_ROLE_USER)
|
||||
routeMap["/api/chunk/cancel"] = this.Wrap(this.Cancel, USER_ROLE_USER)
|
||||
routeMap["/api/chunk/clean/expired"] = this.Wrap(this.CleanExpired, USER_ROLE_ADMINISTRATOR)
|
||||
|
||||
return routeMap
|
||||
}
|
||||
|
||||
// CreateSession creates a new chunked upload session
|
||||
// Parameters:
|
||||
// - puuid: parent directory uuid
|
||||
// - filename: target filename
|
||||
// - totalSize: total file size in bytes
|
||||
// - chunkSize: size of each chunk (optional, default 5MB)
|
||||
// - privacy: whether the file is private (optional, default true)
|
||||
// - fileMd5: MD5 of the whole file (optional, for verification)
|
||||
// - spaceUuid: space uuid (optional, default user's private space)
|
||||
func (this *ChunkController) CreateSession(writer http.ResponseWriter, request *http.Request) *result.WebResult {
|
||||
puuid := util.ExtractRequestString(request, "puuid")
|
||||
filename := util.ExtractRequestString(request, "filename")
|
||||
totalSize := util.ExtractRequestInt64(request, "totalSize")
|
||||
chunkSize := util.ExtractRequestOptionalInt64(request, "chunkSize", DEFAULT_CHUNK_SIZE)
|
||||
privacy := util.ExtractRequestOptionalBool(request, "privacy", true)
|
||||
fileMd5 := util.ExtractRequestOptionalString(request, "fileMd5", "")
|
||||
|
||||
user := this.checkUser(request)
|
||||
spaceUuid := util.ExtractRequestOptionalString(request, "spaceUuid", user.SpaceUuid)
|
||||
space := this.spaceService.CheckWritableByUuid(request, user, spaceUuid)
|
||||
|
||||
dirMatter := this.matterDao.CheckWithRootByUuid(puuid, space)
|
||||
|
||||
session := this.chunkService.CreateSession(request, user, space, dirMatter, filename, totalSize, chunkSize, privacy, fileMd5)
|
||||
|
||||
return this.Success(session)
|
||||
}
|
||||
|
||||
// Upload uploads a single chunk
|
||||
// Parameters:
|
||||
// - sessionUuid: upload session uuid
|
||||
// - chunkIndex: chunk index (0-based)
|
||||
// - file: chunk file data
|
||||
// - chunkMd5: MD5 of the chunk (optional, for verification)
|
||||
// - spaceUuid: space uuid (optional)
|
||||
func (this *ChunkController) Upload(writer http.ResponseWriter, request *http.Request) *result.WebResult {
|
||||
sessionUuid := util.ExtractRequestString(request, "sessionUuid")
|
||||
chunkIndex := util.ExtractRequestInt(request, "chunkIndex")
|
||||
chunkMd5 := util.ExtractRequestOptionalString(request, "chunkMd5", "")
|
||||
|
||||
user := this.checkUser(request)
|
||||
spaceUuid := util.ExtractRequestOptionalString(request, "spaceUuid", user.SpaceUuid)
|
||||
space := this.spaceService.CheckWritableByUuid(request, user, spaceUuid)
|
||||
|
||||
file, _, err := request.FormFile("file")
|
||||
if err != nil {
|
||||
panic(result.BadRequest("file is required: %s", err.Error()))
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
chunk := this.chunkService.UploadChunk(request, sessionUuid, chunkIndex, file, chunkMd5, user, space)
|
||||
|
||||
return this.Success(chunk)
|
||||
}
|
||||
|
||||
// Merge merges all chunks into the final file
|
||||
// Parameters:
|
||||
// - sessionUuid: upload session uuid
|
||||
// - spaceUuid: space uuid (optional)
|
||||
func (this *ChunkController) Merge(writer http.ResponseWriter, request *http.Request) *result.WebResult {
|
||||
sessionUuid := util.ExtractRequestString(request, "sessionUuid")
|
||||
|
||||
user := this.checkUser(request)
|
||||
spaceUuid := util.ExtractRequestOptionalString(request, "spaceUuid", user.SpaceUuid)
|
||||
space := this.spaceService.CheckWritableByUuid(request, user, spaceUuid)
|
||||
|
||||
matter := this.chunkService.MergeChunks(request, sessionUuid, user, space)
|
||||
|
||||
return this.Success(matter)
|
||||
}
|
||||
|
||||
// SessionInfo gets the upload session info including uploaded chunks
|
||||
// Parameters:
|
||||
// - sessionUuid: upload session uuid
|
||||
func (this *ChunkController) SessionInfo(writer http.ResponseWriter, request *http.Request) *result.WebResult {
|
||||
sessionUuid := util.ExtractRequestString(request, "sessionUuid")
|
||||
|
||||
user := this.checkUser(request)
|
||||
|
||||
sessionInfo := this.chunkService.GetSessionInfo(sessionUuid, user)
|
||||
|
||||
return this.Success(sessionInfo)
|
||||
}
|
||||
|
||||
// Cancel cancels an upload session
|
||||
// Parameters:
|
||||
// - sessionUuid: upload session uuid
|
||||
// - spaceUuid: space uuid (optional)
|
||||
func (this *ChunkController) Cancel(writer http.ResponseWriter, request *http.Request) *result.WebResult {
|
||||
sessionUuid := util.ExtractRequestString(request, "sessionUuid")
|
||||
|
||||
user := this.checkUser(request)
|
||||
spaceUuid := util.ExtractRequestOptionalString(request, "spaceUuid", user.SpaceUuid)
|
||||
space := this.spaceService.CheckWritableByUuid(request, user, spaceUuid)
|
||||
|
||||
this.chunkService.CancelSession(sessionUuid, user, space)
|
||||
|
||||
return this.Success("OK")
|
||||
}
|
||||
|
||||
// CleanExpired cleans up expired upload sessions (admin only)
|
||||
func (this *ChunkController) CleanExpired(writer http.ResponseWriter, request *http.Request) *result.WebResult {
|
||||
this.chunkService.CleanExpiredSessions()
|
||||
return this.Success("OK")
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
package rest
|
||||
|
||||
import (
|
||||
"github.com/eyebluecn/tank/code/core"
|
||||
"github.com/eyebluecn/tank/code/tool/result"
|
||||
"github.com/eyebluecn/tank/code/tool/uuid"
|
||||
"time"
|
||||
)
|
||||
|
||||
// UploadSessionDao handles database operations for UploadSession
|
||||
type UploadSessionDao struct {
|
||||
BaseDao
|
||||
}
|
||||
|
||||
// FindByUuid finds an upload session by uuid
|
||||
func (this *UploadSessionDao) FindByUuid(sessionUuid string) *UploadSession {
|
||||
var entity = &UploadSession{}
|
||||
db := core.CONTEXT.GetDB().Where("uuid = ?", sessionUuid).First(entity)
|
||||
if db.Error != nil {
|
||||
if db.Error.Error() == result.DB_ERROR_NOT_FOUND {
|
||||
return nil
|
||||
} else {
|
||||
panic(db.Error)
|
||||
}
|
||||
}
|
||||
return entity
|
||||
}
|
||||
|
||||
// CheckByUuid checks if session exists, panics if not found
|
||||
func (this *UploadSessionDao) CheckByUuid(sessionUuid string) *UploadSession {
|
||||
entity := this.FindByUuid(sessionUuid)
|
||||
if entity == nil {
|
||||
panic(result.NotFound("upload session not found with uuid = %s", sessionUuid))
|
||||
}
|
||||
return entity
|
||||
}
|
||||
|
||||
// FindByUserUuidAndFileMd5 finds session by user and file MD5 (for quick upload)
|
||||
func (this *UploadSessionDao) FindByUserUuidAndFileMd5(userUuid string, fileMd5 string) *UploadSession {
|
||||
var entity = &UploadSession{}
|
||||
db := core.CONTEXT.GetDB().Where("user_uuid = ? AND file_md5 = ? AND status = ?",
|
||||
userUuid, fileMd5, UPLOAD_SESSION_STATUS_COMPLETED).First(entity)
|
||||
if db.Error != nil {
|
||||
if db.Error.Error() == result.DB_ERROR_NOT_FOUND {
|
||||
return nil
|
||||
} else {
|
||||
panic(db.Error)
|
||||
}
|
||||
}
|
||||
return entity
|
||||
}
|
||||
|
||||
// FindBySpaceUuidAndFileMd5 finds completed session by space and file MD5
|
||||
func (this *UploadSessionDao) FindBySpaceUuidAndFileMd5(spaceUuid string, fileMd5 string) *UploadSession {
|
||||
var entity = &UploadSession{}
|
||||
db := core.CONTEXT.GetDB().Where("space_uuid = ? AND file_md5 = ? AND status = ?",
|
||||
spaceUuid, fileMd5, UPLOAD_SESSION_STATUS_COMPLETED).First(entity)
|
||||
if db.Error != nil {
|
||||
if db.Error.Error() == result.DB_ERROR_NOT_FOUND {
|
||||
return nil
|
||||
} else {
|
||||
panic(db.Error)
|
||||
}
|
||||
}
|
||||
return entity
|
||||
}
|
||||
|
||||
// Create creates a new upload session
|
||||
func (this *UploadSessionDao) Create(session *UploadSession) *UploadSession {
|
||||
timeUUID, _ := uuid.NewV4()
|
||||
session.Uuid = timeUUID.String()
|
||||
session.CreateTime = time.Now()
|
||||
session.UpdateTime = time.Now()
|
||||
session.Sort = time.Now().UnixNano() / 1e6
|
||||
|
||||
db := core.CONTEXT.GetDB().Create(session)
|
||||
this.PanicError(db.Error)
|
||||
return session
|
||||
}
|
||||
|
||||
// Save updates an upload session
|
||||
func (this *UploadSessionDao) Save(session *UploadSession) *UploadSession {
|
||||
session.UpdateTime = time.Now()
|
||||
db := core.CONTEXT.GetDB().Save(session)
|
||||
this.PanicError(db.Error)
|
||||
return session
|
||||
}
|
||||
|
||||
// Delete deletes an upload session
|
||||
func (this *UploadSessionDao) Delete(session *UploadSession) {
|
||||
db := core.CONTEXT.GetDB().Delete(session)
|
||||
this.PanicError(db.Error)
|
||||
}
|
||||
|
||||
// DeleteByUuid deletes session by uuid
|
||||
func (this *UploadSessionDao) DeleteByUuid(sessionUuid string) {
|
||||
db := core.CONTEXT.GetDB().Where("uuid = ?", sessionUuid).Delete(UploadSession{})
|
||||
this.PanicError(db.Error)
|
||||
}
|
||||
|
||||
// FindExpiredSessions finds all expired sessions
|
||||
func (this *UploadSessionDao) FindExpiredSessions() []*UploadSession {
|
||||
var sessions []*UploadSession
|
||||
db := core.CONTEXT.GetDB().Where("expire_time < ? AND status = ?",
|
||||
time.Now(), UPLOAD_SESSION_STATUS_UPLOADING).Find(&sessions)
|
||||
this.PanicError(db.Error)
|
||||
return sessions
|
||||
}
|
||||
|
||||
// FindByUserUuid finds all sessions for a user
|
||||
func (this *UploadSessionDao) FindByUserUuid(userUuid string) []*UploadSession {
|
||||
var sessions []*UploadSession
|
||||
db := core.CONTEXT.GetDB().Where("user_uuid = ?", userUuid).Find(&sessions)
|
||||
this.PanicError(db.Error)
|
||||
return sessions
|
||||
}
|
||||
|
||||
// DeleteByUserUuid deletes all sessions for a user
|
||||
func (this *UploadSessionDao) DeleteByUserUuid(userUuid string) {
|
||||
db := core.CONTEXT.GetDB().Where("user_uuid = ?", userUuid).Delete(UploadSession{})
|
||||
this.PanicError(db.Error)
|
||||
}
|
||||
|
||||
// UploadChunkDao handles database operations for UploadChunk
|
||||
type UploadChunkDao struct {
|
||||
BaseDao
|
||||
}
|
||||
|
||||
// FindByUuid finds a chunk by uuid
|
||||
func (this *UploadChunkDao) FindByUuid(chunkUuid string) *UploadChunk {
|
||||
var entity = &UploadChunk{}
|
||||
db := core.CONTEXT.GetDB().Where("uuid = ?", chunkUuid).First(entity)
|
||||
if db.Error != nil {
|
||||
if db.Error.Error() == result.DB_ERROR_NOT_FOUND {
|
||||
return nil
|
||||
} else {
|
||||
panic(db.Error)
|
||||
}
|
||||
}
|
||||
return entity
|
||||
}
|
||||
|
||||
// FindBySessionUuidAndChunkIndex finds a chunk by session and index
|
||||
func (this *UploadChunkDao) FindBySessionUuidAndChunkIndex(sessionUuid string, chunkIndex int) *UploadChunk {
|
||||
var entity = &UploadChunk{}
|
||||
db := core.CONTEXT.GetDB().Where("session_uuid = ? AND chunk_index = ?", sessionUuid, chunkIndex).First(entity)
|
||||
if db.Error != nil {
|
||||
if db.Error.Error() == result.DB_ERROR_NOT_FOUND {
|
||||
return nil
|
||||
} else {
|
||||
panic(db.Error)
|
||||
}
|
||||
}
|
||||
return entity
|
||||
}
|
||||
|
||||
// FindBySessionUuid finds all chunks for a session
|
||||
func (this *UploadChunkDao) FindBySessionUuid(sessionUuid string) []*UploadChunk {
|
||||
var chunks []*UploadChunk
|
||||
db := core.CONTEXT.GetDB().Where("session_uuid = ?", sessionUuid).Order("chunk_index asc").Find(&chunks)
|
||||
this.PanicError(db.Error)
|
||||
return chunks
|
||||
}
|
||||
|
||||
// FindUploadedBySessionUuid finds all uploaded chunks for a session
|
||||
func (this *UploadChunkDao) FindUploadedBySessionUuid(sessionUuid string) []*UploadChunk {
|
||||
var chunks []*UploadChunk
|
||||
db := core.CONTEXT.GetDB().Where("session_uuid = ? AND uploaded = ?", sessionUuid, true).Order("chunk_index asc").Find(&chunks)
|
||||
this.PanicError(db.Error)
|
||||
return chunks
|
||||
}
|
||||
|
||||
// CountUploadedBySessionUuid counts uploaded chunks for a session
|
||||
func (this *UploadChunkDao) CountUploadedBySessionUuid(sessionUuid string) int64 {
|
||||
var count int64
|
||||
db := core.CONTEXT.GetDB().Model(&UploadChunk{}).Where("session_uuid = ? AND uploaded = ?", sessionUuid, true).Count(&count)
|
||||
this.PanicError(db.Error)
|
||||
return count
|
||||
}
|
||||
|
||||
// Create creates a new chunk record
|
||||
func (this *UploadChunkDao) Create(chunk *UploadChunk) *UploadChunk {
|
||||
timeUUID, _ := uuid.NewV4()
|
||||
chunk.Uuid = timeUUID.String()
|
||||
chunk.CreateTime = time.Now()
|
||||
chunk.UpdateTime = time.Now()
|
||||
chunk.Sort = time.Now().UnixNano() / 1e6
|
||||
|
||||
db := core.CONTEXT.GetDB().Create(chunk)
|
||||
this.PanicError(db.Error)
|
||||
return chunk
|
||||
}
|
||||
|
||||
// Save updates a chunk record
|
||||
func (this *UploadChunkDao) Save(chunk *UploadChunk) *UploadChunk {
|
||||
chunk.UpdateTime = time.Now()
|
||||
db := core.CONTEXT.GetDB().Save(chunk)
|
||||
this.PanicError(db.Error)
|
||||
return chunk
|
||||
}
|
||||
|
||||
// Delete deletes a chunk record
|
||||
func (this *UploadChunkDao) Delete(chunk *UploadChunk) {
|
||||
db := core.CONTEXT.GetDB().Delete(chunk)
|
||||
this.PanicError(db.Error)
|
||||
}
|
||||
|
||||
// DeleteBySessionUuid deletes all chunks for a session
|
||||
func (this *UploadChunkDao) DeleteBySessionUuid(sessionUuid string) {
|
||||
db := core.CONTEXT.GetDB().Where("session_uuid = ?", sessionUuid).Delete(UploadChunk{})
|
||||
this.PanicError(db.Error)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package rest
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// Upload session status
|
||||
UPLOAD_SESSION_STATUS_UPLOADING = "UPLOADING"
|
||||
UPLOAD_SESSION_STATUS_MERGING = "MERGING"
|
||||
UPLOAD_SESSION_STATUS_COMPLETED = "COMPLETED"
|
||||
UPLOAD_SESSION_STATUS_ERROR = "ERROR"
|
||||
|
||||
// Chunk directory name
|
||||
CHUNK_DIR = "chunk"
|
||||
|
||||
// Default chunk size: 50MB
|
||||
DEFAULT_CHUNK_SIZE = 50 * 1024 * 1024
|
||||
|
||||
// Max chunk size: 100MB
|
||||
MAX_CHUNK_SIZE = 100 * 1024 * 1024
|
||||
|
||||
// Min chunk size: 5MB (aligned with AWS S3 standard)
|
||||
MIN_CHUNK_SIZE = 5 * 1024 * 1024
|
||||
)
|
||||
|
||||
// UploadSession represents a chunked upload session
|
||||
type UploadSession struct {
|
||||
Uuid string `json:"uuid" gorm:"type:char(36);primary_key;unique"`
|
||||
Sort int64 `json:"sort" gorm:"type:bigint(20) not null"`
|
||||
UpdateTime time.Time `json:"updateTime" gorm:"type:timestamp not null;default:CURRENT_TIMESTAMP"`
|
||||
CreateTime time.Time `json:"createTime" gorm:"type:timestamp not null;default:'2018-01-01 00:00:00'"`
|
||||
UserUuid string `json:"userUuid" gorm:"type:char(36) not null;index:idx_upload_session_uu"`
|
||||
SpaceUuid string `json:"spaceUuid" gorm:"type:char(36) not null;index:idx_upload_session_su"`
|
||||
MatterUuid string `json:"matterUuid" gorm:"type:char(36)"` // Final matter uuid after merge
|
||||
Puuid string `json:"puuid" gorm:"type:char(36) not null"` // Parent directory uuid
|
||||
Filename string `json:"filename" gorm:"type:varchar(255) not null"` // Target filename
|
||||
TotalSize int64 `json:"totalSize" gorm:"type:bigint(20) not null;default:0"` // Total file size
|
||||
ChunkSize int64 `json:"chunkSize" gorm:"type:bigint(20) not null;default:52428800"` // Size of each chunk (default 50MB)
|
||||
TotalChunks int `json:"totalChunks" gorm:"type:int not null;default:0"` // Total number of chunks
|
||||
Privacy bool `json:"privacy" gorm:"type:tinyint(1) not null;default:1"` // Privacy flag
|
||||
FileMd5 string `json:"fileMd5" gorm:"type:varchar(45)"` // MD5 of the whole file
|
||||
Status string `json:"status" gorm:"type:varchar(20) not null;default:'UPLOADING'"` // Session status
|
||||
ExpireTime time.Time `json:"expireTime" gorm:"type:timestamp not null;default:'2018-01-01 00:00:00'"` // Expiration time
|
||||
}
|
||||
|
||||
// UploadChunk represents a single chunk in an upload session
|
||||
type UploadChunk struct {
|
||||
Uuid string `json:"uuid" gorm:"type:char(36);primary_key;unique"`
|
||||
Sort int64 `json:"sort" gorm:"type:bigint(20) not null"`
|
||||
UpdateTime time.Time `json:"updateTime" gorm:"type:timestamp not null;default:CURRENT_TIMESTAMP"`
|
||||
CreateTime time.Time `json:"createTime" gorm:"type:timestamp not null;default:'2018-01-01 00:00:00'"`
|
||||
SessionUuid string `json:"sessionUuid" gorm:"type:char(36) not null;index:idx_upload_chunk_su"` // Reference to UploadSession
|
||||
ChunkIndex int `json:"chunkIndex" gorm:"type:int not null"` // Chunk sequence number (0-based)
|
||||
ChunkSize int64 `json:"chunkSize" gorm:"type:bigint(20) not null;default:0"` // Actual size of this chunk
|
||||
ChunkMd5 string `json:"chunkMd5" gorm:"type:varchar(45)"` // MD5 of this chunk
|
||||
ChunkFilename string `json:"chunkFilename" gorm:"type:varchar(255) not null"` // Temp filename for this chunk
|
||||
Uploaded bool `json:"uploaded" gorm:"type:tinyint(1) not null;default:0"` // Whether this chunk is uploaded
|
||||
}
|
||||
|
||||
// Get the chunk temp directory for a space
|
||||
func GetSpaceChunkDir(spaceName string) string {
|
||||
return GetUserSpaceRootDir(spaceName) + "/" + CHUNK_DIR
|
||||
}
|
||||
|
||||
// Get the session chunk directory
|
||||
func GetSessionChunkDir(spaceName string, sessionUuid string) string {
|
||||
return GetSpaceChunkDir(spaceName) + "/" + sessionUuid
|
||||
}
|
||||
|
||||
// UploadSessionInfo is the response structure for session queries
|
||||
type UploadSessionInfo struct {
|
||||
Session *UploadSession `json:"session"`
|
||||
UploadedChunks []int `json:"uploadedChunks"` // List of uploaded chunk indices
|
||||
MissingChunks []int `json:"missingChunks"` // List of missing chunk indices
|
||||
}
|
||||
@@ -0,0 +1,581 @@
|
||||
package rest
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/eyebluecn/tank/code/core"
|
||||
"github.com/eyebluecn/tank/code/tool/i18n"
|
||||
"github.com/eyebluecn/tank/code/tool/result"
|
||||
"github.com/eyebluecn/tank/code/tool/util"
|
||||
)
|
||||
|
||||
// ChunkService handles chunked upload operations
|
||||
//@Service
|
||||
type ChunkService struct {
|
||||
BaseBean
|
||||
uploadSessionDao *UploadSessionDao
|
||||
uploadChunkDao *UploadChunkDao
|
||||
matterDao *MatterDao
|
||||
matterService *MatterService
|
||||
spaceDao *SpaceDao
|
||||
userService *UserService
|
||||
// chunkLocker is used to prevent concurrent uploads of the same chunk
|
||||
chunkLocker sync.Map
|
||||
// mergeLocker is used to prevent concurrent merge of the same session
|
||||
mergeLocker sync.Map
|
||||
}
|
||||
|
||||
func (this *ChunkService) Init() {
|
||||
this.BaseBean.Init()
|
||||
|
||||
b := core.CONTEXT.GetBean(this.uploadSessionDao)
|
||||
if b, ok := b.(*UploadSessionDao); ok {
|
||||
this.uploadSessionDao = b
|
||||
}
|
||||
|
||||
b = core.CONTEXT.GetBean(this.uploadChunkDao)
|
||||
if b, ok := b.(*UploadChunkDao); ok {
|
||||
this.uploadChunkDao = b
|
||||
}
|
||||
|
||||
b = core.CONTEXT.GetBean(this.matterDao)
|
||||
if b, ok := b.(*MatterDao); ok {
|
||||
this.matterDao = b
|
||||
}
|
||||
|
||||
b = core.CONTEXT.GetBean(this.matterService)
|
||||
if b, ok := b.(*MatterService); ok {
|
||||
this.matterService = b
|
||||
}
|
||||
|
||||
b = core.CONTEXT.GetBean(this.spaceDao)
|
||||
if b, ok := b.(*SpaceDao); ok {
|
||||
this.spaceDao = b
|
||||
}
|
||||
|
||||
b = core.CONTEXT.GetBean(this.userService)
|
||||
if b, ok := b.(*UserService); ok {
|
||||
this.userService = b
|
||||
}
|
||||
}
|
||||
|
||||
// CreateSession creates a new upload session
|
||||
func (this *ChunkService) CreateSession(
|
||||
request *http.Request,
|
||||
user *User,
|
||||
space *Space,
|
||||
dirMatter *Matter,
|
||||
filename string,
|
||||
totalSize int64,
|
||||
chunkSize int64,
|
||||
privacy bool,
|
||||
fileMd5 string,
|
||||
) *UploadSession {
|
||||
|
||||
if user == nil {
|
||||
panic(result.BadRequest("user cannot be nil."))
|
||||
}
|
||||
|
||||
if dirMatter == nil {
|
||||
panic(result.BadRequest("dirMatter cannot be nil."))
|
||||
}
|
||||
|
||||
if dirMatter.Deleted {
|
||||
panic(result.BadRequest("Dir has been deleted. Cannot upload under it."))
|
||||
}
|
||||
|
||||
// Validate filename
|
||||
if len(filename) > MATTER_NAME_MAX_LENGTH {
|
||||
panic(result.BadRequestI18n(request, i18n.MatterNameLengthExceedLimit, len(filename), MATTER_NAME_MAX_LENGTH))
|
||||
}
|
||||
CheckMatterName(request, filename)
|
||||
|
||||
// Validate chunk size
|
||||
if chunkSize < MIN_CHUNK_SIZE {
|
||||
chunkSize = MIN_CHUNK_SIZE
|
||||
}
|
||||
if chunkSize > MAX_CHUNK_SIZE {
|
||||
chunkSize = MAX_CHUNK_SIZE
|
||||
}
|
||||
|
||||
// Validate total size
|
||||
if totalSize <= 0 {
|
||||
panic(result.BadRequest("totalSize must be positive"))
|
||||
}
|
||||
|
||||
// Check space size limit
|
||||
if space.SizeLimit >= 0 {
|
||||
if totalSize > space.SizeLimit {
|
||||
panic(result.BadRequestI18n(request, i18n.MatterSizeExceedLimit, util.HumanFileSize(totalSize), util.HumanFileSize(space.SizeLimit)))
|
||||
}
|
||||
}
|
||||
|
||||
// Check total space limit
|
||||
if space.TotalSizeLimit >= 0 {
|
||||
if space.TotalSize+totalSize > space.TotalSizeLimit {
|
||||
panic(result.BadRequestI18n(request, i18n.MatterSizeExceedTotalLimit, util.HumanFileSize(space.TotalSize), util.HumanFileSize(space.TotalSizeLimit)))
|
||||
}
|
||||
}
|
||||
|
||||
// Check if file already exists
|
||||
dbMatter := this.matterDao.FindBySpaceUuidAndPuuidAndDirAndName(space.Uuid, dirMatter.Uuid, false, filename)
|
||||
if dbMatter != nil {
|
||||
if dbMatter.Deleted {
|
||||
panic(result.BadRequestI18n(request, i18n.MatterRecycleBinExist, filename))
|
||||
} else {
|
||||
panic(result.BadRequestI18n(request, i18n.MatterExist, filename))
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate total chunks
|
||||
totalChunks := int((totalSize + chunkSize - 1) / chunkSize)
|
||||
|
||||
// Create session
|
||||
session := &UploadSession{
|
||||
UserUuid: user.Uuid,
|
||||
SpaceUuid: space.Uuid,
|
||||
Puuid: dirMatter.Uuid,
|
||||
Filename: filename,
|
||||
TotalSize: totalSize,
|
||||
ChunkSize: chunkSize,
|
||||
TotalChunks: totalChunks,
|
||||
Privacy: privacy,
|
||||
FileMd5: fileMd5,
|
||||
Status: UPLOAD_SESSION_STATUS_UPLOADING,
|
||||
ExpireTime: time.Now().Add(24 * time.Hour), // 24 hours expiration
|
||||
}
|
||||
|
||||
session = this.uploadSessionDao.Create(session)
|
||||
|
||||
// Create chunk directory
|
||||
chunkDir := GetSessionChunkDir(space.Name, session.Uuid)
|
||||
util.MakeDirAll(chunkDir)
|
||||
|
||||
this.logger.Info("Created upload session %s for file %s, total chunks: %d", session.Uuid, filename, totalChunks)
|
||||
|
||||
return session
|
||||
}
|
||||
|
||||
// UploadChunk uploads a single chunk with optional MD5 verification
|
||||
func (this *ChunkService) UploadChunk(
|
||||
request *http.Request,
|
||||
sessionUuid string,
|
||||
chunkIndex int,
|
||||
file io.Reader,
|
||||
clientChunkMd5 string,
|
||||
user *User,
|
||||
space *Space,
|
||||
) *UploadChunk {
|
||||
|
||||
// Check session
|
||||
session := this.uploadSessionDao.CheckByUuid(sessionUuid)
|
||||
|
||||
// Validate session ownership
|
||||
if session.UserUuid != user.Uuid {
|
||||
panic(result.BadRequest("session does not belong to you"))
|
||||
}
|
||||
|
||||
if session.Status != UPLOAD_SESSION_STATUS_UPLOADING {
|
||||
panic(result.BadRequest("session status is not uploading"))
|
||||
}
|
||||
|
||||
// Check expiration
|
||||
if time.Now().After(session.ExpireTime) {
|
||||
panic(result.BadRequest("upload session has expired"))
|
||||
}
|
||||
|
||||
// Validate chunk index
|
||||
if chunkIndex < 0 || chunkIndex >= session.TotalChunks {
|
||||
panic(result.BadRequest("invalid chunk index: %d, total chunks: %d", chunkIndex, session.TotalChunks))
|
||||
}
|
||||
|
||||
// Acquire chunk-level lock to prevent concurrent upload of the same chunk
|
||||
chunkLockKey := fmt.Sprintf("%s_%d", sessionUuid, chunkIndex)
|
||||
if _, loaded := this.chunkLocker.LoadOrStore(chunkLockKey, true); loaded {
|
||||
panic(result.BadRequest("chunk %d is being uploaded by another request, please retry later", chunkIndex))
|
||||
}
|
||||
defer this.chunkLocker.Delete(chunkLockKey)
|
||||
|
||||
// Check if chunk already exists
|
||||
existingChunk := this.uploadChunkDao.FindBySessionUuidAndChunkIndex(sessionUuid, chunkIndex)
|
||||
if existingChunk != nil && existingChunk.Uploaded {
|
||||
this.logger.Info("Chunk %d already uploaded for session %s", chunkIndex, sessionUuid)
|
||||
return existingChunk
|
||||
}
|
||||
|
||||
// Prepare chunk file path
|
||||
chunkFilename := fmt.Sprintf("chunk_%d", chunkIndex)
|
||||
chunkDir := GetSessionChunkDir(space.Name, sessionUuid)
|
||||
chunkFilePath := chunkDir + "/" + chunkFilename
|
||||
|
||||
// Write chunk to disk (use 0600 for security - only owner can read/write)
|
||||
destFile, err := os.OpenFile(chunkFilePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
|
||||
if err != nil {
|
||||
panic(result.BadRequest("failed to create chunk file: %s", err.Error()))
|
||||
}
|
||||
|
||||
// Calculate MD5 while writing
|
||||
hash := md5.New()
|
||||
multiWriter := io.MultiWriter(destFile, hash)
|
||||
|
||||
chunkSize, err := io.Copy(multiWriter, file)
|
||||
destFile.Close()
|
||||
|
||||
if err != nil {
|
||||
os.Remove(chunkFilePath)
|
||||
panic(result.BadRequest("failed to write chunk: %s", err.Error()))
|
||||
}
|
||||
|
||||
// Validate chunk size to prevent malicious oversized uploads
|
||||
isLastChunk := chunkIndex == session.TotalChunks-1
|
||||
if isLastChunk {
|
||||
// Last chunk: should be <= session.ChunkSize and match expected remainder
|
||||
expectedLastChunkSize := session.TotalSize - int64(session.TotalChunks-1)*session.ChunkSize
|
||||
if chunkSize != expectedLastChunkSize {
|
||||
os.Remove(chunkFilePath)
|
||||
panic(result.BadRequest("last chunk size mismatch: expected %d, got %d", expectedLastChunkSize, chunkSize))
|
||||
}
|
||||
} else {
|
||||
// Non-last chunks: should be exactly session.ChunkSize
|
||||
if chunkSize != session.ChunkSize {
|
||||
os.Remove(chunkFilePath)
|
||||
panic(result.BadRequest("chunk size mismatch: expected %d, got %d", session.ChunkSize, chunkSize))
|
||||
}
|
||||
}
|
||||
|
||||
chunkMd5 := hex.EncodeToString(hash.Sum(nil))
|
||||
|
||||
// Verify chunk MD5 if client provided one
|
||||
if clientChunkMd5 != "" && clientChunkMd5 != chunkMd5 {
|
||||
os.Remove(chunkFilePath)
|
||||
this.logger.Error("Chunk MD5 verification failed for chunk %d: client=%s, server=%s", chunkIndex, clientChunkMd5, chunkMd5)
|
||||
panic(result.BadRequest("chunk MD5 verification failed: data may be corrupted during upload"))
|
||||
}
|
||||
|
||||
this.logger.Info("Uploaded chunk %d for session %s, size: %d, md5: %s", chunkIndex, sessionUuid, chunkSize, chunkMd5)
|
||||
|
||||
// Create or update chunk record
|
||||
var chunk *UploadChunk
|
||||
if existingChunk != nil {
|
||||
existingChunk.ChunkSize = chunkSize
|
||||
existingChunk.ChunkMd5 = chunkMd5
|
||||
existingChunk.ChunkFilename = chunkFilename
|
||||
existingChunk.Uploaded = true
|
||||
chunk = this.uploadChunkDao.Save(existingChunk)
|
||||
} else {
|
||||
chunk = &UploadChunk{
|
||||
SessionUuid: sessionUuid,
|
||||
ChunkIndex: chunkIndex,
|
||||
ChunkSize: chunkSize,
|
||||
ChunkMd5: chunkMd5,
|
||||
ChunkFilename: chunkFilename,
|
||||
Uploaded: true,
|
||||
}
|
||||
chunk = this.uploadChunkDao.Create(chunk)
|
||||
}
|
||||
|
||||
return chunk
|
||||
}
|
||||
|
||||
// GetSessionInfo gets the upload session info including uploaded chunks
|
||||
func (this *ChunkService) GetSessionInfo(sessionUuid string, user *User) *UploadSessionInfo {
|
||||
session := this.uploadSessionDao.CheckByUuid(sessionUuid)
|
||||
|
||||
// Validate ownership
|
||||
if session.UserUuid != user.Uuid {
|
||||
panic(result.BadRequest("session does not belong to you"))
|
||||
}
|
||||
|
||||
// Get uploaded chunks
|
||||
uploadedChunks := this.uploadChunkDao.FindUploadedBySessionUuid(sessionUuid)
|
||||
|
||||
uploadedIndices := make([]int, 0)
|
||||
uploadedMap := make(map[int]bool)
|
||||
for _, chunk := range uploadedChunks {
|
||||
uploadedIndices = append(uploadedIndices, chunk.ChunkIndex)
|
||||
uploadedMap[chunk.ChunkIndex] = true
|
||||
}
|
||||
|
||||
// Calculate missing chunks
|
||||
missingIndices := make([]int, 0)
|
||||
for i := 0; i < session.TotalChunks; i++ {
|
||||
if !uploadedMap[i] {
|
||||
missingIndices = append(missingIndices, i)
|
||||
}
|
||||
}
|
||||
|
||||
return &UploadSessionInfo{
|
||||
Session: session,
|
||||
UploadedChunks: uploadedIndices,
|
||||
MissingChunks: missingIndices,
|
||||
}
|
||||
}
|
||||
|
||||
// MergeChunks merges all chunks into final file
|
||||
func (this *ChunkService) MergeChunks(
|
||||
request *http.Request,
|
||||
sessionUuid string,
|
||||
user *User,
|
||||
space *Space,
|
||||
) *Matter {
|
||||
|
||||
// Use session-level lock instead of user-level lock to allow concurrent uploads of different files
|
||||
if _, loaded := this.mergeLocker.LoadOrStore(sessionUuid, true); loaded {
|
||||
panic(result.BadRequest("merge operation is already in progress for this session"))
|
||||
}
|
||||
defer this.mergeLocker.Delete(sessionUuid)
|
||||
|
||||
session := this.uploadSessionDao.CheckByUuid(sessionUuid)
|
||||
|
||||
// Validate ownership
|
||||
if session.UserUuid != user.Uuid {
|
||||
panic(result.BadRequest("session does not belong to you"))
|
||||
}
|
||||
|
||||
// Check session expiration
|
||||
if time.Now().After(session.ExpireTime) {
|
||||
panic(result.BadRequest("upload session has expired, please start a new upload"))
|
||||
}
|
||||
|
||||
if session.Status == UPLOAD_SESSION_STATUS_COMPLETED {
|
||||
// Already completed, return the matter
|
||||
if session.MatterUuid != "" {
|
||||
return this.matterDao.CheckByUuid(session.MatterUuid)
|
||||
}
|
||||
panic(result.BadRequest("session completed but matter not found"))
|
||||
}
|
||||
|
||||
// Allow retry from ERROR status (e.g., previous merge failed)
|
||||
if session.Status != UPLOAD_SESSION_STATUS_UPLOADING && session.Status != UPLOAD_SESSION_STATUS_ERROR {
|
||||
panic(result.BadRequest("session status is not uploading or error, current: %s", session.Status))
|
||||
}
|
||||
|
||||
// If retrying from ERROR status, reset to UPLOADING first
|
||||
if session.Status == UPLOAD_SESSION_STATUS_ERROR {
|
||||
this.logger.Info("Retrying merge for session %s from ERROR status", sessionUuid)
|
||||
session.Status = UPLOAD_SESSION_STATUS_UPLOADING
|
||||
this.uploadSessionDao.Save(session)
|
||||
}
|
||||
|
||||
// Check if all chunks are uploaded
|
||||
uploadedCount := this.uploadChunkDao.CountUploadedBySessionUuid(sessionUuid)
|
||||
if int(uploadedCount) < session.TotalChunks {
|
||||
panic(result.BadRequest("not all chunks uploaded: %d/%d", uploadedCount, session.TotalChunks))
|
||||
}
|
||||
|
||||
// Update status to merging
|
||||
session.Status = UPLOAD_SESSION_STATUS_MERGING
|
||||
this.uploadSessionDao.Save(session)
|
||||
|
||||
// Get parent directory
|
||||
var dirMatter *Matter
|
||||
if session.Puuid == MATTER_ROOT {
|
||||
dirMatter = NewRootMatter(space)
|
||||
} else {
|
||||
dirMatter = this.matterDao.CheckByUuid(session.Puuid)
|
||||
}
|
||||
|
||||
// Prepare final file path
|
||||
dirAbsolutePath := dirMatter.AbsolutePath()
|
||||
util.MakeDirAll(dirAbsolutePath)
|
||||
fileAbsolutePath := dirAbsolutePath + "/" + session.Filename
|
||||
|
||||
// Check if file exists
|
||||
if util.PathExists(fileAbsolutePath) {
|
||||
this.logger.Error("%s exists, removing it.", fileAbsolutePath)
|
||||
os.Remove(fileAbsolutePath)
|
||||
}
|
||||
|
||||
// Create final file (use 0644 for normal file permissions)
|
||||
destFile, err := os.OpenFile(fileAbsolutePath, os.O_WRONLY|os.O_CREATE, 0644)
|
||||
if err != nil {
|
||||
session.Status = UPLOAD_SESSION_STATUS_ERROR
|
||||
this.uploadSessionDao.Save(session)
|
||||
panic(result.BadRequest("failed to create destination file: %s", err.Error()))
|
||||
}
|
||||
|
||||
// Get all chunks sorted by index
|
||||
chunks := this.uploadChunkDao.FindBySessionUuid(sessionUuid)
|
||||
sort.Slice(chunks, func(i, j int) bool {
|
||||
return chunks[i].ChunkIndex < chunks[j].ChunkIndex
|
||||
})
|
||||
|
||||
chunkDir := GetSessionChunkDir(space.Name, sessionUuid)
|
||||
|
||||
// Merge chunks
|
||||
var totalWritten int64 = 0
|
||||
for _, chunk := range chunks {
|
||||
chunkPath := chunkDir + "/" + chunk.ChunkFilename
|
||||
chunkFile, err := os.Open(chunkPath)
|
||||
if err != nil {
|
||||
destFile.Close()
|
||||
os.Remove(fileAbsolutePath)
|
||||
session.Status = UPLOAD_SESSION_STATUS_ERROR
|
||||
this.uploadSessionDao.Save(session)
|
||||
panic(result.BadRequest("failed to open chunk %d: %s", chunk.ChunkIndex, err.Error()))
|
||||
}
|
||||
|
||||
written, err := io.Copy(destFile, chunkFile)
|
||||
chunkFile.Close()
|
||||
|
||||
if err != nil {
|
||||
destFile.Close()
|
||||
os.Remove(fileAbsolutePath)
|
||||
session.Status = UPLOAD_SESSION_STATUS_ERROR
|
||||
this.uploadSessionDao.Save(session)
|
||||
panic(result.BadRequest("failed to write chunk %d: %s", chunk.ChunkIndex, err.Error()))
|
||||
}
|
||||
|
||||
totalWritten += written
|
||||
}
|
||||
|
||||
destFile.Close()
|
||||
|
||||
this.logger.Info("Merged %d chunks into file %s, total size: %d", len(chunks), session.Filename, totalWritten)
|
||||
|
||||
// Verify file size
|
||||
if totalWritten != session.TotalSize {
|
||||
os.Remove(fileAbsolutePath)
|
||||
session.Status = UPLOAD_SESSION_STATUS_ERROR
|
||||
this.uploadSessionDao.Save(session)
|
||||
panic(result.BadRequest("merged file size mismatch: expected %d, got %d", session.TotalSize, totalWritten))
|
||||
}
|
||||
|
||||
// Verify file MD5 if provided
|
||||
if session.FileMd5 != "" {
|
||||
mergedFile, err := os.Open(fileAbsolutePath)
|
||||
if err != nil {
|
||||
os.Remove(fileAbsolutePath)
|
||||
session.Status = UPLOAD_SESSION_STATUS_ERROR
|
||||
this.uploadSessionDao.Save(session)
|
||||
panic(result.BadRequest("failed to open merged file for MD5 verification: %s", err.Error()))
|
||||
}
|
||||
|
||||
hash := md5.New()
|
||||
_, err = io.Copy(hash, mergedFile)
|
||||
mergedFile.Close()
|
||||
|
||||
if err != nil {
|
||||
os.Remove(fileAbsolutePath)
|
||||
session.Status = UPLOAD_SESSION_STATUS_ERROR
|
||||
this.uploadSessionDao.Save(session)
|
||||
panic(result.BadRequest("failed to calculate MD5 of merged file: %s", err.Error()))
|
||||
}
|
||||
|
||||
calculatedMd5 := hex.EncodeToString(hash.Sum(nil))
|
||||
if calculatedMd5 != session.FileMd5 {
|
||||
os.Remove(fileAbsolutePath)
|
||||
session.Status = UPLOAD_SESSION_STATUS_ERROR
|
||||
this.uploadSessionDao.Save(session)
|
||||
this.logger.Error("MD5 verification failed for session %s: expected %s, got %s", sessionUuid, session.FileMd5, calculatedMd5)
|
||||
panic(result.BadRequest("MD5 verification failed: file may be corrupted during upload"))
|
||||
}
|
||||
|
||||
this.logger.Info("MD5 verification passed for session %s", sessionUuid)
|
||||
}
|
||||
|
||||
// Create matter record
|
||||
dirRelativePath := dirMatter.Path
|
||||
fileRelativePath := dirRelativePath + "/" + session.Filename
|
||||
|
||||
matter := &Matter{
|
||||
Puuid: dirMatter.Uuid,
|
||||
UserUuid: user.Uuid,
|
||||
SpaceName: space.Name,
|
||||
SpaceUuid: space.Uuid,
|
||||
Dir: false,
|
||||
Name: session.Filename,
|
||||
Md5: session.FileMd5,
|
||||
Size: totalWritten,
|
||||
Privacy: session.Privacy,
|
||||
Path: fileRelativePath,
|
||||
Prop: EMPTY_JSON_MAP,
|
||||
VisitTime: time.Now(),
|
||||
}
|
||||
matter = this.matterDao.Create(matter)
|
||||
|
||||
// Update session status
|
||||
session.Status = UPLOAD_SESSION_STATUS_COMPLETED
|
||||
session.MatterUuid = matter.Uuid
|
||||
this.uploadSessionDao.Save(session)
|
||||
|
||||
// Clean up chunk files
|
||||
go core.RunWithRecovery(func() {
|
||||
this.cleanupChunks(space.Name, sessionUuid)
|
||||
this.matterService.ComputeRouteSize(dirMatter.Uuid, user, space)
|
||||
})
|
||||
|
||||
this.logger.Info("Upload session %s completed, matter uuid: %s", sessionUuid, matter.Uuid)
|
||||
|
||||
return matter
|
||||
}
|
||||
|
||||
// cleanupChunks removes chunk files and records with improved atomicity
|
||||
// First delete database records, then delete files (safer approach)
|
||||
func (this *ChunkService) cleanupChunks(spaceName string, sessionUuid string) {
|
||||
chunkDir := GetSessionChunkDir(spaceName, sessionUuid)
|
||||
|
||||
// First, remove chunk records from database
|
||||
// This ensures that even if file deletion fails, we don't have orphaned DB records
|
||||
this.uploadChunkDao.DeleteBySessionUuid(sessionUuid)
|
||||
|
||||
// Then remove chunk directory with retry mechanism
|
||||
maxRetries := 3
|
||||
for i := 0; i < maxRetries; i++ {
|
||||
err := os.RemoveAll(chunkDir)
|
||||
if err == nil {
|
||||
this.logger.Info("Cleaned up chunks for session %s", sessionUuid)
|
||||
return
|
||||
}
|
||||
|
||||
this.logger.Error("failed to remove chunk dir %s (attempt %d/%d): %s", chunkDir, i+1, maxRetries, err.Error())
|
||||
|
||||
// Wait before retry (exponential backoff)
|
||||
if i < maxRetries-1 {
|
||||
time.Sleep(time.Duration(100*(i+1)) * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// If all retries failed, log a critical warning
|
||||
this.logger.Error("CRITICAL: Failed to remove chunk dir %s after %d attempts. Manual cleanup may be required.", chunkDir, maxRetries)
|
||||
}
|
||||
|
||||
// CancelSession cancels an upload session
|
||||
func (this *ChunkService) CancelSession(sessionUuid string, user *User, space *Space) {
|
||||
session := this.uploadSessionDao.CheckByUuid(sessionUuid)
|
||||
|
||||
// Validate ownership
|
||||
if session.UserUuid != user.Uuid {
|
||||
panic(result.BadRequest("session does not belong to you"))
|
||||
}
|
||||
|
||||
// Clean up
|
||||
this.cleanupChunks(space.Name, sessionUuid)
|
||||
|
||||
// Delete session
|
||||
this.uploadSessionDao.Delete(session)
|
||||
|
||||
this.logger.Info("Cancelled upload session %s", sessionUuid)
|
||||
}
|
||||
|
||||
// CleanExpiredSessions cleans up expired sessions
|
||||
func (this *ChunkService) CleanExpiredSessions() {
|
||||
sessions := this.uploadSessionDao.FindExpiredSessions()
|
||||
|
||||
for _, session := range sessions {
|
||||
space := this.spaceDao.FindByUuid(session.SpaceUuid)
|
||||
if space != nil {
|
||||
this.cleanupChunks(space.Name, session.Uuid)
|
||||
}
|
||||
this.uploadSessionDao.Delete(session)
|
||||
this.logger.Info("Cleaned expired session %s", session.Uuid)
|
||||
}
|
||||
}
|
||||
@@ -81,6 +81,8 @@ func (this *InstallController) Init() {
|
||||
&Space{},
|
||||
&SpaceMember{},
|
||||
&UploadToken{},
|
||||
&UploadSession{},
|
||||
&UploadChunk{},
|
||||
&User{},
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"github.com/eyebluecn/tank/code/tool/result"
|
||||
"github.com/eyebluecn/tank/code/tool/util"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -77,6 +78,7 @@ func (this *MatterController) RegisterRoutes() map[string]func(writer http.Respo
|
||||
routeMap := make(map[string]func(writer http.ResponseWriter, request *http.Request))
|
||||
routeMap["/api/matter/detail"] = this.Wrap(this.Detail, USER_ROLE_USER)
|
||||
routeMap["/api/matter/page"] = this.Wrap(this.Page, USER_ROLE_USER)
|
||||
routeMap["/api/matter/search"] = this.Wrap(this.Search, USER_ROLE_USER)
|
||||
|
||||
routeMap["/api/matter/create/directory"] = this.Wrap(this.CreateDirectory, USER_ROLE_USER)
|
||||
routeMap["/api/matter/upload"] = this.Wrap(this.Upload, USER_ROLE_USER)
|
||||
@@ -174,6 +176,55 @@ func (this *MatterController) Page(writer http.ResponseWriter, request *http.Req
|
||||
return this.Success(pager)
|
||||
}
|
||||
|
||||
// DFS search.
|
||||
func (this *MatterController) Search(writer http.ResponseWriter, request *http.Request) *result.WebResult {
|
||||
|
||||
limit := util.ExtractRequestOptionalInt(request, "limit", 200)
|
||||
puuid := util.ExtractRequestString(request, "puuid")
|
||||
keyword := util.ExtractRequestString(request, "keyword")
|
||||
deletedStr := util.ExtractRequestOptionalString(request, "deleted", "")
|
||||
var deleted = false
|
||||
if deletedStr == TRUE {
|
||||
deleted = true
|
||||
}
|
||||
|
||||
user := this.checkUser(request)
|
||||
spaceUuid := util.ExtractRequestOptionalString(request, "spaceUuid", user.SpaceUuid)
|
||||
this.spaceService.CheckReadableByUuid(request, user, spaceUuid)
|
||||
|
||||
matters := this.matterService.DfsSearch(
|
||||
request,
|
||||
limit,
|
||||
puuid,
|
||||
keyword,
|
||||
spaceUuid,
|
||||
deleted,
|
||||
)
|
||||
|
||||
//sort.
|
||||
//从小到大自定义排序
|
||||
sort.Slice(matters, func(i, j int) bool {
|
||||
matter1 := matters[i]
|
||||
matter2 := matters[j]
|
||||
if matter1.Dir {
|
||||
return true
|
||||
} else if matter2.Dir {
|
||||
return false
|
||||
} else {
|
||||
cmp := strings.Compare(matter1.Name, matter2.Name)
|
||||
if cmp < 0 {
|
||||
return true
|
||||
} else if cmp == 0 {
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return this.Success(matters)
|
||||
}
|
||||
|
||||
func (this *MatterController) CreateDirectory(writer http.ResponseWriter, request *http.Request) *result.WebResult {
|
||||
|
||||
puuid := util.ExtractRequestString(request, "puuid")
|
||||
@@ -301,7 +352,7 @@ func (this *MatterController) SoftDeleteBatch(writer http.ResponseWriter, reques
|
||||
continue
|
||||
}
|
||||
|
||||
if matter.UserUuid != user.Uuid {
|
||||
if matter.SpaceUuid != spaceUuid {
|
||||
panic(result.UNAUTHORIZED)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
package rest
|
||||
|
||||
import (
|
||||
"math"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/eyebluecn/tank/code/core"
|
||||
"github.com/eyebluecn/tank/code/tool/builder"
|
||||
"github.com/eyebluecn/tank/code/tool/result"
|
||||
"github.com/eyebluecn/tank/code/tool/util"
|
||||
"github.com/eyebluecn/tank/code/tool/uuid"
|
||||
"gorm.io/gorm"
|
||||
"math"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
type MatterDao struct {
|
||||
@@ -205,6 +206,38 @@ func (this *MatterDao) CountBySpaceUuidAndPuuidAndDirAndName(spaceUuid string, p
|
||||
return int(count)
|
||||
}
|
||||
|
||||
func (this *MatterDao) FindBySpaceUuidAndPuuidAndDirAndName(spaceUuid string, puuid string, dir bool, name string) *Matter {
|
||||
|
||||
var matter = &Matter{}
|
||||
var wp = &builder.WherePair{}
|
||||
|
||||
if puuid != "" {
|
||||
wp = wp.And(&builder.WherePair{Query: "puuid = ?", Args: []interface{}{puuid}})
|
||||
}
|
||||
|
||||
if spaceUuid != "" {
|
||||
wp = wp.And(&builder.WherePair{Query: "space_uuid = ?", Args: []interface{}{spaceUuid}})
|
||||
}
|
||||
|
||||
if name != "" {
|
||||
wp = wp.And(&builder.WherePair{Query: "name = ?", Args: []interface{}{name}})
|
||||
}
|
||||
|
||||
wp = wp.And(&builder.WherePair{Query: "dir = ?", Args: []interface{}{dir}})
|
||||
|
||||
db := core.CONTEXT.GetDB().Where(wp.Query, wp.Args...).First(matter)
|
||||
|
||||
if db.Error != nil {
|
||||
if db.Error.Error() == result.DB_ERROR_NOT_FOUND {
|
||||
return nil
|
||||
} else {
|
||||
this.PanicError(db.Error)
|
||||
}
|
||||
}
|
||||
|
||||
return matter
|
||||
}
|
||||
|
||||
func (this *MatterDao) FindByUserUuidAndPuuidAndDirAndName(userUuid string, puuid string, dir string, name string) *Matter {
|
||||
|
||||
var matter = &Matter{}
|
||||
@@ -279,6 +312,32 @@ func (this *MatterDao) FindBySpaceNameAndPuuidAndDirAndName(spaceName string, pu
|
||||
return matter
|
||||
}
|
||||
|
||||
func (this *MatterDao) FindByPuuidAndSpaceUuid(puuid string, spaceUuid string, sortArray []builder.OrderPair) []*Matter {
|
||||
var matters []*Matter
|
||||
|
||||
var wp = &builder.WherePair{}
|
||||
wp = wp.And(&builder.WherePair{Query: "puuid = ? AND space_uuid = ?", Args: []interface{}{puuid, spaceUuid}})
|
||||
|
||||
if sortArray == nil {
|
||||
|
||||
sortArray = []builder.OrderPair{
|
||||
{
|
||||
Key: "dir",
|
||||
Value: DIRECTION_DESC,
|
||||
},
|
||||
{
|
||||
Key: "create_time",
|
||||
Value: DIRECTION_DESC,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
db := core.CONTEXT.GetDB().Model(&Matter{}).Where(wp.Query, wp.Args...).Order(this.GetSortString(sortArray)).Find(&matters)
|
||||
this.PanicError(db.Error)
|
||||
|
||||
return matters
|
||||
}
|
||||
|
||||
func (this *MatterDao) FindByPuuidAndUserUuid(puuid string, userUuid string, sortArray []builder.OrderPair) []*Matter {
|
||||
return this.FindByPuuidAndUserUuidAndDeleted(puuid, userUuid, "", sortArray)
|
||||
}
|
||||
@@ -322,6 +381,8 @@ func (this *MatterDao) FindByUuids(uuids []string, sortArray []builder.OrderPair
|
||||
|
||||
return matters
|
||||
}
|
||||
|
||||
// pagination is 0 base.
|
||||
func (this *MatterDao) PlainPage(
|
||||
page int,
|
||||
pageSize int,
|
||||
|
||||
@@ -3,12 +3,6 @@ package rest
|
||||
import (
|
||||
"archive/zip"
|
||||
"fmt"
|
||||
"github.com/eyebluecn/tank/code/core"
|
||||
"github.com/eyebluecn/tank/code/tool/builder"
|
||||
"github.com/eyebluecn/tank/code/tool/download"
|
||||
"github.com/eyebluecn/tank/code/tool/i18n"
|
||||
"github.com/eyebluecn/tank/code/tool/result"
|
||||
"github.com/eyebluecn/tank/code/tool/util"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"mime/multipart"
|
||||
@@ -19,6 +13,13 @@ import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/eyebluecn/tank/code/core"
|
||||
"github.com/eyebluecn/tank/code/tool/builder"
|
||||
"github.com/eyebluecn/tank/code/tool/download"
|
||||
"github.com/eyebluecn/tank/code/tool/i18n"
|
||||
"github.com/eyebluecn/tank/code/tool/result"
|
||||
"github.com/eyebluecn/tank/code/tool/util"
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -137,6 +138,62 @@ func (this *MatterService) Page(
|
||||
return pager
|
||||
}
|
||||
|
||||
// search files by dfs.
|
||||
func (this *MatterService) DfsSearch(
|
||||
request *http.Request,
|
||||
limit int,
|
||||
puuid string,
|
||||
keyword string,
|
||||
spaceUuid string,
|
||||
deleted bool,
|
||||
) []*Matter {
|
||||
|
||||
deletedStr := FALSE
|
||||
if deleted {
|
||||
deletedStr = FALSE
|
||||
}
|
||||
|
||||
//find all matters including dir and files.
|
||||
_, matters := this.matterDao.PlainPage(0, limit, puuid, "", spaceUuid, keyword, "", deletedStr, nil, nil, nil)
|
||||
if len(matters) >= limit {
|
||||
return matters
|
||||
}
|
||||
|
||||
var resultList = make([]*Matter, 0)
|
||||
for _, matter := range matters {
|
||||
if !matter.Dir {
|
||||
resultList = append(resultList, matter)
|
||||
}
|
||||
}
|
||||
|
||||
//dfs from puuid.
|
||||
_, dirMatters := this.matterDao.PlainPage(0, 1000, puuid, "", spaceUuid, "", TRUE, "", nil, nil, nil)
|
||||
for _, dirMatter := range dirMatters {
|
||||
|
||||
//add dir if match.
|
||||
if strings.Contains(dirMatter.Name, keyword) {
|
||||
if deleted == dirMatter.Deleted {
|
||||
resultList = append(resultList, dirMatter)
|
||||
}
|
||||
}
|
||||
|
||||
remainLimit := limit - len(resultList)
|
||||
subMatters := this.DfsSearch(request, remainLimit, dirMatter.Uuid, keyword, spaceUuid, deleted)
|
||||
for _, subMatter := range subMatters {
|
||||
resultList = append(resultList, subMatter)
|
||||
}
|
||||
|
||||
//enough then return
|
||||
if len(resultList) >= limit {
|
||||
return resultList
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
//not enough ,return.
|
||||
return resultList
|
||||
}
|
||||
|
||||
// Download. Support chunk download.
|
||||
func (this *MatterService) DownloadFile(
|
||||
writer http.ResponseWriter,
|
||||
@@ -157,12 +214,12 @@ func (this *MatterService) DownloadZip(
|
||||
if matters == nil || len(matters) == 0 {
|
||||
panic(result.BadRequest("matters cannot be nil."))
|
||||
}
|
||||
userUuid := matters[0].UserUuid
|
||||
spaceUuid := matters[0].SpaceUuid
|
||||
puuid := matters[0].Puuid
|
||||
|
||||
for _, m := range matters {
|
||||
if m.UserUuid != userUuid {
|
||||
panic(result.BadRequest("userUuid not same"))
|
||||
if m.SpaceUuid != spaceUuid {
|
||||
panic(result.BadRequest("spaceUuid not same"))
|
||||
} else if m.Puuid != puuid {
|
||||
panic(result.BadRequest("puuid not same"))
|
||||
}
|
||||
@@ -229,13 +286,13 @@ func (this *MatterService) zipMatters(request *http.Request, matters []*Matter,
|
||||
if matters == nil || len(matters) == 0 {
|
||||
panic(result.BadRequest("matters cannot be nil."))
|
||||
}
|
||||
userUuid := matters[0].UserUuid
|
||||
spaceUuid := matters[0].SpaceUuid
|
||||
puuid := matters[0].Puuid
|
||||
baseDirPath := util.GetDirOfPath(matters[0].AbsolutePath()) + "/"
|
||||
|
||||
for _, m := range matters {
|
||||
if m.UserUuid != userUuid {
|
||||
panic(result.BadRequest("userUuid not same"))
|
||||
if m.SpaceUuid != spaceUuid {
|
||||
panic(result.BadRequest("spaceUuid not same"))
|
||||
} else if m.Puuid != puuid {
|
||||
panic(result.BadRequest("puuid not same"))
|
||||
}
|
||||
@@ -426,9 +483,7 @@ func (this *MatterService) Upload(request *http.Request, file io.Reader, fileHea
|
||||
panic(result.BadRequest("Dir has been deleted. Cannot upload under it."))
|
||||
}
|
||||
|
||||
if len(filename) > MATTER_NAME_MAX_LENGTH {
|
||||
panic(result.BadRequestI18n(request, i18n.MatterNameLengthExceedLimit, len(filename), MATTER_NAME_MAX_LENGTH))
|
||||
}
|
||||
CheckMatterName(request, filename)
|
||||
|
||||
//if fileHeader.Size not nill . check size in advance.
|
||||
if fileHeader != nil {
|
||||
@@ -449,9 +504,14 @@ func (this *MatterService) Upload(request *http.Request, file io.Reader, fileHea
|
||||
|
||||
dirAbsolutePath := dirMatter.AbsolutePath()
|
||||
|
||||
count := this.matterDao.CountBySpaceUuidAndPuuidAndDirAndName(space.Uuid, dirMatter.Uuid, false, filename)
|
||||
if count > 0 {
|
||||
panic(result.BadRequestI18n(request, i18n.MatterExist, filename))
|
||||
dbMatter := this.matterDao.FindBySpaceUuidAndPuuidAndDirAndName(space.Uuid, dirMatter.Uuid, false, filename)
|
||||
if dbMatter != nil {
|
||||
if dbMatter.Deleted {
|
||||
panic(result.BadRequestI18n(request, i18n.MatterRecycleBinExist, filename))
|
||||
} else {
|
||||
panic(result.BadRequestI18n(request, i18n.MatterExist, filename))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
fileAbsolutePath := dirAbsolutePath + "/" + filename
|
||||
@@ -1199,7 +1259,7 @@ func (this *MatterService) WrapChildrenDetail(request *http.Request, matter *Mat
|
||||
|
||||
if matter.Dir {
|
||||
|
||||
children := this.matterDao.FindByPuuidAndUserUuid(matter.Uuid, matter.UserUuid, nil)
|
||||
children := this.matterDao.FindByPuuidAndSpaceUuid(matter.Uuid, matter.SpaceUuid, nil)
|
||||
matter.Children = children
|
||||
|
||||
for _, child := range matter.Children {
|
||||
|
||||
@@ -89,6 +89,7 @@ func (this *PreferenceController) Edit(writer http.ResponseWriter, request *http
|
||||
downloadDirMaxNumStr := request.FormValue("downloadDirMaxNum")
|
||||
defaultTotalSizeLimitStr := request.FormValue("defaultTotalSizeLimit")
|
||||
allowRegisterStr := request.FormValue("allowRegister")
|
||||
allowChunkUploadStr := request.FormValue("allowChunkUpload")
|
||||
deletedKeepDaysStr := request.FormValue("deletedKeepDays")
|
||||
|
||||
if name == "" {
|
||||
@@ -140,6 +141,11 @@ func (this *PreferenceController) Edit(writer http.ResponseWriter, request *http
|
||||
allowRegister = true
|
||||
}
|
||||
|
||||
var allowChunkUpload = false
|
||||
if allowChunkUploadStr == TRUE {
|
||||
allowChunkUpload = true
|
||||
}
|
||||
|
||||
preference := this.preferenceDao.Fetch()
|
||||
oldDeletedKeepDays := preference.DeletedKeepDays
|
||||
preference.Name = name
|
||||
@@ -151,6 +157,7 @@ func (this *PreferenceController) Edit(writer http.ResponseWriter, request *http
|
||||
preference.DownloadDirMaxNum = downloadDirMaxNum
|
||||
preference.DefaultTotalSizeLimit = defaultTotalSizeLimit
|
||||
preference.AllowRegister = allowRegister
|
||||
preference.AllowChunkUpload = allowChunkUpload
|
||||
preference.DeletedKeepDays = deletedKeepDays
|
||||
|
||||
preference = this.preferenceService.Save(preference)
|
||||
|
||||
@@ -19,6 +19,7 @@ type Preference struct {
|
||||
DownloadDirMaxNum int64 `json:"downloadDirMaxNum" gorm:"type:bigint(20) not null;default:-1"`
|
||||
DefaultTotalSizeLimit int64 `json:"defaultTotalSizeLimit" gorm:"type:bigint(20) not null;default:-1"`
|
||||
AllowRegister bool `json:"allowRegister" gorm:"type:tinyint(1) not null;default:0"`
|
||||
AllowChunkUpload bool `json:"allowChunkUpload" gorm:"type:tinyint(1) not null;default:0"`
|
||||
PreviewConfig string `json:"previewConfig" gorm:"type:text"`
|
||||
ScanConfig string `json:"scanConfig" gorm:"type:text"`
|
||||
DeletedKeepDays int64 `json:"deletedKeepDays" gorm:"type:bigint(20) not null;default:7"`
|
||||
|
||||
@@ -183,6 +183,13 @@ func (this *SpaceDao) PageHandle(fun func(space *Space)) {
|
||||
}
|
||||
}
|
||||
|
||||
func (this *SpaceDao) DeleteByUserUuid(userUuid string) {
|
||||
|
||||
db := core.CONTEXT.GetDB().Where("user_uuid = ?", userUuid).Delete(Space{})
|
||||
this.PanicError(db.Error)
|
||||
|
||||
}
|
||||
|
||||
func (this *SpaceDao) Delete(space *Space) {
|
||||
|
||||
db := core.CONTEXT.GetDB().Delete(&space)
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"github.com/eyebluecn/tank/code/tool/result"
|
||||
"github.com/eyebluecn/tank/code/tool/util"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type SpaceMemberController struct {
|
||||
@@ -73,13 +74,20 @@ func (this *SpaceMemberController) RegisterRoutes() map[string]func(writer http.
|
||||
func (this *SpaceMemberController) Create(writer http.ResponseWriter, request *http.Request) *result.WebResult {
|
||||
|
||||
spaceUuid := util.ExtractRequestString(request, "spaceUuid")
|
||||
userUuid := util.ExtractRequestString(request, "userUuid")
|
||||
userUuidsStr := util.ExtractRequestString(request, "userUuids")
|
||||
spaceRole := util.ExtractRequestString(request, "role")
|
||||
|
||||
if spaceRole != SPACE_MEMBER_ROLE_READ_ONLY && spaceRole != SPACE_MEMBER_ROLE_READ_WRITE && spaceRole != SPACE_MEMBER_ROLE_ADMIN {
|
||||
panic("spaceRole is not correct")
|
||||
}
|
||||
|
||||
//validate userUuids
|
||||
if userUuidsStr == "" {
|
||||
panic("userUuids is required")
|
||||
}
|
||||
userUuids := strings.Split(userUuidsStr, ",")
|
||||
|
||||
// check operator's permission
|
||||
currentUser := this.checkUser(request)
|
||||
canManage := this.spaceMemberService.canManage(currentUser, spaceUuid)
|
||||
if !canManage {
|
||||
@@ -87,18 +95,24 @@ func (this *SpaceMemberController) Create(writer http.ResponseWriter, request *h
|
||||
}
|
||||
|
||||
//check whether exists.
|
||||
spaceMember := this.spaceMemberDao.FindBySpaceUuidAndUserUuid(spaceUuid, userUuid)
|
||||
if spaceMember != nil {
|
||||
panic(result.BadRequestI18n(request, i18n.SpaceMemberExist))
|
||||
for _, userUuid := range userUuids {
|
||||
spaceMember := this.spaceMemberDao.FindBySpaceUuidAndUserUuid(spaceUuid, userUuid)
|
||||
user := this.userDao.CheckByUuid(userUuid)
|
||||
if spaceMember != nil {
|
||||
panic(result.BadRequestI18n(request, i18n.SpaceMemberExist, user.Username))
|
||||
}
|
||||
}
|
||||
|
||||
//check whether space exists.
|
||||
space := this.spaceDao.CheckByUuid(spaceUuid)
|
||||
user := this.userDao.CheckByUuid(userUuid)
|
||||
|
||||
spaceMember = this.spaceMemberService.CreateMember(space, user, spaceRole)
|
||||
//check whether exists.
|
||||
for _, userUuid := range userUuids {
|
||||
user := this.userDao.CheckByUuid(userUuid)
|
||||
this.spaceMemberService.CreateMember(space, user, spaceRole)
|
||||
}
|
||||
|
||||
return this.Success(spaceMember)
|
||||
return this.Success("OK")
|
||||
}
|
||||
|
||||
func (this *SpaceMemberController) Edit(writer http.ResponseWriter, request *http.Request) *result.WebResult {
|
||||
|
||||
@@ -15,6 +15,7 @@ type TaskService struct {
|
||||
dashboardService *DashboardService
|
||||
preferenceService *PreferenceService
|
||||
matterService *MatterService
|
||||
chunkService *ChunkService
|
||||
userDao *UserDao
|
||||
spaceDao *SpaceDao
|
||||
|
||||
@@ -45,6 +46,12 @@ func (this *TaskService) Init() {
|
||||
if b, ok := b.(*MatterService); ok {
|
||||
this.matterService = b
|
||||
}
|
||||
|
||||
b = core.CONTEXT.GetBean(this.chunkService)
|
||||
if b, ok := b.(*ChunkService); ok {
|
||||
this.chunkService = b
|
||||
}
|
||||
|
||||
b = core.CONTEXT.GetBean(this.userDao)
|
||||
if b, ok := b.(*UserDao); ok {
|
||||
this.userDao = b
|
||||
@@ -94,6 +101,18 @@ func (this *TaskService) InitCleanDeletedMattersTask() {
|
||||
this.logger.Info("[cron job] Everyday 01:00 Clean deleted matters.")
|
||||
}
|
||||
|
||||
// init the clean chunk sessions task.
|
||||
func (this *TaskService) InitCleanExpiredChunkSessions() {
|
||||
|
||||
expression := "0 2 * * *"
|
||||
cronJob := cron.New()
|
||||
_, err := cronJob.AddFunc(expression, this.chunkService.CleanExpiredSessions)
|
||||
core.PanicError(err)
|
||||
cronJob.Start()
|
||||
|
||||
this.logger.Info("[cron job] Everyday 02:00 Clean expired chunk sessions.")
|
||||
}
|
||||
|
||||
// scan task.
|
||||
func (this *TaskService) doScanTask() {
|
||||
|
||||
@@ -210,6 +229,9 @@ func (this *TaskService) Bootstrap() {
|
||||
//load the clean deleted matters task.
|
||||
this.InitCleanDeletedMattersTask()
|
||||
|
||||
//load the clean chunk sessions task.
|
||||
this.InitCleanExpiredChunkSessions()
|
||||
|
||||
//load the scan task.
|
||||
this.InitScanTask()
|
||||
|
||||
|
||||
@@ -64,6 +64,7 @@ func (this *UserController) RegisterRoutes() map[string]func(writer http.Respons
|
||||
routeMap["/api/user/change/password"] = this.Wrap(this.ChangePassword, USER_ROLE_USER)
|
||||
routeMap["/api/user/reset/password"] = this.Wrap(this.ResetPassword, USER_ROLE_ADMINISTRATOR)
|
||||
routeMap["/api/user/page"] = this.Wrap(this.Page, USER_ROLE_ADMINISTRATOR)
|
||||
routeMap["/api/user/search"] = this.Wrap(this.Search, USER_ROLE_USER)
|
||||
routeMap["/api/user/toggle/status"] = this.Wrap(this.ToggleStatus, USER_ROLE_ADMINISTRATOR)
|
||||
routeMap["/api/user/transfiguration"] = this.Wrap(this.Transfiguration, USER_ROLE_ADMINISTRATOR)
|
||||
routeMap["/api/user/scan"] = this.Wrap(this.Scan, USER_ROLE_ADMINISTRATOR)
|
||||
@@ -240,19 +241,31 @@ func (this *UserController) Edit(writer http.ResponseWriter, request *http.Reque
|
||||
sizeLimit := util.ExtractRequestInt64(request, "sizeLimit")
|
||||
totalSizeLimit := util.ExtractRequestInt64(request, "totalSizeLimit")
|
||||
|
||||
user := this.checkUser(request)
|
||||
operator := this.checkUser(request)
|
||||
currentUser := this.userDao.CheckByUuid(uuid)
|
||||
|
||||
currentUser.AvatarUrl = avatarUrl
|
||||
|
||||
if user.Role == USER_ROLE_ADMINISTRATOR {
|
||||
//only admin can edit user's sizeLimit
|
||||
if operator.Role == USER_ROLE_ADMINISTRATOR {
|
||||
//only admin can edit user's role and sizeLimit
|
||||
|
||||
if role == USER_ROLE_USER || role == USER_ROLE_ADMINISTRATOR {
|
||||
currentUser.Role = role
|
||||
}
|
||||
|
||||
} else if user.Uuid != uuid {
|
||||
} else if operator.Uuid == uuid {
|
||||
//cannot edit sizeLimit, totalSizeLimit
|
||||
space := this.spaceDao.CheckByUuid(currentUser.SpaceUuid)
|
||||
if space.SizeLimit != sizeLimit {
|
||||
this.logger.Error(" %s try to modify sizeLimit from %d to %d.", operator.Uuid, space.SizeLimit, sizeLimit)
|
||||
panic(result.BadRequestI18n(request, i18n.PermissionDenied))
|
||||
}
|
||||
if space.TotalSizeLimit != totalSizeLimit {
|
||||
this.logger.Error(" %s try to modify TotalSizeLimit from %d to %d.", operator.Uuid, space.TotalSizeLimit, totalSizeLimit)
|
||||
panic(result.BadRequestI18n(request, i18n.PermissionDenied))
|
||||
}
|
||||
|
||||
} else {
|
||||
panic(result.UNAUTHORIZED)
|
||||
}
|
||||
|
||||
@@ -260,7 +273,7 @@ func (this *UserController) Edit(writer http.ResponseWriter, request *http.Reque
|
||||
currentUser = this.userDao.Save(currentUser)
|
||||
|
||||
//edit user's private space info.
|
||||
space := this.spaceService.Edit(request, user, currentUser.SpaceUuid, sizeLimit, totalSizeLimit)
|
||||
space := this.spaceService.Edit(request, operator, currentUser.SpaceUuid, sizeLimit, totalSizeLimit)
|
||||
|
||||
//remove cache user.
|
||||
this.userService.RemoveCacheUserByUuid(currentUser.Uuid)
|
||||
@@ -373,6 +386,23 @@ func (this *UserController) Page(writer http.ResponseWriter, request *http.Reque
|
||||
return this.Success(pager)
|
||||
}
|
||||
|
||||
func (this *UserController) Search(writer http.ResponseWriter, request *http.Request) *result.WebResult {
|
||||
|
||||
keyword := request.FormValue("keyword")
|
||||
|
||||
pager := this.userDao.Page(0, 10, keyword, "", nil)
|
||||
|
||||
var resultList []*User = make([]*User, 0)
|
||||
for _, u := range pager.Data.([]*User) {
|
||||
resultList = append(resultList, &User{
|
||||
Uuid: u.Uuid,
|
||||
Username: u.Username,
|
||||
})
|
||||
}
|
||||
|
||||
return this.Success(resultList)
|
||||
}
|
||||
|
||||
func (this *UserController) ToggleStatus(writer http.ResponseWriter, request *http.Request) *result.WebResult {
|
||||
|
||||
uuid := request.FormValue("uuid")
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package rest
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/eyebluecn/tank/code/core"
|
||||
"github.com/eyebluecn/tank/code/tool/cache"
|
||||
"github.com/eyebluecn/tank/code/tool/result"
|
||||
@@ -8,7 +11,6 @@ import (
|
||||
"github.com/eyebluecn/tank/code/tool/uuid"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
// @Service
|
||||
@@ -19,12 +21,16 @@ type UserService struct {
|
||||
|
||||
spaceService *SpaceService
|
||||
|
||||
//file lock
|
||||
//file lock (legacy, kept for compatibility)
|
||||
locker *cache.Table
|
||||
// matterLocker is used for atomic matter operations with retry support
|
||||
matterLocker sync.Map
|
||||
|
||||
matterDao *MatterDao
|
||||
matterService *MatterService
|
||||
imageCacheDao *ImageCacheDao
|
||||
spaceDao *SpaceDao
|
||||
spaceMemberDao *SpaceMemberDao
|
||||
shareDao *ShareDao
|
||||
shareService *ShareService
|
||||
downloadTokenDao *DownloadTokenDao
|
||||
@@ -65,6 +71,15 @@ func (this *UserService) Init() {
|
||||
this.imageCacheDao = b
|
||||
}
|
||||
|
||||
b = core.CONTEXT.GetBean(this.spaceDao)
|
||||
if b, ok := b.(*SpaceDao); ok {
|
||||
this.spaceDao = b
|
||||
}
|
||||
|
||||
b = core.CONTEXT.GetBean(this.spaceMemberDao)
|
||||
if b, ok := b.(*SpaceMemberDao); ok {
|
||||
this.spaceMemberDao = b
|
||||
}
|
||||
b = core.CONTEXT.GetBean(this.shareService)
|
||||
if b, ok := b.(*ShareService); ok {
|
||||
this.shareService = b
|
||||
@@ -89,32 +104,33 @@ func (this *UserService) Init() {
|
||||
this.locker = cache.NewTable()
|
||||
}
|
||||
|
||||
// lock a user's operation. If lock, user cannot operate file.
|
||||
// lock a user's operation with retry mechanism.
|
||||
// This allows concurrent operations to wait for each other instead of immediately failing.
|
||||
// Uses sync.Map for thread-safe atomic operations.
|
||||
func (this *UserService) MatterLock(userUuid string) {
|
||||
maxRetries := 30 // Maximum retry attempts (30 * 200ms = 6 seconds max wait)
|
||||
retryInterval := 200 // Retry interval in milliseconds
|
||||
|
||||
cacheItem, err := this.locker.Value(userUuid)
|
||||
if err != nil {
|
||||
this.logger.Error("error while get cache" + err.Error())
|
||||
for i := 0; i < maxRetries; i++ {
|
||||
// Try to acquire lock atomically
|
||||
if _, loaded := this.matterLocker.LoadOrStore(userUuid, true); !loaded {
|
||||
// Successfully acquired the lock
|
||||
return
|
||||
}
|
||||
|
||||
// Lock is held by another operation, wait and retry
|
||||
if i < maxRetries-1 {
|
||||
time.Sleep(time.Duration(retryInterval) * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
if cacheItem != nil && cacheItem.Data() != nil {
|
||||
panic(result.BadRequest("file is being operating, retry later"))
|
||||
}
|
||||
|
||||
duration := 12 * time.Hour
|
||||
this.locker.Add(userUuid, duration, true)
|
||||
// After all retries, still cannot acquire lock
|
||||
panic(result.BadRequest("file is being operating, retry later"))
|
||||
}
|
||||
|
||||
// unlock
|
||||
func (this *UserService) MatterUnlock(userUuid string) {
|
||||
|
||||
exist := this.locker.Exists(userUuid)
|
||||
if exist {
|
||||
_, err := this.locker.Delete(userUuid)
|
||||
this.PanicError(err)
|
||||
} else {
|
||||
this.logger.Error("unlock error. %s has no matter lock ", userUuid)
|
||||
}
|
||||
this.matterLocker.Delete(userUuid)
|
||||
}
|
||||
|
||||
// load session to SessionCache. This method will be invoked in every request.
|
||||
@@ -300,6 +316,15 @@ func (this *UserService) DeleteUser(request *http.Request, currentUser *User) {
|
||||
this.logger.Info("delete matters")
|
||||
this.matterDao.DeleteByUserUuid(currentUser.Uuid)
|
||||
|
||||
//delete space members
|
||||
space := this.spaceDao.CheckByUuid(currentUser.SpaceUuid)
|
||||
this.logger.Info("delete space members")
|
||||
this.spaceMemberDao.DeleteBySpaceUuid(space.Uuid)
|
||||
|
||||
//delete spaces
|
||||
this.logger.Info("delete spaces")
|
||||
this.spaceDao.DeleteByUserUuid(currentUser.Uuid)
|
||||
|
||||
//delete this user
|
||||
this.logger.Info("delete this user.")
|
||||
this.userDao.Delete(currentUser)
|
||||
|
||||
@@ -217,6 +217,12 @@ func (this *TankContext) registerBeans() {
|
||||
//uploadToken
|
||||
this.registerBean(new(rest.UploadTokenDao))
|
||||
|
||||
//chunk upload
|
||||
this.registerBean(new(rest.ChunkController))
|
||||
this.registerBean(new(rest.ChunkService))
|
||||
this.registerBean(new(rest.UploadSessionDao))
|
||||
this.registerBean(new(rest.UploadChunkDao))
|
||||
|
||||
//task
|
||||
this.registerBean(new(rest.TaskService))
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
|
||||
func TestHello(t *testing.T) {
|
||||
|
||||
split := strings.Split("good", "/")
|
||||
split := strings.Split("", "")
|
||||
fmt.Printf("%v", split)
|
||||
|
||||
var i int
|
||||
|
||||
@@ -3,8 +3,6 @@ package download
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/eyebluecn/tank/code/tool/result"
|
||||
"github.com/eyebluecn/tank/code/tool/util"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
@@ -14,6 +12,9 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/eyebluecn/tank/code/tool/result"
|
||||
"github.com/eyebluecn/tank/code/tool/util"
|
||||
)
|
||||
|
||||
// HttpRange specifies the byte range to be sent to the client.
|
||||
@@ -313,6 +314,8 @@ func DownloadFile(
|
||||
sendSize = ra.length
|
||||
code = http.StatusPartialContent
|
||||
writer.Header().Set("Content-Range", ra.contentRange(size))
|
||||
// 把文件放进去
|
||||
io.CopyN(writer, diskFile, int64(ra.length))
|
||||
case len(ranges) > 1:
|
||||
sendSize = RangesMIMESize(ranges, ctype, size)
|
||||
code = http.StatusPartialContent
|
||||
|
||||
@@ -34,6 +34,7 @@ var (
|
||||
UserDisabled = &Item{English: `user has been disabled`, Chinese: `用户已经被禁用了`}
|
||||
MatterDestinationMustDirectory = &Item{English: `destination must be directory'`, Chinese: `目标对象只能是文件夹。`}
|
||||
MatterExist = &Item{English: `"%s" already exists, invalid operation`, Chinese: `"%s" 已经存在了,操作无效`}
|
||||
MatterRecycleBinExist = &Item{English: `"%s" already exists in recycle bin, invalid operation`, Chinese: `"%s" 已经存在于回收站,请彻底删除后再操作`}
|
||||
MatterDepthExceedLimit = &Item{English: `directory's depth exceed the limit %d > %d`, Chinese: `文件加层数超过限制 %d > %d `}
|
||||
MatterNameLengthExceedLimit = &Item{English: `filename's length exceed the limit %d > %d`, Chinese: `文件名称长度超过限制 %d > %d `}
|
||||
MatterSelectNumExceedLimit = &Item{English: `selected files' num exceed the limit %d > %d`, Chinese: `选择的文件数量超出限制了 %d > %d `}
|
||||
@@ -50,7 +51,7 @@ var (
|
||||
SpaceNameError = &Item{English: `space's name can only be letters, numbers or _`, Chinese: `空间名称必填,且只能包含中文,字母,数字和'_'`}
|
||||
SpaceNameExist = &Item{English: `space's name "%s" exists`, Chinese: `空间名称"%s"已被占用,请使用其他名字`}
|
||||
SpaceExclusive = &Item{English: `user can only own ONE space`, Chinese: `一个用户只能拥有一个私有空间`}
|
||||
SpaceMemberExist = &Item{English: `space member exists`, Chinese: `该用于已经是空间的成员`}
|
||||
SpaceMemberExist = &Item{English: `space member %s exists`, Chinese: `用户 %s 已经是空间的成员`}
|
||||
PermissionDenied = &Item{English: `permission denied.`, Chinese: `没有操作权限`}
|
||||
)
|
||||
|
||||
|
||||
@@ -46,16 +46,14 @@ func ExtractRequestArray(request *http.Request, key string) []string {
|
||||
func ExtractRequestInt64(request *http.Request, key string) int64 {
|
||||
keyStr := request.FormValue(key)
|
||||
|
||||
var num int64 = 0
|
||||
if keyStr == "" {
|
||||
panic(fmt.Sprintf("%s is required", key))
|
||||
} else {
|
||||
intVal, err := strconv.Atoi(keyStr)
|
||||
intVal, err := strconv.ParseInt(keyStr, 10, 64)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
num = int64(intVal)
|
||||
return num
|
||||
return intVal
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,7 +74,36 @@ func ExtractRequestInt64WithErrorHint(request *http.Request, key string, errorHi
|
||||
}
|
||||
}
|
||||
|
||||
// param is optional. when missing, use default.
|
||||
func ExtractRequestOptionalInt64(request *http.Request, key string, defaultValue int64) int64 {
|
||||
str := request.FormValue(key)
|
||||
if str == "" {
|
||||
return defaultValue
|
||||
} else {
|
||||
intVal, err := strconv.ParseInt(str, 10, 64)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return intVal
|
||||
}
|
||||
}
|
||||
|
||||
// param is required. when missing, panic error.
|
||||
func ExtractRequestInt(request *http.Request, key string) int {
|
||||
keyStr := request.FormValue(key)
|
||||
|
||||
if keyStr == "" {
|
||||
panic(fmt.Sprintf("%s is required", key))
|
||||
} else {
|
||||
intVal, err := strconv.Atoi(keyStr)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return intVal
|
||||
}
|
||||
}
|
||||
|
||||
// param is optional. when missing, use default.
|
||||
func ExtractRequestOptionalInt(request *http.Request, key string, defaultValue int) int {
|
||||
str := request.FormValue(key)
|
||||
if str == "" {
|
||||
|
||||
@@ -1,14 +1,36 @@
|
||||
module github.com/eyebluecn/tank
|
||||
|
||||
go 1.14
|
||||
go 1.22.5
|
||||
|
||||
require (
|
||||
github.com/disintegration/imaging v1.6.2
|
||||
github.com/glebarez/sqlite v1.8.0
|
||||
github.com/glebarez/sqlite v1.11.0
|
||||
github.com/json-iterator/go v1.1.12
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
golang.org/x/crypto v0.1.0
|
||||
golang.org/x/text v0.4.0
|
||||
gorm.io/driver/mysql v1.3.2
|
||||
gorm.io/gorm v1.25.1
|
||||
golang.org/x/crypto v0.25.0
|
||||
golang.org/x/text v0.16.0
|
||||
gorm.io/driver/mysql v1.5.7
|
||||
gorm.io/gorm v1.25.11
|
||||
)
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/glebarez/go-sqlite v1.22.0 // indirect
|
||||
github.com/go-sql-driver/mysql v1.8.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/image v0.18.0 // indirect
|
||||
golang.org/x/sys v0.22.0 // indirect
|
||||
golang.org/x/term v0.22.0 // indirect
|
||||
modernc.org/libc v1.55.4 // indirect
|
||||
modernc.org/mathutil v1.6.0 // indirect
|
||||
modernc.org/memory v1.8.0 // indirect
|
||||
modernc.org/sqlite v1.31.1 // indirect
|
||||
)
|
||||
|
||||
@@ -1,48 +1,41 @@
|
||||
github.com/chzyer/logex v1.2.0/go.mod h1:9+9sk7u7pGNWYMkh0hdiL++6OeibzJccyQU4p4MedaY=
|
||||
github.com/chzyer/readline v1.5.0/go.mod h1:x22KAscuvRqlLoK9CsoYsmxoXZMMFVyOl86cAH8qUic=
|
||||
github.com/chzyer/test v0.0.0-20210722231415-061457976a23/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
|
||||
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
|
||||
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/glebarez/go-sqlite v1.21.1 h1:7MZyUPh2XTrHS7xNEHQbrhfMZuPSzhkm2A1qgg0y5NY=
|
||||
github.com/glebarez/go-sqlite v1.21.1/go.mod h1:ISs8MF6yk5cL4n/43rSOmVMGJJjHYr7L2MbZZ5Q4E2E=
|
||||
github.com/glebarez/sqlite v1.8.0 h1:02X12E2I/4C1n+v90yTqrjRa8yuo7c3KeHI3FRznCvc=
|
||||
github.com/glebarez/sqlite v1.8.0/go.mod h1:bpET16h1za2KOOMb8+jCp6UBP/iahDpfPQqSaYLTLx8=
|
||||
github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE=
|
||||
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
|
||||
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
|
||||
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
|
||||
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
|
||||
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
|
||||
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
|
||||
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/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
|
||||
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/ianlancetaylor/demangle v0.0.0-20220319035150-800ac71e25c2/go.mod h1:aYm2/VgdVmcIU8iMfdMvDMsRAQjcfZSKFby6HOFvi/w=
|
||||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
|
||||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.4/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
|
||||
github.com/klauspost/cpuid/v2 v2.2.3/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng=
|
||||
github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
|
||||
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/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
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/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||
@@ -50,93 +43,59 @@ github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzG
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.1.0 h1:MDRAIl0xIo9Io2xV565hzXHw3zVseKrJKodhohM5CjU=
|
||||
golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw=
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 h1:hVwzHzIUGRjiF7EcUjqNxk3NCfkPxbDKRdnNE1Rpg0U=
|
||||
golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30=
|
||||
golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M=
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.4.0 h1:Zr2JFtRQNX3BCZ8YtxRE9hNJYC8J6I1MVbMg6owUp18=
|
||||
golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.1.0 h1:g6Z6vPFA9dYBAF7DWcH6sCcOntplXsDKcliusYijMlw=
|
||||
golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ=
|
||||
golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E=
|
||||
golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.19.0 h1:fEdghXQSo20giMthA7cd28ZC+jts4amQ3YMXiP5oMQ8=
|
||||
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
|
||||
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.22.0 h1:BbsgPEJULsl2fV/AT3v15Mjva5yXKQDyKf+TbDz7QJk=
|
||||
golang.org/x/term v0.22.0/go.mod h1:F3qCibpT5AMpCRfhfT53vVJwhLtIVHhB9XDjfFvnMI4=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.4.0 h1:BrVqGRd7+k1DiOgtnFvAkoQEWQvBc25ouMJM6429SFg=
|
||||
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20201124115921-2c860bdd6e78/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gorm.io/driver/mysql v1.3.2 h1:QJryWiqQ91EvZ0jZL48NOpdlPdMjdip1hQ8bTgo4H7I=
|
||||
gorm.io/driver/mysql v1.3.2/go.mod h1:ChK6AHbHgDCFZyJp0F+BmVGb06PSIoh9uVYKAlRbb2U=
|
||||
gorm.io/gorm v1.23.1/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk=
|
||||
gorm.io/gorm v1.24.6/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k=
|
||||
gorm.io/gorm v1.25.1 h1:nsSALe5Pr+cM3V1qwwQ7rOkw+6UeLrX5O4v3llhHa64=
|
||||
gorm.io/gorm v1.25.1/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k=
|
||||
lukechampine.com/uint128 v1.1.1/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
|
||||
lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
|
||||
modernc.org/cc/v3 v3.37.0/go.mod h1:vtL+3mdHx/wcj3iEGz84rQa8vEqR6XM84v5Lcvfph20=
|
||||
modernc.org/cc/v3 v3.38.1/go.mod h1:vtL+3mdHx/wcj3iEGz84rQa8vEqR6XM84v5Lcvfph20=
|
||||
modernc.org/cc/v3 v3.40.0/go.mod h1:/bTg4dnWkSXowUO6ssQKnOV0yMVxDYNIsIrzqTFDGH0=
|
||||
modernc.org/ccgo/v3 v3.0.0-20220904174949-82d86e1b6d56/go.mod h1:YSXjPL62P2AMSxBphRHPn7IkzhVHqkvOnRKAKh+W6ZI=
|
||||
modernc.org/ccgo/v3 v3.0.0-20220910160915-348f15de615a/go.mod h1:8p47QxPkdugex9J4n9P2tLZ9bK01yngIVp00g4nomW0=
|
||||
modernc.org/ccgo/v3 v3.16.13-0.20221017192402-261537637ce8/go.mod h1:fUB3Vn0nVPReA+7IG7yZDfjv1TMWjhQP8gCxrFAtL5g=
|
||||
modernc.org/ccgo/v3 v3.16.13/go.mod h1:2Quk+5YgpImhPjv2Qsob1DnZ/4som1lJTodubIcoUkY=
|
||||
modernc.org/ccorpus v1.11.6/go.mod h1:2gEUTrWqdpH2pXsmTM1ZkjeSrUWDpjMu2T6m29L/ErQ=
|
||||
modernc.org/httpfs v1.0.6/go.mod h1:7dosgurJGp0sPaRanU53W4xZYKh14wfzX420oZADeHM=
|
||||
modernc.org/libc v1.17.4/go.mod h1:WNg2ZH56rDEwdropAJeZPQkXmDwh+JCA1s/htl6r2fA=
|
||||
modernc.org/libc v1.18.0/go.mod h1:vj6zehR5bfc98ipowQOM2nIDUZnVew/wNC/2tOGS+q0=
|
||||
modernc.org/libc v1.19.0/go.mod h1:ZRfIaEkgrYgZDl6pa4W39HgN5G/yDW+NRmNKZBDFrk0=
|
||||
modernc.org/libc v1.20.3/go.mod h1:ZRfIaEkgrYgZDl6pa4W39HgN5G/yDW+NRmNKZBDFrk0=
|
||||
modernc.org/libc v1.21.2/go.mod h1:przBsL5RDOZajTVslkugzLBj1evTue36jEomFQOoYuI=
|
||||
modernc.org/libc v1.21.4/go.mod h1:przBsL5RDOZajTVslkugzLBj1evTue36jEomFQOoYuI=
|
||||
modernc.org/libc v1.22.3 h1:D/g6O5ftAfavceqlLOFwaZuA5KYafKwmr30A6iSqoyY=
|
||||
modernc.org/libc v1.22.3/go.mod h1:MQrloYP209xa2zHome2a8HLiLm6k0UT8CoHpV74tOFw=
|
||||
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
|
||||
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
|
||||
modernc.org/memory v1.3.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
|
||||
modernc.org/memory v1.4.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
|
||||
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
|
||||
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
|
||||
modernc.org/opt v0.1.1/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
|
||||
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
|
||||
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/tools v0.23.0 h1:SGsXPZ+2l4JsgaCKkx+FQ9YZ5XEtA1GZYuoDjenLjvg=
|
||||
gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo=
|
||||
gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM=
|
||||
gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
||||
gorm.io/gorm v1.25.11 h1:/Wfyg1B/je1hnDx3sMkX+gAlxrlZpn6X0BXRlwXlvHg=
|
||||
gorm.io/gorm v1.25.11/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
|
||||
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
|
||||
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
|
||||
modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y=
|
||||
modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s=
|
||||
modernc.org/ccgo/v4 v4.20.4 h1:3pPOlMcblnu5CBU3w1BFtepwBnLezGjPYTH8xBeYZM8=
|
||||
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
|
||||
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
|
||||
modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw=
|
||||
modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
|
||||
modernc.org/gc/v2 v2.4.3 h1:Ik4ZcMbC7aY4ZDPUhzXVXi7GMub9QcXLTfXn3mWpNw8=
|
||||
modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=
|
||||
modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=
|
||||
modernc.org/libc v1.55.4 h1:iWzZ96v1Iut2Sm4lEp0yzFde20M9zpcI0wY3DFcb+8g=
|
||||
modernc.org/libc v1.55.4/go.mod h1:GPuVtbWvXUo590z/xfVIQcOqnugb1WovSfMJWMe0ZfA=
|
||||
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
|
||||
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
|
||||
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
|
||||
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
|
||||
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
|
||||
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
|
||||
modernc.org/sqlite v1.21.1 h1:GyDFqNnESLOhwwDRaHGdp2jKLDzpyT/rNLglX3ZkMSU=
|
||||
modernc.org/sqlite v1.21.1/go.mod h1:XwQ0wZPIh1iKb5mkvCJ3szzbhk+tykC8ZWqTRTgYRwI=
|
||||
modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw=
|
||||
modernc.org/tcl v1.15.1/go.mod h1:aEjeGJX2gz1oWKOLDVZ2tnEWLUrIn8H+GFu+akoDhqs=
|
||||
modernc.org/token v1.0.1/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
modernc.org/z v1.7.0/go.mod h1:hVdgNMh8ggTuRG1rGU8x+xGRFfiQUIAw0ZqlPy8+HyQ=
|
||||
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
|
||||
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
|
||||
modernc.org/sqlite v1.30.2 h1:IPVVkhLu5mMVnS1dQgh3h0SAACRWcVk7aoLP9Us3UCk=
|
||||
modernc.org/sqlite v1.30.2/go.mod h1:DUmsiWQDaAvU4abhc/N+djlom/L2o8f7gZ95RCvyoLU=
|
||||
modernc.org/sqlite v1.31.1 h1:XVU0VyzxrYHlBhIs1DiEgSl0ZtdnPtbLVy8hSkzxGrs=
|
||||
modernc.org/sqlite v1.31.1/go.mod h1:UqoylwmTb9F+IqXERT8bW9zzOWN8qwAIcLdzeBZs4hA=
|
||||
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
|
||||
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
|
||||