Compare commits

...

32 Commits
3.6.2 ... 3.7.0

Author SHA1 Message Date
Aaron Liu
835605a5cb chore: keep artifacts naming consistent 2023-02-10 13:02:37 +08:00
Aaron Liu
35c4215c0f chore: update archive config to keep original arch name 2023-02-10 12:13:14 +08:00
Aaron Liu
3db803ed38 chore: update readme and archive config 2023-02-09 20:29:38 +08:00
Aaron Liu
c2d7168c26 release: 3.7.0 2023-02-09 19:03:52 +08:00
Aaron Liu
b441d884f6 chore: fix amd64.v1 inconsistency 2023-02-08 22:10:58 +08:00
Aaron Liu
d4c79cb962 chore: fix amd64.v1 inconsistency 2023-02-08 21:39:04 +08:00
Aaron Liu
e134826bd1 chore: skip git validation before release 2023-02-08 21:10:55 +08:00
Aaron Liu
b78f475df8 chore: use goreleaser to build docker images 2023-02-08 21:06:14 +08:00
Aaron Liu
e7de7e868d chore: use goreleaser to build artifacts 2023-02-08 20:04:45 +08:00
Aaron Liu
a58e3b19ec Revert "chore(build): add go-task support (#1608)"
This reverts commit abe90e4c88.

Revert "chore: fix env in task yaml and test new build action"

This reverts commit 7dfe8fb439.

Revert "remove unused env"

This reverts commit 076aa2c567.

Revert "fix: ci build failed as env in go tasks cannot be overwritten"

