init
This commit is contained in:
commit
5d1439aaf6
1
.github/FUNDING.yml
vendored
Normal file
1
.github/FUNDING.yml
vendored
Normal file
@ -0,0 +1 @@
|
||||
custom: ["https://cloudreve.org/pricing"]
|
38
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
38
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@ -0,0 +1,38 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Desktop (please complete the following information):**
|
||||
- OS: [e.g. iOS]
|
||||
- Browser [e.g. chrome, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
**Smartphone (please complete the following information):**
|
||||
- Device: [e.g. iPhone6]
|
||||
- OS: [e.g. iOS8.1]
|
||||
- Browser [e.g. stock browser, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
@ -0,0 +1,20 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
61
.github/stale.yml
vendored
Normal file
61
.github/stale.yml
vendored
Normal file
@ -0,0 +1,61 @@
|
||||
# Configuration for probot-stale - https://github.com/probot/stale
|
||||
|
||||
# Number of days of inactivity before an Issue or Pull Request becomes stale
|
||||
daysUntilStale: 360
|
||||
|
||||
# Number of days of inactivity before an Issue or Pull Request with the stale label is closed.
|
||||
# Set to false to disable. If disabled, issues still need to be closed manually, but will remain marked as stale.
|
||||
daysUntilClose: 30
|
||||
|
||||
# Only issues or pull requests with all of these labels are check if stale. Defaults to `[]` (disabled)
|
||||
onlyLabels: []
|
||||
|
||||
# Issues or Pull Requests with these labels will never be considered stale. Set to `[]` to disable
|
||||
exemptLabels:
|
||||
- pinned
|
||||
- security
|
||||
- "[Status] Maybe Later"
|
||||
|
||||
# Set to true to ignore issues in a project (defaults to false)
|
||||
exemptProjects: true
|
||||
|
||||
# Set to true to ignore issues in a milestone (defaults to false)
|
||||
exemptMilestones: true
|
||||
|
||||
# Set to true to ignore issues with an assignee (defaults to false)
|
||||
exemptAssignees: true
|
||||
|
||||
# Label to use when marking as stale
|
||||
staleLabel: wontfix
|
||||
|
||||
# Comment to post when marking as stale. Set to `false` to disable
|
||||
markComment: >
|
||||
This issue has been automatically marked as stale because it has not had
|
||||
recent activity. It will be closed if no further activity occurs. Thank you
|
||||
for your contributions.
|
||||
|
||||
# Comment to post when removing the stale label.
|
||||
# unmarkComment: >
|
||||
# Your comment here.
|
||||
|
||||
# Comment to post when closing a stale Issue or Pull Request.
|
||||
# closeComment: >
|
||||
# Your comment here.
|
||||
|
||||
# Limit the number of actions per hour, from 1-30. Default is 30
|
||||
limitPerRun: 30
|
||||
|
||||
# Limit to only `issues` or `pulls`
|
||||
# only: issues
|
||||
|
||||
# Optionally, specify configuration settings that are specific to just 'issues' or 'pulls':
|
||||
# pulls:
|
||||
# daysUntilStale: 30
|
||||
# markComment: >
|
||||
# This pull request has been automatically marked as stale because it has not had
|
||||
# recent activity. It will be closed if no further activity occurs. Thank you
|
||||
# for your contributions.
|
||||
|
||||
# issues:
|
||||
# exemptLabels:
|
||||
# - confirmed
|
31
.github/workflows/build.yml
vendored
Normal file
31
.github/workflows/build.yml
vendored
Normal file
@ -0,0 +1,31 @@
|
||||
name: Build
|
||||
|
||||
on: workflow_dispatch
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Set up Go 1.20
|
||||
uses: actions/setup-go@v2
|
||||
with:
|
||||
go-version: "1.20"
|
||||
id: go
|
||||
|
||||
- name: Check out code into the Go module directory
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
clean: false
|
||||
submodules: "recursive"
|
||||
- run: |
|
||||
git fetch --prune --unshallow --tags
|
||||
|
||||
- name: Build and Release
|
||||
uses: goreleaser/goreleaser-action@v4
|
||||
with:
|
||||
distribution: goreleaser
|
||||
version: latest
|
||||
args: release --clean --skip-validate
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
57
.github/workflows/docker-release.yml
vendored
Normal file
57
.github/workflows/docker-release.yml
vendored
Normal file
@ -0,0 +1,57 @@
|
||||
name: Build and push docker image
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 3.* # triggered on every push with tag 3.*
|
||||
workflow_dispatch: # or just on button clicked
|
||||
|
||||
jobs:
|
||||
docker-build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
- run: git fetch --prune --unshallow
|
||||
- name: Setup Environments
|
||||
id: envs
|
||||
run: |
|
||||
CLOUDREVE_LATEST_TAG=$(git describe --tags --abbrev=0)
|
||||
DOCKER_IMAGE="cloudreve/cloudreve"
|
||||
|
||||
echo "RELEASE_VERSION=${GITHUB_REF#refs}"
|
||||
TAGS="${DOCKER_IMAGE}:latest,${DOCKER_IMAGE}:${CLOUDREVE_LATEST_TAG}"
|
||||
|
||||
echo "CLOUDREVE_LATEST_TAG:${CLOUDREVE_LATEST_TAG}"
|
||||
echo ::set-output name=tags::${TAGS}
|
||||
- name: Setup QEMU Emulator
|
||||
uses: docker/setup-qemu-action@master
|
||||
with:
|
||||
platforms: all
|
||||
- name: Setup Docker Buildx Command
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@master
|
||||
- name: Login to Dockerhub
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
- name: Build Docker Image and Push
|
||||
id: docker_build
|
||||
uses: docker/build-push-action@v2
|
||||
with:
|
||||
push: true
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
tags: ${{ steps.envs.outputs.tags }}
|
||||
- name: Update Docker Hub Description
|
||||
uses: peter-evans/dockerhub-description@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
repository: cloudreve/cloudreve
|
||||
short-description: ${{ github.event.repository.description }}
|
||||
- name: Image Digest
|
||||
run: echo ${{ steps.docker_build.outputs.digest }}
|
35
.github/workflows/test.yml
vendored
Normal file
35
.github/workflows/test.yml
vendored
Normal file
@ -0,0 +1,35 @@
|
||||
name: Test
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
push:
|
||||
branches: [master]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Set up Go 1.20
|
||||
uses: actions/setup-go@v2
|
||||
with:
|
||||
go-version: "1.20"
|
||||
id: go
|
||||
|
||||
- name: Check out code into the Go module directory
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
submodules: "recursive"
|
||||
|
||||
- name: Build static files
|
||||
run: |
|
||||
mkdir assets/build
|
||||
touch assets/build/test.html
|
||||
|
||||
- name: Test
|
||||
run: go test -coverprofile=coverage.txt -covermode=atomic ./...
|
||||
|
||||
- name: Upload coverage reports to Codecov
|
||||
uses: codecov/codecov-action@v2
|
33
.gitignore
vendored
Normal file
33
.gitignore
vendored
Normal file
@ -0,0 +1,33 @@
|
||||
# Binaries for programs and plugins
|
||||
cloudreve
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
*.db
|
||||
*.bin
|
||||
/release/
|
||||
assets.zip
|
||||
|
||||
# Test binary, build with `go test -c`
|
||||
*.test
|
||||
|
||||
# Output of the go coverage tool, specifically when used with LiteIDE
|
||||
*.out
|
||||
|
||||
# Development enviroment
|
||||
.idea/*
|
||||
uploads/*
|
||||
temp
|
||||
|
||||
# Version control
|
||||
version.lock
|
||||
|
||||
# Config file
|
||||
*.ini
|
||||
conf/conf.ini
|
||||
/statik/
|
||||
.vscode/
|
||||
|
||||
dist/
|
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
[submodule "assets"]
|
||||
path = assets
|
||||
url = https://git.loliquq.cn/earthjasonlin/CloudrevePlus-frontend.git
|
121
.goreleaser.yaml
Normal file
121
.goreleaser.yaml
Normal file
@ -0,0 +1,121 @@
|
||||
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
|
||||
|
||||
binary: cloudreve
|
||||
|
||||
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"
|
17
Dockerfile
Normal file
17
Dockerfile
Normal file
@ -0,0 +1,17 @@
|
||||
FROM alpine:latest
|
||||
|
||||
WORKDIR /cloudreve
|
||||
COPY cloudreve ./cloudreve
|
||||
|
||||
RUN apk update \
|
||||
&& apk add --no-cache tzdata \
|
||||
&& cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
|
||||
&& echo "Asia/Shanghai" > /etc/timezone \
|
||||
&& chmod +x ./cloudreve \
|
||||
&& mkdir -p /data/aria2 \
|
||||
&& chmod -R 766 /data/aria2
|
||||
|
||||
EXPOSE 5212
|
||||
VOLUME ["/cloudreve/uploads", "/cloudreve/avatar", "/data"]
|
||||
|
||||
ENTRYPOINT ["./cloudreve"]
|
674
LICENSE
Normal file
674
LICENSE
Normal file
@ -0,0 +1,674 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<http://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<http://www.gnu.org/philosophy/why-not-lgpl.html>.
|
37
README.md
Normal file
37
README.md
Normal file
@ -0,0 +1,37 @@
|
||||
## CloudrevePlus
|
||||
### 简介
|
||||
+ 🌩 支持多家云存储的云盘系统
|
||||
+ 基于 [3.8.3开源版本](https://github.com/cloudreve/Cloudreve/releases/tag/3.8.3) 二次开发
|
||||
+ 拉取主线最新版源码
|
||||
+ 更新依赖至较新版本
|
||||
+ 合并部分pr
|
||||
- [frontend#167](https://github.com/cloudreve/frontend/pull/167)
|
||||
- [backend#1911](https://github.com/cloudreve/Cloudreve/pull/1911)
|
||||
- [backend#1949](https://github.com/cloudreve/Cloudreve/pull/1949)
|
||||
+ 修复部分已知Bug
|
||||
+ 添加一些实用功能
|
||||
|
||||
### 使用
|
||||
+ 无需修改启动脚本,正常运行即可
|
||||
+ 使用原有社区版数据库需备份后执行以下命令:
|
||||
```
|
||||
./cloudreveplus --database-script OSSToPlus
|
||||
```
|
||||
|
||||
### 编译
|
||||
+ 还是如果不需要修改前端,直接构建后端即可,前端包已预置
|
||||
+ 前端
|
||||
- 环境:NodeJS v16.20 *
|
||||
- 进入 assets 目录:`cd assets`
|
||||
- 安装依赖:`yarn install` *
|
||||
- 构建静态:`yarn build` *
|
||||
- 打包文件:`bash pakstatics.sh`
|
||||
- (注:包管理器一定要用yarn,否则会报错)
|
||||
+ 后端
|
||||
- 环境:Golang >= 1.18,越新越好
|
||||
- 进入源码目录
|
||||
- 构建程序:`go build -ldflags "-s -w" -tags "go_json" .`
|
||||
|
||||
### 其它
|
||||
+ 未经完整测试,建议不要用于生产环境
|
||||
+ “仅供交流学习使用,严禁用于非法目的,否则造成一切后果自负”
|
104
README_zh-CN.md
Normal file
104
README_zh-CN.md
Normal file
@ -0,0 +1,104 @@
|
||||
[English Version](https://github.com/cloudreve/Cloudreve/blob/master/README.md)
|
||||
|
||||
<h1 align="center">
|
||||
<br>
|
||||
<a href="https://cloudreve.org/" alt="logo" ><img src="https://raw.githubusercontent.com/cloudreve/frontend/master/public/static/img/logo192.png" width="150"/></a>
|
||||
<br>
|
||||
Cloudreve
|
||||
<br>
|
||||
</h1>
|
||||
|
||||
<h4 align="center">支持多家云存储驱动的公有云文件系统.</h4>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/cloudreve/Cloudreve/actions/workflows/test.yml">
|
||||
<img src="https://img.shields.io/github/actions/workflow/status/cloudreve/Cloudreve/test.yml?branch=master&style=flat-square"
|
||||
alt="GitHub Test Workflow">
|
||||
</a>
|
||||
<a href="https://codecov.io/gh/cloudreve/Cloudreve"><img src="https://img.shields.io/codecov/c/github/cloudreve/Cloudreve?style=flat-square"></a>
|
||||
<a href="https://goreportcard.com/report/github.com/cloudreve/Cloudreve">
|
||||
<img src="https://goreportcard.com/badge/github.com/cloudreve/Cloudreve?style=flat-square">
|
||||
</a>
|
||||
<a href="https://github.com/cloudreve/Cloudreve/releases">
|
||||
<img src="https://img.shields.io/github/v/release/cloudreve/Cloudreve?include_prereleases&style=flat-square" />
|
||||
</a>
|
||||
<a href="https://hub.docker.com/r/cloudreve/cloudreve">
|
||||
<img src="https://img.shields.io/docker/image-size/cloudreve/cloudreve?style=flat-square"/>
|
||||
</a>
|
||||
</p>
|
||||
<p align="center">
|
||||
<a href="https://cloudreve.org">主页</a> •
|
||||
<a href="https://demo.cloudreve.org">演示站</a> •
|
||||
<a href="https://forum.cloudreve.org/">讨论社区</a> •
|
||||
<a href="https://docs.cloudreve.org/">文档</a> •
|
||||
<a href="https://github.com/cloudreve/Cloudreve/releases">下载</a> •
|
||||
<a href="https://t.me/cloudreve_official">Telegram 群组</a> •
|
||||
<a href="#scroll-许可证">许可证</a>
|
||||
</p>
|
||||
|
||||
|
||||
![Screenshot](https://raw.githubusercontent.com/cloudreve/docs/master/images/homepage.png)
|
||||
|
||||
## :sparkles: 特性
|
||||
|
||||
* :cloud: 支持本机、从机、七牛、阿里云 OSS、腾讯云 COS、又拍云、OneDrive (包括世纪互联版) 、S3兼容协议 作为存储端
|
||||
* :outbox_tray: 上传/下载 支持客户端直传,支持下载限速
|
||||
* 💾 可对接 Aria2 离线下载,可使用多个从机节点分担下载任务
|
||||
* 📚 在线 压缩/解压缩、多文件打包下载
|
||||
* 💻 覆盖全部存储策略的 WebDAV 协议支持
|
||||
* :zap: 拖拽上传、目录上传、流式上传处理
|
||||
* :card_file_box: 文件拖拽管理
|
||||
* :family_woman_girl_boy: 多用户、用户组、多存储策略
|
||||
* :link: 创建文件、目录的分享链接,可设定自动过期
|
||||
* :eye_speech_bubble: 视频、图像、音频、 ePub 在线预览,文本、Office 文档在线编辑
|
||||
* :art: 自定义配色、黑暗模式、PWA 应用、全站单页应用、国际化支持
|
||||
* :rocket: All-In-One 打包,开箱即用
|
||||
* 🌈 ... ...
|
||||
|
||||
## :hammer_and_wrench: 部署
|
||||
|
||||
下载适用于您目标机器操作系统、CPU架构的主程序,直接运行即可。
|
||||
|
||||
```shell
|
||||
# 解压程序包
|
||||
tar -zxvf cloudreve_VERSION_OS_ARCH.tar.gz
|
||||
|
||||
# 赋予执行权限
|
||||
chmod +x ./cloudreve
|
||||
|
||||
# 启动 Cloudreve
|
||||
./cloudreve
|
||||
```
|
||||
|
||||
以上为最简单的部署示例,您可以参考 [文档 - 起步](https://docs.cloudreve.org/) 进行更为完善的部署。
|
||||
|
||||
## :gear: 构建
|
||||
|
||||
自行构建前需要拥有 `Go >= 1.18`、`node.js`、`yarn`、`zip`, [goreleaser](https://goreleaser.com/intro/) 等必要依赖。
|
||||
|
||||
#### 安装 goreleaser
|
||||
|
||||
```shell
|
||||
go install github.com/goreleaser/goreleaser@latest
|
||||
```
|
||||
|
||||
#### 克隆代码
|
||||
|
||||
```shell
|
||||
git clone --recurse-submodules https://github.com/cloudreve/Cloudreve.git
|
||||
```
|
||||
|
||||
#### 编译项目
|
||||
|
||||
```shell
|
||||
goreleaser build --clean --single-target --snapshot
|
||||
```
|
||||
|
||||
## :alembic: 技术栈
|
||||
|
||||
* [Go](https://golang.org/) + [Gin](https://github.com/gin-gonic/gin)
|
||||
* [React](https://github.com/facebook/react) + [Redux](https://github.com/reduxjs/redux) + [Material-UI](https://github.com/mui-org/material-ui)
|
||||
|
||||
## :scroll: 许可证
|
||||
|
||||
GPL V3
|
1
assets
Submodule
1
assets
Submodule
@ -0,0 +1 @@
|
||||
Subproject commit 20c1fa08dc78024a19242ca67431aae61cb4eebd
|
125
bootstrap/app.go
Normal file
125
bootstrap/app.go
Normal file
File diff suppressed because one or more lines are too long
3
bootstrap/constant/constant.go
Normal file
3
bootstrap/constant/constant.go
Normal file
@ -0,0 +1,3 @@
|
||||
package constant
|
||||
|
||||
// var HashIDTable = []int{0, 1, 2, 3, 4, 5}
|
432
bootstrap/embed.go
Normal file
432
bootstrap/embed.go
Normal file
@ -0,0 +1,432 @@
|
||||
// Copyright 2020 The Go Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
// Package embed provides access to files embedded in the running Go program.
|
||||
//
|
||||
// Go source files that import "embed" can use the //go:embed directive
|
||||
// to initialize a variable of type string, []byte, or FS with the contents of
|
||||
// files read from the package directory or subdirectories at compile time.
|
||||
//
|
||||
// For example, here are three ways to embed a file named hello.txt
|
||||
// and then print its contents at run time.
|
||||
//
|
||||
// Embedding one file into a string:
|
||||
//
|
||||
// import _ "embed"
|
||||
//
|
||||
// //go:embed hello.txt
|
||||
// var s string
|
||||
// print(s)
|
||||
//
|
||||
// Embedding one file into a slice of bytes:
|
||||
//
|
||||
// import _ "embed"
|
||||
//
|
||||
// //go:embed hello.txt
|
||||
// var b []byte
|
||||
// print(string(b))
|
||||
//
|
||||
// Embedded one or more files into a file system:
|
||||
//
|
||||
// import "embed"
|
||||
//
|
||||
// //go:embed hello.txt
|
||||
// var f embed.FS
|
||||
// data, _ := f.ReadFile("hello.txt")
|
||||
// print(string(data))
|
||||
//
|
||||
// # Directives
|
||||
//
|
||||
// A //go:embed directive above a variable declaration specifies which files to embed,
|
||||
// using one or more path.Match patterns.
|
||||
//
|
||||
// The directive must immediately precede a line containing the declaration of a single variable.
|
||||
// Only blank lines and ‘//’ line comments are permitted between the directive and the declaration.
|
||||
//
|
||||
// The type of the variable must be a string type, or a slice of a byte type,
|
||||
// or FS (or an alias of FS).
|
||||
//
|
||||
// For example:
|
||||
//
|
||||
// package server
|
||||
//
|
||||
// import "embed"
|
||||
//
|
||||
// // content holds our static web server content.
|
||||
// //go:embed image/* template/*
|
||||
// //go:embed html/index.html
|
||||
// var content embed.FS
|
||||
//
|
||||
// The Go build system will recognize the directives and arrange for the declared variable
|
||||
// (in the example above, content) to be populated with the matching files from the file system.
|
||||
//
|
||||
// The //go:embed directive accepts multiple space-separated patterns for
|
||||
// brevity, but it can also be repeated, to avoid very long lines when there are
|
||||
// many patterns. The patterns are interpreted relative to the package directory
|
||||
// containing the source file. The path separator is a forward slash, even on
|
||||
// Windows systems. Patterns may not contain ‘.’ or ‘..’ or empty path elements,
|
||||
// nor may they begin or end with a slash. To match everything in the current
|
||||
// directory, use ‘*’ instead of ‘.’. To allow for naming files with spaces in
|
||||
// their names, patterns can be written as Go double-quoted or back-quoted
|
||||
// string literals.
|
||||
//
|
||||
// If a pattern names a directory, all files in the subtree rooted at that directory are
|
||||
// embedded (recursively), except that files with names beginning with ‘.’ or ‘_’
|
||||
// are excluded. So the variable in the above example is almost equivalent to:
|
||||
//
|
||||
// // content is our static web server content.
|
||||
// //go:embed image template html/index.html
|
||||
// var content embed.FS
|
||||
//
|
||||
// The difference is that ‘image/*’ embeds ‘image/.tempfile’ while ‘image’ does not.
|
||||
// Neither embeds ‘image/dir/.tempfile’.
|
||||
//
|
||||
// If a pattern begins with the prefix ‘all:’, then the rule for walking directories is changed
|
||||
// to include those files beginning with ‘.’ or ‘_’. For example, ‘all:image’ embeds
|
||||
// both ‘image/.tempfile’ and ‘image/dir/.tempfile’.
|
||||
//
|
||||
// The //go:embed directive can be used with both exported and unexported variables,
|
||||
// depending on whether the package wants to make the data available to other packages.
|
||||
// It can only be used with variables at package scope, not with local variables.
|
||||
//
|
||||
// Patterns must not match files outside the package's module, such as ‘.git/*’ or symbolic links.
|
||||
// Patterns must not match files whose names include the special punctuation characters " * < > ? ` ' | / \ and :.
|
||||
// Matches for empty directories are ignored. After that, each pattern in a //go:embed line
|
||||
// must match at least one file or non-empty directory.
|
||||
//
|
||||
// If any patterns are invalid or have invalid matches, the build will fail.
|
||||
//
|
||||
// # Strings and Bytes
|
||||
//
|
||||
// The //go:embed line for a variable of type string or []byte can have only a single pattern,
|
||||
// and that pattern can match only a single file. The string or []byte is initialized with
|
||||
// the contents of that file.
|
||||
//
|
||||
// The //go:embed directive requires importing "embed", even when using a string or []byte.
|
||||
// In source files that don't refer to embed.FS, use a blank import (import _ "embed").
|
||||
//
|
||||
// # File Systems
|
||||
//
|
||||
// For embedding a single file, a variable of type string or []byte is often best.
|
||||
// The FS type enables embedding a tree of files, such as a directory of static
|
||||
// web server content, as in the example above.
|
||||
//
|
||||
// FS implements the io/fs package's FS interface, so it can be used with any package that
|
||||
// understands file systems, including net/http, text/template, and html/template.
|
||||
//
|
||||
// For example, given the content variable in the example above, we can write:
|
||||
//
|
||||
// http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(content))))
|
||||
//
|
||||
// template.ParseFS(content, "*.tmpl")
|
||||
//
|
||||
// # Tools
|
||||
//
|
||||
// To support tools that analyze Go packages, the patterns found in //go:embed lines
|
||||
// are available in “go list” output. See the EmbedPatterns, TestEmbedPatterns,
|
||||
// and XTestEmbedPatterns fields in the “go help list” output.
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"io/fs"
|
||||
"time"
|
||||
)
|
||||
|
||||
// An FS is a read-only collection of files, usually initialized with a //go:embed directive.
|
||||
// When declared without a //go:embed directive, an FS is an empty file system.
|
||||
//
|
||||
// An FS is a read-only value, so it is safe to use from multiple goroutines
|
||||
// simultaneously and also safe to assign values of type FS to each other.
|
||||
//
|
||||
// FS implements fs.FS, so it can be used with any package that understands
|
||||
// file system interfaces, including net/http, text/template, and html/template.
|
||||
//
|
||||
// See the package documentation for more details about initializing an FS.
|
||||
type FS struct {
|
||||
// The compiler knows the layout of this struct.
|
||||
// See cmd/compile/internal/staticdata's WriteEmbed.
|
||||
//
|
||||
// The files list is sorted by name but not by simple string comparison.
|
||||
// Instead, each file's name takes the form "dir/elem" or "dir/elem/".
|
||||
// The optional trailing slash indicates that the file is itself a directory.
|
||||
// The files list is sorted first by dir (if dir is missing, it is taken to be ".")
|
||||
// and then by base, so this list of files:
|
||||
//
|
||||
// p
|
||||
// q/
|
||||
// q/r
|
||||
// q/s/
|
||||
// q/s/t
|
||||
// q/s/u
|
||||
// q/v
|
||||
// w
|
||||
//
|
||||
// is actually sorted as:
|
||||
//
|
||||
// p # dir=. elem=p
|
||||
// q/ # dir=. elem=q
|
||||
// w/ # dir=. elem=w
|
||||
// q/r # dir=q elem=r
|
||||
// q/s/ # dir=q elem=s
|
||||
// q/v # dir=q elem=v
|
||||
// q/s/t # dir=q/s elem=t
|
||||
// q/s/u # dir=q/s elem=u
|
||||
//
|
||||
// This order brings directory contents together in contiguous sections
|
||||
// of the list, allowing a directory read to use binary search to find
|
||||
// the relevant sequence of entries.
|
||||
files *[]file
|
||||
}
|
||||
|
||||
// split splits the name into dir and elem as described in the
|
||||
// comment in the FS struct above. isDir reports whether the
|
||||
// final trailing slash was present, indicating that name is a directory.
|
||||
func split(name string) (dir, elem string, isDir bool) {
|
||||
if name[len(name)-1] == '/' {
|
||||
isDir = true
|
||||
name = name[:len(name)-1]
|
||||
}
|
||||
i := len(name) - 1
|
||||
for i >= 0 && name[i] != '/' {
|
||||
i--
|
||||
}
|
||||
if i < 0 {
|
||||
return ".", name, isDir
|
||||
}
|
||||
return name[:i], name[i+1:], isDir
|
||||
}
|
||||
|
||||
// trimSlash trims a trailing slash from name, if present,
|
||||
// returning the possibly shortened name.
|
||||
func trimSlash(name string) string {
|
||||
if len(name) > 0 && name[len(name)-1] == '/' {
|
||||
return name[:len(name)-1]
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
var (
|
||||
_ fs.ReadDirFS = FS{}
|
||||
_ fs.ReadFileFS = FS{}
|
||||
)
|
||||
|
||||
// A file is a single file in the FS.
|
||||
// It implements fs.FileInfo and fs.DirEntry.
|
||||
type file struct {
|
||||
// The compiler knows the layout of this struct.
|
||||
// See cmd/compile/internal/staticdata's WriteEmbed.
|
||||
name string
|
||||
data string
|
||||
hash [16]byte // truncated SHA256 hash
|
||||
}
|
||||
|
||||
var (
|
||||
_ fs.FileInfo = (*file)(nil)
|
||||
_ fs.DirEntry = (*file)(nil)
|
||||
)
|
||||
|
||||
func (f *file) Name() string { _, elem, _ := split(f.name); return elem }
|
||||
func (f *file) Size() int64 { return int64(len(f.data)) }
|
||||
func (f *file) ModTime() time.Time { return time.Time{} }
|
||||
func (f *file) IsDir() bool { _, _, isDir := split(f.name); return isDir }
|
||||
func (f *file) Sys() any { return nil }
|
||||
func (f *file) Type() fs.FileMode { return f.Mode().Type() }
|
||||
func (f *file) Info() (fs.FileInfo, error) { return f, nil }
|
||||
|
||||
func (f *file) Mode() fs.FileMode {
|
||||
if f.IsDir() {
|
||||
return fs.ModeDir | 0555
|
||||
}
|
||||
return 0444
|
||||
}
|
||||
|
||||
// dotFile is a file for the root directory,
|
||||
// which is omitted from the files list in a FS.
|
||||
var dotFile = &file{name: "./"}
|
||||
|
||||
// lookup returns the named file, or nil if it is not present.
|
||||
func (f FS) lookup(name string) *file {
|
||||
if !fs.ValidPath(name) {
|
||||
// The compiler should never emit a file with an invalid name,
|
||||
// so this check is not strictly necessary (if name is invalid,
|
||||
// we shouldn't find a match below), but it's a good backstop anyway.
|
||||
return nil
|
||||
}
|
||||
if name == "." {
|
||||
return dotFile
|
||||
}
|
||||
if f.files == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Binary search to find where name would be in the list,
|
||||
// and then check if name is at that position.
|
||||
dir, elem, _ := split(name)
|
||||
files := *f.files
|
||||
i := sortSearch(len(files), func(i int) bool {
|
||||
idir, ielem, _ := split(files[i].name)
|
||||
return idir > dir || idir == dir && ielem >= elem
|
||||
})
|
||||
if i < len(files) && trimSlash(files[i].name) == name {
|
||||
return &files[i]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// readDir returns the list of files corresponding to the directory dir.
|
||||
func (f FS) readDir(dir string) []file {
|
||||
if f.files == nil {
|
||||
return nil
|
||||
}
|
||||
// Binary search to find where dir starts and ends in the list
|
||||
// and then return that slice of the list.
|
||||
files := *f.files
|
||||
i := sortSearch(len(files), func(i int) bool {
|
||||
idir, _, _ := split(files[i].name)
|
||||
return idir >= dir
|
||||
})
|
||||
j := sortSearch(len(files), func(j int) bool {
|
||||
jdir, _, _ := split(files[j].name)
|
||||
return jdir > dir
|
||||
})
|
||||
return files[i:j]
|
||||
}
|
||||
|
||||
// Open opens the named file for reading and returns it as an fs.File.
|
||||
//
|
||||
// The returned file implements io.Seeker when the file is not a directory.
|
||||
func (f FS) Open(name string) (fs.File, error) {
|
||||
file := f.lookup(name)
|
||||
if file == nil {
|
||||
return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrNotExist}
|
||||
}
|
||||
if file.IsDir() {
|
||||
return &openDir{file, f.readDir(name), 0}, nil
|
||||
}
|
||||
return &openFile{file, 0}, nil
|
||||
}
|
||||
|
||||
// ReadDir reads and returns the entire named directory.
|
||||
func (f FS) ReadDir(name string) ([]fs.DirEntry, error) {
|
||||
file, err := f.Open(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dir, ok := file.(*openDir)
|
||||
if !ok {
|
||||
return nil, &fs.PathError{Op: "read", Path: name, Err: errors.New("not a directory")}
|
||||
}
|
||||
list := make([]fs.DirEntry, len(dir.files))
|
||||
for i := range list {
|
||||
list[i] = &dir.files[i]
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
// ReadFile reads and returns the content of the named file.
|
||||
func (f FS) ReadFile(name string) ([]byte, error) {
|
||||
file, err := f.Open(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ofile, ok := file.(*openFile)
|
||||
if !ok {
|
||||
return nil, &fs.PathError{Op: "read", Path: name, Err: errors.New("is a directory")}
|
||||
}
|
||||
return []byte(ofile.f.data), nil
|
||||
}
|
||||
|
||||
// An openFile is a regular file open for reading.
|
||||
type openFile struct {
|
||||
f *file // the file itself
|
||||
offset int64 // current read offset
|
||||
}
|
||||
|
||||
var (
|
||||
_ io.Seeker = (*openFile)(nil)
|
||||
)
|
||||
|
||||
func (f *openFile) Close() error { return nil }
|
||||
func (f *openFile) Stat() (fs.FileInfo, error) { return f.f, nil }
|
||||
|
||||
func (f *openFile) Read(b []byte) (int, error) {
|
||||
if f.offset >= int64(len(f.f.data)) {
|
||||
return 0, io.EOF
|
||||
}
|
||||
if f.offset < 0 {
|
||||
return 0, &fs.PathError{Op: "read", Path: f.f.name, Err: fs.ErrInvalid}
|
||||
}
|
||||
n := copy(b, f.f.data[f.offset:])
|
||||
f.offset += int64(n)
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (f *openFile) Seek(offset int64, whence int) (int64, error) {
|
||||
switch whence {
|
||||
case 0:
|
||||
// offset += 0
|
||||
case 1:
|
||||
offset += f.offset
|
||||
case 2:
|
||||
offset += int64(len(f.f.data))
|
||||
}
|
||||
if offset < 0 || offset > int64(len(f.f.data)) {
|
||||
return 0, &fs.PathError{Op: "seek", Path: f.f.name, Err: fs.ErrInvalid}
|
||||
}
|
||||
f.offset = offset
|
||||
return offset, nil
|
||||
}
|
||||
|
||||
// An openDir is a directory open for reading.
|
||||
type openDir struct {
|
||||
f *file // the directory file itself
|
||||
files []file // the directory contents
|
||||
offset int // the read offset, an index into the files slice
|
||||
}
|
||||
|
||||
func (d *openDir) Close() error { return nil }
|
||||
func (d *openDir) Stat() (fs.FileInfo, error) { return d.f, nil }
|
||||
|
||||
func (d *openDir) Read([]byte) (int, error) {
|
||||
return 0, &fs.PathError{Op: "read", Path: d.f.name, Err: errors.New("is a directory")}
|
||||
}
|
||||
|
||||
func (d *openDir) ReadDir(count int) ([]fs.DirEntry, error) {
|
||||
n := len(d.files) - d.offset
|
||||
if n == 0 {
|
||||
if count <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, io.EOF
|
||||
}
|
||||
if count > 0 && n > count {
|
||||
n = count
|
||||
}
|
||||
list := make([]fs.DirEntry, n)
|
||||
for i := range list {
|
||||
list[i] = &d.files[d.offset+i]
|
||||
}
|
||||
d.offset += n
|
||||
return list, nil
|
||||
}
|
||||
|
||||
// sortSearch is like sort.Search, avoiding an import.
|
||||
func sortSearch(n int, f func(int) bool) int {
|
||||
// Define f(-1) == false and f(n) == true.
|
||||
// Invariant: f(i-1) == false, f(j) == true.
|
||||
i, j := 0, n
|
||||
for i < j {
|
||||
h := int(uint(i+j) >> 1) // avoid overflow when computing h
|
||||
// i ≤ h < j
|
||||
if !f(h) {
|
||||
i = h + 1 // preserves f(i-1) == false
|
||||
} else {
|
||||
j = h // preserves f(j) == true
|
||||
}
|
||||
}
|
||||
// i == j, f(i-1) == false, and f(j) (= f(i)) == true => answer is i.
|
||||
return i
|
||||
}
|
75
bootstrap/fs.go
Normal file
75
bootstrap/fs.go
Normal file
@ -0,0 +1,75 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"crypto/sha256"
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/util"
|
||||
"github.com/pkg/errors"
|
||||
"io"
|
||||
"io/fs"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func NewFS(zipContent string) fs.FS {
|
||||
zipReader, err := zip.NewReader(strings.NewReader(zipContent), int64(len(zipContent)))
|
||||
if err != nil {
|
||||
util.Log().Panic("Static resource is not a valid zip file: %s", err)
|
||||
}
|
||||
|
||||
var files []file
|
||||
err = fs.WalkDir(zipReader, ".", func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return errors.Errorf("无法获取[%s]的信息, %s, 跳过...", path, err)
|
||||
}
|
||||
|
||||
if path == "." {
|
||||
return nil
|
||||
}
|
||||
|
||||
var f file
|
||||
if d.IsDir() {
|
||||
f.name = path + "/"
|
||||
} else {
|
||||
f.name = path
|
||||
|
||||
rc, err := zipReader.Open(path)
|
||||
if err != nil {
|
||||
return errors.Errorf("无法打开文件[%s], %s, 跳过...", path, err)
|
||||
}
|
||||
defer rc.Close()
|
||||
|
||||
data, err := io.ReadAll(rc)
|
||||
if err != nil {
|
||||
return errors.Errorf("无法读取文件[%s], %s, 跳过...", path, err)
|
||||
}
|
||||
|
||||
f.data = string(data)
|
||||
|
||||
hash := sha256.Sum256(data)
|
||||
for i := range f.hash {
|
||||
f.hash[i] = ^hash[i]
|
||||
}
|
||||
}
|
||||
files = append(files, f)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
util.Log().Panic("初始化静态资源失败: %s", err)
|
||||
}
|
||||
|
||||
sort.Slice(files, func(i, j int) bool {
|
||||
fi, fj := files[i], files[j]
|
||||
di, ei, _ := split(fi.name)
|
||||
dj, ej, _ := split(fj.name)
|
||||
|
||||
if di != dj {
|
||||
return di < dj
|
||||
}
|
||||
return ei < ej
|
||||
})
|
||||
|
||||
var embedFS FS
|
||||
embedFS.files = &files
|
||||
return embedFS
|
||||
}
|
133
bootstrap/init.go
Normal file
133
bootstrap/init.go
Normal file
@ -0,0 +1,133 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"path/filepath"
|
||||
|
||||
model "github.com/cloudreve/Cloudreve/v3/models"
|
||||
"github.com/cloudreve/Cloudreve/v3/models/scripts"
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/aria2"
|
||||
"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/crontab"
|
||||
"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"
|
||||
)
|
||||
|
||||
// Init 初始化启动
|
||||
func Init(path string, statics fs.FS) {
|
||||
InitApplication()
|
||||
conf.Init(path)
|
||||
// Debug 关闭时,切换为生产模式
|
||||
if !conf.SystemConfig.Debug {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
}
|
||||
|
||||
dependencies := []struct {
|
||||
mode string
|
||||
factory func()
|
||||
}{
|
||||
{
|
||||
"both",
|
||||
func() {
|
||||
scripts.Init()
|
||||
},
|
||||
},
|
||||
{
|
||||
"both",
|
||||
func() {
|
||||
cache.Init()
|
||||
},
|
||||
},
|
||||
{
|
||||
"slave",
|
||||
func() {
|
||||
model.InitSlaveDefaults()
|
||||
},
|
||||
},
|
||||
{
|
||||
"slave",
|
||||
func() {
|
||||
cache.InitSlaveOverwrites()
|
||||
},
|
||||
},
|
||||
{
|
||||
"master",
|
||||
func() {
|
||||
model.Init()
|
||||
},
|
||||
},
|
||||
{
|
||||
"both",
|
||||
func() {
|
||||
cache.Restore(filepath.Join(model.GetSettingByName("temp_path"), cache.DefaultCacheFile))
|
||||
},
|
||||
},
|
||||
{
|
||||
"both",
|
||||
func() {
|
||||
task.Init()
|
||||
},
|
||||
},
|
||||
{
|
||||
"master",
|
||||
func() {
|
||||
cluster.Init()
|
||||
},
|
||||
},
|
||||
{
|
||||
"master",
|
||||
func() {
|
||||
aria2.Init(false, cluster.Default, mq.GlobalMQ)
|
||||
},
|
||||
},
|
||||
{
|
||||
"master",
|
||||
func() {
|
||||
email.Init()
|
||||
},
|
||||
},
|
||||
{
|
||||
"master",
|
||||
func() {
|
||||
crontab.Init()
|
||||
},
|
||||
},
|
||||
{
|
||||
"master",
|
||||
func() {
|
||||
InitStatic(statics)
|
||||
},
|
||||
},
|
||||
{
|
||||
"slave",
|
||||
func() {
|
||||
cluster.InitController()
|
||||
},
|
||||
},
|
||||
{
|
||||
"both",
|
||||
func() {
|
||||
auth.Init()
|
||||
},
|
||||
},
|
||||
{
|
||||
"master",
|
||||
func() {
|
||||
wopi.Init()
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, dependency := range dependencies {
|
||||
if dependency.mode == conf.SystemConfig.Mode || dependency.mode == "both" {
|
||||
dependency.factory()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
18
bootstrap/script.go
Normal file
18
bootstrap/script.go
Normal file
@ -0,0 +1,18 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/cloudreve/Cloudreve/v3/models/scripts/invoker"
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/util"
|
||||
)
|
||||
|
||||
func RunScript(name string) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
if err := invoker.RunDBScript(name, ctx); err != nil {
|
||||
util.Log().Error("Failed to execute database script: %s", err)
|
||||
return
|
||||
}
|
||||
|
||||
util.Log().Info("Finish executing database script %q.", name)
|
||||
}
|
136
bootstrap/static.go
Normal file
136
bootstrap/static.go
Normal file
@ -0,0 +1,136 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/conf"
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/util"
|
||||
|
||||
"github.com/gin-contrib/static"
|
||||
)
|
||||
|
||||
const StaticFolder = "statics"
|
||||
|
||||
type GinFS struct {
|
||||
FS http.FileSystem
|
||||
}
|
||||
|
||||
type staticVersion struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
// StaticFS 内置静态文件资源
|
||||
var StaticFS static.ServeFileSystem
|
||||
|
||||
// Open 打开文件
|
||||
func (b *GinFS) Open(name string) (http.File, error) {
|
||||
return b.FS.Open(name)
|
||||
}
|
||||
|
||||
// Exists 文件是否存在
|
||||
func (b *GinFS) Exists(prefix string, filepath string) bool {
|
||||
if _, err := b.FS.Open(filepath); err != nil {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// InitStatic 初始化静态资源文件
|
||||
func InitStatic(statics fs.FS) {
|
||||
if util.Exists(util.RelativePath(StaticFolder)) {
|
||||
util.Log().Info("Folder with name \"statics\" already exists, it will be used to serve static files.")
|
||||
StaticFS = static.LocalFile(util.RelativePath("statics"), false)
|
||||
} else {
|
||||
// 初始化静态资源
|
||||
embedFS, err := fs.Sub(statics, "assets/build")
|
||||
if err != nil {
|
||||
util.Log().Panic("Failed to initialize static resources: %s", err)
|
||||
}
|
||||
|
||||
StaticFS = &GinFS{
|
||||
FS: http.FS(embedFS),
|
||||
}
|
||||
}
|
||||
// 检查静态资源的版本
|
||||
f, err := StaticFS.Open("version.json")
|
||||
if err != nil {
|
||||
util.Log().Warning("Missing version identifier file in static resources, please delete \"statics\" folder and rebuild it.")
|
||||
return
|
||||
}
|
||||
|
||||
b, err := io.ReadAll(f)
|
||||
if err != nil {
|
||||
util.Log().Warning("Failed to read version identifier file in static resources, please delete \"statics\" folder and rebuild it.")
|
||||
return
|
||||
}
|
||||
|
||||
var v staticVersion
|
||||
if err := json.Unmarshal(b, &v); err != nil {
|
||||
util.Log().Warning("Failed to parse version identifier file in static resources: %s", err)
|
||||
return
|
||||
}
|
||||
|
||||
staticName := "cloudreve-frontend"
|
||||
if conf.IsPlus == "true" {
|
||||
staticName += "-plus"
|
||||
}
|
||||
|
||||
if v.Name != staticName {
|
||||
util.Log().Warning("Static resource version mismatch, please delete \"statics\" folder and rebuild it.")
|
||||
return
|
||||
}
|
||||
|
||||
if v.Version != conf.RequiredStaticVersion {
|
||||
util.Log().Warning("Static resource version mismatch [Current %s, Desired: %s],please delete \"statics\" folder and rebuild it.", v.Version, conf.RequiredStaticVersion)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Eject 抽离内置静态资源
|
||||
func Eject(statics fs.FS) {
|
||||
// 初始化静态资源
|
||||
embedFS, err := fs.Sub(statics, "assets/build")
|
||||
if err != nil {
|
||||
util.Log().Panic("Failed to initialize static resources: %s", err)
|
||||
}
|
||||
|
||||
// var walk func(relPath string, d fs.DirEntry, err error) error
|
||||
walk := func(relPath string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return errors.Errorf("Failed to read info of %q: %s, skipping...", relPath, err)
|
||||
}
|
||||
|
||||
if !d.IsDir() {
|
||||
// 写入文件
|
||||
out, err := util.CreatNestedFile(filepath.Join(util.RelativePath(""), StaticFolder, relPath))
|
||||
|
||||
if err != nil {
|
||||
return errors.Errorf("Failed to create file %q: %s, skipping...", relPath, err)
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
util.Log().Info("Ejecting %q...", relPath)
|
||||
obj, _ := embedFS.Open(relPath)
|
||||
if _, err := io.Copy(out, bufio.NewReader(obj)); err != nil {
|
||||
return errors.Errorf("Cannot write file %q: %s, skipping...", relPath, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// util.Log().Info("开始导出内置静态资源...")
|
||||
err = fs.WalkDir(embedFS, ".", walk)
|
||||
if err != nil {
|
||||
util.Log().Error("Error occurs while ejecting static resources: %s", err)
|
||||
return
|
||||
}
|
||||
util.Log().Info("Finish ejecting static resources.")
|
||||
}
|
45
docker-compose.yml
Normal file
45
docker-compose.yml
Normal file
@ -0,0 +1,45 @@
|
||||
version: "3.8"
|
||||
services:
|
||||
redis:
|
||||
container_name: redis
|
||||
image: bitnami/redis:latest
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- ALLOW_EMPTY_PASSWORD=yes
|
||||
volumes:
|
||||
- redis_data:/bitnami/redis/data
|
||||
|
||||
cloudreve:
|
||||
container_name: cloudreve
|
||||
image: cloudreve/cloudreve:latest
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "5212:5212"
|
||||
volumes:
|
||||
- temp_data:/data
|
||||
- ./cloudreve/uploads:/cloudreve/uploads
|
||||
- ./cloudreve/conf.ini:/cloudreve/conf.ini
|
||||
- ./cloudreve/cloudreve.db:/cloudreve/cloudreve.db
|
||||
- ./cloudreve/avatar:/cloudreve/avatar
|
||||
depends_on:
|
||||
- aria2
|
||||
|
||||
aria2:
|
||||
container_name: aria2
|
||||
image: p3terx/aria2-pro # third party image, please keep notice what you are doing
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- RPC_SECRET=your_aria_rpc_token # aria rpc token, customize your own
|
||||
- RPC_PORT=6800
|
||||
volumes:
|
||||
- ./aria2/config:/config
|
||||
- temp_data:/data
|
||||
volumes:
|
||||
redis_data:
|
||||
driver: local
|
||||
temp_data:
|
||||
driver: local
|
||||
driver_opts:
|
||||
type: none
|
||||
device: $PWD/data
|
||||
o: bind
|
180
go.mod
Normal file
180
go.mod
Normal file
@ -0,0 +1,180 @@
|
||||
module github.com/cloudreve/Cloudreve/v3
|
||||
|
||||
go 1.18
|
||||
|
||||
require (
|
||||
github.com/HFO4/aliyun-oss-go-sdk v2.2.3+incompatible
|
||||
github.com/aws/aws-sdk-go v1.31.5
|
||||
github.com/duo-labs/webauthn v0.0.0-20220330035159-03696f3d4499
|
||||
github.com/fatih/color v1.16.0
|
||||
github.com/gin-contrib/cors v1.3.0
|
||||
github.com/gin-contrib/gzip v0.0.2-0.20200226035851-25bef2ef21e8
|
||||
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
|
||||
github.com/gofrs/uuid v4.0.0+incompatible
|
||||
github.com/gomodule/redigo v2.0.0+incompatible
|
||||
github.com/google/go-querystring v1.0.0
|
||||
github.com/google/uuid v1.3.0
|
||||
github.com/gorilla/securecookie v1.1.1
|
||||
github.com/gorilla/sessions v1.2.1
|
||||
github.com/gorilla/websocket v1.4.2
|
||||
github.com/hashicorp/go-version v1.3.0
|
||||
github.com/iGoogle-ink/gopay v1.5.36
|
||||
github.com/jinzhu/gorm v1.9.11
|
||||
github.com/juju/ratelimit v1.0.1
|
||||
github.com/mholt/archiver/v4 v4.0.0-alpha.6
|
||||
github.com/mojocn/base64Captcha v0.0.0-20190801020520-752b1cd608b2
|
||||
github.com/pkg/errors v0.9.1
|
||||
github.com/pquerna/otp v1.2.0
|
||||
github.com/qingwg/payjs v0.0.0-20190928033402-c53dbe16b371
|
||||
github.com/qiniu/go-sdk/v7 v7.11.1
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
github.com/samber/lo v1.38.1
|
||||
github.com/smartwalle/alipay/v3 v3.2.20
|
||||
github.com/speps/go-hashids v2.0.0+incompatible
|
||||
github.com/stretchr/testify v1.8.3
|
||||
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/captcha v1.0.393
|
||||
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.393
|
||||
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/scf v1.0.393
|
||||
github.com/tencentyun/cos-go-sdk-v5 v0.0.0-20200120023323-87ff3bc489ac
|
||||
github.com/upyun/go-sdk v2.1.0+incompatible
|
||||
golang.org/x/image v0.0.0-20211028202545-6944b10bf410
|
||||
golang.org/x/time v0.0.0-20210220033141-f8bda1e9f3ba
|
||||
google.golang.org/api v0.45.0
|
||||
)
|
||||
|
||||
require (
|
||||
cloud.google.com/go v0.81.0 // indirect
|
||||
github.com/andybalholm/brotli v1.0.4 // indirect
|
||||
github.com/baiyubin/aliyun-sts-go-sdk v0.0.0-20180326062324-cfa1a18b161f // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/bgentry/speakeasy v0.1.0 // indirect
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
|
||||
github.com/census-instrumentation/opencensus-proto v0.3.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.1.1 // indirect
|
||||
github.com/cloudflare/cfssl v1.6.1 // indirect
|
||||
github.com/cncf/udpa/go v0.0.0-20210322005330-6414d713912e // indirect
|
||||
github.com/coreos/go-semver v0.3.0 // indirect
|
||||
github.com/coreos/go-systemd/v22 v22.3.2 // indirect
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.0 // indirect
|
||||
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.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
|
||||
github.com/fullstorydev/grpcurl v1.8.1 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.4.0 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-sql-driver/mysql v1.6.0 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/gogo/protobuf v1.3.2 // indirect
|
||||
github.com/golang-jwt/jwt/v4 v4.1.0 // indirect
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
||||
github.com/golang/mock v1.5.0 // indirect
|
||||
github.com/golang/protobuf v1.5.2 // indirect
|
||||
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.9 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.0.5 // indirect
|
||||
github.com/gorilla/context v1.1.1 // indirect
|
||||
github.com/grpc-ecosystem/go-grpc-middleware v1.3.0 // indirect
|
||||
github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway v1.16.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.0.0 // indirect
|
||||
github.com/jhump/protoreflect v1.8.2 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jmespath/go-jmespath v0.3.0 // indirect
|
||||
github.com/jonboulle/clockwork v0.2.2 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/compress v1.15.1 // indirect
|
||||
github.com/klauspost/pgzip v1.2.5 // indirect
|
||||
github.com/leodido/go-urn v1.2.4 // indirect
|
||||
github.com/lib/pq v1.10.9 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.12 // 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
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/mozillazg/go-httpheader v0.2.1 // indirect
|
||||
github.com/nwaples/rardecode/v2 v2.0.0-beta.2 // indirect
|
||||
github.com/olekukonko/tablewriter v0.0.5 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.0.8 // indirect
|
||||
github.com/pierrec/lz4/v4 v4.1.14 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/prometheus/client_golang v1.10.0 // indirect
|
||||
github.com/prometheus/client_model v0.2.0 // indirect
|
||||
github.com/prometheus/common v0.24.0 // indirect
|
||||
github.com/prometheus/procfs v0.6.0 // 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
|
||||
github.com/sirupsen/logrus v1.8.1 // indirect
|
||||
github.com/smartwalle/ncrypto v1.0.4 // indirect
|
||||
github.com/smartwalle/ngx v1.0.9 // indirect
|
||||
github.com/smartwalle/nsign v1.0.9 // indirect
|
||||
github.com/soheilhy/cmux v0.1.5 // indirect
|
||||
github.com/spf13/cobra v1.1.3 // indirect
|
||||
github.com/spf13/pflag v1.0.5 // indirect
|
||||
github.com/stretchr/objx v0.5.0 // indirect
|
||||
github.com/therootcompany/xz v1.0.1 // indirect
|
||||
github.com/tmc/grpc-websocket-proxy v0.0.0-20201229170055-e5319fda7802 // indirect
|
||||
github.com/ugorji/go/codec v1.2.11 // indirect
|
||||
github.com/ulikunitz/xz v0.5.10 // indirect
|
||||
github.com/urfave/cli v1.22.5 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2 // indirect
|
||||
go.etcd.io/bbolt v1.3.5 // indirect
|
||||
go.etcd.io/etcd/api/v3 v3.5.0-alpha.0 // indirect
|
||||
go.etcd.io/etcd/client/v2 v2.305.0-alpha.0 // indirect
|
||||
go.etcd.io/etcd/client/v3 v3.5.0-alpha.0 // indirect
|
||||
go.etcd.io/etcd/etcdctl/v3 v3.5.0-alpha.0 // indirect
|
||||
go.etcd.io/etcd/pkg/v3 v3.5.0-alpha.0 // indirect
|
||||
go.etcd.io/etcd/raft/v3 v3.5.0-alpha.0 // indirect
|
||||
go.etcd.io/etcd/server/v3 v3.5.0-alpha.0 // indirect
|
||||
go.etcd.io/etcd/tests/v3 v3.5.0-alpha.0 // indirect
|
||||
go.etcd.io/etcd/v3 v3.5.0-alpha.0 // indirect
|
||||
go.opencensus.io v0.23.0 // indirect
|
||||
go.uber.org/atomic v1.7.0 // indirect
|
||||
go.uber.org/multierr v1.7.0 // indirect
|
||||
go.uber.org/zap v1.16.0 // indirect
|
||||
golang.org/x/crypto v0.9.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17 // indirect
|
||||
golang.org/x/mod v0.8.0 // indirect
|
||||
golang.org/x/net v0.10.0 // indirect
|
||||
golang.org/x/oauth2 v0.0.0-20210427180440-81ed05c6b58c // indirect
|
||||
golang.org/x/sync v0.1.0 // indirect
|
||||
golang.org/x/sys v0.17.0 // indirect
|
||||
golang.org/x/text v0.9.0 // indirect
|
||||
golang.org/x/tools v0.6.0 // indirect
|
||||
google.golang.org/appengine v1.6.7 // indirect
|
||||
google.golang.org/genproto v0.0.0-20210510173355-fb37daa5cd7a // indirect
|
||||
google.golang.org/grpc v1.37.0 // indirect
|
||||
google.golang.org/protobuf v1.30.0 // indirect
|
||||
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
|
||||
gopkg.in/cheggaaa/pb.v1 v1.0.28 // indirect
|
||||
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
|
||||
|
||||
)
|
||||
|
||||
replace github.com/gomodule/redigo v2.0.0+incompatible => github.com/gomodule/redigo v1.8.9
|
162
main.go
Normal file
162
main.go
Normal file
@ -0,0 +1,162 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
_ "embed"
|
||||
"flag"
|
||||
"io/fs"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/cloudreve/Cloudreve/v3/bootstrap"
|
||||
model "github.com/cloudreve/Cloudreve/v3/models"
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/cache"
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/conf"
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/util"
|
||||
"github.com/cloudreve/Cloudreve/v3/routers"
|
||||
)
|
||||
|
||||
var (
|
||||
isEject bool
|
||||
confPath string
|
||||
scriptName string
|
||||
)
|
||||
|
||||
//go:embed assets.zip
|
||||
var staticZip string
|
||||
|
||||
var staticFS fs.FS
|
||||
|
||||
func init() {
|
||||
flag.StringVar(&confPath, "c", util.RelativePath("conf.ini"), "Path to the config file.")
|
||||
flag.BoolVar(&isEject, "eject", false, "Eject all embedded static files.")
|
||||
flag.StringVar(&scriptName, "database-script", "", "Name of database util script.")
|
||||
flag.Parse()
|
||||
|
||||
staticFS = bootstrap.NewFS(staticZip)
|
||||
bootstrap.Init(confPath, staticFS)
|
||||
}
|
||||
|
||||
func main() {
|
||||
// 关闭数据库连接
|
||||
defer func() {
|
||||
if model.DB != nil {
|
||||
model.DB.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
if isEject {
|
||||
// 开始导出内置静态资源文件
|
||||
bootstrap.Eject(staticFS)
|
||||
return
|
||||
}
|
||||
|
||||
if scriptName != "" {
|
||||
// 开始运行助手数据库脚本
|
||||
bootstrap.RunScript(scriptName)
|
||||
return
|
||||
}
|
||||
|
||||
api := routers.InitRouter()
|
||||
api.TrustedPlatform = conf.SystemConfig.ProxyHeader
|
||||
server := &http.Server{Handler: api}
|
||||
|
||||
// 收到信号后关闭服务器
|
||||
sigChan := make(chan os.Signal, 1)
|
||||
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM, syscall.SIGHUP, syscall.SIGQUIT)
|
||||
wait := shutdown(sigChan, server)
|
||||
|
||||
defer func() {
|
||||
sigChan <- syscall.SIGTERM
|
||||
<-wait
|
||||
}()
|
||||
|
||||
// 如果启用了SSL
|
||||
if conf.SSLConfig.CertPath != "" {
|
||||
util.Log().Info("Listening to %q", conf.SSLConfig.Listen)
|
||||
server.Addr = conf.SSLConfig.Listen
|
||||
if err := server.ListenAndServeTLS(conf.SSLConfig.CertPath, conf.SSLConfig.KeyPath); err != nil {
|
||||
util.Log().Error("Failed to listen to %q: %s", conf.SSLConfig.Listen, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 如果启用了Unix
|
||||
if conf.UnixConfig.Listen != "" {
|
||||
// delete socket file before listening
|
||||
if _, err := os.Stat(conf.UnixConfig.Listen); err == nil {
|
||||
if err = os.Remove(conf.UnixConfig.Listen); err != nil {
|
||||
util.Log().Error("Failed to delete socket file: %s", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
util.Log().Info("Listening to %q", conf.UnixConfig.Listen)
|
||||
if err := RunUnix(server); err != nil {
|
||||
util.Log().Error("Failed to listen to %q: %s", conf.UnixConfig.Listen, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
util.Log().Info("Listening to %q", conf.SystemConfig.Listen)
|
||||
server.Addr = conf.SystemConfig.Listen
|
||||
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
util.Log().Error("Failed to listen to %q: %s", conf.SystemConfig.Listen, err)
|
||||
}
|
||||
}
|
||||
|
||||
func RunUnix(server *http.Server) error {
|
||||
listener, err := net.Listen("unix", conf.UnixConfig.Listen)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer listener.Close()
|
||||
defer os.Remove(conf.UnixConfig.Listen)
|
||||
|
||||
if conf.UnixConfig.Perm > 0 {
|
||||
err = os.Chmod(conf.UnixConfig.Listen, os.FileMode(conf.UnixConfig.Perm))
|
||||
if err != nil {
|
||||
util.Log().Warning(
|
||||
"Failed to set permission to %q for socket file %q: %s",
|
||||
conf.UnixConfig.Perm,
|
||||
conf.UnixConfig.Listen,
|
||||
err,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return server.Serve(listener)
|
||||
}
|
||||
|
||||
func shutdown(sigChan chan os.Signal, server *http.Server) chan struct{} {
|
||||
wait := make(chan struct{})
|
||||
go func() {
|
||||
sig := <-sigChan
|
||||
util.Log().Info("Signal %s received, shutting down server...", sig)
|
||||
if conf.SystemConfig.GracePeriod == 0 {
|
||||
conf.SystemConfig.GracePeriod = 10
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(conf.SystemConfig.GracePeriod)*time.Second)
|
||||
defer cancel()
|
||||
// Shutdown http server
|
||||
err := server.Shutdown(ctx)
|
||||
if err != nil {
|
||||
util.Log().Error("Failed to shutdown server: %s", err)
|
||||
}
|
||||
|
||||
// Persist in-memory cache
|
||||
if err := cache.Store.Persist(filepath.Join(model.GetSettingByName("temp_path"), cache.DefaultCacheFile)); err != nil {
|
||||
util.Log().Warning("Failed to persist cache: %s", err)
|
||||
}
|
||||
|
||||
close(sigChan)
|
||||
wait <- struct{}{}
|
||||
}()
|
||||
return wait
|
||||
}
|
323
middleware/auth.go
Normal file
323
middleware/auth.go
Normal file
@ -0,0 +1,323 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/filesystem"
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/filesystem/driver/oss"
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/filesystem/driver/upyun"
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/mq"
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/util"
|
||||
"github.com/qiniu/go-sdk/v7/auth/qbox"
|
||||
|
||||
model "github.com/cloudreve/Cloudreve/v3/models"
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/auth"
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/cache"
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/serializer"
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
CallbackFailedStatusCode = http.StatusUnauthorized
|
||||
)
|
||||
|
||||
// SignRequired 验证请求签名
|
||||
func SignRequired(authInstance auth.Auth) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var err error
|
||||
switch c.Request.Method {
|
||||
case "PUT", "POST", "PATCH":
|
||||
err = auth.CheckRequest(authInstance, c.Request)
|
||||
default:
|
||||
err = auth.CheckURI(authInstance, c.Request.URL)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
c.JSON(200, serializer.Err(serializer.CodeCredentialInvalid, err.Error(), err))
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// CurrentUser 获取登录用户
|
||||
func CurrentUser() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
session := sessions.Default(c)
|
||||
uid := session.Get("user_id")
|
||||
if uid != nil {
|
||||
user, err := model.GetActiveUserByID(uid)
|
||||
if err == nil {
|
||||
c.Set("user", &user)
|
||||
}
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// AuthRequired 需要登录
|
||||
func AuthRequired() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if user, _ := c.Get("user"); user != nil {
|
||||
if _, ok := user.(*model.User); ok {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(200, serializer.CheckLogin())
|
||||
c.Abort()
|
||||
}
|
||||
}
|
||||
|
||||
// PhoneRequired 需要绑定手机
|
||||
// TODO 有bug
|
||||
func PhoneRequired() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if model.IsTrueVal(model.GetSettingByName("phone_required")) &&
|
||||
model.IsTrueVal(model.GetSettingByName("phone_enabled")) {
|
||||
user, _ := c.Get("user")
|
||||
if user.(*model.User).Phone != "" {
|
||||
// TODO 忽略管理员
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// WebDAVAuth 验证WebDAV登录及权限
|
||||
func WebDAVAuth() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// OPTIONS 请求不需要鉴权,否则Windows10下无法保存文档
|
||||
if c.Request.Method == "OPTIONS" {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
username, password, ok := c.Request.BasicAuth()
|
||||
if !ok {
|
||||
c.Writer.Header()["WWW-Authenticate"] = []string{`Basic realm="cloudreve"`}
|
||||
c.Status(http.StatusUnauthorized)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
expectedUser, err := model.GetActiveUserByEmail(username)
|
||||
if err != nil {
|
||||
c.Status(http.StatusUnauthorized)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// 密码正确?
|
||||
webdav, err := model.GetWebdavByPassword(password, expectedUser.ID)
|
||||
if err != nil {
|
||||
c.Status(http.StatusUnauthorized)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// 用户组已启用WebDAV?
|
||||
if !expectedUser.Group.WebDAVEnabled {
|
||||
c.Status(http.StatusForbidden)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// 用户组已启用WebDAV代理?
|
||||
if !expectedUser.Group.OptionsSerialized.WebDAVProxy {
|
||||
webdav.UseProxy = false
|
||||
}
|
||||
|
||||
c.Set("user", &expectedUser)
|
||||
c.Set("webdav", webdav)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// 对上传会话进行验证
|
||||
func UseUploadSession(policyType string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// 验证key并查找用户
|
||||
resp := uploadCallbackCheck(c, policyType)
|
||||
if resp.Code != 0 {
|
||||
c.JSON(CallbackFailedStatusCode, resp)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// uploadCallbackCheck 对上传回调请求的 callback key 进行验证,如果成功则返回上传用户
|
||||
func uploadCallbackCheck(c *gin.Context, policyType string) serializer.Response {
|
||||
// 验证 Callback Key
|
||||
sessionID := c.Param("sessionID")
|
||||
if sessionID == "" {
|
||||
return serializer.ParamErr("Session ID cannot be empty", nil)
|
||||
}
|
||||
|
||||
callbackSessionRaw, exist := cache.Get(filesystem.UploadSessionCachePrefix + sessionID)
|
||||
if !exist {
|
||||
return serializer.Err(serializer.CodeUploadSessionExpired, "上传会话不存在或已过期", nil)
|
||||
}
|
||||
|
||||
callbackSession := callbackSessionRaw.(serializer.UploadSession)
|
||||
c.Set(filesystem.UploadSessionCtx, &callbackSession)
|
||||
if callbackSession.Policy.Type != policyType {
|
||||
return serializer.Err(serializer.CodePolicyNotAllowed, "", nil)
|
||||
}
|
||||
|
||||
// 清理回调会话
|
||||
_ = cache.Deletes([]string{sessionID}, filesystem.UploadSessionCachePrefix)
|
||||
|
||||
// 查找用户
|
||||
user, err := model.GetActiveUserByID(callbackSession.UID)
|
||||
if err != nil {
|
||||
return serializer.Err(serializer.CodeUserNotFound, "", err)
|
||||
}
|
||||
c.Set(filesystem.UserCtx, &user)
|
||||
return serializer.Response{}
|
||||
}
|
||||
|
||||
// RemoteCallbackAuth 远程回调签名验证
|
||||
func RemoteCallbackAuth() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// 验证签名
|
||||
session := c.MustGet(filesystem.UploadSessionCtx).(*serializer.UploadSession)
|
||||
authInstance := auth.HMACAuth{SecretKey: []byte(session.Policy.SecretKey)}
|
||||
if err := auth.CheckRequest(authInstance, c.Request); err != nil {
|
||||
c.JSON(CallbackFailedStatusCode, serializer.Err(serializer.CodeCredentialInvalid, err.Error(), err))
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
// QiniuCallbackAuth 七牛回调签名验证
|
||||
func QiniuCallbackAuth() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
session := c.MustGet(filesystem.UploadSessionCtx).(*serializer.UploadSession)
|
||||
|
||||
// 验证回调是否来自qiniu
|
||||
mac := qbox.NewMac(session.Policy.AccessKey, session.Policy.SecretKey)
|
||||
ok, err := mac.VerifyCallback(c.Request)
|
||||
if err != nil {
|
||||
util.Log().Debug("Failed to verify callback request: %s", err)
|
||||
c.JSON(401, serializer.GeneralUploadCallbackFailed{Error: "Failed to verify callback request."})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
if !ok {
|
||||
c.JSON(401, serializer.GeneralUploadCallbackFailed{Error: "Invalid signature."})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// OSSCallbackAuth 阿里云OSS回调签名验证
|
||||
func OSSCallbackAuth() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
err := oss.VerifyCallbackSignature(c.Request)
|
||||
if err != nil {
|
||||
util.Log().Debug("Failed to verify callback request: %s", err)
|
||||
c.JSON(401, serializer.GeneralUploadCallbackFailed{Error: "Failed to verify callback request."})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// UpyunCallbackAuth 又拍云回调签名验证
|
||||
func UpyunCallbackAuth() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
session := c.MustGet(filesystem.UploadSessionCtx).(*serializer.UploadSession)
|
||||
|
||||
// 获取请求正文
|
||||
body, err := ioutil.ReadAll(c.Request.Body)
|
||||
c.Request.Body.Close()
|
||||
if err != nil {
|
||||
c.JSON(401, serializer.GeneralUploadCallbackFailed{Error: err.Error()})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Request.Body = ioutil.NopCloser(bytes.NewReader(body))
|
||||
|
||||
// 准备验证Upyun回调签名
|
||||
handler := upyun.Driver{Policy: &session.Policy}
|
||||
contentMD5 := c.Request.Header.Get("Content-Md5")
|
||||
date := c.Request.Header.Get("Date")
|
||||
actualSignature := c.Request.Header.Get("Authorization")
|
||||
|
||||
// 计算正文MD5
|
||||
actualContentMD5 := fmt.Sprintf("%x", md5.Sum(body))
|
||||
if actualContentMD5 != contentMD5 {
|
||||
c.JSON(401, serializer.GeneralUploadCallbackFailed{Error: "MD5 mismatch."})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// 计算理论签名
|
||||
signature := handler.Sign(context.Background(), []string{
|
||||
"POST",
|
||||
c.Request.URL.Path,
|
||||
date,
|
||||
contentMD5,
|
||||
})
|
||||
|
||||
// 对比签名
|
||||
if signature != actualSignature {
|
||||
c.JSON(401, serializer.GeneralUploadCallbackFailed{Error: "Signature not match"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// OneDriveCallbackAuth OneDrive回调签名验证
|
||||
func OneDriveCallbackAuth() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// 发送回调结束信号
|
||||
mq.GlobalMQ.Publish(c.Param("sessionID"), mq.Message{})
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// IsAdmin 必须为管理员用户组
|
||||
func IsAdmin() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
user, _ := c.Get("user")
|
||||
if user.(*model.User).Group.ID != 1 && user.(*model.User).ID != 1 {
|
||||
c.JSON(200, serializer.Err(serializer.CodeNoPermissionErr, "", nil))
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
127
middleware/captcha.go
Normal file
127
middleware/captcha.go
Normal file
@ -0,0 +1,127 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
model "github.com/cloudreve/Cloudreve/v3/models"
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/recaptcha"
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/serializer"
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/util"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/mojocn/base64Captcha"
|
||||
captcha "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/captcha/v20190722"
|
||||
"github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common"
|
||||
"github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common/profile"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
type req struct {
|
||||
CaptchaCode string `json:"captchaCode"`
|
||||
Ticket string `json:"ticket"`
|
||||
Randstr string `json:"randstr"`
|
||||
}
|
||||
|
||||
const (
|
||||
captchaNotMatch = "CAPTCHA not match."
|
||||
captchaRefresh = "Verification failed, please refresh the page and retry."
|
||||
)
|
||||
|
||||
// CaptchaRequired 验证请求签名
|
||||
func CaptchaRequired(configName string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// 相关设定
|
||||
options := model.GetSettingByNames(configName,
|
||||
"captcha_type",
|
||||
"captcha_ReCaptchaSecret",
|
||||
"captcha_TCaptcha_SecretId",
|
||||
"captcha_TCaptcha_SecretKey",
|
||||
"captcha_TCaptcha_CaptchaAppId",
|
||||
"captcha_TCaptcha_AppSecretKey")
|
||||
// 检查验证码
|
||||
isCaptchaRequired := model.IsTrueVal(options[configName])
|
||||
|
||||
if isCaptchaRequired {
|
||||
var service req
|
||||
bodyCopy := new(bytes.Buffer)
|
||||
_, err := io.Copy(bodyCopy, c.Request.Body)
|
||||
if err != nil {
|
||||
c.JSON(200, serializer.Err(serializer.CodeCaptchaError, captchaNotMatch, err))
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
bodyData := bodyCopy.Bytes()
|
||||
err = json.Unmarshal(bodyData, &service)
|
||||
if err != nil {
|
||||
c.JSON(200, serializer.Err(serializer.CodeCaptchaError, captchaNotMatch, err))
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Request.Body = ioutil.NopCloser(bytes.NewReader(bodyData))
|
||||
switch options["captcha_type"] {
|
||||
case "normal":
|
||||
captchaID := util.GetSession(c, "captchaID")
|
||||
util.DeleteSession(c, "captchaID")
|
||||
if captchaID == nil || !base64Captcha.VerifyCaptcha(captchaID.(string), service.CaptchaCode) {
|
||||
c.JSON(200, serializer.Err(serializer.CodeCaptchaError, captchaNotMatch, err))
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
break
|
||||
case "recaptcha":
|
||||
reCAPTCHA, err := recaptcha.NewReCAPTCHA(options["captcha_ReCaptchaSecret"], recaptcha.V2, 10*time.Second)
|
||||
if err != nil {
|
||||
util.Log().Warning("reCAPTCHA verification failed, %s", err)
|
||||
c.Abort()
|
||||
break
|
||||
}
|
||||
|
||||
err = reCAPTCHA.Verify(service.CaptchaCode)
|
||||
if err != nil {
|
||||
util.Log().Warning("reCAPTCHA verification failed, %s", err)
|
||||
c.JSON(200, serializer.Err(serializer.CodeCaptchaRefreshNeeded, captchaRefresh, nil))
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
break
|
||||
case "tcaptcha":
|
||||
credential := common.NewCredential(
|
||||
options["captcha_TCaptcha_SecretId"],
|
||||
options["captcha_TCaptcha_SecretKey"],
|
||||
)
|
||||
cpf := profile.NewClientProfile()
|
||||
cpf.HttpProfile.Endpoint = "captcha.tencentcloudapi.com"
|
||||
client, _ := captcha.NewClient(credential, "", cpf)
|
||||
request := captcha.NewDescribeCaptchaResultRequest()
|
||||
request.CaptchaType = common.Uint64Ptr(9)
|
||||
appid, _ := strconv.Atoi(options["captcha_TCaptcha_CaptchaAppId"])
|
||||
request.CaptchaAppId = common.Uint64Ptr(uint64(appid))
|
||||
request.AppSecretKey = common.StringPtr(options["captcha_TCaptcha_AppSecretKey"])
|
||||
request.Ticket = common.StringPtr(service.Ticket)
|
||||
request.Randstr = common.StringPtr(service.Randstr)
|
||||
request.UserIp = common.StringPtr(c.ClientIP())
|
||||
response, err := client.DescribeCaptchaResult(request)
|
||||
if err != nil {
|
||||
util.Log().Warning("TCaptcha verification failed, %s", err)
|
||||
c.Abort()
|
||||
break
|
||||
}
|
||||
|
||||
if *response.Response.CaptchaCode != int64(1) {
|
||||
c.JSON(200, serializer.Err(serializer.CodeCaptchaRefreshNeeded, captchaRefresh, nil))
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
62
middleware/cluster.go
Normal file
62
middleware/cluster.go
Normal file
@ -0,0 +1,62 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/auth"
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/cluster"
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/serializer"
|
||||
"github.com/gin-gonic/gin"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// MasterMetadata 解析主机节点发来请求的包含主机节点信息的元数据
|
||||
func MasterMetadata() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Set("MasterSiteID", c.GetHeader(auth.CrHeaderPrefix+"Site-Id"))
|
||||
c.Set("MasterSiteURL", c.GetHeader(auth.CrHeaderPrefix+"Site-Url"))
|
||||
c.Set("MasterVersion", c.GetHeader(auth.CrHeaderPrefix+"Cloudreve-Version"))
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// UseSlaveAria2Instance 从机用于获取对应主机节点的Aria2实例
|
||||
func UseSlaveAria2Instance(clusterController cluster.Controller) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if siteID, exist := c.Get("MasterSiteID"); exist {
|
||||
// 获取对应主机节点的从机Aria2实例
|
||||
caller, err := clusterController.GetAria2Instance(siteID.(string))
|
||||
if err != nil {
|
||||
c.JSON(200, serializer.Err(serializer.CodeNotSet, "Failed to get Aria2 instance", err))
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Set("MasterAria2Instance", caller)
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(200, serializer.ParamErr("Unknown master node ID", nil))
|
||||
c.Abort()
|
||||
}
|
||||
}
|
||||
|
||||
func SlaveRPCSignRequired(nodePool cluster.Pool) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
nodeID, err := strconv.ParseUint(c.GetHeader(auth.CrHeaderPrefix+"Node-Id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(200, serializer.ParamErr("Unknown master node ID", err))
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
slaveNode := nodePool.GetNodeByID(uint(nodeID))
|
||||
if slaveNode == nil {
|
||||
c.JSON(200, serializer.ParamErr("Unknown master node ID", err))
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
SignRequired(slaveNode.MasterAuthInstance())(c)
|
||||
|
||||
}
|
||||
}
|
77
middleware/common.go
Normal file
77
middleware/common.go
Normal file
@ -0,0 +1,77 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
model "github.com/cloudreve/Cloudreve/v3/models"
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/auth"
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/hashid"
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/serializer"
|
||||
"github.com/gin-gonic/gin"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// HashID 将给定对象的HashID转换为真实ID
|
||||
func HashID(IDType int) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if c.Param("id") != "" {
|
||||
id, err := hashid.DecodeHashID(c.Param("id"), IDType)
|
||||
if err == nil {
|
||||
c.Set("object_id", id)
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
c.JSON(200, serializer.ParamErr("Failed to parse object ID", nil))
|
||||
c.Abort()
|
||||
return
|
||||
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// IsFunctionEnabled 当功能未开启时阻止访问
|
||||
func IsFunctionEnabled(key string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if !model.IsTrueVal(model.GetSettingByName(key)) {
|
||||
c.JSON(200, serializer.Err(serializer.CodeFeatureNotEnabled, "This feature is not enabled", nil))
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// CacheControl 屏蔽客户端缓存
|
||||
func CacheControl() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Header("Cache-Control", "private, no-cache")
|
||||
}
|
||||
}
|
||||
|
||||
func Sandbox() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Header("Content-Security-Policy", "sandbox")
|
||||
}
|
||||
}
|
||||
|
||||
// StaticResourceCache 使用静态资源缓存策略
|
||||
func StaticResourceCache() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Header("Cache-Control", fmt.Sprintf("public, max-age=%d", model.GetIntSetting("public_resource_maxage", 86400)))
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
// MobileRequestOnly
|
||||
func MobileRequestOnly() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if c.GetHeader(auth.CrHeaderPrefix+"ios") == "" {
|
||||
c.Redirect(http.StatusMovedPermanently, model.GetSiteURL().String())
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
30
middleware/file.go
Normal file
30
middleware/file.go
Normal file
@ -0,0 +1,30 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
model "github.com/cloudreve/Cloudreve/v3/models"
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/serializer"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ValidateSourceLink validates if the perm source link is a valid redirect link
|
||||
func ValidateSourceLink() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
linkID, ok := c.Get("object_id")
|
||||
if !ok {
|
||||
c.JSON(200, serializer.Err(serializer.CodeFileNotFound, "", nil))
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
sourceLink, err := model.GetSourceLinkByID(linkID)
|
||||
if err != nil || sourceLink.File.ID == 0 || sourceLink.File.Name != c.Param("name") {
|
||||
c.JSON(200, serializer.Err(serializer.CodeFileNotFound, "", nil))
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
sourceLink.Downloaded()
|
||||
c.Set("source_link", sourceLink)
|
||||
c.Next()
|
||||
}
|
||||
}
|
84
middleware/frontend.go
Normal file
84
middleware/frontend.go
Normal file
@ -0,0 +1,84 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/cloudreve/Cloudreve/v3/bootstrap"
|
||||
model "github.com/cloudreve/Cloudreve/v3/models"
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/util"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// FrontendFileHandler 前端静态文件处理
|
||||
func FrontendFileHandler() gin.HandlerFunc {
|
||||
ignoreFunc := func(c *gin.Context) {
|
||||
c.Next()
|
||||
}
|
||||
|
||||
if bootstrap.StaticFS == nil {
|
||||
return ignoreFunc
|
||||
}
|
||||
|
||||
// 读取index.html
|
||||
file, err := bootstrap.StaticFS.Open("/index.html")
|
||||
if err != nil {
|
||||
util.Log().Warning("Static file \"index.html\" does not exist, it might affect the display of the homepage.")
|
||||
return ignoreFunc
|
||||
}
|
||||
|
||||
fileContentBytes, err := ioutil.ReadAll(file)
|
||||
if err != nil {
|
||||
util.Log().Warning("Cannot read static file \"index.html\", it might affect the display of the homepage.")
|
||||
return ignoreFunc
|
||||
}
|
||||
fileContent := string(fileContentBytes)
|
||||
|
||||
fileServer := http.FileServer(bootstrap.StaticFS)
|
||||
return func(c *gin.Context) {
|
||||
path := c.Request.URL.Path
|
||||
|
||||
// API 跳过
|
||||
if strings.HasPrefix(path, "/api") ||
|
||||
strings.HasPrefix(path, "/custom") ||
|
||||
strings.HasPrefix(path, "/dav") ||
|
||||
strings.HasPrefix(path, "/f") ||
|
||||
path == "/manifest.json" {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
// 不存在的路径和index.html均返回index.html
|
||||
if (path == "/index.html") || (path == "/") || !bootstrap.StaticFS.Exists("/", path) {
|
||||
// 读取、替换站点设置
|
||||
options := model.GetSettingByNames(
|
||||
"siteName", // 站点名称
|
||||
"siteKeywords", // 关键词
|
||||
"siteDes", // 描述
|
||||
"siteScript", // 自定义代码
|
||||
"pwa_small_icon", // 图标
|
||||
)
|
||||
finalHTML := util.Replace(map[string]string{
|
||||
"{siteName}": options["siteName"],
|
||||
"{siteKeywords}": options["siteKeywords"],
|
||||
"{siteDes}": options["siteDes"],
|
||||
"{siteScript}": options["siteScript"],
|
||||
"{pwa_small_icon}": options["pwa_small_icon"],
|
||||
}, fileContent)
|
||||
|
||||
c.Header("Content-Type", "text/html")
|
||||
c.String(200, finalHTML)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
if path == "/service-worker.js" {
|
||||
c.Header("Cache-Control", "public, no-cache")
|
||||
}
|
||||
|
||||
// 存在的静态文件
|
||||
fileServer.ServeHTTP(c.Writer, c.Request)
|
||||
c.Abort()
|
||||
}
|
||||
}
|
24
middleware/mock.go
Normal file
24
middleware/mock.go
Normal file
@ -0,0 +1,24 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/util"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// SessionMock 测试时模拟Session
|
||||
var SessionMock = make(map[string]interface{})
|
||||
|
||||
// ContextMock 测试时模拟Context
|
||||
var ContextMock = make(map[string]interface{})
|
||||
|
||||
// MockHelper 单元测试助手中间件
|
||||
func MockHelper() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// 将SessionMock写入会话
|
||||
util.SetSession(c, SessionMock)
|
||||
for key, value := range ContextMock {
|
||||
c.Set(key, value)
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
68
middleware/session.go
Normal file
68
middleware/session.go
Normal file
@ -0,0 +1,68 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/cache"
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/sessionstore"
|
||||
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/conf"
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/serializer"
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/util"
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Store session存储
|
||||
var Store sessions.Store
|
||||
|
||||
// Session 初始化session
|
||||
func Session(secret string) gin.HandlerFunc {
|
||||
// Redis设置不为空,且非测试模式时使用Redis
|
||||
Store = sessionstore.NewStore(cache.Store, []byte(secret))
|
||||
|
||||
sameSiteMode := http.SameSiteDefaultMode
|
||||
switch strings.ToLower(conf.CORSConfig.SameSite) {
|
||||
case "default":
|
||||
sameSiteMode = http.SameSiteDefaultMode
|
||||
case "none":
|
||||
sameSiteMode = http.SameSiteNoneMode
|
||||
case "strict":
|
||||
sameSiteMode = http.SameSiteStrictMode
|
||||
case "lax":
|
||||
sameSiteMode = http.SameSiteLaxMode
|
||||
}
|
||||
|
||||
// Also set Secure: true if using SSL, you should though
|
||||
Store.Options(sessions.Options{
|
||||
HttpOnly: true,
|
||||
MaxAge: 60 * 86400,
|
||||
Path: "/",
|
||||
SameSite: sameSiteMode,
|
||||
Secure: conf.CORSConfig.Secure,
|
||||
})
|
||||
|
||||
return sessions.Sessions("cloudreve-session", Store)
|
||||
}
|
||||
|
||||
// CSRFInit 初始化CSRF标记
|
||||
func CSRFInit() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
util.SetSession(c, map[string]interface{}{"CSRF": true})
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// CSRFCheck 检查CSRF标记
|
||||
func CSRFCheck() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if check, ok := util.GetSession(c, "CSRF").(bool); ok && check {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(200, serializer.Err(serializer.CodeNoPermissionErr, "Invalid origin", nil))
|
||||
c.Abort()
|
||||
}
|
||||
}
|
139
middleware/share.go
Normal file
139
middleware/share.go
Normal file
@ -0,0 +1,139 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
model "github.com/cloudreve/Cloudreve/v3/models"
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/serializer"
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/util"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ShareOwner 检查当前登录用户是否为分享所有者
|
||||
func ShareOwner() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var user *model.User
|
||||
if userCtx, ok := c.Get("user"); ok {
|
||||
user = userCtx.(*model.User)
|
||||
} else {
|
||||
c.JSON(200, serializer.Err(serializer.CodeCheckLogin, "", nil))
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
if share, ok := c.Get("share"); ok {
|
||||
if share.(*model.Share).Creator().ID != user.ID {
|
||||
c.JSON(200, serializer.Err(serializer.CodeShareLinkNotFound, "", nil))
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// ShareAvailable 检查分享是否可用
|
||||
func ShareAvailable() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var user *model.User
|
||||
if userCtx, ok := c.Get("user"); ok {
|
||||
user = userCtx.(*model.User)
|
||||
} else {
|
||||
user = model.NewAnonymousUser()
|
||||
}
|
||||
|
||||
share := model.GetShareByHashID(c.Param("id"))
|
||||
|
||||
if share == nil || !share.IsAvailable() {
|
||||
c.JSON(200, serializer.Err(serializer.CodeShareLinkNotFound, "", nil))
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Set("user", user)
|
||||
c.Set("share", share)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// ShareCanPreview 检查分享是否可被预览
|
||||
func ShareCanPreview() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if share, ok := c.Get("share"); ok {
|
||||
if share.(*model.Share).PreviewEnabled {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
c.JSON(200, serializer.Err(serializer.CodeDisabledSharePreview, "",
|
||||
nil))
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Abort()
|
||||
}
|
||||
}
|
||||
|
||||
// CheckShareUnlocked 检查分享是否已解锁
|
||||
func CheckShareUnlocked() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if shareCtx, ok := c.Get("share"); ok {
|
||||
share := shareCtx.(*model.Share)
|
||||
// 分享是否已解锁
|
||||
if share.Password != "" {
|
||||
sessionKey := fmt.Sprintf("share_unlock_%d", share.ID)
|
||||
unlocked := util.GetSession(c, sessionKey) != nil
|
||||
if !unlocked {
|
||||
c.JSON(200, serializer.Err(serializer.CodeNoPermissionErr,
|
||||
"", nil))
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
c.Abort()
|
||||
}
|
||||
}
|
||||
|
||||
// BeforeShareDownload 分享被下载前的检查
|
||||
func BeforeShareDownload() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if shareCtx, ok := c.Get("share"); ok {
|
||||
if userCtx, ok := c.Get("user"); ok {
|
||||
share := shareCtx.(*model.Share)
|
||||
user := userCtx.(*model.User)
|
||||
|
||||
// 检查用户是否可以下载此分享的文件
|
||||
err := share.CanBeDownloadBy(user)
|
||||
if err != nil {
|
||||
c.JSON(200, serializer.Err(serializer.CodeGroupNotAllowed, err.Error(),
|
||||
nil))
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// 对积分、下载次数进行更新
|
||||
err = share.DownloadBy(user, c)
|
||||
if err != nil {
|
||||
if err == model.ErrInsufficientCredit {
|
||||
c.JSON(200, serializer.Err(serializer.CodeInsufficientCredit, err.Error(),
|
||||
nil))
|
||||
} else {
|
||||
c.JSON(200, serializer.Err(serializer.CodeGroupNotAllowed, err.Error(),
|
||||
nil))
|
||||
}
|
||||
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
}
|
||||
c.Abort()
|
||||
}
|
||||
}
|
70
middleware/wopi.go
Normal file
70
middleware/wopi.go
Normal 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()
|
||||
}
|
||||
}
|
186
models/defaults.go
Normal file
186
models/defaults.go
Normal file
@ -0,0 +1,186 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/cache"
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/conf"
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/util"
|
||||
"github.com/gofrs/uuid"
|
||||
)
|
||||
|
||||
var defaultSettings = []Setting{
|
||||
{Name: "siteURL", Value: `http://localhost`, Type: "basic"},
|
||||
{Name: "siteName", Value: `CloudrevePlus`, Type: "basic"},
|
||||
{Name: "register_enabled", Value: `1`, Type: "register"},
|
||||
{Name: "default_group", Value: `2`, Type: "register"},
|
||||
{Name: "mail_domain_filter", Value: `0`, Type: "register"},
|
||||
{Name: "mail_domain_filter_list", Value: `126.com,163.com,gmail.com,outlook.com,qq.com,foxmail.com,yeah.net,sohu.com,sohu.cn,139.com,wo.cn,189.cn,hotmail.com,live.com,live.cn`, Type: "register"},
|
||||
{Name: "siteKeywords", Value: `CloudrevePlus, cloud storage`, Type: "basic"},
|
||||
{Name: "siteDes", Value: `部署公私兼备的网盘系统`, Type: "basic"},
|
||||
{Name: "siteTitle", Value: `Inclusive cloud storage for everyone`, Type: "basic"},
|
||||
{Name: "siteNotice", Value: ``, Type: "basic"},
|
||||
{Name: "siteScript", Value: ``, Type: "basic"},
|
||||
{Name: "siteID", Value: uuid.Must(uuid.NewV4()).String(), Type: "basic"},
|
||||
{Name: "fromName", Value: `Cloudreve`, Type: "mail"},
|
||||
{Name: "mail_keepalive", Value: `30`, Type: "mail"},
|
||||
{Name: "fromAdress", Value: `no-reply@acg.blue`, Type: "mail"},
|
||||
{Name: "smtpHost", Value: `smtp.mxhichina.com`, Type: "mail"},
|
||||
{Name: "smtpPort", Value: `25`, Type: "mail"},
|
||||
{Name: "replyTo", Value: `abslant@126.com`, Type: "mail"},
|
||||
{Name: "smtpUser", Value: `no-reply@acg.blue`, Type: "mail"},
|
||||
{Name: "smtpPass", Value: ``, Type: "mail"},
|
||||
{Name: "smtpEncryption", Value: `0`, Type: "mail"},
|
||||
{Name: "over_used_template", Value: `<meta name="viewport"content="width=device-width"><meta http-equiv="Content-Type"content="text/html; charset=UTF-8"><title>容量超额提醒</title><style type="text/css">img{max-width:100%}body{-webkit-font-smoothing:antialiased;-webkit-text-size-adjust:none;width:100%!important;height:100%;line-height:1.6em}body{background-color:#f6f6f6}@media only screen and(max-width:640px){body{padding:0!important}h1{font-weight:800!important;margin:20px 0 5px!important}h2{font-weight:800!important;margin:20px 0 5px!important}h3{font-weight:800!important;margin:20px 0 5px!important}h4{font-weight:800!important;margin:20px 0 5px!important}h1{font-size:22px!important}h2{font-size:18px!important}h3{font-size:16px!important}.container{padding:0!important;width:100%!important}.content{padding:0!important}.content-wrap{padding:10px!important}.invoice{width:100%!important}}</style><table class="body-wrap"style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; width: 100%; background-color: #f6f6f6; margin: 0;"bgcolor="#f6f6f6"><tbody><tr style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;"><td style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; vertical-align: top; margin: 0;"valign="top"></td><td class="container"width="600"style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; vertical-align: top; display: block !important; max-width: 600px !important; clear: both !important; margin: 0 auto;"valign="top"><div class="content"style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; max-width: 600px; display: block; margin: 0 auto; padding: 20px;"><table class="main"width="100%"cellpadding="0"cellspacing="0"style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; border-radius: 3px; background-color: #fff; margin: 0; border: 1px
|
||||
solid #e9e9e9;"bgcolor="#fff"><tbody><tr style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;"><td class="alert alert-warning"style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 16px; vertical-align: top; color: #fff; font-weight: 500; text-align: center; border-radius: 3px 3px 0 0; background-color: #FF9F00; margin: 0; padding: 20px;"align="center"bgcolor="#FF9F00"valign="top">容量超额警告</td></tr><tr style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;"><td class="content-wrap"style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; vertical-align: top; margin: 0; padding: 20px;"valign="top"><table width="100%"cellpadding="0"cellspacing="0"style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;"><tbody><tr style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;"><td class="content-block"style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; vertical-align: top; margin: 0; padding: 0 0 20px;"valign="top">亲爱的<strong style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;">{userName}</strong>:</td></tr><tr style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;"><td class="content-block"style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; vertical-align: top; margin: 0; padding: 0 0 20px;"valign="top">由于{notifyReason},您在{siteTitle}的账户的容量使用超出配额,您将无法继续上传新文件,请尽快清理文件,否则我们将会禁用您的账户。</td></tr><tr style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;"><td class="content-block"style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; vertical-align: top; margin: 0; padding: 0 0 20px;"valign="top"><a href="{siteUrl}Login"class="btn-primary"style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; color: #FFF; text-decoration: none; line-height: 2em; font-weight: bold; text-align: center; cursor: pointer; display: inline-block; border-radius: 5px; text-transform: capitalize; background-color: #348eda; margin: 0; border-color: #348eda; border-style: solid; border-width: 10px 20px;">登录{siteTitle}</a></td></tr><tr style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;"><td class="content-block"style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; vertical-align: top; margin: 0; padding: 0 0 20px;"valign="top">感谢您选择{siteTitle}。</td></tr></tbody></table></td></tr></tbody></table><div class="footer"style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; width: 100%; clear: both; color: #999; margin: 0; padding: 20px;"><table width="100%"style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;"><tbody><tr style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;"><td class="aligncenter content-block"style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 12px; vertical-align: top; color: #999; text-align: center; margin: 0; padding: 0 0 20px;"align="center"valign="top">此邮件由系统自动发送,请不要直接回复。</td></tr></tbody></table></div></div></td><td style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; vertical-align: top; margin: 0;"valign="top"></td></tr></tbody></table>`, Type: "mail_template"},
|
||||
{Name: "ban_time", Value: `604800`, Type: "storage_policy"},
|
||||
{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"},
|
||||
{Name: "doc_preview_timeout", Value: `600`, Type: "timeout"},
|
||||
{Name: "upload_session_timeout", Value: `86400`, Type: "timeout"},
|
||||
{Name: "slave_api_timeout", Value: `60`, Type: "timeout"},
|
||||
{Name: "slave_node_retry", Value: `3`, Type: "slave"},
|
||||
{Name: "slave_ping_interval", Value: `60`, Type: "slave"},
|
||||
{Name: "slave_recover_interval", Value: `120`, Type: "slave"},
|
||||
{Name: "slave_transfer_timeout", Value: `172800`, Type: "timeout"},
|
||||
{Name: "onedrive_monitor_timeout", Value: `600`, Type: "timeout"},
|
||||
{Name: "onedrive_source_timeout", Value: `1800`, Type: "timeout"},
|
||||
{Name: "share_download_session_timeout", Value: `2073600`, Type: "timeout"},
|
||||
{Name: "onedrive_callback_check", Value: `20`, Type: "timeout"},
|
||||
{Name: "folder_props_timeout", Value: `300`, Type: "timeout"},
|
||||
{Name: "chunk_retries", Value: `5`, Type: "retry"},
|
||||
{Name: "onedrive_source_timeout", Value: `1800`, Type: "timeout"},
|
||||
{Name: "reset_after_upload_failed", Value: `0`, Type: "upload"},
|
||||
{Name: "use_temp_chunk_buffer", Value: `1`, Type: "upload"},
|
||||
{Name: "login_captcha", Value: `0`, Type: "login"},
|
||||
{Name: "qq_login", Value: `0`, Type: "login"},
|
||||
{Name: "qq_direct_login", Value: `0`, Type: "login"},
|
||||
{Name: "qq_login_id", Value: ``, Type: "login"},
|
||||
{Name: "qq_login_key", Value: ``, Type: "login"},
|
||||
{Name: "reg_captcha", Value: `0`, Type: "login"},
|
||||
{Name: "email_active", Value: `0`, Type: "register"},
|
||||
{Name: "mail_activation_template", Value: `<!DOCTYPE html PUBLIC"-//W3C//DTD XHTML 1.0 Transitional//EN""http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html xmlns="http://www.w3.org/1999/xhtml"style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box;
|
||||
font-size: 14px; margin: 0;"><head><meta name="viewport"content="width=device-width"/><meta http-equiv="Content-Type"content="text/html; charset=UTF-8"/><title>用户激活</title><style type="text/css">img{max-width:100%}body{-webkit-font-smoothing:antialiased;-webkit-text-size-adjust:none;width:100%!important;height:100%;line-height:1.6em}body{background-color:#f6f6f6}@media only screen and(max-width:640px){body{padding:0!important}h1{font-weight:800!important;margin:20px 0 5px!important}h2{font-weight:800!important;margin:20px 0 5px!important}h3{font-weight:800!important;margin:20px 0 5px!important}h4{font-weight:800!important;margin:20px 0 5px!important}h1{font-size:22px!important}h2{font-size:18px!important}h3{font-size:16px!important}.container{padding:0!important;width:100%!important}.content{padding:0!important}.content-wrap{padding:10px!important}.invoice{width:100%!important}}</style></head><body itemscope itemtype="http://schema.org/EmailMessage"style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing:
|
||||
border-box; font-size: 14px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; width: 100% !important; height: 100%; line-height: 1.6em; background-color: #f6f6f6; margin: 0;"bgcolor="#f6f6f6"><table class="body-wrap"style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; width: 100%; background-color: #f6f6f6; margin: 0;"bgcolor="#f6f6f6"><tr style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif;
|
||||
box-sizing: border-box; font-size: 14px; margin: 0;"><td style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; vertical-align: top; margin: 0;"valign="top"></td><td class="container"width="600"style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; vertical-align: top; display: block !important; max-width: 600px !important; clear: both !important; margin: 0 auto;"valign="top"><div class="content"style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; max-width: 600px; display: block; margin: 0 auto; padding: 20px;"><table class="main"width="100%"cellpadding="0"cellspacing="0"style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; border-radius: 3px; background-color: #fff; margin: 0; border: 1px
|
||||
solid #e9e9e9;"bgcolor="#fff"><tr style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size:
|
||||
14px; margin: 0;"><td class="alert alert-warning"style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 16px; vertical-align: top; color: #fff; font-weight: 500; text-align: center; border-radius: 3px 3px 0 0; background-color: #009688; margin: 0; padding: 20px;"align="center"bgcolor="#FF9F00"valign="top">激活{siteTitle}账户</td></tr><tr style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;"><td class="content-wrap"style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; vertical-align: top; margin: 0; padding: 20px;"valign="top"><table width="100%"cellpadding="0"cellspacing="0"style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;"><tr style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;"><td class="content-block"style="font-family: 'Helvetica
|
||||
Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; vertical-align: top; margin: 0; padding: 0 0 20px;"valign="top">亲爱的<strong style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;">{userName}</strong>:</td></tr><tr style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;"><td class="content-block"style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; vertical-align: top; margin: 0; padding: 0 0 20px;"valign="top">感谢您注册{siteTitle},请点击下方按钮完成账户激活。</td></tr><tr style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;"><td class="content-block"style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; vertical-align: top; margin: 0; padding: 0 0 20px;"valign="top"><a href="{activationUrl}"class="btn-primary"style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; color: #FFF; text-decoration: none; line-height: 2em; font-weight: bold; text-align: center; cursor: pointer; display: inline-block; border-radius: 5px; text-transform: capitalize; background-color: #009688; margin: 0; border-color: #009688; border-style: solid; border-width: 10px 20px;">激活账户</a></td></tr><tr style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;"><td class="content-block"style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; vertical-align: top; margin: 0; padding: 0 0 20px;"valign="top">感谢您选择{siteTitle}。</td></tr></table></td></tr></table><div class="footer"style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; width: 100%; clear: both; color: #999; margin: 0; padding: 20px;"><table width="100%"style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;"><tr style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;"><td class="aligncenter content-block"style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 12px; vertical-align: top; color: #999; text-align: center; margin: 0; padding: 0 0 20px;"align="center"valign="top">此邮件由系统自动发送,请不要直接回复。</td></tr></table></div></div></td><td style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; vertical-align: top; margin: 0;"valign="top"></td></tr></table></body></html>`, Type: "mail_template"},
|
||||
{Name: "forget_captcha", Value: `0`, Type: "login"},
|
||||
{Name: "mail_reset_pwd_template", Value: `<!DOCTYPE html PUBLIC"-//W3C//DTD XHTML 1.0 Transitional//EN""http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html xmlns="http://www.w3.org/1999/xhtml"style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box;
|
||||
font-size: 14px; margin: 0;"><head><meta name="viewport"content="width=device-width"/><meta http-equiv="Content-Type"content="text/html; charset=UTF-8"/><title>重设密码</title><style type="text/css">img{max-width:100%}body{-webkit-font-smoothing:antialiased;-webkit-text-size-adjust:none;width:100%!important;height:100%;line-height:1.6em}body{background-color:#f6f6f6}@media only screen and(max-width:640px){body{padding:0!important}h1{font-weight:800!important;margin:20px 0 5px!important}h2{font-weight:800!important;margin:20px 0 5px!important}h3{font-weight:800!important;margin:20px 0 5px!important}h4{font-weight:800!important;margin:20px 0 5px!important}h1{font-size:22px!important}h2{font-size:18px!important}h3{font-size:16px!important}.container{padding:0!important;width:100%!important}.content{padding:0!important}.content-wrap{padding:10px!important}.invoice{width:100%!important}}</style></head><body itemscope itemtype="http://schema.org/EmailMessage"style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing:
|
||||
border-box; font-size: 14px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; width: 100% !important; height: 100%; line-height: 1.6em; background-color: #f6f6f6; margin: 0;"bgcolor="#f6f6f6"><table class="body-wrap"style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; width: 100%; background-color: #f6f6f6; margin: 0;"bgcolor="#f6f6f6"><tr style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif;
|
||||
box-sizing: border-box; font-size: 14px; margin: 0;"><td style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; vertical-align: top; margin: 0;"valign="top"></td><td class="container"width="600"style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; vertical-align: top; display: block !important; max-width: 600px !important; clear: both !important; margin: 0 auto;"valign="top"><div class="content"style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; max-width: 600px; display: block; margin: 0 auto; padding: 20px;"><table class="main"width="100%"cellpadding="0"cellspacing="0"style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; border-radius: 3px; background-color: #fff; margin: 0; border: 1px
|
||||
solid #e9e9e9;"bgcolor="#fff"><tr style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size:
|
||||
14px; margin: 0;"><td class="alert alert-warning"style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 16px; vertical-align: top; color: #fff; font-weight: 500; text-align: center; border-radius: 3px 3px 0 0; background-color: #2196F3; margin: 0; padding: 20px;"align="center"bgcolor="#FF9F00"valign="top">重设{siteTitle}密码</td></tr><tr style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;"><td class="content-wrap"style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; vertical-align: top; margin: 0; padding: 20px;"valign="top"><table width="100%"cellpadding="0"cellspacing="0"style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;"><tr style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;"><td class="content-block"style="font-family: 'Helvetica
|
||||
Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; vertical-align: top; margin: 0; padding: 0 0 20px;"valign="top">亲爱的<strong style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;">{userName}</strong>:</td></tr><tr style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;"><td class="content-block"style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; vertical-align: top; margin: 0; padding: 0 0 20px;"valign="top">请点击下方按钮完成密码重设。如果非你本人操作,请忽略此邮件。</td></tr><tr style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;"><td class="content-block"style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; vertical-align: top; margin: 0; padding: 0 0 20px;"valign="top"><a href="{resetUrl}"class="btn-primary"style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; color: #FFF; text-decoration: none; line-height: 2em; font-weight: bold; text-align: center; cursor: pointer; display: inline-block; border-radius: 5px; text-transform: capitalize; background-color: #2196F3; margin: 0; border-color: #2196F3; border-style: solid; border-width: 10px 20px;">重设密码</a></td></tr><tr style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;"><td class="content-block"style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; vertical-align: top; margin: 0; padding: 0 0 20px;"valign="top">感谢您选择{siteTitle}。</td></tr></table></td></tr></table><div class="footer"style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; width: 100%; clear: both; color: #999; margin: 0; padding: 20px;"><table width="100%"style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;"><tr style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;"><td class="aligncenter content-block"style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 12px; vertical-align: top; color: #999; text-align: center; margin: 0; padding: 0 0 20px;"align="center"valign="top">此邮件由系统自动发送,请不要直接回复。</td></tr></table></div></div></td><td style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; vertical-align: top; margin: 0;"valign="top"></td></tr></table></body></html>`, Type: "mail_template"},
|
||||
{Name: "pack_data", Value: `[]`, Type: "pack"},
|
||||
{Name: "db_version_" + conf.RequiredDBVersion, Value: `installed`, Type: "version"},
|
||||
{Name: "alipay_enabled", Value: `0`, Type: "payment"},
|
||||
{Name: "payjs_enabled", Value: `0`, Type: "payment"},
|
||||
{Name: "payjs_id", Value: ``, Type: "payment"},
|
||||
{Name: "payjs_secret", Value: ``, Type: "payment"},
|
||||
{Name: "appid", Value: ``, Type: "payment"},
|
||||
{Name: "appkey", Value: ``, Type: "payment"},
|
||||
{Name: "shopid", Value: ``, Type: "payment"},
|
||||
{Name: "wechat_enabled", Value: `0`, Type: "payment"},
|
||||
{Name: "wechat_appid", Value: ``, Type: "payment"},
|
||||
{Name: "wechat_mchid", Value: ``, Type: "payment"},
|
||||
{Name: "wechat_serial_no", Value: ``, Type: "payment"},
|
||||
{Name: "wechat_api_key", Value: ``, Type: "payment"},
|
||||
{Name: "wechat_pk_content", Value: ``, Type: "payment"},
|
||||
{Name: "hot_share_num", Value: `10`, Type: "share"},
|
||||
{Name: "group_sell_data", Value: `[]`, Type: "group_sell"},
|
||||
{Name: "gravatar_server", Value: `https://www.gravatar.com/`, Type: "avatar"},
|
||||
{Name: "defaultTheme", Value: `#3f51b5`, Type: "basic"},
|
||||
{Name: "themes", Value: `{"#3f51b5":{"palette":{"primary":{"main":"#3f51b5"},"secondary":{"main":"#f50057"}}},"#2196f3":{"palette":{"primary":{"main":"#2196f3"},"secondary":{"main":"#FFC107"}}},"#673AB7":{"palette":{"primary":{"main":"#673AB7"},"secondary":{"main":"#2196F3"}}},"#E91E63":{"palette":{"primary":{"main":"#E91E63"},"secondary":{"main":"#42A5F5","contrastText":"#fff"}}},"#FF5722":{"palette":{"primary":{"main":"#FF5722"},"secondary":{"main":"#3F51B5"}}},"#FFC107":{"palette":{"primary":{"main":"#FFC107"},"secondary":{"main":"#26C6DA"}}},"#8BC34A":{"palette":{"primary":{"main":"#8BC34A","contrastText":"#fff"},"secondary":{"main":"#FF8A65","contrastText":"#fff"}}},"#009688":{"palette":{"primary":{"main":"#009688"},"secondary":{"main":"#4DD0E1","contrastText":"#fff"}}},"#607D8B":{"palette":{"primary":{"main":"#607D8B"},"secondary":{"main":"#F06292"}}},"#795548":{"palette":{"primary":{"main":"#795548"},"secondary":{"main":"#4CAF50","contrastText":"#fff"}}}}`, Type: "basic"},
|
||||
{Name: "max_worker_num", Value: `10`, Type: "task"},
|
||||
{Name: "max_parallel_transfer", Value: `4`, Type: "task"},
|
||||
{Name: "secret_key", Value: util.RandStringRunes(256), Type: "auth"},
|
||||
{Name: "temp_path", Value: "temp", Type: "path"},
|
||||
{Name: "avatar_path", Value: "avatar", Type: "path"},
|
||||
{Name: "avatar_size", Value: "2097152", Type: "avatar"},
|
||||
{Name: "avatar_size_l", Value: "200", Type: "avatar"},
|
||||
{Name: "avatar_size_m", Value: "130", Type: "avatar"},
|
||||
{Name: "avatar_size_s", Value: "50", Type: "avatar"},
|
||||
{Name: "score_enabled", Value: "1", Type: "score"},
|
||||
{Name: "share_score_rate", Value: "80", Type: "score"},
|
||||
{Name: "score_price", Value: "1", Type: "score"},
|
||||
{Name: "report_enabled", Value: "0", Type: "report"},
|
||||
{Name: "home_view_method", Value: "list", Type: "view"},
|
||||
{Name: "share_view_method", Value: "list", Type: "view"},
|
||||
{Name: "cron_garbage_collect", Value: "@hourly", Type: "cron"},
|
||||
{Name: "cron_notify_user", Value: "@hourly", Type: "cron"},
|
||||
{Name: "cron_ban_user", Value: "@hourly", Type: "cron"},
|
||||
{Name: "cron_recycle_upload_session", Value: "@every 1h30m", Type: "cron"},
|
||||
{Name: "authn_enabled", Value: "0", Type: "authn"},
|
||||
{Name: "captcha_type", Value: "normal", Type: "captcha"},
|
||||
{Name: "captcha_height", Value: "60", Type: "captcha"},
|
||||
{Name: "captcha_width", Value: "240", Type: "captcha"},
|
||||
{Name: "captcha_mode", Value: "3", Type: "captcha"},
|
||||
{Name: "captcha_ComplexOfNoiseText", Value: "0", Type: "captcha"},
|
||||
{Name: "captcha_ComplexOfNoiseDot", Value: "0", Type: "captcha"},
|
||||
{Name: "captcha_IsShowHollowLine", Value: "0", Type: "captcha"},
|
||||
{Name: "captcha_IsShowNoiseDot", Value: "1", Type: "captcha"},
|
||||
{Name: "captcha_IsShowNoiseText", Value: "0", Type: "captcha"},
|
||||
{Name: "captcha_IsShowSlimeLine", Value: "1", Type: "captcha"},
|
||||
{Name: "captcha_IsShowSineLine", Value: "0", Type: "captcha"},
|
||||
{Name: "captcha_CaptchaLen", Value: "6", Type: "captcha"},
|
||||
{Name: "captcha_ReCaptchaKey", Value: "defaultKey", Type: "captcha"},
|
||||
{Name: "captcha_ReCaptchaSecret", Value: "defaultSecret", Type: "captcha"},
|
||||
{Name: "captcha_TCaptcha_CaptchaAppId", Value: "", Type: "captcha"},
|
||||
{Name: "captcha_TCaptcha_AppSecretKey", Value: "", Type: "captcha"},
|
||||
{Name: "captcha_TCaptcha_SecretId", Value: "", Type: "captcha"},
|
||||
{Name: "captcha_TCaptcha_SecretKey", Value: "", Type: "captcha"},
|
||||
{Name: "thumb_width", Value: "400", Type: "thumb"},
|
||||
{Name: "thumb_height", Value: "300", Type: "thumb"},
|
||||
{Name: "thumb_file_suffix", Value: "._thumb", Type: "thumb"},
|
||||
{Name: "thumb_max_task_count", Value: "-1", Type: "thumb"},
|
||||
{Name: "thumb_encode_method", Value: "jpg", Type: "thumb"},
|
||||
{Name: "thumb_gc_after_gen", Value: "0", Type: "thumb"},
|
||||
{Name: "thumb_encode_quality", Value: "85", Type: "thumb"},
|
||||
{Name: "thumb_builtin_enabled", Value: "1", Type: "thumb"},
|
||||
{Name: "thumb_vips_enabled", Value: "0", Type: "thumb"},
|
||||
{Name: "thumb_ffmpeg_enabled", Value: "0", Type: "thumb"},
|
||||
{Name: "thumb_vips_path", Value: "vips", Type: "thumb"},
|
||||
{Name: "thumb_vips_exts", Value: "csv,mat,img,hdr,pbm,pgm,ppm,pfm,pnm,svg,svgz,j2k,jp2,jpt,j2c,jpc,gif,png,jpg,jpeg,jpe,webp,tif,tiff,fits,fit,fts,exr,jxl,pdf,heic,heif,avif,svs,vms,vmu,ndpi,scn,mrxs,svslide,bif,raw", Type: "thumb"},
|
||||
{Name: "thumb_ffmpeg_seek", Value: "00:00:01.00", Type: "thumb"},
|
||||
{Name: "thumb_ffmpeg_path", Value: "ffmpeg", Type: "thumb"},
|
||||
{Name: "thumb_ffmpeg_exts", Value: "3g2,3gp,asf,asx,avi,divx,flv,m2ts,m2v,m4v,mkv,mov,mp4,mpeg,mpg,mts,mxf,ogv,rm,swf,webm,wmv", Type: "thumb"},
|
||||
{Name: "thumb_libreoffice_path", Value: "soffice", Type: "thumb"},
|
||||
{Name: "thumb_libreoffice_enabled", Value: "0", Type: "thumb"},
|
||||
{Name: "thumb_libreoffice_exts", Value: "md,ods,ots,fods,uos,xlsx,xml,xls,xlt,dif,dbf,html,slk,csv,xlsm,docx,dotx,doc,dot,rtf,xlsm,xlst,xls,xlw,xlc,xlt,pptx,ppsx,potx,pomx,ppt,pps,ppm,pot,pom", Type: "thumb"},
|
||||
{Name: "thumb_proxy_enabled", Value: "0", Type: "thumb"},
|
||||
{Name: "thumb_proxy_policy", Value: "[]", Type: "thumb"},
|
||||
{Name: "thumb_max_src_size", Value: "31457280", Type: "thumb"},
|
||||
{Name: "pwa_small_icon", Value: "/static/img/favicon.ico", Type: "pwa"},
|
||||
{Name: "pwa_medium_icon", Value: "/static/img/logo192.png", Type: "pwa"},
|
||||
{Name: "pwa_large_icon", Value: "/static/img/logo512.png", Type: "pwa"},
|
||||
{Name: "pwa_display", Value: "standalone", Type: "pwa"},
|
||||
{Name: "pwa_theme_color", Value: "#000000", Type: "pwa"},
|
||||
{Name: "pwa_background_color", Value: "#ffffff", Type: "pwa"},
|
||||
{Name: "initial_files", Value: "[]", Type: "register"},
|
||||
{Name: "office_preview_service", Value: "https://view.officeapps.live.com/op/view.aspx?src={$src}", Type: "preview"},
|
||||
{Name: "phone_required", Value: "false", Type: "phone"},
|
||||
{Name: "phone_enabled", Value: "false", Type: "phone"},
|
||||
{Name: "vol_content", Value: "eyJkb21haW4iOiJjbG91ZHJldmUub3JnIiwicHVyY2hhc2VfZGF0ZSI6MTY3MDMyOTI3OX0=", Type: "vol"},
|
||||
{Name: "vol_signature", Value: "UzVBwjfFNTU1bSQV8OTgbMvTdRO7FwNYyMdTu4/phmyUltc6MrluUItiK0v+Uq6yX05L4ZnhTlojVLgi3zXWNq0Tjo3zW3CffZVwj7FCrmG72PBuQp4hV3+b/eMpUbYcTTT9zEt2mneSpGJBOsxDgaf9isVzP+J+YwynPJy1UMa1ckYlc/rEExcxqZxH1tiSHfkyuelIENDiwiggOZl7J2opM5jbxH9oTiAhxl6MN1dbY6DH9bydTibcylSXoQASCse6P/i6JmEWPSRDY22Ofkw3cqTzQcxuMSJjYYVkdAHdeqoDYi4ywmAr1tAJnlDyNNU/KmLQzufgAWjdGKTPNA==", Type: "vol"},
|
||||
{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"},
|
||||
{Name: "custom_payment_enabled", Value: "0", Type: "payment"},
|
||||
{Name: "custom_payment_endpoint", Value: "", Type: "payment"},
|
||||
{Name: "custom_payment_secret", Value: "", Type: "payment"},
|
||||
{Name: "custom_payment_name", Value: "", Type: "payment"},
|
||||
{Name: "app_feedback_link", Value: "", Type: "mobile"},
|
||||
{Name: "app_forum_link", Value: "", Type: "mobile"},
|
||||
}
|
||||
|
||||
func InitSlaveDefaults() {
|
||||
for _, setting := range defaultSettings {
|
||||
cache.Set("setting_"+setting.Name, setting.Value, -1)
|
||||
}
|
||||
}
|
288
models/dialects/dialect_sqlite.go
Normal file
288
models/dialects/dialect_sqlite.go
Normal 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 %%');", 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
|
||||
}
|
128
models/download.go
Normal file
128
models/download.go
Normal file
@ -0,0 +1,128 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/aria2/rpc"
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/util"
|
||||
"github.com/jinzhu/gorm"
|
||||
)
|
||||
|
||||
// Download 离线下载队列模型
|
||||
type Download struct {
|
||||
gorm.Model
|
||||
Status int // 任务状态
|
||||
Type int // 任务类型
|
||||
Source string `gorm:"type:text"` // 文件下载地址
|
||||
TotalSize uint64 // 文件大小
|
||||
DownloadedSize uint64 // 文件大小
|
||||
GID string `gorm:"size:32,index:gid"` // 任务ID
|
||||
Speed int // 下载速度
|
||||
Parent string `gorm:"type:text"` // 存储目录
|
||||
Attrs string `gorm:"size:4294967295"` // 任务状态属性
|
||||
Error string `gorm:"type:text"` // 错误描述
|
||||
Dst string `gorm:"type:text"` // 用户文件系统存储父目录路径
|
||||
UserID uint // 发起者UID
|
||||
TaskID uint // 对应的转存任务ID
|
||||
NodeID uint // 处理任务的节点ID
|
||||
|
||||
// 关联模型
|
||||
User *User `gorm:"PRELOAD:false,association_autoupdate:false"`
|
||||
|
||||
// 数据库忽略字段
|
||||
StatusInfo rpc.StatusInfo `gorm:"-"`
|
||||
Task *Task `gorm:"-"`
|
||||
NodeName string `gorm:"-"`
|
||||
}
|
||||
|
||||
// AfterFind 找到下载任务后的钩子,处理Status结构
|
||||
func (task *Download) AfterFind() (err error) {
|
||||
// 解析状态
|
||||
if task.Attrs != "" {
|
||||
err = json.Unmarshal([]byte(task.Attrs), &task.StatusInfo)
|
||||
}
|
||||
|
||||
if task.TaskID != 0 {
|
||||
task.Task, _ = GetTasksByID(task.TaskID)
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// BeforeSave Save下载任务前的钩子
|
||||
func (task *Download) BeforeSave() (err error) {
|
||||
// 解析状态
|
||||
if task.Attrs != "" {
|
||||
err = json.Unmarshal([]byte(task.Attrs), &task.StatusInfo)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Create 创建离线下载记录
|
||||
func (task *Download) Create() (uint, error) {
|
||||
if err := DB.Create(task).Error; err != nil {
|
||||
util.Log().Warning("Failed to insert download record: %s", err)
|
||||
return 0, err
|
||||
}
|
||||
return task.ID, nil
|
||||
}
|
||||
|
||||
// Save 更新
|
||||
func (task *Download) Save() error {
|
||||
if err := DB.Save(task).Error; err != nil {
|
||||
util.Log().Warning("Failed to update download record: %s", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetDownloadsByStatus 根据状态检索下载
|
||||
func GetDownloadsByStatus(status ...int) []Download {
|
||||
var tasks []Download
|
||||
DB.Where("status in (?)", status).Find(&tasks)
|
||||
return tasks
|
||||
}
|
||||
|
||||
// GetDownloadsByStatusAndUser 根据状态检索和用户ID下载
|
||||
// page 为 0 表示列出所有,非零时分页
|
||||
func GetDownloadsByStatusAndUser(page, uid uint, status ...int) []Download {
|
||||
var tasks []Download
|
||||
dbChain := DB
|
||||
if page > 0 {
|
||||
dbChain = dbChain.Limit(10).Offset((page - 1) * 10).Order("updated_at DESC")
|
||||
}
|
||||
dbChain.Where("user_id = ? and status in (?)", uid, status).Find(&tasks)
|
||||
return tasks
|
||||
}
|
||||
|
||||
// GetDownloadByGid 根据GID和用户ID查找下载
|
||||
func GetDownloadByGid(gid string, uid uint) (*Download, error) {
|
||||
download := &Download{}
|
||||
result := DB.Where("user_id = ? and g_id = ?", uid, gid).First(download)
|
||||
return download, result.Error
|
||||
}
|
||||
|
||||
// GetOwner 获取下载任务所属用户
|
||||
func (task *Download) GetOwner() *User {
|
||||
if task.User == nil {
|
||||
if user, err := GetUserByID(task.UserID); err == nil {
|
||||
return &user
|
||||
}
|
||||
}
|
||||
return task.User
|
||||
}
|
||||
|
||||
// Delete 删除离线下载记录
|
||||
func (download *Download) Delete() error {
|
||||
return DB.Model(download).Delete(download).Error
|
||||
}
|
||||
|
||||
// GetNodeID 返回任务所属节点ID
|
||||
func (task *Download) GetNodeID() uint {
|
||||
// 兼容3.4版本之前生成的下载记录
|
||||
if task.NodeID == 0 {
|
||||
return 1
|
||||
}
|
||||
|
||||
return task.NodeID
|
||||
}
|
525
models/file.go
Normal file
525
models/file.go
Normal file
@ -0,0 +1,525 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"encoding/gob"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/filesystem/fsctx"
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/util"
|
||||
"github.com/jinzhu/gorm"
|
||||
)
|
||||
|
||||
// File 文件
|
||||
type File struct {
|
||||
// 表字段
|
||||
gorm.Model
|
||||
Name string `gorm:"unique_index:idx_only_one"`
|
||||
SourceName string `gorm:"type:text"`
|
||||
UserID uint `gorm:"index:user_id;unique_index:idx_only_one"`
|
||||
Size uint64
|
||||
PicInfo string
|
||||
FolderID uint `gorm:"index:folder_id;unique_index:idx_only_one"`
|
||||
PolicyID uint
|
||||
UploadSessionID *string `gorm:"index:session_id;unique_index:session_only_one"`
|
||||
Metadata string `gorm:"type:text"`
|
||||
|
||||
// 关联模型
|
||||
Policy Policy `gorm:"PRELOAD:false,association_autoupdate:false"`
|
||||
|
||||
// 数据库忽略字段
|
||||
Position string `gorm:"-"`
|
||||
MetadataSerialized map[string]string `gorm:"-"`
|
||||
}
|
||||
|
||||
// Thumb related metadata
|
||||
const (
|
||||
ThumbStatusNotExist = ""
|
||||
ThumbStatusExist = "exist"
|
||||
ThumbStatusNotAvailable = "not_available"
|
||||
|
||||
ThumbStatusMetadataKey = "thumb_status"
|
||||
ThumbSidecarMetadataKey = "thumb_sidecar"
|
||||
|
||||
ChecksumMetadataKey = "webdav_checksum"
|
||||
)
|
||||
|
||||
func init() {
|
||||
// 注册缓存用到的复杂结构
|
||||
gob.Register(File{})
|
||||
}
|
||||
|
||||
// Create 创建文件记录
|
||||
func (file *File) Create() error {
|
||||
tx := DB.Begin()
|
||||
|
||||
if err := tx.Create(file).Error; err != nil {
|
||||
util.Log().Warning("Failed to insert file record: %s", err)
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
user := &User{}
|
||||
user.ID = file.UserID
|
||||
if err := user.ChangeStorage(tx, "+", file.Size); err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
return tx.Commit().Error
|
||||
}
|
||||
|
||||
// AfterFind 找到文件后的钩子
|
||||
func (file *File) AfterFind() (err error) {
|
||||
// 反序列化文件元数据
|
||||
if file.Metadata != "" {
|
||||
err = json.Unmarshal([]byte(file.Metadata), &file.MetadataSerialized)
|
||||
} else {
|
||||
file.MetadataSerialized = make(map[string]string)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// BeforeSave Save策略前的钩子
|
||||
func (file *File) BeforeSave() (err error) {
|
||||
if len(file.MetadataSerialized) > 0 {
|
||||
metaValue, err := json.Marshal(&file.MetadataSerialized)
|
||||
file.Metadata = string(metaValue)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetChildFile 查找目录下名为name的子文件
|
||||
func (folder *Folder) GetChildFile(name string) (*File, error) {
|
||||
var file File
|
||||
result := DB.Where("folder_id = ? AND name = ?", folder.ID, name).Find(&file)
|
||||
|
||||
if result.Error == nil {
|
||||
file.Position = path.Join(folder.Position, folder.Name)
|
||||
}
|
||||
return &file, result.Error
|
||||
}
|
||||
|
||||
// GetChildFiles 查找目录下子文件
|
||||
func (folder *Folder) GetChildFiles() ([]File, error) {
|
||||
var files []File
|
||||
result := DB.Where("folder_id = ?", folder.ID).Find(&files)
|
||||
|
||||
if result.Error == nil {
|
||||
for i := 0; i < len(files); i++ {
|
||||
files[i].Position = path.Join(folder.Position, folder.Name)
|
||||
}
|
||||
}
|
||||
return files, result.Error
|
||||
}
|
||||
|
||||
// GetFilesByIDs 根据文件ID批量获取文件,
|
||||
// UID为0表示忽略用户,只根据文件ID检索
|
||||
func GetFilesByIDs(ids []uint, uid uint) ([]File, error) {
|
||||
return GetFilesByIDsFromTX(DB, ids, uid)
|
||||
}
|
||||
|
||||
func GetFilesByIDsFromTX(tx *gorm.DB, ids []uint, uid uint) ([]File, error) {
|
||||
var files []File
|
||||
var result *gorm.DB
|
||||
if uid == 0 {
|
||||
result = tx.Where("id in (?)", ids).Find(&files)
|
||||
} else {
|
||||
result = tx.Where("id in (?) AND user_id = ?", ids, uid).Find(&files)
|
||||
}
|
||||
return files, result.Error
|
||||
}
|
||||
|
||||
// GetFilesByKeywords 根据关键字搜索文件,
|
||||
// UID为0表示忽略用户,只根据文件ID检索. 如果 parents 非空, 则只限制在 parent 包含的目录下搜索
|
||||
func GetFilesByKeywords(uid uint, parents []uint, keywords ...interface{}) ([]File, error) {
|
||||
var (
|
||||
files []File
|
||||
result = DB
|
||||
conditions string
|
||||
)
|
||||
|
||||
// 生成查询条件
|
||||
for i := 0; i < len(keywords); i++ {
|
||||
conditions += "name like ?"
|
||||
if i != len(keywords)-1 {
|
||||
conditions += " or "
|
||||
}
|
||||
}
|
||||
|
||||
if uid != 0 {
|
||||
result = result.Where("user_id = ?", uid)
|
||||
}
|
||||
|
||||
if len(parents) > 0 {
|
||||
result = result.Where("folder_id in (?)", parents)
|
||||
}
|
||||
|
||||
result = result.Where("("+conditions+")", keywords...).Find(&files)
|
||||
|
||||
return files, result.Error
|
||||
}
|
||||
|
||||
// GetChildFilesOfFolders 批量检索目录子文件
|
||||
func GetChildFilesOfFolders(folders *[]Folder) ([]File, error) {
|
||||
// 将所有待检索目录ID抽离,以便检索文件
|
||||
folderIDs := make([]uint, 0, len(*folders))
|
||||
for _, value := range *folders {
|
||||
folderIDs = append(folderIDs, value.ID)
|
||||
}
|
||||
|
||||
// 检索文件
|
||||
var files []File
|
||||
result := DB.Where("folder_id in (?)", folderIDs).Find(&files)
|
||||
return files, result.Error
|
||||
}
|
||||
|
||||
// GetUploadPlaceholderFiles 获取所有上传占位文件
|
||||
// UID为0表示忽略用户
|
||||
func GetUploadPlaceholderFiles(uid uint) []*File {
|
||||
query := DB
|
||||
if uid != 0 {
|
||||
query = query.Where("user_id = ?", uid)
|
||||
}
|
||||
|
||||
var files []*File
|
||||
query.Where("upload_session_id is not NULL").Find(&files)
|
||||
return files
|
||||
}
|
||||
|
||||
// GetPolicy 获取文件所属策略
|
||||
func (file *File) GetPolicy() *Policy {
|
||||
if file.Policy.Model.ID == 0 {
|
||||
file.Policy, _ = GetPolicyByID(file.PolicyID)
|
||||
}
|
||||
return &file.Policy
|
||||
}
|
||||
|
||||
// RemoveFilesWithSoftLinks 去除给定的文件列表中有软链接的文件
|
||||
func RemoveFilesWithSoftLinks(files []File) ([]File, error) {
|
||||
// 结果值
|
||||
filteredFiles := make([]File, 0)
|
||||
|
||||
if len(files) == 0 {
|
||||
return filteredFiles, nil
|
||||
}
|
||||
|
||||
// 查询软链接的文件
|
||||
filesWithSoftLinks := make([]File, 0)
|
||||
for _, file := range files {
|
||||
var softLinkFile File
|
||||
res := DB.
|
||||
Where("source_name = ? and policy_id = ? and id != ?", file.SourceName, file.PolicyID, file.ID).
|
||||
First(&softLinkFile)
|
||||
if res.Error == nil {
|
||||
filesWithSoftLinks = append(filesWithSoftLinks, softLinkFile)
|
||||
}
|
||||
}
|
||||
|
||||
// 过滤具有软连接的文件
|
||||
// TODO: 优化复杂度
|
||||
if len(filesWithSoftLinks) == 0 {
|
||||
filteredFiles = files
|
||||
} else {
|
||||
for i := 0; i < len(files); i++ {
|
||||
finder := false
|
||||
for _, value := range filesWithSoftLinks {
|
||||
if value.PolicyID == files[i].PolicyID && value.SourceName == files[i].SourceName {
|
||||
finder = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !finder {
|
||||
filteredFiles = append(filteredFiles, files[i])
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
return filteredFiles, nil
|
||||
|
||||
}
|
||||
|
||||
// DeleteFiles 批量删除文件记录并归还容量
|
||||
func DeleteFiles(files []*File, uid uint) error {
|
||||
tx := DB.Begin()
|
||||
user := &User{}
|
||||
user.ID = uid
|
||||
var size uint64
|
||||
for _, file := range files {
|
||||
if uid > 0 && file.UserID != uid {
|
||||
tx.Rollback()
|
||||
return errors.New("user id not consistent")
|
||||
}
|
||||
|
||||
result := tx.Unscoped().Where("size = ?", file.Size).Delete(file)
|
||||
if result.Error != nil {
|
||||
tx.Rollback()
|
||||
return result.Error
|
||||
}
|
||||
|
||||
if result.RowsAffected == 0 {
|
||||
tx.Rollback()
|
||||
return errors.New("file size is dirty")
|
||||
}
|
||||
|
||||
size += file.Size
|
||||
}
|
||||
|
||||
if uid > 0 {
|
||||
if err := user.ChangeStorage(tx, "-", size); err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit().Error
|
||||
}
|
||||
|
||||
// GetFilesByParentIDs 根据父目录ID查找文件
|
||||
func GetFilesByParentIDs(ids []uint, uid uint) ([]File, error) {
|
||||
files := make([]File, 0, len(ids))
|
||||
result := DB.Where("user_id = ? and folder_id in (?)", uid, ids).Find(&files)
|
||||
return files, result.Error
|
||||
}
|
||||
|
||||
// GetFilesByUploadSession 查找上传会话对应的文件
|
||||
func GetFilesByUploadSession(sessionID string, uid uint) (*File, error) {
|
||||
file := File{}
|
||||
result := DB.Where("user_id = ? and upload_session_id = ?", uid, sessionID).Find(&file)
|
||||
return &file, result.Error
|
||||
}
|
||||
|
||||
// Rename 重命名文件
|
||||
func (file *File) Rename(new string) error {
|
||||
if file.MetadataSerialized[ThumbStatusMetadataKey] == ThumbStatusNotAvailable {
|
||||
if !strings.EqualFold(filepath.Ext(new), filepath.Ext(file.Name)) {
|
||||
// Reset thumb status for new ext name.
|
||||
if err := file.resetThumb(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return DB.Model(&file).Set("gorm:association_autoupdate", false).Updates(map[string]interface{}{
|
||||
"name": new,
|
||||
"metadata": file.Metadata,
|
||||
}).Error
|
||||
}
|
||||
|
||||
// UpdatePicInfo 更新文件的图像信息
|
||||
func (file *File) UpdatePicInfo(value string) error {
|
||||
return DB.Model(&file).Set("gorm:association_autoupdate", false).UpdateColumns(File{PicInfo: value}).Error
|
||||
}
|
||||
|
||||
// UpdateMetadata 新增或修改文件的元信息
|
||||
func (file *File) UpdateMetadata(data map[string]string) error {
|
||||
if file.MetadataSerialized == nil {
|
||||
file.MetadataSerialized = make(map[string]string)
|
||||
}
|
||||
|
||||
for k, v := range data {
|
||||
file.MetadataSerialized[k] = v
|
||||
}
|
||||
metaValue, err := json.Marshal(&file.MetadataSerialized)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return DB.Model(&file).Set("gorm:association_autoupdate", false).UpdateColumns(File{Metadata: string(metaValue)}).Error
|
||||
}
|
||||
|
||||
// UpdateSize 更新文件的大小信息
|
||||
// TODO: 全局锁
|
||||
func (file *File) UpdateSize(value uint64) error {
|
||||
tx := DB.Begin()
|
||||
var sizeDelta uint64
|
||||
operator := "+"
|
||||
user := User{}
|
||||
user.ID = file.UserID
|
||||
if value > file.Size {
|
||||
sizeDelta = value - file.Size
|
||||
} else {
|
||||
operator = "-"
|
||||
sizeDelta = file.Size - value
|
||||
}
|
||||
|
||||
if err := file.resetThumb(); err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
if res := tx.Model(&file).
|
||||
Where("size = ?", file.Size).
|
||||
Set("gorm:association_autoupdate", false).
|
||||
Updates(map[string]interface{}{
|
||||
"size": value,
|
||||
"metadata": file.Metadata,
|
||||
}); res.Error != nil {
|
||||
tx.Rollback()
|
||||
return res.Error
|
||||
}
|
||||
|
||||
if err := user.ChangeStorage(tx, operator, sizeDelta); err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
file.Size = value
|
||||
return tx.Commit().Error
|
||||
}
|
||||
|
||||
// UpdateSourceName 更新文件的源文件名
|
||||
func (file *File) UpdateSourceName(value string) error {
|
||||
if err := file.resetThumb(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return DB.Model(&file).Set("gorm:association_autoupdate", false).Updates(map[string]interface{}{
|
||||
"source_name": value,
|
||||
"metadata": file.Metadata,
|
||||
}).Error
|
||||
}
|
||||
|
||||
// Relocate 更新文件的物理指向
|
||||
func (file *File) Relocate(src string, policyID uint) error {
|
||||
file.Policy = Policy{}
|
||||
return DB.Model(&file).Set("gorm:association_autoupdate", false).Updates(map[string]interface{}{
|
||||
"source_name": src,
|
||||
"policy_id": policyID,
|
||||
}).Error
|
||||
}
|
||||
|
||||
func (file *File) PopChunkToFile(lastModified *time.Time, picInfo string) error {
|
||||
file.UploadSessionID = nil
|
||||
if lastModified != nil {
|
||||
file.UpdatedAt = *lastModified
|
||||
}
|
||||
|
||||
return DB.Model(file).UpdateColumns(map[string]interface{}{
|
||||
"upload_session_id": file.UploadSessionID,
|
||||
"updated_at": file.UpdatedAt,
|
||||
"pic_info": picInfo,
|
||||
}).Error
|
||||
}
|
||||
|
||||
// CanCopy 返回文件是否可被复制
|
||||
func (file *File) CanCopy() bool {
|
||||
return file.UploadSessionID == nil
|
||||
}
|
||||
|
||||
// CreateOrGetSourceLink creates a SourceLink model. If the given model exists, the existing
|
||||
// model will be returned.
|
||||
func (file *File) CreateOrGetSourceLink() (*SourceLink, error) {
|
||||
res := &SourceLink{}
|
||||
err := DB.Set("gorm:auto_preload", true).Where("file_id = ?", file.ID).Find(&res).Error
|
||||
if err == nil && res.ID > 0 {
|
||||
return res, nil
|
||||
}
|
||||
|
||||
res.FileID = file.ID
|
||||
res.Name = file.Name
|
||||
if err := DB.Save(res).Error; err != nil {
|
||||
return nil, fmt.Errorf("failed to insert SourceLink: %w", err)
|
||||
}
|
||||
|
||||
res.File = *file
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (file *File) resetThumb() error {
|
||||
if _, ok := file.MetadataSerialized[ThumbStatusMetadataKey]; !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
delete(file.MetadataSerialized, ThumbStatusMetadataKey)
|
||||
metaValue, err := json.Marshal(&file.MetadataSerialized)
|
||||
file.Metadata = string(metaValue)
|
||||
return err
|
||||
}
|
||||
|
||||
/*
|
||||
实现 webdav.FileInfo 接口
|
||||
*/
|
||||
|
||||
func (file *File) GetName() string {
|
||||
return file.Name
|
||||
}
|
||||
|
||||
func (file *File) GetSize() uint64 {
|
||||
return file.Size
|
||||
}
|
||||
func (file *File) ModTime() time.Time {
|
||||
return file.UpdatedAt
|
||||
}
|
||||
|
||||
func (file *File) IsDir() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (file *File) GetPosition() string {
|
||||
return file.Position
|
||||
}
|
||||
|
||||
// ShouldLoadThumb returns if file explorer should try to load thumbnail for this file.
|
||||
// `True` does not guarantee the load request will success in next step, but the client
|
||||
// should try to load and fallback to default placeholder in case error returned.
|
||||
func (file *File) ShouldLoadThumb() bool {
|
||||
return file.MetadataSerialized[ThumbStatusMetadataKey] != ThumbStatusNotAvailable
|
||||
}
|
||||
|
||||
// return sidecar thumb file name
|
||||
func (file *File) ThumbFile() string {
|
||||
return file.SourceName + GetSettingByNameWithDefault("thumb_file_suffix", "._thumb")
|
||||
}
|
||||
|
||||
/*
|
||||
实现 filesystem.FileHeader 接口
|
||||
*/
|
||||
|
||||
// Read 实现 io.Reader
|
||||
func (file *File) Read(p []byte) (n int, err error) {
|
||||
return 0, errors.New("noe supported")
|
||||
}
|
||||
|
||||
// Close 实现io.Closer
|
||||
func (file *File) Close() error {
|
||||
return errors.New("noe supported")
|
||||
}
|
||||
|
||||
// Seeker 实现io.Seeker
|
||||
func (file *File) Seek(offset int64, whence int) (int64, error) {
|
||||
return 0, errors.New("noe supported")
|
||||
}
|
||||
|
||||
func (file *File) Info() *fsctx.UploadTaskInfo {
|
||||
return &fsctx.UploadTaskInfo{
|
||||
Size: file.Size,
|
||||
FileName: file.Name,
|
||||
VirtualPath: file.Position,
|
||||
Mode: 0,
|
||||
Metadata: file.MetadataSerialized,
|
||||
LastModified: &file.UpdatedAt,
|
||||
SavePath: file.SourceName,
|
||||
UploadSessionID: file.UploadSessionID,
|
||||
}
|
||||
}
|
||||
|
||||
func (file *File) SetSize(size uint64) {
|
||||
file.Size = size
|
||||
}
|
||||
|
||||
func (file *File) SetModel(newFile interface{}) {
|
||||
}
|
||||
|
||||
func (file *File) Seekable() bool {
|
||||
return false
|
||||
}
|
365
models/folder.go
Normal file
365
models/folder.go
Normal file
@ -0,0 +1,365 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"path"
|
||||
"time"
|
||||
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/util"
|
||||
"github.com/jinzhu/gorm"
|
||||
)
|
||||
|
||||
// Folder 目录
|
||||
type Folder struct {
|
||||
// 表字段
|
||||
gorm.Model
|
||||
Name string `gorm:"unique_index:idx_only_one_name"`
|
||||
ParentID *uint `gorm:"index:parent_id;unique_index:idx_only_one_name"`
|
||||
OwnerID uint `gorm:"index:owner_id"`
|
||||
PolicyID uint // Webdav下挂载的存储策略ID
|
||||
|
||||
// 数据库忽略字段
|
||||
Position string `gorm:"-"`
|
||||
WebdavDstName string `gorm:"-"`
|
||||
InheritPolicyID uint `gorm:"-"` // 从父目录继承而来的policy id,默认值则使用自身的的PolicyID
|
||||
}
|
||||
|
||||
// Create 创建目录
|
||||
func (folder *Folder) Create() (uint, error) {
|
||||
if err := DB.FirstOrCreate(folder, *folder).Error; err != nil {
|
||||
folder.Model = gorm.Model{}
|
||||
err2 := DB.First(folder, *folder).Error
|
||||
return folder.ID, err2
|
||||
}
|
||||
|
||||
return folder.ID, nil
|
||||
}
|
||||
|
||||
// GetMountedFolders 列出已挂载存储策略的目录
|
||||
func GetMountedFolders(uid uint) []Folder {
|
||||
var folders []Folder
|
||||
DB.Where("owner_id = ? and policy_id <> ?", uid, 0).Find(&folders)
|
||||
return folders
|
||||
}
|
||||
|
||||
// GetChild 返回folder下名为name的子目录,不存在则返回错误
|
||||
func (folder *Folder) GetChild(name string) (*Folder, error) {
|
||||
var resFolder Folder
|
||||
err := DB.
|
||||
Where("parent_id = ? AND owner_id = ? AND name = ?", folder.ID, folder.OwnerID, name).
|
||||
First(&resFolder).Error
|
||||
|
||||
// 将子目录的路径及存储策略传递下去
|
||||
if err == nil {
|
||||
resFolder.Position = path.Join(folder.Position, folder.Name)
|
||||
if folder.PolicyID > 0 {
|
||||
resFolder.InheritPolicyID = folder.PolicyID
|
||||
} else if folder.InheritPolicyID > 0 {
|
||||
resFolder.InheritPolicyID = folder.InheritPolicyID
|
||||
}
|
||||
}
|
||||
return &resFolder, err
|
||||
}
|
||||
|
||||
// TraceRoot 向上递归查找父目录
|
||||
func (folder *Folder) TraceRoot() error {
|
||||
if folder.ParentID == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var parentFolder Folder
|
||||
err := DB.
|
||||
Where("id = ? AND owner_id = ?", folder.ParentID, folder.OwnerID).
|
||||
First(&parentFolder).Error
|
||||
|
||||
if err == nil {
|
||||
err := parentFolder.TraceRoot()
|
||||
folder.Position = path.Join(parentFolder.Position, parentFolder.Name)
|
||||
return err
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// GetChildFolder 查找子目录
|
||||
func (folder *Folder) GetChildFolder() ([]Folder, error) {
|
||||
var folders []Folder
|
||||
result := DB.Where("parent_id = ?", folder.ID).Find(&folders)
|
||||
|
||||
if result.Error == nil {
|
||||
for i := 0; i < len(folders); i++ {
|
||||
folders[i].Position = path.Join(folder.Position, folder.Name)
|
||||
}
|
||||
}
|
||||
return folders, result.Error
|
||||
}
|
||||
|
||||
// GetRecursiveChildFolder 查找所有递归子目录,包括自身
|
||||
func GetRecursiveChildFolder(dirs []uint, uid uint, includeSelf bool) ([]Folder, error) {
|
||||
folders := make([]Folder, 0, len(dirs))
|
||||
var err error
|
||||
|
||||
var parFolders []Folder
|
||||
result := DB.Where("owner_id = ? and id in (?)", uid, dirs).Find(&parFolders)
|
||||
if result.Error != nil {
|
||||
return folders, err
|
||||
}
|
||||
|
||||
// 整理父目录的ID
|
||||
var parentIDs = make([]uint, 0, len(parFolders))
|
||||
for _, folder := range parFolders {
|
||||
parentIDs = append(parentIDs, folder.ID)
|
||||
}
|
||||
|
||||
if includeSelf {
|
||||
// 合并至最终结果
|
||||
folders = append(folders, parFolders...)
|
||||
}
|
||||
parFolders = []Folder{}
|
||||
|
||||
// 递归查询子目录,最大递归65535次
|
||||
for i := 0; i < 65535; i++ {
|
||||
|
||||
result = DB.Where("owner_id = ? and parent_id in (?)", uid, parentIDs).Find(&parFolders)
|
||||
|
||||
// 查询结束条件
|
||||
if len(parFolders) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
// 整理父目录的ID
|
||||
parentIDs = make([]uint, 0, len(parFolders))
|
||||
for _, folder := range parFolders {
|
||||
parentIDs = append(parentIDs, folder.ID)
|
||||
}
|
||||
|
||||
// 合并至最终结果
|
||||
folders = append(folders, parFolders...)
|
||||
parFolders = []Folder{}
|
||||
|
||||
}
|
||||
|
||||
return folders, err
|
||||
}
|
||||
|
||||
// DeleteFolderByIDs 根据给定ID批量删除目录记录
|
||||
func DeleteFolderByIDs(ids []uint) error {
|
||||
result := DB.Where("id in (?)", ids).Unscoped().Delete(&Folder{})
|
||||
return result.Error
|
||||
}
|
||||
|
||||
// GetFoldersByIDs 根据ID和用户查找所有目录
|
||||
func GetFoldersByIDs(ids []uint, uid uint) ([]Folder, error) {
|
||||
var folders []Folder
|
||||
result := DB.Where("id in (?) AND owner_id = ?", ids, uid).Find(&folders)
|
||||
return folders, result.Error
|
||||
}
|
||||
|
||||
// MoveOrCopyFileTo 将此目录下的files移动或复制至dstFolder,
|
||||
// 返回此操作新增的容量
|
||||
func (folder *Folder) MoveOrCopyFileTo(files []uint, dstFolder *Folder, isCopy bool) (uint64, error) {
|
||||
// 已复制文件的总大小
|
||||
var copiedSize uint64
|
||||
|
||||
if isCopy {
|
||||
// 检索出要复制的文件
|
||||
var originFiles = make([]File, 0, len(files))
|
||||
if err := DB.Where(
|
||||
"id in (?) and user_id = ? and folder_id = ?",
|
||||
files,
|
||||
folder.OwnerID,
|
||||
folder.ID,
|
||||
).Find(&originFiles).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// 复制文件记录
|
||||
for _, oldFile := range originFiles {
|
||||
if !oldFile.CanCopy() {
|
||||
util.Log().Warning("Cannot copy file %q because it's being uploaded now, skipping...", oldFile.Name)
|
||||
continue
|
||||
}
|
||||
|
||||
oldFile.Model = gorm.Model{}
|
||||
oldFile.FolderID = dstFolder.ID
|
||||
oldFile.UserID = dstFolder.OwnerID
|
||||
|
||||
// webdav目标名重置
|
||||
if dstFolder.WebdavDstName != "" {
|
||||
oldFile.Name = dstFolder.WebdavDstName
|
||||
}
|
||||
|
||||
if err := DB.Create(&oldFile).Error; err != nil {
|
||||
return copiedSize, err
|
||||
}
|
||||
|
||||
copiedSize += oldFile.Size
|
||||
}
|
||||
|
||||
} else {
|
||||
var updates = map[string]interface{}{
|
||||
"folder_id": dstFolder.ID,
|
||||
}
|
||||
// webdav目标名重置
|
||||
if dstFolder.WebdavDstName != "" {
|
||||
updates["name"] = dstFolder.WebdavDstName
|
||||
}
|
||||
|
||||
// 更改顶级要移动文件的父目录指向
|
||||
err := DB.Model(File{}).Where(
|
||||
"id in (?) and user_id = ? and folder_id = ?",
|
||||
files,
|
||||
folder.OwnerID,
|
||||
folder.ID,
|
||||
).
|
||||
Update(updates).
|
||||
Error
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return copiedSize, nil
|
||||
|
||||
}
|
||||
|
||||
// CopyFolderTo 将此目录及其子目录及文件递归复制至dstFolder
|
||||
// 返回此操作新增的容量
|
||||
func (folder *Folder) CopyFolderTo(folderID uint, dstFolder *Folder) (size uint64, err error) {
|
||||
// 列出所有子目录
|
||||
subFolders, err := GetRecursiveChildFolder([]uint{folderID}, folder.OwnerID, true)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// 抽离所有子目录的ID
|
||||
var subFolderIDs = make([]uint, len(subFolders))
|
||||
for key, value := range subFolders {
|
||||
subFolderIDs[key] = value.ID
|
||||
}
|
||||
|
||||
// 复制子目录
|
||||
var newIDCache = make(map[uint]uint)
|
||||
for _, folder := range subFolders {
|
||||
// 新的父目录指向
|
||||
var newID uint
|
||||
// 顶级目录直接指向新的目的目录
|
||||
if folder.ID == folderID {
|
||||
newID = dstFolder.ID
|
||||
// webdav目标名重置
|
||||
if dstFolder.WebdavDstName != "" {
|
||||
folder.Name = dstFolder.WebdavDstName
|
||||
}
|
||||
} else if IDCache, ok := newIDCache[*folder.ParentID]; ok {
|
||||
newID = IDCache
|
||||
} else {
|
||||
util.Log().Warning("Failed to get parent folder %q", *folder.ParentID)
|
||||
return size, errors.New("Failed to get parent folder")
|
||||
}
|
||||
|
||||
// 插入新的目录记录
|
||||
oldID := folder.ID
|
||||
folder.Model = gorm.Model{}
|
||||
folder.ParentID = &newID
|
||||
folder.OwnerID = dstFolder.OwnerID
|
||||
if err = DB.Create(&folder).Error; err != nil {
|
||||
return size, err
|
||||
}
|
||||
// 记录新的ID以便其子目录使用
|
||||
newIDCache[oldID] = folder.ID
|
||||
|
||||
}
|
||||
|
||||
// 复制文件
|
||||
var originFiles = make([]File, 0, len(subFolderIDs))
|
||||
if err := DB.Where(
|
||||
"user_id = ? and folder_id in (?)",
|
||||
folder.OwnerID,
|
||||
subFolderIDs,
|
||||
).Find(&originFiles).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// 复制文件记录
|
||||
for _, oldFile := range originFiles {
|
||||
if !oldFile.CanCopy() {
|
||||
util.Log().Warning("Cannot copy file %q because it's being uploaded now, skipping...", oldFile.Name)
|
||||
continue
|
||||
}
|
||||
|
||||
oldFile.Model = gorm.Model{}
|
||||
oldFile.FolderID = newIDCache[oldFile.FolderID]
|
||||
oldFile.UserID = dstFolder.OwnerID
|
||||
if err := DB.Create(&oldFile).Error; err != nil {
|
||||
return size, err
|
||||
}
|
||||
|
||||
size += oldFile.Size
|
||||
}
|
||||
|
||||
return size, nil
|
||||
|
||||
}
|
||||
|
||||
// MoveFolderTo 将folder目录下的dirs子目录复制或移动到dstFolder,
|
||||
// 返回此过程中增加的容量
|
||||
func (folder *Folder) MoveFolderTo(dirs []uint, dstFolder *Folder) error {
|
||||
|
||||
// 如果目标位置为待移动的目录,会导致 parent 为自己
|
||||
// 造成死循环且无法被除搜索以外的组件展示
|
||||
if folder.OwnerID == dstFolder.OwnerID && util.ContainsUint(dirs, dstFolder.ID) {
|
||||
return errors.New("cannot move a folder into itself")
|
||||
}
|
||||
|
||||
var updates = map[string]interface{}{
|
||||
"parent_id": dstFolder.ID,
|
||||
}
|
||||
// webdav目标名重置
|
||||
if dstFolder.WebdavDstName != "" {
|
||||
updates["name"] = dstFolder.WebdavDstName
|
||||
}
|
||||
|
||||
// 更改顶级要移动目录的父目录指向
|
||||
err := DB.Model(Folder{}).Where(
|
||||
"id in (?) and owner_id = ? and parent_id = ?",
|
||||
dirs,
|
||||
folder.OwnerID,
|
||||
folder.ID,
|
||||
).Update(updates).Error
|
||||
|
||||
return err
|
||||
|
||||
}
|
||||
|
||||
// Rename 重命名目录
|
||||
func (folder *Folder) Rename(new string) error {
|
||||
return DB.Model(&folder).UpdateColumn("name", new).Error
|
||||
}
|
||||
|
||||
// Mount 目录挂载
|
||||
func (folder *Folder) Mount(new uint) error {
|
||||
return DB.Model(&folder).Update("policy_id", new).Error
|
||||
}
|
||||
|
||||
/*
|
||||
实现 FileInfo.FileInfo 接口
|
||||
TODO 测试
|
||||
*/
|
||||
|
||||
func (folder *Folder) GetName() string {
|
||||
return folder.Name
|
||||
}
|
||||
|
||||
func (folder *Folder) GetSize() uint64 {
|
||||
return 0
|
||||
}
|
||||
func (folder *Folder) ModTime() time.Time {
|
||||
return folder.UpdatedAt
|
||||
}
|
||||
func (folder *Folder) IsDir() bool {
|
||||
return true
|
||||
}
|
||||
func (folder *Folder) GetPosition() string {
|
||||
return folder.Position
|
||||
}
|
89
models/group.go
Normal file
89
models/group.go
Normal file
@ -0,0 +1,89 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/jinzhu/gorm"
|
||||
)
|
||||
|
||||
// Group 用户组模型
|
||||
type Group struct {
|
||||
gorm.Model
|
||||
Name string
|
||||
Policies string
|
||||
MaxStorage uint64
|
||||
ShareEnabled bool
|
||||
WebDAVEnabled bool
|
||||
SpeedLimit int
|
||||
Options string `json:"-" gorm:"size:4294967295"`
|
||||
|
||||
// 数据库忽略字段
|
||||
PolicyList []uint `gorm:"-"`
|
||||
OptionsSerialized GroupOption `gorm:"-"`
|
||||
}
|
||||
|
||||
// GroupOption 用户组其他配置
|
||||
type GroupOption struct {
|
||||
ArchiveDownload bool `json:"archive_download,omitempty"` // 打包下载
|
||||
ArchiveTask bool `json:"archive_task,omitempty"` // 在线压缩
|
||||
CompressSize uint64 `json:"compress_size,omitempty"` // 可压缩大小
|
||||
DecompressSize uint64 `json:"decompress_size,omitempty"`
|
||||
OneTimeDownload bool `json:"one_time_download,omitempty"`
|
||||
ShareDownload bool `json:"share_download,omitempty"`
|
||||
ShareFree bool `json:"share_free,omitempty"`
|
||||
Aria2 bool `json:"aria2,omitempty"` // 离线下载
|
||||
Aria2Options map[string]interface{} `json:"aria2_options,omitempty"` // 离线下载用户组配置
|
||||
Relocate bool `json:"relocate,omitempty"` // 转移文件
|
||||
SourceBatchSize int `json:"source_batch,omitempty"`
|
||||
RedirectedSource bool `json:"redirected_source,omitempty"`
|
||||
Aria2BatchSize int `json:"aria2_batch,omitempty"`
|
||||
AvailableNodes []uint `json:"available_nodes,omitempty"`
|
||||
SelectNode bool `json:"select_node,omitempty"`
|
||||
AdvanceDelete bool `json:"advance_delete,omitempty"`
|
||||
WebDAVProxy bool `json:"webdav_proxy,omitempty"`
|
||||
}
|
||||
|
||||
// GetGroupByID 用ID获取用户组
|
||||
func GetGroupByID(ID interface{}) (Group, error) {
|
||||
var group Group
|
||||
result := DB.First(&group, ID)
|
||||
return group, result.Error
|
||||
}
|
||||
|
||||
// AfterFind 找到用户组后的钩子,处理Policy列表
|
||||
func (group *Group) AfterFind() (err error) {
|
||||
// 解析用户组策略列表
|
||||
if group.Policies != "" {
|
||||
err = json.Unmarshal([]byte(group.Policies), &group.PolicyList)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 解析用户组设置
|
||||
if group.Options != "" {
|
||||
err = json.Unmarshal([]byte(group.Options), &group.OptionsSerialized)
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// BeforeSave Save用户前的钩子
|
||||
func (group *Group) BeforeSave() (err error) {
|
||||
err = group.SerializePolicyList()
|
||||
return err
|
||||
}
|
||||
|
||||
// SerializePolicyList 将序列后的可选策略列表、配置写入数据库字段
|
||||
// TODO 完善测试
|
||||
func (group *Group) SerializePolicyList() (err error) {
|
||||
policies, err := json.Marshal(&group.PolicyList)
|
||||
group.Policies = string(policies)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
optionsValue, err := json.Marshal(&group.OptionsSerialized)
|
||||
group.Options = string(optionsValue)
|
||||
return err
|
||||
}
|
106
models/init.go
Normal file
106
models/init.go
Normal file
@ -0,0 +1,106 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/conf"
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/util"
|
||||
"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"
|
||||
)
|
||||
|
||||
// DB 数据库链接单例
|
||||
var DB *gorm.DB
|
||||
|
||||
// Init 初始化 MySQL 链接
|
||||
func Init() {
|
||||
util.Log().Info("Initializing database connection...")
|
||||
|
||||
var (
|
||||
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("sqlite", ":memory:")
|
||||
} else {
|
||||
switch confDBType {
|
||||
case "UNSET", "sqlite":
|
||||
// 未指定数据库或者明确指定为 sqlite 时,使用 SQLite 数据库
|
||||
db, err = gorm.Open("sqlite", util.RelativePath(conf.DatabaseConfig.DBFile))
|
||||
case "postgres":
|
||||
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":
|
||||
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,
|
||||
host,
|
||||
conf.DatabaseConfig.Name,
|
||||
conf.DatabaseConfig.Charset))
|
||||
default:
|
||||
util.Log().Panic("Unsupported database type %q.", confDBType)
|
||||
}
|
||||
}
|
||||
|
||||
//db.SetLogger(util.Log())
|
||||
if err != nil {
|
||||
util.Log().Panic("Failed to connect to database: %s", err)
|
||||
}
|
||||
|
||||
// 处理表前缀
|
||||
gorm.DefaultTableNameHandler = func(db *gorm.DB, defaultTableName string) string {
|
||||
return conf.DatabaseConfig.TablePrefix + defaultTableName
|
||||
}
|
||||
|
||||
// Debug模式下,输出所有 SQL 日志
|
||||
if conf.SystemConfig.Debug {
|
||||
db.LogMode(true)
|
||||
} else {
|
||||
db.LogMode(false)
|
||||
}
|
||||
|
||||
//设置连接池
|
||||
db.DB().SetMaxIdleConns(50)
|
||||
if confDBType == "sqlite" || confDBType == "UNSET" {
|
||||
db.DB().SetMaxOpenConns(1)
|
||||
} else {
|
||||
db.DB().SetMaxOpenConns(100)
|
||||
}
|
||||
|
||||
//超时
|
||||
db.DB().SetConnMaxLifetime(time.Second * 30)
|
||||
|
||||
DB = db
|
||||
|
||||
//执行迁移
|
||||
migration()
|
||||
}
|
221
models/migration.go
Normal file
221
models/migration.go
Normal file
@ -0,0 +1,221 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/cloudreve/Cloudreve/v3/models/scripts/invoker"
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/cache"
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/conf"
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/util"
|
||||
"github.com/fatih/color"
|
||||
"github.com/hashicorp/go-version"
|
||||
"github.com/jinzhu/gorm"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 是否需要迁移
|
||||
func needMigration() bool {
|
||||
var setting Setting
|
||||
return DB.Where("name = ?", "db_version_"+conf.RequiredDBVersion).First(&setting).Error != nil
|
||||
}
|
||||
|
||||
// 执行数据迁移
|
||||
func migration() {
|
||||
// 确认是否需要执行迁移
|
||||
if !needMigration() {
|
||||
util.Log().Info("Database version fulfilled, skip schema migration.")
|
||||
return
|
||||
|
||||
}
|
||||
|
||||
util.Log().Info("Start initializing database schema...")
|
||||
|
||||
// 清除所有缓存
|
||||
if instance, ok := cache.Store.(*cache.RedisStore); ok {
|
||||
instance.DeleteAll()
|
||||
}
|
||||
|
||||
// 自动迁移模式
|
||||
if conf.DatabaseConfig.Type == "mysql" {
|
||||
DB = DB.Set("gorm:table_options", "ENGINE=InnoDB")
|
||||
}
|
||||
|
||||
DB.AutoMigrate(&User{}, &Setting{}, &Group{}, &Policy{}, &Folder{}, &File{}, &StoragePack{}, &Share{},
|
||||
&Task{}, &Download{}, &Tag{}, &Webdav{}, &Order{}, &Redeem{}, &Report{}, &Node{}, &SourceLink{})
|
||||
|
||||
// 创建初始存储策略
|
||||
addDefaultPolicy()
|
||||
|
||||
// 创建初始用户组
|
||||
addDefaultGroups()
|
||||
|
||||
// 创建初始管理员账户
|
||||
addDefaultUser()
|
||||
|
||||
// 创建初始节点
|
||||
addDefaultNode()
|
||||
|
||||
// 向设置数据表添加初始设置
|
||||
addDefaultSettings()
|
||||
|
||||
// 执行数据库升级脚本
|
||||
execUpgradeScripts()
|
||||
|
||||
util.Log().Info("Finish initializing database schema.")
|
||||
|
||||
}
|
||||
|
||||
func addDefaultPolicy() {
|
||||
_, err := GetPolicyByID(uint(1))
|
||||
// 未找到初始存储策略时,则创建
|
||||
if gorm.IsRecordNotFoundError(err) {
|
||||
defaultPolicy := Policy{
|
||||
Name: "Default storage policy",
|
||||
Type: "local",
|
||||
MaxSize: 0,
|
||||
AutoRename: true,
|
||||
DirNameRule: "uploads/{uid}/{path}",
|
||||
FileNameRule: "{uid}_{randomkey8}_{originname}",
|
||||
IsOriginLinkEnable: false,
|
||||
OptionsSerialized: PolicyOption{
|
||||
ChunkSize: 25 << 20, // 25MB
|
||||
},
|
||||
}
|
||||
if err := DB.Create(&defaultPolicy).Error; err != nil {
|
||||
util.Log().Panic("Failed to create default storage policy: %s", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func addDefaultSettings() {
|
||||
for _, value := range defaultSettings {
|
||||
DB.Where(Setting{Name: value.Name}).Create(&value)
|
||||
}
|
||||
}
|
||||
|
||||
func addDefaultGroups() {
|
||||
_, err := GetGroupByID(1)
|
||||
// 未找到初始管理组时,则创建
|
||||
if gorm.IsRecordNotFoundError(err) {
|
||||
defaultAdminGroup := Group{
|
||||
Name: "Admin",
|
||||
PolicyList: []uint{1},
|
||||
MaxStorage: 1 * 1024 * 1024 * 1024,
|
||||
ShareEnabled: true,
|
||||
WebDAVEnabled: true,
|
||||
OptionsSerialized: GroupOption{
|
||||
ArchiveDownload: true,
|
||||
ArchiveTask: true,
|
||||
ShareDownload: true,
|
||||
ShareFree: true,
|
||||
Aria2: true,
|
||||
Relocate: true,
|
||||
SourceBatchSize: 1000,
|
||||
Aria2BatchSize: 50,
|
||||
RedirectedSource: true,
|
||||
SelectNode: true,
|
||||
AdvanceDelete: true,
|
||||
},
|
||||
}
|
||||
if err := DB.Create(&defaultAdminGroup).Error; err != nil {
|
||||
util.Log().Panic("Failed to create admin user group: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
err = nil
|
||||
_, err = GetGroupByID(2)
|
||||
// 未找到初始注册会员时,则创建
|
||||
if gorm.IsRecordNotFoundError(err) {
|
||||
defaultAdminGroup := Group{
|
||||
Name: "User",
|
||||
PolicyList: []uint{1},
|
||||
MaxStorage: 1 * 1024 * 1024 * 1024,
|
||||
ShareEnabled: true,
|
||||
WebDAVEnabled: true,
|
||||
OptionsSerialized: GroupOption{
|
||||
ShareDownload: true,
|
||||
SourceBatchSize: 10,
|
||||
Aria2BatchSize: 1,
|
||||
RedirectedSource: true,
|
||||
},
|
||||
}
|
||||
if err := DB.Create(&defaultAdminGroup).Error; err != nil {
|
||||
util.Log().Panic("Failed to create initial user group: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
err = nil
|
||||
_, err = GetGroupByID(3)
|
||||
// 未找到初始游客用户组时,则创建
|
||||
if gorm.IsRecordNotFoundError(err) {
|
||||
defaultAdminGroup := Group{
|
||||
Name: "Anonymous",
|
||||
PolicyList: []uint{},
|
||||
Policies: "[]",
|
||||
OptionsSerialized: GroupOption{
|
||||
ShareDownload: true,
|
||||
},
|
||||
}
|
||||
if err := DB.Create(&defaultAdminGroup).Error; err != nil {
|
||||
util.Log().Panic("Failed to create anonymous user group: %s", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func addDefaultUser() {
|
||||
_, err := GetUserByID(1)
|
||||
password := util.RandStringRunes(8)
|
||||
|
||||
// 未找到初始用户时,则创建
|
||||
if gorm.IsRecordNotFoundError(err) {
|
||||
defaultUser := NewUser()
|
||||
defaultUser.Email = "admin@cloudreve.org"
|
||||
defaultUser.Nick = "admin"
|
||||
defaultUser.Status = Active
|
||||
defaultUser.GroupID = 1
|
||||
err := defaultUser.SetPassword(password)
|
||||
if err != nil {
|
||||
util.Log().Panic("Failed to create password: %s", err)
|
||||
}
|
||||
if err := DB.Create(&defaultUser).Error; err != nil {
|
||||
util.Log().Panic("Failed to create initial root user: %s", err)
|
||||
}
|
||||
|
||||
c := color.New(color.FgWhite).Add(color.BgBlack).Add(color.Bold)
|
||||
util.Log().Info("Admin user name: " + c.Sprint("admin@cloudreve.org"))
|
||||
util.Log().Info("Admin password: " + c.Sprint(password))
|
||||
}
|
||||
}
|
||||
|
||||
func addDefaultNode() {
|
||||
_, err := GetNodeByID(1)
|
||||
|
||||
if gorm.IsRecordNotFoundError(err) {
|
||||
defaultAdminGroup := Node{
|
||||
Name: "Master (Local machine)",
|
||||
Status: NodeActive,
|
||||
Type: MasterNodeType,
|
||||
Aria2OptionsSerialized: Aria2Option{
|
||||
Interval: 10,
|
||||
Timeout: 10,
|
||||
},
|
||||
}
|
||||
if err := DB.Create(&defaultAdminGroup).Error; err != nil {
|
||||
util.Log().Panic("Failed to create initial node: %s", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func execUpgradeScripts() {
|
||||
s := invoker.ListPrefix("UpgradeTo")
|
||||
versions := make([]*version.Version, len(s))
|
||||
for i, raw := range s {
|
||||
v, _ := version.NewVersion(strings.TrimPrefix(raw, "UpgradeTo"))
|
||||
versions[i] = v
|
||||
}
|
||||
sort.Sort(version.Collection(versions))
|
||||
|
||||
for i := 0; i < len(versions); i++ {
|
||||
invoker.RunDBScript("UpgradeTo"+versions[i].String(), context.Background())
|
||||
}
|
||||
}
|
91
models/node.go
Normal file
91
models/node.go
Normal file
@ -0,0 +1,91 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"github.com/jinzhu/gorm"
|
||||
)
|
||||
|
||||
// Node 从机节点信息模型
|
||||
type Node struct {
|
||||
gorm.Model
|
||||
Status NodeStatus // 节点状态
|
||||
Name string // 节点别名
|
||||
Type ModelType // 节点状态
|
||||
Server string // 服务器地址
|
||||
SlaveKey string `gorm:"type:text"` // 主->从 通信密钥
|
||||
MasterKey string `gorm:"type:text"` // 从->主 通信密钥
|
||||
Aria2Enabled bool // 是否支持用作离线下载节点
|
||||
Aria2Options string `gorm:"type:text"` // 离线下载配置
|
||||
Rank int // 负载均衡权重
|
||||
|
||||
// 数据库忽略字段
|
||||
Aria2OptionsSerialized Aria2Option `gorm:"-"`
|
||||
}
|
||||
|
||||
// Aria2Option 非公有的Aria2配置属性
|
||||
type Aria2Option struct {
|
||||
// RPC 服务器地址
|
||||
Server string `json:"server,omitempty"`
|
||||
// RPC 密钥
|
||||
Token string `json:"token,omitempty"`
|
||||
// 临时下载目录
|
||||
TempPath string `json:"temp_path,omitempty"`
|
||||
// 附加下载配置
|
||||
Options string `json:"options,omitempty"`
|
||||
// 下载监控间隔
|
||||
Interval int `json:"interval,omitempty"`
|
||||
// RPC API 请求超时
|
||||
Timeout int `json:"timeout,omitempty"`
|
||||
}
|
||||
|
||||
type NodeStatus int
|
||||
type ModelType int
|
||||
|
||||
const (
|
||||
NodeActive NodeStatus = iota
|
||||
NodeSuspend
|
||||
)
|
||||
|
||||
const (
|
||||
SlaveNodeType ModelType = iota
|
||||
MasterNodeType
|
||||
)
|
||||
|
||||
// GetNodeByID 用ID获取节点
|
||||
func GetNodeByID(ID interface{}) (Node, error) {
|
||||
var node Node
|
||||
result := DB.First(&node, ID)
|
||||
return node, result.Error
|
||||
}
|
||||
|
||||
// GetNodesByStatus 根据给定状态获取节点
|
||||
func GetNodesByStatus(status ...NodeStatus) ([]Node, error) {
|
||||
var nodes []Node
|
||||
result := DB.Where("status in (?)", status).Find(&nodes)
|
||||
return nodes, result.Error
|
||||
}
|
||||
|
||||
// AfterFind 找到节点后的钩子
|
||||
func (node *Node) AfterFind() (err error) {
|
||||
// 解析离线下载设置到 Aria2OptionsSerialized
|
||||
if node.Aria2Options != "" {
|
||||
err = json.Unmarshal([]byte(node.Aria2Options), &node.Aria2OptionsSerialized)
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// BeforeSave Save策略前的钩子
|
||||
func (node *Node) BeforeSave() (err error) {
|
||||
optionsValue, err := json.Marshal(&node.Aria2OptionsSerialized)
|
||||
node.Aria2Options = string(optionsValue)
|
||||
return err
|
||||
}
|
||||
|
||||
// SetStatus 设置节点启用状态
|
||||
func (node *Node) SetStatus(status NodeStatus) error {
|
||||
node.Status = status
|
||||
return DB.Model(node).Updates(map[string]interface{}{
|
||||
"status": status,
|
||||
}).Error
|
||||
}
|
59
models/order.go
Normal file
59
models/order.go
Normal file
@ -0,0 +1,59 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/util"
|
||||
"github.com/jinzhu/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
// PackOrderType 容量包订单
|
||||
PackOrderType = iota
|
||||
// GroupOrderType 用户组订单
|
||||
GroupOrderType
|
||||
// ScoreOrderType 积分充值订单
|
||||
ScoreOrderType
|
||||
)
|
||||
|
||||
const (
|
||||
// OrderUnpaid 未支付
|
||||
OrderUnpaid = iota
|
||||
// OrderPaid 已支付
|
||||
OrderPaid
|
||||
// OrderCanceled 已取消
|
||||
OrderCanceled
|
||||
)
|
||||
|
||||
// Order 交易订单
|
||||
type Order struct {
|
||||
gorm.Model
|
||||
UserID uint // 创建者ID
|
||||
OrderNo string `gorm:"index:order_number"` // 商户自定义订单编号
|
||||
Type int // 订单类型
|
||||
Method string // 支付类型
|
||||
ProductID int64 // 商品ID
|
||||
Num int // 商品数量
|
||||
Name string // 订单标题
|
||||
Price int // 商品单价
|
||||
Status int // 订单状态
|
||||
}
|
||||
|
||||
// Create 创建订单记录
|
||||
func (order *Order) Create() (uint, error) {
|
||||
if err := DB.Create(order).Error; err != nil {
|
||||
util.Log().Warning("Failed to insert order record: %s", err)
|
||||
return 0, err
|
||||
}
|
||||
return order.ID, nil
|
||||
}
|
||||
|
||||
// UpdateStatus 更新订单状态
|
||||
func (order *Order) UpdateStatus(status int) {
|
||||
DB.Model(order).Update("status", status)
|
||||
}
|
||||
|
||||
// GetOrderByNo 根据商户订单号查询订单
|
||||
func GetOrderByNo(id string) (*Order, error) {
|
||||
var order Order
|
||||
err := DB.Where("order_no = ?", id).First(&order).Error
|
||||
return &order, err
|
||||
}
|
267
models/policy.go
Normal file
267
models/policy.go
Normal file
@ -0,0 +1,267 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"encoding/gob"
|
||||
"encoding/json"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gofrs/uuid"
|
||||
"github.com/samber/lo"
|
||||
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/cache"
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/util"
|
||||
"github.com/jinzhu/gorm"
|
||||
)
|
||||
|
||||
// Policy 存储策略
|
||||
type Policy struct {
|
||||
// 表字段
|
||||
gorm.Model
|
||||
Name string
|
||||
Type string
|
||||
Server string
|
||||
BucketName string
|
||||
IsPrivate bool
|
||||
BaseURL string
|
||||
AccessKey string `gorm:"type:text"`
|
||||
SecretKey string `gorm:"type:text"`
|
||||
MaxSize uint64
|
||||
AutoRename bool
|
||||
DirNameRule string
|
||||
FileNameRule string
|
||||
IsOriginLinkEnable bool
|
||||
Options string `gorm:"type:text"`
|
||||
|
||||
// 数据库忽略字段
|
||||
OptionsSerialized PolicyOption `gorm:"-"`
|
||||
MasterID string `gorm:"-"`
|
||||
}
|
||||
|
||||
// PolicyOption 非公有的存储策略属性
|
||||
type PolicyOption struct {
|
||||
// Upyun访问Token
|
||||
Token string `json:"token"`
|
||||
// 允许的文件扩展名
|
||||
FileType []string `json:"file_type"`
|
||||
// MimeType
|
||||
MimeType string `json:"mimetype"`
|
||||
// OauthRedirect Oauth 重定向地址
|
||||
OauthRedirect string `json:"od_redirect,omitempty"`
|
||||
// OdProxy Onedrive 反代地址
|
||||
OdProxy string `json:"od_proxy,omitempty"`
|
||||
// OdDriver OneDrive 驱动器定位符
|
||||
OdDriver string `json:"od_driver,omitempty"`
|
||||
// Region 区域代码
|
||||
Region string `json:"region,omitempty"`
|
||||
// ServerSideEndpoint 服务端请求使用的 Endpoint,为空时使用 Policy.Server 字段
|
||||
ServerSideEndpoint string `json:"server_side_endpoint,omitempty"`
|
||||
// 分片上传的分片大小
|
||||
ChunkSize uint64 `json:"chunk_size,omitempty"`
|
||||
// 分片上传时是否需要预留空间
|
||||
PlaceholderWithSize bool `json:"placeholder_with_size,omitempty"`
|
||||
// 每秒对存储端的 API 请求上限
|
||||
TPSLimit float64 `json:"tps_limit,omitempty"`
|
||||
// 每秒 API 请求爆发上限
|
||||
TPSLimitBurst int `json:"tps_limit_burst,omitempty"`
|
||||
// Set this to `true` to force the request to use path-style addressing,
|
||||
// i.e., `http://s3.amazonaws.com/BUCKET/KEY `
|
||||
S3ForcePathStyle bool `json:"s3_path_style"`
|
||||
// File extensions that support thumbnail generation using native policy API.
|
||||
ThumbExts []string `json:"thumb_exts,omitempty"`
|
||||
}
|
||||
|
||||
// thumbSuffix 支持缩略图处理的文件扩展名
|
||||
var thumbSuffix = map[string][]string{
|
||||
"local": {},
|
||||
"qiniu": {".psd", ".jpg", ".jpeg", ".png", ".gif", ".webp", ".tiff", ".bmp"},
|
||||
"oss": {".jpg", ".jpeg", ".png", ".gif", ".webp", ".tiff", ".bmp"},
|
||||
"cos": {".jpg", ".jpeg", ".png", ".gif", ".webp", ".tiff", ".bmp"},
|
||||
"upyun": {".svg", ".jpg", ".jpeg", ".png", ".gif", ".webp", ".tiff", ".bmp"},
|
||||
"s3": {},
|
||||
"remote": {},
|
||||
"onedrive": {"*"},
|
||||
}
|
||||
|
||||
func init() {
|
||||
// 注册缓存用到的复杂结构
|
||||
gob.Register(Policy{})
|
||||
}
|
||||
|
||||
// GetPolicyByID 用ID获取存储策略
|
||||
func GetPolicyByID(ID interface{}) (Policy, error) {
|
||||
// 尝试读取缓存
|
||||
cacheKey := "policy_" + strconv.Itoa(int(ID.(uint)))
|
||||
if policy, ok := cache.Get(cacheKey); ok {
|
||||
return policy.(Policy), nil
|
||||
}
|
||||
|
||||
var policy Policy
|
||||
result := DB.First(&policy, ID)
|
||||
|
||||
// 写入缓存
|
||||
if result.Error == nil {
|
||||
_ = cache.Set(cacheKey, policy, -1)
|
||||
}
|
||||
|
||||
return policy, result.Error
|
||||
}
|
||||
|
||||
// AfterFind 找到存储策略后的钩子
|
||||
func (policy *Policy) AfterFind() (err error) {
|
||||
// 解析存储策略设置到OptionsSerialized
|
||||
if policy.Options != "" {
|
||||
err = json.Unmarshal([]byte(policy.Options), &policy.OptionsSerialized)
|
||||
}
|
||||
if policy.OptionsSerialized.FileType == nil {
|
||||
policy.OptionsSerialized.FileType = []string{}
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// BeforeSave Save策略前的钩子
|
||||
func (policy *Policy) BeforeSave() (err error) {
|
||||
err = policy.SerializeOptions()
|
||||
return err
|
||||
}
|
||||
|
||||
// SerializeOptions 将序列后的Option写入到数据库字段
|
||||
func (policy *Policy) SerializeOptions() (err error) {
|
||||
optionsValue, err := json.Marshal(&policy.OptionsSerialized)
|
||||
policy.Options = string(optionsValue)
|
||||
return err
|
||||
}
|
||||
|
||||
// GeneratePath 生成存储文件的路径
|
||||
func (policy *Policy) GeneratePath(uid uint, origin string) string {
|
||||
dirRule := policy.DirNameRule
|
||||
replaceTable := map[string]string{
|
||||
"{randomkey16}": util.RandStringRunes(16),
|
||||
"{randomkey8}": util.RandStringRunes(8),
|
||||
"{timestamp}": strconv.FormatInt(time.Now().Unix(), 10),
|
||||
"{timestamp_nano}": strconv.FormatInt(time.Now().UnixNano(), 10),
|
||||
"{uid}": strconv.Itoa(int(uid)),
|
||||
"{datetime}": time.Now().Format("20060102150405"),
|
||||
"{date}": time.Now().Format("20060102"),
|
||||
"{year}": time.Now().Format("2006"),
|
||||
"{month}": time.Now().Format("01"),
|
||||
"{day}": time.Now().Format("02"),
|
||||
"{hour}": time.Now().Format("15"),
|
||||
"{minute}": time.Now().Format("04"),
|
||||
"{second}": time.Now().Format("05"),
|
||||
"{path}": origin + "/",
|
||||
}
|
||||
dirRule = util.Replace(replaceTable, dirRule)
|
||||
return path.Clean(dirRule)
|
||||
}
|
||||
|
||||
// GenerateFileName 生成存储文件名
|
||||
func (policy *Policy) GenerateFileName(uid uint, origin string) string {
|
||||
// 未开启自动重命名时,直接返回原始文件名
|
||||
if !policy.AutoRename {
|
||||
return origin
|
||||
}
|
||||
|
||||
fileRule := policy.FileNameRule
|
||||
|
||||
replaceTable := map[string]string{
|
||||
"{randomkey16}": util.RandStringRunes(16),
|
||||
"{randomkey8}": util.RandStringRunes(8),
|
||||
"{timestamp}": strconv.FormatInt(time.Now().Unix(), 10),
|
||||
"{timestamp_nano}": strconv.FormatInt(time.Now().UnixNano(), 10),
|
||||
"{uid}": strconv.Itoa(int(uid)),
|
||||
"{datetime}": time.Now().Format("20060102150405"),
|
||||
"{date}": time.Now().Format("20060102"),
|
||||
"{year}": time.Now().Format("2006"),
|
||||
"{month}": time.Now().Format("01"),
|
||||
"{day}": time.Now().Format("02"),
|
||||
"{hour}": time.Now().Format("15"),
|
||||
"{minute}": time.Now().Format("04"),
|
||||
"{second}": time.Now().Format("05"),
|
||||
"{originname}": origin,
|
||||
"{ext}": filepath.Ext(origin),
|
||||
"{originname_without_ext}": strings.TrimSuffix(origin, filepath.Ext(origin)),
|
||||
"{uuid}": uuid.Must(uuid.NewV4()).String(),
|
||||
}
|
||||
|
||||
fileRule = util.Replace(replaceTable, fileRule)
|
||||
return fileRule
|
||||
}
|
||||
|
||||
// IsThumbExist 给定文件名,返回此存储策略下是否可能存在缩略图
|
||||
func (policy *Policy) IsThumbExist(name string) bool {
|
||||
if list, ok := thumbSuffix[policy.Type]; ok {
|
||||
if len(list) == 1 && list[0] == "*" {
|
||||
return true
|
||||
}
|
||||
return util.ContainsString(list, strings.ToLower(filepath.Ext(name)))
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IsDirectlyPreview 返回此策略下文件是否可以直接预览(不需要重定向)
|
||||
func (policy *Policy) IsDirectlyPreview() bool {
|
||||
return policy.Type == "local"
|
||||
}
|
||||
|
||||
// IsTransitUpload 返回此策略上传给定size文件时是否需要服务端中转
|
||||
func (policy *Policy) IsTransitUpload(size uint64) bool {
|
||||
return policy.Type == "local"
|
||||
}
|
||||
|
||||
// IsThumbGenerateNeeded 返回此策略是否需要在上传后生成缩略图
|
||||
func (policy *Policy) IsThumbGenerateNeeded() bool {
|
||||
return policy.Type == "local"
|
||||
}
|
||||
|
||||
// IsUploadPlaceholderWithSize 返回此策略创建上传会话时是否需要预留空间
|
||||
func (policy *Policy) IsUploadPlaceholderWithSize() bool {
|
||||
if policy.Type == "remote" {
|
||||
return true
|
||||
}
|
||||
|
||||
if util.ContainsString([]string{"onedrive", "oss", "qiniu", "cos", "s3"}, policy.Type) {
|
||||
return policy.OptionsSerialized.PlaceholderWithSize
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// CanStructureBeListed 返回存储策略是否能被前台列物理目录
|
||||
func (policy *Policy) CanStructureBeListed() bool {
|
||||
return policy.Type != "local" && policy.Type != "remote"
|
||||
}
|
||||
|
||||
// SaveAndClearCache 更新并清理缓存
|
||||
func (policy *Policy) SaveAndClearCache() error {
|
||||
err := DB.Save(policy).Error
|
||||
policy.ClearCache()
|
||||
return err
|
||||
}
|
||||
|
||||
// SaveAndClearCache 更新并清理缓存
|
||||
func (policy *Policy) UpdateAccessKeyAndClearCache(s string) error {
|
||||
err := DB.Model(policy).UpdateColumn("access_key", s).Error
|
||||
policy.ClearCache()
|
||||
return err
|
||||
}
|
||||
|
||||
// ClearCache 清空policy缓存
|
||||
func (policy *Policy) ClearCache() {
|
||||
cache.Deletes([]string{strconv.FormatUint(uint64(policy.ID), 10)}, "policy_")
|
||||
}
|
||||
|
||||
// CouldProxyThumb return if proxy thumbs is allowed for this policy.
|
||||
func (policy *Policy) CouldProxyThumb() bool {
|
||||
if policy.Type == "local" || !IsTrueVal(GetSettingByName("thumb_proxy_enabled")) {
|
||||
return false
|
||||
}
|
||||
|
||||
allowed := make([]uint, 0)
|
||||
_ = json.Unmarshal([]byte(GetSettingByName("thumb_proxy_policy")), &allowed)
|
||||
return lo.Contains[uint](allowed, policy.ID)
|
||||
}
|
27
models/redeem.go
Normal file
27
models/redeem.go
Normal file
@ -0,0 +1,27 @@
|
||||
package model
|
||||
|
||||
import "github.com/jinzhu/gorm"
|
||||
|
||||
// Redeem 兑换码
|
||||
type Redeem struct {
|
||||
gorm.Model
|
||||
Type int // 订单类型
|
||||
ProductID int64 // 商品ID
|
||||
Num int // 商品数量
|
||||
Code string `gorm:"size:64,index:redeem_code"` // 兑换码
|
||||
Used bool // 是否已被使用
|
||||
}
|
||||
|
||||
// GetAvailableRedeem 根据code查找可用兑换码
|
||||
func GetAvailableRedeem(code string) (*Redeem, error) {
|
||||
redeem := &Redeem{}
|
||||
result := DB.Where("code = ? and used = ?", code, false).First(redeem)
|
||||
return redeem, result.Error
|
||||
}
|
||||
|
||||
// Use 设定为已使用状态
|
||||
func (redeem *Redeem) Use() {
|
||||
DB.Model(redeem).Updates(map[string]interface{}{
|
||||
"used": true,
|
||||
})
|
||||
}
|
21
models/report.go
Normal file
21
models/report.go
Normal file
@ -0,0 +1,21 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"github.com/jinzhu/gorm"
|
||||
)
|
||||
|
||||
// Report 举报模型
|
||||
type Report struct {
|
||||
gorm.Model
|
||||
ShareID uint `gorm:"index:share_id"` // 对应分享ID
|
||||
Reason int // 举报原因
|
||||
Description string // 补充描述
|
||||
|
||||
// 关联模型
|
||||
Share Share `gorm:"save_associations:false:false"`
|
||||
}
|
||||
|
||||
// Create 创建举报
|
||||
func (report *Report) Create() error {
|
||||
return DB.Create(report).Error
|
||||
}
|
10
models/scripts/init.go
Normal file
10
models/scripts/init.go
Normal file
@ -0,0 +1,10 @@
|
||||
package scripts
|
||||
|
||||
import "github.com/cloudreve/Cloudreve/v3/models/scripts/invoker"
|
||||
|
||||
func Init() {
|
||||
invoker.Register("ResetAdminPassword", ResetAdminPassword(0))
|
||||
invoker.Register("CalibrateUserStorage", UserStorageCalibration(0))
|
||||
invoker.Register("OSSToPlus", UpgradeToPro(0))
|
||||
invoker.Register("UpgradeTo3.4.0", UpgradeTo340(0))
|
||||
}
|
38
models/scripts/invoker/invoker.go
Normal file
38
models/scripts/invoker/invoker.go
Normal file
@ -0,0 +1,38 @@
|
||||
package invoker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/util"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type DBScript interface {
|
||||
Run(ctx context.Context)
|
||||
}
|
||||
|
||||
var availableScripts = make(map[string]DBScript)
|
||||
|
||||
func RunDBScript(name string, ctx context.Context) error {
|
||||
if script, ok := availableScripts[name]; ok {
|
||||
util.Log().Info("Start executing database script %q.", name)
|
||||
script.Run(ctx)
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("Database script %q not exist.", name)
|
||||
}
|
||||
|
||||
func Register(name string, script DBScript) {
|
||||
availableScripts[name] = script
|
||||
}
|
||||
|
||||
func ListPrefix(prefix string) []string {
|
||||
var scripts []string
|
||||
for name := range availableScripts {
|
||||
if strings.HasPrefix(name, prefix) {
|
||||
scripts = append(scripts, name)
|
||||
}
|
||||
}
|
||||
return scripts
|
||||
}
|
31
models/scripts/reset.go
Normal file
31
models/scripts/reset.go
Normal file
@ -0,0 +1,31 @@
|
||||
package scripts
|
||||
|
||||
import (
|
||||
"context"
|
||||
model "github.com/cloudreve/Cloudreve/v3/models"
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/util"
|
||||
"github.com/fatih/color"
|
||||
)
|
||||
|
||||
type ResetAdminPassword int
|
||||
|
||||
// Run 运行脚本从社区版升级至 Pro 版
|
||||
func (script ResetAdminPassword) Run(ctx context.Context) {
|
||||
// 查找用户
|
||||
user, err := model.GetUserByID(1)
|
||||
if err != nil {
|
||||
util.Log().Panic("Initial admin user not exist: %s", err)
|
||||
}
|
||||
|
||||
// 生成密码
|
||||
password := util.RandStringRunes(8)
|
||||
|
||||
// 更改为新密码
|
||||
user.SetPassword(password)
|
||||
if err := user.Update(map[string]interface{}{"password": user.Password}); err != nil {
|
||||
util.Log().Panic("Failed to update password: %s", err)
|
||||
}
|
||||
|
||||
c := color.New(color.FgWhite).Add(color.BgBlack).Add(color.Bold)
|
||||
util.Log().Info("Initial admin user password changed to:" + c.Sprint(password))
|
||||
}
|
33
models/scripts/storage.go
Normal file
33
models/scripts/storage.go
Normal file
@ -0,0 +1,33 @@
|
||||
package scripts
|
||||
|
||||
import (
|
||||
"context"
|
||||
model "github.com/cloudreve/Cloudreve/v3/models"
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/util"
|
||||
)
|
||||
|
||||
type UserStorageCalibration int
|
||||
|
||||
type storageResult struct {
|
||||
Total uint64
|
||||
}
|
||||
|
||||
// Run 运行脚本校准所有用户容量
|
||||
func (script UserStorageCalibration) Run(ctx context.Context) {
|
||||
// 列出所有用户
|
||||
var res []model.User
|
||||
model.DB.Model(&model.User{}).Find(&res)
|
||||
|
||||
// 逐个检查容量
|
||||
for _, user := range res {
|
||||
// 计算正确的容量
|
||||
var total storageResult
|
||||
model.DB.Model(&model.File{}).Where("user_id = ?", user.ID).Select("sum(size) as total").Scan(&total)
|
||||
// 更新用户的容量
|
||||
if user.Storage != total.Total {
|
||||
util.Log().Info("Calibrate used storage for user %q, from %d to %d.", user.Email,
|
||||
user.Storage, total.Total)
|
||||
}
|
||||
model.DB.Model(&user).Update("storage", total.Total)
|
||||
}
|
||||
}
|
22
models/scripts/upgrade-pro.go
Normal file
22
models/scripts/upgrade-pro.go
Normal file
@ -0,0 +1,22 @@
|
||||
package scripts
|
||||
|
||||
import (
|
||||
"context"
|
||||
model "github.com/cloudreve/Cloudreve/v3/models"
|
||||
)
|
||||
|
||||
type UpgradeToPro int
|
||||
|
||||
// Run 运行脚本从社区版升级至 Pro 版
|
||||
func (script UpgradeToPro) Run(ctx context.Context) {
|
||||
// folder.PolicyID 字段设为 0
|
||||
model.DB.Model(model.Folder{}).UpdateColumn("policy_id", 0)
|
||||
// shares.Score 字段设为0
|
||||
model.DB.Model(model.Share{}).UpdateColumn("score", 0)
|
||||
// user 表相关初始字段
|
||||
model.DB.Model(model.User{}).Updates(map[string]interface{}{
|
||||
"score": 0,
|
||||
"previous_group_id": 0,
|
||||
"open_id": "",
|
||||
})
|
||||
}
|
43
models/scripts/upgrade.go
Normal file
43
models/scripts/upgrade.go
Normal file
@ -0,0 +1,43 @@
|
||||
package scripts
|
||||
|
||||
import (
|
||||
"context"
|
||||
model "github.com/cloudreve/Cloudreve/v3/models"
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/util"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
type UpgradeTo340 int
|
||||
|
||||
// Run upgrade from older version to 3.4.0
|
||||
func (script UpgradeTo340) Run(ctx context.Context) {
|
||||
// 取回老版本 aria2 设定
|
||||
old := model.GetSettingByType([]string{"aria2"})
|
||||
if len(old) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// 写入到新版本的节点设定
|
||||
n, err := model.GetNodeByID(1)
|
||||
if err != nil {
|
||||
util.Log().Error("找不到主机节点, %s", err)
|
||||
}
|
||||
|
||||
n.Aria2Enabled = old["aria2_rpcurl"] != ""
|
||||
n.Aria2OptionsSerialized.Options = old["aria2_options"]
|
||||
n.Aria2OptionsSerialized.Server = old["aria2_rpcurl"]
|
||||
|
||||
interval, err := strconv.Atoi(old["aria2_interval"])
|
||||
if err != nil {
|
||||
interval = 10
|
||||
}
|
||||
n.Aria2OptionsSerialized.Interval = interval
|
||||
n.Aria2OptionsSerialized.TempPath = old["aria2_temp_path"]
|
||||
n.Aria2OptionsSerialized.Token = old["aria2_token"]
|
||||
if err := model.DB.Save(&n).Error; err != nil {
|
||||
util.Log().Error("无法保存主机节点 Aria2 配置信息, %s", err)
|
||||
} else {
|
||||
model.DB.Where("type = ?", "aria2").Delete(model.Setting{})
|
||||
util.Log().Info("Aria2 配置信息已成功迁移至 3.4.0+ 版本的模式")
|
||||
}
|
||||
}
|
110
models/setting.go
Normal file
110
models/setting.go
Normal file
@ -0,0 +1,110 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strconv"
|
||||
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/cache"
|
||||
"github.com/jinzhu/gorm"
|
||||
)
|
||||
|
||||
// Setting 系统设置模型
|
||||
type Setting struct {
|
||||
gorm.Model
|
||||
Type string `gorm:"not null"`
|
||||
Name string `gorm:"unique;not null;index:setting_key"`
|
||||
Value string `gorm:"size:65535"`
|
||||
}
|
||||
|
||||
// IsTrueVal 返回设置的值是否为真
|
||||
func IsTrueVal(val string) bool {
|
||||
return val == "1" || val == "true"
|
||||
}
|
||||
|
||||
// GetSettingByName 用 Name 获取设置值
|
||||
func GetSettingByName(name string) string {
|
||||
return GetSettingByNameFromTx(DB, name)
|
||||
}
|
||||
|
||||
// GetSettingByNameFromTx 用 Name 获取设置值,使用事务
|
||||
func GetSettingByNameFromTx(tx *gorm.DB, name string) string {
|
||||
var setting Setting
|
||||
|
||||
// 优先从缓存中查找
|
||||
cacheKey := "setting_" + name
|
||||
if optionValue, ok := cache.Get(cacheKey); ok {
|
||||
return optionValue.(string)
|
||||
}
|
||||
|
||||
// 尝试数据库中查找
|
||||
if tx == nil {
|
||||
tx = DB
|
||||
if tx == nil {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
result := tx.Where("name = ?", name).First(&setting)
|
||||
if result.Error == nil {
|
||||
_ = cache.Set(cacheKey, setting.Value, -1)
|
||||
return setting.Value
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetSettingByNameWithDefault 用 Name 获取设置值, 取不到时使用缺省值
|
||||
func GetSettingByNameWithDefault(name, fallback string) string {
|
||||
res := GetSettingByName(name)
|
||||
if res == "" {
|
||||
return fallback
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// GetSettingByNames 用多个 Name 获取设置值
|
||||
func GetSettingByNames(names ...string) map[string]string {
|
||||
var queryRes []Setting
|
||||
res, miss := cache.GetSettings(names, "setting_")
|
||||
|
||||
if len(miss) > 0 {
|
||||
DB.Where("name IN (?)", miss).Find(&queryRes)
|
||||
for _, setting := range queryRes {
|
||||
res[setting.Name] = setting.Value
|
||||
}
|
||||
}
|
||||
|
||||
_ = cache.SetSettings(res, "setting_")
|
||||
return res
|
||||
}
|
||||
|
||||
// GetSettingByType 获取一个或多个分组的所有设置值
|
||||
func GetSettingByType(types []string) map[string]string {
|
||||
var queryRes []Setting
|
||||
res := make(map[string]string)
|
||||
|
||||
DB.Where("type IN (?)", types).Find(&queryRes)
|
||||
for _, setting := range queryRes {
|
||||
res[setting.Name] = setting.Value
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
// GetSiteURL 获取站点地址
|
||||
func GetSiteURL() *url.URL {
|
||||
base, err := url.Parse(GetSettingByName("siteURL"))
|
||||
if err != nil {
|
||||
base, _ = url.Parse("https://cloudreve.org")
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
// GetIntSetting 获取整形设置值,如果转换失败则返回默认值defaultVal
|
||||
func GetIntSetting(key string, defaultVal int) int {
|
||||
res, err := strconv.Atoi(GetSettingByName(key))
|
||||
if err != nil {
|
||||
return defaultVal
|
||||
}
|
||||
return res
|
||||
}
|
280
models/share.go
Normal file
280
models/share.go
Normal file
@ -0,0 +1,280 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/cache"
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/hashid"
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/util"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/jinzhu/gorm"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInsufficientCredit = errors.New("积分不足")
|
||||
)
|
||||
|
||||
// Share 分享模型
|
||||
type Share struct {
|
||||
gorm.Model
|
||||
Password string // 分享密码,空值为非加密分享
|
||||
IsDir bool // 原始资源是否为目录
|
||||
UserID uint // 创建用户ID
|
||||
SourceID uint // 原始资源ID
|
||||
Views int // 浏览数
|
||||
Downloads int // 下载数
|
||||
RemainDownloads int // 剩余下载配额,负值标识无限制
|
||||
Expires *time.Time // 过期时间,空值表示无过期时间
|
||||
Score int // 每人次下载扣除积分
|
||||
PreviewEnabled bool // 是否允许直接预览
|
||||
SourceName string `gorm:"index:source"` // 用于搜索的字段
|
||||
|
||||
// 数据库忽略字段
|
||||
User User `gorm:"PRELOAD:false,association_autoupdate:false"`
|
||||
File File `gorm:"PRELOAD:false,association_autoupdate:false"`
|
||||
Folder Folder `gorm:"PRELOAD:false,association_autoupdate:false"`
|
||||
}
|
||||
|
||||
// Create 创建分享
|
||||
func (share *Share) Create() (uint, error) {
|
||||
if err := DB.Create(share).Error; err != nil {
|
||||
util.Log().Warning("Failed to insert share record: %s", err)
|
||||
return 0, err
|
||||
}
|
||||
return share.ID, nil
|
||||
}
|
||||
|
||||
// GetShareByHashID 根据HashID查找分享
|
||||
func GetShareByHashID(hashID string) *Share {
|
||||
id, err := hashid.DecodeHashID(hashID, hashid.ShareID)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var share Share
|
||||
result := DB.First(&share, id)
|
||||
if result.Error != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &share
|
||||
}
|
||||
|
||||
// IsAvailable 返回此分享是否可用(是否过期)
|
||||
func (share *Share) IsAvailable() bool {
|
||||
if share.RemainDownloads == 0 {
|
||||
return false
|
||||
}
|
||||
if share.Expires != nil && time.Now().After(*share.Expires) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 检查创建者状态
|
||||
if share.Creator().Status != Active {
|
||||
return false
|
||||
}
|
||||
|
||||
// 检查源对象是否存在
|
||||
var sourceID uint
|
||||
if share.IsDir {
|
||||
folder := share.SourceFolder()
|
||||
sourceID = folder.ID
|
||||
} else {
|
||||
file := share.SourceFile()
|
||||
sourceID = file.ID
|
||||
}
|
||||
if sourceID == 0 {
|
||||
// TODO 是否要在这里删除这个无效分享?
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Creator 获取分享的创建者
|
||||
func (share *Share) Creator() *User {
|
||||
if share.User.ID == 0 {
|
||||
share.User, _ = GetUserByID(share.UserID)
|
||||
}
|
||||
return &share.User
|
||||
}
|
||||
|
||||
// Source 返回源对象
|
||||
func (share *Share) Source() interface{} {
|
||||
if share.IsDir {
|
||||
return share.SourceFolder()
|
||||
}
|
||||
return share.SourceFile()
|
||||
}
|
||||
|
||||
// SourceFolder 获取源目录
|
||||
func (share *Share) SourceFolder() *Folder {
|
||||
if share.Folder.ID == 0 {
|
||||
folders, _ := GetFoldersByIDs([]uint{share.SourceID}, share.UserID)
|
||||
if len(folders) > 0 {
|
||||
share.Folder = folders[0]
|
||||
}
|
||||
}
|
||||
return &share.Folder
|
||||
}
|
||||
|
||||
// SourceFile 获取源文件
|
||||
func (share *Share) SourceFile() *File {
|
||||
if share.File.ID == 0 {
|
||||
files, _ := GetFilesByIDs([]uint{share.SourceID}, share.UserID)
|
||||
if len(files) > 0 {
|
||||
share.File = files[0]
|
||||
}
|
||||
}
|
||||
return &share.File
|
||||
}
|
||||
|
||||
// CanBeDownloadBy 返回此分享是否可以被给定用户下载
|
||||
func (share *Share) CanBeDownloadBy(user *User) error {
|
||||
// 用户组权限
|
||||
if !user.Group.OptionsSerialized.ShareDownload {
|
||||
if user.IsAnonymous() {
|
||||
return errors.New("you must login to download")
|
||||
}
|
||||
return errors.New("your group has no permission to download")
|
||||
}
|
||||
|
||||
// 需要积分但未登录
|
||||
if share.Score > 0 && user.IsAnonymous() {
|
||||
return errors.New("you must login to download")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// WasDownloadedBy 返回分享是否已被用户下载过
|
||||
func (share *Share) WasDownloadedBy(user *User, c *gin.Context) (exist bool) {
|
||||
if user.IsAnonymous() {
|
||||
exist = util.GetSession(c, fmt.Sprintf("share_%d_%d", share.ID, user.ID)) != nil
|
||||
} else {
|
||||
_, exist = cache.Get(fmt.Sprintf("share_%d_%d", share.ID, user.ID))
|
||||
}
|
||||
|
||||
return exist
|
||||
}
|
||||
|
||||
// DownloadBy 增加下载次数、检查积分等,匿名用户不会缓存
|
||||
func (share *Share) DownloadBy(user *User, c *gin.Context) error {
|
||||
if !share.WasDownloadedBy(user, c) {
|
||||
if err := share.Purchase(user); err != nil {
|
||||
return err
|
||||
}
|
||||
share.Downloaded()
|
||||
if !user.IsAnonymous() {
|
||||
cache.Set(fmt.Sprintf("share_%d_%d", share.ID, user.ID), true,
|
||||
GetIntSetting("share_download_session_timeout", 2073600))
|
||||
} else {
|
||||
util.SetSession(c, map[string]interface{}{fmt.Sprintf("share_%d_%d", share.ID, user.ID): true})
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Purchase 使用积分购买分享
|
||||
func (share *Share) Purchase(user *User) error {
|
||||
// 不需要付积分
|
||||
if share.Score == 0 || user.Group.OptionsSerialized.ShareFree || user.ID == share.UserID {
|
||||
return nil
|
||||
}
|
||||
|
||||
ok := user.PayScore(share.Score)
|
||||
if !ok {
|
||||
return ErrInsufficientCredit
|
||||
}
|
||||
|
||||
scoreRate := GetIntSetting("share_score_rate", 100)
|
||||
gainedScore := int(math.Ceil(float64(share.Score*scoreRate) / 100))
|
||||
share.Creator().AddScore(gainedScore)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Viewed 增加访问次数
|
||||
func (share *Share) Viewed() {
|
||||
share.Views++
|
||||
DB.Model(share).UpdateColumn("views", gorm.Expr("views + ?", 1))
|
||||
}
|
||||
|
||||
// Downloaded 增加下载次数
|
||||
func (share *Share) Downloaded() {
|
||||
share.Downloads++
|
||||
if share.RemainDownloads > 0 {
|
||||
share.RemainDownloads--
|
||||
}
|
||||
DB.Model(share).Updates(map[string]interface{}{
|
||||
"downloads": share.Downloads,
|
||||
"remain_downloads": share.RemainDownloads,
|
||||
})
|
||||
}
|
||||
|
||||
// Update 更新分享属性
|
||||
func (share *Share) Update(props map[string]interface{}) error {
|
||||
return DB.Model(share).Updates(props).Error
|
||||
}
|
||||
|
||||
// Delete 删除分享
|
||||
func (share *Share) Delete() error {
|
||||
return DB.Model(share).Delete(share).Error
|
||||
}
|
||||
|
||||
// DeleteShareBySourceIDs 根据原始资源类型和ID删除文件
|
||||
func DeleteShareBySourceIDs(sources []uint, isDir bool) error {
|
||||
return DB.Where("source_id in (?) and is_dir = ?", sources, isDir).Delete(&Share{}).Error
|
||||
}
|
||||
|
||||
// ListShares 列出UID下的分享
|
||||
func ListShares(uid uint, page, pageSize int, order string, publicOnly bool) ([]Share, int) {
|
||||
var (
|
||||
shares []Share
|
||||
total int
|
||||
)
|
||||
dbChain := DB
|
||||
dbChain = dbChain.Where("user_id = ?", uid)
|
||||
if publicOnly {
|
||||
dbChain = dbChain.Where("password = ?", "")
|
||||
}
|
||||
|
||||
// 计算总数用于分页
|
||||
dbChain.Model(&Share{}).Count(&total)
|
||||
|
||||
// 查询记录
|
||||
dbChain.Limit(pageSize).Offset((page - 1) * pageSize).Order(order).Find(&shares)
|
||||
return shares, total
|
||||
}
|
||||
|
||||
// SearchShares 根据关键字搜索分享
|
||||
func SearchShares(page, pageSize int, order, keywords string) ([]Share, int) {
|
||||
var (
|
||||
shares []Share
|
||||
total int
|
||||
)
|
||||
|
||||
keywordList := strings.Split(keywords, " ")
|
||||
availableList := make([]string, 0, len(keywordList))
|
||||
for i := 0; i < len(keywordList); i++ {
|
||||
if len(keywordList[i]) > 0 {
|
||||
availableList = append(availableList, keywordList[i])
|
||||
}
|
||||
}
|
||||
if len(availableList) == 0 {
|
||||
return shares, 0
|
||||
}
|
||||
|
||||
dbChain := DB
|
||||
dbChain = dbChain.Where("password = ? and remain_downloads <> 0 and (expires is NULL or expires > ?) and source_name like ?", "", time.Now(), "%"+strings.Join(availableList, "%")+"%")
|
||||
|
||||
// 计算总数用于分页
|
||||
dbChain.Model(&Share{}).Count(&total)
|
||||
|
||||
// 查询记录
|
||||
dbChain.Limit(pageSize).Offset((page - 1) * pageSize).Order(order).Find(&shares)
|
||||
return shares, total
|
||||
}
|
47
models/source_link.go
Normal file
47
models/source_link.go
Normal file
@ -0,0 +1,47 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/hashid"
|
||||
"github.com/jinzhu/gorm"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
// SourceLink represent a shared file source link
|
||||
type SourceLink struct {
|
||||
gorm.Model
|
||||
FileID uint // corresponding file ID
|
||||
Name string // name of the file while creating the source link, for annotation
|
||||
Downloads int // 下载数
|
||||
|
||||
// 关联模型
|
||||
File File `gorm:"save_associations:false:false"`
|
||||
}
|
||||
|
||||
// Link gets the URL of a SourceLink
|
||||
func (s *SourceLink) Link() (string, error) {
|
||||
baseURL := GetSiteURL()
|
||||
linkPath, err := url.Parse(fmt.Sprintf("/f/%s/%s", hashid.HashID(s.ID, hashid.SourceLinkID), s.File.Name))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return baseURL.ResolveReference(linkPath).String(), nil
|
||||
}
|
||||
|
||||
// GetTasksByID queries source link based on ID
|
||||
func GetSourceLinkByID(id interface{}) (*SourceLink, error) {
|
||||
link := &SourceLink{}
|
||||
result := DB.Where("id = ?", id).First(link)
|
||||
files, _ := GetFilesByIDs([]uint{link.FileID}, 0)
|
||||
if len(files) > 0 {
|
||||
link.File = files[0]
|
||||
}
|
||||
|
||||
return link, result.Error
|
||||
}
|
||||
|
||||
// Viewed 增加访问次数
|
||||
func (s *SourceLink) Downloaded() {
|
||||
s.Downloads++
|
||||
DB.Model(s).UpdateColumn("downloads", gorm.Expr("downloads + ?", 1))
|
||||
}
|
91
models/storage_pack.go
Normal file
91
models/storage_pack.go
Normal file
@ -0,0 +1,91 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/cache"
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/util"
|
||||
"github.com/jinzhu/gorm"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// StoragePack 容量包模型
|
||||
type StoragePack struct {
|
||||
// 表字段
|
||||
gorm.Model
|
||||
Name string
|
||||
UserID uint
|
||||
ActiveTime *time.Time
|
||||
ExpiredTime *time.Time `gorm:"index:expired"`
|
||||
Size uint64
|
||||
}
|
||||
|
||||
// Create 创建容量包
|
||||
func (pack *StoragePack) Create() (uint, error) {
|
||||
if err := DB.Create(pack).Error; err != nil {
|
||||
util.Log().Warning("Failed to insert storage pack record: %s", err)
|
||||
return 0, err
|
||||
}
|
||||
return pack.ID, nil
|
||||
}
|
||||
|
||||
// GetAvailablePackSize 返回给定用户当前可用的容量包总容量
|
||||
func (user *User) GetAvailablePackSize() uint64 {
|
||||
var (
|
||||
total uint64
|
||||
firstExpire *time.Time
|
||||
timeNow = time.Now()
|
||||
ttl int64
|
||||
)
|
||||
|
||||
// 尝试从缓存中读取
|
||||
cacheKey := "pack_size_" + strconv.FormatUint(uint64(user.ID), 10)
|
||||
if total, ok := cache.Get(cacheKey); ok {
|
||||
return total.(uint64)
|
||||
}
|
||||
|
||||
// 查找所有有效容量包
|
||||
packs := user.GetAvailableStoragePacks()
|
||||
|
||||
// 计算总容量, 并找到其中最早的过期时间
|
||||
for _, v := range packs {
|
||||
total += v.Size
|
||||
if firstExpire == nil {
|
||||
firstExpire = v.ExpiredTime
|
||||
continue
|
||||
}
|
||||
if v.ExpiredTime != nil && firstExpire.After(*v.ExpiredTime) {
|
||||
firstExpire = v.ExpiredTime
|
||||
}
|
||||
}
|
||||
|
||||
// 用最早的过期时间计算缓存TTL,并写入缓存
|
||||
if firstExpire != nil {
|
||||
ttl = firstExpire.Unix() - timeNow.Unix()
|
||||
if ttl > 0 {
|
||||
_ = cache.Set(cacheKey, total, int(ttl))
|
||||
}
|
||||
}
|
||||
|
||||
return total
|
||||
}
|
||||
|
||||
// GetAvailableStoragePacks 返回用户可用的容量包
|
||||
func (user *User) GetAvailableStoragePacks() []StoragePack {
|
||||
var packs []StoragePack
|
||||
timeNow := time.Now()
|
||||
// 查找所有有效容量包
|
||||
DB.Where("expired_time > ? AND user_id = ?", timeNow, user.ID).Find(&packs)
|
||||
return packs
|
||||
}
|
||||
|
||||
// GetExpiredStoragePack 获取已过期的容量包
|
||||
func GetExpiredStoragePack() []StoragePack {
|
||||
var packs []StoragePack
|
||||
DB.Where("expired_time < ?", time.Now()).Find(&packs)
|
||||
return packs
|
||||
}
|
||||
|
||||
// Delete 删除容量包
|
||||
func (pack *StoragePack) Delete() error {
|
||||
return DB.Delete(&pack).Error
|
||||
}
|
53
models/tag.go
Normal file
53
models/tag.go
Normal file
@ -0,0 +1,53 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/util"
|
||||
"github.com/jinzhu/gorm"
|
||||
)
|
||||
|
||||
// Tag 用户自定义标签
|
||||
type Tag struct {
|
||||
gorm.Model
|
||||
Name string // 标签名
|
||||
Icon string // 图标标识
|
||||
Color string // 图标颜色
|
||||
Type int // 标签类型(文件分类/目录直达)
|
||||
Expression string `gorm:"type:text"` // 搜索表表达式/直达路径
|
||||
UserID uint // 创建者ID
|
||||
}
|
||||
|
||||
const (
|
||||
// FileTagType 文件分类标签
|
||||
FileTagType = iota
|
||||
// DirectoryLinkType 目录快捷方式标签
|
||||
DirectoryLinkType
|
||||
)
|
||||
|
||||
// Create 创建标签记录
|
||||
func (tag *Tag) Create() (uint, error) {
|
||||
if err := DB.Create(tag).Error; err != nil {
|
||||
util.Log().Warning("Failed to insert tag record: %s", err)
|
||||
return 0, err
|
||||
}
|
||||
return tag.ID, nil
|
||||
}
|
||||
|
||||
// DeleteTagByID 根据给定ID和用户ID删除标签
|
||||
func DeleteTagByID(id, uid uint) error {
|
||||
result := DB.Where("id = ? and user_id = ?", id, uid).Delete(&Tag{})
|
||||
return result.Error
|
||||
}
|
||||
|
||||
// GetTagsByUID 根据用户ID查找标签
|
||||
func GetTagsByUID(uid uint) ([]Tag, error) {
|
||||
var tag []Tag
|
||||
result := DB.Where("user_id = ?", uid).Find(&tag)
|
||||
return tag, result.Error
|
||||
}
|
||||
|
||||
// GetTagsByID 根据ID查找标签
|
||||
func GetTagsByID(id, uid uint) (*Tag, error) {
|
||||
var tag Tag
|
||||
result := DB.Where("user_id = ? and id = ?", uid, id).First(&tag)
|
||||
return &tag, result.Error
|
||||
}
|
73
models/task.go
Normal file
73
models/task.go
Normal file
@ -0,0 +1,73 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/util"
|
||||
"github.com/jinzhu/gorm"
|
||||
)
|
||||
|
||||
// Task 任务模型
|
||||
type Task struct {
|
||||
gorm.Model
|
||||
Status int // 任务状态
|
||||
Type int // 任务类型
|
||||
UserID uint // 发起者UID,0表示为系统发起
|
||||
Progress int // 进度
|
||||
Error string `gorm:"type:text"` // 错误信息
|
||||
Props string `gorm:"type:text"` // 任务属性
|
||||
}
|
||||
|
||||
// Create 创建任务记录
|
||||
func (task *Task) Create() (uint, error) {
|
||||
if err := DB.Create(task).Error; err != nil {
|
||||
util.Log().Warning("Failed to insert task record: %s", err)
|
||||
return 0, err
|
||||
}
|
||||
return task.ID, nil
|
||||
}
|
||||
|
||||
// SetStatus 设定任务状态
|
||||
func (task *Task) SetStatus(status int) error {
|
||||
return DB.Model(task).Select("status").Updates(map[string]interface{}{"status": status}).Error
|
||||
}
|
||||
|
||||
// SetProgress 设定任务进度
|
||||
func (task *Task) SetProgress(progress int) error {
|
||||
return DB.Model(task).Select("progress").Updates(map[string]interface{}{"progress": progress}).Error
|
||||
}
|
||||
|
||||
// SetError 设定错误信息
|
||||
func (task *Task) SetError(err string) error {
|
||||
return DB.Model(task).Select("error").Updates(map[string]interface{}{"error": err}).Error
|
||||
}
|
||||
|
||||
// GetTasksByStatus 根据状态检索任务
|
||||
func GetTasksByStatus(status ...int) []Task {
|
||||
var tasks []Task
|
||||
DB.Where("status in (?)", status).Find(&tasks)
|
||||
return tasks
|
||||
}
|
||||
|
||||
// GetTasksByID 根据ID检索任务
|
||||
func GetTasksByID(id interface{}) (*Task, error) {
|
||||
task := &Task{}
|
||||
result := DB.Where("id = ?", id).First(task)
|
||||
return task, result.Error
|
||||
}
|
||||
|
||||
// ListTasks 列出用户所属的任务
|
||||
func ListTasks(uid uint, page, pageSize int, order string) ([]Task, int) {
|
||||
var (
|
||||
tasks []Task
|
||||
total int
|
||||
)
|
||||
dbChain := DB
|
||||
dbChain = dbChain.Where("user_id = ?", uid)
|
||||
|
||||
// 计算总数用于分页
|
||||
dbChain.Model(&Task{}).Count(&total)
|
||||
|
||||
// 查询记录
|
||||
dbChain.Limit(pageSize).Offset((page - 1) * pageSize).Order(order).Find(&tasks)
|
||||
|
||||
return tasks, total
|
||||
}
|
429
models/user.go
Normal file
429
models/user.go
Normal file
@ -0,0 +1,429 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"crypto/sha1"
|
||||
"encoding/gob"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/util"
|
||||
"github.com/jinzhu/gorm"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
const (
|
||||
// Active 账户正常状态
|
||||
Active = iota
|
||||
// NotActivicated 未激活
|
||||
NotActivicated
|
||||
// Baned 被封禁
|
||||
Baned
|
||||
// OveruseBaned 超额使用被封禁
|
||||
OveruseBaned
|
||||
)
|
||||
|
||||
// User 用户模型
|
||||
type User struct {
|
||||
// 表字段
|
||||
gorm.Model
|
||||
Email string `gorm:"type:varchar(100);unique_index"`
|
||||
Nick string `gorm:"size:50"`
|
||||
Password string `json:"-"`
|
||||
Status int
|
||||
GroupID uint
|
||||
Storage uint64
|
||||
OpenID string
|
||||
TwoFactor string
|
||||
Avatar string
|
||||
Options string `json:"-" gorm:"size:4294967295"`
|
||||
Authn string `gorm:"size:4294967295"`
|
||||
Score int
|
||||
PreviousGroupID uint // 初始用户组
|
||||
GroupExpires *time.Time // 用户组过期日期
|
||||
NotifyDate *time.Time // 通知超出配额时的日期
|
||||
Phone string
|
||||
|
||||
// 关联模型
|
||||
Group Group `gorm:"save_associations:false:false"`
|
||||
|
||||
// 数据库忽略字段
|
||||
OptionsSerialized UserOption `gorm:"-"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
gob.Register(User{})
|
||||
}
|
||||
|
||||
// UserOption 用户个性化配置字段
|
||||
type UserOption struct {
|
||||
ProfileOff bool `json:"profile_off,omitempty"`
|
||||
PreferredPolicy uint `json:"preferred_policy,omitempty"`
|
||||
PreferredTheme string `json:"preferred_theme,omitempty"`
|
||||
}
|
||||
|
||||
// Root 获取用户的根目录
|
||||
func (user *User) Root() (*Folder, error) {
|
||||
var folder Folder
|
||||
err := DB.Where("parent_id is NULL AND owner_id = ?", user.ID).First(&folder).Error
|
||||
return &folder, err
|
||||
}
|
||||
|
||||
// DeductionStorage 减少用户已用容量
|
||||
func (user *User) DeductionStorage(size uint64) bool {
|
||||
if size == 0 {
|
||||
return true
|
||||
}
|
||||
if size <= user.Storage {
|
||||
user.Storage -= size
|
||||
DB.Model(user).Update("storage", gorm.Expr("storage - ?", size))
|
||||
return true
|
||||
}
|
||||
// 如果要减少的容量超出已用容量,则设为零
|
||||
user.Storage = 0
|
||||
DB.Model(user).Update("storage", 0)
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// IncreaseStorage 检查并增加用户已用容量
|
||||
func (user *User) IncreaseStorage(size uint64) bool {
|
||||
if size == 0 {
|
||||
return true
|
||||
}
|
||||
if size <= user.GetRemainingCapacity() {
|
||||
user.Storage += size
|
||||
DB.Model(user).Update("storage", gorm.Expr("storage + ?", size))
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ChangeStorage 更新用户容量
|
||||
func (user *User) ChangeStorage(tx *gorm.DB, operator string, size uint64) error {
|
||||
return tx.Model(user).Update("storage", gorm.Expr("storage "+operator+" ?", size)).Error
|
||||
}
|
||||
|
||||
// PayScore 扣除积分,返回是否成功
|
||||
func (user *User) PayScore(score int) bool {
|
||||
if score == 0 {
|
||||
return true
|
||||
}
|
||||
if score <= user.Score {
|
||||
user.Score -= score
|
||||
DB.Model(user).Update("score", gorm.Expr("score - ?", score))
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// AddScore 增加积分
|
||||
func (user *User) AddScore(score int) {
|
||||
user.Score += score
|
||||
DB.Model(user).Update("score", gorm.Expr("score + ?", score))
|
||||
}
|
||||
|
||||
// IncreaseStorageWithoutCheck 忽略可用容量,增加用户已用容量
|
||||
func (user *User) IncreaseStorageWithoutCheck(size uint64) {
|
||||
if size == 0 {
|
||||
return
|
||||
}
|
||||
user.Storage += size
|
||||
DB.Model(user).Update("storage", gorm.Expr("storage + ?", size))
|
||||
|
||||
}
|
||||
|
||||
// GetRemainingCapacity 获取剩余配额
|
||||
func (user *User) GetRemainingCapacity() uint64 {
|
||||
total := user.Group.MaxStorage + user.GetAvailablePackSize()
|
||||
if total <= user.Storage {
|
||||
return 0
|
||||
}
|
||||
return total - user.Storage
|
||||
}
|
||||
|
||||
// GetPolicyID 获取给定目录的存储策略, 如果为 nil 则使用默认
|
||||
func (user *User) GetPolicyID(folder *Folder) *Policy {
|
||||
if user.IsAnonymous() {
|
||||
return &Policy{Type: "anonymous"}
|
||||
}
|
||||
|
||||
defaultPolicy := uint(1)
|
||||
if len(user.Group.PolicyList) > 0 {
|
||||
defaultPolicy = user.Group.PolicyList[0]
|
||||
}
|
||||
|
||||
if folder != nil {
|
||||
prefer := folder.PolicyID
|
||||
if prefer == 0 && folder.InheritPolicyID > 0 {
|
||||
prefer = folder.InheritPolicyID
|
||||
}
|
||||
|
||||
if prefer > 0 && util.ContainsUint(user.Group.PolicyList, prefer) {
|
||||
defaultPolicy = prefer
|
||||
}
|
||||
}
|
||||
|
||||
p, _ := GetPolicyByID(defaultPolicy)
|
||||
return &p
|
||||
}
|
||||
|
||||
// GetPolicyByPreference 在可用存储策略中优先获取 preference
|
||||
func (user *User) GetPolicyByPreference(preference uint) *Policy {
|
||||
if user.IsAnonymous() {
|
||||
return &Policy{Type: "anonymous"}
|
||||
}
|
||||
|
||||
defaultPolicy := uint(1)
|
||||
if len(user.Group.PolicyList) > 0 {
|
||||
defaultPolicy = user.Group.PolicyList[0]
|
||||
}
|
||||
|
||||
if preference != 0 {
|
||||
if util.ContainsUint(user.Group.PolicyList, preference) {
|
||||
defaultPolicy = preference
|
||||
}
|
||||
}
|
||||
|
||||
p, _ := GetPolicyByID(defaultPolicy)
|
||||
return &p
|
||||
}
|
||||
|
||||
// GetUserByID 用ID获取用户
|
||||
func GetUserByID(ID interface{}) (User, error) {
|
||||
var user User
|
||||
result := DB.Set("gorm:auto_preload", true).First(&user, ID)
|
||||
return user, result.Error
|
||||
}
|
||||
|
||||
// GetActiveUserByID 用ID获取可登录用户
|
||||
func GetActiveUserByID(ID interface{}) (User, error) {
|
||||
var user User
|
||||
result := DB.Set("gorm:auto_preload", true).Where("status = ?", Active).First(&user, ID)
|
||||
return user, result.Error
|
||||
}
|
||||
|
||||
// GetActiveUserByOpenID 用OpenID获取可登录用户
|
||||
func GetActiveUserByOpenID(openid string) (User, error) {
|
||||
var user User
|
||||
result := DB.Set("gorm:auto_preload", true).Where("status = ? and open_id = ?", Active, openid).Find(&user)
|
||||
return user, result.Error
|
||||
}
|
||||
|
||||
// GetUserByEmail 用Email获取用户
|
||||
func GetUserByEmail(email string) (User, error) {
|
||||
var user User
|
||||
result := DB.Set("gorm:auto_preload", true).Where("email = ?", email).First(&user)
|
||||
return user, result.Error
|
||||
}
|
||||
|
||||
// GetActiveUserByEmail 用Email获取可登录用户
|
||||
func GetActiveUserByEmail(email string) (User, error) {
|
||||
var user User
|
||||
result := DB.Set("gorm:auto_preload", true).Where("status = ? and email = ?", Active, email).First(&user)
|
||||
return user, result.Error
|
||||
}
|
||||
|
||||
// NewUser 返回一个新的空 User
|
||||
func NewUser() User {
|
||||
options := UserOption{}
|
||||
return User{
|
||||
OptionsSerialized: options,
|
||||
}
|
||||
}
|
||||
|
||||
// BeforeSave Save用户前的钩子
|
||||
func (user *User) BeforeSave() (err error) {
|
||||
err = user.SerializeOptions()
|
||||
return err
|
||||
}
|
||||
|
||||
// AfterCreate 创建用户后的钩子
|
||||
func (user *User) AfterCreate(tx *gorm.DB) (err error) {
|
||||
// 创建用户的默认根目录
|
||||
defaultFolder := &Folder{
|
||||
Name: "/",
|
||||
OwnerID: user.ID,
|
||||
}
|
||||
tx.Create(defaultFolder)
|
||||
|
||||
// 创建用户初始文件记录
|
||||
initialFiles := GetSettingByNameFromTx(tx, "initial_files")
|
||||
if initialFiles != "" {
|
||||
initialFileIDs := make([]uint, 0)
|
||||
if err := json.Unmarshal([]byte(initialFiles), &initialFileIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if files, err := GetFilesByIDsFromTX(tx, initialFileIDs, 0); err == nil {
|
||||
for _, file := range files {
|
||||
file.ID = 0
|
||||
file.UserID = user.ID
|
||||
file.FolderID = defaultFolder.ID
|
||||
user.Storage += file.Size
|
||||
tx.Create(&file)
|
||||
}
|
||||
tx.Save(user)
|
||||
}
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// AfterFind 找到用户后的钩子
|
||||
func (user *User) AfterFind() (err error) {
|
||||
// 解析用户设置到OptionsSerialized
|
||||
if user.Options != "" {
|
||||
err = json.Unmarshal([]byte(user.Options), &user.OptionsSerialized)
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// SerializeOptions 将序列后的Option写入到数据库字段
|
||||
func (user *User) SerializeOptions() (err error) {
|
||||
optionsValue, err := json.Marshal(&user.OptionsSerialized)
|
||||
user.Options = string(optionsValue)
|
||||
return err
|
||||
}
|
||||
|
||||
// CheckPassword 根据明文校验密码
|
||||
func (user *User) CheckPassword(password string) (bool, error) {
|
||||
|
||||
// 根据存储密码拆分为 Salt 和 Digest
|
||||
passwordStore := strings.Split(user.Password, ":")
|
||||
if len(passwordStore) != 2 && len(passwordStore) != 3 {
|
||||
return false, errors.New("Unknown password type")
|
||||
}
|
||||
|
||||
// 兼容V2密码,升级后存储格式为: md5:$HASH:$SALT
|
||||
if len(passwordStore) == 3 {
|
||||
if passwordStore[0] != "md5" {
|
||||
return false, errors.New("Unknown password type")
|
||||
}
|
||||
hash := md5.New()
|
||||
_, err := hash.Write([]byte(passwordStore[2] + password))
|
||||
bs := hex.EncodeToString(hash.Sum(nil))
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return bs == passwordStore[1], nil
|
||||
}
|
||||
|
||||
//计算 Salt 和密码组合的SHA1摘要
|
||||
hash := sha1.New()
|
||||
_, err := hash.Write([]byte(password + passwordStore[0]))
|
||||
bs := hex.EncodeToString(hash.Sum(nil))
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return bs == passwordStore[1], nil
|
||||
}
|
||||
|
||||
// SetPassword 根据给定明文设定 User 的 Password 字段
|
||||
func (user *User) SetPassword(password string) error {
|
||||
//生成16位 Salt
|
||||
salt := util.RandStringRunes(16)
|
||||
|
||||
//计算 Salt 和密码组合的SHA1摘要
|
||||
hash := sha1.New()
|
||||
_, err := hash.Write([]byte(password + salt))
|
||||
bs := hex.EncodeToString(hash.Sum(nil))
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
//存储 Salt 值和摘要, ":"分割
|
||||
user.Password = salt + ":" + string(bs)
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewAnonymousUser 返回一个匿名用户
|
||||
func NewAnonymousUser() *User {
|
||||
user := User{}
|
||||
user.Group, _ = GetGroupByID(3)
|
||||
return &user
|
||||
}
|
||||
|
||||
// IsAnonymous 返回是否为未登录用户
|
||||
func (user *User) IsAnonymous() bool {
|
||||
return user.ID == 0
|
||||
}
|
||||
|
||||
// Notified 更新用户容量超额通知日期
|
||||
func (user *User) Notified() {
|
||||
if user.NotifyDate == nil {
|
||||
timeNow := time.Now()
|
||||
user.NotifyDate = &timeNow
|
||||
DB.Model(&user).Update("notify_date", user.NotifyDate)
|
||||
}
|
||||
}
|
||||
|
||||
// ClearNotified 清除用户通知标记
|
||||
func (user *User) ClearNotified() {
|
||||
DB.Model(&user).Update("notify_date", nil)
|
||||
}
|
||||
|
||||
// SetStatus 设定用户状态
|
||||
func (user *User) SetStatus(status int) {
|
||||
DB.Model(&user).Update("status", status)
|
||||
}
|
||||
|
||||
// Update 更新用户
|
||||
func (user *User) Update(val map[string]interface{}) error {
|
||||
return DB.Model(user).Updates(val).Error
|
||||
}
|
||||
|
||||
// UpdateOptions 更新用户偏好设定
|
||||
func (user *User) UpdateOptions() error {
|
||||
if err := user.SerializeOptions(); err != nil {
|
||||
return err
|
||||
}
|
||||
return user.Update(map[string]interface{}{"options": user.Options})
|
||||
}
|
||||
|
||||
// GetGroupExpiredUsers 获取用户组过期的用户
|
||||
func GetGroupExpiredUsers() []User {
|
||||
var users []User
|
||||
DB.Where("group_expires < ? and previous_group_id <> 0", time.Now()).Find(&users)
|
||||
return users
|
||||
}
|
||||
|
||||
// GetTolerantExpiredUser 获取超过宽容期的用户
|
||||
func GetTolerantExpiredUser() []User {
|
||||
var users []User
|
||||
DB.Set("gorm:auto_preload", true).Where("notify_date < ?", time.Now().Add(
|
||||
time.Duration(-GetIntSetting("ban_time", 10))*time.Second),
|
||||
).Find(&users)
|
||||
return users
|
||||
}
|
||||
|
||||
// GroupFallback 回退到初始用户组
|
||||
func (user *User) GroupFallback() {
|
||||
if user.GroupExpires != nil && user.PreviousGroupID != 0 {
|
||||
user.Group.ID = user.PreviousGroupID
|
||||
DB.Model(&user).Updates(map[string]interface{}{
|
||||
"group_expires": nil,
|
||||
"previous_group_id": 0,
|
||||
"group_id": user.PreviousGroupID,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// UpgradeGroup 升级用户组
|
||||
func (user *User) UpgradeGroup(id uint, expires *time.Time) error {
|
||||
user.Group.ID = id
|
||||
previousGroupID := user.GroupID
|
||||
if user.PreviousGroupID != 0 && user.GroupID == id {
|
||||
previousGroupID = user.PreviousGroupID
|
||||
}
|
||||
return DB.Model(&user).Updates(map[string]interface{}{
|
||||
"group_expires": expires,
|
||||
"previous_group_id": previousGroupID,
|
||||
"group_id": id,
|
||||
}).Error
|
||||
}
|
79
models/user_authn.go
Normal file
79
models/user_authn.go
Normal file
@ -0,0 +1,79 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/hashid"
|
||||
"github.com/duo-labs/webauthn/webauthn"
|
||||
)
|
||||
|
||||
/*
|
||||
`webauthn.User` 接口的实现
|
||||
*/
|
||||
|
||||
// WebAuthnID 返回用户ID
|
||||
func (user User) WebAuthnID() []byte {
|
||||
bs := make([]byte, 8)
|
||||
binary.LittleEndian.PutUint64(bs, uint64(user.ID))
|
||||
return bs
|
||||
}
|
||||
|
||||
// WebAuthnName 返回用户名
|
||||
func (user User) WebAuthnName() string {
|
||||
return user.Email
|
||||
}
|
||||
|
||||
// WebAuthnDisplayName 获得用于展示的用户名
|
||||
func (user User) WebAuthnDisplayName() string {
|
||||
return user.Nick
|
||||
}
|
||||
|
||||
// WebAuthnIcon 获得用户头像
|
||||
func (user User) WebAuthnIcon() string {
|
||||
avatar, _ := url.Parse("/api/v3/user/avatar/" + hashid.HashID(user.ID, hashid.UserID) + "/l")
|
||||
base := GetSiteURL()
|
||||
base.Scheme = "https"
|
||||
return base.ResolveReference(avatar).String()
|
||||
}
|
||||
|
||||
// WebAuthnCredentials 获得已注册的验证器凭证
|
||||
func (user User) WebAuthnCredentials() []webauthn.Credential {
|
||||
var res []webauthn.Credential
|
||||
err := json.Unmarshal([]byte(user.Authn), &res)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// RegisterAuthn 添加新的验证器
|
||||
func (user *User) RegisterAuthn(credential *webauthn.Credential) error {
|
||||
exists := user.WebAuthnCredentials()
|
||||
exists = append(exists, *credential)
|
||||
res, err := json.Marshal(exists)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return DB.Model(user).Update("authn", string(res)).Error
|
||||
}
|
||||
|
||||
// RemoveAuthn 删除验证器
|
||||
func (user *User) RemoveAuthn(id string) {
|
||||
exists := user.WebAuthnCredentials()
|
||||
for i := 0; i < len(exists); i++ {
|
||||
idEncoded := base64.StdEncoding.EncodeToString(exists[i].ID)
|
||||
if idEncoded == id {
|
||||
exists[len(exists)-1], exists[i] = exists[i], exists[len(exists)-1]
|
||||
exists = exists[:len(exists)-1]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
res, _ := json.Marshal(exists)
|
||||
DB.Model(user).Update("authn", string(res))
|
||||
}
|
53
models/webdav.go
Normal file
53
models/webdav.go
Normal file
@ -0,0 +1,53 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"github.com/jinzhu/gorm"
|
||||
)
|
||||
|
||||
// Webdav 应用账户
|
||||
type Webdav struct {
|
||||
gorm.Model
|
||||
Name string // 应用名称
|
||||
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"` // 是否只读
|
||||
UseProxy bool `gorm:"type:bool"` // 是否进行反代
|
||||
}
|
||||
|
||||
// Create 创建账户
|
||||
func (webdav *Webdav) Create() (uint, error) {
|
||||
if err := DB.Create(webdav).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return webdav.ID, nil
|
||||
}
|
||||
|
||||
// GetWebdavByPassword 根据密码和用户查找Webdav应用
|
||||
func GetWebdavByPassword(password string, uid uint) (*Webdav, error) {
|
||||
webdav := &Webdav{}
|
||||
res := DB.Where("user_id = ? and password = ?", uid, password).First(webdav)
|
||||
return webdav, res.Error
|
||||
}
|
||||
|
||||
// ListWebDAVAccounts 列出用户的所有账号
|
||||
func ListWebDAVAccounts(uid uint) []Webdav {
|
||||
var accounts []Webdav
|
||||
DB.Where("user_id = ?", uid).Order("created_at desc").Find(&accounts)
|
||||
return accounts
|
||||
}
|
||||
|
||||
// DeleteWebDAVAccountByID 根据账户ID和UID删除账户
|
||||
func DeleteWebDAVAccountByID(id, uid uint) {
|
||||
DB.Where("user_id = ? and id = ?", uid, id).Delete(&Webdav{})
|
||||
}
|
||||
|
||||
// UpdateWebDAVAccountByID 根据账户ID和UID更新账户
|
||||
func UpdateWebDAVAccountByID(id, uid uint, updates map[string]interface{}) {
|
||||
DB.Model(&Webdav{Model: gorm.Model{ID: id}, UserID: uid}).Updates(updates)
|
||||
}
|
||||
|
||||
// UpdateWebDAVAccountReadonlyByID 根据账户ID和UID更新账户的只读性
|
||||
func UpdateWebDAVAccountReadonlyByID(id, uid uint, readonly bool) {
|
||||
DB.Model(&Webdav{Model: gorm.Model{ID: id}, UserID: uid}).UpdateColumn("readonly", readonly)
|
||||
}
|
4
paksource.sh
Normal file
4
paksource.sh
Normal file
@ -0,0 +1,4 @@
|
||||
#!/usr/bin/bash
|
||||
|
||||
cd ../
|
||||
zip -r cloudreveplus-source.zip ./PlusBackend/ -x './PlusBackend/assets/node_modules/*' --exclude '**/.git/**'
|
67
pkg/aria2/aria2.go
Normal file
67
pkg/aria2/aria2.go
Normal file
@ -0,0 +1,67 @@
|
||||
package aria2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
model "github.com/cloudreve/Cloudreve/v3/models"
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/aria2/common"
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/aria2/monitor"
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/aria2/rpc"
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/balancer"
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/cluster"
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/mq"
|
||||
)
|
||||
|
||||
// Instance 默认使用的Aria2处理实例
|
||||
var Instance common.Aria2 = &common.DummyAria2{}
|
||||
|
||||
// LB 获取 Aria2 节点的负载均衡器
|
||||
var LB balancer.Balancer
|
||||
|
||||
// Lock Instance的读写锁
|
||||
var Lock sync.RWMutex
|
||||
|
||||
// GetLoadBalancer 返回供Aria2使用的负载均衡器
|
||||
func GetLoadBalancer() balancer.Balancer {
|
||||
Lock.RLock()
|
||||
defer Lock.RUnlock()
|
||||
return LB
|
||||
}
|
||||
|
||||
// Init 初始化
|
||||
func Init(isReload bool, pool cluster.Pool, mqClient mq.MQ) {
|
||||
Lock.Lock()
|
||||
LB = balancer.NewBalancer("RoundRobin")
|
||||
Lock.Unlock()
|
||||
|
||||
if !isReload {
|
||||
// 从数据库中读取未完成任务,创建监控
|
||||
unfinished := model.GetDownloadsByStatus(common.Ready, common.Paused, common.Downloading, common.Seeding)
|
||||
|
||||
for i := 0; i < len(unfinished); i++ {
|
||||
// 创建任务监控
|
||||
monitor.NewMonitor(&unfinished[i], pool, mqClient)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRPCConnection 发送测试用的 RPC 请求,测试服务连通性
|
||||
func TestRPCConnection(server, secret string, timeout int) (rpc.VersionInfo, error) {
|
||||
// 解析RPC服务地址
|
||||
rpcServer, err := url.Parse(server)
|
||||
if err != nil {
|
||||
return rpc.VersionInfo{}, fmt.Errorf("cannot parse RPC server: %w", err)
|
||||
}
|
||||
|
||||
rpcServer.Path = "/jsonrpc"
|
||||
caller, err := rpc.New(context.Background(), rpcServer.String(), secret, time.Duration(timeout)*time.Second, nil)
|
||||
if err != nil {
|
||||
return rpc.VersionInfo{}, fmt.Errorf("cannot initialize rpc connection: %w", err)
|
||||
}
|
||||
|
||||
return caller.GetVersion()
|
||||
}
|
119
pkg/aria2/common/common.go
Normal file
119
pkg/aria2/common/common.go
Normal file
@ -0,0 +1,119 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
model "github.com/cloudreve/Cloudreve/v3/models"
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/aria2/rpc"
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/serializer"
|
||||
)
|
||||
|
||||
// Aria2 离线下载处理接口
|
||||
type Aria2 interface {
|
||||
// Init 初始化客户端连接
|
||||
Init() error
|
||||
// CreateTask 创建新的任务
|
||||
CreateTask(task *model.Download, options map[string]interface{}) (string, error)
|
||||
// 返回状态信息
|
||||
Status(task *model.Download) (rpc.StatusInfo, error)
|
||||
// 取消任务
|
||||
Cancel(task *model.Download) error
|
||||
// 选择要下载的文件
|
||||
Select(task *model.Download, files []int) error
|
||||
// 获取离线下载配置
|
||||
GetConfig() model.Aria2Option
|
||||
// 删除临时下载文件
|
||||
DeleteTempFile(*model.Download) error
|
||||
}
|
||||
|
||||
const (
|
||||
// URLTask 从URL添加的任务
|
||||
URLTask = iota
|
||||
// TorrentTask 种子任务
|
||||
TorrentTask
|
||||
)
|
||||
|
||||
const (
|
||||
// Ready 准备就绪
|
||||
Ready = iota
|
||||
// Downloading 下载中
|
||||
Downloading
|
||||
// Paused 暂停中
|
||||
Paused
|
||||
// Error 出错
|
||||
Error
|
||||
// Complete 完成
|
||||
Complete
|
||||
// Canceled 取消/停止
|
||||
Canceled
|
||||
// Unknown 未知状态
|
||||
Unknown
|
||||
// Seeding 做种中
|
||||
Seeding
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrNotEnabled 功能未开启错误
|
||||
ErrNotEnabled = serializer.NewError(serializer.CodeFeatureNotEnabled, "not enabled", nil)
|
||||
// ErrUserNotFound 未找到下载任务创建者
|
||||
ErrUserNotFound = serializer.NewError(serializer.CodeUserNotFound, "", nil)
|
||||
)
|
||||
|
||||
// DummyAria2 未开启Aria2功能时使用的默认处理器
|
||||
type DummyAria2 struct {
|
||||
}
|
||||
|
||||
func (instance *DummyAria2) Init() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateTask 创建新任务,此处直接返回未开启错误
|
||||
func (instance *DummyAria2) CreateTask(model *model.Download, options map[string]interface{}) (string, error) {
|
||||
return "", ErrNotEnabled
|
||||
}
|
||||
|
||||
// Status 返回未开启错误
|
||||
func (instance *DummyAria2) Status(task *model.Download) (rpc.StatusInfo, error) {
|
||||
return rpc.StatusInfo{}, ErrNotEnabled
|
||||
}
|
||||
|
||||
// Cancel 返回未开启错误
|
||||
func (instance *DummyAria2) Cancel(task *model.Download) error {
|
||||
return ErrNotEnabled
|
||||
}
|
||||
|
||||
// Select 返回未开启错误
|
||||
func (instance *DummyAria2) Select(task *model.Download, files []int) error {
|
||||
return ErrNotEnabled
|
||||
}
|
||||
|
||||
// GetConfig 返回空的
|
||||
func (instance *DummyAria2) GetConfig() model.Aria2Option {
|
||||
return model.Aria2Option{}
|
||||
}
|
||||
|
||||
// GetConfig 返回空的
|
||||
func (instance *DummyAria2) DeleteTempFile(src *model.Download) error {
|
||||
return ErrNotEnabled
|
||||
}
|
||||
|
||||
// GetStatus 将给定的状态字符串转换为状态标识数字
|
||||
func GetStatus(status rpc.StatusInfo) int {
|
||||
switch status.Status {
|
||||
case "complete":
|
||||
return Complete
|
||||
case "active":
|
||||
if status.BitTorrent.Mode != "" && status.CompletedLength == status.TotalLength {
|
||||
return Seeding
|
||||
}
|
||||
return Downloading
|
||||
case "waiting":
|
||||
return Ready
|
||||
case "paused":
|
||||
return Paused
|
||||
case "error":
|
||||
return Error
|
||||
case "removed":
|
||||
return Canceled
|
||||
default:
|
||||
return Unknown
|
||||
}
|
||||
}
|
320
pkg/aria2/monitor/monitor.go
Normal file
320
pkg/aria2/monitor/monitor.go
Normal file
@ -0,0 +1,320 @@
|
||||
package monitor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
model "github.com/cloudreve/Cloudreve/v3/models"
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/aria2/common"
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/aria2/rpc"
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/cluster"
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/filesystem"
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/filesystem/fsctx"
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/mq"
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/task"
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/util"
|
||||
)
|
||||
|
||||
// Monitor 离线下载状态监控
|
||||
type Monitor struct {
|
||||
Task *model.Download
|
||||
Interval time.Duration
|
||||
|
||||
notifier <-chan mq.Message
|
||||
node cluster.Node
|
||||
retried int
|
||||
}
|
||||
|
||||
var MAX_RETRY = 10
|
||||
|
||||
// NewMonitor 新建离线下载状态监控
|
||||
func NewMonitor(task *model.Download, pool cluster.Pool, mqClient mq.MQ) {
|
||||
monitor := &Monitor{
|
||||
Task: task,
|
||||
notifier: make(chan mq.Message),
|
||||
node: pool.GetNodeByID(task.GetNodeID()),
|
||||
}
|
||||
|
||||
if monitor.node != nil {
|
||||
monitor.Interval = time.Duration(monitor.node.GetAria2Instance().GetConfig().Interval) * time.Second
|
||||
go monitor.Loop(mqClient)
|
||||
|
||||
monitor.notifier = mqClient.Subscribe(monitor.Task.GID, 0)
|
||||
} else {
|
||||
monitor.setErrorStatus(errors.New("node not avaliable"))
|
||||
}
|
||||
}
|
||||
|
||||
// Loop 开启监控循环
|
||||
func (monitor *Monitor) Loop(mqClient mq.MQ) {
|
||||
defer mqClient.Unsubscribe(monitor.Task.GID, monitor.notifier)
|
||||
fmt.Println(cluster.Default)
|
||||
|
||||
// 首次循环立即更新
|
||||
interval := 50 * time.Millisecond
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-monitor.notifier:
|
||||
if monitor.Update() {
|
||||
return
|
||||
}
|
||||
case <-time.After(interval):
|
||||
interval = monitor.Interval
|
||||
if monitor.Update() {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update 更新状态,返回值表示是否退出监控
|
||||
func (monitor *Monitor) Update() bool {
|
||||
status, err := monitor.node.GetAria2Instance().Status(monitor.Task)
|
||||
|
||||
if err != nil {
|
||||
monitor.retried++
|
||||
util.Log().Warning("Cannot get status of download task %q: %s", monitor.Task.GID, err)
|
||||
|
||||
// 十次重试后认定为任务失败
|
||||
if monitor.retried > MAX_RETRY {
|
||||
util.Log().Warning("Cannot get status of download task %q,exceed maximum retry threshold: %s",
|
||||
monitor.Task.GID, err)
|
||||
monitor.setErrorStatus(err)
|
||||
monitor.RemoveTempFolder()
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
monitor.retried = 0
|
||||
|
||||
// 磁力链下载需要跟随
|
||||
if len(status.FollowedBy) > 0 {
|
||||
util.Log().Debug("Redirected download task from %q to %q.", monitor.Task.GID, status.FollowedBy[0])
|
||||
monitor.Task.GID = status.FollowedBy[0]
|
||||
monitor.Task.Save()
|
||||
return false
|
||||
}
|
||||
|
||||
// 更新任务信息
|
||||
if err := monitor.UpdateTaskInfo(status); err != nil {
|
||||
util.Log().Warning("Failed to update status of download task %q: %s", monitor.Task.GID, err)
|
||||
monitor.setErrorStatus(err)
|
||||
monitor.RemoveTempFolder()
|
||||
return true
|
||||
}
|
||||
|
||||
util.Log().Debug("Remote download %q status updated to %q.", status.Gid, status.Status)
|
||||
|
||||
switch common.GetStatus(status) {
|
||||
case common.Complete, common.Seeding:
|
||||
return monitor.Complete(task.TaskPoll)
|
||||
case common.Error:
|
||||
return monitor.Error(status)
|
||||
case common.Downloading, common.Ready, common.Paused:
|
||||
return false
|
||||
case common.Canceled:
|
||||
monitor.Task.Status = common.Canceled
|
||||
monitor.Task.Save()
|
||||
monitor.RemoveTempFolder()
|
||||
return true
|
||||
default:
|
||||
util.Log().Warning("Download task %q returns unknown status %q.", monitor.Task.GID, status.Status)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateTaskInfo 更新数据库中的任务信息
|
||||
func (monitor *Monitor) UpdateTaskInfo(status rpc.StatusInfo) error {
|
||||
originSize := monitor.Task.TotalSize
|
||||
|
||||
monitor.Task.GID = status.Gid
|
||||
monitor.Task.Status = common.GetStatus(status)
|
||||
|
||||
// 文件大小、已下载大小
|
||||
total, err := strconv.ParseUint(status.TotalLength, 10, 64)
|
||||
if err != nil {
|
||||
total = 0
|
||||
}
|
||||
downloaded, err := strconv.ParseUint(status.CompletedLength, 10, 64)
|
||||
if err != nil {
|
||||
downloaded = 0
|
||||
}
|
||||
monitor.Task.TotalSize = total
|
||||
monitor.Task.DownloadedSize = downloaded
|
||||
monitor.Task.GID = status.Gid
|
||||
monitor.Task.Parent = status.Dir
|
||||
|
||||
// 下载速度
|
||||
speed, err := strconv.Atoi(status.DownloadSpeed)
|
||||
if err != nil {
|
||||
speed = 0
|
||||
}
|
||||
|
||||
monitor.Task.Speed = speed
|
||||
attrs, _ := json.Marshal(status)
|
||||
monitor.Task.Attrs = string(attrs)
|
||||
|
||||
if err := monitor.Task.Save(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if originSize != monitor.Task.TotalSize {
|
||||
// 文件大小更新后,对文件限制等进行校验
|
||||
if err := monitor.ValidateFile(); err != nil {
|
||||
// 验证失败时取消任务
|
||||
monitor.node.GetAria2Instance().Cancel(monitor.Task)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateFile 上传过程中校验文件大小、文件名
|
||||
func (monitor *Monitor) ValidateFile() error {
|
||||
// 找到任务创建者
|
||||
user := monitor.Task.GetOwner()
|
||||
if user == nil {
|
||||
return common.ErrUserNotFound
|
||||
}
|
||||
|
||||
// 创建文件系统
|
||||
fs, err := filesystem.NewFileSystem(user)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer fs.Recycle()
|
||||
|
||||
if err := fs.SetPolicyFromPath(monitor.Task.Dst); err != nil {
|
||||
return fmt.Errorf("failed to switch policy to target dir: %w", err)
|
||||
}
|
||||
|
||||
// 创建上下文环境
|
||||
file := &fsctx.FileStream{
|
||||
Size: monitor.Task.TotalSize,
|
||||
}
|
||||
|
||||
// 验证用户容量
|
||||
if err := filesystem.HookValidateCapacity(context.Background(), fs, file); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 验证每个文件
|
||||
for _, fileInfo := range monitor.Task.StatusInfo.Files {
|
||||
if fileInfo.Selected == "true" {
|
||||
// 创建上下文环境
|
||||
fileSize, _ := strconv.ParseUint(fileInfo.Length, 10, 64)
|
||||
file := &fsctx.FileStream{
|
||||
Size: fileSize,
|
||||
Name: filepath.Base(fileInfo.Path),
|
||||
}
|
||||
if err := filesystem.HookValidateFile(context.Background(), fs, file); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Error 任务下载出错处理,返回是否中断监控
|
||||
func (monitor *Monitor) Error(status rpc.StatusInfo) bool {
|
||||
monitor.setErrorStatus(errors.New(status.ErrorMessage))
|
||||
|
||||
// 清理临时文件
|
||||
monitor.RemoveTempFolder()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// RemoveTempFolder 清理下载临时目录
|
||||
func (monitor *Monitor) RemoveTempFolder() {
|
||||
monitor.node.GetAria2Instance().DeleteTempFile(monitor.Task)
|
||||
}
|
||||
|
||||
// Complete 完成下载,返回是否中断监控
|
||||
func (monitor *Monitor) Complete(pool task.Pool) bool {
|
||||
// 未开始转存,提交转存任务
|
||||
if monitor.Task.TaskID == 0 {
|
||||
return monitor.transfer(pool)
|
||||
}
|
||||
|
||||
// 做种完成
|
||||
if common.GetStatus(monitor.Task.StatusInfo) == common.Complete {
|
||||
transferTask, err := model.GetTasksByID(monitor.Task.TaskID)
|
||||
if err != nil {
|
||||
monitor.setErrorStatus(err)
|
||||
monitor.RemoveTempFolder()
|
||||
return true
|
||||
}
|
||||
|
||||
// 转存完成,回收下载目录
|
||||
if transferTask.Type == task.TransferTaskType && transferTask.Status >= task.Error {
|
||||
job, err := task.NewRecycleTask(monitor.Task)
|
||||
if err != nil {
|
||||
monitor.setErrorStatus(err)
|
||||
monitor.RemoveTempFolder()
|
||||
return true
|
||||
}
|
||||
|
||||
// 提交回收任务
|
||||
pool.Submit(job)
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (monitor *Monitor) transfer(pool task.Pool) bool {
|
||||
// 创建中转任务
|
||||
file := make([]string, 0, len(monitor.Task.StatusInfo.Files))
|
||||
sizes := make(map[string]uint64, len(monitor.Task.StatusInfo.Files))
|
||||
for i := 0; i < len(monitor.Task.StatusInfo.Files); i++ {
|
||||
fileInfo := monitor.Task.StatusInfo.Files[i]
|
||||
if fileInfo.Selected == "true" {
|
||||
file = append(file, fileInfo.Path)
|
||||
size, _ := strconv.ParseUint(fileInfo.Length, 10, 64)
|
||||
sizes[fileInfo.Path] = size
|
||||
}
|
||||
}
|
||||
|
||||
job, err := task.NewTransferTask(
|
||||
monitor.Task.UserID,
|
||||
file,
|
||||
monitor.Task.Dst,
|
||||
monitor.Task.Parent,
|
||||
true,
|
||||
monitor.node.ID(),
|
||||
sizes,
|
||||
)
|
||||
if err != nil {
|
||||
monitor.setErrorStatus(err)
|
||||
monitor.RemoveTempFolder()
|
||||
return true
|
||||
}
|
||||
|
||||
// 提交中转任务
|
||||
pool.Submit(job)
|
||||
|
||||
// 更新任务ID
|
||||
monitor.Task.TaskID = job.Model().ID
|
||||
monitor.Task.Save()
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (monitor *Monitor) setErrorStatus(err error) {
|
||||
monitor.Task.Status = common.Error
|
||||
monitor.Task.Error = err.Error()
|
||||
monitor.Task.Save()
|
||||
}
|
257
pkg/aria2/rpc/README.md
Normal file
257
pkg/aria2/rpc/README.md
Normal file
@ -0,0 +1,257 @@
|
||||
# PACKAGE DOCUMENTATION
|
||||
|
||||
**package rpc**
|
||||
|
||||
import "github.com/matzoe/argo/rpc"
|
||||
|
||||
|
||||
|
||||
## FUNCTIONS
|
||||
|
||||
```
|
||||
func Call(address, method string, params, reply interface{}) error
|
||||
```
|
||||
|
||||
## TYPES
|
||||
|
||||
```
|
||||
type Client struct {
|
||||
// contains filtered or unexported fields
|
||||
}
|
||||
```
|
||||
|
||||
```
|
||||
func New(uri string) *Client
|
||||
```
|
||||
|
||||
```
|
||||
func (id *Client) AddMetalink(uri string, options ...interface{}) (gid string, err error)
|
||||
```
|
||||
`aria2.addMetalink(metalink[, options[, position]])` This method adds Metalink download by uploading ".metalink" file. `metalink` is of type base64 which contains Base64-encoded ".metalink" file. `options` is of type struct and its members are a pair of option name and value. See Options below for more details. If `position` is given as an integer starting from 0, the new download is inserted at `position` in the
|
||||
waiting queue. If `position` is not given or `position` is larger than the size of the queue, it is appended at the end of the queue. This method returns array of GID of registered download. If `--rpc-save-upload-metadata` is true, the uploaded data is saved as a file named hex string of SHA-1 hash of data plus ".metalink" in the directory specified by `--dir` option. The example of filename is 0a3893293e27ac0490424c06de4d09242215f0a6.metalink. If same file already exists, it is overwritten. If the file cannot be saved successfully or `--rpc-save-upload-metadata` is false, the downloads added by this method are not saved by `--save-session`.
|
||||
|
||||
```
|
||||
func (id *Client) AddTorrent(filename string, options ...interface{}) (gid string, err error)
|
||||
```
|
||||
`aria2.addTorrent(torrent[, uris[, options[, position]]])` This method adds BitTorrent download by uploading ".torrent" file. If you want to add BitTorrent Magnet URI, use `aria2.addUri()` method instead. torrent is of type base64 which contains Base64-encoded ".torrent" file. `uris` is of type array and its element is URI which is of type string. `uris` is used for Web-seeding. For single file torrents, URI can be a complete URI pointing to the resource or if URI ends with /, name in torrent file is added. For multi-file torrents, name and path in torrent are added to form a URI for each file. options is of type struct and its members are
|
||||
a pair of option name and value. See Options below for more details. If `position` is given as an integer starting from 0, the new download is inserted at `position` in the waiting queue. If `position` is not given or `position` is larger than the size of the queue, it is appended at the end of the queue. This method returns GID of registered download. If `--rpc-save-upload-metadata` is true, the uploaded data is saved as a file named hex string of SHA-1 hash of data plus ".torrent" in the
|
||||
directory specified by `--dir` option. The example of filename is 0a3893293e27ac0490424c06de4d09242215f0a6.torrent. If same file already exists, it is overwritten. If the file cannot be saved successfully or `--rpc-save-upload-metadata` is false, the downloads added by this method are not saved by -`-save-session`.
|
||||
|
||||
```
|
||||
func (id *Client) AddUri(uri string, options ...interface{}) (gid string, err error)
|
||||
```
|
||||
|
||||
`aria2.addUri(uris[, options[, position]])` This method adds new HTTP(S)/FTP/BitTorrent Magnet URI. `uris` is of type array and its element is URI which is of type string. For BitTorrent Magnet URI, `uris` must have only one element and it should be BitTorrent Magnet URI. URIs in uris must point to the same file. If you mix other URIs which point to another file, aria2 does not complain but download may
|
||||
fail. `options` is of type struct and its members are a pair of option name and value. See Options below for more details. If `position` is given as an integer starting from 0, the new download is inserted at position in the waiting queue. If `position` is not given or `position` is larger than the size of the queue, it is appended at the end of the queue. This method returns GID of registered download.
|
||||
|
||||
```
|
||||
func (id *Client) ChangeGlobalOption(options map[string]interface{}) (g string, err error)
|
||||
```
|
||||
|
||||
`aria2.changeGlobalOption(options)` This method changes global options dynamically. `options` is of type struct. The following `options` are available:
|
||||
|
||||
download-result
|
||||
log
|
||||
log-level
|
||||
max-concurrent-downloads
|
||||
max-download-result
|
||||
max-overall-download-limit
|
||||
max-overall-upload-limit
|
||||
save-cookies
|
||||
save-session
|
||||
server-stat-of
|
||||
|
||||
In addition to them, options listed in Input File subsection are available, except for following options: `checksum`, `index-out`, `out`, `pause` and `select-file`. Using `log` option, you can dynamically start logging or change log file. To stop logging, give empty string("") as a parameter value. Note that log file is always opened in append mode. This method returns OK for success.
|
||||
|
||||
```
|
||||
func (id *Client) ChangeOption(gid string, options map[string]interface{}) (g string, err error)
|
||||
```
|
||||
|
||||
`aria2.changeOption(gid, options)` This method changes options of the download denoted by `gid` dynamically. `gid` is of type string. `options` is of type struct. The following `options` are available for active downloads:
|
||||
|
||||
bt-max-peers
|
||||
bt-request-peer-speed-limit
|
||||
bt-remove-unselected-file
|
||||
force-save
|
||||
max-download-limit
|
||||
max-upload-limit
|
||||
|
||||
For waiting or paused downloads, in addition to the above options, options listed in Input File subsection are available, except for following options: dry-run, metalink-base-uri, parameterized-uri, pause, piece-length and rpc-save-upload-metadata option. This method returns OK for success.
|
||||
|
||||
```
|
||||
func (id *Client) ChangePosition(gid string, pos int, how string) (p int, err error)
|
||||
```
|
||||
|
||||
`aria2.changePosition(gid, pos, how)` This method changes the position of the download denoted by `gid`. `pos` is of type integer. `how` is of type string. If `how` is `POS_SET`, it moves the download to a position relative to the beginning of the queue. If `how` is `POS_CUR`, it moves the download to a position relative to the current position. If `how` is `POS_END`, it moves the download to a position relative to the end of the queue. If the destination position is less than 0 or beyond the end
|
||||
of the queue, it moves the download to the beginning or the end of the queue respectively. The response is of type integer and it is the destination position.
|
||||
|
||||
```
|
||||
func (id *Client) ChangeUri(gid string, fileindex int, delUris []string, addUris []string, position ...int) (p []int, err error)
|
||||
```
|
||||
|
||||
`aria2.changeUri(gid, fileIndex, delUris, addUris[, position])` This method removes URIs in `delUris` from and appends URIs in `addUris` to download denoted by gid. `delUris` and `addUris` are list of string. A download can contain multiple files and URIs are attached to each file. `fileIndex` is used to select which file to remove/attach given URIs. `fileIndex` is 1-based. `position` is used to specify where URIs are inserted in the existing waiting URI list. `position` is 0-based. When
|
||||
`position` is omitted, URIs are appended to the back of the list. This method first execute removal and then addition. `position` is the `position` after URIs are removed, not the `position` when this method is called. When removing URI, if same URIs exist in download, only one of them is removed for each URI in delUris. In other words, there are three URIs http://example.org/aria2 and you want remove them all, you
|
||||
have to specify (at least) 3 http://example.org/aria2 in delUris. This method returns a list which contains 2 integers. The first integer is the number of URIs deleted. The second integer is the number of URIs added.
|
||||
|
||||
```
|
||||
func (id *Client) ForcePause(gid string) (g string, err error)
|
||||
```
|
||||
|
||||
`aria2.forcePause(pid)` This method pauses the download denoted by `gid`. This method behaves just like aria2.pause() except that this method pauses download without any action which takes time such as contacting BitTorrent tracker.
|
||||
|
||||
```
|
||||
func (id *Client) ForcePauseAll() (g string, err error)
|
||||
```
|
||||
|
||||
`aria2.forcePauseAll()` This method is equal to calling `aria2.forcePause()` for every active/waiting download. This methods returns OK for success.
|
||||
|
||||
```
|
||||
func (id *Client) ForceRemove(gid string) (g string, err error)
|
||||
```
|
||||
|
||||
`aria2.forceRemove(gid)` This method removes the download denoted by `gid`. This method behaves just like aria2.remove() except that this method removes download without any action which takes time such as contacting BitTorrent tracker.
|
||||
|
||||
```
|
||||
func (id *Client) ForceShutdown() (g string, err error)
|
||||
```
|
||||
|
||||
`aria2.forceShutdown()` This method shutdowns aria2. This method behaves like `aria2.shutdown()` except that any actions which takes time such as contacting BitTorrent tracker are skipped. This method returns OK.
|
||||
|
||||
```
|
||||
func (id *Client) GetFiles(gid string) (m map[string]interface{}, err error)
|
||||
```
|
||||
|
||||
`aria2.getFiles(gid)` This method returns file list of the download denoted by `gid`. `gid` is of type string.
|
||||
|
||||
```
|
||||
func (id *Client) GetGlobalOption() (m map[string]interface{}, err error)
|
||||
```
|
||||
|
||||
`aria2.getGlobalOption()` This method returns global options. The response is of type struct. Its key is the name of option. The value type is string. Note that this method does not return options which have no default value and have not been set by the command-line options, configuration files or RPC methods. Because global options are used as a template for the options of newly added download, the response contains
|
||||
keys returned by `aria2.getOption()` method.
|
||||
|
||||
```
|
||||
func (id *Client) GetGlobalStat() (m map[string]interface{}, err error)
|
||||
```
|
||||
|
||||
`aria2.getGlobalStat()` This method returns global statistics such as overall download and upload speed.
|
||||
|
||||
```
|
||||
func (id *Client) GetOption(gid string) (m map[string]interface{}, err error)
|
||||
```
|
||||
|
||||
`aria2.getOption(gid)` This method returns options of the download denoted by `gid`. The response is of type struct. Its key is the name of option. The value type is string. Note that this method does not return options which have no default value and have not been set by the command-line options, configuration files or RPC methods.
|
||||
|
||||
```
|
||||
func (id *Client) GetPeers(gid string) (m []map[string]interface{}, err error)
|
||||
```
|
||||
|
||||
`aria2.getPeers(gid)` This method returns peer list of the download denoted by `gid`. `gid` is of type string. This method is for BitTorrent only.
|
||||
|
||||
```
|
||||
func (id *Client) GetServers(gid string) (m []map[string]interface{}, err error)
|
||||
```
|
||||
|
||||
`aria2.getServers(gid)` This method returns currently connected HTTP(S)/FTP servers of the download denoted by `gid`. `gid` is of type string.
|
||||
|
||||
```
|
||||
func (id *Client) GetSessionInfo() (m map[string]interface{}, err error)
|
||||
```
|
||||
|
||||
`aria2.getSessionInfo()` This method returns session information.
|
||||
|
||||
```
|
||||
func (id *Client) GetUris(gid string) (m map[string]interface{}, err error)
|
||||
```
|
||||
|
||||
`aria2.getUris(gid)` This method returns URIs used in the download denoted by `gid`. `gid` is of type string.
|
||||
|
||||
```
|
||||
func (id *Client) GetVersion() (m map[string]interface{}, err error)
|
||||
```
|
||||
|
||||
`aria2.getVersion()` This method returns version of the program and the list of enabled features.
|
||||
|
||||
```
|
||||
func (id *Client) Multicall(methods []map[string]interface{}) (r []interface{}, err error)
|
||||
```
|
||||
|
||||
`system.multicall(methods)` This method encapsulates multiple method calls in a single request. `methods` is of type array and its element is struct. The struct contains two keys: `methodName` and `params`. `methodName` is the method name to call and `params` is array containing parameters to the method. This method returns array of responses. The element of array will either be a one-item array containing the return value of each method call or struct of fault element if an encapsulated method call fails.
|
||||
|
||||
```
|
||||
func (id *Client) Pause(gid string) (g string, err error)
|
||||
```
|
||||
|
||||
`aria2.pause(gid)` This method pauses the download denoted by `gid`. `gid` is of type string. The status of paused download becomes paused. If the download is active, the download is placed on the first position of waiting queue. As long as the status is paused, the download is not started. To change status to waiting, use `aria2.unpause()` method. This method returns GID of paused download.
|
||||
|
||||
```
|
||||
func (id *Client) PauseAll() (g string, err error)
|
||||
```
|
||||
|
||||
`aria2.pauseAll()` This method is equal to calling `aria2.pause()` for every active/waiting download. This methods returns OK for success.
|
||||
|
||||
```
|
||||
func (id *Client) PurgeDowloadResult() (g string, err error)
|
||||
```
|
||||
|
||||
`aria2.purgeDownloadResult()` This method purges completed/error/removed downloads to free memory. This method returns OK.
|
||||
|
||||
```
|
||||
func (id *Client) Remove(gid string) (g string, err error)
|
||||
```
|
||||
|
||||
`aria2.remove(gid)` This method removes the download denoted by gid. `gid` is of type string. If specified download is in progress, it is stopped at first. The status of removed download becomes removed. This method returns GID of removed download.
|
||||
|
||||
```
|
||||
func (id *Client) RemoveDownloadResult(gid string) (g string, err error)
|
||||
```
|
||||
|
||||
`aria2.removeDownloadResult(gid)` This method removes completed/error/removed download denoted by `gid` from memory. This method returns OK for success.
|
||||
|
||||
```
|
||||
func (id *Client) Shutdown() (g string, err error)
|
||||
```
|
||||
|
||||
`aria2.shutdown()` This method shutdowns aria2. This method returns OK.
|
||||
|
||||
```
|
||||
func (id *Client) TellActive(keys ...string) (m []map[string]interface{}, err error)
|
||||
```
|
||||
|
||||
`aria2.tellActive([keys])` This method returns the list of active downloads. The response is of type array and its element is the same struct returned by `aria2.tellStatus()` method. For `keys` parameter, please refer to `aria2.tellStatus()` method.
|
||||
|
||||
```
|
||||
func (id *Client) TellStatus(gid string, keys ...string) (m map[string]interface{}, err error)
|
||||
```
|
||||
|
||||
`aria2.tellStatus(gid[, keys])` This method returns download progress of the download denoted by `gid`. `gid` is of type string. `keys` is array of string. If it is specified, the response contains only keys in `keys` array. If `keys` is empty or not specified, the response contains all keys. This is useful when you just want specific keys and avoid unnecessary transfers. For example, `aria2.tellStatus("2089b05ecca3d829", ["gid", "status"])` returns `gid` and `status` key.
|
||||
|
||||
```
|
||||
func (id *Client) TellStopped(offset, num int, keys ...string) (m []map[string]interface{}, err error)
|
||||
```
|
||||
|
||||
`aria2.tellStopped(offset, num[, keys])` This method returns the list of stopped download. `offset` is of type integer and specifies the `offset` from the oldest download. `num` is of type integer and specifies the number of downloads to be returned. For keys parameter, please refer to `aria2.tellStatus()` method. `offset` and `num` have the same semantics as `aria2.tellWaiting()` method. The response is of type array and its element is the same struct returned by `aria2.tellStatus()` method.
|
||||
|
||||
```
|
||||
func (id *Client) TellWaiting(offset, num int, keys ...string) (m []map[string]interface{}, err error)
|
||||
```
|
||||
`aria2.tellWaiting(offset, num[, keys])` This method returns the list of waiting download, including paused downloads. `offset` is of type integer and specifies the `offset` from the download waiting at the front. num is of type integer and specifies the number of downloads to be returned. For keys parameter, please refer to aria2.tellStatus() method. If `offset` is a positive integer, this method returns downloads
|
||||
in the range of `[offset, offset + num)`. `offset` can be a negative integer. `offset == -1` points last download in the waiting queue and `offset == -2` points the download before the last download, and so on. The downloads in the response are in reversed order. For example, imagine that three downloads "A","B" and "C" are waiting in this order.
|
||||
|
||||
aria2.tellWaiting(0, 1) returns ["A"].
|
||||
aria2.tellWaiting(1, 2) returns ["B", "C"].
|
||||
aria2.tellWaiting(-1, 2) returns ["C", "B"].
|
||||
|
||||
The response is of type array and its element is the same struct returned by `aria2.tellStatus()` method.
|
||||
|
||||
```
|
||||
func (id *Client) Unpause(gid string) (g string, err error)
|
||||
```
|
||||
|
||||
`aria2.unpause(gid)` This method changes the status of the download denoted by `gid` from paused to waiting. This makes the download eligible to restart. `gid` is of type string. This method returns GID of unpaused download.
|
||||
|
||||
```
|
||||
func (id *Client) UnpauseAll() (g string, err error)
|
||||
```
|
||||
|
||||
`aria2.unpauseAll()` This method is equal to calling `aria2.unpause()` for every active/waiting download. This methods returns OK for success.
|
274
pkg/aria2/rpc/call.go
Normal file
274
pkg/aria2/rpc/call.go
Normal file
@ -0,0 +1,274 @@
|
||||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
type caller interface {
|
||||
// Call sends a request of rpc to aria2 daemon
|
||||
Call(method string, params, reply interface{}) (err error)
|
||||
Close() error
|
||||
}
|
||||
|
||||
type httpCaller struct {
|
||||
uri string
|
||||
c *http.Client
|
||||
cancel context.CancelFunc
|
||||
wg *sync.WaitGroup
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func newHTTPCaller(ctx context.Context, u *url.URL, timeout time.Duration, notifer Notifier) *httpCaller {
|
||||
c := &http.Client{
|
||||
Transport: &http.Transport{
|
||||
MaxIdleConnsPerHost: 1,
|
||||
MaxConnsPerHost: 1,
|
||||
// TLSClientConfig: tlsConfig,
|
||||
Dial: (&net.Dialer{
|
||||
Timeout: timeout,
|
||||
KeepAlive: 60 * time.Second,
|
||||
}).Dial,
|
||||
TLSHandshakeTimeout: 3 * time.Second,
|
||||
ResponseHeaderTimeout: timeout,
|
||||
},
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
h := &httpCaller{uri: u.String(), c: c, cancel: cancel, wg: &wg}
|
||||
if notifer != nil {
|
||||
h.setNotifier(ctx, *u, notifer)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *httpCaller) Close() (err error) {
|
||||
h.once.Do(func() {
|
||||
h.cancel()
|
||||
h.wg.Wait()
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
func (h *httpCaller) setNotifier(ctx context.Context, u url.URL, notifer Notifier) (err error) {
|
||||
u.Scheme = "ws"
|
||||
conn, _, err := websocket.DefaultDialer.Dial(u.String(), nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
h.wg.Add(1)
|
||||
go func() {
|
||||
defer h.wg.Done()
|
||||
defer conn.Close()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
conn.SetWriteDeadline(time.Now().Add(time.Second))
|
||||
if err := conn.WriteMessage(websocket.CloseMessage,
|
||||
websocket.FormatCloseMessage(websocket.CloseNormalClosure, "")); err != nil {
|
||||
log.Printf("sending websocket close message: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
}()
|
||||
h.wg.Add(1)
|
||||
go func() {
|
||||
defer h.wg.Done()
|
||||
var request websocketResponse
|
||||
var err error
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
if err = conn.ReadJSON(&request); err != nil {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
log.Printf("conn.ReadJSON|err:%v", err.Error())
|
||||
return
|
||||
}
|
||||
switch request.Method {
|
||||
case "aria2.onDownloadStart":
|
||||
notifer.OnDownloadStart(request.Params)
|
||||
case "aria2.onDownloadPause":
|
||||
notifer.OnDownloadPause(request.Params)
|
||||
case "aria2.onDownloadStop":
|
||||
notifer.OnDownloadStop(request.Params)
|
||||
case "aria2.onDownloadComplete":
|
||||
notifer.OnDownloadComplete(request.Params)
|
||||
case "aria2.onDownloadError":
|
||||
notifer.OnDownloadError(request.Params)
|
||||
case "aria2.onBtDownloadComplete":
|
||||
notifer.OnBtDownloadComplete(request.Params)
|
||||
default:
|
||||
log.Printf("unexpected notification: %s", request.Method)
|
||||
}
|
||||
}
|
||||
}()
|
||||
return
|
||||
}
|
||||
|
||||
func (h httpCaller) Call(method string, params, reply interface{}) (err error) {
|
||||
payload, err := EncodeClientRequest(method, params)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
r, err := h.c.Post(h.uri, "application/json", payload)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
err = DecodeClientResponse(r.Body, &reply)
|
||||
r.Body.Close()
|
||||
return
|
||||
}
|
||||
|
||||
type websocketCaller struct {
|
||||
conn *websocket.Conn
|
||||
sendChan chan *sendRequest
|
||||
cancel context.CancelFunc
|
||||
wg *sync.WaitGroup
|
||||
once sync.Once
|
||||
timeout time.Duration
|
||||
}
|
||||
|
||||
func newWebsocketCaller(ctx context.Context, uri string, timeout time.Duration, notifier Notifier) (*websocketCaller, error) {
|
||||
var header = http.Header{}
|
||||
conn, _, err := websocket.DefaultDialer.Dial(uri, header)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sendChan := make(chan *sendRequest, 16)
|
||||
var wg sync.WaitGroup
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
w := &websocketCaller{conn: conn, wg: &wg, cancel: cancel, sendChan: sendChan, timeout: timeout}
|
||||
processor := NewResponseProcessor()
|
||||
wg.Add(1)
|
||||
go func() { // routine:recv
|
||||
defer wg.Done()
|
||||
defer cancel()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
var resp websocketResponse
|
||||
if err := conn.ReadJSON(&resp); err != nil {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
log.Printf("conn.ReadJSON|err:%v", err.Error())
|
||||
return
|
||||
}
|
||||
if resp.Id == nil { // RPC notifications
|
||||
if notifier != nil {
|
||||
switch resp.Method {
|
||||
case "aria2.onDownloadStart":
|
||||
notifier.OnDownloadStart(resp.Params)
|
||||
case "aria2.onDownloadPause":
|
||||
notifier.OnDownloadPause(resp.Params)
|
||||
case "aria2.onDownloadStop":
|
||||
notifier.OnDownloadStop(resp.Params)
|
||||
case "aria2.onDownloadComplete":
|
||||
notifier.OnDownloadComplete(resp.Params)
|
||||
case "aria2.onDownloadError":
|
||||
notifier.OnDownloadError(resp.Params)
|
||||
case "aria2.onBtDownloadComplete":
|
||||
notifier.OnBtDownloadComplete(resp.Params)
|
||||
default:
|
||||
log.Printf("unexpected notification: %s", resp.Method)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
processor.Process(resp.clientResponse)
|
||||
}
|
||||
}()
|
||||
wg.Add(1)
|
||||
go func() { // routine:send
|
||||
defer wg.Done()
|
||||
defer cancel()
|
||||
defer w.conn.Close()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
if err := w.conn.WriteMessage(websocket.CloseMessage,
|
||||
websocket.FormatCloseMessage(websocket.CloseNormalClosure, "")); err != nil {
|
||||
log.Printf("sending websocket close message: %v", err)
|
||||
}
|
||||
return
|
||||
case req := <-sendChan:
|
||||
processor.Add(req.request.Id, func(resp clientResponse) error {
|
||||
err := resp.decode(req.reply)
|
||||
req.cancel()
|
||||
return err
|
||||
})
|
||||
w.conn.SetWriteDeadline(time.Now().Add(timeout))
|
||||
w.conn.WriteJSON(req.request)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return w, nil
|
||||
}
|
||||
|
||||
func (w *websocketCaller) Close() (err error) {
|
||||
w.once.Do(func() {
|
||||
w.cancel()
|
||||
w.wg.Wait()
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
func (w websocketCaller) Call(method string, params, reply interface{}) (err error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), w.timeout)
|
||||
defer cancel()
|
||||
select {
|
||||
case w.sendChan <- &sendRequest{cancel: cancel, request: &clientRequest{
|
||||
Version: "2.0",
|
||||
Method: method,
|
||||
Params: params,
|
||||
Id: reqid(),
|
||||
}, reply: reply}:
|
||||
|
||||
default:
|
||||
return errors.New("sending channel blocking")
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
if err := ctx.Err(); err == context.DeadlineExceeded {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
type sendRequest struct {
|
||||
cancel context.CancelFunc
|
||||
request *clientRequest
|
||||
reply interface{}
|
||||
}
|
||||
|
||||
var reqid = func() func() uint64 {
|
||||
var id = uint64(time.Now().UnixNano())
|
||||
return func() uint64 {
|
||||
return atomic.AddUint64(&id, 1)
|
||||
}
|
||||
}()
|
656
pkg/aria2/rpc/client.go
Normal file
656
pkg/aria2/rpc/client.go
Normal file
@ -0,0 +1,656 @@
|
||||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"io/ioutil"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Option is a container for specifying Call parameters and returning results
|
||||
type Option map[string]interface{}
|
||||
|
||||
type Client interface {
|
||||
Protocol
|
||||
Close() error
|
||||
}
|
||||
|
||||
type client struct {
|
||||
caller
|
||||
url *url.URL
|
||||
token string
|
||||
}
|
||||
|
||||
var (
|
||||
errInvalidParameter = errors.New("invalid parameter")
|
||||
errNotImplemented = errors.New("not implemented")
|
||||
errConnTimeout = errors.New("connect to aria2 daemon timeout")
|
||||
)
|
||||
|
||||
// New returns an instance of Client
|
||||
func New(ctx context.Context, uri string, token string, timeout time.Duration, notifier Notifier) (Client, error) {
|
||||
u, err := url.Parse(uri)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var caller caller
|
||||
switch u.Scheme {
|
||||
case "http", "https":
|
||||
caller = newHTTPCaller(ctx, u, timeout, notifier)
|
||||
case "ws", "wss":
|
||||
caller, err = newWebsocketCaller(ctx, u.String(), timeout, notifier)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
default:
|
||||
return nil, errInvalidParameter
|
||||
}
|
||||
c := &client{caller: caller, url: u, token: token}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// `aria2.addUri([secret, ]uris[, options[, position]])`
|
||||
// This method adds a new download. uris is an array of HTTP/FTP/SFTP/BitTorrent URIs (strings) pointing to the same resource.
|
||||
// If you mix URIs pointing to different resources, then the download may fail or be corrupted without aria2 complaining.
|
||||
// When adding BitTorrent Magnet URIs, uris must have only one element and it should be BitTorrent Magnet URI.
|
||||
// options is a struct and its members are pairs of option name and value.
|
||||
// If position is given, it must be an integer starting from 0.
|
||||
// The new download will be inserted at position in the waiting queue.
|
||||
// If position is omitted or position is larger than the current size of the queue, the new download is appended to the end of the queue.
|
||||
// This method returns the GID of the newly registered download.
|
||||
func (c *client) AddURI(uri string, options ...interface{}) (gid string, err error) {
|
||||
params := make([]interface{}, 0, 2)
|
||||
if c.token != "" {
|
||||
params = append(params, "token:"+c.token)
|
||||
}
|
||||
params = append(params, []string{uri})
|
||||
if options != nil {
|
||||
params = append(params, options...)
|
||||
}
|
||||
err = c.Call(aria2AddURI, params, &gid)
|
||||
return
|
||||
}
|
||||
|
||||
// `aria2.addTorrent([secret, ]torrent[, uris[, options[, position]]])`
|
||||
// This method adds a BitTorrent download by uploading a ".torrent" file.
|
||||
// If you want to add a BitTorrent Magnet URI, use the aria2.addUri() method instead.
|
||||
// torrent must be a base64-encoded string containing the contents of the ".torrent" file.
|
||||
// uris is an array of URIs (string). uris is used for Web-seeding.
|
||||
// For single file torrents, the URI can be a complete URI pointing to the resource; if URI ends with /, name in torrent file is added.
|
||||
// For multi-file torrents, name and path in torrent are added to form a URI for each file. options is a struct and its members are pairs of option name and value.
|
||||
// If position is given, it must be an integer starting from 0.
|
||||
// The new download will be inserted at position in the waiting queue.
|
||||
// If position is omitted or position is larger than the current size of the queue, the new download is appended to the end of the queue.
|
||||
// This method returns the GID of the newly registered download.
|
||||
// If --rpc-save-upload-metadata is true, the uploaded data is saved as a file named as the hex string of SHA-1 hash of data plus ".torrent" in the directory specified by --dir option.
|
||||
// E.g. a file name might be 0a3893293e27ac0490424c06de4d09242215f0a6.torrent.
|
||||
// If a file with the same name already exists, it is overwritten!
|
||||
// If the file cannot be saved successfully or --rpc-save-upload-metadata is false, the downloads added by this method are not saved by --save-session.
|
||||
func (c *client) AddTorrent(filename string, options ...interface{}) (gid string, err error) {
|
||||
co, err := ioutil.ReadFile(filename)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
file := base64.StdEncoding.EncodeToString(co)
|
||||
params := make([]interface{}, 0, 2)
|
||||
if c.token != "" {
|
||||
params = append(params, "token:"+c.token)
|
||||
}
|
||||
params = append(params, file)
|
||||
if options != nil {
|
||||
params = append(params, options...)
|
||||
}
|
||||
err = c.Call(aria2AddTorrent, params, &gid)
|
||||
return
|
||||
}
|
||||
|
||||
// `aria2.addMetalink([secret, ]metalink[, options[, position]])`
|
||||
// This method adds a Metalink download by uploading a ".metalink" file.
|
||||
// metalink is a base64-encoded string which contains the contents of the ".metalink" file.
|
||||
// options is a struct and its members are pairs of option name and value.
|
||||
// If position is given, it must be an integer starting from 0.
|
||||
// The new download will be inserted at position in the waiting queue.
|
||||
// If position is omitted or position is larger than the current size of the queue, the new download is appended to the end of the queue.
|
||||
// This method returns an array of GIDs of newly registered downloads.
|
||||
// If --rpc-save-upload-metadata is true, the uploaded data is saved as a file named hex string of SHA-1 hash of data plus ".metalink" in the directory specified by --dir option.
|
||||
// E.g. a file name might be 0a3893293e27ac0490424c06de4d09242215f0a6.metalink.
|
||||
// If a file with the same name already exists, it is overwritten!
|
||||
// If the file cannot be saved successfully or --rpc-save-upload-metadata is false, the downloads added by this method are not saved by --save-session.
|
||||
func (c *client) AddMetalink(filename string, options ...interface{}) (gid []string, err error) {
|
||||
co, err := ioutil.ReadFile(filename)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
file := base64.StdEncoding.EncodeToString(co)
|
||||
params := make([]interface{}, 0, 2)
|
||||
if c.token != "" {
|
||||
params = append(params, "token:"+c.token)
|
||||
}
|
||||
params = append(params, file)
|
||||
if options != nil {
|
||||
params = append(params, options...)
|
||||
}
|
||||
err = c.Call(aria2AddMetalink, params, &gid)
|
||||
return
|
||||
}
|
||||
|
||||
// `aria2.remove([secret, ]gid)`
|
||||
// This method removes the download denoted by gid (string).
|
||||
// If the specified download is in progress, it is first stopped.
|
||||
// The status of the removed download becomes removed.
|
||||
// This method returns GID of removed download.
|
||||
func (c *client) Remove(gid string) (g string, err error) {
|
||||
params := make([]interface{}, 0, 2)
|
||||
if c.token != "" {
|
||||
params = append(params, "token:"+c.token)
|
||||
}
|
||||
params = append(params, gid)
|
||||
err = c.Call(aria2Remove, params, &g)
|
||||
return
|
||||
}
|
||||
|
||||
// `aria2.forceRemove([secret, ]gid)`
|
||||
// This method removes the download denoted by gid.
|
||||
// This method behaves just like aria2.remove() except that this method removes the download without performing any actions which take time, such as contacting BitTorrent trackers to unregister the download first.
|
||||
func (c *client) ForceRemove(gid string) (g string, err error) {
|
||||
params := make([]interface{}, 0, 2)
|
||||
if c.token != "" {
|
||||
params = append(params, "token:"+c.token)
|
||||
}
|
||||
params = append(params, gid)
|
||||
err = c.Call(aria2ForceRemove, params, &g)
|
||||
return
|
||||
}
|
||||
|
||||
// `aria2.pause([secret, ]gid)`
|
||||
// This method pauses the download denoted by gid (string).
|
||||
// The status of paused download becomes paused.
|
||||
// If the download was active, the download is placed in the front of waiting queue.
|
||||
// While the status is paused, the download is not started.
|
||||
// To change status to waiting, use the aria2.unpause() method.
|
||||
// This method returns GID of paused download.
|
||||
func (c *client) Pause(gid string) (g string, err error) {
|
||||
params := make([]interface{}, 0, 2)
|
||||
if c.token != "" {
|
||||
params = append(params, "token:"+c.token)
|
||||
}
|
||||
params = append(params, gid)
|
||||
err = c.Call(aria2Pause, params, &g)
|
||||
return
|
||||
}
|
||||
|
||||
// `aria2.pauseAll([secret])`
|
||||
// This method is equal to calling aria2.pause() for every active/waiting download.
|
||||
// This methods returns OK.
|
||||
func (c *client) PauseAll() (ok string, err error) {
|
||||
params := []string{}
|
||||
if c.token != "" {
|
||||
params = append(params, "token:"+c.token)
|
||||
}
|
||||
err = c.Call(aria2PauseAll, params, &ok)
|
||||
return
|
||||
}
|
||||
|
||||
// `aria2.forcePause([secret, ]gid)`
|
||||
// This method pauses the download denoted by gid.
|
||||
// This method behaves just like aria2.pause() except that this method pauses downloads without performing any actions which take time, such as contacting BitTorrent trackers to unregister the download first.
|
||||
func (c *client) ForcePause(gid string) (g string, err error) {
|
||||
params := make([]interface{}, 0, 2)
|
||||
if c.token != "" {
|
||||
params = append(params, "token:"+c.token)
|
||||
}
|
||||
params = append(params, gid)
|
||||
err = c.Call(aria2ForcePause, params, &g)
|
||||
return
|
||||
}
|
||||
|
||||
// `aria2.forcePauseAll([secret])`
|
||||
// This method is equal to calling aria2.forcePause() for every active/waiting download.
|
||||
// This methods returns OK.
|
||||
func (c *client) ForcePauseAll() (ok string, err error) {
|
||||
params := []string{}
|
||||
if c.token != "" {
|
||||
params = append(params, "token:"+c.token)
|
||||
}
|
||||
err = c.Call(aria2ForcePauseAll, params, &ok)
|
||||
return
|
||||
}
|
||||
|
||||
// `aria2.unpause([secret, ]gid)`
|
||||
// This method changes the status of the download denoted by gid (string) from paused to waiting, making the download eligible to be restarted.
|
||||
// This method returns the GID of the unpaused download.
|
||||
func (c *client) Unpause(gid string) (g string, err error) {
|
||||
params := make([]interface{}, 0, 2)
|
||||
if c.token != "" {
|
||||
params = append(params, "token:"+c.token)
|
||||
}
|
||||
params = append(params, gid)
|
||||
err = c.Call(aria2Unpause, params, &g)
|
||||
return
|
||||
}
|
||||
|
||||
// `aria2.unpauseAll([secret])`
|
||||
// This method is equal to calling aria2.unpause() for every active/waiting download.
|
||||
// This methods returns OK.
|
||||
func (c *client) UnpauseAll() (ok string, err error) {
|
||||
params := []string{}
|
||||
if c.token != "" {
|
||||
params = append(params, "token:"+c.token)
|
||||
}
|
||||
err = c.Call(aria2UnpauseAll, params, &ok)
|
||||
return
|
||||
}
|
||||
|
||||
// `aria2.tellStatus([secret, ]gid[, keys])`
|
||||
// This method returns the progress of the download denoted by gid (string).
|
||||
// keys is an array of strings.
|
||||
// If specified, the response contains only keys in the keys array.
|
||||
// If keys is empty or omitted, the response contains all keys.
|
||||
// This is useful when you just want specific keys and avoid unnecessary transfers.
|
||||
// For example, aria2.tellStatus("2089b05ecca3d829", ["gid", "status"]) returns the gid and status keys only.
|
||||
// The response is a struct and contains following keys. Values are strings.
|
||||
// https://aria2.github.io/manual/en/html/aria2c.html#aria2.tellStatus
|
||||
func (c *client) TellStatus(gid string, keys ...string) (info StatusInfo, err error) {
|
||||
params := make([]interface{}, 0, 2)
|
||||
if c.token != "" {
|
||||
params = append(params, "token:"+c.token)
|
||||
}
|
||||
params = append(params, gid)
|
||||
if keys != nil {
|
||||
params = append(params, keys)
|
||||
}
|
||||
err = c.Call(aria2TellStatus, params, &info)
|
||||
return
|
||||
}
|
||||
|
||||
// `aria2.getUris([secret, ]gid)`
|
||||
// This method returns the URIs used in the download denoted by gid (string).
|
||||
// The response is an array of structs and it contains following keys. Values are string.
|
||||
// uri URI
|
||||
// status 'used' if the URI is in use. 'waiting' if the URI is still waiting in the queue.
|
||||
func (c *client) GetURIs(gid string) (infos []URIInfo, err error) {
|
||||
params := make([]interface{}, 0, 2)
|
||||
if c.token != "" {
|
||||
params = append(params, "token:"+c.token)
|
||||
}
|
||||
params = append(params, gid)
|
||||
err = c.Call(aria2GetURIs, params, &infos)
|
||||
return
|
||||
}
|
||||
|
||||
// `aria2.getFiles([secret, ]gid)`
|
||||
// This method returns the file list of the download denoted by gid (string).
|
||||
// The response is an array of structs which contain following keys. Values are strings.
|
||||
// https://aria2.github.io/manual/en/html/aria2c.html#aria2.getFiles
|
||||
func (c *client) GetFiles(gid string) (infos []FileInfo, err error) {
|
||||
params := make([]interface{}, 0, 2)
|
||||
if c.token != "" {
|
||||
params = append(params, "token:"+c.token)
|
||||
}
|
||||
params = append(params, gid)
|
||||
err = c.Call(aria2GetFiles, params, &infos)
|
||||
return
|
||||
}
|
||||
|
||||
// `aria2.getPeers([secret, ]gid)`
|
||||
// This method returns a list peers of the download denoted by gid (string).
|
||||
// This method is for BitTorrent only.
|
||||
// The response is an array of structs and contains the following keys. Values are strings.
|
||||
// https://aria2.github.io/manual/en/html/aria2c.html#aria2.getPeers
|
||||
func (c *client) GetPeers(gid string) (infos []PeerInfo, err error) {
|
||||
params := make([]interface{}, 0, 2)
|
||||
if c.token != "" {
|
||||
params = append(params, "token:"+c.token)
|
||||
}
|
||||
params = append(params, gid)
|
||||
err = c.Call(aria2GetPeers, params, &infos)
|
||||
return
|
||||
}
|
||||
|
||||
// `aria2.getServers([secret, ]gid)`
|
||||
// This method returns currently connected HTTP(S)/FTP/SFTP servers of the download denoted by gid (string).
|
||||
// The response is an array of structs and contains the following keys. Values are strings.
|
||||
// https://aria2.github.io/manual/en/html/aria2c.html#aria2.getServers
|
||||
func (c *client) GetServers(gid string) (infos []ServerInfo, err error) {
|
||||
params := make([]interface{}, 0, 2)
|
||||
if c.token != "" {
|
||||
params = append(params, "token:"+c.token)
|
||||
}
|
||||
params = append(params, gid)
|
||||
err = c.Call(aria2GetServers, params, &infos)
|
||||
return
|
||||
}
|
||||
|
||||
// `aria2.tellActive([secret][, keys])`
|
||||
// This method returns a list of active downloads.
|
||||
// The response is an array of the same structs as returned by the aria2.tellStatus() method.
|
||||
// For the keys parameter, please refer to the aria2.tellStatus() method.
|
||||
func (c *client) TellActive(keys ...string) (infos []StatusInfo, err error) {
|
||||
params := make([]interface{}, 0, 1)
|
||||
if c.token != "" {
|
||||
params = append(params, "token:"+c.token)
|
||||
}
|
||||
if keys != nil {
|
||||
params = append(params, keys)
|
||||
}
|
||||
err = c.Call(aria2TellActive, params, &infos)
|
||||
return
|
||||
}
|
||||
|
||||
// `aria2.tellWaiting([secret, ]offset, num[, keys])`
|
||||
// This method returns a list of waiting downloads, including paused ones.
|
||||
// offset is an integer and specifies the offset from the download waiting at the front.
|
||||
// num is an integer and specifies the max. number of downloads to be returned.
|
||||
// For the keys parameter, please refer to the aria2.tellStatus() method.
|
||||
// If offset is a positive integer, this method returns downloads in the range of [offset, offset + num).
|
||||
// offset can be a negative integer. offset == -1 points last download in the waiting queue and offset == -2 points the download before the last download, and so on.
|
||||
// Downloads in the response are in reversed order then.
|
||||
// For example, imagine three downloads "A","B" and "C" are waiting in this order.
|
||||
// aria2.tellWaiting(0, 1) returns ["A"].
|
||||
// aria2.tellWaiting(1, 2) returns ["B", "C"].
|
||||
// aria2.tellWaiting(-1, 2) returns ["C", "B"].
|
||||
// The response is an array of the same structs as returned by aria2.tellStatus() method.
|
||||
func (c *client) TellWaiting(offset, num int, keys ...string) (infos []StatusInfo, err error) {
|
||||
params := make([]interface{}, 0, 3)
|
||||
if c.token != "" {
|
||||
params = append(params, "token:"+c.token)
|
||||
}
|
||||
params = append(params, offset)
|
||||
params = append(params, num)
|
||||
if keys != nil {
|
||||
params = append(params, keys)
|
||||
}
|
||||
err = c.Call(aria2TellWaiting, params, &infos)
|
||||
return
|
||||
}
|
||||
|
||||
// `aria2.tellStopped([secret, ]offset, num[, keys])`
|
||||
// This method returns a list of stopped downloads.
|
||||
// offset is an integer and specifies the offset from the least recently stopped download.
|
||||
// num is an integer and specifies the max. number of downloads to be returned.
|
||||
// For the keys parameter, please refer to the aria2.tellStatus() method.
|
||||
// offset and num have the same semantics as described in the aria2.tellWaiting() method.
|
||||
// The response is an array of the same structs as returned by the aria2.tellStatus() method.
|
||||
func (c *client) TellStopped(offset, num int, keys ...string) (infos []StatusInfo, err error) {
|
||||
params := make([]interface{}, 0, 3)
|
||||
if c.token != "" {
|
||||
params = append(params, "token:"+c.token)
|
||||
}
|
||||
params = append(params, offset)
|
||||
params = append(params, num)
|
||||
if keys != nil {
|
||||
params = append(params, keys)
|
||||
}
|
||||
err = c.Call(aria2TellStopped, params, &infos)
|
||||
return
|
||||
}
|
||||
|
||||
// `aria2.changePosition([secret, ]gid, pos, how)`
|
||||
// This method changes the position of the download denoted by gid in the queue.
|
||||
// pos is an integer. how is a string.
|
||||
// If how is POS_SET, it moves the download to a position relative to the beginning of the queue.
|
||||
// If how is POS_CUR, it moves the download to a position relative to the current position.
|
||||
// If how is POS_END, it moves the download to a position relative to the end of the queue.
|
||||
// If the destination position is less than 0 or beyond the end of the queue, it moves the download to the beginning or the end of the queue respectively.
|
||||
// The response is an integer denoting the resulting position.
|
||||
// For example, if GID#2089b05ecca3d829 is currently in position 3, aria2.changePosition('2089b05ecca3d829', -1, 'POS_CUR') will change its position to 2. Additionally aria2.changePosition('2089b05ecca3d829', 0, 'POS_SET') will change its position to 0 (the beginning of the queue).
|
||||
func (c *client) ChangePosition(gid string, pos int, how string) (p int, err error) {
|
||||
params := make([]interface{}, 0, 3)
|
||||
if c.token != "" {
|
||||
params = append(params, "token:"+c.token)
|
||||
}
|
||||
params = append(params, gid)
|
||||
params = append(params, pos)
|
||||
params = append(params, how)
|
||||
err = c.Call(aria2ChangePosition, params, &p)
|
||||
return
|
||||
}
|
||||
|
||||
// `aria2.changeUri([secret, ]gid, fileIndex, delUris, addUris[, position])`
|
||||
// This method removes the URIs in delUris from and appends the URIs in addUris to download denoted by gid.
|
||||
// delUris and addUris are lists of strings.
|
||||
// A download can contain multiple files and URIs are attached to each file.
|
||||
// fileIndex is used to select which file to remove/attach given URIs. fileIndex is 1-based.
|
||||
// position is used to specify where URIs are inserted in the existing waiting URI list. position is 0-based.
|
||||
// When position is omitted, URIs are appended to the back of the list.
|
||||
// This method first executes the removal and then the addition.
|
||||
// position is the position after URIs are removed, not the position when this method is called.
|
||||
// When removing an URI, if the same URIs exist in download, only one of them is removed for each URI in delUris.
|
||||
// In other words, if there are three URIs http://example.org/aria2 and you want remove them all, you have to specify (at least) 3 http://example.org/aria2 in delUris.
|
||||
// This method returns a list which contains two integers.
|
||||
// The first integer is the number of URIs deleted.
|
||||
// The second integer is the number of URIs added.
|
||||
func (c *client) ChangeURI(gid string, fileindex int, delUris []string, addUris []string, position ...int) (p []int, err error) {
|
||||
params := make([]interface{}, 0, 5)
|
||||
if c.token != "" {
|
||||
params = append(params, "token:"+c.token)
|
||||
}
|
||||
params = append(params, gid)
|
||||
params = append(params, fileindex)
|
||||
params = append(params, delUris)
|
||||
params = append(params, addUris)
|
||||
if position != nil {
|
||||
params = append(params, position[0])
|
||||
}
|
||||
err = c.Call(aria2ChangeURI, params, &p)
|
||||
return
|
||||
}
|
||||
|
||||
// `aria2.getOption([secret, ]gid)`
|
||||
// This method returns options of the download denoted by gid.
|
||||
// The response is a struct where keys are the names of options.
|
||||
// The values are strings.
|
||||
// Note that this method does not return options which have no default value and have not been set on the command-line, in configuration files or RPC methods.
|
||||
func (c *client) GetOption(gid string) (m Option, err error) {
|
||||
params := make([]interface{}, 0, 2)
|
||||
if c.token != "" {
|
||||
params = append(params, "token:"+c.token)
|
||||
}
|
||||
params = append(params, gid)
|
||||
err = c.Call(aria2GetOption, params, &m)
|
||||
return
|
||||
}
|
||||
|
||||
// `aria2.changeOption([secret, ]gid, options)`
|
||||
// This method changes options of the download denoted by gid (string) dynamically. options is a struct.
|
||||
// The following options are available for active downloads:
|
||||
// bt-max-peers
|
||||
// bt-request-peer-speed-limit
|
||||
// bt-remove-unselected-file
|
||||
// force-save
|
||||
// max-download-limit
|
||||
// max-upload-limit
|
||||
// For waiting or paused downloads, in addition to the above options, options listed in Input File subsection are available, except for following options: dry-run, metalink-base-uri, parameterized-uri, pause, piece-length and rpc-save-upload-metadata option.
|
||||
// This method returns OK for success.
|
||||
func (c *client) ChangeOption(gid string, option Option) (ok string, err error) {
|
||||
params := make([]interface{}, 0, 2)
|
||||
if c.token != "" {
|
||||
params = append(params, "token:"+c.token)
|
||||
}
|
||||
params = append(params, gid)
|
||||
if option != nil {
|
||||
params = append(params, option)
|
||||
}
|
||||
err = c.Call(aria2ChangeOption, params, &ok)
|
||||
return
|
||||
}
|
||||
|
||||
// `aria2.getGlobalOption([secret])`
|
||||
// This method returns the global options.
|
||||
// The response is a struct.
|
||||
// Its keys are the names of options.
|
||||
// Values are strings.
|
||||
// Note that this method does not return options which have no default value and have not been set on the command-line, in configuration files or RPC methods. Because global options are used as a template for the options of newly added downloads, the response contains keys returned by the aria2.getOption() method.
|
||||
func (c *client) GetGlobalOption() (m Option, err error) {
|
||||
params := []string{}
|
||||
if c.token != "" {
|
||||
params = append(params, "token:"+c.token)
|
||||
}
|
||||
err = c.Call(aria2GetGlobalOption, params, &m)
|
||||
return
|
||||
}
|
||||
|
||||
// `aria2.changeGlobalOption([secret, ]options)`
|
||||
// This method changes global options dynamically.
|
||||
// options is a struct.
|
||||
// The following options are available:
|
||||
// bt-max-open-files
|
||||
// download-result
|
||||
// log
|
||||
// log-level
|
||||
// max-concurrent-downloads
|
||||
// max-download-result
|
||||
// max-overall-download-limit
|
||||
// max-overall-upload-limit
|
||||
// save-cookies
|
||||
// save-session
|
||||
// server-stat-of
|
||||
// In addition, options listed in the Input File subsection are available, except for following options: checksum, index-out, out, pause and select-file.
|
||||
// With the log option, you can dynamically start logging or change log file.
|
||||
// To stop logging, specify an empty string("") as the parameter value.
|
||||
// Note that log file is always opened in append mode.
|
||||
// This method returns OK for success.
|
||||
func (c *client) ChangeGlobalOption(options Option) (ok string, err error) {
|
||||
params := make([]interface{}, 0, 2)
|
||||
if c.token != "" {
|
||||
params = append(params, "token:"+c.token)
|
||||
}
|
||||
params = append(params, options)
|
||||
err = c.Call(aria2ChangeGlobalOption, params, &ok)
|
||||
return
|
||||
}
|
||||
|
||||
// `aria2.getGlobalStat([secret])`
|
||||
// This method returns global statistics such as the overall download and upload speeds.
|
||||
// The response is a struct and contains the following keys. Values are strings.
|
||||
// downloadSpeed Overall download speed (byte/sec).
|
||||
// uploadSpeed Overall upload speed(byte/sec).
|
||||
// numActive The number of active downloads.
|
||||
// numWaiting The number of waiting downloads.
|
||||
// numStopped The number of stopped downloads in the current session.
|
||||
// This value is capped by the --max-download-result option.
|
||||
// numStoppedTotal The number of stopped downloads in the current session and not capped by the --max-download-result option.
|
||||
func (c *client) GetGlobalStat() (info GlobalStatInfo, err error) {
|
||||
params := []string{}
|
||||
if c.token != "" {
|
||||
params = append(params, "token:"+c.token)
|
||||
}
|
||||
err = c.Call(aria2GetGlobalStat, params, &info)
|
||||
return
|
||||
}
|
||||
|
||||
// `aria2.purgeDownloadResult([secret])`
|
||||
// This method purges completed/error/removed downloads to free memory.
|
||||
// This method returns OK.
|
||||
func (c *client) PurgeDownloadResult() (ok string, err error) {
|
||||
params := []string{}
|
||||
if c.token != "" {
|
||||
params = append(params, "token:"+c.token)
|
||||
}
|
||||
err = c.Call(aria2PurgeDownloadResult, params, &ok)
|
||||
return
|
||||
}
|
||||
|
||||
// `aria2.removeDownloadResult([secret, ]gid)`
|
||||
// This method removes a completed/error/removed download denoted by gid from memory.
|
||||
// This method returns OK for success.
|
||||
func (c *client) RemoveDownloadResult(gid string) (ok string, err error) {
|
||||
params := make([]interface{}, 0, 2)
|
||||
if c.token != "" {
|
||||
params = append(params, "token:"+c.token)
|
||||
}
|
||||
params = append(params, gid)
|
||||
err = c.Call(aria2RemoveDownloadResult, params, &ok)
|
||||
return
|
||||
}
|
||||
|
||||
// `aria2.getVersion([secret])`
|
||||
// This method returns the version of aria2 and the list of enabled features.
|
||||
// The response is a struct and contains following keys.
|
||||
// version Version number of aria2 as a string.
|
||||
// enabledFeatures List of enabled features. Each feature is given as a string.
|
||||
func (c *client) GetVersion() (info VersionInfo, err error) {
|
||||
params := []string{}
|
||||
if c.token != "" {
|
||||
params = append(params, "token:"+c.token)
|
||||
}
|
||||
err = c.Call(aria2GetVersion, params, &info)
|
||||
return
|
||||
}
|
||||
|
||||
// `aria2.getSessionInfo([secret])`
|
||||
// This method returns session information.
|
||||
// The response is a struct and contains following key.
|
||||
// sessionId Session ID, which is generated each time when aria2 is invoked.
|
||||
func (c *client) GetSessionInfo() (info SessionInfo, err error) {
|
||||
params := []string{}
|
||||
if c.token != "" {
|
||||
params = append(params, "token:"+c.token)
|
||||
}
|
||||
err = c.Call(aria2GetSessionInfo, params, &info)
|
||||
return
|
||||
}
|
||||
|
||||
// `aria2.shutdown([secret])`
|
||||
// This method shutdowns aria2.
|
||||
// This method returns OK.
|
||||
func (c *client) Shutdown() (ok string, err error) {
|
||||
params := []string{}
|
||||
if c.token != "" {
|
||||
params = append(params, "token:"+c.token)
|
||||
}
|
||||
err = c.Call(aria2Shutdown, params, &ok)
|
||||
return
|
||||
}
|
||||
|
||||
// `aria2.forceShutdown([secret])`
|
||||
// This method shuts down aria2().
|
||||
// This method behaves like :func:'aria2.shutdown` without performing any actions which take time, such as contacting BitTorrent trackers to unregister downloads first.
|
||||
// This method returns OK.
|
||||
func (c *client) ForceShutdown() (ok string, err error) {
|
||||
params := []string{}
|
||||
if c.token != "" {
|
||||
params = append(params, "token:"+c.token)
|
||||
}
|
||||
err = c.Call(aria2ForceShutdown, params, &ok)
|
||||
return
|
||||
}
|
||||
|
||||
// `aria2.saveSession([secret])`
|
||||
// This method saves the current session to a file specified by the --save-session option.
|
||||
// This method returns OK if it succeeds.
|
||||
func (c *client) SaveSession() (ok string, err error) {
|
||||
params := []string{}
|
||||
if c.token != "" {
|
||||
params = append(params, "token:"+c.token)
|
||||
}
|
||||
err = c.Call(aria2SaveSession, params, &ok)
|
||||
return
|
||||
}
|
||||
|
||||
// `system.multicall(methods)`
|
||||
// This methods encapsulates multiple method calls in a single request.
|
||||
// methods is an array of structs.
|
||||
// The structs contain two keys: methodName and params.
|
||||
// methodName is the method name to call and params is array containing parameters to the method call.
|
||||
// This method returns an array of responses.
|
||||
// The elements will be either a one-item array containing the return value of the method call or a struct of fault element if an encapsulated method call fails.
|
||||
func (c *client) Multicall(methods []Method) (r []interface{}, err error) {
|
||||
if len(methods) == 0 {
|
||||
err = errInvalidParameter
|
||||
return
|
||||
}
|
||||
err = c.Call(aria2Multicall, methods, &r)
|
||||
return
|
||||
}
|
||||
|
||||
// `system.listMethods()`
|
||||
// This method returns the all available RPC methods in an array of string.
|
||||
// Unlike other methods, this method does not require secret token.
|
||||
// This is safe because this method jsut returns the available method names.
|
||||
func (c *client) ListMethods() (methods []string, err error) {
|
||||
err = c.Call(aria2ListMethods, []string{}, &methods)
|
||||
return
|
||||
}
|
39
pkg/aria2/rpc/const.go
Normal file
39
pkg/aria2/rpc/const.go
Normal file
@ -0,0 +1,39 @@
|
||||
package rpc
|
||||
|
||||
const (
|
||||
aria2AddURI = "aria2.addUri"
|
||||
aria2AddTorrent = "aria2.addTorrent"
|
||||
aria2AddMetalink = "aria2.addMetalink"
|
||||
aria2Remove = "aria2.remove"
|
||||
aria2ForceRemove = "aria2.forceRemove"
|
||||
aria2Pause = "aria2.pause"
|
||||
aria2PauseAll = "aria2.pauseAll"
|
||||
aria2ForcePause = "aria2.forcePause"
|
||||
aria2ForcePauseAll = "aria2.forcePauseAll"
|
||||
aria2Unpause = "aria2.unpause"
|
||||
aria2UnpauseAll = "aria2.unpauseAll"
|
||||
aria2TellStatus = "aria2.tellStatus"
|
||||
aria2GetURIs = "aria2.getUris"
|
||||
aria2GetFiles = "aria2.getFiles"
|
||||
aria2GetPeers = "aria2.getPeers"
|
||||
aria2GetServers = "aria2.getServers"
|
||||
aria2TellActive = "aria2.tellActive"
|
||||
aria2TellWaiting = "aria2.tellWaiting"
|
||||
aria2TellStopped = "aria2.tellStopped"
|
||||
aria2ChangePosition = "aria2.changePosition"
|
||||
aria2ChangeURI = "aria2.changeUri"
|
||||
aria2GetOption = "aria2.getOption"
|
||||
aria2ChangeOption = "aria2.changeOption"
|
||||
aria2GetGlobalOption = "aria2.getGlobalOption"
|
||||
aria2ChangeGlobalOption = "aria2.changeGlobalOption"
|
||||
aria2GetGlobalStat = "aria2.getGlobalStat"
|
||||
aria2PurgeDownloadResult = "aria2.purgeDownloadResult"
|
||||
aria2RemoveDownloadResult = "aria2.removeDownloadResult"
|
||||
aria2GetVersion = "aria2.getVersion"
|
||||
aria2GetSessionInfo = "aria2.getSessionInfo"
|
||||
aria2Shutdown = "aria2.shutdown"
|
||||
aria2ForceShutdown = "aria2.forceShutdown"
|
||||
aria2SaveSession = "aria2.saveSession"
|
||||
aria2Multicall = "system.multicall"
|
||||
aria2ListMethods = "system.listMethods"
|
||||
)
|
116
pkg/aria2/rpc/json2.go
Normal file
116
pkg/aria2/rpc/json2.go
Normal file
@ -0,0 +1,116 @@
|
||||
package rpc
|
||||
|
||||
// based on "github.com/gorilla/rpc/v2/json2"
|
||||
|
||||
// Copyright 2009 The Go Authors. All rights reserved.
|
||||
// Copyright 2012 The Gorilla Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
)
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Request and Response
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
// clientRequest represents a JSON-RPC request sent by a client.
|
||||
type clientRequest struct {
|
||||
// JSON-RPC protocol.
|
||||
Version string `json:"jsonrpc"`
|
||||
|
||||
// A String containing the name of the method to be invoked.
|
||||
Method string `json:"method"`
|
||||
|
||||
// Object to pass as request parameter to the method.
|
||||
Params interface{} `json:"params"`
|
||||
|
||||
// The request id. This can be of any type. It is used to match the
|
||||
// response with the request that it is replying to.
|
||||
Id uint64 `json:"id"`
|
||||
}
|
||||
|
||||
// clientResponse represents a JSON-RPC response returned to a client.
|
||||
type clientResponse struct {
|
||||
Version string `json:"jsonrpc"`
|
||||
Result *json.RawMessage `json:"result"`
|
||||
Error *json.RawMessage `json:"error"`
|
||||
Id *uint64 `json:"id"`
|
||||
}
|
||||
|
||||
// EncodeClientRequest encodes parameters for a JSON-RPC client request.
|
||||
func EncodeClientRequest(method string, args interface{}) (*bytes.Buffer, error) {
|
||||
var buf bytes.Buffer
|
||||
c := &clientRequest{
|
||||
Version: "2.0",
|
||||
Method: method,
|
||||
Params: args,
|
||||
Id: reqid(),
|
||||
}
|
||||
if err := json.NewEncoder(&buf).Encode(c); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &buf, nil
|
||||
}
|
||||
|
||||
func (c clientResponse) decode(reply interface{}) error {
|
||||
if c.Error != nil {
|
||||
jsonErr := &Error{}
|
||||
if err := json.Unmarshal(*c.Error, jsonErr); err != nil {
|
||||
return &Error{
|
||||
Code: E_SERVER,
|
||||
Message: string(*c.Error),
|
||||
}
|
||||
}
|
||||
return jsonErr
|
||||
}
|
||||
|
||||
if c.Result == nil {
|
||||
return ErrNullResult
|
||||
}
|
||||
|
||||
return json.Unmarshal(*c.Result, reply)
|
||||
}
|
||||
|
||||
// DecodeClientResponse decodes the response body of a client request into
|
||||
// the interface reply.
|
||||
func DecodeClientResponse(r io.Reader, reply interface{}) error {
|
||||
var c clientResponse
|
||||
if err := json.NewDecoder(r).Decode(&c); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.decode(reply)
|
||||
}
|
||||
|
||||
type ErrorCode int
|
||||
|
||||
const (
|
||||
E_PARSE ErrorCode = -32700
|
||||
E_INVALID_REQ ErrorCode = -32600
|
||||
E_NO_METHOD ErrorCode = -32601
|
||||
E_BAD_PARAMS ErrorCode = -32602
|
||||
E_INTERNAL ErrorCode = -32603
|
||||
E_SERVER ErrorCode = -32000
|
||||
)
|
||||
|
||||
var ErrNullResult = errors.New("result is null")
|
||||
|
||||
type Error struct {
|
||||
// A Number that indicates the error type that occurred.
|
||||
Code ErrorCode `json:"code"` /* required */
|
||||
|
||||
// A String providing a short description of the error.
|
||||
// The message SHOULD be limited to a concise single sentence.
|
||||
Message string `json:"message"` /* required */
|
||||
|
||||
// A Primitive or Structured value that contains additional information about the error.
|
||||
Data interface{} `json:"data"` /* optional */
|
||||
}
|
||||
|
||||
func (e *Error) Error() string {
|
||||
return e.Message
|
||||
}
|
44
pkg/aria2/rpc/notification.go
Normal file
44
pkg/aria2/rpc/notification.go
Normal file
@ -0,0 +1,44 @@
|
||||
package rpc
|
||||
|
||||
import (
|
||||
"log"
|
||||
)
|
||||
|
||||
type Event struct {
|
||||
Gid string `json:"gid"` // GID of the download
|
||||
}
|
||||
|
||||
// The RPC server might send notifications to the client.
|
||||
// Notifications is unidirectional, therefore the client which receives the notification must not respond to it.
|
||||
// The method signature of a notification is much like a normal method request but lacks the id key
|
||||
|
||||
type websocketResponse struct {
|
||||
clientResponse
|
||||
Method string `json:"method"`
|
||||
Params []Event `json:"params"`
|
||||
}
|
||||
|
||||
// Notifier handles rpc notification from aria2 server
|
||||
type Notifier interface {
|
||||
// OnDownloadStart will be sent when a download is started.
|
||||
OnDownloadStart([]Event)
|
||||
// OnDownloadPause will be sent when a download is paused.
|
||||
OnDownloadPause([]Event)
|
||||
// OnDownloadStop will be sent when a download is stopped by the user.
|
||||
OnDownloadStop([]Event)
|
||||
// OnDownloadComplete will be sent when a download is complete. For BitTorrent downloads, this notification is sent when the download is complete and seeding is over.
|
||||
OnDownloadComplete([]Event)
|
||||
// OnDownloadError will be sent when a download is stopped due to an error.
|
||||
OnDownloadError([]Event)
|
||||
// OnBtDownloadComplete will be sent when a torrent download is complete but seeding is still going on.
|
||||
OnBtDownloadComplete([]Event)
|
||||
}
|
||||
|
||||
type DummyNotifier struct{}
|
||||
|
||||
func (DummyNotifier) OnDownloadStart(events []Event) { log.Printf("%s started.", events) }
|
||||
func (DummyNotifier) OnDownloadPause(events []Event) { log.Printf("%s paused.", events) }
|
||||
func (DummyNotifier) OnDownloadStop(events []Event) { log.Printf("%s stopped.", events) }
|
||||
func (DummyNotifier) OnDownloadComplete(events []Event) { log.Printf("%s completed.", events) }
|
||||
func (DummyNotifier) OnDownloadError(events []Event) { log.Printf("%s error.", events) }
|
||||
func (DummyNotifier) OnBtDownloadComplete(events []Event) { log.Printf("bt %s completed.", events) }
|
42
pkg/aria2/rpc/proc.go
Normal file
42
pkg/aria2/rpc/proc.go
Normal file
@ -0,0 +1,42 @@
|
||||
package rpc
|
||||
|
||||
import "sync"
|
||||
|
||||
type ResponseProcFn func(resp clientResponse) error
|
||||
|
||||
type ResponseProcessor struct {
|
||||
cbs map[uint64]ResponseProcFn
|
||||
mu *sync.RWMutex
|
||||
}
|
||||
|
||||
func NewResponseProcessor() *ResponseProcessor {
|
||||
return &ResponseProcessor{
|
||||
make(map[uint64]ResponseProcFn),
|
||||
&sync.RWMutex{},
|
||||
}
|
||||
}
|
||||
|
||||
func (r *ResponseProcessor) Add(id uint64, fn ResponseProcFn) {
|
||||
r.mu.Lock()
|
||||
r.cbs[id] = fn
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
func (r *ResponseProcessor) remove(id uint64) {
|
||||
r.mu.Lock()
|
||||
delete(r.cbs, id)
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
// Process called by recv routine
|
||||
func (r *ResponseProcessor) Process(resp clientResponse) error {
|
||||
id := *resp.Id
|
||||
r.mu.RLock()
|
||||
fn, ok := r.cbs[id]
|
||||
r.mu.RUnlock()
|
||||
if ok && fn != nil {
|
||||
defer r.remove(id)
|
||||
return fn(resp)
|
||||
}
|
||||
return nil
|
||||
}
|
40
pkg/aria2/rpc/proto.go
Normal file
40
pkg/aria2/rpc/proto.go
Normal file
@ -0,0 +1,40 @@
|
||||
package rpc
|
||||
|
||||
// Protocol is a set of rpc methods that aria2 daemon supports
|
||||
type Protocol interface {
|
||||
AddURI(uri string, options ...interface{}) (gid string, err error)
|
||||
AddTorrent(filename string, options ...interface{}) (gid string, err error)
|
||||
AddMetalink(filename string, options ...interface{}) (gid []string, err error)
|
||||
Remove(gid string) (g string, err error)
|
||||
ForceRemove(gid string) (g string, err error)
|
||||
Pause(gid string) (g string, err error)
|
||||
PauseAll() (ok string, err error)
|
||||
ForcePause(gid string) (g string, err error)
|
||||
ForcePauseAll() (ok string, err error)
|
||||
Unpause(gid string) (g string, err error)
|
||||
UnpauseAll() (ok string, err error)
|
||||
TellStatus(gid string, keys ...string) (info StatusInfo, err error)
|
||||
GetURIs(gid string) (infos []URIInfo, err error)
|
||||
GetFiles(gid string) (infos []FileInfo, err error)
|
||||
GetPeers(gid string) (infos []PeerInfo, err error)
|
||||
GetServers(gid string) (infos []ServerInfo, err error)
|
||||
TellActive(keys ...string) (infos []StatusInfo, err error)
|
||||
TellWaiting(offset, num int, keys ...string) (infos []StatusInfo, err error)
|
||||
TellStopped(offset, num int, keys ...string) (infos []StatusInfo, err error)
|
||||
ChangePosition(gid string, pos int, how string) (p int, err error)
|
||||
ChangeURI(gid string, fileindex int, delUris []string, addUris []string, position ...int) (p []int, err error)
|
||||
GetOption(gid string) (m Option, err error)
|
||||
ChangeOption(gid string, option Option) (ok string, err error)
|
||||
GetGlobalOption() (m Option, err error)
|
||||
ChangeGlobalOption(options Option) (ok string, err error)
|
||||
GetGlobalStat() (info GlobalStatInfo, err error)
|
||||
PurgeDownloadResult() (ok string, err error)
|
||||
RemoveDownloadResult(gid string) (ok string, err error)
|
||||
GetVersion() (info VersionInfo, err error)
|
||||
GetSessionInfo() (info SessionInfo, err error)
|
||||
Shutdown() (ok string, err error)
|
||||
ForceShutdown() (ok string, err error)
|
||||
SaveSession() (ok string, err error)
|
||||
Multicall(methods []Method) (r []interface{}, err error)
|
||||
ListMethods() (methods []string, err error)
|
||||
}
|
104
pkg/aria2/rpc/resp.go
Normal file
104
pkg/aria2/rpc/resp.go
Normal file
@ -0,0 +1,104 @@
|
||||
//go:generate easyjson -all
|
||||
|
||||
package rpc
|
||||
|
||||
// StatusInfo represents response of aria2.tellStatus
|
||||
type StatusInfo struct {
|
||||
Gid string `json:"gid"` // GID of the download.
|
||||
Status string `json:"status"` // active for currently downloading/seeding downloads. waiting for downloads in the queue; download is not started. paused for paused downloads. error for downloads that were stopped because of error. complete for stopped and completed downloads. removed for the downloads removed by user.
|
||||
TotalLength string `json:"totalLength"` // Total length of the download in bytes.
|
||||
CompletedLength string `json:"completedLength"` // Completed length of the download in bytes.
|
||||
UploadLength string `json:"uploadLength"` // Uploaded length of the download in bytes.
|
||||
BitField string `json:"bitfield"` // Hexadecimal representation of the download progress. The highest bit corresponds to the piece at index 0. Any set bits indicate loaded pieces, while unset bits indicate not yet loaded and/or missing pieces. Any overflow bits at the end are set to zero. When the download was not started yet, this key will not be included in the response.
|
||||
DownloadSpeed string `json:"downloadSpeed"` // Download speed of this download measured in bytes/sec.
|
||||
UploadSpeed string `json:"uploadSpeed"` // LocalUpload speed of this download measured in bytes/sec.
|
||||
InfoHash string `json:"infoHash"` // InfoHash. BitTorrent only.
|
||||
NumSeeders string `json:"numSeeders"` // The number of seeders aria2 has connected to. BitTorrent only.
|
||||
Seeder string `json:"seeder"` // true if the local endpoint is a seeder. Otherwise false. BitTorrent only.
|
||||
PieceLength string `json:"pieceLength"` // Piece length in bytes.
|
||||
NumPieces string `json:"numPieces"` // The number of pieces.
|
||||
Connections string `json:"connections"` // The number of peers/servers aria2 has connected to.
|
||||
ErrorCode string `json:"errorCode"` // The code of the last error for this item, if any. The value is a string. The error codes are defined in the EXIT STATUS section. This value is only available for stopped/completed downloads.
|
||||
ErrorMessage string `json:"errorMessage"` // The (hopefully) human readable error message associated to errorCode.
|
||||
FollowedBy []string `json:"followedBy"` // List of GIDs which are generated as the result of this download. For example, when aria2 downloads a Metalink file, it generates downloads described in the Metalink (see the --follow-metalink option). This value is useful to track auto-generated downloads. If there are no such downloads, this key will not be included in the response.
|
||||
BelongsTo string `json:"belongsTo"` // GID of a parent download. Some downloads are a part of another download. For example, if a file in a Metalink has BitTorrent resources, the downloads of ".torrent" files are parts of that parent. If this download has no parent, this key will not be included in the response.
|
||||
Dir string `json:"dir"` // Directory to save files.
|
||||
Files []FileInfo `json:"files"` // Returns the list of files. The elements of this list are the same structs used in aria2.getFiles() method.
|
||||
BitTorrent BitTorrentInfo `json:"bittorrent"` // Struct which contains information retrieved from the .torrent (file). BitTorrent only. It contains following keys.
|
||||
}
|
||||
|
||||
// URIInfo represents an element of response of aria2.getUris
|
||||
type URIInfo struct {
|
||||
URI string `json:"uri"` // URI
|
||||
Status string `json:"status"` // 'used' if the URI is in use. 'waiting' if the URI is still waiting in the queue.
|
||||
}
|
||||
|
||||
// FileInfo represents an element of response of aria2.getFiles
|
||||
type FileInfo struct {
|
||||
Index string `json:"index"` // Index of the file, starting at 1, in the same order as files appear in the multi-file torrent.
|
||||
Path string `json:"path"` // File path.
|
||||
Length string `json:"length"` // File size in bytes.
|
||||
CompletedLength string `json:"completedLength"` // Completed length of this file in bytes. Please note that it is possible that sum of completedLength is less than the completedLength returned by the aria2.tellStatus() method. This is because completedLength in aria2.getFiles() only includes completed pieces. On the other hand, completedLength in aria2.tellStatus() also includes partially completed pieces.
|
||||
Selected string `json:"selected"` // true if this file is selected by --select-file option. If --select-file is not specified or this is single-file torrent or not a torrent download at all, this value is always true. Otherwise false.
|
||||
URIs []URIInfo `json:"uris"` // Returns a list of URIs for this file. The element type is the same struct used in the aria2.getUris() method.
|
||||
}
|
||||
|
||||
// PeerInfo represents an element of response of aria2.getPeers
|
||||
type PeerInfo struct {
|
||||
PeerId string `json:"peerId"` // Percent-encoded peer ID.
|
||||
IP string `json:"ip"` // IP address of the peer.
|
||||
Port string `json:"port"` // Port number of the peer.
|
||||
BitField string `json:"bitfield"` // Hexadecimal representation of the download progress of the peer. The highest bit corresponds to the piece at index 0. Set bits indicate the piece is available and unset bits indicate the piece is missing. Any spare bits at the end are set to zero.
|
||||
AmChoking string `json:"amChoking"` // true if aria2 is choking the peer. Otherwise false.
|
||||
PeerChoking string `json:"peerChoking"` // true if the peer is choking aria2. Otherwise false.
|
||||
DownloadSpeed string `json:"downloadSpeed"` // Download speed (byte/sec) that this client obtains from the peer.
|
||||
UploadSpeed string `json:"uploadSpeed"` // LocalUpload speed(byte/sec) that this client uploads to the peer.
|
||||
Seeder string `json:"seeder"` // true if this peer is a seeder. Otherwise false.
|
||||
}
|
||||
|
||||
// ServerInfo represents an element of response of aria2.getServers
|
||||
type ServerInfo struct {
|
||||
Index string `json:"index"` // Index of the file, starting at 1, in the same order as files appear in the multi-file metalink.
|
||||
Servers []struct {
|
||||
URI string `json:"uri"` // Original URI.
|
||||
CurrentURI string `json:"currentUri"` // This is the URI currently used for downloading. If redirection is involved, currentUri and uri may differ.
|
||||
DownloadSpeed string `json:"downloadSpeed"` // Download speed (byte/sec)
|
||||
} `json:"servers"` // A list of structs which contain the following keys.
|
||||
}
|
||||
|
||||
// GlobalStatInfo represents response of aria2.getGlobalStat
|
||||
type GlobalStatInfo struct {
|
||||
DownloadSpeed string `json:"downloadSpeed"` // Overall download speed (byte/sec).
|
||||
UploadSpeed string `json:"uploadSpeed"` // Overall upload speed(byte/sec).
|
||||
NumActive string `json:"numActive"` // The number of active downloads.
|
||||
NumWaiting string `json:"numWaiting"` // The number of waiting downloads.
|
||||
NumStopped string `json:"numStopped"` // The number of stopped downloads in the current session. This value is capped by the --max-download-result option.
|
||||
NumStoppedTotal string `json:"numStoppedTotal"` // The number of stopped downloads in the current session and not capped by the --max-download-result option.
|
||||
}
|
||||
|
||||
// VersionInfo represents response of aria2.getVersion
|
||||
type VersionInfo struct {
|
||||
Version string `json:"version"` // Version number of aria2 as a string.
|
||||
Features []string `json:"enabledFeatures"` // List of enabled features. Each feature is given as a string.
|
||||
}
|
||||
|
||||
// SessionInfo represents response of aria2.getSessionInfo
|
||||
type SessionInfo struct {
|
||||
Id string `json:"sessionId"` // Session ID, which is generated each time when aria2 is invoked.
|
||||
}
|
||||
|
||||
// Method is an element of parameters used in system.multicall
|
||||
type Method struct {
|
||||
Name string `json:"methodName"` // Method name to call
|
||||
Params []interface{} `json:"params"` // Array containing parameters to the method call
|
||||
}
|
||||
|
||||
type BitTorrentInfo struct {
|
||||
AnnounceList [][]string `json:"announceList"` // List of lists of announce URIs. If the torrent contains announce and no announce-list, announce is converted to the announce-list format.
|
||||
Comment string `json:"comment"` // The comment of the torrent. comment.utf-8 is used if available.
|
||||
CreationDate int64 `json:"creationDate"` // The creation time of the torrent. The value is an integer since the epoch, measured in seconds.
|
||||
Mode string `json:"mode"` // File mode of the torrent. The value is either single or multi.
|
||||
Info struct {
|
||||
Name string `json:"name"` // name in info dictionary. name.utf-8 is used if available.
|
||||
} `json:"info"` // Struct which contains data from Info dictionary. It contains following keys.
|
||||
}
|
145
pkg/auth/auth.go
Normal file
145
pkg/auth/auth.go
Normal file
@ -0,0 +1,145 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
model "github.com/cloudreve/Cloudreve/v3/models"
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/conf"
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/serializer"
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/util"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrAuthFailed = serializer.NewError(serializer.CodeInvalidSign, "invalid sign", nil)
|
||||
ErrAuthHeaderMissing = serializer.NewError(serializer.CodeNoPermissionErr, "authorization header is missing", nil)
|
||||
ErrExpiresMissing = serializer.NewError(serializer.CodeNoPermissionErr, "expire timestamp is missing", nil)
|
||||
ErrExpired = serializer.NewError(serializer.CodeSignExpired, "signature expired", nil)
|
||||
)
|
||||
|
||||
const CrHeaderPrefix = "X-Cr-"
|
||||
|
||||
// General 通用的认证接口
|
||||
var General Auth
|
||||
|
||||
// Auth 鉴权认证
|
||||
type Auth interface {
|
||||
// 对给定Body进行签名,expires为0表示永不过期
|
||||
Sign(body string, expires int64) string
|
||||
// 对给定Body和Sign进行检查
|
||||
Check(body string, sign string) error
|
||||
}
|
||||
|
||||
// SignRequest 对PUT\POST等复杂HTTP请求签名,只会对URI部分、
|
||||
// 请求正文、`X-Cr-`开头的header进行签名
|
||||
func SignRequest(instance Auth, r *http.Request, expires int64) *http.Request {
|
||||
// 处理有效期
|
||||
if expires > 0 {
|
||||
expires += time.Now().Unix()
|
||||
}
|
||||
|
||||
// 生成签名
|
||||
sign := instance.Sign(getSignContent(r), expires)
|
||||
|
||||
// 将签名加到请求Header中
|
||||
r.Header["Authorization"] = []string{"Bearer " + sign}
|
||||
return r
|
||||
}
|
||||
|
||||
// CheckRequest 对复杂请求进行签名验证
|
||||
func CheckRequest(instance Auth, r *http.Request) error {
|
||||
var (
|
||||
sign []string
|
||||
ok bool
|
||||
)
|
||||
if sign, ok = r.Header["Authorization"]; !ok || len(sign) == 0 {
|
||||
return ErrAuthHeaderMissing
|
||||
}
|
||||
sign[0] = strings.TrimPrefix(sign[0], "Bearer ")
|
||||
|
||||
return instance.Check(getSignContent(r), sign[0])
|
||||
}
|
||||
|
||||
// getSignContent 签名请求 path、正文、以`X-`开头的 Header. 如果请求 path 为从机上传 API,
|
||||
// 则不对正文签名。返回待签名/验证的字符串
|
||||
func getSignContent(r *http.Request) (rawSignString string) {
|
||||
// 读取所有body正文
|
||||
var body = []byte{}
|
||||
if !strings.Contains(r.URL.Path, "/api/v3/slave/upload/") {
|
||||
if r.Body != nil {
|
||||
body, _ = ioutil.ReadAll(r.Body)
|
||||
_ = r.Body.Close()
|
||||
r.Body = ioutil.NopCloser(bytes.NewReader(body))
|
||||
}
|
||||
}
|
||||
|
||||
// 决定要签名的header
|
||||
var signedHeader []string
|
||||
for k, _ := range r.Header {
|
||||
if strings.HasPrefix(k, CrHeaderPrefix) && k != CrHeaderPrefix+"Filename" {
|
||||
signedHeader = append(signedHeader, fmt.Sprintf("%s=%s", k, r.Header.Get(k)))
|
||||
}
|
||||
}
|
||||
sort.Strings(signedHeader)
|
||||
|
||||
// 读取所有待签名Header
|
||||
rawSignString = serializer.NewRequestSignString(r.URL.Path, strings.Join(signedHeader, "&"), string(body))
|
||||
|
||||
return rawSignString
|
||||
}
|
||||
|
||||
// SignURI 对URI进行签名,签名只针对Path部分,query部分不做验证
|
||||
func SignURI(instance Auth, uri string, expires int64) (*url.URL, error) {
|
||||
// 处理有效期
|
||||
if expires != 0 {
|
||||
expires += time.Now().Unix()
|
||||
}
|
||||
|
||||
base, err := url.Parse(uri)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 生成签名
|
||||
sign := instance.Sign(base.Path, expires)
|
||||
|
||||
// 将签名加到URI中
|
||||
queries := base.Query()
|
||||
queries.Set("sign", sign)
|
||||
base.RawQuery = queries.Encode()
|
||||
|
||||
return base, nil
|
||||
}
|
||||
|
||||
// CheckURI 对URI进行鉴权
|
||||
func CheckURI(instance Auth, url *url.URL) error {
|
||||
//获取待验证的签名正文
|
||||
queries := url.Query()
|
||||
sign := queries.Get("sign")
|
||||
queries.Del("sign")
|
||||
url.RawQuery = queries.Encode()
|
||||
|
||||
return instance.Check(url.Path, sign)
|
||||
}
|
||||
|
||||
// Init 初始化通用鉴权器
|
||||
func Init() {
|
||||
var secretKey string
|
||||
if conf.SystemConfig.Mode == "master" {
|
||||
secretKey = model.GetSettingByName("secret_key")
|
||||
} else {
|
||||
secretKey = conf.SlaveConfig.Secret
|
||||
if secretKey == "" {
|
||||
util.Log().Panic("SlaveSecret is not set, please specify it in config file.")
|
||||
}
|
||||
}
|
||||
General = HMACAuth{
|
||||
SecretKey: []byte(secretKey),
|
||||
}
|
||||
}
|
54
pkg/auth/hmac.go
Normal file
54
pkg/auth/hmac.go
Normal file
@ -0,0 +1,54 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// HMACAuth HMAC算法鉴权
|
||||
type HMACAuth struct {
|
||||
SecretKey []byte
|
||||
}
|
||||
|
||||
// Sign 对给定Body生成expires后失效的签名,expires为过期时间戳,
|
||||
// 填写为0表示不限制有效期
|
||||
func (auth HMACAuth) Sign(body string, expires int64) string {
|
||||
h := hmac.New(sha256.New, auth.SecretKey)
|
||||
expireTimeStamp := strconv.FormatInt(expires, 10)
|
||||
_, err := io.WriteString(h, body+":"+expireTimeStamp)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return base64.URLEncoding.EncodeToString(h.Sum(nil)) + ":" + expireTimeStamp
|
||||
}
|
||||
|
||||
// Check 对给定Body和Sign进行鉴权,包括对expires的检查
|
||||
func (auth HMACAuth) Check(body string, sign string) error {
|
||||
signSlice := strings.Split(sign, ":")
|
||||
// 如果未携带expires字段
|
||||
if signSlice[len(signSlice)-1] == "" {
|
||||
return ErrExpiresMissing
|
||||
}
|
||||
|
||||
// 验证是否过期
|
||||
expires, err := strconv.ParseInt(signSlice[len(signSlice)-1], 10, 64)
|
||||
if err != nil {
|
||||
return ErrAuthFailed.WithError(err)
|
||||
}
|
||||
// 如果签名过期
|
||||
if expires < time.Now().Unix() && expires != 0 {
|
||||
return ErrExpired
|
||||
}
|
||||
|
||||
// 验证签名
|
||||
if auth.Sign(body, expires) != sign {
|
||||
return ErrAuthFailed
|
||||
}
|
||||
return nil
|
||||
}
|
16
pkg/authn/auth.go
Normal file
16
pkg/authn/auth.go
Normal file
@ -0,0 +1,16 @@
|
||||
package authn
|
||||
|
||||
import (
|
||||
model "github.com/cloudreve/Cloudreve/v3/models"
|
||||
"github.com/duo-labs/webauthn/webauthn"
|
||||
)
|
||||
|
||||
// NewAuthnInstance 新建Authn实例
|
||||
func NewAuthnInstance() (*webauthn.WebAuthn, error) {
|
||||
base := model.GetSiteURL()
|
||||
return webauthn.New(&webauthn.Config{
|
||||
RPDisplayName: model.GetSettingByName("siteName"), // Display Name for your site
|
||||
RPID: base.Hostname(), // Generally the FQDN for your site
|
||||
RPOrigin: base.String(), // The origin URL for WebAuthn requests
|
||||
})
|
||||
}
|
15
pkg/balancer/balancer.go
Normal file
15
pkg/balancer/balancer.go
Normal file
@ -0,0 +1,15 @@
|
||||
package balancer
|
||||
|
||||
type Balancer interface {
|
||||
NextPeer(nodes interface{}) (error, interface{})
|
||||
}
|
||||
|
||||
// NewBalancer 根据策略标识返回新的负载均衡器
|
||||
func NewBalancer(strategy string) Balancer {
|
||||
switch strategy {
|
||||
case "RoundRobin":
|
||||
return &RoundRobin{}
|
||||
default:
|
||||
return &RoundRobin{}
|
||||
}
|
||||
}
|
8
pkg/balancer/errors.go
Normal file
8
pkg/balancer/errors.go
Normal file
@ -0,0 +1,8 @@
|
||||
package balancer
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrInputNotSlice = errors.New("Input value is not silice")
|
||||
ErrNoAvaliableNode = errors.New("No nodes avaliable")
|
||||
)
|
30
pkg/balancer/roundrobin.go
Normal file
30
pkg/balancer/roundrobin.go
Normal file
@ -0,0 +1,30 @@
|
||||
package balancer
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
type RoundRobin struct {
|
||||
current uint64
|
||||
}
|
||||
|
||||
// NextPeer 返回轮盘的下一节点
|
||||
func (r *RoundRobin) NextPeer(nodes interface{}) (error, interface{}) {
|
||||
v := reflect.ValueOf(nodes)
|
||||
if v.Kind() != reflect.Slice {
|
||||
return ErrInputNotSlice, nil
|
||||
}
|
||||
|
||||
if v.Len() == 0 {
|
||||
return ErrNoAvaliableNode, nil
|
||||
}
|
||||
|
||||
next := r.NextIndex(v.Len())
|
||||
return nil, v.Index(next).Interface()
|
||||
}
|
||||
|
||||
// NextIndex 返回下一个节点下标
|
||||
func (r *RoundRobin) NextIndex(total int) int {
|
||||
return int(atomic.AddUint64(&r.current, uint64(1)) % uint64(total))
|
||||
}
|
104
pkg/cache/driver.go
vendored
Normal file
104
pkg/cache/driver.go
vendored
Normal file
@ -0,0 +1,104 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"encoding/gob"
|
||||
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/conf"
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/util"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func init() {
|
||||
gob.Register(map[string]itemWithTTL{})
|
||||
}
|
||||
|
||||
// Store 缓存存储器
|
||||
var Store Driver = NewMemoStore()
|
||||
|
||||
// Init 初始化缓存
|
||||
func Init() {
|
||||
if conf.RedisConfig.Server != "" && gin.Mode() != gin.TestMode {
|
||||
Store = NewRedisStore(
|
||||
10,
|
||||
conf.RedisConfig.Network,
|
||||
conf.RedisConfig.Server,
|
||||
conf.RedisConfig.User,
|
||||
conf.RedisConfig.Password,
|
||||
conf.RedisConfig.DB,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Restore restores cache from given disk file
|
||||
func Restore(persistFile string) {
|
||||
if err := Store.Restore(persistFile); err != nil {
|
||||
util.Log().Warning("Failed to restore cache from disk: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
func InitSlaveOverwrites() {
|
||||
err := Store.Sets(conf.OptionOverwrite, "setting_")
|
||||
if err != nil {
|
||||
util.Log().Warning("Failed to overwrite database setting: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Driver 键值缓存存储容器
|
||||
type Driver interface {
|
||||
// 设置值,ttl为过期时间,单位为秒
|
||||
Set(key string, value interface{}, ttl int) error
|
||||
|
||||
// 取值,并返回是否成功
|
||||
Get(key string) (interface{}, bool)
|
||||
|
||||
// 批量取值,返回成功取值的map即不存在的值
|
||||
Gets(keys []string, prefix string) (map[string]interface{}, []string)
|
||||
|
||||
// 批量设置值,所有的key都会加上prefix前缀
|
||||
Sets(values map[string]interface{}, prefix string) error
|
||||
|
||||
// 删除值
|
||||
Delete(keys []string, prefix string) error
|
||||
|
||||
// Save in-memory cache to disk
|
||||
Persist(path string) error
|
||||
|
||||
// Restore cache from disk
|
||||
Restore(path string) error
|
||||
}
|
||||
|
||||
// Set 设置缓存值
|
||||
func Set(key string, value interface{}, ttl int) error {
|
||||
return Store.Set(key, value, ttl)
|
||||
}
|
||||
|
||||
// Get 获取缓存值
|
||||
func Get(key string) (interface{}, bool) {
|
||||
return Store.Get(key)
|
||||
}
|
||||
|
||||
// Deletes 删除值
|
||||
func Deletes(keys []string, prefix string) error {
|
||||
return Store.Delete(keys, prefix)
|
||||
}
|
||||
|
||||
// GetSettings 根据名称批量获取设置项缓存
|
||||
func GetSettings(keys []string, prefix string) (map[string]string, []string) {
|
||||
raw, miss := Store.Gets(keys, prefix)
|
||||
|
||||
res := make(map[string]string, len(raw))
|
||||
for k, v := range raw {
|
||||
res[k] = v.(string)
|
||||
}
|
||||
|
||||
return res, miss
|
||||
}
|
||||
|
||||
// SetSettings 批量设置站点设置缓存
|
||||
func SetSettings(values map[string]string, prefix string) error {
|
||||
var toBeSet = make(map[string]interface{}, len(values))
|
||||
for key, value := range values {
|
||||
toBeSet[key] = interface{}(value)
|
||||
}
|
||||
return Store.Sets(toBeSet, prefix)
|
||||
}
|
181
pkg/cache/memo.go
vendored
Normal file
181
pkg/cache/memo.go
vendored
Normal file
@ -0,0 +1,181 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"encoding/gob"
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/util"
|
||||
)
|
||||
|
||||
// MemoStore 内存存储驱动
|
||||
type MemoStore struct {
|
||||
Store *sync.Map
|
||||
}
|
||||
|
||||
// item 存储的对象
|
||||
type itemWithTTL struct {
|
||||
Expires int64
|
||||
Value interface{}
|
||||
}
|
||||
|
||||
const DefaultCacheFile = "cache_persist.bin"
|
||||
|
||||
func newItem(value interface{}, expires int) itemWithTTL {
|
||||
expires64 := int64(expires)
|
||||
if expires > 0 {
|
||||
expires64 = time.Now().Unix() + expires64
|
||||
}
|
||||
return itemWithTTL{
|
||||
Value: value,
|
||||
Expires: expires64,
|
||||
}
|
||||
}
|
||||
|
||||
// getValue 从itemWithTTL中取值
|
||||
func getValue(item interface{}, ok bool) (interface{}, bool) {
|
||||
if !ok {
|
||||
return nil, ok
|
||||
}
|
||||
|
||||
var itemObj itemWithTTL
|
||||
if itemObj, ok = item.(itemWithTTL); !ok {
|
||||
return item, true
|
||||
}
|
||||
|
||||
if itemObj.Expires > 0 && itemObj.Expires < time.Now().Unix() {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
return itemObj.Value, ok
|
||||
|
||||
}
|
||||
|
||||
// GarbageCollect 回收已过期的缓存
|
||||
func (store *MemoStore) GarbageCollect() {
|
||||
store.Store.Range(func(key, value interface{}) bool {
|
||||
if item, ok := value.(itemWithTTL); ok {
|
||||
if item.Expires > 0 && item.Expires < time.Now().Unix() {
|
||||
util.Log().Debug("Cache %q is garbage collected.", key.(string))
|
||||
store.Store.Delete(key)
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
// NewMemoStore 新建内存存储
|
||||
func NewMemoStore() *MemoStore {
|
||||
return &MemoStore{
|
||||
Store: &sync.Map{},
|
||||
}
|
||||
}
|
||||
|
||||
// Set 存储值
|
||||
func (store *MemoStore) Set(key string, value interface{}, ttl int) error {
|
||||
store.Store.Store(key, newItem(value, ttl))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get 取值
|
||||
func (store *MemoStore) Get(key string) (interface{}, bool) {
|
||||
return getValue(store.Store.Load(key))
|
||||
}
|
||||
|
||||
// Gets 批量取值
|
||||
func (store *MemoStore) Gets(keys []string, prefix string) (map[string]interface{}, []string) {
|
||||
var res = make(map[string]interface{})
|
||||
var notFound = make([]string, 0, len(keys))
|
||||
|
||||
for _, key := range keys {
|
||||
if value, ok := getValue(store.Store.Load(prefix + key)); ok {
|
||||
res[key] = value
|
||||
} else {
|
||||
notFound = append(notFound, key)
|
||||
}
|
||||
}
|
||||
|
||||
return res, notFound
|
||||
}
|
||||
|
||||
// Sets 批量设置值
|
||||
func (store *MemoStore) Sets(values map[string]interface{}, prefix string) error {
|
||||
for key, value := range values {
|
||||
store.Store.Store(prefix+key, newItem(value, 0))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete 批量删除值
|
||||
func (store *MemoStore) Delete(keys []string, prefix string) error {
|
||||
for _, key := range keys {
|
||||
store.Store.Delete(prefix + key)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Persist write memory store into cache
|
||||
func (store *MemoStore) Persist(path string) error {
|
||||
persisted := make(map[string]itemWithTTL)
|
||||
store.Store.Range(func(key, value interface{}) bool {
|
||||
v, ok := store.Store.Load(key)
|
||||
if _, ok := getValue(v, ok); ok {
|
||||
persisted[key.(string)] = v.(itemWithTTL)
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
res, err := serializer(persisted)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to serialize cache: %s", err)
|
||||
}
|
||||
|
||||
// err = os.WriteFile(path, res, 0644)
|
||||
file, err := util.CreatNestedFile(path)
|
||||
if err == nil {
|
||||
_, err = file.Write(res)
|
||||
file.Chmod(0644)
|
||||
file.Close()
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Restore memory cache from disk file
|
||||
func (store *MemoStore) Restore(path string) error {
|
||||
if !util.Exists(path) {
|
||||
return nil
|
||||
}
|
||||
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read cache file: %s", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
f.Close()
|
||||
os.Remove(path)
|
||||
}()
|
||||
|
||||
persisted := &item{}
|
||||
dec := gob.NewDecoder(f)
|
||||
if err := dec.Decode(&persisted); err != nil {
|
||||
return fmt.Errorf("unknown cache file format: %s", err)
|
||||
}
|
||||
|
||||
items := persisted.Value.(map[string]itemWithTTL)
|
||||
loaded := 0
|
||||
for k, v := range items {
|
||||
if _, ok := getValue(v, true); ok {
|
||||
loaded++
|
||||
store.Store.Store(k, v)
|
||||
} else {
|
||||
util.Log().Debug("Persisted cache %q is expired.", k)
|
||||
}
|
||||
}
|
||||
|
||||
util.Log().Info("Restored %d items from %q into memory cache.", loaded, path)
|
||||
return nil
|
||||
}
|
227
pkg/cache/redis.go
vendored
Normal file
227
pkg/cache/redis.go
vendored
Normal file
@ -0,0 +1,227 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/gob"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/util"
|
||||
"github.com/gomodule/redigo/redis"
|
||||
)
|
||||
|
||||
// RedisStore redis存储驱动
|
||||
type RedisStore struct {
|
||||
pool *redis.Pool
|
||||
}
|
||||
|
||||
type item struct {
|
||||
Value interface{}
|
||||
}
|
||||
|
||||
func serializer(value interface{}) ([]byte, error) {
|
||||
var buffer bytes.Buffer
|
||||
enc := gob.NewEncoder(&buffer)
|
||||
storeValue := item{
|
||||
Value: value,
|
||||
}
|
||||
err := enc.Encode(storeValue)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buffer.Bytes(), nil
|
||||
}
|
||||
|
||||
func deserializer(value []byte) (interface{}, error) {
|
||||
var res item
|
||||
buffer := bytes.NewReader(value)
|
||||
dec := gob.NewDecoder(buffer)
|
||||
err := dec.Decode(&res)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return res.Value, nil
|
||||
}
|
||||
|
||||
// NewRedisStore 创建新的redis存储
|
||||
func NewRedisStore(size int, network, address, user, password, database string) *RedisStore {
|
||||
return &RedisStore{
|
||||
pool: &redis.Pool{
|
||||
MaxIdle: size,
|
||||
IdleTimeout: 240 * time.Second,
|
||||
TestOnBorrow: func(c redis.Conn, t time.Time) error {
|
||||
_, err := c.Do("PING")
|
||||
return err
|
||||
},
|
||||
Dial: func() (redis.Conn, error) {
|
||||
db, err := strconv.Atoi(database)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
c, err := redis.Dial(
|
||||
network,
|
||||
address,
|
||||
redis.DialDatabase(db),
|
||||
redis.DialUsername(user),
|
||||
redis.DialPassword(password),
|
||||
)
|
||||
if err != nil {
|
||||
util.Log().Panic("Failed to create Redis connection: %s", err)
|
||||
}
|
||||
return c, nil
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Set 存储值
|
||||
func (store *RedisStore) Set(key string, value interface{}, ttl int) error {
|
||||
rc := store.pool.Get()
|
||||
defer rc.Close()
|
||||
|
||||
serialized, err := serializer(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if rc.Err() != nil {
|
||||
return rc.Err()
|
||||
}
|
||||
|
||||
if ttl > 0 {
|
||||
_, err = rc.Do("SETEX", key, ttl, serialized)
|
||||
} else {
|
||||
_, err = rc.Do("SET", key, serialized)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
|
||||
}
|
||||
|
||||
// Get 取值
|
||||
func (store *RedisStore) Get(key string) (interface{}, bool) {
|
||||
rc := store.pool.Get()
|
||||
defer rc.Close()
|
||||
if rc.Err() != nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
v, err := redis.Bytes(rc.Do("GET", key))
|
||||
if err != nil || v == nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
finalValue, err := deserializer(v)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
return finalValue, true
|
||||
|
||||
}
|
||||
|
||||
// Gets 批量取值
|
||||
func (store *RedisStore) Gets(keys []string, prefix string) (map[string]interface{}, []string) {
|
||||
rc := store.pool.Get()
|
||||
defer rc.Close()
|
||||
if rc.Err() != nil {
|
||||
return nil, keys
|
||||
}
|
||||
|
||||
var queryKeys = make([]string, len(keys))
|
||||
for key, value := range keys {
|
||||
queryKeys[key] = prefix + value
|
||||
}
|
||||
|
||||
v, err := redis.ByteSlices(rc.Do("MGET", redis.Args{}.AddFlat(queryKeys)...))
|
||||
if err != nil {
|
||||
return nil, keys
|
||||
}
|
||||
|
||||
var res = make(map[string]interface{})
|
||||
var missed = make([]string, 0, len(keys))
|
||||
|
||||
for key, value := range v {
|
||||
decoded, err := deserializer(value)
|
||||
if err != nil || decoded == nil {
|
||||
missed = append(missed, keys[key])
|
||||
} else {
|
||||
res[keys[key]] = decoded
|
||||
}
|
||||
}
|
||||
// 解码所得值
|
||||
return res, missed
|
||||
}
|
||||
|
||||
// Sets 批量设置值
|
||||
func (store *RedisStore) Sets(values map[string]interface{}, prefix string) error {
|
||||
rc := store.pool.Get()
|
||||
defer rc.Close()
|
||||
if rc.Err() != nil {
|
||||
return rc.Err()
|
||||
}
|
||||
var setValues = make(map[string]interface{})
|
||||
|
||||
// 编码待设置值
|
||||
for key, value := range values {
|
||||
serialized, err := serializer(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
setValues[prefix+key] = serialized
|
||||
}
|
||||
|
||||
_, err := rc.Do("MSET", redis.Args{}.AddFlat(setValues)...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
|
||||
}
|
||||
|
||||
// Delete 批量删除给定的键
|
||||
func (store *RedisStore) Delete(keys []string, prefix string) error {
|
||||
rc := store.pool.Get()
|
||||
defer rc.Close()
|
||||
if rc.Err() != nil {
|
||||
return rc.Err()
|
||||
}
|
||||
|
||||
// 处理前缀
|
||||
for i := 0; i < len(keys); i++ {
|
||||
keys[i] = prefix + keys[i]
|
||||
}
|
||||
|
||||
_, err := rc.Do("DEL", redis.Args{}.AddFlat(keys)...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteAll 批量所有键
|
||||
func (store *RedisStore) DeleteAll() error {
|
||||
rc := store.pool.Get()
|
||||
defer rc.Close()
|
||||
if rc.Err() != nil {
|
||||
return rc.Err()
|
||||
}
|
||||
|
||||
_, err := rc.Do("FLUSHDB")
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// Persist Dummy implementation
|
||||
func (store *RedisStore) Persist(path string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Restore dummy implementation
|
||||
func (store *RedisStore) Restore(path string) error {
|
||||
return nil
|
||||
}
|
210
pkg/cluster/controller.go
Normal file
210
pkg/cluster/controller.go
Normal file
@ -0,0 +1,210 @@
|
||||
package cluster
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/gob"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"sync"
|
||||
|
||||
model "github.com/cloudreve/Cloudreve/v3/models"
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/aria2/common"
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/aria2/rpc"
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/auth"
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/mq"
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/request"
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/serializer"
|
||||
"github.com/jinzhu/gorm"
|
||||
)
|
||||
|
||||
var DefaultController Controller
|
||||
|
||||
// Controller controls communications between master and slave
|
||||
type Controller interface {
|
||||
// Handle heartbeat sent from master
|
||||
HandleHeartBeat(*serializer.NodePingReq) (serializer.NodePingResp, error)
|
||||
|
||||
// Get Aria2 Instance by master node ID
|
||||
GetAria2Instance(string) (common.Aria2, error)
|
||||
|
||||
// Send event change message to master node
|
||||
SendNotification(string, string, mq.Message) error
|
||||
|
||||
// Submit async task into task pool
|
||||
SubmitTask(string, interface{}, string, func(interface{})) error
|
||||
|
||||
// Get master node info
|
||||
GetMasterInfo(string) (*MasterInfo, error)
|
||||
|
||||
// Get master Oauth based policy credential
|
||||
GetPolicyOauthToken(string, uint) (string, error)
|
||||
}
|
||||
|
||||
type slaveController struct {
|
||||
masters map[string]MasterInfo
|
||||
lock sync.RWMutex
|
||||
}
|
||||
|
||||
// info of master node
|
||||
type MasterInfo struct {
|
||||
ID string
|
||||
TTL int
|
||||
URL *url.URL
|
||||
// used to invoke aria2 rpc calls
|
||||
Instance Node
|
||||
Client request.Client
|
||||
|
||||
jobTracker map[string]bool
|
||||
}
|
||||
|
||||
func InitController() {
|
||||
DefaultController = &slaveController{
|
||||
masters: make(map[string]MasterInfo),
|
||||
}
|
||||
gob.Register(rpc.StatusInfo{})
|
||||
}
|
||||
|
||||
func (c *slaveController) HandleHeartBeat(req *serializer.NodePingReq) (serializer.NodePingResp, error) {
|
||||
c.lock.Lock()
|
||||
defer c.lock.Unlock()
|
||||
|
||||
req.Node.AfterFind()
|
||||
|
||||
// close old node if exist
|
||||
origin, ok := c.masters[req.SiteID]
|
||||
|
||||
if (ok && req.IsUpdate) || !ok {
|
||||
if ok {
|
||||
origin.Instance.Kill()
|
||||
}
|
||||
|
||||
masterUrl, err := url.Parse(req.SiteURL)
|
||||
if err != nil {
|
||||
return serializer.NodePingResp{}, err
|
||||
}
|
||||
|
||||
c.masters[req.SiteID] = MasterInfo{
|
||||
ID: req.SiteID,
|
||||
URL: masterUrl,
|
||||
TTL: req.CredentialTTL,
|
||||
Client: request.NewClient(
|
||||
request.WithEndpoint(masterUrl.String()),
|
||||
request.WithSlaveMeta(fmt.Sprintf("%d", req.Node.ID)),
|
||||
request.WithCredential(auth.HMACAuth{
|
||||
SecretKey: []byte(req.Node.MasterKey),
|
||||
}, int64(req.CredentialTTL)),
|
||||
),
|
||||
jobTracker: make(map[string]bool),
|
||||
Instance: NewNodeFromDBModel(&model.Node{
|
||||
Model: gorm.Model{ID: req.Node.ID},
|
||||
MasterKey: req.Node.MasterKey,
|
||||
Type: model.MasterNodeType,
|
||||
Aria2Enabled: req.Node.Aria2Enabled,
|
||||
Aria2OptionsSerialized: req.Node.Aria2OptionsSerialized,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
return serializer.NodePingResp{}, nil
|
||||
}
|
||||
|
||||
func (c *slaveController) GetAria2Instance(id string) (common.Aria2, error) {
|
||||
c.lock.RLock()
|
||||
defer c.lock.RUnlock()
|
||||
|
||||
if node, ok := c.masters[id]; ok {
|
||||
return node.Instance.GetAria2Instance(), nil
|
||||
}
|
||||
|
||||
return nil, ErrMasterNotFound
|
||||
}
|
||||
|
||||
func (c *slaveController) SendNotification(id, subject string, msg mq.Message) error {
|
||||
c.lock.RLock()
|
||||
|
||||
if node, ok := c.masters[id]; ok {
|
||||
c.lock.RUnlock()
|
||||
|
||||
body := bytes.Buffer{}
|
||||
enc := gob.NewEncoder(&body)
|
||||
if err := enc.Encode(&msg); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
res, err := node.Client.Request(
|
||||
"PUT",
|
||||
fmt.Sprintf("/api/v3/slave/notification/%s", subject),
|
||||
&body,
|
||||
).CheckHTTPResponse(200).DecodeResponse()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if res.Code != 0 {
|
||||
return serializer.NewErrorFromResponse(res)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
c.lock.RUnlock()
|
||||
return ErrMasterNotFound
|
||||
}
|
||||
|
||||
// SubmitTask 提交异步任务
|
||||
func (c *slaveController) SubmitTask(id string, job interface{}, hash string, submitter func(interface{})) error {
|
||||
c.lock.RLock()
|
||||
defer c.lock.RUnlock()
|
||||
|
||||
if node, ok := c.masters[id]; ok {
|
||||
if _, ok := node.jobTracker[hash]; ok {
|
||||
// 任务已存在,直接返回
|
||||
return nil
|
||||
}
|
||||
|
||||
node.jobTracker[hash] = true
|
||||
submitter(job)
|
||||
return nil
|
||||
}
|
||||
|
||||
return ErrMasterNotFound
|
||||
}
|
||||
|
||||
// GetMasterInfo 获取主机节点信息
|
||||
func (c *slaveController) GetMasterInfo(id string) (*MasterInfo, error) {
|
||||
c.lock.RLock()
|
||||
defer c.lock.RUnlock()
|
||||
|
||||
if node, ok := c.masters[id]; ok {
|
||||
return &node, nil
|
||||
}
|
||||
|
||||
return nil, ErrMasterNotFound
|
||||
}
|
||||
|
||||
// GetPolicyOauthToken 获取主机存储策略 Oauth 凭证
|
||||
func (c *slaveController) GetPolicyOauthToken(id string, policyID uint) (string, error) {
|
||||
c.lock.RLock()
|
||||
|
||||
if node, ok := c.masters[id]; ok {
|
||||
c.lock.RUnlock()
|
||||
|
||||
res, err := node.Client.Request(
|
||||
"GET",
|
||||
fmt.Sprintf("/api/v3/slave/credential/%d", policyID),
|
||||
nil,
|
||||
).CheckHTTPResponse(200).DecodeResponse()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if res.Code != 0 {
|
||||
return "", serializer.NewErrorFromResponse(res)
|
||||
}
|
||||
|
||||
return res.Data.(string), nil
|
||||
}
|
||||
|
||||
c.lock.RUnlock()
|
||||
return "", ErrMasterNotFound
|
||||
}
|
12
pkg/cluster/errors.go
Normal file
12
pkg/cluster/errors.go
Normal file
@ -0,0 +1,12 @@
|
||||
package cluster
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/serializer"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrFeatureNotExist = errors.New("No nodes in nodepool match the feature specificed")
|
||||
ErrIlegalPath = errors.New("path out of boundary of setting temp folder")
|
||||
ErrMasterNotFound = serializer.NewError(serializer.CodeMasterNotFound, "Unknown master node id", nil)
|
||||
)
|
272
pkg/cluster/master.go
Normal file
272
pkg/cluster/master.go
Normal file
@ -0,0 +1,272 @@
|
||||
package cluster
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
model "github.com/cloudreve/Cloudreve/v3/models"
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/aria2/common"
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/aria2/rpc"
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/auth"
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/mq"
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/serializer"
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/util"
|
||||
"github.com/gofrs/uuid"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
deleteTempFileDuration = 60 * time.Second
|
||||
statusRetryDuration = 10 * time.Second
|
||||
)
|
||||
|
||||
type MasterNode struct {
|
||||
Model *model.Node
|
||||
aria2RPC rpcService
|
||||
lock sync.RWMutex
|
||||
}
|
||||
|
||||
// RPCService 通过RPC服务的Aria2任务管理器
|
||||
type rpcService struct {
|
||||
Caller rpc.Client
|
||||
Initialized bool
|
||||
|
||||
retryDuration time.Duration
|
||||
deletePaddingDuration time.Duration
|
||||
parent *MasterNode
|
||||
options *clientOptions
|
||||
}
|
||||
|
||||
type clientOptions struct {
|
||||
Options map[string]interface{} // 创建下载时额外添加的设置
|
||||
}
|
||||
|
||||
// Init 初始化节点
|
||||
func (node *MasterNode) Init(nodeModel *model.Node) {
|
||||
node.lock.Lock()
|
||||
node.Model = nodeModel
|
||||
node.aria2RPC.parent = node
|
||||
node.aria2RPC.retryDuration = statusRetryDuration
|
||||
node.aria2RPC.deletePaddingDuration = deleteTempFileDuration
|
||||
node.lock.Unlock()
|
||||
|
||||
node.lock.RLock()
|
||||
if node.Model.Aria2Enabled {
|
||||
node.lock.RUnlock()
|
||||
node.aria2RPC.Init()
|
||||
return
|
||||
}
|
||||
node.lock.RUnlock()
|
||||
}
|
||||
|
||||
func (node *MasterNode) ID() uint {
|
||||
node.lock.RLock()
|
||||
defer node.lock.RUnlock()
|
||||
|
||||
return node.Model.ID
|
||||
}
|
||||
|
||||
func (node *MasterNode) Ping(req *serializer.NodePingReq) (*serializer.NodePingResp, error) {
|
||||
return &serializer.NodePingResp{}, nil
|
||||
}
|
||||
|
||||
// IsFeatureEnabled 查询节点的某项功能是否启用
|
||||
func (node *MasterNode) IsFeatureEnabled(feature string) bool {
|
||||
node.lock.RLock()
|
||||
defer node.lock.RUnlock()
|
||||
|
||||
switch feature {
|
||||
case "aria2":
|
||||
return node.Model.Aria2Enabled
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (node *MasterNode) MasterAuthInstance() auth.Auth {
|
||||
node.lock.RLock()
|
||||
defer node.lock.RUnlock()
|
||||
|
||||
return auth.HMACAuth{SecretKey: []byte(node.Model.MasterKey)}
|
||||
}
|
||||
|
||||
func (node *MasterNode) SlaveAuthInstance() auth.Auth {
|
||||
node.lock.RLock()
|
||||
defer node.lock.RUnlock()
|
||||
|
||||
return auth.HMACAuth{SecretKey: []byte(node.Model.SlaveKey)}
|
||||
}
|
||||
|
||||
// SubscribeStatusChange 订阅节点状态更改
|
||||
func (node *MasterNode) SubscribeStatusChange(callback func(isActive bool, id uint)) {
|
||||
}
|
||||
|
||||
// IsActive 返回节点是否在线
|
||||
func (node *MasterNode) IsActive() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// Kill 结束aria2请求
|
||||
func (node *MasterNode) Kill() {
|
||||
if node.aria2RPC.Caller != nil {
|
||||
node.aria2RPC.Caller.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// GetAria2Instance 获取主机Aria2实例
|
||||
func (node *MasterNode) GetAria2Instance() common.Aria2 {
|
||||
node.lock.RLock()
|
||||
|
||||
if !node.Model.Aria2Enabled {
|
||||
node.lock.RUnlock()
|
||||
return &common.DummyAria2{}
|
||||
}
|
||||
|
||||
if !node.aria2RPC.Initialized {
|
||||
node.lock.RUnlock()
|
||||
node.aria2RPC.Init()
|
||||
return &common.DummyAria2{}
|
||||
}
|
||||
|
||||
defer node.lock.RUnlock()
|
||||
return &node.aria2RPC
|
||||
}
|
||||
|
||||
func (node *MasterNode) IsMater() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (node *MasterNode) DBModel() *model.Node {
|
||||
node.lock.RLock()
|
||||
defer node.lock.RUnlock()
|
||||
|
||||
return node.Model
|
||||
}
|
||||
|
||||
func (r *rpcService) Init() error {
|
||||
r.parent.lock.Lock()
|
||||
defer r.parent.lock.Unlock()
|
||||
r.Initialized = false
|
||||
|
||||
// 客户端已存在,则关闭先前连接
|
||||
if r.Caller != nil {
|
||||
r.Caller.Close()
|
||||
}
|
||||
|
||||
// 解析RPC服务地址
|
||||
server, err := url.Parse(r.parent.Model.Aria2OptionsSerialized.Server)
|
||||
if err != nil {
|
||||
util.Log().Warning("Failed to parse Aria2 RPC server URL: %s", err)
|
||||
return err
|
||||
}
|
||||
server.Path = "/jsonrpc"
|
||||
|
||||
// 加载自定义下载配置
|
||||
var globalOptions map[string]interface{}
|
||||
if r.parent.Model.Aria2OptionsSerialized.Options != "" {
|
||||
err = json.Unmarshal([]byte(r.parent.Model.Aria2OptionsSerialized.Options), &globalOptions)
|
||||
if err != nil {
|
||||
util.Log().Warning("Failed to parse aria2 options: %s", err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
r.options = &clientOptions{
|
||||
Options: globalOptions,
|
||||
}
|
||||
timeout := r.parent.Model.Aria2OptionsSerialized.Timeout
|
||||
caller, err := rpc.New(context.Background(), server.String(), r.parent.Model.Aria2OptionsSerialized.Token, time.Duration(timeout)*time.Second, mq.GlobalMQ)
|
||||
|
||||
r.Caller = caller
|
||||
r.Initialized = err == nil
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *rpcService) CreateTask(task *model.Download, groupOptions map[string]interface{}) (string, error) {
|
||||
r.parent.lock.RLock()
|
||||
// 生成存储路径
|
||||
guid, _ := uuid.NewV4()
|
||||
path := filepath.Join(
|
||||
r.parent.Model.Aria2OptionsSerialized.TempPath,
|
||||
"aria2",
|
||||
guid.String(),
|
||||
)
|
||||
r.parent.lock.RUnlock()
|
||||
|
||||
// 创建下载任务
|
||||
options := map[string]interface{}{
|
||||
"dir": path,
|
||||
}
|
||||
for k, v := range r.options.Options {
|
||||
options[k] = v
|
||||
}
|
||||
for k, v := range groupOptions {
|
||||
options[k] = v
|
||||
}
|
||||
|
||||
gid, err := r.Caller.AddURI(task.Source, options)
|
||||
if err != nil || gid == "" {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return gid, nil
|
||||
}
|
||||
|
||||
func (r *rpcService) Status(task *model.Download) (rpc.StatusInfo, error) {
|
||||
res, err := r.Caller.TellStatus(task.GID)
|
||||
if err != nil {
|
||||
// 失败后重试
|
||||
util.Log().Debug("Failed to get download task status, please retry later: %s", err)
|
||||
time.Sleep(r.retryDuration)
|
||||
res, err = r.Caller.TellStatus(task.GID)
|
||||
}
|
||||
|
||||
return res, err
|
||||
}
|
||||
|
||||
func (r *rpcService) Cancel(task *model.Download) error {
|
||||
// 取消下载任务
|
||||
_, err := r.Caller.Remove(task.GID)
|
||||
if err != nil {
|
||||
util.Log().Warning("Failed to cancel task %q: %s", task.GID, err)
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *rpcService) Select(task *model.Download, files []int) error {
|
||||
var selected = make([]string, len(files))
|
||||
for i := 0; i < len(files); i++ {
|
||||
selected[i] = strconv.Itoa(files[i])
|
||||
}
|
||||
_, err := r.Caller.ChangeOption(task.GID, map[string]interface{}{"select-file": strings.Join(selected, ",")})
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *rpcService) GetConfig() model.Aria2Option {
|
||||
r.parent.lock.RLock()
|
||||
defer r.parent.lock.RUnlock()
|
||||
|
||||
return r.parent.Model.Aria2OptionsSerialized
|
||||
}
|
||||
|
||||
func (s *rpcService) DeleteTempFile(task *model.Download) error {
|
||||
s.parent.lock.RLock()
|
||||
defer s.parent.lock.RUnlock()
|
||||
|
||||
// 避免被aria2占用,异步执行删除
|
||||
go func(d time.Duration, src string) {
|
||||
time.Sleep(d)
|
||||
err := os.RemoveAll(src)
|
||||
if err != nil {
|
||||
util.Log().Warning("Failed to delete temp download folder: %q: %s", src, err)
|
||||
}
|
||||
}(s.deletePaddingDuration, task.Parent)
|
||||
|
||||
return nil
|
||||
}
|
60
pkg/cluster/node.go
Normal file
60
pkg/cluster/node.go
Normal file
@ -0,0 +1,60 @@
|
||||
package cluster
|
||||
|
||||
import (
|
||||
model "github.com/cloudreve/Cloudreve/v3/models"
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/aria2/common"
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/auth"
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/serializer"
|
||||
)
|
||||
|
||||
type Node interface {
|
||||
// Init a node from database model
|
||||
Init(node *model.Node)
|
||||
|
||||
// Check if given feature is enabled
|
||||
IsFeatureEnabled(feature string) bool
|
||||
|
||||
// Subscribe node status change to a callback function
|
||||
SubscribeStatusChange(callback func(isActive bool, id uint))
|
||||
|
||||
// Ping the node
|
||||
Ping(req *serializer.NodePingReq) (*serializer.NodePingResp, error)
|
||||
|
||||
// Returns if the node is active
|
||||
IsActive() bool
|
||||
|
||||
// Get instances for aria2 calls
|
||||
GetAria2Instance() common.Aria2
|
||||
|
||||
// Returns unique id of this node
|
||||
ID() uint
|
||||
|
||||
// Kill node and recycle resources
|
||||
Kill()
|
||||
|
||||
// Returns if current node is master node
|
||||
IsMater() bool
|
||||
|
||||
// Get auth instance used to check RPC call from slave to master
|
||||
MasterAuthInstance() auth.Auth
|
||||
|
||||
// Get auth instance used to check RPC call from master to slave
|
||||
SlaveAuthInstance() auth.Auth
|
||||
|
||||
// Get node DB model
|
||||
DBModel() *model.Node
|
||||
}
|
||||
|
||||
// Create new node from DB model
|
||||
func NewNodeFromDBModel(node *model.Node) Node {
|
||||
switch node.Type {
|
||||
case model.SlaveNodeType:
|
||||
slave := &SlaveNode{}
|
||||
slave.Init(node)
|
||||
return slave
|
||||
default:
|
||||
master := &MasterNode{}
|
||||
master.Init(node)
|
||||
return master
|
||||
}
|
||||
}
|
213
pkg/cluster/pool.go
Normal file
213
pkg/cluster/pool.go
Normal file
@ -0,0 +1,213 @@
|
||||
package cluster
|
||||
|
||||
import (
|
||||
model "github.com/cloudreve/Cloudreve/v3/models"
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/balancer"
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/util"
|
||||
"github.com/samber/lo"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var Default *NodePool
|
||||
|
||||
// 需要分类的节点组
|
||||
var featureGroup = []string{"aria2"}
|
||||
|
||||
// Pool 节点池
|
||||
type Pool interface {
|
||||
// Returns active node selected by given feature and load balancer
|
||||
BalanceNodeByFeature(feature string, lb balancer.Balancer, available []uint, prefer uint) (error, Node)
|
||||
|
||||
// Returns node by ID
|
||||
GetNodeByID(id uint) Node
|
||||
|
||||
// Add given node into pool. If node existed, refresh node.
|
||||
Add(node *model.Node)
|
||||
|
||||
// Delete and kill node from pool by given node id
|
||||
Delete(id uint)
|
||||
}
|
||||
|
||||
// NodePool 通用节点池
|
||||
type NodePool struct {
|
||||
active map[uint]Node
|
||||
inactive map[uint]Node
|
||||
|
||||
featureMap map[string][]Node
|
||||
|
||||
lock sync.RWMutex
|
||||
}
|
||||
|
||||
// Init 初始化从机节点池
|
||||
func Init() {
|
||||
Default = &NodePool{}
|
||||
Default.Init()
|
||||
if err := Default.initFromDB(); err != nil {
|
||||
util.Log().Warning("Failed to initialize node pool: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (pool *NodePool) Init() {
|
||||
pool.lock.Lock()
|
||||
defer pool.lock.Unlock()
|
||||
|
||||
pool.featureMap = make(map[string][]Node)
|
||||
pool.active = make(map[uint]Node)
|
||||
pool.inactive = make(map[uint]Node)
|
||||
}
|
||||
|
||||
func (pool *NodePool) buildIndexMap() {
|
||||
pool.lock.Lock()
|
||||
for _, feature := range featureGroup {
|
||||
pool.featureMap[feature] = make([]Node, 0)
|
||||
}
|
||||
|
||||
for _, v := range pool.active {
|
||||
for _, feature := range featureGroup {
|
||||
if v.IsFeatureEnabled(feature) {
|
||||
pool.featureMap[feature] = append(pool.featureMap[feature], v)
|
||||
}
|
||||
}
|
||||
}
|
||||
pool.lock.Unlock()
|
||||
}
|
||||
|
||||
func (pool *NodePool) GetNodeByID(id uint) Node {
|
||||
pool.lock.RLock()
|
||||
defer pool.lock.RUnlock()
|
||||
|
||||
if node, ok := pool.active[id]; ok {
|
||||
return node
|
||||
}
|
||||
|
||||
return pool.inactive[id]
|
||||
}
|
||||
|
||||
func (pool *NodePool) nodeStatusChange(isActive bool, id uint) {
|
||||
util.Log().Debug("Slave node [ID=%d] status changed to [Active=%t].", id, isActive)
|
||||
var node Node
|
||||
pool.lock.Lock()
|
||||
if n, ok := pool.inactive[id]; ok {
|
||||
node = n
|
||||
delete(pool.inactive, id)
|
||||
} else {
|
||||
node = pool.active[id]
|
||||
delete(pool.active, id)
|
||||
}
|
||||
|
||||
if isActive {
|
||||
pool.active[id] = node
|
||||
} else {
|
||||
pool.inactive[id] = node
|
||||
}
|
||||
pool.lock.Unlock()
|
||||
|
||||
pool.buildIndexMap()
|
||||
}
|
||||
|
||||
func (pool *NodePool) initFromDB() error {
|
||||
nodes, err := model.GetNodesByStatus(model.NodeActive)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
pool.lock.Lock()
|
||||
for i := 0; i < len(nodes); i++ {
|
||||
pool.add(&nodes[i])
|
||||
}
|
||||
pool.lock.Unlock()
|
||||
|
||||
pool.buildIndexMap()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (pool *NodePool) add(node *model.Node) {
|
||||
newNode := NewNodeFromDBModel(node)
|
||||
if newNode.IsActive() {
|
||||
pool.active[node.ID] = newNode
|
||||
} else {
|
||||
pool.inactive[node.ID] = newNode
|
||||
}
|
||||
|
||||
// 订阅节点状态变更
|
||||
newNode.SubscribeStatusChange(func(isActive bool, id uint) {
|
||||
pool.nodeStatusChange(isActive, id)
|
||||
})
|
||||
}
|
||||
|
||||
func (pool *NodePool) Add(node *model.Node) {
|
||||
pool.lock.Lock()
|
||||
defer pool.buildIndexMap()
|
||||
defer pool.lock.Unlock()
|
||||
|
||||
var (
|
||||
old Node
|
||||
ok bool
|
||||
)
|
||||
if old, ok = pool.active[node.ID]; !ok {
|
||||
old, ok = pool.inactive[node.ID]
|
||||
}
|
||||
if old != nil {
|
||||
go old.Init(node)
|
||||
return
|
||||
}
|
||||
|
||||
pool.add(node)
|
||||
}
|
||||
|
||||
func (pool *NodePool) Delete(id uint) {
|
||||
pool.lock.Lock()
|
||||
defer pool.buildIndexMap()
|
||||
defer pool.lock.Unlock()
|
||||
|
||||
if node, ok := pool.active[id]; ok {
|
||||
node.Kill()
|
||||
delete(pool.active, id)
|
||||
return
|
||||
}
|
||||
|
||||
if node, ok := pool.inactive[id]; ok {
|
||||
node.Kill()
|
||||
delete(pool.inactive, id)
|
||||
return
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// BalanceNodeByFeature 根据 feature 和 LoadBalancer 取出节点
|
||||
func (pool *NodePool) BalanceNodeByFeature(feature string, lb balancer.Balancer,
|
||||
available []uint, prefer uint) (error, Node) {
|
||||
pool.lock.RLock()
|
||||
defer pool.lock.RUnlock()
|
||||
if nodes, ok := pool.featureMap[feature]; ok {
|
||||
// Find nodes that are allowed to be used in user group
|
||||
availableNodes := nodes
|
||||
if len(available) > 0 {
|
||||
idHash := make(map[uint]struct{}, len(available))
|
||||
for _, id := range available {
|
||||
idHash[id] = struct{}{}
|
||||
}
|
||||
|
||||
availableNodes = lo.Filter[Node](nodes, func(node Node, index int) bool {
|
||||
_, exist := idHash[node.ID()]
|
||||
return exist
|
||||
})
|
||||
}
|
||||
|
||||
// Return preferred node if exists
|
||||
if preferredNode, found := lo.Find[Node](availableNodes, func(node Node) bool {
|
||||
return node.ID() == prefer
|
||||
}); found {
|
||||
return nil, preferredNode
|
||||
}
|
||||
|
||||
err, res := lb.NextPeer(availableNodes)
|
||||
if err == nil {
|
||||
return nil, res.(Node)
|
||||
}
|
||||
|
||||
return err, nil
|
||||
}
|
||||
|
||||
return ErrFeatureNotExist, nil
|
||||
}
|
451
pkg/cluster/slave.go
Normal file
451
pkg/cluster/slave.go
Normal file
@ -0,0 +1,451 @@
|
||||
package cluster
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
model "github.com/cloudreve/Cloudreve/v3/models"
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/aria2/common"
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/aria2/rpc"
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/auth"
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/conf"
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/request"
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/serializer"
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/util"
|
||||
"io"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type SlaveNode struct {
|
||||
Model *model.Node
|
||||
Active bool
|
||||
|
||||
caller slaveCaller
|
||||
callback func(bool, uint)
|
||||
close chan bool
|
||||
lock sync.RWMutex
|
||||
}
|
||||
|
||||
type slaveCaller struct {
|
||||
parent *SlaveNode
|
||||
Client request.Client
|
||||
}
|
||||
|
||||
// Init 初始化节点
|
||||
func (node *SlaveNode) Init(nodeModel *model.Node) {
|
||||
node.lock.Lock()
|
||||
node.Model = nodeModel
|
||||
|
||||
// Init http request client
|
||||
var endpoint *url.URL
|
||||
if serverURL, err := url.Parse(node.Model.Server); err == nil {
|
||||
var controller *url.URL
|
||||
controller, _ = url.Parse("/api/v3/slave/")
|
||||
endpoint = serverURL.ResolveReference(controller)
|
||||
}
|
||||
|
||||
signTTL := model.GetIntSetting("slave_api_timeout", 60)
|
||||
node.caller.Client = request.NewClient(
|
||||
request.WithMasterMeta(),
|
||||
request.WithTimeout(time.Duration(signTTL)*time.Second),
|
||||
request.WithCredential(auth.HMACAuth{SecretKey: []byte(nodeModel.SlaveKey)}, int64(signTTL)),
|
||||
request.WithEndpoint(endpoint.String()),
|
||||
)
|
||||
|
||||
node.caller.parent = node
|
||||
if node.close != nil {
|
||||
node.lock.Unlock()
|
||||
node.close <- true
|
||||
go node.StartPingLoop()
|
||||
} else {
|
||||
node.Active = true
|
||||
node.lock.Unlock()
|
||||
go node.StartPingLoop()
|
||||
}
|
||||
}
|
||||
|
||||
// IsFeatureEnabled 查询节点的某项功能是否启用
|
||||
func (node *SlaveNode) IsFeatureEnabled(feature string) bool {
|
||||
node.lock.RLock()
|
||||
defer node.lock.RUnlock()
|
||||
|
||||
switch feature {
|
||||
case "aria2":
|
||||
return node.Model.Aria2Enabled
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// SubscribeStatusChange 订阅节点状态更改
|
||||
func (node *SlaveNode) SubscribeStatusChange(callback func(bool, uint)) {
|
||||
node.lock.Lock()
|
||||
node.callback = callback
|
||||
node.lock.Unlock()
|
||||
}
|
||||
|
||||
// Ping 从机节点,返回从机负载
|
||||
func (node *SlaveNode) Ping(req *serializer.NodePingReq) (*serializer.NodePingResp, error) {
|
||||
node.lock.RLock()
|
||||
defer node.lock.RUnlock()
|
||||
|
||||
reqBodyEncoded, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
bodyReader := strings.NewReader(string(reqBodyEncoded))
|
||||
|
||||
resp, err := node.caller.Client.Request(
|
||||
"POST",
|
||||
"heartbeat",
|
||||
bodyReader,
|
||||
).CheckHTTPResponse(200).DecodeResponse()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 处理列取结果
|
||||
if resp.Code != 0 {
|
||||
return nil, serializer.NewErrorFromResponse(resp)
|
||||
}
|
||||
|
||||
var res serializer.NodePingResp
|
||||
|
||||
if resStr, ok := resp.Data.(string); ok {
|
||||
err = json.Unmarshal([]byte(resStr), &res)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return &res, nil
|
||||
}
|
||||
|
||||
// IsActive 返回节点是否在线
|
||||
func (node *SlaveNode) IsActive() bool {
|
||||
node.lock.RLock()
|
||||
defer node.lock.RUnlock()
|
||||
|
||||
return node.Active
|
||||
}
|
||||
|
||||
// Kill 结束节点内相关循环
|
||||
func (node *SlaveNode) Kill() {
|
||||
node.lock.RLock()
|
||||
defer node.lock.RUnlock()
|
||||
|
||||
if node.close != nil {
|
||||
close(node.close)
|
||||
}
|
||||
}
|
||||
|
||||
// GetAria2Instance 获取从机Aria2实例
|
||||
func (node *SlaveNode) GetAria2Instance() common.Aria2 {
|
||||
node.lock.RLock()
|
||||
defer node.lock.RUnlock()
|
||||
|
||||
if !node.Model.Aria2Enabled {
|
||||
return &common.DummyAria2{}
|
||||
}
|
||||
|
||||
return &node.caller
|
||||
}
|
||||
|
||||
func (node *SlaveNode) ID() uint {
|
||||
node.lock.RLock()
|
||||
defer node.lock.RUnlock()
|
||||
|
||||
return node.Model.ID
|
||||
}
|
||||
|
||||
func (node *SlaveNode) StartPingLoop() {
|
||||
node.lock.Lock()
|
||||
node.close = make(chan bool)
|
||||
node.lock.Unlock()
|
||||
|
||||
tickDuration := time.Duration(model.GetIntSetting("slave_ping_interval", 300)) * time.Second
|
||||
recoverDuration := time.Duration(model.GetIntSetting("slave_recover_interval", 600)) * time.Second
|
||||
pingTicker := time.Duration(0)
|
||||
|
||||
util.Log().Debug("Slave node %q heartbeat loop started.", node.Model.Name)
|
||||
retry := 0
|
||||
recoverMode := false
|
||||
isFirstLoop := true
|
||||
|
||||
loop:
|
||||
for {
|
||||
select {
|
||||
case <-time.After(pingTicker):
|
||||
if pingTicker == 0 {
|
||||
pingTicker = tickDuration
|
||||
}
|
||||
|
||||
util.Log().Debug("Slave node %q send ping.", node.Model.Name)
|
||||
res, err := node.Ping(node.getHeartbeatContent(isFirstLoop))
|
||||
isFirstLoop = false
|
||||
|
||||
if err != nil {
|
||||
util.Log().Debug("Error while ping slave node %q: %s", node.Model.Name, err)
|
||||
retry++
|
||||
if retry >= model.GetIntSetting("slave_node_retry", 3) {
|
||||
util.Log().Debug("Retry threshold for pinging slave node %q exceeded, mark it as offline.", node.Model.Name)
|
||||
node.changeStatus(false)
|
||||
|
||||
if !recoverMode {
|
||||
// 启动恢复监控循环
|
||||
util.Log().Debug("Slave node %q entered recovery mode.", node.Model.Name)
|
||||
pingTicker = recoverDuration
|
||||
recoverMode = true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if recoverMode {
|
||||
util.Log().Debug("Slave node %q recovered.", node.Model.Name)
|
||||
pingTicker = tickDuration
|
||||
recoverMode = false
|
||||
isFirstLoop = true
|
||||
}
|
||||
|
||||
util.Log().Debug("Status of slave node %q: %s", node.Model.Name, res)
|
||||
node.changeStatus(true)
|
||||
retry = 0
|
||||
}
|
||||
|
||||
case <-node.close:
|
||||
util.Log().Debug("Slave node %q received shutdown signal.", node.Model.Name)
|
||||
break loop
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (node *SlaveNode) IsMater() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (node *SlaveNode) MasterAuthInstance() auth.Auth {
|
||||
node.lock.RLock()
|
||||
defer node.lock.RUnlock()
|
||||
|
||||
return auth.HMACAuth{SecretKey: []byte(node.Model.MasterKey)}
|
||||
}
|
||||
|
||||
func (node *SlaveNode) SlaveAuthInstance() auth.Auth {
|
||||
node.lock.RLock()
|
||||
defer node.lock.RUnlock()
|
||||
|
||||
return auth.HMACAuth{SecretKey: []byte(node.Model.SlaveKey)}
|
||||
}
|
||||
|
||||
func (node *SlaveNode) DBModel() *model.Node {
|
||||
node.lock.RLock()
|
||||
defer node.lock.RUnlock()
|
||||
|
||||
return node.Model
|
||||
}
|
||||
|
||||
// getHeartbeatContent gets serializer.NodePingReq used to send heartbeat to slave
|
||||
func (node *SlaveNode) getHeartbeatContent(isUpdate bool) *serializer.NodePingReq {
|
||||
return &serializer.NodePingReq{
|
||||
SiteURL: model.GetSiteURL().String(),
|
||||
IsUpdate: isUpdate,
|
||||
SiteID: model.GetSettingByName("siteID"),
|
||||
Node: node.Model,
|
||||
CredentialTTL: model.GetIntSetting("slave_api_timeout", 60),
|
||||
}
|
||||
}
|
||||
|
||||
func (node *SlaveNode) changeStatus(isActive bool) {
|
||||
node.lock.RLock()
|
||||
id := node.Model.ID
|
||||
if isActive != node.Active {
|
||||
node.lock.RUnlock()
|
||||
node.lock.Lock()
|
||||
node.Active = isActive
|
||||
node.lock.Unlock()
|
||||
node.callback(isActive, id)
|
||||
} else {
|
||||
node.lock.RUnlock()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func (s *slaveCaller) Init() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SendAria2Call send remote aria2 call to slave node
|
||||
func (s *slaveCaller) SendAria2Call(body *serializer.SlaveAria2Call, scope string) (*serializer.Response, error) {
|
||||
reqReader, err := getAria2RequestBody(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return s.Client.Request(
|
||||
"POST",
|
||||
"aria2/"+scope,
|
||||
reqReader,
|
||||
).CheckHTTPResponse(200).DecodeResponse()
|
||||
}
|
||||
|
||||
func (s *slaveCaller) CreateTask(task *model.Download, options map[string]interface{}) (string, error) {
|
||||
s.parent.lock.RLock()
|
||||
defer s.parent.lock.RUnlock()
|
||||
|
||||
req := &serializer.SlaveAria2Call{
|
||||
Task: task,
|
||||
GroupOptions: options,
|
||||
}
|
||||
|
||||
res, err := s.SendAria2Call(req, "task")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if res.Code != 0 {
|
||||
return "", serializer.NewErrorFromResponse(res)
|
||||
}
|
||||
|
||||
return res.Data.(string), err
|
||||
}
|
||||
|
||||
func (s *slaveCaller) Status(task *model.Download) (rpc.StatusInfo, error) {
|
||||
s.parent.lock.RLock()
|
||||
defer s.parent.lock.RUnlock()
|
||||
|
||||
req := &serializer.SlaveAria2Call{
|
||||
Task: task,
|
||||
}
|
||||
|
||||
res, err := s.SendAria2Call(req, "status")
|
||||
if err != nil {
|
||||
return rpc.StatusInfo{}, err
|
||||
}
|
||||
|
||||
if res.Code != 0 {
|
||||
return rpc.StatusInfo{}, serializer.NewErrorFromResponse(res)
|
||||
}
|
||||
|
||||
var status rpc.StatusInfo
|
||||
res.GobDecode(&status)
|
||||
|
||||
return status, err
|
||||
}
|
||||
|
||||
func (s *slaveCaller) Cancel(task *model.Download) error {
|
||||
s.parent.lock.RLock()
|
||||
defer s.parent.lock.RUnlock()
|
||||
|
||||
req := &serializer.SlaveAria2Call{
|
||||
Task: task,
|
||||
}
|
||||
|
||||
res, err := s.SendAria2Call(req, "cancel")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if res.Code != 0 {
|
||||
return serializer.NewErrorFromResponse(res)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *slaveCaller) Select(task *model.Download, files []int) error {
|
||||
s.parent.lock.RLock()
|
||||
defer s.parent.lock.RUnlock()
|
||||
|
||||
req := &serializer.SlaveAria2Call{
|
||||
Task: task,
|
||||
Files: files,
|
||||
}
|
||||
|
||||
res, err := s.SendAria2Call(req, "select")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if res.Code != 0 {
|
||||
return serializer.NewErrorFromResponse(res)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *slaveCaller) GetConfig() model.Aria2Option {
|
||||
s.parent.lock.RLock()
|
||||
defer s.parent.lock.RUnlock()
|
||||
|
||||
return s.parent.Model.Aria2OptionsSerialized
|
||||
}
|
||||
|
||||
func (s *slaveCaller) DeleteTempFile(task *model.Download) error {
|
||||
s.parent.lock.RLock()
|
||||
defer s.parent.lock.RUnlock()
|
||||
|
||||
req := &serializer.SlaveAria2Call{
|
||||
Task: task,
|
||||
}
|
||||
|
||||
res, err := s.SendAria2Call(req, "delete")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if res.Code != 0 {
|
||||
return serializer.NewErrorFromResponse(res)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getAria2RequestBody(body *serializer.SlaveAria2Call) (io.Reader, error) {
|
||||
reqBodyEncoded, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return strings.NewReader(string(reqBodyEncoded)), nil
|
||||
}
|
||||
|
||||
// RemoteCallback 发送远程存储策略上传回调请求
|
||||
func RemoteCallback(url string, body serializer.UploadCallback) error {
|
||||
callbackBody, err := json.Marshal(struct {
|
||||
Data serializer.UploadCallback `json:"data"`
|
||||
}{
|
||||
Data: body,
|
||||
})
|
||||
if err != nil {
|
||||
return serializer.NewError(serializer.CodeCallbackError, "Failed to encode callback content", err)
|
||||
}
|
||||
|
||||
resp := request.GeneralClient.Request(
|
||||
"POST",
|
||||
url,
|
||||
bytes.NewReader(callbackBody),
|
||||
request.WithTimeout(time.Duration(conf.SlaveConfig.CallbackTimeout)*time.Second),
|
||||
request.WithCredential(auth.General, int64(conf.SlaveConfig.SignatureTTL)),
|
||||
)
|
||||
|
||||
if resp.Err != nil {
|
||||
return serializer.NewError(serializer.CodeCallbackError, "Slave cannot send callback request", resp.Err)
|
||||
}
|
||||
|
||||
// 解析回调服务端响应
|
||||
response, err := resp.DecodeResponse()
|
||||
if err != nil {
|
||||
msg := fmt.Sprintf("Slave cannot parse callback response from master (StatusCode=%d).", resp.Response.StatusCode)
|
||||
return serializer.NewError(serializer.CodeCallbackError, msg, err)
|
||||
}
|
||||
|
||||
if response.Code != 0 {
|
||||
return serializer.NewError(response.Code, response.Msg, errors.New(response.Error))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
156
pkg/conf/conf.go
Normal file
156
pkg/conf/conf.go
Normal file
@ -0,0 +1,156 @@
|
||||
package conf
|
||||
|
||||
import (
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/util"
|
||||
"github.com/go-ini/ini"
|
||||
"github.com/go-playground/validator/v10"
|
||||
)
|
||||
|
||||
// database 数据库
|
||||
type database struct {
|
||||
Type string
|
||||
User string
|
||||
Password string
|
||||
Host string
|
||||
Name string
|
||||
TablePrefix string
|
||||
DBFile string
|
||||
Port int
|
||||
Charset string
|
||||
UnixSocket bool
|
||||
}
|
||||
|
||||
// system 系统通用配置
|
||||
type system struct {
|
||||
Mode string `validate:"eq=master|eq=slave"`
|
||||
Listen string `validate:"required"`
|
||||
Debug bool
|
||||
SessionSecret string
|
||||
HashIDSalt string
|
||||
GracePeriod int `validate:"gte=0"`
|
||||
ProxyHeader string `validate:"required_with=Listen"`
|
||||
}
|
||||
|
||||
type ssl struct {
|
||||
CertPath string `validate:"omitempty,required"`
|
||||
KeyPath string `validate:"omitempty,required"`
|
||||
Listen string `validate:"required"`
|
||||
}
|
||||
|
||||
type unix struct {
|
||||
Listen string
|
||||
Perm uint32
|
||||
}
|
||||
|
||||
// slave 作为slave存储端配置
|
||||
type slave struct {
|
||||
Secret string `validate:"omitempty,gte=64"`
|
||||
CallbackTimeout int `validate:"omitempty,gte=1"`
|
||||
SignatureTTL int `validate:"omitempty,gte=1"`
|
||||
}
|
||||
|
||||
// redis 配置
|
||||
type redis struct {
|
||||
Network string
|
||||
Server string
|
||||
User string
|
||||
Password string
|
||||
DB string
|
||||
}
|
||||
|
||||
// 跨域配置
|
||||
type cors struct {
|
||||
AllowOrigins []string
|
||||
AllowMethods []string
|
||||
AllowHeaders []string
|
||||
AllowCredentials bool
|
||||
ExposeHeaders []string
|
||||
SameSite string
|
||||
Secure bool
|
||||
}
|
||||
|
||||
var cfg *ini.File
|
||||
|
||||
const defaultConf = `[System]
|
||||
Debug = false
|
||||
Mode = master
|
||||
Listen = :5212
|
||||
SessionSecret = {SessionSecret}
|
||||
HashIDSalt = {HashIDSalt}
|
||||
`
|
||||
|
||||
// Init 初始化配置文件
|
||||
func Init(path string) {
|
||||
var err error
|
||||
|
||||
if path == "" || !util.Exists(path) {
|
||||
// 创建初始配置文件
|
||||
confContent := util.Replace(map[string]string{
|
||||
"{SessionSecret}": util.RandStringRunes(64),
|
||||
"{HashIDSalt}": util.RandStringRunes(64),
|
||||
}, defaultConf)
|
||||
f, err := util.CreatNestedFile(path)
|
||||
if err != nil {
|
||||
util.Log().Panic("Failed to create config file: %s", err)
|
||||
}
|
||||
|
||||
// 写入配置文件
|
||||
_, err = f.WriteString(confContent)
|
||||
if err != nil {
|
||||
util.Log().Panic("Failed to write config file: %s", err)
|
||||
}
|
||||
|
||||
f.Close()
|
||||
}
|
||||
|
||||
cfg, err = ini.Load(path)
|
||||
if err != nil {
|
||||
util.Log().Panic("Failed to parse config file %q: %s", path, err)
|
||||
}
|
||||
|
||||
sections := map[string]interface{}{
|
||||
"Database": DatabaseConfig,
|
||||
"System": SystemConfig,
|
||||
"SSL": SSLConfig,
|
||||
"UnixSocket": UnixConfig,
|
||||
"Redis": RedisConfig,
|
||||
"CORS": CORSConfig,
|
||||
"Slave": SlaveConfig,
|
||||
}
|
||||
for sectionName, sectionStruct := range sections {
|
||||
err = mapSection(sectionName, sectionStruct)
|
||||
if err != nil {
|
||||
util.Log().Panic("Failed to parse config section %q: %s", sectionName, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 映射数据库配置覆盖
|
||||
for _, key := range cfg.Section("OptionOverwrite").Keys() {
|
||||
OptionOverwrite[key.Name()] = key.Value()
|
||||
}
|
||||
|
||||
// 重设log等级
|
||||
if !SystemConfig.Debug {
|
||||
util.Level = util.LevelInformational
|
||||
util.GloablLogger = nil
|
||||
util.Log()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// mapSection 将配置文件的 Section 映射到结构体上
|
||||
func mapSection(section string, confStruct interface{}) error {
|
||||
err := cfg.Section(section).MapTo(confStruct)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 验证合法性
|
||||
validate := validator.New()
|
||||
err = validate.Struct(confStruct)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
55
pkg/conf/defaults.go
Normal file
55
pkg/conf/defaults.go
Normal file
@ -0,0 +1,55 @@
|
||||
package conf
|
||||
|
||||
// RedisConfig Redis服务器配置
|
||||
var RedisConfig = &redis{
|
||||
Network: "tcp",
|
||||
Server: "",
|
||||
Password: "",
|
||||
DB: "0",
|
||||
}
|
||||
|
||||
// DatabaseConfig 数据库配置
|
||||
var DatabaseConfig = &database{
|
||||
Type: "UNSET",
|
||||
Charset: "utf8",
|
||||
DBFile: "cloudreve.db",
|
||||
Port: 3306,
|
||||
UnixSocket: false,
|
||||
}
|
||||
|
||||
// SystemConfig 系统公用配置
|
||||
var SystemConfig = &system{
|
||||
Debug: false,
|
||||
Mode: "master",
|
||||
Listen: ":5212",
|
||||
ProxyHeader: "X-Forwarded-For",
|
||||
}
|
||||
|
||||
// CORSConfig 跨域配置
|
||||
var CORSConfig = &cors{
|
||||
AllowOrigins: []string{"UNSET"},
|
||||
AllowMethods: []string{"PUT", "POST", "GET", "OPTIONS"},
|
||||
AllowHeaders: []string{"Cookie", "X-Cr-Policy", "Authorization", "Content-Length", "Content-Type", "X-Cr-Path", "X-Cr-FileName"},
|
||||
AllowCredentials: false,
|
||||
ExposeHeaders: nil,
|
||||
SameSite: "Default",
|
||||
Secure: false,
|
||||
}
|
||||
|
||||
// SlaveConfig 从机配置
|
||||
var SlaveConfig = &slave{
|
||||
CallbackTimeout: 20,
|
||||
SignatureTTL: 60,
|
||||
}
|
||||
|
||||
var SSLConfig = &ssl{
|
||||
Listen: ":443",
|
||||
CertPath: "",
|
||||
KeyPath: "",
|
||||
}
|
||||
|
||||
var UnixConfig = &unix{
|
||||
Listen: "",
|
||||
}
|
||||
|
||||
var OptionOverwrite = map[string]interface{}{}
|
22
pkg/conf/version.go
Normal file
22
pkg/conf/version.go
Normal file
@ -0,0 +1,22 @@
|
||||
package conf
|
||||
|
||||
// plusVersion 增强版版本号
|
||||
const plusVersion = "+1.1"
|
||||
|
||||
// BackendVersion 当前后端版本号
|
||||
const BackendVersion = "3.8.3" + plusVersion
|
||||
|
||||
// KeyVersion 授权版本号
|
||||
const KeyVersion = "3.3.1"
|
||||
|
||||
// RequiredDBVersion 与当前版本匹配的数据库版本
|
||||
const RequiredDBVersion = "3.8.1+1.0-plus"
|
||||
|
||||
// RequiredStaticVersion 与当前版本匹配的静态资源版本
|
||||
const RequiredStaticVersion = "3.8.3" + plusVersion
|
||||
|
||||
// IsPlus 是否为Plus版本
|
||||
const IsPlus = "true"
|
||||
|
||||
// LastCommit 最后commit id
|
||||
const LastCommit = "88409cc"
|
99
pkg/crontab/collect.go
Normal file
99
pkg/crontab/collect.go
Normal file
@ -0,0 +1,99 @@
|
||||
package crontab
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
model "github.com/cloudreve/Cloudreve/v3/models"
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/cache"
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/filesystem"
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/util"
|
||||
)
|
||||
|
||||
func garbageCollect() {
|
||||
// 清理打包下载产生的临时文件
|
||||
collectArchiveFile()
|
||||
|
||||
// 清理过期的内置内存缓存
|
||||
if store, ok := cache.Store.(*cache.MemoStore); ok {
|
||||
collectCache(store)
|
||||
}
|
||||
|
||||
util.Log().Info("Crontab job \"cron_garbage_collect\" complete.")
|
||||
}
|
||||
|
||||
func collectArchiveFile() {
|
||||
// 读取有效期、目录设置
|
||||
tempPath := util.RelativePath(model.GetSettingByName("temp_path"))
|
||||
expires := model.GetIntSetting("download_timeout", 30)
|
||||
|
||||
// 列出文件
|
||||
root := filepath.Join(tempPath, "archive")
|
||||
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
|
||||
if err == nil && !info.IsDir() &&
|
||||
strings.HasPrefix(filepath.Base(path), "archive_") &&
|
||||
time.Now().Sub(info.ModTime()).Seconds() > float64(expires) {
|
||||
util.Log().Debug("Delete expired batch download temp file %q.", path)
|
||||
// 删除符合条件的文件
|
||||
if err := os.Remove(path); err != nil {
|
||||
util.Log().Debug("Failed to delete temp file %q: %s", path, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
util.Log().Debug("Crontab job cannot list temp batch download folder: %s", err)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func collectCache(store *cache.MemoStore) {
|
||||
util.Log().Debug("Cleanup memory cache.")
|
||||
store.GarbageCollect()
|
||||
}
|
||||
|
||||
func uploadSessionCollect() {
|
||||
placeholders := model.GetUploadPlaceholderFiles(0)
|
||||
|
||||
// 将过期的上传会话按照用户分组
|
||||
userToFiles := make(map[uint][]uint)
|
||||
for _, file := range placeholders {
|
||||
_, sessionExist := cache.Get(filesystem.UploadSessionCachePrefix + *file.UploadSessionID)
|
||||
if sessionExist {
|
||||
continue
|
||||
}
|
||||
|
||||
if _, ok := userToFiles[file.UserID]; !ok {
|
||||
userToFiles[file.UserID] = make([]uint, 0)
|
||||
}
|
||||
|
||||
userToFiles[file.UserID] = append(userToFiles[file.UserID], file.ID)
|
||||
}
|
||||
|
||||
// 删除过期的会话
|
||||
for uid, filesIDs := range userToFiles {
|
||||
user, err := model.GetUserByID(uid)
|
||||
if err != nil {
|
||||
util.Log().Warning("Owner of the upload session cannot be found: %s", err)
|
||||
continue
|
||||
}
|
||||
|
||||
fs, err := filesystem.NewFileSystem(&user)
|
||||
if err != nil {
|
||||
util.Log().Warning("Failed to initialize filesystem: %s", err)
|
||||
continue
|
||||
}
|
||||
|
||||
if err = fs.Delete(context.Background(), []uint{}, filesIDs, false, false); err != nil {
|
||||
util.Log().Warning("Failed to delete upload session: %s", err)
|
||||
}
|
||||
|
||||
fs.Recycle()
|
||||
}
|
||||
|
||||
util.Log().Info("Crontab job \"cron_recycle_upload_session\" complete.")
|
||||
}
|
53
pkg/crontab/init.go
Normal file
53
pkg/crontab/init.go
Normal file
@ -0,0 +1,53 @@
|
||||
package crontab
|
||||
|
||||
import (
|
||||
model "github.com/cloudreve/Cloudreve/v3/models"
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/util"
|
||||
"github.com/robfig/cron/v3"
|
||||
)
|
||||
|
||||
// Cron 定时任务
|
||||
var Cron *cron.Cron
|
||||
|
||||
// Reload 重新启动定时任务
|
||||
func Reload() {
|
||||
if Cron != nil {
|
||||
Cron.Stop()
|
||||
}
|
||||
Init()
|
||||
}
|
||||
|
||||
// Init 初始化定时任务
|
||||
func Init() {
|
||||
util.Log().Info("Initialize crontab jobs...")
|
||||
// 读取cron日程设置
|
||||
options := model.GetSettingByNames(
|
||||
"cron_garbage_collect",
|
||||
"cron_notify_user",
|
||||
"cron_ban_user",
|
||||
"cron_recycle_upload_session",
|
||||
)
|
||||
Cron := cron.New()
|
||||
for k, v := range options {
|
||||
var handler func()
|
||||
switch k {
|
||||
case "cron_garbage_collect":
|
||||
handler = garbageCollect
|
||||
case "cron_notify_user":
|
||||
handler = notifyExpiredVAS
|
||||
case "cron_ban_user":
|
||||
handler = banOverusedUser
|
||||
case "cron_recycle_upload_session":
|
||||
handler = uploadSessionCollect
|
||||
default:
|
||||
util.Log().Warning("Unknown crontab job type %q, skipping...", k)
|
||||
continue
|
||||
}
|
||||
|
||||
if _, err := Cron.AddFunc(v, handler); err != nil {
|
||||
util.Log().Warning("Failed to start crontab job %q: %s", k, err)
|
||||
}
|
||||
|
||||
}
|
||||
Cron.Start()
|
||||
}
|
83
pkg/crontab/vas.go
Normal file
83
pkg/crontab/vas.go
Normal file
@ -0,0 +1,83 @@
|
||||
package crontab
|
||||
|
||||
import (
|
||||
model "github.com/cloudreve/Cloudreve/v3/models"
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/email"
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/util"
|
||||
)
|
||||
|
||||
func notifyExpiredVAS() {
|
||||
checkStoragePack()
|
||||
checkUserGroup()
|
||||
util.Log().Info("Crontab job \"cron_notify_user\" complete.")
|
||||
}
|
||||
|
||||
// banOverusedUser 封禁超出宽容期的用户
|
||||
func banOverusedUser() {
|
||||
users := model.GetTolerantExpiredUser()
|
||||
for _, user := range users {
|
||||
|
||||
// 清除最后通知日期标记
|
||||
user.ClearNotified()
|
||||
|
||||
// 检查容量是否超额
|
||||
if user.Storage > user.Group.MaxStorage+user.GetAvailablePackSize() {
|
||||
// 封禁用户
|
||||
user.SetStatus(model.OveruseBaned)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// checkUserGroup 检查已过期用户组
|
||||
func checkUserGroup() {
|
||||
users := model.GetGroupExpiredUsers()
|
||||
for _, user := range users {
|
||||
|
||||
// 将用户回退到初始用户组
|
||||
user.GroupFallback()
|
||||
|
||||
// 重新加载用户
|
||||
user, _ = model.GetUserByID(user.ID)
|
||||
|
||||
// 检查容量是否超额
|
||||
if user.Storage > user.Group.MaxStorage+user.GetAvailablePackSize() {
|
||||
// 如果超额,则通知用户
|
||||
sendNotification(&user, "用户组过期")
|
||||
// 更新最后通知日期
|
||||
user.Notified()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// checkStoragePack 检查已过期的容量包
|
||||
func checkStoragePack() {
|
||||
packs := model.GetExpiredStoragePack()
|
||||
for _, pack := range packs {
|
||||
// 删除过期的容量包
|
||||
pack.Delete()
|
||||
|
||||
//找到所属用户
|
||||
user, err := model.GetUserByID(pack.UserID)
|
||||
if err != nil {
|
||||
util.Log().Warning("Crontab job failed to get user info of [UID=%d]: %s", pack.UserID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// 检查容量是否超额
|
||||
if user.Storage > user.Group.MaxStorage+user.GetAvailablePackSize() {
|
||||
// 如果超额,则通知用户
|
||||
sendNotification(&user, "容量包过期")
|
||||
|
||||
// 更新最后通知日期
|
||||
user.Notified()
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func sendNotification(user *model.User, reason string) {
|
||||
title, body := email.NewOveruseNotification(user.Nick, reason)
|
||||
if err := email.Send(user.Email, title, body); err != nil {
|
||||
util.Log().Warning("Failed to send notification email: %s", err)
|
||||
}
|
||||
}
|
52
pkg/email/init.go
Normal file
52
pkg/email/init.go
Normal file
@ -0,0 +1,52 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
model "github.com/cloudreve/Cloudreve/v3/models"
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/util"
|
||||
)
|
||||
|
||||
// Client 默认的邮件发送客户端
|
||||
var Client Driver
|
||||
|
||||
// Lock 读写锁
|
||||
var Lock sync.RWMutex
|
||||
|
||||
// Init 初始化
|
||||
func Init() {
|
||||
util.Log().Debug("Initializing email sending queue...")
|
||||
Lock.Lock()
|
||||
defer Lock.Unlock()
|
||||
|
||||
if Client != nil {
|
||||
Client.Close()
|
||||
}
|
||||
|
||||
// 读取SMTP设置
|
||||
options := model.GetSettingByNames(
|
||||
"fromName",
|
||||
"fromAdress",
|
||||
"smtpHost",
|
||||
"replyTo",
|
||||
"smtpUser",
|
||||
"smtpPass",
|
||||
"smtpEncryption",
|
||||
)
|
||||
port := model.GetIntSetting("smtpPort", 25)
|
||||
keepAlive := model.GetIntSetting("mail_keepalive", 30)
|
||||
|
||||
client := NewSMTPClient(SMTPConfig{
|
||||
Name: options["fromName"],
|
||||
Address: options["fromAdress"],
|
||||
ReplyTo: options["replyTo"],
|
||||
Host: options["smtpHost"],
|
||||
Port: port,
|
||||
User: options["smtpUser"],
|
||||
Password: options["smtpPass"],
|
||||
Keepalive: keepAlive,
|
||||
Encryption: model.IsTrueVal(options["smtpEncryption"]),
|
||||
})
|
||||
|
||||
Client = client
|
||||
}
|
38
pkg/email/mail.go
Normal file
38
pkg/email/mail.go
Normal file
@ -0,0 +1,38 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Driver 邮件发送驱动
|
||||
type Driver interface {
|
||||
// Close 关闭驱动
|
||||
Close()
|
||||
// Send 发送邮件
|
||||
Send(to, title, body string) error
|
||||
}
|
||||
|
||||
var (
|
||||
// ErrChanNotOpen 邮件队列未开启
|
||||
ErrChanNotOpen = errors.New("email queue is not started")
|
||||
// ErrNoActiveDriver 无可用邮件发送服务
|
||||
ErrNoActiveDriver = errors.New("no avaliable email provider")
|
||||
)
|
||||
|
||||
// Send 发送邮件
|
||||
func Send(to, title, body string) error {
|
||||
// 忽略通过QQ登录的邮箱
|
||||
if strings.HasSuffix(to, "@login.qq.com") {
|
||||
return nil
|
||||
}
|
||||
|
||||
Lock.RLock()
|
||||
defer Lock.RUnlock()
|
||||
|
||||
if Client == nil {
|
||||
return ErrNoActiveDriver
|
||||
}
|
||||
|
||||
return Client.Send(to, title, body)
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user