This reverts commit 71cc332109.
2023-02-08 15:42:42 +08:00
Aaron Liu
71cc332109 fix: ci build failed as env in go tasks cannot be overwritten 2023-02-08 15:32:34 +08:00
Aaron Liu
076aa2c567 remove unused env 2023-02-08 15:11:53 +08:00
Aaron Liu
7dfe8fb439 chore: fix env in task yaml and test new build action 2023-02-08 15:08:20 +08:00
Aaron Liu
b1b74b7be5 Merge remote-tracking branch 'origin/master' 2023-02-08 13:57:54 +08:00
mritd
abe90e4c88 chore(build): add go-task support (#1608)
* chore(build): add go-task support

add go-task support

Signed-off-by: kovacs <mritd@linux.com>

* chore(docker): build with go-task

build with go-task

Signed-off-by: kovacs <mritd@linux.com>

* chore(task): support cross compile

support cross compile

Signed-off-by: kovacs <mritd@linux.com>

* chore(task): remove GCC build

remove GCC build

Signed-off-by: kovacs <mritd@linux.com>

* docs(task): update README

update README

Signed-off-by: kovacs <mritd@linux.com>

---------

Signed-off-by: kovacs <mritd@linux.com>
2023-02-08 13:57:21 +08:00
Aaron Liu
95027e4f5d refactor(db): move dialects to a standalone pkg 2023-02-08 10:06:24 +08:00
VigorFox
9c58278e08 refactor(db): change SQLite driver from github.com/jinzhu/gorm/dialects/sqlite to github.com/glebarez/go-sqlite (#1626)
* sqlite 驱动从 github.com/jinzhu/gorm/dialects/sqlite 改为 github.com/glebarez/go-sqlite,以移除对 cgo 的依赖

* // 兼容已有配置中的 "sqlite3" 配置项

* Update models/init.go: 修改变量名
2023-02-08 09:53:41 +08:00
Aaron Liu
6d1c44f21b test: fix failed ut 2023-02-07 20:24:21 +08:00
Aaron Liu
489a2bab4f test: delete file while user not found 2023-02-07 20:18:13 +08:00
Aaron Liu
d67d0512f8 feat(explorer): advance delete options for users 2023-02-07 20:08:22 +08:00
Aaron Liu
1c1cd9b342 feat(dashboard): unlink file while not deleting its physical source (#789) 2023-02-07 20:07:05 +08:00
Aaron Liu
2a1e82aede fix(fs): cannot delete file while user is deleted (fix #1586) 2023-02-07 20:04:53 +08:00
WeidiDeng
a93ea2cfa0 feat(webdav): add read-only option (#1629) 2023-02-07 19:43:28 +08:00
HFO4
ffbafca994 Merge remote-tracking branch 'origin/master' 2023-01-10 19:56:59 +08:00
HFO4
99434d7aa5 test(wopi): add tests for wopi client 2023-01-10 19:56:02 +08:00
HFO4
f7fdf10d70 feat(wopi): edit WOPI related settings 2023-01-09 19:38:55 +08:00
HFO4
9ad2c3508f enhancement(upload): keep original file content after failed to update document files 2023-01-09 19:38:31 +08:00
HFO4
5a8c86c72e feat(wopi): adapt libreoffice online 2023-01-09 19:38:12 +08:00
HFO4
1c922ac981 feat(wopi): implement required rest api as a WOPI host 2023-01-09 19:37:46 +08:00
HFO4
4541400755 feat(wopi): change doc preview config based on WOPI discovery results 2023-01-09 19:36:41 +08:00
HFO4
c39daeb0d0 feat(wopi): fetch discover endpoint 2023-01-09 19:34:39 +08:00
5aaee9
8dafb4f40a feat: support connect to mysql with unix socket (#1571) 2022-12-19 19:23:47 +08:00
57 changed files with 1939 additions and 417 deletions

View File

@@ -7,10 +7,10 @@ jobs:
name: Build
runs-on: ubuntu-18.04
steps:
- name: Set up Go 1.18
- name: Set up Go 1.20
uses: actions/setup-go@v2
with:
go-version: "1.18"
go-version: "1.20"
id: go
- name: Check out code into the Go module directory
@@ -21,35 +21,11 @@ jobs:
- run: |
git fetch --prune --unshallow --tags
- name: Get dependencies and build
run: |
sudo apt-get update
sudo apt-get -y install gcc-mingw-w64-x86-64
sudo apt-get -y install gcc-arm-linux-gnueabihf libc6-dev-armhf-cross
sudo apt-get -y install gcc-aarch64-linux-gnu libc6-dev-arm64-cross
chmod +x ./build.sh
./build.sh -r b
- name: Upload binary files (windows_amd64)
uses: actions/upload-artifact@v2
- name: Build and Release
uses: goreleaser/goreleaser-action@v4
with:
name: cloudreve_windows_amd64
path: release/cloudreve*windows_amd64.*
- name: Upload binary files (linux_amd64)
uses: actions/upload-artifact@v2
with:
name: cloudreve_linux_amd64
path: release/cloudreve*linux_amd64.*
- name: Upload binary files (linux_arm)
uses: actions/upload-artifact@v2
with:
name: cloudreve_linux_arm
path: release/cloudreve*linux_arm.*
- name: Upload binary files (linux_arm64)
uses: actions/upload-artifact@v2
with:
name: cloudreve_linux_arm64
path: release/cloudreve*linux_arm64.*
distribution: goreleaser
version: latest
args: release --clean --skip-validate
env:
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}

2
.gitignore vendored
View File

@@ -29,3 +29,5 @@ version.lock
conf/conf.ini
/statik/
.vscode/
dist/

119
.goreleaser.yaml Normal file
View File

@@ -0,0 +1,119 @@
env:
- CI=false
- GENERATE_SOURCEMAP=false
before:
hooks:
- go mod tidy
- sh -c "cd assets && rm -rf build && yarn install --network-timeout 1000000 && yarn run build && cd ../ && zip -r - assets/build >assets.zip"
builds:
-
env:
- CGO_ENABLED=0
ldflags:
- -X 'github.com/cloudreve/Cloudreve/v3/pkg/conf.BackendVersion={{.Tag}}' -X 'github.com/cloudreve/Cloudreve/v3/pkg/conf.LastCommit={{.ShortCommit}}'
goos:
- linux
- windows
- darwin
goarch:
- amd64
- arm
- arm64
goarm:
- 5
- 6
- 7
ignore:
- goos: windows
goarm: 5
- goos: windows
goarm: 6
- goos: windows
goarm: 7
archives:
- format: tar.gz
# this name template makes the OS and Arch compatible with the results of uname.
name_template: >-
cloudreve_{{.Tag}}_{{- .Os }}_{{ .Arch }}
{{- if .Arm }}v{{ .Arm }}{{ end }}
# use zip for windows archives
format_overrides:
- goos: windows
format: zip
checksum:
name_template: 'checksums.txt'
snapshot:
name_template: "{{ incpatch .Version }}-next"
changelog:
sort: asc
filters:
exclude:
- '^docs:'
- '^test:'
release:
draft: true
prerelease: auto
target_commitish: '{{ .Commit }}'
name_template: "{{.Version}}"
dockers:
-
dockerfile: Dockerfile
use: buildx
build_flag_templates:
- "--platform=linux/amd64"
goos: linux
goarch: amd64
goamd64: v1
image_templates:
- "cloudreve/cloudreve:{{ .Tag }}-amd64"
-
dockerfile: Dockerfile
use: buildx
build_flag_templates:
- "--platform=linux/arm64"
goos: linux
goarch: arm64
image_templates:
- "cloudreve/cloudreve:{{ .Tag }}-arm64"
-
dockerfile: Dockerfile
use: buildx
build_flag_templates:
- "--platform=linux/arm/v6"
goos: linux
goarch: arm
goarm: '6'
image_templates:
- "cloudreve/cloudreve:{{ .Tag }}-armv6"
-
dockerfile: Dockerfile
use: buildx
build_flag_templates:
- "--platform=linux/arm/v7"
goos: linux
goarch: arm
goarm: '7'
image_templates:
- "cloudreve/cloudreve:{{ .Tag }}-armv7"
docker_manifests:
- name_template: "cloudreve/cloudreve:latest"
image_templates:
- "cloudreve/cloudreve:{{ .Tag }}-amd64"
- "cloudreve/cloudreve:{{ .Tag }}-arm64"
- "cloudreve/cloudreve:{{ .Tag }}-armv6"
- "cloudreve/cloudreve:{{ .Tag }}-armv7"
- name_template: "cloudreve/cloudreve:{{ .Tag }}"
image_templates:
- "cloudreve/cloudreve:{{ .Tag }}-amd64"
- "cloudreve/cloudreve:{{ .Tag }}-arm64"
- "cloudreve/cloudreve:{{ .Tag }}-armv6"
- "cloudreve/cloudreve:{{ .Tag }}-armv7"

View File

@@ -1,29 +0,0 @@
language: go
go:
- 1.18.x
node_js: "12.16.3"
git:
depth: 1
before_install:
- mkdir assets/build
- touch assets/build/test.html
script:
- go test -coverprofile=coverage.txt -covermode=atomic ./...
after_success:
- bash <(curl -s https://codecov.io/bash)
before_deploy:
- sudo apt-get update
- sudo apt-get -y install gcc-mingw-w64-x86-64
- sudo apt-get -y install gcc-arm-linux-gnueabihf libc6-dev-armhf-cross
- sudo apt-get -y install gcc-aarch64-linux-gnu libc6-dev-arm64-cross
- chmod +x ./build.sh
- ./build.sh -r b
deploy:
provider: releases
api_key: $GITHUB_TOKEN
file_glob: true
file: release/*
draft: true
skip_cleanup: true
on:
tags: true

View File

@@ -1,39 +1,7 @@
# the frontend builder
# cloudreve need node.js 16* to build frontend,
# separate build step and custom image tag will resolve this
FROM node:16-alpine as cloudreve_frontend_builder
RUN apk update \
&& apk add --no-cache wget curl git yarn zip bash \
&& git clone --recurse-submodules https://github.com/cloudreve/Cloudreve.git /cloudreve_frontend
# build frontend assets using build script, make sure all the steps just follow the regular release
WORKDIR /cloudreve_frontend
ENV GENERATE_SOURCEMAP false
RUN chmod +x ./build.sh && ./build.sh -a
# the backend builder
# cloudreve backend needs golang 1.18* to build
FROM golang:1.18-alpine as cloudreve_backend_builder
# install dependencies and build tools
RUN apk update \
# install dependencies and build tools
&& apk add --no-cache wget curl git build-base gcc abuild binutils binutils-doc gcc-doc zip bash \
&& git clone --recurse-submodules https://github.com/cloudreve/Cloudreve.git /cloudreve_backend
WORKDIR /cloudreve_backend
COPY --from=cloudreve_frontend_builder /cloudreve_frontend/assets.zip ./
RUN chmod +x ./build.sh && ./build.sh -c
# TODO: merge the frontend build and backend build into a single one image
# the final published image
FROM alpine:latest
WORKDIR /cloudreve
COPY --from=cloudreve_backend_builder /cloudreve_backend/cloudreve ./cloudreve
COPY cloudreve ./cloudreve
RUN apk update \
&& apk add --no-cache tzdata \

View File

@@ -50,7 +50,7 @@
* :card_file_box: Drag & Drop to manage your files.
* :family_woman_girl_boy: Multi-users with multi-groups.
* :link: Create share links for files and folders with expiration date.
* :eye_speech_bubble: Preview videos, images, audios, texts, Office documents, ePub files online.
* :eye_speech_bubble: Preview videos, images, audios, ePub files online; edit texts, Office documents online.
* :art: Customize theme colors, dark mode, PWA application, SPA, i18n.
* :rocket: All-In-One packing, with all features out-of-the-box.
* 🌈 ... ...
@@ -74,7 +74,13 @@ The above is a minimum deploy example, you can refer to [Getting started](https:
## :gear: Build
You need to have `Go >= 1.18`, `node.js`, `yarn`, `zip` and other necessary dependencies before you can build it yourself.
You need to have `Go >= 1.18`, `node.js`, `yarn`, `zip`, [goreleaser](https://goreleaser.com/intro/) and other necessary dependencies before you can build it yourself.
#### Install goreleaser
```shell
go install github.com/goreleaser/goreleaser@latest
```
#### Clone the code
@@ -82,42 +88,10 @@ You need to have `Go >= 1.18`, `node.js`, `yarn`, `zip` and other necessary depe
git clone --recurse-submodules https://github.com/cloudreve/Cloudreve.git
```
#### Build static resources
```shell
# Enter frontend sub-module
cd assets
# Install dependencies
yarn install
# Start building
yarn run build
# Delete unused map files
cd build
find . -name "*.map" -type f -delete
# Return to main folder to pack static files
cd ../../
zip -r - assets/build >assets.zip
```
#### Compile
```shell
# Obtain version number, commit SHA
export COMMIT_SHA=$(git rev-parse --short HEAD)
export VERSION=$(git describe --tags)
# Compile
go build -a -o cloudreve -ldflags "-s -w -X 'github.com/cloudreve/Cloudreve/v3/pkg/conf.BackendVersion=$VERSION' -X 'github.com/cloudreve/Cloudreve/v3/pkg/conf.LastCommit=$COMMIT_SHA'"
```
You can also start a quick build using `build.sh` in the project root directory:
```shell
./build.sh [-a] [-c] [-b] [-r]
a - Build assets
c - Build binary backend
b - Build both assets and backend
r - Cross-compilation for final release
goreleaser build --clean --single-target --snapshot
```
## :alembic: Stacks

View File

@@ -50,7 +50,7 @@
* :card_file_box: 文件拖拽管理
* :family_woman_girl_boy: 多用户、用户组、多存储策略
* :link: 创建文件、目录的分享链接,可设定自动过期
* :eye_speech_bubble: 视频、图像、音频、文本、Office 文档、 ePub 在线预览
* :eye_speech_bubble: 视频、图像、音频、 ePub 在线预览,文本、Office 文档在线编辑
* :art: 自定义配色、黑暗模式、PWA 应用、全站单页应用、国际化支持
* :rocket: All-In-One 打包,开箱即用
* 🌈 ... ...
@@ -74,7 +74,13 @@ chmod +x ./cloudreve
## :gear: 构建
自行构建前需要拥有 `Go >= 1.18``node.js``yarn``zip` 等必要依赖。
自行构建前需要拥有 `Go >= 1.18``node.js``yarn``zip`, [goreleaser](https://goreleaser.com/intro/) 等必要依赖。
#### 安装 goreleaser
```shell
go install github.com/goreleaser/goreleaser@latest
```
#### 克隆代码
@@ -82,42 +88,10 @@ chmod +x ./cloudreve
git clone --recurse-submodules https://github.com/cloudreve/Cloudreve.git
```
#### 构建静态资源
```shell
# 进入前端子模块
cd assets
# 安装依赖
yarn install
# 开始构建
yarn run build
# 构建完成后删除映射文件
cd build
find . -name "*.map" -type f -delete
# 返回项目主目录打包静态资源
cd ../../
zip -r - assets/build >assets.zip
```
#### 编译项目
```shell
# 获得当前版本号、Commit
export COMMIT_SHA=$(git rev-parse --short HEAD)
export VERSION=$(git describe --tags)
# 开始编译
go build -a -o cloudreve -ldflags "-s -w -X 'github.com/cloudreve/Cloudreve/v3/pkg/conf.BackendVersion=$VERSION' -X 'github.com/cloudreve/Cloudreve/v3/pkg/conf.LastCommit=$COMMIT_SHA'"
```
你也可以使用项目根目录下的 `build.sh` 快速开始构建:
```shell
./build.sh [-a] [-c] [-b] [-r]
a - 构建静态资源
c - 编译二进制文件
b - 构建前端 + 编译二进制文件
r - 交叉编译构建用于release的版本
goreleaser build --clean --single-target --snapshot
```
## :alembic: 技术栈

2
assets

Submodule assets updated: 01343d7656...4815a504a0

View File

@@ -12,6 +12,7 @@ import (
"github.com/cloudreve/Cloudreve/v3/pkg/email"
"github.com/cloudreve/Cloudreve/v3/pkg/mq"
"github.com/cloudreve/Cloudreve/v3/pkg/task"
"github.com/cloudreve/Cloudreve/v3/pkg/wopi"
"github.com/gin-gonic/gin"
"io/fs"
)
@@ -95,6 +96,12 @@ func Init(path string, statics fs.FS) {
auth.Init()
},
},
{
"master",
func() {
wopi.Init()
},
},
}
for _, dependency := range dependencies {

136
build.sh
View File

@@ -1,136 +0,0 @@
#!/bin/bash
REPO=$(
cd $(dirname $0)
pwd
)
COMMIT_SHA=$(git rev-parse --short HEAD)
VERSION=$(git describe --tags)
ASSETS="false"
BINARY="false"
RELEASE="false"
debugInfo() {
echo "Repo: $REPO"
echo "Build assets: $ASSETS"
echo "Build binary: $BINARY"
echo "Release: $RELEASE"
echo "Version: $VERSION"
echo "Commit: $COMMIT_SHA"
}
buildAssets() {
cd $REPO
rm -rf assets/build
export CI=false
export GENERATE_SOURCEMAP=false
cd $REPO/assets
yarn install --network-timeout 1000000
yarn install
yarn run build
cd build
cd $REPO
# please keep in mind that if this final output binary `assets.zip` name changed, please go and update the `Dockerfile` as well
zip -r - assets/build >assets.zip
}
buildBinary() {
cd $REPO
# same as assets, if this final output binary `cloudreve` name changed, please go and update the `Dockerfile`
go build -a -o cloudreve -ldflags " -X 'github.com/cloudreve/Cloudreve/v3/pkg/conf.BackendVersion=$VERSION' -X 'github.com/cloudreve/Cloudreve/v3/pkg/conf.LastCommit=$COMMIT_SHA'"
}
_build() {
local osarch=$1
IFS=/ read -r -a arr <<<"$osarch"
os="${arr[0]}"
arch="${arr[1]}"
gcc="${arr[2]}"
# Go build to build the binary.
export GOOS=$os
export GOARCH=$arch
export CC=$gcc
export CGO_ENABLED=1
if [ -n "$VERSION" ]; then
out="release/cloudreve_${VERSION}_${os}_${arch}"
else
out="release/cloudreve_${COMMIT_SHA}_${os}_${arch}"
fi
go build -a -o "${out}" -ldflags " -X 'github.com/cloudreve/Cloudreve/v3/pkg/conf.BackendVersion=$VERSION' -X 'github.com/cloudreve/Cloudreve/v3/pkg/conf.LastCommit=$COMMIT_SHA'"
if [ "$os" = "windows" ]; then
mv $out release/cloudreve.exe
zip -j -q "${out}.zip" release/cloudreve.exe
rm -f "release/cloudreve.exe"
else
mv $out release/cloudreve
tar -zcvf "${out}.tar.gz" -C release cloudreve
rm -f "release/cloudreve"
fi
}
release() {
cd $REPO
## List of architectures and OS to test coss compilation.
SUPPORTED_OSARCH="linux/amd64/gcc linux/arm/arm-linux-gnueabihf-gcc windows/amd64/x86_64-w64-mingw32-gcc linux/arm64/aarch64-linux-gnu-gcc"
echo "Release builds for OS/Arch/CC: ${SUPPORTED_OSARCH}"
for each_osarch in ${SUPPORTED_OSARCH}; do
_build "${each_osarch}"
done
}
usage() {
echo "Usage: $0 [-a] [-c] [-b] [-r]" 1>&2
exit 1
}
while getopts "bacrd" o; do
case "${o}" in
b)
ASSETS="true"
BINARY="true"
;;
a)
ASSETS="true"
;;
c)
BINARY="true"
;;
r)
ASSETS="true"
RELEASE="true"
;;
d)
DEBUG="true"
;;
*)
usage
;;
esac
done
shift $((OPTIND - 1))
if [ "$DEBUG" = "true" ]; then
debugInfo
fi
if [ "$ASSETS" = "true" ]; then
buildAssets
fi
if [ "$BINARY" = "true" ]; then
buildBinary
fi
if [ "$RELEASE" = "true" ]; then
release
fi

15
go.mod
View File

@@ -13,6 +13,7 @@ require (
github.com/gin-contrib/sessions v0.0.5
github.com/gin-contrib/static v0.0.0-20191128031702-f81c604d8ac2
github.com/gin-gonic/gin v1.8.1
github.com/glebarez/go-sqlite v1.20.3
github.com/go-ini/ini v1.50.0
github.com/go-mail/mail v2.3.1+incompatible
github.com/go-playground/validator/v10 v10.11.0
@@ -59,7 +60,7 @@ require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/denisenkom/go-mssqldb v0.0.0-20190515213511-eb9f6a1743f3 // indirect
github.com/dsnet/compress v0.0.1 // indirect
github.com/dustin/go-humanize v1.0.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d // indirect
github.com/envoyproxy/protoc-gen-validate v0.6.1 // indirect
github.com/form3tech-oss/jwt-go v3.2.3+incompatible // indirect
@@ -79,7 +80,7 @@ require (
github.com/golang/snappy v0.0.4 // indirect
github.com/google/btree v1.0.1 // indirect
github.com/google/certificate-transparency-go v1.1.2-0.20210511102531-373a877eec92 // indirect
github.com/google/go-cmp v0.5.5 // indirect
github.com/google/go-cmp v0.5.9 // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/gorilla/context v1.1.1 // indirect
github.com/gorilla/securecookie v1.1.1 // indirect
@@ -98,9 +99,8 @@ require (
github.com/leodido/go-urn v1.2.1 // indirect
github.com/lib/pq v1.10.3 // indirect
github.com/mattn/go-colorable v0.1.4 // indirect
github.com/mattn/go-isatty v0.0.14 // indirect
github.com/mattn/go-isatty v0.0.17 // indirect
github.com/mattn/go-runewidth v0.0.12 // indirect
github.com/mattn/go-sqlite3 v2.0.3+incompatible // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect
github.com/mitchellh/mapstructure v1.1.2 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
@@ -116,6 +116,7 @@ require (
github.com/prometheus/common v0.24.0 // indirect
github.com/prometheus/procfs v0.6.0 // indirect
github.com/quasoft/memstore v0.0.0-20191010062613-2bce066d2b0b // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230126093431-47fa9a501578 // indirect
github.com/rivo/uniseg v0.2.0 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/satori/go.uuid v1.2.0 // indirect
@@ -149,7 +150,7 @@ require (
golang.org/x/net v0.0.0-20220630215102-69896b714898 // indirect
golang.org/x/oauth2 v0.0.0-20210427180440-81ed05c6b58c // indirect
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c // indirect
golang.org/x/sys v0.0.0-20220702020025-31831981b65f // indirect
golang.org/x/sys v0.4.0 // indirect
golang.org/x/text v0.3.7 // indirect
golang.org/x/tools v0.1.0 // indirect
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
@@ -162,6 +163,10 @@ require (
gopkg.in/mail.v2 v2.3.1 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.22.2 // indirect
modernc.org/mathutil v1.5.0 // indirect
modernc.org/memory v1.5.0 // indirect
modernc.org/sqlite v1.20.3 // indirect
sigs.k8s.io/yaml v1.2.0 // indirect
)

30
go.sum
View File

@@ -211,8 +211,9 @@ github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdf
github.com/duo-labs/webauthn v0.0.0-20220330035159-03696f3d4499 h1:jaQHuGKk9NVcfu9VbA7ygslr/7utxdYs47i4osBhZP8=
github.com/duo-labs/webauthn v0.0.0-20220330035159-03696f3d4499/go.mod h1:UMk1JMDgQDcdI2vQz+WJOIUTSjIq07qSepAVgc93rUc=
github.com/dustin/go-humanize v0.0.0-20171111073723-bb3d318650d4/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo=
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/eapache/go-resiliency v1.1.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5mFgVsvEsIPBvNs=
github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1:+020luEh2TKB4/GOp8oxxtq0Daoen/Cii55CzbTV6DU=
github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I=
@@ -269,6 +270,8 @@ github.com/gin-gonic/gin v1.4.0/go.mod h1:OW2EZn3DO8Ln9oIKOvM++LBO+5UPHJJDH72/q/
github.com/gin-gonic/gin v1.5.0/go.mod h1:Nd6IXA8m5kNZdNEHMBd93KT+mdY3+bewLgRvmCsR2Do=
github.com/gin-gonic/gin v1.8.1 h1:4+fr/el88TOO3ewCmQr8cx/CtZ/umlIRIs5M4NTNjf8=
github.com/gin-gonic/gin v1.8.1/go.mod h1:ji8BvRH1azfM+SYow9zQ6SZMvR8qOMZHmsCuWR9tTTk=
github.com/glebarez/go-sqlite v1.20.3 h1:89BkqGOXR9oRmG58ZrzgoY/Fhy5x0M+/WV48U5zVrZ4=
github.com/glebarez/go-sqlite v1.20.3/go.mod h1:u3N6D/wftiAzIOJtZl6BmedqxmmkDfH3q+ihjqxC9u0=
github.com/gliderlabs/ssh v0.2.2/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aevW3Awn0=
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
@@ -381,8 +384,9 @@ github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-github/v28 v28.1.1/go.mod h1:bsqJWQX05omyWVmc00nEUql9mhQyv38lDZ8kPZcQVoM=
github.com/google/go-licenses v0.0.0-20210329231322-ce1d9163b77d/go.mod h1:+TYOmkVoJOpwnS0wfdsJCV9CoD5nJYsHoFk/0CrTK4M=
github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk=
@@ -406,6 +410,7 @@ github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLe
github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20210122040257-d980be63207e/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20210226084205-cbba55b83ad5/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/google/rpmpack v0.0.0-20191226140753-aa36bfddb3a0/go.mod h1:RaTPr0KUf2K7fnZYLNDrr8rxAamWs3iNywJLtQ2AzBg=
github.com/google/subcommands v1.0.1/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
@@ -590,8 +595,8 @@ github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hd
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ=
github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE=
github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
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-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
github.com/mattn/go-runewidth v0.0.7/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
@@ -602,7 +607,6 @@ github.com/mattn/go-sqlite3 v1.11.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsO
github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
github.com/mattn/go-sqlite3 v1.14.7/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
github.com/mattn/go-sqlite3 v2.0.3+incompatible h1:gXHsfypPkaMZrKbD5209QV9jbUTJKjyR5WD3HYQSd+U=
github.com/mattn/go-sqlite3 v2.0.3+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
github.com/mattn/go-zglob v0.0.1/go.mod h1:9fxibJccNxU2cnpIKLRRFA7zX7qhkJIQWBb449FYHOo=
github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
@@ -751,6 +755,9 @@ github.com/quasoft/memstore v0.0.0-20191010062613-2bce066d2b0b/go.mod h1:wTPjTep
github.com/rafaeljusto/redigomock v0.0.0-20191117212112-00b2509252a1 h1:leEwA4MD1ew0lNgzz6Q4G76G3AEfeci+TMggN6WuFRs=
github.com/rafaeljusto/redigomock v0.0.0-20191117212112-00b2509252a1/go.mod h1:JaY6n2sDr+z2WTsXkOmNRUfDy6FN0L6Nk7x06ndm4tY=
github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/remyoudompheng/bigfft v0.0.0-20230126093431-47fa9a501578 h1:VstopitMQi3hZP0fzvnsLmzXZdQGc4bEcgu24cp+d4M=
github.com/remyoudompheng/bigfft v0.0.0-20230126093431-47fa9a501578/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
@@ -1160,8 +1167,9 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211020174200-9d6173849985/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220702020025-31831981b65f h1:xdsejrW/0Wf2diT5CPp3XmKUNbr7Xvw8kYilQ+6qjRY=
golang.org/x/sys v0.0.0-20220702020025-31831981b65f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/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-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -1434,6 +1442,14 @@ honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9
honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
honnef.co/go/tools v0.1.4 h1:SadWOkti5uVN1FAMgxn165+Mw00fuQKyk4Gyn/inxNQ=
honnef.co/go/tools v0.1.4/go.mod h1:NgwopIslSNH47DimFoV78dnkksY2EFtX0ajyb3K/las=
modernc.org/libc v1.22.2 h1:4U7v51GyhlWqQmwCHj28Rdq2Yzwk55ovjFrdPjs8Hb0=
modernc.org/libc v1.22.2/go.mod h1:uvQavJ1pZ0hIoC/jfqNoMLURIMhKzINIWypNM17puug=
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.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
modernc.org/sqlite v1.20.3 h1:SqGJMMxjj1PHusLxdYxeQSodg7Jxn9WWkaAQjKrntZs=
modernc.org/sqlite v1.20.3/go.mod h1:zKcGyrICaxNTMEHSr1HQ2GUraP0j+845GYw37+EyT6A=
pack.ag/amqp v0.11.2/go.mod h1:4/cbmt4EJXSKlG6LCfWHoqmN0uFdy5i/+YFz+fTfhV4=
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=

70
middleware/wopi.go Normal file
View File

@@ -0,0 +1,70 @@
package middleware
import (
model "github.com/cloudreve/Cloudreve/v3/models"
"github.com/cloudreve/Cloudreve/v3/pkg/cache"
"github.com/cloudreve/Cloudreve/v3/pkg/wopi"
"github.com/gin-gonic/gin"
"net/http"
"strings"
)
const (
WopiSessionCtx = "wopi_session"
)
// WopiWriteAccess validates if write access is obtained.
func WopiWriteAccess() gin.HandlerFunc {
return func(c *gin.Context) {
session := c.MustGet(WopiSessionCtx).(*wopi.SessionCache)
if session.Action != wopi.ActionEdit {
c.Status(http.StatusNotFound)
c.Header(wopi.ServerErrorHeader, "read-only access")
c.Abort()
return
}
c.Next()
}
}
func WopiAccessValidation(w wopi.Client, store cache.Driver) gin.HandlerFunc {
return func(c *gin.Context) {
accessToken := strings.Split(c.Query(wopi.AccessTokenQuery), ".")
if len(accessToken) != 2 {
c.Status(http.StatusForbidden)
c.Header(wopi.ServerErrorHeader, "malformed access token")
c.Abort()
return
}
sessionRaw, exist := store.Get(wopi.SessionCachePrefix + accessToken[0])
if !exist {
c.Status(http.StatusForbidden)
c.Header(wopi.ServerErrorHeader, "invalid access token")
c.Abort()
return
}
session := sessionRaw.(wopi.SessionCache)
user, err := model.GetActiveUserByID(session.UserID)
if err != nil {
c.Status(http.StatusInternalServerError)
c.Header(wopi.ServerErrorHeader, "user not found")
c.Abort()
return
}
fileID := c.MustGet("object_id").(uint)
if fileID != session.FileID {
c.Status(http.StatusInternalServerError)
c.Header(wopi.ServerErrorHeader, "file not found")
c.Abort()
return
}
c.Set("user", &user)
c.Set(WopiSessionCtx, &session)
c.Next()
}
}

112
middleware/wopi_test.go Normal file
View File

@@ -0,0 +1,112 @@
package middleware
import (
"errors"
"github.com/DATA-DOG/go-sqlmock"
"github.com/cloudreve/Cloudreve/v3/pkg/cache"
"github.com/cloudreve/Cloudreve/v3/pkg/mocks/wopimock"
"github.com/cloudreve/Cloudreve/v3/pkg/wopi"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"net/http/httptest"
"testing"
)
func TestWopiWriteAccess(t *testing.T) {
asserts := assert.New(t)
rec := httptest.NewRecorder()
testFunc := WopiWriteAccess()
// deny preview only session
{
c, _ := gin.CreateTestContext(rec)
c.Set(WopiSessionCtx, &wopi.SessionCache{Action: wopi.ActionPreview})
testFunc(c)
asserts.True(c.IsAborted())
}
// pass
{
c, _ := gin.CreateTestContext(rec)
c.Set(WopiSessionCtx, &wopi.SessionCache{Action: wopi.ActionEdit})
testFunc(c)
asserts.False(c.IsAborted())
}
}
func TestWopiAccessValidation(t *testing.T) {
asserts := assert.New(t)
rec := httptest.NewRecorder()
mockWopi := &wopimock.WopiClientMock{}
mockCache := cache.NewMemoStore()
testFunc := WopiAccessValidation(mockWopi, mockCache)
// malformed access token
{
c, _ := gin.CreateTestContext(rec)
c.AddParam(wopi.AccessTokenQuery, "000")
testFunc(c)
asserts.True(c.IsAborted())
}
// session key not exist
{
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest("GET", "/wopi/files/1?access_token=", nil)
query := c.Request.URL.Query()
query.Set(wopi.AccessTokenQuery, "sessionID.key")
c.Request.URL.RawQuery = query.Encode()
testFunc(c)
asserts.True(c.IsAborted())
}
// user key not exist
{
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest("GET", "/wopi/files/1?access_token=", nil)
query := c.Request.URL.Query()
query.Set(wopi.AccessTokenQuery, "sessionID.key")
c.Request.URL.RawQuery = query.Encode()
mockCache.Set(wopi.SessionCachePrefix+"sessionID", wopi.SessionCache{UserID: 1, FileID: 1}, 0)
mock.ExpectQuery("SELECT(.+)users(.+)").WillReturnError(errors.New("error"))
testFunc(c)
asserts.True(c.IsAborted())
asserts.NoError(mock.ExpectationsWereMet())
}
// file not found
{
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest("GET", "/wopi/files/1?access_token=", nil)
query := c.Request.URL.Query()
query.Set(wopi.AccessTokenQuery, "sessionID.key")
c.Request.URL.RawQuery = query.Encode()
mockCache.Set(wopi.SessionCachePrefix+"sessionID", wopi.SessionCache{UserID: 1, FileID: 1}, 0)
mock.ExpectQuery("SELECT(.+)users(.+)").WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(1))
c.Set("object_id", uint(0))
testFunc(c)
asserts.True(c.IsAborted())
asserts.NoError(mock.ExpectationsWereMet())
}
// all pass
{
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest("GET", "/wopi/files/1?access_token=", nil)
query := c.Request.URL.Query()
query.Set(wopi.AccessTokenQuery, "sessionID.key")
c.Request.URL.RawQuery = query.Encode()
mockCache.Set(wopi.SessionCachePrefix+"sessionID", wopi.SessionCache{UserID: 1, FileID: 1}, 0)
mock.ExpectQuery("SELECT(.+)users(.+)").WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(1))
c.Set("object_id", uint(1))
testFunc(c)
asserts.False(c.IsAborted())
asserts.NoError(mock.ExpectationsWereMet())
asserts.NotPanics(func() {
c.MustGet(WopiSessionCtx)
})
asserts.NotPanics(func() {
c.MustGet("user")
})
}
}

View File

@@ -25,7 +25,7 @@ var defaultSettings = []Setting{
{Name: "smtpUser", Value: `no-reply@acg.blue`, Type: "mail"},
{Name: "smtpPass", Value: ``, Type: "mail"},
{Name: "smtpEncryption", Value: `0`, Type: "mail"},
{Name: "maxEditSize", Value: `4194304`, Type: "file_edit"},
{Name: "maxEditSize", Value: `52428800`, Type: "file_edit"},
{Name: "archive_timeout", Value: `600`, Type: "timeout"},
{Name: "download_timeout", Value: `600`, Type: "timeout"},
{Name: "preview_timeout", Value: `600`, Type: "timeout"},
@@ -115,4 +115,8 @@ Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; verti
{Name: "office_preview_service", Value: "https://view.officeapps.live.com/op/view.aspx?src={$src}", Type: "preview"},
{Name: "show_app_promotion", Value: "1", Type: "mobile"},
{Name: "public_resource_maxage", Value: "86400", Type: "timeout"},
{Name: "wopi_enabled", Value: "0", Type: "wopi"},
{Name: "wopi_endpoint", Value: "", Type: "wopi"},
{Name: "wopi_max_size", Value: "52428800", Type: "wopi"},
{Name: "wopi_session_timeout", Value: "36000", Type: "wopi"},
}

View File

@@ -0,0 +1,288 @@
package dialects
import (
"fmt"
"reflect"
"regexp"
"strconv"
"strings"
"time"
"github.com/jinzhu/gorm"
)
var keyNameRegex = regexp.MustCompile("[^a-zA-Z0-9]+")
// DefaultForeignKeyNamer contains the default foreign key name generator method
type DefaultForeignKeyNamer struct {
}
type commonDialect struct {
db gorm.SQLCommon
DefaultForeignKeyNamer
}
func (commonDialect) GetName() string {
return "common"
}
func (s *commonDialect) SetDB(db gorm.SQLCommon) {
s.db = db
}
func (commonDialect) BindVar(i int) string {
return "$$$" // ?
}
func (commonDialect) Quote(key string) string {
return fmt.Sprintf(`"%s"`, key)
}
func (s *commonDialect) fieldCanAutoIncrement(field *gorm.StructField) bool {
if value, ok := field.TagSettingsGet("AUTO_INCREMENT"); ok {
return strings.ToLower(value) != "false"
}
return field.IsPrimaryKey
}
func (s *commonDialect) DataTypeOf(field *gorm.StructField) string {
var dataValue, sqlType, size, additionalType = gorm.ParseFieldStructForDialect(field, s)
if sqlType == "" {
switch dataValue.Kind() {
case reflect.Bool:
sqlType = "BOOLEAN"
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uintptr:
if s.fieldCanAutoIncrement(field) {
sqlType = "INTEGER AUTO_INCREMENT"
} else {
sqlType = "INTEGER"
}
case reflect.Int64, reflect.Uint64:
if s.fieldCanAutoIncrement(field) {
sqlType = "BIGINT AUTO_INCREMENT"
} else {
sqlType = "BIGINT"
}
case reflect.Float32, reflect.Float64:
sqlType = "FLOAT"
case reflect.String:
if size > 0 && size < 65532 {
sqlType = fmt.Sprintf("VARCHAR(%d)", size)
} else {
sqlType = "VARCHAR(65532)"
}
case reflect.Struct:
if _, ok := dataValue.Interface().(time.Time); ok {
sqlType = "TIMESTAMP"
}
default:
if _, ok := dataValue.Interface().([]byte); ok {
if size > 0 && size < 65532 {
sqlType = fmt.Sprintf("BINARY(%d)", size)
} else {
sqlType = "BINARY(65532)"
}
}
}
}
if sqlType == "" {
panic(fmt.Sprintf("invalid sql type %s (%s) for commonDialect", dataValue.Type().Name(), dataValue.Kind().String()))
}
if strings.TrimSpace(additionalType) == "" {
return sqlType
}
return fmt.Sprintf("%v %v", sqlType, additionalType)
}
func currentDatabaseAndTable(dialect gorm.Dialect, tableName string) (string, string) {
if strings.Contains(tableName, ".") {
splitStrings := strings.SplitN(tableName, ".", 2)
return splitStrings[0], splitStrings[1]
}
return dialect.CurrentDatabase(), tableName
}
func (s commonDialect) HasIndex(tableName string, indexName string) bool {
var count int
currentDatabase, tableName := currentDatabaseAndTable(&s, tableName)
s.db.QueryRow("SELECT count(*) FROM INFORMATION_SCHEMA.STATISTICS WHERE table_schema = ? AND table_name = ? AND index_name = ?", currentDatabase, tableName, indexName).Scan(&count)
return count > 0
}
func (s commonDialect) RemoveIndex(tableName string, indexName string) error {
_, err := s.db.Exec(fmt.Sprintf("DROP INDEX %v", indexName))
return err
}
func (s commonDialect) HasForeignKey(tableName string, foreignKeyName string) bool {
return false
}
func (s commonDialect) HasTable(tableName string) bool {
var count int
currentDatabase, tableName := currentDatabaseAndTable(&s, tableName)
s.db.QueryRow("SELECT count(*) FROM INFORMATION_SCHEMA.TABLES WHERE table_schema = ? AND table_name = ?", currentDatabase, tableName).Scan(&count)
return count > 0
}
func (s commonDialect) HasColumn(tableName string, columnName string) bool {
var count int
currentDatabase, tableName := currentDatabaseAndTable(&s, tableName)
s.db.QueryRow("SELECT count(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE table_schema = ? AND table_name = ? AND column_name = ?", currentDatabase, tableName, columnName).Scan(&count)
return count > 0
}
func (s commonDialect) ModifyColumn(tableName string, columnName string, typ string) error {
_, err := s.db.Exec(fmt.Sprintf("ALTER TABLE %v ALTER COLUMN %v TYPE %v", tableName, columnName, typ))
return err
}
func (s commonDialect) CurrentDatabase() (name string) {
s.db.QueryRow("SELECT DATABASE()").Scan(&name)
return
}
func (commonDialect) LimitAndOffsetSQL(limit, offset interface{}) (sql string) {
if limit != nil {
if parsedLimit, err := strconv.ParseInt(fmt.Sprint(limit), 0, 0); err == nil && parsedLimit >= 0 {
sql += fmt.Sprintf(" LIMIT %d", parsedLimit)
}
}
if offset != nil {
if parsedOffset, err := strconv.ParseInt(fmt.Sprint(offset), 0, 0); err == nil && parsedOffset >= 0 {
sql += fmt.Sprintf(" OFFSET %d", parsedOffset)
}
}
return
}
func (commonDialect) SelectFromDummyTable() string {
return ""
}
func (commonDialect) LastInsertIDReturningSuffix(tableName, columnName string) string {
return ""
}
func (commonDialect) DefaultValueStr() string {
return "DEFAULT VALUES"
}
// BuildKeyName returns a valid key name (foreign key, index key) for the given table, field and reference
func (DefaultForeignKeyNamer) BuildKeyName(kind, tableName string, fields ...string) string {
keyName := fmt.Sprintf("%s_%s_%s", kind, tableName, strings.Join(fields, "_"))
keyName = keyNameRegex.ReplaceAllString(keyName, "_")
return keyName
}
// NormalizeIndexAndColumn returns argument's index name and column name without doing anything
func (commonDialect) NormalizeIndexAndColumn(indexName, columnName string) (string, string) {
return indexName, columnName
}
// IsByteArrayOrSlice returns true of the reflected value is an array or slice
func IsByteArrayOrSlice(value reflect.Value) bool {
return (value.Kind() == reflect.Array || value.Kind() == reflect.Slice) && value.Type().Elem() == reflect.TypeOf(uint8(0))
}
type sqlite struct {
commonDialect
}
func init() {
gorm.RegisterDialect("sqlite", &sqlite{})
}
func (sqlite) GetName() string {
return "sqlite"
}
// Get Data Type for Sqlite Dialect
func (s *sqlite) DataTypeOf(field *gorm.StructField) string {
var dataValue, sqlType, size, additionalType = gorm.ParseFieldStructForDialect(field, s)
if sqlType == "" {
switch dataValue.Kind() {
case reflect.Bool:
sqlType = "bool"
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uintptr:
if s.fieldCanAutoIncrement(field) {
field.TagSettingsSet("AUTO_INCREMENT", "AUTO_INCREMENT")
sqlType = "integer primary key autoincrement"
} else {
sqlType = "integer"
}
case reflect.Int64, reflect.Uint64:
if s.fieldCanAutoIncrement(field) {
field.TagSettingsSet("AUTO_INCREMENT", "AUTO_INCREMENT")
sqlType = "integer primary key autoincrement"
} else {
sqlType = "bigint"
}
case reflect.Float32, reflect.Float64:
sqlType = "real"
case reflect.String:
if size > 0 && size < 65532 {
sqlType = fmt.Sprintf("varchar(%d)", size)
} else {
sqlType = "text"
}
case reflect.Struct:
if _, ok := dataValue.Interface().(time.Time); ok {
sqlType = "datetime"
}
default:
if IsByteArrayOrSlice(dataValue) {
sqlType = "blob"
}
}
}
if sqlType == "" {
panic(fmt.Sprintf("invalid sql type %s (%s) for sqlite", dataValue.Type().Name(), dataValue.Kind().String()))
}
if strings.TrimSpace(additionalType) == "" {
return sqlType
}
return fmt.Sprintf("%v %v", sqlType, additionalType)
}
func (s sqlite) HasIndex(tableName string, indexName string) bool {
var count int
s.db.QueryRow(fmt.Sprintf("SELECT count(*) FROM sqlite_master WHERE tbl_name = ? AND sql LIKE '%%INDEX %v ON%%'", indexName), tableName).Scan(&count)
return count > 0
}
func (s sqlite) HasTable(tableName string) bool {
var count int
s.db.QueryRow("SELECT count(*) FROM sqlite_master WHERE type='table' AND name=?", tableName).Scan(&count)
return count > 0
}
func (s sqlite) HasColumn(tableName string, columnName string) bool {
var count int
s.db.QueryRow(fmt.Sprintf("SELECT count(*) FROM sqlite_master WHERE tbl_name = ? AND (sql LIKE '%%\"%v\" %%' OR sql LIKE '%%%v %%');\n", columnName, columnName), tableName).Scan(&count)
return count > 0
}
func (s sqlite) CurrentDatabase() (name string) {
var (
ifaces = make([]interface{}, 3)
pointers = make([]*string, 3)
i int
)
for i = 0; i < 3; i++ {
ifaces[i] = &pointers[i]
}
if err := s.db.QueryRow("PRAGMA database_list").Scan(ifaces...); err != nil {
return
}
if pointers[1] != nil {
name = *pointers[1]
}
return
}

View File

@@ -234,7 +234,7 @@ func DeleteFiles(files []*File, uid uint) error {
user.ID = uid
var size uint64
for _, file := range files {
if file.UserID != uid {
if uid > 0 && file.UserID != uid {
tx.Rollback()
return errors.New("user id not consistent")
}
@@ -253,9 +253,11 @@ func DeleteFiles(files []*File, uid uint) error {
size += file.Size
}
if err := user.ChangeStorage(tx, "-", size); err != nil {
tx.Rollback()
return err
if uid > 0 {
if err := user.ChangeStorage(tx, "-", size); err != nil {
tx.Rollback()
return err
}
}
return tx.Commit().Error

View File

@@ -365,7 +365,7 @@ func TestDeleteFiles(t *testing.T) {
// uid 不一致
{
err := DeleteFiles([]*File{{}}, 1)
err := DeleteFiles([]*File{{UserID: 2}}, 1)
a.Contains("user id not consistent", err.Error())
}
@@ -375,7 +375,7 @@ func TestDeleteFiles(t *testing.T) {
mock.ExpectExec("DELETE(.+)").
WillReturnError(errors.New("error"))
mock.ExpectRollback()
err := DeleteFiles([]*File{{}}, 0)
err := DeleteFiles([]*File{{UserID: 1}}, 1)
a.NoError(mock.ExpectationsWereMet())
a.Error(err)
}
@@ -387,7 +387,7 @@ func TestDeleteFiles(t *testing.T) {
WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectExec("UPDATE(.+)storage(.+)").WillReturnError(errors.New("error"))
mock.ExpectRollback()
err := DeleteFiles([]*File{{}}, 0)
err := DeleteFiles([]*File{{UserID: 1}}, 1)
a.NoError(mock.ExpectationsWereMet())
a.Error(err)
}
@@ -398,7 +398,7 @@ func TestDeleteFiles(t *testing.T) {
mock.ExpectExec("DELETE(.+)").
WillReturnResult(sqlmock.NewResult(1, 0))
mock.ExpectRollback()
err := DeleteFiles([]*File{{Size: 1}, {Size: 2}}, 0)
err := DeleteFiles([]*File{{Size: 1, UserID: 1}, {Size: 2, UserID: 1}}, 1)
a.NoError(mock.ExpectationsWereMet())
a.Error(err)
a.Contains("file size is dirty", err.Error())
@@ -411,9 +411,22 @@ func TestDeleteFiles(t *testing.T) {
WillReturnResult(sqlmock.NewResult(2, 1))
mock.ExpectExec("DELETE(.+)").
WillReturnResult(sqlmock.NewResult(2, 1))
mock.ExpectExec("UPDATE(.+)storage(.+)").WithArgs(uint64(3), sqlmock.AnyArg()).WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectExec("UPDATE(.+)storage(.+)").WithArgs(uint64(3), sqlmock.AnyArg(), uint(1)).WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectCommit()
err := DeleteFiles([]*File{{Size: 1}, {Size: 2}}, 0)
err := DeleteFiles([]*File{{Size: 1, UserID: 1}, {Size: 2, UserID: 1}}, 1)
a.NoError(mock.ExpectationsWereMet())
a.NoError(err)
}
// 成功, 关联用户不存在
{
mock.ExpectBegin()
mock.ExpectExec("DELETE(.+)").
WillReturnResult(sqlmock.NewResult(2, 1))
mock.ExpectExec("DELETE(.+)").
WillReturnResult(sqlmock.NewResult(2, 1))
mock.ExpectCommit()
err := DeleteFiles([]*File{{Size: 1, UserID: 1}, {Size: 2, UserID: 1}}, 0)
a.NoError(mock.ExpectationsWereMet())
a.NoError(err)
}

View File

@@ -106,7 +106,7 @@ func TestFolder_GetChildFolder(t *testing.T) {
}
func TestGetRecursiveChildFolderSQLite(t *testing.T) {
conf.DatabaseConfig.Type = "sqlite3"
conf.DatabaseConfig.Type = "sqlite"
asserts := assert.New(t)
// 测试目录结构

View File

@@ -34,6 +34,7 @@ type GroupOption struct {
SourceBatchSize int `json:"source_batch,omitempty"`
RedirectedSource bool `json:"redirected_source,omitempty"`
Aria2BatchSize int `json:"aria2_batch,omitempty"`
AdvanceDelete bool `json:"advance_delete,omitempty"`
}
// GetGroupByID 用ID获取用户组

View File

@@ -9,10 +9,11 @@ import (
"github.com/gin-gonic/gin"
"github.com/jinzhu/gorm"
_ "github.com/cloudreve/Cloudreve/v3/models/dialects"
_ "github.com/glebarez/go-sqlite"
_ "github.com/jinzhu/gorm/dialects/mssql"
_ "github.com/jinzhu/gorm/dialects/mysql"
_ "github.com/jinzhu/gorm/dialects/postgres"
_ "github.com/jinzhu/gorm/dialects/sqlite"
)
// DB 数据库链接单例
@@ -23,35 +24,50 @@ func Init() {
util.Log().Info("Initializing database connection...")
var (
db *gorm.DB
err error
db *gorm.DB
err error
confDBType string = conf.DatabaseConfig.Type
)
// 兼容已有配置中的 "sqlite3" 配置项
if confDBType == "sqlite3" {
confDBType = "sqlite"
}
if gin.Mode() == gin.TestMode {
// 测试模式下,使用内存数据库
db, err = gorm.Open("sqlite3", ":memory:")
db, err = gorm.Open("sqlite", ":memory:")
} else {
switch conf.DatabaseConfig.Type {
case "UNSET", "sqlite", "sqlite3":
// 未指定数据库或者明确指定为 sqlite 时,使用 SQLite3 数据库
db, err = gorm.Open("sqlite3", util.RelativePath(conf.DatabaseConfig.DBFile))
switch confDBType {
case "UNSET", "sqlite":
// 未指定数据库或者明确指定为 sqlite 时,使用 SQLite 数据库
db, err = gorm.Open("sqlite", util.RelativePath(conf.DatabaseConfig.DBFile))
case "postgres":
db, err = gorm.Open(conf.DatabaseConfig.Type, fmt.Sprintf("host=%s user=%s password=%s dbname=%s port=%d sslmode=disable",
db, err = gorm.Open(confDBType, fmt.Sprintf("host=%s user=%s password=%s dbname=%s port=%d sslmode=disable",
conf.DatabaseConfig.Host,
conf.DatabaseConfig.User,
conf.DatabaseConfig.Password,
conf.DatabaseConfig.Name,
conf.DatabaseConfig.Port))
case "mysql", "mssql":
db, err = gorm.Open(conf.DatabaseConfig.Type, fmt.Sprintf("%s:%s@(%s:%d)/%s?charset=%s&parseTime=True&loc=Local",
var host string
if conf.DatabaseConfig.UnixSocket {
host = fmt.Sprintf("unix(%s)",
conf.DatabaseConfig.Host)
} else {
host = fmt.Sprintf("(%s:%d)",
conf.DatabaseConfig.Host,
conf.DatabaseConfig.Port)
}
db, err = gorm.Open(confDBType, fmt.Sprintf("%s:%s@%s/%s?charset=%s&parseTime=True&loc=Local",
conf.DatabaseConfig.User,
conf.DatabaseConfig.Password,
conf.DatabaseConfig.Host,
conf.DatabaseConfig.Port,
host,
conf.DatabaseConfig.Name,
conf.DatabaseConfig.Charset))
default:
util.Log().Panic("Unsupported database type %q.", conf.DatabaseConfig.Type)
util.Log().Panic("Unsupported database type %q.", confDBType)
}
}
@@ -74,7 +90,7 @@ func Init() {
//设置连接池
db.DB().SetMaxIdleConns(50)
if conf.DatabaseConfig.Type == "sqlite" || conf.DatabaseConfig.Type == "sqlite3" || conf.DatabaseConfig.Type == "UNSET" {
if confDBType == "sqlite" || confDBType == "UNSET" {
db.DB().SetMaxOpenConns(1)
} else {
db.DB().SetMaxOpenConns(100)

View File

@@ -111,6 +111,7 @@ func addDefaultGroups() {
SourceBatchSize: 1000,
Aria2BatchSize: 50,
RedirectedSource: true,
AdvanceDelete: true,
},
}
if err := DB.Create(&defaultAdminGroup).Error; err != nil {

View File

@@ -10,8 +10,8 @@ import (
func TestMigration(t *testing.T) {
asserts := assert.New(t)
conf.DatabaseConfig.Type = "sqlite3"
DB, _ = gorm.Open("sqlite3", ":memory:")
conf.DatabaseConfig.Type = "sqlite"
DB, _ = gorm.Open("sqlite", ":memory:")
asserts.NotPanics(func() {
migration()

View File

@@ -11,6 +11,7 @@ type Webdav struct {
Password string `gorm:"unique_index:password_only_on"` // 应用密码
UserID uint `gorm:"unique_index:password_only_on"` // 用户ID
Root string `gorm:"type:text"` // 根目录
Readonly bool `gorm:"type:bool"` // 是否只读
}
// Create 创建账户
@@ -39,3 +40,8 @@ func ListWebDAVAccounts(uid uint) []Webdav {
func DeleteWebDAVAccountByID(id, uid uint) {
DB.Where("user_id = ? and id = ?", uid, id).Delete(&Webdav{})
}
// UpdateWebDAVAccountReadonlyByID 根据账户ID和UID更新账户的只读性
func UpdateWebDAVAccountReadonlyByID(id, uid uint, readonly bool) {
DB.Model(&Webdav{Model: gorm.Model{ID: id}, UserID: uid}).UpdateColumn("readonly", readonly)
}

View File

@@ -17,6 +17,7 @@ type database struct {
DBFile string
Port int
Charset string
UnixSocket bool
}
// system 系统通用配置

View File

@@ -10,10 +10,11 @@ var RedisConfig = &redis{
// DatabaseConfig 数据库配置
var DatabaseConfig = &database{
Type: "UNSET",
Charset: "utf8",
DBFile: "cloudreve.db",
Port: 3306,
Type: "UNSET",
Charset: "utf8",
DBFile: "cloudreve.db",
Port: 3306,
UnixSocket: false,
}
// SystemConfig 系统公用配置

View File

@@ -1,13 +1,13 @@
package conf
// BackendVersion 当前后端版本号
var BackendVersion = "3.6.2"
var BackendVersion = "3.7.0"
// RequiredDBVersion 与当前版本匹配的数据库版本
var RequiredDBVersion = "3.6.0"
var RequiredDBVersion = "3.7.0"
// RequiredStaticVersion 与当前版本匹配的静态资源版本
var RequiredStaticVersion = "3.6.2"
var RequiredStaticVersion = "3.7.0"
// IsPro 是否为Pro版本
var IsPro = "false"

View File

@@ -88,7 +88,7 @@ func uploadSessionCollect() {
continue
}
if err = fs.Delete(context.Background(), []uint{}, filesIDs, false); err != nil {
if err = fs.Delete(context.Background(), []uint{}, filesIDs, false, false); err != nil {
util.Log().Warning("Failed to delete upload session: %s", err)
}

View File

@@ -120,8 +120,9 @@ func (fs *FileSystem) Move(ctx context.Context, dirs, files []uint, src, dst str
return err
}
// Delete 递归删除对象, force 为 true 时强制删除文件记录,忽略物理删除是否成功
func (fs *FileSystem) Delete(ctx context.Context, dirs, files []uint, force bool) error {
// Delete 递归删除对象, force 为 true 时强制删除文件记录,忽略物理删除是否成功;
// unlink 为 true 时只删除虚拟文件系统的文件记录,不删除物理文件。
func (fs *FileSystem) Delete(ctx context.Context, dirs, files []uint, force, unlink bool) error {
// 已删除的文件ID
var deletedFiles = make([]*model.File, 0, len(fs.FileTarget))
// 删除失败的文件的父目录ID
@@ -155,7 +156,10 @@ func (fs *FileSystem) Delete(ctx context.Context, dirs, files []uint, force bool
policyGroup := fs.GroupFileByPolicy(ctx, filesToBeDelete)
// 按照存储策略分组删除对象
failed := fs.deleteGroupedFile(ctx, policyGroup)
failed := make(map[uint][]string)
if !unlink {
failed = fs.deleteGroupedFile(ctx, policyGroup)
}
// 整理删除结果
for i := 0; i < len(fs.FileTarget); i++ {

View File

@@ -3,13 +3,13 @@ package filesystem
import (
"context"
"errors"
"github.com/DATA-DOG/go-sqlmock"
"os"
"testing"
"github.com/cloudreve/Cloudreve/v3/pkg/filesystem/response"
testMock "github.com/stretchr/testify/mock"
"github.com/DATA-DOG/go-sqlmock"
model "github.com/cloudreve/Cloudreve/v3/models"
"github.com/cloudreve/Cloudreve/v3/pkg/cache"
"github.com/cloudreve/Cloudreve/v3/pkg/conf"
@@ -485,7 +485,6 @@ func TestFileSystem_Delete(t *testing.T) {
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec("DELETE(.+)").
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec("UPDATE(.+)users(.+)storage(.+)").WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectCommit()
// 删除对应分享
mock.ExpectBegin()
@@ -505,7 +504,7 @@ func TestFileSystem_Delete(t *testing.T) {
fs.FileTarget = []model.File{}
fs.DirTarget = []model.Folder{}
err := fs.Delete(ctx, []uint{1}, []uint{1}, true)
err := fs.Delete(ctx, []uint{1}, []uint{1}, true, false)
asserts.NoError(err)
}
//全部成功
@@ -543,7 +542,6 @@ func TestFileSystem_Delete(t *testing.T) {
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec("DELETE(.+)").
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec("UPDATE(.+)users(.+)storage(.+)").WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectCommit()
// 删除对应分享
mock.ExpectBegin()
@@ -563,7 +561,7 @@ func TestFileSystem_Delete(t *testing.T) {
fs.FileTarget = []model.File{}
fs.DirTarget = []model.Folder{}
err = fs.Delete(ctx, []uint{1}, []uint{1}, false)
err = fs.Delete(ctx, []uint{1}, []uint{1}, false, false)
asserts.NoError(err)
}

View File

@@ -0,0 +1,29 @@
package cachemock
import "github.com/stretchr/testify/mock"
type CacheClientMock struct {
mock.Mock
}
func (c CacheClientMock) Set(key string, value interface{}, ttl int) error {
return c.Called(key, value, ttl).Error(0)
}
func (c CacheClientMock) Get(key string) (interface{}, bool) {
args := c.Called(key)
return args.Get(0), args.Bool(1)
}
func (c CacheClientMock) Gets(keys []string, prefix string) (map[string]interface{}, []string) {
args := c.Called(keys, prefix)
return args.Get(0).(map[string]interface{}), args.Get(1).([]string)
}
func (c CacheClientMock) Sets(values map[string]interface{}, prefix string) error {
return c.Called(values).Error(0)
}
func (c CacheClientMock) Delete(keys []string, prefix string) error {
return c.Called(keys, prefix).Error(0)
}

View File

@@ -0,0 +1,21 @@
package wopimock
import (
model "github.com/cloudreve/Cloudreve/v3/models"
"github.com/cloudreve/Cloudreve/v3/pkg/wopi"
"github.com/stretchr/testify/mock"
)
type WopiClientMock struct {
mock.Mock
}
func (w *WopiClientMock) NewSession(user *model.User, file *model.File, action wopi.ActonType) (*wopi.Session, error) {
args := w.Called(user, file, action)
return args.Get(0).(*wopi.Session), args.Error(1)
}
func (w *WopiClientMock) AvailableExts() []string {
args := w.Called()
return args.Get(0).([]string)
}

View File

@@ -84,3 +84,49 @@ type Sources struct {
Parent uint `json:"parent"`
Error string `json:"error,omitempty"`
}
// DocPreviewSession 文档预览会话响应
type DocPreviewSession struct {
URL string `json:"url"`
AccessToken string `json:"access_token,omitempty"`
AccessTokenTTL int64 `json:"access_token_ttl,omitempty"`
}
// WopiFileInfo Response for `CheckFileInfo`
type WopiFileInfo struct {
// Required
BaseFileName string
Version string
Size int64
// Breadcrumb
BreadcrumbBrandName string
BreadcrumbBrandUrl string
BreadcrumbFolderName string
BreadcrumbFolderUrl string
// Post Message
FileSharingPostMessage bool
ClosePostMessage bool
PostMessageOrigin string
// Other miscellaneous properties
FileNameMaxLength int
LastModifiedTime string
// User metadata
IsAnonymousUser bool
UserFriendlyName string
UserId string
OwnerId string
// Permission
ReadOnly bool
UserCanRename bool
UserCanReview bool
UserCanWrite bool
SupportsRename bool
SupportsReviewing bool
SupportsUpdate bool
}

View File

@@ -7,22 +7,23 @@ import (
// SiteConfig 站点全局设置序列
type SiteConfig struct {
SiteName string `json:"title"`
LoginCaptcha bool `json:"loginCaptcha"`
RegCaptcha bool `json:"regCaptcha"`
ForgetCaptcha bool `json:"forgetCaptcha"`
EmailActive bool `json:"emailActive"`
Themes string `json:"themes"`
DefaultTheme string `json:"defaultTheme"`
HomepageViewMethod string `json:"home_view_method"`
ShareViewMethod string `json:"share_view_method"`
Authn bool `json:"authn"`
User User `json:"user"`
ReCaptchaKey string `json:"captcha_ReCaptchaKey"`
CaptchaType string `json:"captcha_type"`
TCaptchaCaptchaAppId string `json:"tcaptcha_captcha_app_id"`
RegisterEnabled bool `json:"registerEnabled"`
AppPromotion bool `json:"app_promotion"`
SiteName string `json:"title"`
LoginCaptcha bool `json:"loginCaptcha"`
RegCaptcha bool `json:"regCaptcha"`
ForgetCaptcha bool `json:"forgetCaptcha"`
EmailActive bool `json:"emailActive"`
Themes string `json:"themes"`
DefaultTheme string `json:"defaultTheme"`
HomepageViewMethod string `json:"home_view_method"`
ShareViewMethod string `json:"share_view_method"`
Authn bool `json:"authn"`
User User `json:"user"`
ReCaptchaKey string `json:"captcha_ReCaptchaKey"`
CaptchaType string `json:"captcha_type"`
TCaptchaCaptchaAppId string `json:"tcaptcha_captcha_app_id"`
RegisterEnabled bool `json:"registerEnabled"`
AppPromotion bool `json:"app_promotion"`
WopiExts []string `json:"wopi_exts"`
}
type task struct {
@@ -60,7 +61,7 @@ func checkSettingValue(setting map[string]string, key string) string {
}
// BuildSiteConfig 站点全局设置
func BuildSiteConfig(settings map[string]string, user *model.User) Response {
func BuildSiteConfig(settings map[string]string, user *model.User, wopiExts []string) Response {
var userRes User
if user != nil {
userRes = BuildUser(*user)
@@ -85,6 +86,7 @@ func BuildSiteConfig(settings map[string]string, user *model.User) Response {
TCaptchaCaptchaAppId: checkSettingValue(settings, "captcha_TCaptcha_CaptchaAppId"),
RegisterEnabled: model.IsTrueVal(checkSettingValue(settings, "register_enabled")),
AppPromotion: model.IsTrueVal(checkSettingValue(settings, "show_app_promotion")),
WopiExts: wopiExts,
}}
return res
}

View File

@@ -18,10 +18,10 @@ func TestCheckSettingValue(t *testing.T) {
func TestBuildSiteConfig(t *testing.T) {
asserts := assert.New(t)
res := BuildSiteConfig(map[string]string{"not exist": ""}, &model.User{})
res := BuildSiteConfig(map[string]string{"not exist": ""}, &model.User{}, nil)
asserts.Equal("", res.Data.(SiteConfig).SiteName)
res = BuildSiteConfig(map[string]string{"siteName": "123"}, &model.User{})
res = BuildSiteConfig(map[string]string{"siteName": "123"}, &model.User{}, nil)
asserts.Equal("123", res.Data.(SiteConfig).SiteName)
// 非空用户
@@ -29,7 +29,7 @@ func TestBuildSiteConfig(t *testing.T) {
Model: gorm.Model{
ID: 5,
},
})
}, nil)
asserts.Len(res.Data.(SiteConfig).User.ID, 4)
}

View File

@@ -41,6 +41,7 @@ type group struct {
CompressEnabled bool `json:"compress"`
WebDAVEnabled bool `json:"webdav"`
SourceBatchSize int `json:"sourceBatch"`
AdvanceDelete bool `json:"advanceDelete"`
}
type tag struct {
@@ -100,6 +101,7 @@ func BuildUser(user model.User) User {
CompressEnabled: user.Group.OptionsSerialized.ArchiveTask,
WebDAVEnabled: user.Group.WebDAVEnabled,
SourceBatchSize: user.Group.OptionsSerialized.SourceBatchSize,
AdvanceDelete: user.Group.OptionsSerialized.AdvanceDelete,
},
Tags: buildTagRes(tags),
}

View File

@@ -66,7 +66,7 @@ func TestBuildRegexp(t *testing.T) {
func TestBuildConcat(t *testing.T) {
asserts := assert.New(t)
asserts.Equal("CONCAT(1,2)", BuildConcat("1", "2", "mysql"))
asserts.Equal("1||2", BuildConcat("1", "2", "sqlite3"))
asserts.Equal("1||2", BuildConcat("1", "2", "sqlite"))
}
func TestSliceDifference(t *testing.T) {

View File

@@ -218,7 +218,7 @@ func (h *Handler) confirmLocks(r *http.Request, src, dst string, fs *filesystem.
}, 0, nil
}
//OK
// OK
func (h *Handler) handleOptions(w http.ResponseWriter, r *http.Request, fs *filesystem.FileSystem) (status int, err error) {
reqPath, status, err := h.stripPrefix(r.URL.Path, fs.User.ID)
if err != nil {
@@ -303,7 +303,7 @@ func (h *Handler) handleDelete(w http.ResponseWriter, r *http.Request, fs *files
// 尝试作为文件删除
if ok, file := fs.IsFileExist(reqPath); ok {
if err := fs.Delete(ctx, []uint{}, []uint{file.ID}, false); err != nil {
if err := fs.Delete(ctx, []uint{}, []uint{file.ID}, false, false); err != nil {
return http.StatusMethodNotAllowed, err
}
return http.StatusNoContent, nil
@@ -311,7 +311,7 @@ func (h *Handler) handleDelete(w http.ResponseWriter, r *http.Request, fs *files
// 尝试作为目录删除
if ok, folder := fs.IsPathExist(reqPath); ok {
if err := fs.Delete(ctx, []uint{folder.ID}, []uint{}, false); err != nil {
if err := fs.Delete(ctx, []uint{folder.ID}, []uint{}, false, false); err != nil {
return http.StatusMethodNotAllowed, err
}
return http.StatusNoContent, nil
@@ -783,10 +783,11 @@ const (
// infiniteDepth. Parsing any other string returns invalidDepth.
//
// Different WebDAV methods have further constraints on valid depths:
// - PROPFIND has no further restrictions, as per section 9.1.
// - COPY accepts only "0" or "infinity", as per section 9.8.3.
// - MOVE accepts only "infinity", as per section 9.9.2.
// - LOCK accepts only "0" or "infinity", as per section 9.10.3.
// - PROPFIND has no further restrictions, as per section 9.1.
// - COPY accepts only "0" or "infinity", as per section 9.8.3.
// - MOVE accepts only "infinity", as per section 9.9.2.
// - LOCK accepts only "0" or "infinity", as per section 9.10.3.
//
// These constraints are enforced by the handleXxx methods.
func parseDepth(s string) int {
switch s {

101
pkg/wopi/discovery.go Normal file
View File

@@ -0,0 +1,101 @@
package wopi
import (
"encoding/xml"
"fmt"
"github.com/cloudreve/Cloudreve/v3/pkg/util"
"net/http"
"strings"
)
type ActonType string
var (
ActionPreview = ActonType("embedview")
ActionPreviewFallback = ActonType("view")
ActionEdit = ActonType("edit")
)
const (
DiscoverResponseCacheKey = "wopi_discover"
DiscoverRefreshDuration = 24 * 3600 // 24 hrs
)
func (c *client) AvailableExts() []string {
if err := c.checkDiscovery(); err != nil {
util.Log().Error("Failed to check WOPI discovery: %s", err)
return nil
}
c.mu.RLock()
defer c.mu.RUnlock()
exts := make([]string, 0, len(c.actions))
for ext, actions := range c.actions {
_, previewable := actions[string(ActionPreview)]
_, editable := actions[string(ActionEdit)]
_, previewableFallback := actions[string(ActionPreviewFallback)]
if previewable || editable || previewableFallback {
exts = append(exts, strings.TrimPrefix(ext, "."))
}
}
return exts
}
// checkDiscovery checks if discovery content is needed to be refreshed.
// If so, it will refresh discovery content.
func (c *client) checkDiscovery() error {
c.mu.RLock()
if c.discovery == nil {
c.mu.RUnlock()
return c.refreshDiscovery()
}
c.mu.RUnlock()
return nil
}
// refresh Discovery action configs.
func (c *client) refreshDiscovery() error {
c.mu.Lock()
defer c.mu.Unlock()
cached, exist := c.cache.Get(DiscoverResponseCacheKey)
if exist {
cachedDiscovery := cached.(WopiDiscovery)
c.discovery = &cachedDiscovery
} else {
res, err := c.http.Request("GET", c.config.discoveryEndpoint.String(), nil).
CheckHTTPResponse(http.StatusOK).GetResponse()
if err != nil {
return fmt.Errorf("failed to request discovery endpoint: %w", err)
}
if err := xml.Unmarshal([]byte(res), &c.discovery); err != nil {
return fmt.Errorf("failed to parse response discovery endpoint: %w", err)
}
if err := c.cache.Set(DiscoverResponseCacheKey, *c.discovery, DiscoverRefreshDuration); err != nil {
return err
}
}
// construct actions map
c.actions = make(map[string]map[string]Action)
for _, app := range c.discovery.NetZone.App {
for _, action := range app.Action {
if action.Ext == "" {
continue
}
if _, ok := c.actions["."+action.Ext]; !ok {
c.actions["."+action.Ext] = make(map[string]Action)
}
c.actions["."+action.Ext][action.Name] = action
}
}
return nil
}

129
pkg/wopi/discovery_test.go Normal file

File diff suppressed because one or more lines are too long

70
pkg/wopi/types.go Normal file
View File

@@ -0,0 +1,70 @@
package wopi
import (
"encoding/gob"
"encoding/xml"
"net/url"
)
// Response content from discovery endpoint.
type WopiDiscovery struct {
XMLName xml.Name `xml:"wopi-discovery"`
Text string `xml:",chardata"`
NetZone struct {
Text string `xml:",chardata"`
Name string `xml:"name,attr"`
App []struct {
Text string `xml:",chardata"`
Name string `xml:"name,attr"`
FavIconUrl string `xml:"favIconUrl,attr"`
BootstrapperUrl string `xml:"bootstrapperUrl,attr"`
AppBootstrapperUrl string `xml:"appBootstrapperUrl,attr"`
ApplicationBaseUrl string `xml:"applicationBaseUrl,attr"`
StaticResourceOrigin string `xml:"staticResourceOrigin,attr"`
CheckLicense string `xml:"checkLicense,attr"`
Action []Action `xml:"action"`
} `xml:"app"`
} `xml:"net-zone"`
ProofKey struct {
Text string `xml:",chardata"`
Oldvalue string `xml:"oldvalue,attr"`
Oldmodulus string `xml:"oldmodulus,attr"`
Oldexponent string `xml:"oldexponent,attr"`
Value string `xml:"value,attr"`
Modulus string `xml:"modulus,attr"`
Exponent string `xml:"exponent,attr"`
} `xml:"proof-key"`
}
type Action struct {
Text string `xml:",chardata"`
Name string `xml:"name,attr"`
Ext string `xml:"ext,attr"`
Default string `xml:"default,attr"`
Urlsrc string `xml:"urlsrc,attr"`
Requires string `xml:"requires,attr"`
Targetext string `xml:"targetext,attr"`
Progid string `xml:"progid,attr"`
UseParent string `xml:"useParent,attr"`
Newprogid string `xml:"newprogid,attr"`
Newext string `xml:"newext,attr"`
}
type Session struct {
AccessToken string
AccessTokenTTL int64
ActionURL *url.URL
}
type SessionCache struct {
AccessToken string
FileID uint
UserID uint
Action ActonType
}
func init() {
gob.Register(WopiDiscovery{})
gob.Register(Action{})
gob.Register(SessionCache{})
}

219
pkg/wopi/wopi.go Normal file
View File

@@ -0,0 +1,219 @@
package wopi
import (
"errors"
"fmt"
model "github.com/cloudreve/Cloudreve/v3/models"
"github.com/cloudreve/Cloudreve/v3/pkg/cache"
"github.com/cloudreve/Cloudreve/v3/pkg/hashid"
"github.com/cloudreve/Cloudreve/v3/pkg/request"
"github.com/cloudreve/Cloudreve/v3/pkg/util"
"github.com/gofrs/uuid"
"net/url"
"path"
"strings"
"sync"
"time"
)
type Client interface {
// NewSession creates a new document session with access token.
NewSession(user *model.User, file *model.File, action ActonType) (*Session, error)
// AvailableExts returns a list of file extensions that are supported by WOPI.
AvailableExts() []string
}
var (
ErrActionNotSupported = errors.New("action not supported by current wopi endpoint")
Default Client
DefaultMu sync.Mutex
queryPlaceholders = map[string]string{
"BUSINESS_USER": "",
"DC_LLCC": "lng",
"DISABLE_ASYNC": "",
"DISABLE_CHAT": "",
"EMBEDDED": "true",
"FULLSCREEN": "true",
"HOST_SESSION_ID": "",
"SESSION_CONTEXT": "",
"RECORDING": "",
"THEME_ID": "darkmode",
"UI_LLCC": "lng",
"VALIDATOR_TEST_CATEGORY": "",
}
)
const (
SessionCachePrefix = "wopi_session_"
AccessTokenQuery = "access_token"
OverwriteHeader = wopiHeaderPrefix + "Override"
ServerErrorHeader = wopiHeaderPrefix + "ServerError"
RenameRequestHeader = wopiHeaderPrefix + "RequestedName"
MethodLock = "LOCK"
MethodUnlock = "UNLOCK"
MethodRefreshLock = "REFRESH_LOCK"
MethodRename = "RENAME_FILE"
wopiSrcPlaceholder = "WOPI_SOURCE"
wopiSrcParamDefault = "WOPISrc"
sessionExpiresPadding = 10
wopiHeaderPrefix = "X-WOPI-"
)
// Init initializes a new global WOPI client.
func Init() {
settings := model.GetSettingByNames("wopi_endpoint", "wopi_enabled")
if !model.IsTrueVal(settings["wopi_enabled"]) {
DefaultMu.Lock()
Default = nil
DefaultMu.Unlock()
return
}
cache.Deletes([]string{DiscoverResponseCacheKey}, "")
wopiClient, err := NewClient(settings["wopi_endpoint"], cache.Store, request.NewClient())
if err != nil {
util.Log().Error("Failed to initialize WOPI client: %s", err)
return
}
DefaultMu.Lock()
Default = wopiClient
DefaultMu.Unlock()
}
type client struct {
cache cache.Driver
http request.Client
mu sync.RWMutex
discovery *WopiDiscovery
actions map[string]map[string]Action
config
}
type config struct {
discoveryEndpoint *url.URL
}
func NewClient(endpoint string, cache cache.Driver, http request.Client) (Client, error) {
endpointUrl, err := url.Parse(endpoint)
if err != nil {
return nil, fmt.Errorf("failed to parse WOPI endpoint: %s", err)
}
return &client{
cache: cache,
http: http,
config: config{
discoveryEndpoint: endpointUrl,
},
}, nil
}
func (c *client) NewSession(user *model.User, file *model.File, action ActonType) (*Session, error) {
if err := c.checkDiscovery(); err != nil {
return nil, err
}
c.mu.RLock()
defer c.mu.RUnlock()
ext := path.Ext(file.Name)
availableActions, ok := c.actions[ext]
if !ok {
return nil, ErrActionNotSupported
}
var (
actionConfig Action
)
fallbackOrder := []ActonType{action, ActionPreview, ActionPreviewFallback, ActionEdit}
for _, a := range fallbackOrder {
if actionConfig, ok = availableActions[string(a)]; ok {
break
}
}
if actionConfig.Urlsrc == "" {
return nil, ErrActionNotSupported
}
// Generate WOPI REST endpoint for given file
baseURL := model.GetSiteURL()
linkPath, err := url.Parse(fmt.Sprintf("/api/v3/wopi/files/%s", hashid.HashID(file.ID, hashid.FileID)))
if err != nil {
return nil, err
}
actionUrl, err := generateActionUrl(actionConfig.Urlsrc, baseURL.ResolveReference(linkPath).String())
if err != nil {
return nil, err
}
// Create document session
sessionID := uuid.Must(uuid.NewV4())
token := util.RandStringRunes(64)
ttl := model.GetIntSetting("wopi_session_timeout", 36000)
session := &SessionCache{
AccessToken: fmt.Sprintf("%s.%s", sessionID, token),
FileID: file.ID,
UserID: user.ID,
Action: action,
}
err = c.cache.Set(SessionCachePrefix+sessionID.String(), *session, ttl)
if err != nil {
return nil, fmt.Errorf("failed to create document session: %w", err)
}
sessionRes := &Session{
AccessToken: session.AccessToken,
ActionURL: actionUrl,
AccessTokenTTL: time.Now().Add(time.Duration(ttl-sessionExpiresPadding) * time.Second).UnixMilli(),
}
return sessionRes, nil
}
// Replace query parameters in action URL template. Some placeholders need to be replaced
// at the frontend, e.g. `THEME_ID`.
func generateActionUrl(src string, fileSrc string) (*url.URL, error) {
src = strings.ReplaceAll(src, "<", "")
src = strings.ReplaceAll(src, ">", "")
actionUrl, err := url.Parse(src)
if err != nil {
return nil, fmt.Errorf("failed to parse action url: %s", err)
}
queries := actionUrl.Query()
srcReplaced := false
queryReplaced := url.Values{}
for k := range queries {
if placeholder, ok := queryPlaceholders[queries.Get(k)]; ok {
if placeholder != "" {
queryReplaced.Set(k, placeholder)
}
continue
}
if queries.Get(k) == wopiSrcPlaceholder {
queryReplaced.Set(k, fileSrc)
srcReplaced = true
continue
}
queryReplaced.Set(k, queries.Get(k))
}
if !srcReplaced {
queryReplaced.Set(wopiSrcParamDefault, fileSrc)
}
actionUrl.RawQuery = queryReplaced.Encode()
return actionUrl, nil
}

184
pkg/wopi/wopi_test.go Normal file
View File

@@ -0,0 +1,184 @@
package wopi
import (
"database/sql"
"errors"
"github.com/DATA-DOG/go-sqlmock"
model "github.com/cloudreve/Cloudreve/v3/models"
"github.com/cloudreve/Cloudreve/v3/pkg/cache"
"github.com/cloudreve/Cloudreve/v3/pkg/mocks/cachemock"
"github.com/cloudreve/Cloudreve/v3/pkg/mocks/requestmock"
"github.com/cloudreve/Cloudreve/v3/pkg/request"
"github.com/jinzhu/gorm"
"github.com/stretchr/testify/assert"
testMock "github.com/stretchr/testify/mock"
"net/url"
"testing"
)
var mock sqlmock.Sqlmock
// TestMain 初始化数据库Mock
func TestMain(m *testing.M) {
var db *sql.DB
var err error
db, mock, err = sqlmock.New()
if err != nil {
panic("An error was not expected when opening a stub database connection")
}
model.DB, _ = gorm.Open("mysql", db)
defer db.Close()
m.Run()
}
func TestNewSession(t *testing.T) {
a := assert.New(t)
endpoint, _ := url.Parse("http://localhost:8001/hosting/discovery")
client := &client{
cache: cache.NewMemoStore(),
config: config{
discoveryEndpoint: endpoint,
},
}
// Discovery failed
{
expectedErr := errors.New("error")
mockHttp := &requestmock.RequestMock{}
client.http = mockHttp
mockHttp.On(
"Request",
"GET",
endpoint.String(),
testMock.Anything,
testMock.Anything,
).Return(&request.Response{
Err: expectedErr,
})
res, err := client.NewSession(&model.User{}, &model.File{}, ActionPreview)
a.Nil(res)
a.ErrorIs(err, expectedErr)
mockHttp.AssertExpectations(t)
}
// not supported ext
{
client.discovery = &WopiDiscovery{}
client.actions = make(map[string]map[string]Action)
res, err := client.NewSession(&model.User{}, &model.File{}, ActionPreview)
a.Nil(res)
a.ErrorIs(err, ErrActionNotSupported)
}
// preferred action not supported
{
client.discovery = &WopiDiscovery{}
client.actions = map[string]map[string]Action{
".doc": {},
}
res, err := client.NewSession(&model.User{}, &model.File{Name: "1.doc"}, ActionPreview)
a.Nil(res)
a.ErrorIs(err, ErrActionNotSupported)
}
// src url cannot be parsed
{
client.discovery = &WopiDiscovery{}
client.actions = map[string]map[string]Action{
".doc": {
string(ActionPreviewFallback): Action{
Urlsrc: string([]byte{0x7f}),
},
},
}
res, err := client.NewSession(&model.User{}, &model.File{Name: "1.doc"}, ActionEdit)
a.Nil(res)
a.ErrorContains(err, "invalid control character in URL")
}
// all pass - default placeholder
{
client.discovery = &WopiDiscovery{}
client.actions = map[string]map[string]Action{
".doc": {
string(ActionPreviewFallback): Action{
Urlsrc: "https://doc.com/doc",
},
},
}
res, err := client.NewSession(&model.User{}, &model.File{Name: "1.doc"}, ActionEdit)
a.NotNil(res)
a.NoError(err)
resUrl := res.ActionURL.String()
a.Contains(resUrl, wopiSrcParamDefault)
}
// all pass - with placeholders
{
client.discovery = &WopiDiscovery{}
client.actions = map[string]map[string]Action{
".doc": {
string(ActionPreviewFallback): Action{
Urlsrc: "https://doc.com/doc?origin=preserved&<dc=DC_LLCC&><notsuported=DISABLE_ASYNC&><src=WOPI_SOURCE&>",
},
},
}
res, err := client.NewSession(&model.User{}, &model.File{Name: "1.doc"}, ActionEdit)
a.NotNil(res)
a.NoError(err)
resUrl := res.ActionURL.String()
a.Contains(resUrl, "origin=preserved")
a.Contains(resUrl, "dc=lng")
a.Contains(resUrl, "src=")
a.NotContains(resUrl, "notsuported")
}
// cache operation failed
{
mockCache := &cachemock.CacheClientMock{}
expectedErr := errors.New("error")
client.cache = mockCache
client.discovery = &WopiDiscovery{}
client.actions = map[string]map[string]Action{
".doc": {
string(ActionPreviewFallback): Action{
Urlsrc: "https://doc.com/doc",
},
},
}
mockCache.On("Set", testMock.Anything, testMock.Anything, testMock.Anything).Return(expectedErr)
res, err := client.NewSession(&model.User{}, &model.File{Name: "1.doc"}, ActionEdit)
a.Nil(res)
a.ErrorIs(err, expectedErr)
}
}
func TestInit(t *testing.T) {
a := assert.New(t)
// not enabled
{
a.Nil(Default)
Default = &client{}
Init()
a.Nil(Default)
}
// throw error
{
a.Nil(Default)
cache.Set("setting_wopi_enabled", "1", 0)
cache.Set("setting_wopi_endpoint", string([]byte{0x7f}), 0)
Init()
a.Nil(Default)
}
// all pass
{
a.Nil(Default)
cache.Set("setting_wopi_enabled", "1", 0)
cache.Set("setting_wopi_endpoint", "", 0)
Init()
a.NotNil(Default)
}
}

View File

@@ -10,6 +10,7 @@ import (
"github.com/cloudreve/Cloudreve/v3/pkg/email"
"github.com/cloudreve/Cloudreve/v3/pkg/request"
"github.com/cloudreve/Cloudreve/v3/pkg/serializer"
"github.com/cloudreve/Cloudreve/v3/pkg/wopi"
"github.com/cloudreve/Cloudreve/v3/service/admin"
"github.com/gin-gonic/gin"
)
@@ -79,6 +80,8 @@ func AdminReloadService(c *gin.Context) {
email.Init()
case "aria2":
aria2.Init(true, cluster.Default, mq.GlobalMQ)
case "wopi":
wopi.Init()
}
c.JSON(200, serializer.Response{})

View File

@@ -236,7 +236,7 @@ func GetDocPreview(c *gin.Context) {
var service explorer.FileIDService
if err := c.ShouldBindUri(&service); err == nil {
res := service.CreateDocPreviewSession(ctx, c)
res := service.CreateDocPreviewSession(ctx, c, true)
c.JSON(200, res)
} else {
c.JSON(200, ErrorResponse(err))

View File

@@ -5,6 +5,7 @@ import (
"github.com/cloudreve/Cloudreve/v3/pkg/conf"
"github.com/cloudreve/Cloudreve/v3/pkg/serializer"
"github.com/cloudreve/Cloudreve/v3/pkg/util"
"github.com/cloudreve/Cloudreve/v3/pkg/wopi"
"github.com/gin-gonic/gin"
"github.com/mojocn/base64Captcha"
)
@@ -30,14 +31,19 @@ func SiteConfig(c *gin.Context) {
"show_app_promotion",
)
var wopiExts []string
if wopi.Default != nil {
wopiExts = wopi.Default.AvailableExts()
}
// 如果已登录,则同时返回用户信息和标签
user, _ := c.Get("user")
if user, ok := user.(*model.User); ok {
c.JSON(200, serializer.BuildSiteConfig(siteConfig, user))
c.JSON(200, serializer.BuildSiteConfig(siteConfig, user, wopiExts))
return
}
c.JSON(200, serializer.BuildSiteConfig(siteConfig, nil))
c.JSON(200, serializer.BuildSiteConfig(siteConfig, nil, wopiExts))
}
// Ping 状态检查页面

View File

@@ -7,6 +7,7 @@ import (
"github.com/cloudreve/Cloudreve/v3/pkg/webdav"
"github.com/cloudreve/Cloudreve/v3/service/setting"
"github.com/gin-gonic/gin"
"net/http"
"sync"
)
@@ -39,6 +40,15 @@ func ServeWebDAV(c *gin.Context) {
fs.Root = root
}
}
// 检查是否只读
if application.Readonly {
switch c.Request.Method {
case "DELETE", "PUT", "MKCOL", "COPY", "MOVE":
c.Status(http.StatusForbidden)
return
}
}
}
handler.ServeHTTP(c.Writer, c.Request, fs)
@@ -66,6 +76,17 @@ func DeleteWebDAVAccounts(c *gin.Context) {
}
}
// UpdateWebDAVAccountsReadonly 更改WebDAV账户只读性
func UpdateWebDAVAccountsReadonly(c *gin.Context) {
var service setting.WebDAVAccountUpdateReadonlyService
if err := c.ShouldBindJSON(&service); err == nil {
res := service.Update(c, CurrentUser(c))
c.JSON(200, res)
} else {
c.JSON(200, ErrorResponse(err))
}
}
// CreateWebDAVAccounts 创建WebDAV账户
func CreateWebDAVAccounts(c *gin.Context) {
var service setting.WebDAVAccountCreateService

View File

@@ -0,0 +1,77 @@
package controllers
import (
"context"
"github.com/cloudreve/Cloudreve/v3/pkg/serializer"
"github.com/cloudreve/Cloudreve/v3/pkg/wopi"
"github.com/cloudreve/Cloudreve/v3/service/explorer"
"github.com/gin-gonic/gin"
"net/http"
)
// CheckFileInfo Get file info
func CheckFileInfo(c *gin.Context) {
var service explorer.WopiService
res, err := service.FileInfo(c)
if err != nil {
c.Status(http.StatusInternalServerError)
c.Header(wopi.ServerErrorHeader, err.Error())
return
}
c.JSON(200, res)
}
// GetFile Get file content
func GetFile(c *gin.Context) {
var service explorer.WopiService
err := service.GetFile(c)
if err != nil {
c.Status(http.StatusInternalServerError)
c.Header(wopi.ServerErrorHeader, err.Error())
return
}
}
// PutFile Puts file content
func PutFile(c *gin.Context) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
service := &explorer.FileIDService{}
res := service.PutContent(ctx, c)
switch res.Code {
case serializer.CodeFileTooLarge:
c.Status(http.StatusRequestEntityTooLarge)
c.Header(wopi.ServerErrorHeader, res.Error)
case serializer.CodeNotFound:
c.Status(http.StatusNotFound)
c.Header(wopi.ServerErrorHeader, res.Error)
case 0:
c.Status(http.StatusOK)
default:
c.Status(http.StatusInternalServerError)
c.Header(wopi.ServerErrorHeader, res.Error)
}
}
// ModifyFile Modify file properties
func ModifyFile(c *gin.Context) {
action := c.GetHeader(wopi.OverwriteHeader)
switch action {
case wopi.MethodLock, wopi.MethodRefreshLock, wopi.MethodUnlock:
c.Status(http.StatusOK)
return
case wopi.MethodRename:
var service explorer.WopiService
err := service.Rename(c)
if err != nil {
c.Status(http.StatusInternalServerError)
c.Header(wopi.ServerErrorHeader, err.Error())
return
}
default:
c.Status(http.StatusNotImplemented)
return
}
}

View File

@@ -3,10 +3,12 @@ package routers
import (
"github.com/cloudreve/Cloudreve/v3/middleware"
"github.com/cloudreve/Cloudreve/v3/pkg/auth"
"github.com/cloudreve/Cloudreve/v3/pkg/cache"
"github.com/cloudreve/Cloudreve/v3/pkg/cluster"
"github.com/cloudreve/Cloudreve/v3/pkg/conf"
"github.com/cloudreve/Cloudreve/v3/pkg/hashid"
"github.com/cloudreve/Cloudreve/v3/pkg/util"
wopi2 "github.com/cloudreve/Cloudreve/v3/pkg/wopi"
"github.com/cloudreve/Cloudreve/v3/routers/controllers"
"github.com/gin-contrib/cors"
"github.com/gin-contrib/gzip"
@@ -385,6 +387,22 @@ func InitMasterRouter() *gin.Engine {
v3.Group("share").GET("search", controllers.SearchShare)
}
wopi := v3.Group(
"wopi",
middleware.HashID(hashid.FileID),
middleware.WopiAccessValidation(wopi2.Default, cache.Store),
)
{
// 获取文件信息
wopi.GET("files/:id", controllers.CheckFileInfo)
// 获取文件内容
wopi.GET("files/:id/contents", controllers.GetFile)
// 更新文件内容
wopi.POST("files/:id/contents", middleware.WopiWriteAccess(), controllers.PutFile)
// 通用文件操作
wopi.POST("files/:id", middleware.WopiWriteAccess(), controllers.ModifyFile)
}
// 需要登录保护的
auth := v3.Group("")
auth.Use(middleware.AuthRequired())
@@ -681,6 +699,8 @@ func InitMasterRouter() *gin.Engine {
webdav.POST("accounts", controllers.CreateWebDAVAccounts)
// 删除账号
webdav.DELETE("accounts/:id", controllers.DeleteWebDAVAccounts)
// 更新账号可读性
webdav.PATCH("accounts", controllers.UpdateWebDAVAccountsReadonly)
}
}

View File

@@ -19,8 +19,9 @@ type FileService struct {
// FileBatchService 文件批量操作服务
type FileBatchService struct {
ID []uint `json:"id" binding:"min=1"`
Force bool `json:"force"`
ID []uint `json:"id" binding:"min=1"`
Force bool `json:"force"`
UnlinkOnly bool `json:"unlink"`
}
// ListFolderService 列目录结构
@@ -103,15 +104,22 @@ func (service *FileBatchService) Delete(c *gin.Context) serializer.Response {
// 异步执行删除
go func(files map[uint][]model.File) {
for uid, file := range files {
var (
fs *filesystem.FileSystem
err error
)
user, err := model.GetUserByID(uid)
if err != nil {
continue
}
fs, err := filesystem.NewFileSystem(&user)
if err != nil {
fs.Recycle()
continue
fs, err = filesystem.NewAnonymousFileSystem()
if err != nil {
continue
}
} else {
fs, err = filesystem.NewFileSystem(&user)
if err != nil {
fs.Recycle()
continue
}
}
// 汇总文件ID
@@ -121,7 +129,7 @@ func (service *FileBatchService) Delete(c *gin.Context) serializer.Response {
}
// 执行删除
fs.Delete(context.Background(), []uint{}, ids, service.Force)
fs.Delete(context.Background(), []uint{}, ids, service.Force, service.UnlinkOnly)
fs.Recycle()
}
}(userFile)

View File

@@ -66,7 +66,7 @@ func (service *UserBatchService) Delete() serializer.Response {
if err != nil {
return serializer.Err(serializer.CodeInternalSetting, "User's root folder not exist", err)
}
fs.Delete(context.Background(), []uint{root.ID}, []uint{}, false)
fs.Delete(context.Background(), []uint{root.ID}, []uint{}, false, false)
// 删除相关任务
model.DB.Where("user_id = ?", uid).Delete(&model.Download{})

View File

@@ -18,6 +18,7 @@ import (
"github.com/cloudreve/Cloudreve/v3/pkg/filesystem"
"github.com/cloudreve/Cloudreve/v3/pkg/filesystem/fsctx"
"github.com/cloudreve/Cloudreve/v3/pkg/serializer"
"github.com/cloudreve/Cloudreve/v3/pkg/wopi"
"github.com/gin-gonic/gin"
)
@@ -192,7 +193,7 @@ func (service *FileAnonymousGetService) Source(ctx context.Context, c *gin.Conte
}
// CreateDocPreviewSession 创建DOC文件预览会话返回预览地址
func (service *FileIDService) CreateDocPreviewSession(ctx context.Context, c *gin.Context) serializer.Response {
func (service *FileIDService) CreateDocPreviewSession(ctx context.Context, c *gin.Context, editable bool) serializer.Response {
// 创建文件系统
fs, err := filesystem.NewFileSystemFromContext(c)
if err != nil {
@@ -226,18 +227,47 @@ func (service *FileIDService) CreateDocPreviewSession(ctx context.Context, c *gi
return serializer.Err(serializer.CodeNotSet, err.Error(), err)
}
var resp serializer.DocPreviewSession
// Use WOPI preview if available
if model.IsTrueVal(model.GetSettingByName("wopi_enabled")) && wopi.Default != nil {
maxSize := model.GetIntSetting("maxEditSize", 0)
if maxSize > 0 && fs.FileTarget[0].Size > uint64(maxSize) {
return serializer.Err(serializer.CodeFileTooLarge, "", nil)
}
action := wopi.ActionPreview
if editable {
action = wopi.ActionEdit
}
session, err := wopi.Default.NewSession(fs.User, &fs.FileTarget[0], action)
if err != nil {
return serializer.Err(serializer.CodeInternalSetting, "Failed to create WOPI session", err)
}
resp.URL = session.ActionURL.String()
resp.AccessTokenTTL = session.AccessTokenTTL
resp.AccessToken = session.AccessToken
return serializer.Response{
Code: 0,
Data: resp,
}
}
// 生成最终的预览器地址
srcB64 := base64.StdEncoding.EncodeToString([]byte(downloadURL))
srcEncoded := url.QueryEscape(downloadURL)
srcB64Encoded := url.QueryEscape(srcB64)
resp.URL = util.Replace(map[string]string{
"{$src}": srcEncoded,
"{$srcB64}": srcB64Encoded,
"{$name}": url.QueryEscape(fs.FileTarget[0].Name),
}, model.GetSettingByName("office_preview_service"))
return serializer.Response{
Code: 0,
Data: util.Replace(map[string]string{
"{$src}": srcEncoded,
"{$srcB64}": srcB64Encoded,
"{$name}": url.QueryEscape(fs.FileTarget[0].Name),
}, model.GetSettingByName("office_preview_service")),
Data: resp,
}
}
@@ -407,18 +437,18 @@ func (service *FileIDService) PutContent(ctx context.Context, c *gin.Context) se
fileData.Mode &= ^fsctx.Overwrite
fs.Use("AfterUpload", filesystem.HookUpdateSourceName)
fs.Use("AfterUploadCanceled", filesystem.HookUpdateSourceName)
fs.Use("AfterUploadCanceled", filesystem.HookCleanFileContent)
fs.Use("AfterUploadCanceled", filesystem.HookClearFileSize)
fs.Use("AfterValidateFailed", filesystem.HookUpdateSourceName)
fs.Use("AfterValidateFailed", filesystem.HookCleanFileContent)
fs.Use("AfterValidateFailed", filesystem.HookClearFileSize)
}
// 给文件系统分配钩子
fs.Use("BeforeUpload", filesystem.HookResetPolicy)
fs.Use("BeforeUpload", filesystem.HookValidateFile)
fs.Use("BeforeUpload", filesystem.HookValidateCapacityDiff)
fs.Use("AfterUploadCanceled", filesystem.HookCleanFileContent)
fs.Use("AfterUploadCanceled", filesystem.HookClearFileSize)
fs.Use("AfterUpload", filesystem.GenericAfterUpdate)
fs.Use("AfterValidateFailed", filesystem.HookCleanFileContent)
fs.Use("AfterValidateFailed", filesystem.HookClearFileSize)
// 执行上传
uploadCtx = context.WithValue(uploadCtx, fsctx.FileModelCtx, originFile[0])

View File

@@ -41,9 +41,11 @@ type ItemService struct {
// ItemIDService 处理多文件/目录相关服务字段值为HashID可通过Raw()方法获取原始ID
type ItemIDService struct {
Items []string `json:"items"`
Dirs []string `json:"dirs"`
Source *ItemService
Items []string `json:"items"`
Dirs []string `json:"dirs"`
Source *ItemService
Force bool `json:"force"`
UnlinkOnly bool `json:"unlink"`
}
// ItemCompressService 文件压缩任务服务
@@ -272,9 +274,15 @@ func (service *ItemIDService) Delete(ctx context.Context, c *gin.Context) serial
}
defer fs.Recycle()
force, unlink := false, false
if fs.User.Group.OptionsSerialized.AdvanceDelete {
force = service.Force
unlink = service.UnlinkOnly
}
// 删除对象
items := service.Raw()
err = fs.Delete(ctx, items.Dirs, items.Items, false)
err = fs.Delete(ctx, items.Dirs, items.Items, force, unlink)
if err != nil {
return serializer.Err(serializer.CodeNotSet, err.Error(), err)
}

View File

@@ -237,7 +237,7 @@ func (service *UploadSessionService) Delete(ctx context.Context, c *gin.Context)
}
// 删除文件
if err := fs.Delete(ctx, []uint{}, []uint{file.ID}, false); err != nil {
if err := fs.Delete(ctx, []uint{}, []uint{file.ID}, false, false); err != nil {
return serializer.Err(serializer.CodeInternalSetting, "Failed to delete upload session", err)
}
@@ -283,7 +283,7 @@ func DeleteAllUploadSession(ctx context.Context, c *gin.Context) serializer.Resp
}
// 删除文件
if err := fs.Delete(ctx, []uint{}, fileIDs, false); err != nil {
if err := fs.Delete(ctx, []uint{}, fileIDs, false, false); err != nil {
return serializer.Err(serializer.CodeInternalSetting, "Failed to cleanup upload session", err)
}

138
service/explorer/wopi.go Normal file
View File

@@ -0,0 +1,138 @@
package explorer
import (
"errors"
"fmt"
"github.com/cloudreve/Cloudreve/v3/middleware"
model "github.com/cloudreve/Cloudreve/v3/models"
"github.com/cloudreve/Cloudreve/v3/pkg/filesystem"
"github.com/cloudreve/Cloudreve/v3/pkg/hashid"
"github.com/cloudreve/Cloudreve/v3/pkg/serializer"
"github.com/cloudreve/Cloudreve/v3/pkg/wopi"
"github.com/gin-gonic/gin"
"net/http"
"time"
)
type WopiService struct {
}
func (service *WopiService) Rename(c *gin.Context) error {
fs, _, err := service.prepareFs(c)
if err != nil {
return err
}
defer fs.Recycle()
return fs.Rename(c, []uint{}, []uint{c.MustGet("object_id").(uint)}, c.GetHeader(wopi.RenameRequestHeader))
}
func (service *WopiService) GetFile(c *gin.Context) error {
fs, _, err := service.prepareFs(c)
if err != nil {
return err
}
defer fs.Recycle()
resp, err := fs.Preview(c, fs.FileTarget[0].ID, true)
if err != nil {
return fmt.Errorf("failed to pull file content: %w", err)
}
// 重定向到文件源
if resp.Redirect {
return fmt.Errorf("redirect not supported in WOPI")
}
// 直接返回文件内容
defer resp.Content.Close()
c.Header("Cache-Control", "no-cache")
http.ServeContent(c.Writer, c.Request, fs.FileTarget[0].Name, fs.FileTarget[0].UpdatedAt, resp.Content)
return nil
}
func (service *WopiService) FileInfo(c *gin.Context) (*serializer.WopiFileInfo, error) {
fs, session, err := service.prepareFs(c)
if err != nil {
return nil, err
}
defer fs.Recycle()
parent, err := model.GetFoldersByIDs([]uint{fs.FileTarget[0].FolderID}, fs.User.ID)
if err != nil {
return nil, err
}
if len(parent) == 0 {
return nil, fmt.Errorf("failed to find parent folder")
}
parent[0].TraceRoot()
siteUrl := model.GetSiteURL()
// Generate url for parent folder
parentUrl := model.GetSiteURL()
parentUrl.Path = "/home"
query := parentUrl.Query()
query.Set("path", parent[0].Position)
parentUrl.RawQuery = query.Encode()
info := &serializer.WopiFileInfo{
BaseFileName: fs.FileTarget[0].Name,
Version: fs.FileTarget[0].Model.UpdatedAt.String(),
BreadcrumbBrandName: model.GetSettingByName("siteName"),
BreadcrumbBrandUrl: siteUrl.String(),
FileSharingPostMessage: false,
PostMessageOrigin: "*",
FileNameMaxLength: 256,
LastModifiedTime: fs.FileTarget[0].Model.UpdatedAt.Format(time.RFC3339),
IsAnonymousUser: true,
ReadOnly: true,
ClosePostMessage: true,
Size: int64(fs.FileTarget[0].Size),
OwnerId: hashid.HashID(fs.FileTarget[0].UserID, hashid.UserID),
}
if session.Action == wopi.ActionEdit {
info.FileSharingPostMessage = true
info.IsAnonymousUser = false
info.SupportsRename = true
info.SupportsReviewing = true
info.SupportsUpdate = true
info.UserFriendlyName = fs.User.Nick
info.UserId = hashid.HashID(fs.User.ID, hashid.UserID)
info.UserCanRename = true
info.UserCanReview = true
info.UserCanWrite = true
info.ReadOnly = false
info.BreadcrumbFolderName = parent[0].Name
info.BreadcrumbFolderUrl = parentUrl.String()
}
return info, nil
}
func (service *WopiService) prepareFs(c *gin.Context) (*filesystem.FileSystem, *wopi.SessionCache, error) {
// 创建文件系统
fs, err := filesystem.NewFileSystemFromContext(c)
if err != nil {
return nil, nil, err
}
session := c.MustGet(middleware.WopiSessionCtx).(*wopi.SessionCache)
if err := fs.SetTargetFileByIDs([]uint{session.FileID}); err != nil {
fs.Recycle()
return nil, nil, fmt.Errorf("failed to find file: %w", err)
}
maxSize := model.GetIntSetting("maxEditSize", 0)
if maxSize > 0 && fs.FileTarget[0].Size > uint64(maxSize) {
return nil, nil, errors.New("file too large")
}
return fs, session, nil
}

View File

@@ -22,6 +22,12 @@ type WebDAVAccountCreateService struct {
Name string `json:"name" binding:"required,min=1,max=255"`
}
// WebDAVAccountUpdateReadonlyService WebDAV 修改只读性服务
type WebDAVAccountUpdateReadonlyService struct {
ID uint `json:"id" binding:"required,min=1"`
Readonly bool `json:"readonly"`
}
// WebDAVMountCreateService WebDAV 挂载创建服务
type WebDAVMountCreateService struct {
Path string `json:"path" binding:"required,min=1,max=65535"`
@@ -56,6 +62,14 @@ func (service *WebDAVAccountService) Delete(c *gin.Context, user *model.User) se
return serializer.Response{}
}
// Update 修改WebDAV账户的只读性
func (service *WebDAVAccountUpdateReadonlyService) Update(c *gin.Context, user *model.User) serializer.Response {
model.UpdateWebDAVAccountReadonlyByID(service.ID, user.ID, service.Readonly)
return serializer.Response{Data: map[string]bool{
"readonly": service.Readonly,
}}
}
// Accounts 列出WebDAV账号
func (service *WebDAVListService) Accounts(c *gin.Context, user *model.User) serializer.Response {
accounts := model.ListWebDAVAccounts(user.ID)

View File

@@ -221,7 +221,7 @@ func (service *Service) CreateDocPreviewSession(c *gin.Context) serializer.Respo
}
subService := explorer.FileIDService{}
return subService.CreateDocPreviewSession(ctx, c)
return subService.CreateDocPreviewSession(ctx, c, false)
}
// List 列出分享的目录下的对象