├── testdata
├── data
│ └── foo
└── README.md
├── .gitignore
├── .github
└── workflows
│ └── gotest.yml
├── entity
├── chunk.go
├── stat.go
├── file.go
└── index.go
├── util
├── disk_mobile.go
├── disk.go
└── hash.go
├── ref_test.go
├── sync_test.go
├── log_test.go
├── store_test.go
├── README_zh_CN.md
├── README.md
├── go.mod
├── diff.go
├── ref.go
├── log.go
├── sync_lock.go
├── repo_test.go
├── backup.go
├── sync_manual.go
├── cloud
├── cloud.go
├── local.go
├── webdav.go
├── s3.go
└── siyuan.go
├── store.go
├── go.sum
└── LICENSE
/testdata/data/foo:
--------------------------------------------------------------------------------
1 | Hello
--------------------------------------------------------------------------------
/testdata/README.md:
--------------------------------------------------------------------------------
1 | Test data.
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Binaries for programs and plugins
2 | *.exe
3 | *.exe~
4 | *.dll
5 | *.so
6 | *.dylib
7 | *.log
8 |
9 | # Test binary, built with `go test -c`
10 | *.test
11 |
12 | # Output of the go coverage tool, specifically when used with LiteIDE
13 | *.out
14 |
15 | # IDE
16 | .idea/
17 |
18 | # Test
19 | !testdata/data/foo
20 | !testdata/data/bar
21 | testdata/data/
22 | testdata/data-checkout/
23 | testdata/repo/
24 | covprofile
25 |
--------------------------------------------------------------------------------
/.github/workflows/gotest.yml:
--------------------------------------------------------------------------------
1 | name: Go Test
2 | on: [ push, pull_request ]
3 | jobs:
4 | test:
5 | name: Test with Coverage
6 | runs-on: ubuntu-latest
7 | steps:
8 | - name: Set up Go
9 | uses: actions/setup-go@v2
10 | with:
11 | go-version: '1.21'
12 | - name: Check out code
13 | uses: actions/checkout@v2
14 | - name: Install dependencies
15 | run: |
16 | go mod download
17 | - name: Run Unit tests
18 | run: |
19 | go test -coverprofile=covprofile -coverpkg="github.com/siyuan-note/dejavu" ./...
20 | - name: Install goveralls
21 | run: go install github.com/mattn/goveralls@latest
22 | - name: Send coverage
23 | env:
24 | COVERALLS_TOKEN: ${{ secrets.GITHUB_TOKEN }}
25 | run: goveralls -coverprofile=covprofile -service=github
--------------------------------------------------------------------------------
/entity/chunk.go:
--------------------------------------------------------------------------------
1 | // DejaVu - Data snapshot and sync.
2 | // Copyright (c) 2022-present, b3log.org
3 | //
4 | // This program is free software: you can redistribute it and/or modify
5 | // it under the terms of the GNU Affero General Public License as published by
6 | // the Free Software Foundation, either version 3 of the License, or
7 | // (at your option) any later version.
8 | //
9 | // This program is distributed in the hope that it will be useful,
10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | // GNU Affero General Public License for more details.
13 | //
14 | // You should have received a copy of the GNU Affero General Public License
15 | // along with this program. If not, see .
16 |
17 | package entity
18 |
19 | // Chunk 描述了文件分块
20 | type Chunk struct {
21 | ID string `json:"id"`
22 | Data []byte `json:"data"` // 实际的数据
23 | }
24 |
--------------------------------------------------------------------------------
/util/disk_mobile.go:
--------------------------------------------------------------------------------
1 | // DejaVu - Data snapshot and sync.
2 | // Copyright (c) 2022-present, b3log.org
3 | //
4 | // This program is free software: you can redistribute it and/or modify
5 | // it under the terms of the GNU Affero General Public License as published by
6 | // the Free Software Foundation, either version 3 of the License, or
7 | // (at your option) any later version.
8 | //
9 | // This program is distributed in the hope that it will be useful,
10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | // GNU Affero General Public License for more details.
13 | //
14 | // You should have received a copy of the GNU Affero General Public License
15 | // along with this program. If not, see .
16 |
17 | //go:build ios || android
18 |
19 | package util
20 |
21 | import "math"
22 |
23 | func GetFreeDiskSpace(p string) (free int64) {
24 | return math.MaxInt64
25 | }
26 |
--------------------------------------------------------------------------------
/entity/stat.go:
--------------------------------------------------------------------------------
1 | // DejaVu - Data snapshot and sync.
2 | // Copyright (c) 2022-present, b3log.org
3 | //
4 | // This program is free software: you can redistribute it and/or modify
5 | // it under the terms of the GNU Affero General Public License as published by
6 | // the Free Software Foundation, either version 3 of the License, or
7 | // (at your option) any later version.
8 | //
9 | // This program is distributed in the hope that it will be useful,
10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | // GNU Affero General Public License for more details.
13 | //
14 | // You should have received a copy of the GNU Affero General Public License
15 | // along with this program. If not, see .
16 |
17 | package entity
18 |
19 | type ObjectInfo struct {
20 | Path string
21 | Size int64
22 | }
23 |
24 | type PurgeStat struct {
25 | Objects int
26 | Indexes int
27 | Size int64
28 | }
29 |
--------------------------------------------------------------------------------
/util/disk.go:
--------------------------------------------------------------------------------
1 | // DejaVu - Data snapshot and sync.
2 | // Copyright (c) 2022-present, b3log.org
3 | //
4 | // This program is free software: you can redistribute it and/or modify
5 | // it under the terms of the GNU Affero General Public License as published by
6 | // the Free Software Foundation, either version 3 of the License, or
7 | // (at your option) any later version.
8 | //
9 | // This program is distributed in the hope that it will be useful,
10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | // GNU Affero General Public License for more details.
13 | //
14 | // You should have received a copy of the GNU Affero General Public License
15 | // along with this program. If not, see .
16 |
17 | //go:build !ios && !android
18 |
19 | package util
20 |
21 | import (
22 | "math"
23 |
24 | "github.com/shirou/gopsutil/v4/disk"
25 | )
26 |
27 | func GetFreeDiskSpace(p string) (free int64) {
28 | usage, err := disk.Usage(p)
29 | if err != nil {
30 | return math.MaxInt64
31 | }
32 | return int64(usage.Free)
33 | }
34 |
--------------------------------------------------------------------------------
/util/hash.go:
--------------------------------------------------------------------------------
1 | // DejaVu - Data snapshot and sync.
2 | // Copyright (c) 2022-present, b3log.org
3 | //
4 | // This program is free software: you can redistribute it and/or modify
5 | // it under the terms of the GNU Affero General Public License as published by
6 | // the Free Software Foundation, either version 3 of the License, or
7 | // (at your option) any later version.
8 | //
9 | // This program is distributed in the hope that it will be useful,
10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | // GNU Affero General Public License for more details.
13 | //
14 | // You should have received a copy of the GNU Affero General Public License
15 | // along with this program. If not, see .
16 |
17 | package util
18 |
19 | import (
20 | "crypto/rand"
21 | "crypto/sha1"
22 | "fmt"
23 |
24 | "github.com/88250/gulu"
25 | "github.com/siyuan-note/logging"
26 | )
27 |
28 | func Hash(data []byte) string {
29 | return fmt.Sprintf("%x", sha1.Sum(data))
30 | }
31 |
32 | func RandHash() string {
33 | b := make([]byte, 32)
34 | _, err := rand.Read(b)
35 | if nil != err {
36 | logging.LogErrorf("read rand failed: %s", err)
37 | return Hash([]byte(gulu.Rand.String(512)))
38 | }
39 | return Hash(b)
40 | }
41 |
--------------------------------------------------------------------------------
/ref_test.go:
--------------------------------------------------------------------------------
1 | // DejaVu - Data snapshot and sync.
2 | // Copyright (c) 2022-present, b3log.org
3 | //
4 | // This program is free software: you can redistribute it and/or modify
5 | // it under the terms of the GNU Affero General Public License as published by
6 | // the Free Software Foundation, either version 3 of the License, or
7 | // (at your option) any later version.
8 | //
9 | // This program is distributed in the hope that it will be useful,
10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | // GNU Affero General Public License for more details.
13 | //
14 | // You should have received a copy of the GNU Affero General Public License
15 | // along with this program. If not, see .
16 |
17 | package dejavu
18 |
19 | import (
20 | "testing"
21 | )
22 |
23 | func TestTag(t *testing.T) {
24 | clearTestdata(t)
25 |
26 | repo, index := initIndex(t)
27 | err := repo.AddTag(index.ID, "v1.0.0")
28 | if nil != err {
29 | t.Fatalf("add tag failed: %s", err)
30 | return
31 | }
32 |
33 | v100, err := repo.GetTag("v1.0.0")
34 | if v100 != index.ID {
35 | t.Fatalf("get tag failed: %s", err)
36 | return
37 | }
38 |
39 | err = repo.AddTag(index.ID, "v1.0.1")
40 | if nil != err {
41 | t.Fatalf("add tag failed: %s", err)
42 | return
43 | }
44 |
45 | v101, err := repo.GetTag("v1.0.1")
46 | if v101 != v100 {
47 | t.Fatalf("get tag failed: %s", err)
48 | return
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/sync_test.go:
--------------------------------------------------------------------------------
1 | // DejaVu - Data snapshot and sync.
2 | // Copyright (c) 2022-present, b3log.org
3 | //
4 | // This program is free software: you can redistribute it and/or modify
5 | // it under the terms of the GNU Affero General Public License as published by
6 | // the Free Software Foundation, either version 3 of the License, or
7 | // (at your option) any later version.
8 | //
9 | // This program is distributed in the hope that it will be useful,
10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | // GNU Affero General Public License for more details.
13 | //
14 | // You should have received a copy of the GNU Affero General Public License
15 | // along with this program. If not, see .
16 |
17 | package dejavu
18 |
19 | import (
20 | "testing"
21 |
22 | "github.com/siyuan-note/dejavu/cloud"
23 | )
24 |
25 | func TestSync(t *testing.T) {
26 | repo, _ := initIndex(t)
27 |
28 | userId := "0"
29 | token := ""
30 |
31 | return // 注释掉不跑
32 |
33 | repo.cloud = &cloud.SiYuan{BaseCloud: &cloud.BaseCloud{Conf: &cloud.Conf{
34 | Dir: "test",
35 | UserID: userId,
36 | AvailableSize: 1024 * 1024 * 1024 * 8,
37 | Token: token,
38 | Server: "http://127.0.0.1:64388",
39 | }}}
40 |
41 | mergeResult, trafficStat, err := repo.Sync(nil)
42 | if nil != err {
43 | t.Fatalf("sync failed: %s", err)
44 | return
45 | }
46 | _ = mergeResult
47 | _ = trafficStat
48 | }
49 |
--------------------------------------------------------------------------------
/entity/file.go:
--------------------------------------------------------------------------------
1 | // DejaVu - Data snapshot and sync.
2 | // Copyright (c) 2022-present, b3log.org
3 | //
4 | // This program is free software: you can redistribute it and/or modify
5 | // it under the terms of the GNU Affero General Public License as published by
6 | // the Free Software Foundation, either version 3 of the License, or
7 | // (at your option) any later version.
8 | //
9 | // This program is distributed in the hope that it will be useful,
10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | // GNU Affero General Public License for more details.
13 | //
14 | // You should have received a copy of the GNU Affero General Public License
15 | // along with this program. If not, see .
16 |
17 | package entity
18 |
19 | import (
20 | "bytes"
21 | "strconv"
22 |
23 | "github.com/siyuan-note/dejavu/util"
24 | )
25 |
26 | // File 描述了文件。
27 | type File struct {
28 | ID string `json:"id"` // Hash
29 | Path string `json:"path"` // 文件路径
30 | Size int64 `json:"size"` // 文件大小
31 | Updated int64 `json:"updated"` // 最后更新时间
32 | Chunks []string `json:"chunks"` // 文件分块列表
33 | }
34 |
35 | func NewFile(path string, size int64, updated int64) (ret *File) {
36 | ret = &File{
37 | Path: path,
38 | Size: size,
39 | Updated: updated,
40 | }
41 | buf := bytes.Buffer{}
42 | buf.WriteString(ret.Path)
43 | buf.WriteString(strconv.FormatInt(ret.Updated/1000, 10))
44 | ret.ID = util.Hash(buf.Bytes())
45 | return
46 | }
47 |
48 | func (f *File) SecUpdated() int64 {
49 | return f.Updated / 1000
50 | }
51 |
--------------------------------------------------------------------------------
/log_test.go:
--------------------------------------------------------------------------------
1 | // DejaVu - Data snapshot and sync.
2 | // Copyright (c) 2022-present, b3log.org
3 | //
4 | // This program is free software: you can redistribute it and/or modify
5 | // it under the terms of the GNU Affero General Public License as published by
6 | // the Free Software Foundation, either version 3 of the License, or
7 | // (at your option) any later version.
8 | //
9 | // This program is distributed in the hope that it will be useful,
10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | // GNU Affero General Public License for more details.
13 | //
14 | // You should have received a copy of the GNU Affero General Public License
15 | // along with this program. If not, see .
16 |
17 | package dejavu
18 |
19 | import (
20 | "testing"
21 | )
22 |
23 | func TestGetIndexLogs(t *testing.T) {
24 | clearTestdata(t)
25 |
26 | repo, _ := initIndex(t)
27 |
28 | logs, pageCount, totalCount, err := repo.GetIndexLogs(1, 10)
29 | if nil != err {
30 | t.Fatalf("get index logs failed: %s", err)
31 | return
32 | }
33 | if 1 > len(logs) {
34 | t.Fatalf("logs length not match: %d", len(logs))
35 | return
36 | }
37 |
38 | t.Logf("page count [%d], total count [%d]", pageCount, totalCount)
39 | for _, log := range logs {
40 | t.Logf("%+v", log)
41 | }
42 | }
43 |
44 | func TestGetTagLogs(t *testing.T) {
45 | clearTestdata(t)
46 |
47 | repo, index := initIndex(t)
48 | err := repo.AddTag(index.ID, "v1.0.0")
49 | if nil != err {
50 | t.Fatalf("add tag failed: %s", err)
51 | return
52 | }
53 |
54 | err = repo.AddTag(index.ID, "v1.0.1")
55 | if nil != err {
56 | t.Fatalf("add tag failed: %s", err)
57 | return
58 | }
59 |
60 | logs, err := repo.GetTagLogs()
61 | if nil != err {
62 | t.Fatalf("get tag logs failed: %s", err)
63 | return
64 | }
65 | if 2 != len(logs) {
66 | t.Fatalf("logs length not match: %d", len(logs))
67 | return
68 | }
69 |
70 | for _, log := range logs {
71 | t.Logf("%+v", log)
72 | }
73 | }
74 |
--------------------------------------------------------------------------------
/store_test.go:
--------------------------------------------------------------------------------
1 | // DejaVu - Data snapshot and sync.
2 | // Copyright (c) 2022-present, b3log.org
3 | //
4 | // This program is free software: you can redistribute it and/or modify
5 | // it under the terms of the GNU Affero General Public License as published by
6 | // the Free Software Foundation, either version 3 of the License, or
7 | // (at your option) any later version.
8 | //
9 | // This program is distributed in the hope that it will be useful,
10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | // GNU Affero General Public License for more details.
13 | //
14 | // You should have received a copy of the GNU Affero General Public License
15 | // along with this program. If not, see .
16 |
17 | package dejavu
18 |
19 | import (
20 | "bytes"
21 | "testing"
22 |
23 | "github.com/siyuan-note/dejavu/entity"
24 | "github.com/siyuan-note/dejavu/util"
25 | "github.com/siyuan-note/encryption"
26 | )
27 |
28 | func TestPutGet(t *testing.T) {
29 | clearTestdata(t)
30 |
31 | aesKey, err := encryption.KDF(testRepoPassword, testRepoPasswordSalt)
32 | if nil != err {
33 | t.Fatalf("kdf failed: %s", err)
34 | return
35 | }
36 |
37 | store, err := NewStore(testRepoPath, aesKey)
38 | if nil != err {
39 | t.Fatalf("new store failed: %s", err)
40 | return
41 | }
42 |
43 | data := []byte("Hello!")
44 | chunk := &entity.Chunk{ID: util.Hash(data), Data: data}
45 | err = store.PutChunk(chunk)
46 | if nil != err {
47 | t.Fatalf("put failed: %s", err)
48 | return
49 | }
50 |
51 | chunk, err = store.GetChunk(chunk.ID)
52 | if nil != err {
53 | t.Fatalf("get failed: %s", err)
54 | return
55 | }
56 | if 0 != bytes.Compare(chunk.Data, data) {
57 | t.Fatalf("data not match")
58 | return
59 | }
60 |
61 | err = store.Remove(chunk.ID)
62 | if nil != err {
63 | t.Fatalf("remove failed: %s", err)
64 | return
65 | }
66 |
67 | chunk, err = store.GetChunk(chunk.ID)
68 | if nil != chunk {
69 | t.Fatalf("get should be failed")
70 | return
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/entity/index.go:
--------------------------------------------------------------------------------
1 | // DejaVu - Data snapshot and sync.
2 | // Copyright (c) 2022-present, b3log.org
3 | //
4 | // This program is free software: you can redistribute it and/or modify
5 | // it under the terms of the GNU Affero General Public License as published by
6 | // the Free Software Foundation, either version 3 of the License, or
7 | // (at your option) any later version.
8 | //
9 | // This program is distributed in the hope that it will be useful,
10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | // GNU Affero General Public License for more details.
13 | //
14 | // You should have received a copy of the GNU Affero General Public License
15 | // along with this program. If not, see .
16 |
17 | package entity
18 |
19 | import (
20 | "fmt"
21 | "time"
22 |
23 | "github.com/88250/go-humanize"
24 | )
25 |
26 | // Index 描述了快照索引。
27 | type Index struct {
28 | ID string `json:"id"` // Hash
29 | Memo string `json:"memo"` // 索引备注
30 | Created int64 `json:"created"` // 索引时间
31 | Files []string `json:"files"` // 文件列表
32 | Count int `json:"count"` // 文件总数
33 | Size int64 `json:"size"` // 文件总大小
34 | SystemID string `json:"systemID"` // 系统 ID
35 | SystemName string `json:"systemName"` // 系统名称
36 | SystemOS string `json:"systemOS"` // 系统操作系统
37 | CheckIndexID string `json:"checkIndexID"` // Check Index ID
38 | }
39 |
40 | func (index *Index) String() string {
41 | return fmt.Sprintf("device=%s/%s, id=%s, files=%d, size=%s, created=%s",
42 | index.SystemID, index.SystemOS, index.ID, len(index.Files), humanize.BytesCustomCeil(uint64(index.Size), 2), time.UnixMilli(index.Created).Format("2006-01-02 15:04:05"))
43 | }
44 |
45 | // CheckIndex 描述了一个 Index 对应的数据 ID,包括 File ID 和 Chunk ID。
46 | //
47 | // 该结构体在数据同步云端时根据本地 Latest Index 生成,在云端服务上用于校验数据完整性。
48 | //
49 | // 该数据结构仅保存 ID,因此不会影响端到端加密的安全性,不会对数据安全造成任何影响。
50 | //
51 | // 存放路径:repo/check/indexes/{id}。
52 | type CheckIndex struct {
53 | ID string `json:"id"` // Hash
54 | IndexID string `json:"indexID"` // Index ID
55 | Files []*CheckIndexFile `json:"files"` // File IDs
56 | }
57 |
58 | type CheckIndexFile struct {
59 | ID string `json:"id"` // File ID
60 | Chunks []string `json:"chunks"` // Chunk IDs
61 | }
62 |
63 | type CheckReport struct {
64 | CheckTime int64 `json:"checkTime"`
65 | CheckCount int `json:"checkCount"`
66 | FixCount int `json:"fixCount"`
67 | MissingObjects []string `json:"missingObjects"`
68 | }
69 |
--------------------------------------------------------------------------------
/README_zh_CN.md:
--------------------------------------------------------------------------------
1 | # DejaVu
2 |
3 | [English](README.md)
4 |
5 | ## 💡 简介
6 |
7 | [DejaVu](https://github.com/siyuan-note/dejavu) 是思源笔记的数据快照和同步组件。
8 |
9 | ## ✨ 特性
10 |
11 | * 类似 Git 的版本控制
12 | * 文件分块去重
13 | * 数据压缩
14 | * AES 加密
15 | * 云端同步和备份
16 |
17 | ⚠️ 注意
18 |
19 | * 不支持文件夹
20 | * 不支持权限属性
21 | * 不支持符号链接
22 |
23 | ## 🎨 设计
24 |
25 | 设计参考自 [ArtiVC](https://github.com/InfuseAI/ArtiVC)。
26 |
27 | ### 实体
28 |
29 | * `ID` 每个实体都通过 SHA-1 标识
30 | * `Index` 文件列表,每次索引操作都生成一个新的索引
31 | * `memo` 索引备注
32 | * `created` 索引时间
33 | * `files` 文件列表
34 | * `count` 文件总数
35 | * `size` 文件列表总大小
36 | * `File` 文件,实际的数据文件路径或者内容发生变动时生成一个新的文件
37 | * `path` 文件路径
38 | * `size` 文件大小
39 | * `updated` 最后更新时间
40 | * `chunks` 文件分块列表
41 | * `Chunk` 文件块
42 | * `data` 实际的数据
43 | * `Ref` 引用指向索引
44 | * `latest` 内置引用,自动指向最新的索引
45 | * `tag` 标签引用,手动指向指定的索引
46 | * `Repo` 仓库
47 |
48 | ### 仓库
49 |
50 | * `DataPath` 数据文件夹路径,实际的数据文件所在文件夹
51 | * `Path` 仓库文件夹路径,仓库不保存在数据文件夹中,需要单独指定仓库文件夹路径
52 |
53 | 仓库文件夹结构如下:
54 |
55 | ```text
56 | ├─indexes
57 | │ 0531732dca85404e716abd6bb896319a41fa372b
58 | │ 19fc2c2e5317b86f9e048f8d8da2e4ed8300d8af
59 | │ 5f32d78d69e314beee36ad7de302b984da47ddd2
60 | │ cbd254ca246498978d4f47e535bac87ad7640fe6
61 | │
62 | ├─objects
63 | │ ├─1e
64 | │ │ 0ac5f319f5f24b3fe5bf63639e8dbc31a52e3b
65 | │ │
66 | │ ├─56
67 | │ │ 322ccdb61feab7f2f76f5eb82006bd51da7348
68 | │ │
69 | │ ├─7e
70 | │ │ dccca8340ebe149b10660a079f34a20f35c4d4
71 | │ │
72 | │ ├─83
73 | │ │ a7d72fe9a071b696fc81a3dc041cf36cbde802
74 | │ │
75 | │ ├─85
76 | │ │ 26b9a7efde615b67b4666ae509f9fbc91d370b
77 | │ │
78 | │ ├─87
79 | │ │ 1355acd062116d1713e8f7f55969dbb507a040
80 | │ │
81 | │ ├─96
82 | │ │ 46ba13a4e8eabeca4f5259bfd7da41d368a1a6
83 | │ │
84 | │ ├─a5
85 | │ │ 5b8e6b9ccad3fc9b792d3d453a0793f8635b9f
86 | │ │ b28787922f4e2a477b4f027e132aa7e35253d4
87 | │ │
88 | │ ├─be
89 | │ │ c7a729d1b5f021f8eca0dd8b6ef689ad753567
90 | │ │
91 | │ ├─d1
92 | │ │ 324c714bde18442b5629a84a361b5e7528b14a
93 | │ │
94 | │ ├─f1
95 | │ │ d7229171f4fa1c5eacb411995b16938a04f7f6
96 | │ │
97 | │ └─f7
98 | │ ff9e8b7bb2e09b70935a5d785e0cc5d9d0abf0
99 | │
100 | └─refs
101 | │ latest
102 | │
103 | └─tags
104 | v1.0.0
105 | v1.0.1
106 | ```
107 |
108 | ## 📄 授权
109 |
110 | DejaVu 使用 [GNU Affero 通用公共许可证, 版本 3](https://www.gnu.org/licenses/agpl-3.0.txt) 开源协议。
111 |
112 | ## 🙏 鸣谢
113 |
114 | * [https://github.com/dustin/go-humanize](https://github.com/dustin/go-humanize) `MIT license`
115 | * [https://github.com/klauspost/compress](https://github.com/klauspost/compress) `BSD-3-Clause license`
116 | * [https://github.com/panjf2000/ants](https://github.com/panjf2000/ants) `MIT license`
117 | * [https://github.com/InfuseAI/ArtiVC](https://github.com/InfuseAI/ArtiVC) `Apache-2.0 license`
118 | * [https://github.com/restic/restic](https://github.com/restic/restic) `BSD-2-Clause license`
119 | * [https://github.com/sabhiram/go-gitignore](https://github.com/sabhiram/go-gitignore) `MIT license`
120 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # DejaVu
2 |
3 | [中文](README_zh_CN.md)
4 |
5 | ## 💡 Introduction
6 |
7 | [DejaVu](https://github.com/siyuan-note/dejavu) is the component of data snapshot and sync for SiYuan.
8 |
9 | ## ✨ Features
10 |
11 | * Git-like version control
12 | * File deduplication in chunks
13 | * Data compression
14 | * AES Encrypted
15 | * Cloud sync and backup
16 |
17 | ⚠️ Attention
18 |
19 | * Folders are not supported
20 | * Permission attributes are not supported
21 | * Symbolic links are not supported
22 |
23 | ## 🎨 Design
24 |
25 | Design reference from [ArtiVC](https://github.com/InfuseAI/ArtiVC).
26 |
27 | ### Entity
28 |
29 | * `ID` Each entity is identified by SHA-1
30 | * `Index` file list, each index operation generates a new index
31 | * `memo` index memo
32 | * `created` index time
33 | * `files` file list
34 | * `count` count of total files
35 | * `size` size of total files
36 | * `File` file, a new file is generated when the actual data file path or content changes
37 | * `path` file path
38 | * `size` file size
39 | * `updated` last update time
40 | * `chunks` file chunk list
41 | * `Chunk` file chunk
42 | * `data` actual data
43 | * `Ref` refers to the index
44 | * `latest` built-in reference, automatically points to the latest index
45 | * `tag` tag reference, manually point to the specified index
46 | * `Repo` repository
47 |
48 | ### Repo
49 |
50 | * `DataPath` data folder path, the folder where the actual data file is located
51 | * `Path` repo folder path, the repo is not stored in the data folder, we need to specify the repo folder path separately
52 |
53 | The repo folder layout is as follows:
54 |
55 | ```text
56 | ├─indexes
57 | │ 0531732dca85404e716abd6bb896319a41fa372b
58 | │ 19fc2c2e5317b86f9e048f8d8da2e4ed8300d8af
59 | │ 5f32d78d69e314beee36ad7de302b984da47ddd2
60 | │ cbd254ca246498978d4f47e535bac87ad7640fe6
61 | │
62 | ├─objects
63 | │ ├─1e
64 | │ │ 0ac5f319f5f24b3fe5bf63639e8dbc31a52e3b
65 | │ │
66 | │ ├─56
67 | │ │ 322ccdb61feab7f2f76f5eb82006bd51da7348
68 | │ │
69 | │ ├─7e
70 | │ │ dccca8340ebe149b10660a079f34a20f35c4d4
71 | │ │
72 | │ ├─83
73 | │ │ a7d72fe9a071b696fc81a3dc041cf36cbde802
74 | │ │
75 | │ ├─85
76 | │ │ 26b9a7efde615b67b4666ae509f9fbc91d370b
77 | │ │
78 | │ ├─87
79 | │ │ 1355acd062116d1713e8f7f55969dbb507a040
80 | │ │
81 | │ ├─96
82 | │ │ 46ba13a4e8eabeca4f5259bfd7da41d368a1a6
83 | │ │
84 | │ ├─a5
85 | │ │ 5b8e6b9ccad3fc9b792d3d453a0793f8635b9f
86 | │ │ b28787922f4e2a477b4f027e132aa7e35253d4
87 | │ │
88 | │ ├─be
89 | │ │ c7a729d1b5f021f8eca0dd8b6ef689ad753567
90 | │ │
91 | │ ├─d1
92 | │ │ 324c714bde18442b5629a84a361b5e7528b14a
93 | │ │
94 | │ ├─f1
95 | │ │ d7229171f4fa1c5eacb411995b16938a04f7f6
96 | │ │
97 | │ └─f7
98 | │ ff9e8b7bb2e09b70935a5d785e0cc5d9d0abf0
99 | │
100 | └─refs
101 | │ latest
102 | │
103 | └─tags
104 | v1.0.0
105 | v1.0.1
106 | ```
107 |
108 | ## 📄 License
109 |
110 | DejaVu uses the [GNU AFFERO GENERAL PUBLIC LICENSE, Version 3](https://www.gnu.org/licenses/agpl-3.0.txt) open source license.
111 |
112 | ## 🙏 Acknowledgement
113 |
114 | * [https://github.com/dustin/go-humanize](https://github.com/dustin/go-humanize) `MIT license`
115 | * [https://github.com/klauspost/compress](https://github.com/klauspost/compress) `BSD-3-Clause license`
116 | * [https://github.com/panjf2000/ants](https://github.com/panjf2000/ants) `MIT license`
117 | * [https://github.com/InfuseAI/ArtiVC](https://github.com/InfuseAI/ArtiVC) `Apache-2.0 license`
118 | * [https://github.com/restic/restic](https://github.com/restic/restic) `BSD-2-Clause license`
119 | * [https://github.com/sabhiram/go-gitignore](https://github.com/sabhiram/go-gitignore) `MIT license`
120 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/siyuan-note/dejavu
2 |
3 | go 1.24.4
4 |
5 | require (
6 | github.com/88250/go-humanize v0.0.0-20240424102817-4f78fac47ea7
7 | github.com/88250/gulu v1.2.3-0.20251119142510-7b1583ab4aa0
8 | github.com/88250/lute v1.7.7-0.20250801084148-32f2ef961381
9 | github.com/aws/aws-sdk-go-v2 v1.41.0
10 | github.com/aws/aws-sdk-go-v2/config v1.32.6
11 | github.com/aws/aws-sdk-go-v2/credentials v1.19.6
12 | github.com/aws/aws-sdk-go-v2/service/s3 v1.94.0
13 | github.com/aws/smithy-go v1.24.0
14 | github.com/dgraph-io/ristretto v0.2.0
15 | github.com/klauspost/compress v1.18.2
16 | github.com/panjf2000/ants/v2 v2.11.3
17 | github.com/qiniu/go-sdk/v7 v7.25.5
18 | github.com/restic/chunker v0.4.0
19 | github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06
20 | github.com/shirou/gopsutil/v4 v4.25.11
21 | github.com/siyuan-note/dataparser v0.0.0-20251203120213-59c16535cb56
22 | github.com/siyuan-note/encryption v0.0.0-20251120032857-3ddc3c2cc49f
23 | github.com/siyuan-note/eventbus v0.0.0-20240627125516-396fdb0f0f97
24 | github.com/siyuan-note/filelock v0.0.0-20251212095217-08318833e008
25 | github.com/siyuan-note/httpclient v0.0.0-20251217011734-7f49de93158a
26 | github.com/siyuan-note/logging v0.0.0-20251209020516-52f1a2f65ec5
27 | github.com/studio-b12/gowebdav v0.11.0
28 | github.com/vmihailenco/msgpack/v5 v5.4.1
29 | )
30 |
31 | require (
32 | github.com/BurntSushi/toml v1.5.0 // indirect
33 | github.com/alecthomas/chroma v0.10.0 // indirect
34 | github.com/alex-ant/gomath v0.0.0-20160516115720-89013a210a82 // indirect
35 | github.com/andybalholm/brotli v1.2.0 // indirect
36 | github.com/asaskevich/EventBus v0.0.0-20200907212545-49d423059eef // indirect
37 | github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 // indirect
38 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 // indirect
39 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 // indirect
40 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 // indirect
41 | github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect
42 | github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.16 // indirect
43 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect
44 | github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.7 // indirect
45 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 // indirect
46 | github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16 // indirect
47 | github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 // indirect
48 | github.com/aws/aws-sdk-go-v2/service/sso v1.30.8 // indirect
49 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 // indirect
50 | github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 // indirect
51 | github.com/cespare/xxhash/v2 v2.3.0 // indirect
52 | github.com/dlclark/regexp2 v1.11.5 // indirect
53 | github.com/dustin/go-humanize v1.0.1 // indirect
54 | github.com/ebitengine/purego v0.9.1 // indirect
55 | github.com/gammazero/toposort v0.1.1 // indirect
56 | github.com/go-ole/go-ole v1.3.0 // indirect
57 | github.com/goccy/go-json v0.10.5 // indirect
58 | github.com/gofrs/flock v0.13.0 // indirect
59 | github.com/google/go-querystring v1.1.0 // indirect
60 | github.com/gopherjs/gopherjs v1.17.2 // indirect
61 | github.com/icholy/digest v1.1.0 // indirect
62 | github.com/imroc/req/v3 v3.57.0 // indirect
63 | github.com/pkg/errors v0.9.1 // indirect
64 | github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
65 | github.com/quic-go/qpack v0.6.0 // indirect
66 | github.com/quic-go/quic-go v0.57.1 // indirect
67 | github.com/refraction-networking/utls v1.8.1 // indirect
68 | github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
69 | github.com/yusufpapurcu/wmi v1.2.4 // indirect
70 | golang.org/x/crypto v0.46.0 // indirect
71 | golang.org/x/net v0.48.0 // indirect
72 | golang.org/x/sync v0.19.0 // indirect
73 | golang.org/x/sys v0.39.0 // indirect
74 | golang.org/x/text v0.32.0 // indirect
75 | modernc.org/fileutil v1.3.40 // indirect
76 | )
77 |
78 | //replace github.com/siyuan-note/filelock => D:\88250\filelock
79 |
--------------------------------------------------------------------------------
/diff.go:
--------------------------------------------------------------------------------
1 | // DejaVu - Data snapshot and sync.
2 | // Copyright (c) 2022-present, b3log.org
3 | //
4 | // This program is free software: you can redistribute it and/or modify
5 | // it under the terms of the GNU Affero General Public License as published by
6 | // the Free Software Foundation, either version 3 of the License, or
7 | // (at your option) any later version.
8 | //
9 | // This program is distributed in the hope that it will be useful,
10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | // GNU Affero General Public License for more details.
13 | //
14 | // You should have received a copy of the GNU Affero General Public License
15 | // along with this program. If not, see .
16 |
17 | package dejavu
18 |
19 | import (
20 | "time"
21 |
22 | "github.com/siyuan-note/dejavu/entity"
23 | "github.com/siyuan-note/logging"
24 | )
25 |
26 | // diffUpsertRemove 比较 left 多于/变动 right 的文件以及 left 少于 right 的文件。
27 | func (repo *Repo) diffUpsertRemove(left, right []*entity.File, log bool) (upserts, removes []*entity.File) {
28 | l := map[string]*entity.File{}
29 | r := map[string]*entity.File{}
30 | for _, f := range left {
31 | l[f.Path] = f
32 | }
33 | for _, f := range right {
34 | r[f.Path] = f
35 | }
36 |
37 | for lPath, lFile := range l {
38 | rFile := r[lPath]
39 | if nil == rFile {
40 | upserts = append(upserts, l[lPath])
41 | if log {
42 | logging.LogInfof("upsert [%s, %s, %s]", l[lPath].ID, l[lPath].Path, time.UnixMilli(l[lPath].Updated).Format("2006-01-02 15:04:05"))
43 | }
44 |
45 | continue
46 | }
47 | if !equalFile(lFile, rFile) {
48 | if log {
49 | logging.LogInfof("upsert [lID=%s, lPath=%s, lUpdated=%s, rID=%s, rPath=%s, rUpdated=%s]",
50 | l[lPath].ID, l[lPath].Path, time.UnixMilli(l[lPath].Updated).Format("2006-01-02 15:04:05"),
51 | rFile.ID, rFile.Path, time.UnixMilli(rFile.Updated).Format("2006-01-02 15:04:05"))
52 | }
53 | upserts = append(upserts, l[lPath])
54 | continue
55 | }
56 | }
57 |
58 | for rPath := range r {
59 | lFile := l[rPath]
60 | if nil == lFile {
61 | removes = append(removes, r[rPath])
62 | if log {
63 | logging.LogInfof("remove [%s, %s, %s]", r[rPath].ID, r[rPath].Path, time.UnixMilli(r[rPath].Updated).Format("2006-01-02 15:04:05"))
64 | }
65 | continue
66 | }
67 | }
68 | return
69 | }
70 |
71 | type LeftRightDiff struct {
72 | LeftIndex *entity.Index
73 | RightIndex *entity.Index
74 | AddsLeft []*entity.File
75 | UpdatesLeft []*entity.File
76 | UpdatesRight []*entity.File
77 | RemovesRight []*entity.File
78 | }
79 |
80 | // DiffIndex 返回索引 left 比索引 right 新增、更新和删除的文件列表。
81 | func (repo *Repo) DiffIndex(leftIndexID, rightIndexID string) (ret *LeftRightDiff, err error) {
82 | leftIndex, err := repo.GetIndex(leftIndexID)
83 | if nil != err {
84 | return
85 | }
86 | rightIndex, err := repo.GetIndex(rightIndexID)
87 | if nil != err {
88 | return
89 | }
90 |
91 | ret, err = repo.diffIndex(leftIndex, rightIndex)
92 | return
93 | }
94 |
95 | func (repo *Repo) diffIndex(leftIndex, rightIndex *entity.Index) (ret *LeftRightDiff, err error) {
96 | leftFiles, err := repo.getFiles(leftIndex.Files)
97 | if nil != err {
98 | return
99 | }
100 | rightFiles, err := repo.getFiles(rightIndex.Files)
101 | if nil != err {
102 | return
103 | }
104 |
105 | l := map[string]*entity.File{}
106 | r := map[string]*entity.File{}
107 | for _, f := range leftFiles {
108 | l[f.Path] = f
109 | }
110 | for _, f := range rightFiles {
111 | r[f.Path] = f
112 | }
113 |
114 | ret = &LeftRightDiff{
115 | LeftIndex: leftIndex,
116 | RightIndex: rightIndex,
117 | }
118 |
119 | for lPath, lFile := range l {
120 | rFile := r[lPath]
121 | if nil == rFile {
122 | ret.AddsLeft = append(ret.AddsLeft, l[lPath])
123 | continue
124 | }
125 | if !equalFile(lFile, rFile) {
126 | ret.UpdatesLeft = append(ret.UpdatesLeft, l[lPath])
127 | ret.UpdatesRight = append(ret.UpdatesRight, r[lPath])
128 | continue
129 | }
130 | }
131 |
132 | for rPath := range r {
133 | lFile := l[rPath]
134 | if nil == lFile {
135 | ret.RemovesRight = append(ret.RemovesRight, r[rPath])
136 | continue
137 | }
138 | }
139 | return
140 | }
141 |
142 | func equalFile(left, right *entity.File) bool {
143 | if left.Path != right.Path {
144 | return false
145 | }
146 | if left.Updated/1000 != right.Updated/1000 { // Improve data sync file timestamp comparison https://github.com/siyuan-note/siyuan/issues/8573
147 | return false
148 | }
149 | return true
150 | }
151 |
--------------------------------------------------------------------------------
/ref.go:
--------------------------------------------------------------------------------
1 | // DejaVu - Data snapshot and sync.
2 | // Copyright (c) 2022-present, b3log.org
3 | //
4 | // This program is free software: you can redistribute it and/or modify
5 | // it under the terms of the GNU Affero General Public License as published by
6 | // the Free Software Foundation, either version 3 of the License, or
7 | // (at your option) any later version.
8 | //
9 | // This program is distributed in the hope that it will be useful,
10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | // GNU Affero General Public License for more details.
13 | //
14 | // You should have received a copy of the GNU Affero General Public License
15 | // along with this program. If not, see .
16 |
17 | package dejavu
18 |
19 | import (
20 | "errors"
21 | "os"
22 | "path/filepath"
23 | "time"
24 |
25 | "github.com/88250/go-humanize"
26 | "github.com/88250/gulu"
27 | "github.com/siyuan-note/dejavu/entity"
28 | "github.com/siyuan-note/filelock"
29 | "github.com/siyuan-note/logging"
30 | "github.com/vmihailenco/msgpack/v5"
31 | )
32 |
33 | var ErrNotFoundIndex = errors.New("not found index")
34 |
35 | func (repo *Repo) Latest() (ret *entity.Index, err error) {
36 | latest := filepath.Join(repo.Path, "refs", "latest")
37 | if !filelock.IsExist(latest) {
38 | err = ErrNotFoundIndex
39 | return
40 | }
41 |
42 | data, err := os.ReadFile(latest)
43 | if nil != err {
44 | logging.LogErrorf("read latest index [%s] failed: %s", latest, err)
45 | return
46 | }
47 | hash := string(data)
48 | ret, err = repo.store.GetIndex(hash)
49 | if nil != err {
50 | logging.LogErrorf("get latest index [%s] failed: %s", hash, err)
51 | return
52 | }
53 | //logging.LogInfof("got local latest [%s]", ret.String())
54 | return
55 | }
56 |
57 | // FullIndex 描述了完整的索引结构。
58 | type FullIndex struct {
59 | ID string `json:"id"`
60 | Files []*entity.File `json:"files"`
61 | Spec int `json:"spec"`
62 | }
63 |
64 | func (repo *Repo) UpdateLatest(index *entity.Index) (err error) {
65 | start := time.Now()
66 |
67 | refs := filepath.Join(repo.Path, "refs")
68 | err = os.MkdirAll(refs, 0755)
69 | if nil != err {
70 | return
71 | }
72 | err = gulu.File.WriteFileSafer(filepath.Join(refs, "latest"), []byte(index.ID), 0644)
73 | if nil != err {
74 | return
75 | }
76 |
77 | fullLatestPath := filepath.Join(repo.Path, "full-latest.json")
78 | files, err := repo.GetFiles(index)
79 | if nil != err {
80 | return
81 | }
82 |
83 | fullIndex := &FullIndex{ID: index.ID, Files: files, Spec: 0}
84 | data, err := msgpack.Marshal(fullIndex)
85 | if nil != err {
86 | return
87 | }
88 | err = gulu.File.WriteFileSafer(fullLatestPath, data, 0644)
89 | if nil != err {
90 | return
91 | }
92 |
93 | logging.LogInfof("updated local latest to [%s], full latest [size=%s], cost [%s]", index.String(), humanize.Bytes(uint64(len(data))), time.Since(start))
94 | return
95 | }
96 |
97 | func (repo *Repo) getFullLatest(latest *entity.Index) (ret *FullIndex) {
98 | start := time.Now()
99 |
100 | fullLatestPath := filepath.Join(repo.Path, "full-latest.json")
101 | if !gulu.File.IsExist(fullLatestPath) {
102 | return
103 | }
104 |
105 | data, err := os.ReadFile(fullLatestPath)
106 | if nil != err {
107 | logging.LogErrorf("read full latest failed: %s", err)
108 | return
109 | }
110 |
111 | ret = &FullIndex{}
112 | if err = msgpack.Unmarshal(data, ret); nil != err {
113 | logging.LogErrorf("unmarshal full latest [%s] failed: %s", fullLatestPath, err)
114 | ret = nil
115 | if err = os.RemoveAll(fullLatestPath); nil != err {
116 | logging.LogErrorf("remove full latest [%s] failed: %s", fullLatestPath, err)
117 | }
118 | return
119 | }
120 |
121 | if ret.ID != latest.ID {
122 | logging.LogErrorf("full latest ID [%s] not match latest ID [%s]", ret.ID, latest.ID)
123 | ret = nil
124 | if err = os.RemoveAll(fullLatestPath); nil != err {
125 | logging.LogErrorf("remove full latest [%s] failed: %s", fullLatestPath, err)
126 | }
127 | return
128 | }
129 |
130 | for _, f := range ret.Files {
131 | repo.store.cacheFile(f)
132 | }
133 |
134 | logging.LogInfof("got local full latest [files=%d, size=%s], cost [%s]", len(ret.Files), humanize.Bytes(uint64(len(data))), time.Since(start))
135 | return
136 | }
137 |
138 | func (repo *Repo) GetTag(tag string) (id string, err error) {
139 | if !gulu.File.IsValidFilename(tag) {
140 | err = errors.New("invalid tag name")
141 | }
142 | tag = filepath.Join(repo.Path, "refs", "tags", tag)
143 | if !filelock.IsExist(tag) {
144 | err = errors.New("tag not found")
145 | }
146 | data, err := filelock.ReadFile(tag)
147 | if nil != err {
148 | return
149 | }
150 | id = string(data)
151 | return
152 | }
153 |
154 | func (repo *Repo) AddTag(id, tag string) (err error) {
155 | if !gulu.File.IsValidFilename(tag) {
156 | return errors.New("invalid tag name")
157 | }
158 |
159 | _, err = repo.store.GetIndex(id)
160 | if nil != err {
161 | return
162 | }
163 |
164 | tags := filepath.Join(repo.Path, "refs", "tags")
165 | if err = os.MkdirAll(tags, 0755); nil != err {
166 | return
167 | }
168 | tag = filepath.Join(tags, tag)
169 | err = gulu.File.WriteFileSafer(tag, []byte(id), 0644)
170 | return
171 | }
172 |
173 | func (repo *Repo) RemoveTag(tag string) (err error) {
174 | tag = filepath.Join(repo.Path, "refs", "tags", tag)
175 | if !gulu.File.IsExist(tag) {
176 | return
177 | }
178 |
179 | err = os.Remove(tag)
180 | return
181 | }
182 |
--------------------------------------------------------------------------------
/log.go:
--------------------------------------------------------------------------------
1 | // DejaVu - Data snapshot and sync.
2 | // Copyright (c) 2022-present, b3log.org
3 | //
4 | // This program is free software: you can redistribute it and/or modify
5 | // it under the terms of the GNU Affero General Public License as published by
6 | // the Free Software Foundation, either version 3 of the License, or
7 | // (at your option) any later version.
8 | //
9 | // This program is distributed in the hope that it will be useful,
10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | // GNU Affero General Public License for more details.
13 | //
14 | // You should have received a copy of the GNU Affero General Public License
15 | // along with this program. If not, see .
16 |
17 | package dejavu
18 |
19 | import (
20 | "os"
21 | "path/filepath"
22 | "sort"
23 | "time"
24 |
25 | "github.com/88250/go-humanize"
26 | "github.com/88250/gulu"
27 | "github.com/siyuan-note/dejavu/entity"
28 | "github.com/siyuan-note/filelock"
29 | )
30 |
31 | type Log struct {
32 | ID string `json:"id"` // 索引 ID
33 | Memo string `json:"memo"` // 索引备注
34 | Created int64 `json:"created"` // 索引时间
35 | HCreated string `json:"hCreated"` // 索引时间 "2006-01-02 15:04:05"
36 | Files []*entity.File `json:"files"` // 文件列表
37 | Count int `json:"count"` // 文件总数
38 | Size int64 `json:"size"` // 文件总大小
39 | HSize string `json:"hSize"` // 格式化好的文件总大小 "10.00 MB"
40 | SystemID string `json:"systemID"` // 设备 ID
41 | SystemName string `json:"systemName"` // 设备名称
42 | SystemOS string `json:"systemOS"` // 设备操作系统
43 | Tag string `json:"tag"` // 索引标记名称
44 | HTagUpdated string `json:"hTagUpdated"` // 标记时间 "2006-01-02 15:04:05"
45 | }
46 |
47 | func (log *Log) String() string {
48 | data, err := gulu.JSON.MarshalJSON(log)
49 | if nil != err {
50 | return "print log [" + log.ID + "] failed"
51 | }
52 | return string(data)
53 | }
54 |
55 | func (repo *Repo) GetCloudRepoLogs(page int) (ret []*Log, pageCount, totalCount int, err error) {
56 | cloudIndexes, pageCount, totalCount, err := repo.cloud.GetIndexes(page)
57 | if nil != err {
58 | return
59 | }
60 |
61 | for _, index := range cloudIndexes {
62 | var log *Log
63 | log, err = repo.getLog(index, true)
64 | if nil != err {
65 | return
66 | }
67 | ret = append(ret, log)
68 | }
69 | return
70 | }
71 |
72 | func (repo *Repo) GetCloudRepoTagLogs(context map[string]interface{}) (ret []*Log, err error) {
73 | cloudTags, err := repo.cloud.GetTags()
74 | if nil != err {
75 | return
76 | }
77 | for _, tag := range cloudTags {
78 | index, _ := repo.store.GetIndex(tag.ID)
79 | if nil == index {
80 | _, index, err = repo.downloadCloudIndex(tag.ID, context)
81 | if nil != err {
82 | return
83 | }
84 | }
85 |
86 | var log *Log
87 | log, err = repo.getLog(index, false)
88 | if nil != err {
89 | return
90 | }
91 | log.Tag = tag.Name
92 | log.HTagUpdated = tag.Updated
93 | ret = append(ret, log)
94 | }
95 | sort.Slice(ret, func(i, j int) bool { return ret[i].Created > ret[j].Created })
96 | return
97 | }
98 |
99 | func (repo *Repo) GetTagLogs() (ret []*Log, err error) {
100 | tags := filepath.Join(repo.Path, "refs", "tags")
101 | if !gulu.File.IsExist(tags) {
102 | return
103 | }
104 |
105 | entries, err := os.ReadDir(tags)
106 | if nil != err {
107 | return
108 | }
109 | for _, entry := range entries {
110 | if entry.IsDir() {
111 | continue
112 | }
113 | var data []byte
114 | name := entry.Name()
115 | data, err = filelock.ReadFile(filepath.Join(tags, name))
116 | if nil != err {
117 | return
118 | }
119 | info, _ := os.Stat(filepath.Join(tags, name))
120 | updated := info.ModTime().Format("2006-01-02 15:04:05")
121 | id := string(data)
122 | if 40 != len(id) {
123 | continue
124 | }
125 | var index *entity.Index
126 | index, err = repo.store.GetIndex(id)
127 | if nil != err {
128 | return
129 | }
130 |
131 | var log *Log
132 | log, err = repo.getLog(index, true)
133 | if nil != err {
134 | return
135 | }
136 | log.Tag = name
137 | log.HTagUpdated = updated
138 | ret = append(ret, log)
139 | }
140 | sort.Slice(ret, func(i, j int) bool { return ret[i].Created > ret[j].Created })
141 | return
142 | }
143 |
144 | func (repo *Repo) GetIndexLogs(page, pageSize int) (ret []*Log, pageCount, totalCount int, err error) {
145 | indexes, totalCount, pageCount, err := repo.GetIndexes(page, pageSize)
146 | if nil != err {
147 | return
148 | }
149 |
150 | for _, index := range indexes {
151 | var log *Log
152 | log, err = repo.getLog(index, true)
153 | if nil != err {
154 | return
155 | }
156 | ret = append(ret, log)
157 | }
158 | return
159 | }
160 |
161 | func (repo *Repo) getLog(index *entity.Index, fetchFiles bool) (ret *Log, err error) {
162 | var files []*entity.File
163 | if fetchFiles {
164 | files, _ = repo.getFiles(index.Files)
165 | }
166 | ret = &Log{
167 | ID: index.ID,
168 | Memo: index.Memo,
169 | Created: index.Created,
170 | HCreated: time.UnixMilli(index.Created).Format("2006-01-02 15:04:05"),
171 | Files: files,
172 | Count: index.Count,
173 | Size: index.Size,
174 | HSize: humanize.BytesCustomCeil(uint64(index.Size), 2),
175 | SystemID: index.SystemID,
176 | SystemName: index.SystemName,
177 | SystemOS: index.SystemOS,
178 | }
179 | return
180 | }
181 |
--------------------------------------------------------------------------------
/sync_lock.go:
--------------------------------------------------------------------------------
1 | // DejaVu - Data snapshot and sync.
2 | // Copyright (c) 2022-present, b3log.org
3 | //
4 | // This program is free software: you can redistribute it and/or modify
5 | // it under the terms of the GNU Affero General Public License as published by
6 | // the Free Software Foundation, either version 3 of the License, or
7 | // (at your option) any later version.
8 | //
9 | // This program is distributed in the hope that it will be useful,
10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | // GNU Affero General Public License for more details.
13 | //
14 | // You should have received a copy of the GNU Affero General Public License
15 | // along with this program. If not, see .
16 |
17 | package dejavu
18 |
19 | import (
20 | "errors"
21 | "os"
22 | "path/filepath"
23 | "strings"
24 | "time"
25 |
26 | "github.com/88250/gulu"
27 | "github.com/siyuan-note/dejavu/cloud"
28 | "github.com/siyuan-note/eventbus"
29 | "github.com/siyuan-note/logging"
30 | )
31 |
32 | var (
33 | ErrLockCloudFailed = errors.New("lock cloud repo failed")
34 | ErrCloudLocked = errors.New("cloud repo is locked")
35 | )
36 |
37 | const (
38 | lockSyncKey = "lock-sync"
39 | )
40 |
41 | func (repo *Repo) unlockCloud(context map[string]interface{}) {
42 | endRefreshLock <- true
43 | var err error
44 | for i := 0; i < 3; i++ {
45 | eventbus.Publish(eventbus.EvtCloudUnlock, context)
46 | err = repo.cloud.RemoveObject(lockSyncKey)
47 | if nil == err {
48 | return
49 | }
50 | }
51 |
52 | if errors.Is(err, cloud.ErrCloudAuthFailed) {
53 | return
54 | }
55 |
56 | logging.LogErrorf("unlock cloud repo failed: %s", err)
57 | return
58 | }
59 |
60 | var endRefreshLock = make(chan bool)
61 |
62 | func (repo *Repo) tryLockCloud(currentDeviceID string, context map[string]interface{}) (err error) {
63 | for i := 0; i < 3; i++ {
64 | err = repo.lockCloud(currentDeviceID, context)
65 | if nil != err {
66 | if errors.Is(err, ErrCloudLocked) {
67 | logging.LogInfof("cloud repo is locked, retry after 5s")
68 | time.Sleep(5 * time.Second)
69 | continue
70 | }
71 | return
72 | }
73 |
74 | // 锁定成功,定时刷新锁
75 | go func() {
76 | ticker := time.NewTicker(30 * time.Second)
77 | defer ticker.Stop()
78 | for {
79 | select {
80 | case <-endRefreshLock:
81 | return
82 | case <-ticker.C:
83 | if refershErr := repo.lockCloud0(currentDeviceID); nil != refershErr {
84 | logging.LogErrorf("refresh cloud repo lock failed: %s", refershErr)
85 | }
86 | }
87 | }
88 | }()
89 |
90 | return
91 | }
92 | return
93 | }
94 |
95 | // lockCloud 锁定云端仓库,不要单独调用,应该调用 tryLockCloud,否则解锁时 endRefreshLock 会阻塞。
96 | func (repo *Repo) lockCloud(currentDeviceID string, context map[string]interface{}) (err error) {
97 | eventbus.Publish(eventbus.EvtCloudLock, context)
98 | data, err := repo.cloud.DownloadObject(lockSyncKey)
99 | if errors.Is(err, cloud.ErrCloudObjectNotFound) {
100 | err = repo.lockCloud0(currentDeviceID)
101 | return
102 | }
103 |
104 | content := map[string]interface{}{}
105 | err = gulu.JSON.UnmarshalJSON(data, &content)
106 | if nil != err {
107 | logging.LogErrorf("unmarshal lock sync failed: %s", err)
108 | err = repo.cloud.RemoveObject(lockSyncKey)
109 | if nil != err {
110 | logging.LogErrorf("remove unmarshalled lock sync failed: %s", err)
111 | } else {
112 | err = repo.lockCloud0(currentDeviceID)
113 | }
114 |
115 | if ok, retErr := parseErr(err); ok {
116 | return retErr
117 | }
118 | return
119 | }
120 |
121 | deviceID := content["deviceID"].(string)
122 | t := int64(content["time"].(float64))
123 | now := time.Now()
124 | lockTime := time.UnixMilli(t)
125 | if now.After(lockTime.Add(65*time.Second)) || deviceID == currentDeviceID {
126 | // 云端锁超时过期或者就是当前设备锁的,那么当前设备可以继续直接锁
127 | err = repo.lockCloud0(currentDeviceID)
128 | return
129 | }
130 |
131 | logging.LogWarnf("cloud repo is locked by device [%s] at [%s], will retry after 30s", content["deviceID"].(string), lockTime.Format("2006-01-02 15:04:05"))
132 | err = ErrCloudLocked
133 | return
134 | }
135 |
136 | func (repo *Repo) lockCloud0(currentDeviceID string) (err error) {
137 | if !gulu.File.IsDir(repo.Path) {
138 | if err = os.MkdirAll(repo.Path, 0755); nil != err {
139 | logging.LogErrorf("create repo dir failed: %s", err)
140 | return
141 | }
142 | }
143 |
144 | lockSyncPath := filepath.Join(repo.Path, lockSyncKey)
145 | content := map[string]interface{}{
146 | "deviceID": currentDeviceID,
147 | "time": time.Now().UnixMilli(),
148 | }
149 | data, err := gulu.JSON.MarshalJSON(content)
150 | if nil != err {
151 | logging.LogErrorf("marshal lock sync failed: %s", err)
152 | err = ErrLockCloudFailed
153 | return
154 | }
155 | err = gulu.File.WriteFileSafer(lockSyncPath, data, 0644)
156 | if nil != err {
157 | logging.LogErrorf("write lock sync failed: %s", err)
158 | err = ErrCloudLocked
159 | return
160 | }
161 |
162 | _, err = repo.cloud.UploadObject(lockSyncKey, true)
163 | if nil != err {
164 | if errors.Is(err, cloud.ErrSystemTimeIncorrect) || errors.Is(err, cloud.ErrCloudAuthFailed) || errors.Is(err, cloud.ErrDeprecatedVersion) ||
165 | errors.Is(err, cloud.ErrCloudCheckFailed) {
166 | return
167 | }
168 |
169 | logging.LogErrorf("upload lock sync failed: %s", err)
170 | if ok, retErr := parseErr(err); ok {
171 | return retErr
172 | }
173 |
174 | err = ErrLockCloudFailed
175 | return
176 | }
177 | return
178 | }
179 |
180 | func parseErr(err error) (bool, error) {
181 | if nil == err {
182 | return true, nil
183 | }
184 |
185 | msg := strings.ToLower(err.Error())
186 | if strings.Contains(msg, "requesttimetooskewed") || strings.Contains(msg, "request time and the current time is too large") {
187 | return true, cloud.ErrSystemTimeIncorrect
188 | } else if strings.Contains(msg, "500") || strings.Contains(msg, "internal server error") || strings.Contains(msg, "503") || strings.Contains(msg, "unavailable") {
189 | return true, cloud.ErrCloudServiceUnavailable
190 | } else if strings.Contains(msg, "401") || strings.Contains(msg, "unauthorized") ||
191 | strings.Contains(msg, "403") || strings.Contains(msg, "forbidden") {
192 | return true, cloud.ErrCloudForbidden
193 | } else if strings.Contains(msg, "429") || strings.Contains(msg, "too many requests") {
194 | return true, cloud.ErrCloudTooManyRequests
195 | }
196 | return false, err
197 | }
198 |
--------------------------------------------------------------------------------
/repo_test.go:
--------------------------------------------------------------------------------
1 | // DejaVu - Data snapshot and sync.
2 | // Copyright (c) 2022-present, b3log.org
3 | //
4 | // This program is free software: you can redistribute it and/or modify
5 | // it under the terms of the GNU Affero General Public License as published by
6 | // the Free Software Foundation, either version 3 of the License, or
7 | // (at your option) any later version.
8 | //
9 | // This program is distributed in the hope that it will be useful,
10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | // GNU Affero General Public License for more details.
13 | //
14 | // You should have received a copy of the GNU Affero General Public License
15 | // along with this program. If not, see .
16 |
17 | package dejavu
18 |
19 | import (
20 | "errors"
21 | "os"
22 | "path/filepath"
23 | "runtime"
24 | "testing"
25 |
26 | "github.com/88250/gulu"
27 | "github.com/siyuan-note/dejavu/entity"
28 | "github.com/siyuan-note/encryption"
29 | "github.com/siyuan-note/eventbus"
30 | )
31 |
32 | const (
33 | testRepoPassword = "pass"
34 | testRepoPasswordSalt = "salt"
35 | testRepoPath = "testdata/repo"
36 | testHistoryPath = "testdata/history"
37 | testTempPath = "testdata/temp"
38 | testDataPath = "testdata/data"
39 | testDataCheckoutPath = "testdata/data-checkout"
40 | )
41 |
42 | var (
43 | deviceID = "device-id-0"
44 | deviceName, _ = os.Hostname()
45 | deviceOS = runtime.GOOS
46 | )
47 |
48 | func TestIndexEmpty(t *testing.T) {
49 | clearTestdata(t)
50 | subscribeEvents(t)
51 |
52 | aesKey, err := encryption.KDF(testRepoPassword, testRepoPasswordSalt)
53 | if nil != err {
54 | return
55 | }
56 |
57 | testEmptyDataPath := "testdata/empty-data"
58 | if err = os.MkdirAll(testEmptyDataPath, 0755); nil != err {
59 | t.Fatalf("mkdir failed: %s", err)
60 | return
61 | }
62 | repo, err := NewRepo(testEmptyDataPath, testRepoPath, testHistoryPath, testTempPath, deviceID, deviceName, deviceOS, aesKey, ignoreLines(), nil)
63 | if nil != err {
64 | t.Fatalf("new repo failed: %s", err)
65 | return
66 | }
67 | _, err = repo.Index("Index 1", true, map[string]interface{}{})
68 | if !errors.Is(err, ErrEmptyIndex) {
69 | t.Fatalf("should be empty index")
70 | return
71 | }
72 | }
73 |
74 | func TestPurge(t *testing.T) {
75 | clearTestdata(t)
76 | subscribeEvents(t)
77 |
78 | repo, _ := initIndex(t)
79 | stat, err := repo.Purge()
80 | if nil != err {
81 | t.Fatalf("purge failed: %s", err)
82 | return
83 | }
84 |
85 | t.Logf("purge stat: %#v", stat)
86 | }
87 |
88 | func TestIndexCheckout(t *testing.T) {
89 | clearTestdata(t)
90 | subscribeEvents(t)
91 |
92 | repo, index := initIndex(t)
93 | index2, err := repo.Index("Index 2", true, map[string]interface{}{})
94 | if nil != err {
95 | t.Fatalf("index failed: %s", err)
96 | return
97 | }
98 | if index.ID != index2.ID {
99 | t.Fatalf("index id not match")
100 | return
101 | }
102 |
103 | aesKey := repo.store.AesKey
104 | repo, err = NewRepo(testDataCheckoutPath, testRepoPath, testHistoryPath, testTempPath, deviceID, deviceName, deviceOS, aesKey, ignoreLines(), nil)
105 | if nil != err {
106 | t.Fatalf("new repo failed: %s", err)
107 | return
108 | }
109 | _, _, err = repo.Checkout(index.ID, map[string]interface{}{})
110 | if nil != err {
111 | t.Fatalf("checkout failed: %s", err)
112 | return
113 | }
114 |
115 | if !gulu.File.IsExist(filepath.Join(testDataCheckoutPath, "foo")) {
116 | t.Fatalf("checkout failed")
117 | return
118 | }
119 | }
120 |
121 | func clearTestdata(t *testing.T) {
122 | err := os.RemoveAll(testRepoPath)
123 | if nil != err {
124 | t.Fatalf("remove failed: %s", err)
125 | return
126 | }
127 |
128 | err = os.RemoveAll(testDataCheckoutPath)
129 | if nil != err {
130 | t.Fatalf("remove failed: %s", err)
131 | return
132 | }
133 | }
134 |
135 | func subscribeEvents(t *testing.T) {
136 | eventbus.Subscribe(eventbus.EvtIndexBeforeWalkData, func(context map[string]interface{}, path string) {
137 | t.Logf("[%s]: [%s]", eventbus.EvtIndexBeforeWalkData, path)
138 | })
139 | eventbus.Subscribe(eventbus.EvtIndexWalkData, func(context map[string]interface{}, path string) {
140 | t.Logf("[%s]: [%s]", eventbus.EvtIndexWalkData, path)
141 | })
142 | eventbus.Subscribe(eventbus.EvtIndexBeforeGetLatestFiles, func(context map[string]interface{}, total int) {
143 | t.Logf("[%s]: [%v/%v]", eventbus.EvtIndexBeforeGetLatestFiles, 0, total)
144 | })
145 | eventbus.Subscribe(eventbus.EvtIndexGetLatestFile, func(context map[string]interface{}, count int, total int) {
146 | t.Logf("[%s]: [%v/%v]", eventbus.EvtIndexGetLatestFile, count, total)
147 | })
148 | eventbus.Subscribe(eventbus.EvtIndexUpsertFiles, func(context map[string]interface{}, total int) {
149 | t.Logf("[%s]: [%v/%v]", eventbus.EvtIndexUpsertFiles, 0, total)
150 | })
151 | eventbus.Subscribe(eventbus.EvtIndexUpsertFile, func(context map[string]interface{}, count int, total int) {
152 | t.Logf("[%s]: [%v/%v]", eventbus.EvtIndexUpsertFile, count, total)
153 | })
154 |
155 | eventbus.Subscribe(eventbus.EvtCheckoutBeforeWalkData, func(context map[string]interface{}, path string) {
156 | t.Logf("[%s]: [%s]", eventbus.EvtCheckoutBeforeWalkData, path)
157 | })
158 | eventbus.Subscribe(eventbus.EvtCheckoutWalkData, func(context map[string]interface{}, path string) {
159 | t.Logf("[%s]: [%s]", eventbus.EvtCheckoutWalkData, path)
160 | })
161 | eventbus.Subscribe(eventbus.EvtCheckoutUpsertFiles, func(context map[string]interface{}, total int) {
162 | t.Logf("[%s]: [%d/%d]", eventbus.EvtCheckoutUpsertFiles, 0, total)
163 | })
164 | eventbus.Subscribe(eventbus.EvtCheckoutUpsertFile, func(context map[string]interface{}, count, total int) {
165 | t.Logf("[%s]: [%d/%d]", eventbus.EvtCheckoutUpsertFile, count, total)
166 | })
167 | eventbus.Subscribe(eventbus.EvtCheckoutRemoveFiles, func(context map[string]interface{}, total int) {
168 | t.Logf("[%s]: [%d/%d]", eventbus.EvtCheckoutRemoveFiles, 0, total)
169 | })
170 | eventbus.Subscribe(eventbus.EvtCheckoutRemoveFile, func(context map[string]interface{}, count, total int) {
171 | t.Logf("[%s]: [%d/%d]", eventbus.EvtCheckoutRemoveFile, count, total)
172 | })
173 | }
174 |
175 | func initIndex(t *testing.T) (repo *Repo, index *entity.Index) {
176 | aesKey, err := encryption.KDF(testRepoPassword, testRepoPasswordSalt)
177 | if nil != err {
178 | return
179 | }
180 |
181 | repo, err = NewRepo(testDataPath, testRepoPath, testHistoryPath, testTempPath, deviceID, deviceName, deviceOS, aesKey, ignoreLines(), nil)
182 | if nil != err {
183 | t.Fatalf("new repo failed: %s", err)
184 | return
185 | }
186 | index, err = repo.Index("Index 1", true, map[string]interface{}{})
187 | if nil != err {
188 | t.Fatalf("index failed: %s", err)
189 | return
190 | }
191 | return
192 | }
193 |
194 | func ignoreLines() []string {
195 | return []string{"bar"}
196 | }
197 |
--------------------------------------------------------------------------------
/backup.go:
--------------------------------------------------------------------------------
1 | // DejaVu - Data snapshot and sync.
2 | // Copyright (c) 2022-present, b3log.org
3 | //
4 | // This program is free software: you can redistribute it and/or modify
5 | // it under the terms of the GNU Affero General Public License as published by
6 | // the Free Software Foundation, either version 3 of the License, or
7 | // (at your option) any later version.
8 | //
9 | // This program is distributed in the hope that it will be useful,
10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | // GNU Affero General Public License for more details.
13 | //
14 | // You should have received a copy of the GNU Affero General Public License
15 | // along with this program. If not, see .
16 |
17 | package dejavu
18 |
19 | import (
20 | "github.com/siyuan-note/dejavu/cloud"
21 | "os"
22 | "path"
23 | "strings"
24 |
25 | "github.com/88250/gulu"
26 | "github.com/siyuan-note/dejavu/entity"
27 | "github.com/siyuan-note/logging"
28 | )
29 |
30 | func (repo *Repo) DownloadIndex(id string, context map[string]interface{}) (downloadFileCount, downloadChunkCount int, downloadBytes int64, err error) {
31 | lock.Lock()
32 | defer lock.Unlock()
33 |
34 | downloadFileCount, downloadChunkCount, downloadBytes, err = repo.downloadIndex(id, context)
35 | return
36 | }
37 |
38 | func (repo *Repo) DownloadTagIndex(tag, id string, context map[string]interface{}) (downloadFileCount, downloadChunkCount int, downloadBytes int64, err error) {
39 | lock.Lock()
40 | defer lock.Unlock()
41 |
42 | downloadFileCount, downloadChunkCount, downloadBytes, err = repo.downloadIndex(id, context)
43 |
44 | // 更新本地标签
45 | err = repo.AddTag(id, tag)
46 | if nil != err {
47 | logging.LogErrorf("add tag failed: %s", err)
48 | return
49 | }
50 |
51 | return
52 | }
53 |
54 | func (repo *Repo) downloadIndex(id string, context map[string]interface{}) (downloadFileCount, downloadChunkCount int, downloadBytes int64, err error) {
55 | // 从云端下载标签指向的索引
56 | length, index, err := repo.downloadCloudIndex(id, context)
57 | if nil != err {
58 | logging.LogErrorf("download cloud index failed: %s", err)
59 | return
60 | }
61 | downloadFileCount++
62 | downloadBytes += length
63 | apiGet := 1
64 |
65 | // 计算本地缺失的文件
66 | fetchFileIDs, err := repo.localNotFoundFiles(index.Files)
67 | if nil != err {
68 | logging.LogErrorf("get local not found files failed: %s", err)
69 | return
70 | }
71 |
72 | // 从云端下载缺失文件并入库
73 | length, fetchedFiles, err := repo.downloadCloudFilesPut(fetchFileIDs, context)
74 | if nil != err {
75 | logging.LogErrorf("download cloud files put failed: %s", err)
76 | return
77 | }
78 | downloadBytes += length
79 | downloadFileCount = len(fetchFileIDs)
80 | apiGet += downloadFileCount
81 |
82 | // 从文件列表中得到去重后的分块列表
83 | cloudChunkIDs := repo.getChunks(fetchedFiles)
84 |
85 | // 计算本地缺失的分块
86 | fetchChunkIDs, err := repo.localNotFoundChunks(cloudChunkIDs)
87 | if nil != err {
88 | logging.LogErrorf("get local not found chunks failed: %s", err)
89 | return
90 | }
91 |
92 | // 从云端获取分块并入库
93 | length, err = repo.downloadCloudChunksPut(fetchChunkIDs, context)
94 | downloadBytes += length
95 | downloadChunkCount = len(fetchChunkIDs)
96 | apiGet += downloadChunkCount
97 |
98 | // 更新本地索引
99 | err = repo.store.PutIndex(index)
100 | if nil != err {
101 | logging.LogErrorf("put index failed: %s", err)
102 | return
103 | }
104 |
105 | // 统计流量
106 | go repo.cloud.AddTraffic(&cloud.Traffic{DownloadBytes: downloadBytes, APIGet: apiGet})
107 | return
108 | }
109 |
110 | func (repo *Repo) UploadTagIndex(tag, id string, context map[string]interface{}) (uploadFileCount, uploadChunkCount int, uploadBytes int64, err error) {
111 | lock.Lock()
112 | defer lock.Unlock()
113 |
114 | uploadFileCount, uploadChunkCount, uploadBytes, err = repo.uploadTagIndex(tag, id, context)
115 | if e, ok := err.(*os.PathError); ok && os.IsNotExist(err) {
116 | p := e.Path
117 | if !strings.Contains(p, "objects") {
118 | return
119 | }
120 |
121 | // 索引时正常,但是上传时可能因为外部变更导致对象(文件或者分块)不存在,此时需要告知用户数据仓库已经损坏,需要重置数据仓库
122 | logging.LogErrorf("upload tag index failed: %s", err)
123 | err = ErrRepoFatal
124 | }
125 | return
126 | }
127 |
128 | func (repo *Repo) uploadTagIndex(tag, id string, context map[string]interface{}) (uploadFileCount, uploadChunkCount int, uploadBytes int64, err error) {
129 | index, err := repo.store.GetIndex(id)
130 | if nil != err {
131 | logging.LogErrorf("get index failed: %s", err)
132 | return
133 | }
134 |
135 | availableSize := repo.cloud.GetAvailableSize()
136 | if availableSize <= index.Size {
137 | err = ErrCloudStorageSizeExceeded
138 | return
139 | }
140 |
141 | // 获取云端数据仓库统计信息
142 | cloudRepoSize, cloudBackupCount, err := repo.getCloudRepoStat()
143 | if nil != err {
144 | logging.LogErrorf("get cloud repo stat failed: %s", err)
145 | return
146 | }
147 | if 12 <= cloudBackupCount {
148 | err = ErrCloudBackupCountExceeded
149 | return
150 | }
151 |
152 | if availableSize <= cloudRepoSize+index.Size {
153 | err = ErrCloudStorageSizeExceeded
154 | return
155 | }
156 |
157 | // 从云端获取文件列表
158 | cloudFileIDs, refs, err := repo.cloud.GetRefsFiles()
159 | if nil != err {
160 | logging.LogErrorf("get cloud repo refs files failed: %s", err)
161 | return
162 | }
163 | apiGet := len(refs) + 1
164 |
165 | // 计算云端缺失的文件
166 | var uploadFiles []*entity.File
167 | for _, localFileID := range index.Files {
168 | if !gulu.Str.Contains(localFileID, cloudFileIDs) {
169 | var uploadFile *entity.File
170 | uploadFile, err = repo.store.GetFile(localFileID)
171 | if nil != err {
172 | logging.LogErrorf("get file failed: %s", err)
173 | return
174 | }
175 | uploadFiles = append(uploadFiles, uploadFile)
176 | }
177 | }
178 |
179 | // 从文件列表中得到去重后的分块列表
180 | uploadChunkIDs := repo.getChunks(uploadFiles)
181 |
182 | // 计算云端缺失的分块
183 | uploadChunkIDs, err = repo.cloud.GetChunks(uploadChunkIDs)
184 | if nil != err {
185 | logging.LogErrorf("get cloud repo upload chunks failed: %s", err)
186 | return
187 | }
188 | apiGet += len(uploadChunkIDs)
189 |
190 | // 上传分块
191 | length, err := repo.uploadChunks(uploadChunkIDs, context)
192 | if nil != err {
193 | logging.LogErrorf("upload chunks failed: %s", err)
194 | return
195 | }
196 | uploadChunkCount = len(uploadChunkIDs)
197 | uploadBytes += length
198 | apiPut := uploadChunkCount
199 |
200 | // 上传文件
201 | length, err = repo.uploadFiles(uploadFiles, context)
202 | if nil != err {
203 | logging.LogErrorf("upload files failed: %s", err)
204 | return
205 | }
206 | uploadFileCount = len(uploadFiles)
207 | uploadBytes += length
208 | apiPut += uploadFileCount
209 |
210 | // 上传索引
211 | length, err = repo.uploadIndex(index, context)
212 | uploadFileCount++
213 | uploadBytes += length
214 | apiPut++
215 |
216 | // 上传标签
217 | length, err = repo.updateCloudRef("refs/tags/"+tag, context)
218 | uploadFileCount++
219 | uploadBytes += length
220 | apiPut++
221 |
222 | // 统计流量
223 | go repo.cloud.AddTraffic(&cloud.Traffic{UploadBytes: uploadBytes, APIGet: apiGet, APIPut: apiPut})
224 | return
225 | }
226 |
227 | func (repo *Repo) getCloudRepoStat() (repoSize int64, backupCount int, err error) {
228 | repoStat, err := repo.cloud.GetStat()
229 | if nil != err {
230 | return
231 | }
232 |
233 | repoSize = repoStat.Sync.Size + repoStat.Backup.Size
234 | backupCount = repoStat.Backup.Count
235 | return
236 | }
237 |
238 | func (repo *Repo) RemoveCloudRepoTag(tag string) (err error) {
239 | key := path.Join("refs", "tags", tag)
240 | return repo.cloud.RemoveObject(key)
241 | }
242 |
--------------------------------------------------------------------------------
/sync_manual.go:
--------------------------------------------------------------------------------
1 | // DejaVu - Data snapshot and sync.
2 | // Copyright (c) 2022-present, b3log.org
3 | //
4 | // This program is free software: you can redistribute it and/or modify
5 | // it under the terms of the GNU Affero General Public License as published by
6 | // the Free Software Foundation, either version 3 of the License, or
7 | // (at your option) any later version.
8 | //
9 | // This program is distributed in the hope that it will be useful,
10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | // GNU Affero General Public License for more details.
13 | //
14 | // You should have received a copy of the GNU Affero General Public License
15 | // along with this program. If not, see .
16 |
17 | package dejavu
18 |
19 | import (
20 | "errors"
21 | "path/filepath"
22 | "sync"
23 | "time"
24 |
25 | "github.com/88250/gulu"
26 | "github.com/siyuan-note/dejavu/cloud"
27 | "github.com/siyuan-note/dejavu/entity"
28 | "github.com/siyuan-note/logging"
29 | )
30 |
31 | func (repo *Repo) SyncDownload(context map[string]interface{}) (mergeResult *MergeResult, trafficStat *TrafficStat, err error) {
32 | lock.Lock()
33 | defer lock.Unlock()
34 |
35 | // 锁定云端,防止其他设备并发上传数据
36 | err = repo.tryLockCloud(repo.DeviceID, context)
37 | if nil != err {
38 | return
39 | }
40 | defer repo.unlockCloud(context)
41 |
42 | mergeResult = &MergeResult{Time: time.Now()}
43 | trafficStat = &TrafficStat{m: &sync.Mutex{}}
44 |
45 | // 获取本地最新索引
46 | latest, err := repo.Latest()
47 | if nil != err {
48 | logging.LogErrorf("get latest failed: %s", err)
49 | return
50 | }
51 |
52 | // 从云端获取最新索引
53 | length, cloudLatest, err := repo.downloadCloudLatest(context)
54 | if nil != err {
55 | if !errors.Is(err, cloud.ErrCloudObjectNotFound) {
56 | logging.LogErrorf("download cloud latest failed: %s", err)
57 | return
58 | }
59 | }
60 | trafficStat.DownloadFileCount++
61 | trafficStat.DownloadBytes += length
62 | trafficStat.APIGet++
63 |
64 | if cloudLatest.ID == latest.ID || "" == cloudLatest.ID {
65 | // 数据一致或者云端为空,直接返回
66 | return
67 | }
68 |
69 | // 计算本地缺失的文件
70 | fetchFileIDs, err := repo.localNotFoundFiles(cloudLatest.Files)
71 | if nil != err {
72 | logging.LogErrorf("get local not found files failed: %s", err)
73 | return
74 | }
75 |
76 | // 从云端下载缺失文件并入库
77 | length, fetchedFiles, err := repo.downloadCloudFilesPut(fetchFileIDs, context)
78 | if nil != err {
79 | logging.LogErrorf("download cloud files put failed: %s", err)
80 | return
81 | }
82 | trafficStat.DownloadFileCount += len(fetchFileIDs)
83 | trafficStat.DownloadBytes += length
84 | trafficStat.APIGet += trafficStat.DownloadFileCount
85 |
86 | // 组装还原云端最新文件列表
87 | cloudLatestFiles, err := repo.getFiles(cloudLatest.Files)
88 | if nil != err {
89 | logging.LogErrorf("get cloud latest files failed: %s", err)
90 | return
91 | }
92 |
93 | // 从文件列表中得到去重后的分块列表
94 | cloudChunkIDs := repo.getChunks(cloudLatestFiles)
95 |
96 | // 计算本地缺失的分块
97 | fetchChunkIDs, err := repo.localNotFoundChunks(cloudChunkIDs)
98 | if nil != err {
99 | logging.LogErrorf("get local not found chunks failed: %s", err)
100 | return
101 | }
102 |
103 | // 从云端下载缺失分块并入库
104 | length, err = repo.downloadCloudChunksPut(fetchChunkIDs, context)
105 | trafficStat.DownloadBytes += length
106 | trafficStat.DownloadChunkCount += len(fetchChunkIDs)
107 | trafficStat.APIGet += trafficStat.DownloadChunkCount
108 |
109 | // 计算本地相比上一个同步点的 upsert 和 remove 差异
110 | latestFiles, err := repo.getFiles(latest.Files)
111 | if nil != err {
112 | logging.LogErrorf("get latest files failed: %s", err)
113 | return
114 | }
115 | latestSync := repo.latestSync()
116 | latestSyncFiles, err := repo.getFiles(latestSync.Files)
117 | if nil != err {
118 | logging.LogErrorf("get latest sync files failed: %s", err)
119 | return
120 | }
121 | localUpserts, localRemoves := repo.diffUpsertRemove(latestFiles, latestSyncFiles, false)
122 | localChanged := 0 < len(localUpserts) || 0 < len(localRemoves)
123 |
124 | // 计算云端最新相比本地最新的 upsert 和 remove 差异
125 | // 在单向同步的情况下该结果可直接作为合并结果
126 | mergeResult.Upserts, mergeResult.Removes = repo.diffUpsertRemove(cloudLatestFiles, latestFiles, false)
127 |
128 | var fetchedFileIDs []string
129 | for _, fetchedFile := range fetchedFiles {
130 | fetchedFileIDs = append(fetchedFileIDs, fetchedFile.ID)
131 | }
132 |
133 | // 计算冲突的 upsert
134 | // 冲突的文件以云端 upsert 和 remove 为准
135 | for _, localUpsert := range localUpserts {
136 | if nil != repo.getFile(mergeResult.Upserts, localUpsert) || nil != repo.getFile(mergeResult.Removes, localUpsert) {
137 | mergeResult.Conflicts = append(mergeResult.Conflicts, localUpsert)
138 | logging.LogInfof("sync download conflict [%s, %s, %s]", localUpsert.ID, localUpsert.Path, time.UnixMilli(localUpsert.Updated).Format("2006-01-02 15:04:05"))
139 | }
140 | }
141 |
142 | // 冲突文件复制到数据历史文件夹
143 | if 0 < len(mergeResult.Conflicts) {
144 | now := mergeResult.Time.Format("2006-01-02-150405")
145 | temp := filepath.Join(repo.TempPath, "repo", "sync", "conflicts", now)
146 | for i, file := range mergeResult.Conflicts {
147 | var checkoutTmp *entity.File
148 | checkoutTmp, err = repo.store.GetFile(file.ID)
149 | if nil != err {
150 | logging.LogErrorf("get file failed: %s", err)
151 | return
152 | }
153 |
154 | err = repo.checkoutFile(checkoutTmp, temp, i+1, len(mergeResult.Conflicts), context)
155 | if nil != err {
156 | logging.LogErrorf("checkout file failed: %s", err)
157 | return
158 | }
159 |
160 | absPath := filepath.Join(temp, checkoutTmp.Path)
161 | err = repo.genSyncHistory(now, file.Path, absPath)
162 | if nil != err {
163 | logging.LogErrorf("generate sync history failed: %s", err)
164 | err = ErrCloudGenerateConflictHistory
165 | return
166 | }
167 | }
168 | }
169 |
170 | // 数据变更后还原文件
171 | err = repo.restoreFiles(mergeResult, context)
172 | if nil != err {
173 | logging.LogErrorf("restore files failed: %s", err)
174 | return
175 | }
176 |
177 | // 处理合并
178 | err = repo.mergeSync(mergeResult, localChanged, false, latest, cloudLatest, cloudChunkIDs, trafficStat, context)
179 | if nil != err {
180 | logging.LogErrorf("merge sync failed: %s", err)
181 | return
182 | }
183 |
184 | // 统计流量
185 | go repo.cloud.AddTraffic(&cloud.Traffic{
186 | DownloadBytes: trafficStat.DownloadBytes,
187 | APIGet: trafficStat.APIGet,
188 | })
189 |
190 | // 移除空目录
191 | gulu.File.RemoveEmptyDirs(repo.DataPath, removeEmptyDirExcludes...)
192 | return
193 | }
194 |
195 | func (repo *Repo) SyncUpload(context map[string]interface{}) (trafficStat *TrafficStat, err error) {
196 | lock.Lock()
197 | defer lock.Unlock()
198 |
199 | // 锁定云端,防止其他设备并发上传数据
200 | err = repo.tryLockCloud(repo.DeviceID, context)
201 | if nil != err {
202 | return
203 | }
204 | defer repo.unlockCloud(context)
205 |
206 | trafficStat = &TrafficStat{m: &sync.Mutex{}}
207 |
208 | latest, err := repo.Latest()
209 | if nil != err {
210 | logging.LogErrorf("get latest failed: %s", err)
211 | return
212 | }
213 |
214 | // 从云端获取最新索引
215 | length, cloudLatest, err := repo.downloadCloudLatest(context)
216 | if nil != err {
217 | if !errors.Is(err, cloud.ErrCloudObjectNotFound) {
218 | logging.LogErrorf("download cloud latest failed: %s", err)
219 | return
220 | }
221 | }
222 | trafficStat.DownloadFileCount++
223 | trafficStat.DownloadBytes += length
224 | trafficStat.APIPut++
225 |
226 | if cloudLatest.ID == latest.ID {
227 | // 数据一致,直接返回
228 | return
229 | }
230 |
231 | availableSize := repo.cloud.GetAvailableSize()
232 | if availableSize <= cloudLatest.Size || availableSize <= latest.Size {
233 | err = ErrCloudStorageSizeExceeded
234 | return
235 | }
236 |
237 | // 计算云端缺失的文件
238 | var uploadFiles []*entity.File
239 | for _, localFileID := range latest.Files {
240 | if !gulu.Str.Contains(localFileID, cloudLatest.Files) {
241 | var uploadFile *entity.File
242 | uploadFile, err = repo.store.GetFile(localFileID)
243 | if nil != err {
244 | logging.LogErrorf("get file failed: %s", err)
245 | return
246 | }
247 | uploadFiles = append(uploadFiles, uploadFile)
248 | }
249 | }
250 |
251 | // 从文件列表中得到去重后的分块列表
252 | uploadChunkIDs := repo.getChunks(uploadFiles)
253 |
254 | // 这里暂时不计算云端缺失的分块了,因为目前计数云端缺失分块的代价太大
255 | //uploadChunkIDs, err = repo.cloud.GetChunks(uploadChunkIDs)
256 | //if nil != err {
257 | // logging.LogErrorf("get cloud repo upload chunks failed: %s", err)
258 | // return
259 | //}
260 |
261 | // 上传分块
262 | length, err = repo.uploadChunks(uploadChunkIDs, context)
263 | if nil != err {
264 | logging.LogErrorf("upload chunks failed: %s", err)
265 | return
266 | }
267 | trafficStat.UploadChunkCount += len(uploadChunkIDs)
268 | trafficStat.UploadBytes += length
269 | trafficStat.APIPut += trafficStat.UploadChunkCount
270 |
271 | // 上传文件
272 | length, err = repo.uploadFiles(uploadFiles, context)
273 | if nil != err {
274 | logging.LogErrorf("upload files failed: %s", err)
275 | return
276 | }
277 | trafficStat.UploadChunkCount += len(uploadFiles)
278 | trafficStat.UploadBytes += length
279 | trafficStat.APIPut += trafficStat.UploadChunkCount
280 |
281 | // 更新云端索引信息
282 | err = repo.updateCloudIndexes(latest, trafficStat, context)
283 | if nil != err {
284 | logging.LogErrorf("update cloud indexes failed: %s", err)
285 | return
286 | }
287 |
288 | // 更新本地同步点
289 | err = repo.UpdateLatestSync(latest)
290 | if nil != err {
291 | logging.LogErrorf("update latest sync failed: %s", err)
292 | return
293 | }
294 |
295 | // 统计流量
296 | go repo.cloud.AddTraffic(&cloud.Traffic{
297 | UploadBytes: trafficStat.UploadBytes,
298 | APIPut: trafficStat.APIPut,
299 | })
300 | return
301 | }
302 |
--------------------------------------------------------------------------------
/cloud/cloud.go:
--------------------------------------------------------------------------------
1 | // DejaVu - Data snapshot and sync.
2 | // Copyright (c) 2022-present, b3log.org
3 | //
4 | // This program is free software: you can redistribute it and/or modify
5 | // it under the terms of the GNU Affero General Public License as published by
6 | // the Free Software Foundation, either version 3 of the License, or
7 | // (at your option) any later version.
8 | //
9 | // This program is distributed in the hope that it will be useful,
10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | // GNU Affero General Public License for more details.
13 | //
14 | // You should have received a copy of the GNU Affero General Public License
15 | // along with this program. If not, see .
16 |
17 | package cloud
18 |
19 | import (
20 | "errors"
21 | "strings"
22 |
23 | "github.com/dgraph-io/ristretto"
24 | "github.com/klauspost/compress/zstd"
25 | "github.com/siyuan-note/dejavu/entity"
26 | )
27 |
28 | // Conf 用于描述云端存储服务配置信息。
29 | type Conf struct {
30 | Dir string // 存储目录,第三方存储不使用 Dir 区别多租户
31 | UserID string // 用户 ID,没有的话请传入一个定值比如 "0"
32 | RepoPath string // 本地仓库的绝对路径,如:F:\\SiYuan\\repo\\
33 | Endpoint string // 服务端点
34 | Extras map[string]interface{} // 一些可能需要的附加信息
35 |
36 | // S3 对象存储协议所需配置
37 | S3 *ConfS3
38 |
39 | // WebDAV 协议所需配置
40 | WebDAV *ConfWebDAV
41 |
42 | // 本地存储服务配置
43 | Local *ConfLocal
44 |
45 | // 以下值非官方存储服务不必传入
46 | Token string // 云端接口鉴权令牌
47 | AvailableSize int64 // 云端存储可用空间字节数
48 | Server string // 云端接口端点
49 | }
50 |
51 | // ConfS3 用于描述 S3 对象存储协议所需配置。
52 | type ConfS3 struct {
53 | Endpoint string // 服务端点
54 | AccessKey string // Access Key
55 | SecretKey string // Secret Key
56 | Region string // 存储区域
57 | Bucket string // 存储空间
58 | PathStyle bool // 是否使用路径风格寻址
59 | SkipTlsVerify bool // 是否跳过 TLS 验证
60 | Timeout int // 超时时间,单位:秒
61 | ConcurrentReqs int // 并发请求数
62 | }
63 |
64 | // ConfWebDAV 用于描述 WebDAV 协议所需配置。
65 | type ConfWebDAV struct {
66 | Endpoint string // 服务端点
67 | Username string // 用户名
68 | Password string // 密码
69 | SkipTlsVerify bool // 是否跳过 TLS 验证
70 | Timeout int // 超时时间,单位:秒
71 | ConcurrentReqs int // 并发请求数
72 | }
73 |
74 | // ConfLocal 用于描述本地存储服务配置信息。
75 | type ConfLocal struct {
76 | // 服务端点 (本地文件系统目录)
77 | //
78 | // "D:/path/to/repos/directory" // Windows
79 | // "/path/to/repos/directory" // Unix
80 | Endpoint string
81 | Timeout int // 超时时间,单位:秒
82 | ConcurrentReqs int // 并发请求数
83 | }
84 |
85 | // Cloud 描述了云端存储服务,接入云端存储服务时需要实现该接口。
86 | type Cloud interface {
87 |
88 | // CreateRepo 用于创建名称为 name 的云端仓库。
89 | CreateRepo(name string) (err error)
90 |
91 | // RemoveRepo 用于删除云端仓库。
92 | RemoveRepo(name string) (err error)
93 |
94 | // GetRepos 用于获取云端仓库列表 repos,size 为仓库总大小字节数。
95 | GetRepos() (repos []*Repo, size int64, err error)
96 |
97 | // UploadObject 用于上传对象,overwrite 参数用于指示是否覆盖已有对象。
98 | UploadObject(filePath string, overwrite bool) (length int64, err error)
99 |
100 | // UploadBytes 用于上传对象数据 data,overwrite 参数用于指示是否覆盖已有对象。
101 | UploadBytes(filePath string, data []byte, overwrite bool) (length int64, err error)
102 |
103 | // DownloadObject 用于下载对象数据 data。
104 | DownloadObject(filePath string) (data []byte, err error)
105 |
106 | // RemoveObject 用于删除对象。
107 | RemoveObject(filePath string) (err error)
108 |
109 | // GetTags 用于获取快照标记列表。
110 | GetTags() (tags []*Ref, err error)
111 |
112 | // GetIndexes 用于获取索引列表。
113 | GetIndexes(page int) (indexes []*entity.Index, pageCount, totalCount int, err error)
114 |
115 | // GetRefsFiles 用于获取所有引用索引中的文件 ID 列表 fileIDs。
116 | GetRefsFiles() (fileIDs []string, refs []*Ref, err error)
117 |
118 | // GetChunks 用于获取 checkChunkIDs 中不存在的分块 ID 列表 chunkIDs。
119 | GetChunks(checkChunkIDs []string) (chunkIDs []string, err error)
120 |
121 | // GetStat 用于获取统计信息 stat。
122 | GetStat() (stat *Stat, err error)
123 |
124 | // GetConf 用于获取配置信息。
125 | GetConf() *Conf
126 |
127 | // GetAvailableSize 用于获取云端存储可用空间字节数。
128 | GetAvailableSize() (size int64)
129 |
130 | // AddTraffic 用于统计流量。
131 | AddTraffic(traffic *Traffic)
132 |
133 | // ListObjects 用于列出指定前缀的对象。
134 | ListObjects(pathPrefix string) (objInfos map[string]*entity.ObjectInfo, err error)
135 |
136 | // GetIndex 用于获取索引。
137 | GetIndex(id string) (index *entity.Index, err error)
138 |
139 | // GetConcurrentReqs 用于获取配置的并发请求数。
140 | GetConcurrentReqs() int
141 | }
142 |
143 | // Traffic 描述了流量信息。
144 | type Traffic struct {
145 | UploadBytes int64 // 上传字节数
146 | DownloadBytes int64 // 下载字节数
147 | APIGet int // API GET 请求次数
148 | APIPut int // API PUT 请求次数
149 | }
150 |
151 | // Stat 描述了统计信息。
152 | type Stat struct {
153 | Sync *StatSync `json:"sync"` // 同步统计
154 | Backup *StatBackup `json:"backup"` // 备份统计
155 | AssetSize int64 `json:"assetSize"` // 资源文件大小字节数
156 | RepoCount int `json:"repoCount"` // 仓库数量
157 | }
158 |
159 | // Repo 描述了云端仓库。
160 | type Repo struct {
161 | Name string `json:"name"`
162 | Size int64 `json:"size"`
163 | Updated string `json:"updated"`
164 | }
165 |
166 | // Ref 描述了快照引用。
167 | type Ref struct {
168 | Name string `json:"name"` // 引用文件名称,比如 latest、tag1
169 | ID string `json:"id"` // 引用 ID
170 | Updated string `json:"updated"` // 最近更新时间
171 | }
172 |
173 | // StatSync 描述了同步统计信息。
174 | type StatSync struct {
175 | Size int64 `json:"size"` // 总大小字节数
176 | FileCount int `json:"fileCount"` // 总文件数
177 | Updated string `json:"updated"` // 最近更新时间
178 | }
179 |
180 | // StatBackup 描述了备份统计信息。
181 | type StatBackup struct {
182 | Count int `json:"count"` // 已标记的快照数量
183 | Size int64 `json:"size"` // 总大小字节数
184 | FileCount int `json:"fileCount"` // 总文件数
185 | Updated string `json:"updated"` // 最近更新时间
186 | }
187 |
188 | // Indexes 描述了云端索引列表。
189 | type Indexes struct {
190 | Indexes []*Index `json:"indexes"`
191 | }
192 |
193 | // Index 描述了云端索引。
194 | type Index struct {
195 | ID string `json:"id"`
196 | SystemID string `json:"systemID"`
197 | SystemName string `json:"systemName"`
198 | SystemOS string `json:"systemOS"`
199 | }
200 |
201 | // BaseCloud 描述了云端存储服务的基础实现。
202 | type BaseCloud struct {
203 | *Conf
204 | Cloud
205 | }
206 |
207 | func (baseCloud *BaseCloud) CreateRepo(name string) (err error) {
208 | err = ErrUnsupported
209 | return
210 | }
211 |
212 | func (baseCloud *BaseCloud) RemoveRepo(name string) (err error) {
213 | err = ErrUnsupported
214 | return
215 | }
216 |
217 | func (baseCloud *BaseCloud) GetRepos() (repos []*Repo, size int64, err error) {
218 | err = ErrUnsupported
219 | return
220 | }
221 |
222 | func (baseCloud *BaseCloud) UploadObject(filePath string, overwrite bool) (length int64, err error) {
223 | err = ErrUnsupported
224 | return
225 | }
226 |
227 | func (baseCloud *BaseCloud) UploadBytes(filePath string, data []byte, overwrite bool) (length int64, err error) {
228 | err = ErrUnsupported
229 | return
230 | }
231 |
232 | func (baseCloud *BaseCloud) DownloadObject(filePath string) (data []byte, err error) {
233 | err = ErrUnsupported
234 | return
235 | }
236 |
237 | func (baseCloud *BaseCloud) RemoveObject(filePath string) (err error) {
238 | err = ErrUnsupported
239 | return
240 | }
241 |
242 | func (baseCloud *BaseCloud) GetTags() (tags []*Ref, err error) {
243 | err = ErrUnsupported
244 | return
245 | }
246 |
247 | func (baseCloud *BaseCloud) GetIndexes(page int) (indexes []*entity.Index, pageCount, totalCount int, err error) {
248 | err = ErrUnsupported
249 | return
250 | }
251 |
252 | func (baseCloud *BaseCloud) GetRefsFiles() (fileIDs []string, refs []*Ref, err error) {
253 | err = ErrUnsupported
254 | return
255 | }
256 |
257 | func (baseCloud *BaseCloud) GetChunks(checkChunkIDs []string) (chunkIDs []string, err error) {
258 | err = ErrUnsupported
259 | return
260 | }
261 |
262 | func (baseCloud *BaseCloud) GetStat() (stat *Stat, err error) {
263 | stat = &Stat{
264 | Sync: &StatSync{},
265 | Backup: &StatBackup{},
266 | }
267 | return
268 | }
269 |
270 | func (baseCloud *BaseCloud) ListObjects(pathPrefix string) (objInfos map[string]*entity.ObjectInfo, err error) {
271 | err = ErrUnsupported
272 | return
273 | }
274 |
275 | func (baseCloud *BaseCloud) GetIndex(id string) (index *entity.Index, err error) {
276 | err = ErrUnsupported
277 | return
278 | }
279 |
280 | func (baseCloud *BaseCloud) GetConcurrentReqs() int {
281 | return 8
282 | }
283 |
284 | func (baseCloud *BaseCloud) GetConf() *Conf {
285 | return baseCloud.Conf
286 | }
287 |
288 | func (baseCloud *BaseCloud) GetAvailableSize() int64 {
289 | return baseCloud.Conf.AvailableSize
290 | }
291 |
292 | func (baseCloud *BaseCloud) AddTraffic(*Traffic) {
293 | return
294 | }
295 |
296 | var (
297 | ErrUnsupported = errors.New("not supported yet") // ErrUnsupported 描述了尚未支持的操作
298 | ErrCloudObjectNotFound = errors.New("cloud object not found") // ErrCloudObjectNotFound 描述了云端存储服务中的对象不存在的错误
299 | ErrCloudAuthFailed = errors.New("cloud account auth failed") // ErrCloudAuthFailed 描述了云端存储服务鉴权失败的错误
300 | ErrCloudServiceUnavailable = errors.New("cloud service unavailable") // ErrCloudServiceUnavailable 描述了云端存储服务不可用的错误
301 | ErrSystemTimeIncorrect = errors.New("system time incorrect") // ErrSystemTimeIncorrect 描述了系统时间不正确的错误
302 | ErrDeprecatedVersion = errors.New("deprecated version") // ErrDeprecatedVersion 描述了版本过低的错误
303 | ErrCloudCheckFailed = errors.New("cloud check failed") // ErrCloudCheckFailed 描述了云端存储服务检查失败的错误
304 | ErrCloudForbidden = errors.New("cloud forbidden") // ErrCloudForbidden 描述了云端存储服务禁止访问的错误
305 | ErrCloudTooManyRequests = errors.New("cloud too many requests") // ErrCloudTooManyRequests 描述了云端存储服务请求过多的错误
306 | )
307 |
308 | func IsValidCloudDirName(cloudDirName string) bool {
309 | if 63 < len(cloudDirName) || 1 > len(cloudDirName) {
310 | return false
311 | }
312 |
313 | chars := []byte{'~', '`', '!', '@', '#', '$', '%', '^', '&', '*', '(', ')', '+', '=',
314 | '[', ']', '{', '}', '\\', '|', ';', ':', '\'', '"', '<', ',', '>', '.', '?', '/', ' '}
315 | var charsStr string
316 | for _, char := range chars {
317 | charsStr += string(char)
318 | }
319 |
320 | if strings.ContainsAny(cloudDirName, charsStr) {
321 | return false
322 | }
323 |
324 | tmp := stripCtlFromUTF8(cloudDirName)
325 | return tmp == cloudDirName
326 | }
327 |
328 | func stripCtlFromUTF8(str string) string {
329 | return strings.Map(func(r rune) rune {
330 | if r >= 32 && r != 127 {
331 | return r
332 | }
333 | return -1
334 | }, str)
335 | }
336 |
337 | var (
338 | compressDecoder *zstd.Decoder
339 | cache *ristretto.Cache
340 | )
341 |
342 | func init() {
343 | var err error
344 | compressDecoder, err = zstd.NewReader(nil, zstd.WithDecoderMaxMemory(16*1024*1024*1024))
345 | if nil != err {
346 | panic(err)
347 | }
348 |
349 | cache, err = ristretto.NewCache(&ristretto.Config{
350 | NumCounters: 200000,
351 | MaxCost: 1000 * 1000 * 32,
352 | BufferItems: 64,
353 | })
354 | if nil != err {
355 | panic(err)
356 | }
357 | }
358 |
359 | // objectInfo 描述了对象信息,用于内部处理。
360 | type objectInfo struct {
361 | Key string `json:"key"`
362 | Size int64 `json:"size"`
363 | Updated string `json:"updated"`
364 | }
365 |
--------------------------------------------------------------------------------
/cloud/local.go:
--------------------------------------------------------------------------------
1 | // DejaVu - Data snapshot and sync.
2 | // Copyright (c) 2022-present, b3log.org
3 | //
4 | // This program is free software: you can redistribute it and/or modify
5 | // it under the terms of the GNU Affero General Public License as published by
6 | // the Free Software Foundation, either version 3 of the License, or
7 | // (at your option) any later version.
8 | //
9 | // This program is distributed in the hope that it will be useful,
10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | // GNU Affero General Public License for more details.
13 | //
14 | // You should have received a copy of the GNU Affero General Public License
15 | // along with this program. If not, see .
16 |
17 | package cloud
18 |
19 | import (
20 | "math"
21 | "os"
22 | "path"
23 | "path/filepath"
24 | "sort"
25 | "strings"
26 |
27 | "github.com/88250/gulu"
28 | "github.com/siyuan-note/dejavu/entity"
29 | "github.com/siyuan-note/dejavu/util"
30 | "github.com/siyuan-note/logging"
31 | )
32 |
33 | // Local 描述了本地文件系统服务实现。
34 | type Local struct {
35 | *BaseCloud
36 | }
37 |
38 | func NewLocal(baseCloud *BaseCloud) (local *Local) {
39 | local = &Local{
40 | BaseCloud: baseCloud,
41 | }
42 | return
43 | }
44 |
45 | func (local *Local) CreateRepo(name string) (err error) {
46 | repoPath := path.Join(local.Local.Endpoint, name)
47 | err = os.MkdirAll(repoPath, 0755)
48 | return
49 | }
50 |
51 | func (local *Local) RemoveRepo(name string) (err error) {
52 | repoPath := path.Join(local.Local.Endpoint, name)
53 | err = os.RemoveAll(repoPath)
54 | return
55 | }
56 |
57 | func (local *Local) GetRepos() (repos []*Repo, size int64, err error) {
58 | repos, err = local.listRepos()
59 | if err != nil {
60 | return
61 | }
62 |
63 | for _, repo := range repos {
64 | size += repo.Size
65 | }
66 | return
67 | }
68 |
69 | func (local *Local) UploadObject(filePath string, overwrite bool) (length int64, err error) {
70 | absFilePath := filepath.Join(local.Conf.RepoPath, filePath)
71 | data, err := os.ReadFile(absFilePath)
72 | if err != nil {
73 | return
74 | }
75 |
76 | length, err = local.UploadBytes(filePath, data, overwrite)
77 | return
78 | }
79 |
80 | func (local *Local) UploadBytes(filePath string, data []byte, overwrite bool) (length int64, err error) {
81 | key := path.Join(local.getCurrentRepoDirPath(), filePath)
82 | folder := path.Dir(key)
83 | err = os.MkdirAll(folder, 0755)
84 | if err != nil {
85 | return
86 | }
87 |
88 | if !overwrite { // not overwrite the file
89 | _, err = os.Stat(key)
90 | if err != nil {
91 | if os.IsNotExist(err) { // file not exist
92 | // do nothing and continue
93 | err = nil
94 | } else { // other error
95 | logging.LogErrorf("upload object [%s] failed: %s", key, err)
96 | return
97 | }
98 | }
99 | }
100 |
101 | err = os.WriteFile(key, data, 0644)
102 | if err != nil {
103 | logging.LogErrorf("upload object [%s] failed: %s", key, err)
104 | return
105 | }
106 |
107 | length = int64(len(data))
108 |
109 | //logging.LogInfof("uploaded object [%s]", key)
110 | return
111 | }
112 |
113 | func (local *Local) DownloadObject(filePath string) (data []byte, err error) {
114 | key := path.Join(local.getCurrentRepoDirPath(), filePath)
115 | data, err = os.ReadFile(key)
116 | if err != nil {
117 | if os.IsNotExist(err) {
118 | err = ErrCloudObjectNotFound
119 | }
120 | return
121 | }
122 |
123 | //logging.LogInfof("downloaded object [%s]", key)
124 | return
125 | }
126 |
127 | func (local *Local) RemoveObject(filePath string) (err error) {
128 | key := path.Join(local.getCurrentRepoDirPath(), filePath)
129 | err = os.Remove(key)
130 | if err != nil {
131 | if os.IsNotExist(err) {
132 | err = nil
133 | } else {
134 | logging.LogErrorf("remove object [%s] failed: %s", key, err)
135 | }
136 | return
137 | }
138 |
139 | //logging.LogInfof("removed object [%s]", key)
140 | return
141 | }
142 |
143 | func (local *Local) ListObjects(pathPrefix string) (objects map[string]*entity.ObjectInfo, err error) {
144 | objects = map[string]*entity.ObjectInfo{}
145 | pathPrefix = path.Join(local.getCurrentRepoDirPath(), pathPrefix)
146 | entries, err := os.ReadDir(pathPrefix)
147 | if err != nil {
148 | logging.LogErrorf("list objects [%s] failed: %s", pathPrefix, err)
149 | return
150 | }
151 |
152 | for _, entry := range entries {
153 | entryInfo, infoErr := entry.Info()
154 | if infoErr != nil {
155 | err = infoErr
156 | logging.LogErrorf("get object [%s] info failed: %s", path.Join(pathPrefix, entry.Name()), err)
157 | return
158 | }
159 |
160 | filePath := entry.Name()
161 | objects[filePath] = &entity.ObjectInfo{
162 | Path: filePath,
163 | Size: entryInfo.Size(),
164 | }
165 | }
166 |
167 | //logging.LogInfof("list objects [%s]", pathPrefix)
168 | return
169 | }
170 |
171 | func (local *Local) GetTags() (tags []*Ref, err error) {
172 | tags, err = local.listRepoRefs("tags")
173 | if err != nil {
174 | return
175 | }
176 | if 1 > len(tags) {
177 | tags = []*Ref{}
178 | }
179 | return
180 | }
181 |
182 | func (local *Local) GetIndexes(page int) (indexes []*entity.Index, pageCount, totalCount int, err error) {
183 | data, err := local.DownloadObject("indexes-v2.json")
184 | if err != nil {
185 | if os.IsNotExist(err) {
186 | err = nil
187 | }
188 | return
189 | }
190 |
191 | data, err = compressDecoder.DecodeAll(data, nil)
192 | if err != nil {
193 | return
194 | }
195 |
196 | indexesJSON := &Indexes{}
197 | if err = gulu.JSON.UnmarshalJSON(data, indexesJSON); err != nil {
198 | return
199 | }
200 |
201 | totalCount = len(indexesJSON.Indexes)
202 | pageCount = int(math.Ceil(float64(totalCount) / float64(pageSize)))
203 | start := (page - 1) * pageSize
204 | end := page * pageSize
205 | if end > totalCount {
206 | end = totalCount
207 | }
208 |
209 | for i := start; i < end; i++ {
210 | index, getErr := local.repoIndex(indexesJSON.Indexes[i].ID)
211 | if getErr != nil {
212 | logging.LogWarnf("get repo index [%s] failed: %s", indexesJSON.Indexes[i], getErr)
213 | continue
214 | }
215 |
216 | index.Files = nil // Optimize the performance of obtaining cloud snapshots https://github.com/siyuan-note/siyuan/issues/8387
217 | indexes = append(indexes, index)
218 | }
219 | return
220 | }
221 |
222 | func (local *Local) GetRefsFiles() (fileIDs []string, refs []*Ref, err error) {
223 | refs, err = local.listRepoRefs("")
224 | var files []string
225 | for _, ref := range refs {
226 | index, getErr := local.repoIndex(ref.ID)
227 | if getErr != nil {
228 | return
229 | }
230 | if index == nil {
231 | continue
232 | }
233 |
234 | files = append(files, index.Files...)
235 | }
236 |
237 | fileIDs = gulu.Str.RemoveDuplicatedElem(files)
238 | if 1 > len(fileIDs) {
239 | fileIDs = []string{}
240 | }
241 | return
242 | }
243 |
244 | func (local *Local) GetChunks(checkChunkIDs []string) (chunkIDs []string, err error) {
245 | repoObjectsPath := path.Join(local.getCurrentRepoDirPath(), "objects")
246 | var keys []string
247 | for _, chunkID := range checkChunkIDs {
248 | key := path.Join(repoObjectsPath, chunkID[:2], chunkID[2:])
249 | keys = append(keys, key)
250 | }
251 |
252 | notFound, err := local.getNotFound(keys)
253 | if err != nil {
254 | return
255 | }
256 |
257 | var notFoundChunkIDs []string
258 | for _, key := range notFound {
259 | chunkID := strings.TrimPrefix(key, repoObjectsPath)
260 | chunkID = strings.ReplaceAll(chunkID, "/", "")
261 | notFoundChunkIDs = append(notFoundChunkIDs, chunkID)
262 | }
263 |
264 | chunkIDs = append(chunkIDs, notFoundChunkIDs...)
265 | chunkIDs = gulu.Str.RemoveDuplicatedElem(chunkIDs)
266 | if 1 > len(chunkIDs) {
267 | chunkIDs = []string{}
268 | }
269 | return
270 | }
271 |
272 | // func (local *Local) GetStat() (stat *Stat, err error)
273 |
274 | func (local *Local) GetIndex(id string) (index *entity.Index, err error) {
275 | index, err = local.repoIndex(id)
276 | if err != nil {
277 | logging.LogErrorf("get repo index [%s] failed: %s", id, err)
278 | return
279 | }
280 | if index == nil {
281 | err = ErrCloudObjectNotFound
282 | return
283 | }
284 | return
285 | }
286 |
287 | func (local *Local) GetConcurrentReqs() (ret int) {
288 | ret = local.Local.ConcurrentReqs
289 | if ret < 1 {
290 | ret = 16
291 | }
292 | if ret > 1024 {
293 | ret = 1024
294 | }
295 | return
296 | }
297 |
298 | func (local *Local) GetConf() *Conf {
299 | return local.Conf
300 | }
301 |
302 | func (local *Local) GetAvailableSize() int64 {
303 | return util.GetFreeDiskSpace(local.Local.Endpoint)
304 | }
305 |
306 | func (local *Local) AddTraffic(*Traffic) {
307 | return
308 | }
309 |
310 | func (local *Local) listRepos() (repos []*Repo, err error) {
311 | entries, err := os.ReadDir(local.Local.Endpoint)
312 | if err != nil {
313 | logging.LogErrorf("list repos [%s] failed: %s", local.Local.Endpoint, err)
314 | return
315 | }
316 |
317 | for _, entry := range entries {
318 | if !entry.IsDir() {
319 | continue
320 | }
321 |
322 | entryInfo, infoErr := entry.Info()
323 | if infoErr != nil {
324 | err = infoErr
325 | logging.LogErrorf("get repo [%s] info failed: %s", path.Join(local.Local.Endpoint, entry.Name()), err)
326 | return
327 | }
328 | repos = append(repos, &Repo{
329 | Name: entry.Name(),
330 | Size: entryInfo.Size(),
331 | Updated: entryInfo.ModTime().Local().Format("2006-01-02 15:04:05"),
332 | })
333 | }
334 | sort.Slice(repos, func(i, j int) bool { return repos[i].Name < repos[j].Name })
335 | return
336 | }
337 |
338 | func (local *Local) listRepoRefs(refPrefix string) (refs []*Ref, err error) {
339 | keyPath := path.Join(local.getCurrentRepoDirPath(), "refs", refPrefix)
340 | entries, err := os.ReadDir(keyPath)
341 | if err != nil {
342 | logging.LogErrorf("list repo refs [%s] failed: %s", keyPath, err)
343 | return
344 | }
345 |
346 | for _, entry := range entries {
347 | if entry.IsDir() {
348 | continue
349 | }
350 |
351 | entryInfo, infoErr := entry.Info()
352 | if infoErr != nil {
353 | err = infoErr
354 | logging.LogErrorf("get repo ref [%s] info failed: %s", path.Join(local.Local.Endpoint, entry.Name()), err)
355 | return
356 | }
357 |
358 | data, readErr := os.ReadFile(path.Join(keyPath, entry.Name()))
359 | if readErr != nil {
360 | err = readErr
361 | logging.LogErrorf("get repo ref [%s] ID failed: %s", path.Join(local.Local.Endpoint, entry.Name()), err)
362 | return
363 | }
364 |
365 | id := string(data)
366 | ref := &Ref{
367 | Name: entry.Name(),
368 | ID: id,
369 | Updated: entryInfo.ModTime().Local().Format("2006-01-02 15:04:05"),
370 | }
371 | refs = append(refs, ref)
372 | }
373 | return
374 | }
375 |
376 | func (local *Local) repoIndex(id string) (index *entity.Index, err error) {
377 | indexFilePath := path.Join(local.getCurrentRepoDirPath(), "indexes", id)
378 | indexFileInfo, err := os.Stat(indexFilePath)
379 | if err != nil {
380 | return
381 | }
382 | if 1 > indexFileInfo.Size() {
383 | return
384 | }
385 |
386 | data, err := os.ReadFile(indexFilePath)
387 | if err != nil {
388 | return
389 | }
390 |
391 | data, err = compressDecoder.DecodeAll(data, nil)
392 | if err != nil {
393 | return
394 | }
395 |
396 | index = &entity.Index{}
397 | err = gulu.JSON.UnmarshalJSON(data, index)
398 | if err != nil {
399 | return
400 | }
401 | return
402 | }
403 |
404 | func (local *Local) getNotFound(keys []string) (ret []string, err error) {
405 | if 1 > len(keys) {
406 | return
407 | }
408 | for _, key := range keys {
409 | _, statErr := os.Stat(key)
410 | if os.IsNotExist(statErr) {
411 | ret = append(ret, key)
412 | }
413 | }
414 | return
415 | }
416 |
417 | func (local *Local) getCurrentRepoDirPath() string {
418 | return path.Join(local.Local.Endpoint, local.Dir)
419 | }
420 |
--------------------------------------------------------------------------------
/cloud/webdav.go:
--------------------------------------------------------------------------------
1 | // DejaVu - Data snapshot and sync.
2 | // Copyright (c) 2022-present, b3log.org
3 | //
4 | // This program is free software: you can redistribute it and/or modify
5 | // it under the terms of the GNU Affero General Public License as published by
6 | // the Free Software Foundation, either version 3 of the License, or
7 | // (at your option) any later version.
8 | //
9 | // This program is distributed in the hope that it will be useful,
10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | // GNU Affero General Public License for more details.
13 | //
14 | // You should have received a copy of the GNU Affero General Public License
15 | // along with this program. If not, see .
16 |
17 | package cloud
18 |
19 | import (
20 | "errors"
21 | "io/fs"
22 | "math"
23 | "os"
24 | "path"
25 | "path/filepath"
26 | "sort"
27 | "strings"
28 | "sync"
29 |
30 | "github.com/88250/gulu"
31 | "github.com/siyuan-note/dejavu/entity"
32 | "github.com/siyuan-note/logging"
33 | "github.com/studio-b12/gowebdav"
34 | )
35 |
36 | // WebDAV 描述了 WebDAV 云端存储服务实现。
37 | type WebDAV struct {
38 | *BaseCloud
39 | Client *gowebdav.Client
40 |
41 | lock sync.Mutex
42 | }
43 |
44 | func NewWebDAV(baseCloud *BaseCloud, client *gowebdav.Client) (ret *WebDAV) {
45 | ret = &WebDAV{
46 | BaseCloud: baseCloud,
47 | Client: client,
48 | lock: sync.Mutex{},
49 | }
50 | return
51 | }
52 |
53 | func (webdav *WebDAV) GetRepos() (repos []*Repo, size int64, err error) {
54 | repos, err = webdav.listRepos()
55 | if nil != err {
56 | return
57 | }
58 |
59 | for _, repo := range repos {
60 | size += repo.Size
61 | }
62 | return
63 | }
64 |
65 | func (webdav *WebDAV) UploadObject(filePath string, overwrite bool) (length int64, err error) {
66 | absFilePath := filepath.Join(webdav.Conf.RepoPath, filePath)
67 | data, err := os.ReadFile(absFilePath)
68 | if nil != err {
69 | return
70 | }
71 |
72 | length, err = webdav.UploadBytes(filePath, data, overwrite)
73 | return
74 | }
75 |
76 | func (webdav *WebDAV) UploadBytes(filePath string, data []byte, overwrite bool) (length int64, err error) {
77 | length = int64(len(data))
78 | key := path.Join(webdav.Dir, "siyuan", "repo", filePath)
79 | folder := path.Dir(key)
80 | err = webdav.mkdirAll(folder)
81 | if nil != err {
82 | return
83 | }
84 |
85 | err = webdav.Client.Write(key, data, 0644)
86 | err = webdav.parseErr(err)
87 | if nil != err {
88 | logging.LogErrorf("upload object [%s] failed: %s", key, err)
89 | return
90 | }
91 | //logging.LogInfof("uploaded object [%s]", key)
92 | return
93 | }
94 |
95 | func (webdav *WebDAV) DownloadObject(filePath string) (data []byte, err error) {
96 | key := path.Join(webdav.Dir, "siyuan", "repo", filePath)
97 | data, err = webdav.Client.Read(key)
98 | err = webdav.parseErr(err)
99 | if nil != err {
100 | return
101 | }
102 |
103 | //logging.LogInfof("downloaded object [%s]", key)
104 | return
105 | }
106 |
107 | func (webdav *WebDAV) RemoveObject(filePath string) (err error) {
108 | key := path.Join(webdav.Dir, "siyuan", "repo", filePath)
109 | err = webdav.Client.Remove(key)
110 | err = webdav.parseErr(err)
111 | if nil != err {
112 | return
113 | }
114 |
115 | //logging.LogInfof("removed object [%s]", key)
116 | return
117 | }
118 |
119 | func (webdav *WebDAV) GetTags() (tags []*Ref, err error) {
120 | tags, err = webdav.listRepoRefs("tags")
121 | if nil != err {
122 | err = webdav.parseErr(err)
123 | if errors.Is(err, ErrCloudObjectNotFound) { // https://ld246.com/article/1749182255326
124 | err = nil
125 | tags = []*Ref{}
126 | return
127 | }
128 | return
129 | }
130 | if 1 > len(tags) {
131 | tags = []*Ref{}
132 | }
133 | return
134 | }
135 |
136 | func (webdav *WebDAV) GetIndexes(page int) (ret []*entity.Index, pageCount, totalCount int, err error) {
137 | ret = []*entity.Index{}
138 | data, err := webdav.DownloadObject("indexes-v2.json")
139 | if nil != err {
140 | err = webdav.parseErr(err)
141 | if ErrCloudObjectNotFound == err {
142 | err = nil
143 | }
144 | return
145 | }
146 |
147 | data, err = compressDecoder.DecodeAll(data, nil)
148 | if nil != err {
149 | return
150 | }
151 |
152 | indexesJSON := &Indexes{}
153 | if err = gulu.JSON.UnmarshalJSON(data, indexesJSON); nil != err {
154 | return
155 | }
156 |
157 | totalCount = len(indexesJSON.Indexes)
158 | pageCount = int(math.Ceil(float64(totalCount) / float64(pageSize)))
159 |
160 | start := (page - 1) * pageSize
161 | end := page * pageSize
162 | if end > totalCount {
163 | end = totalCount
164 | }
165 |
166 | repoKey := path.Join(webdav.Dir, "siyuan", "repo")
167 | for i := start; i < end; i++ {
168 | index, getErr := webdav.repoIndex(repoKey, indexesJSON.Indexes[i].ID)
169 | if nil != getErr {
170 | logging.LogWarnf("get index [%s] failed: %s", indexesJSON.Indexes[i], getErr)
171 | continue
172 | }
173 |
174 | index.Files = nil // Optimize the performance of obtaining cloud snapshots https://github.com/siyuan-note/siyuan/issues/8387
175 | ret = append(ret, index)
176 | }
177 | return
178 | }
179 |
180 | func (webdav *WebDAV) GetRefsFiles() (fileIDs []string, refs []*Ref, err error) {
181 | refs, err = webdav.listRepoRefs("")
182 | repoKey := path.Join(webdav.Dir, "siyuan", "repo")
183 | var files []string
184 | for _, ref := range refs {
185 | index, getErr := webdav.repoIndex(repoKey, ref.ID)
186 | if nil != getErr {
187 | err = getErr
188 | return
189 | }
190 | if nil == index {
191 | continue
192 | }
193 |
194 | files = append(files, index.Files...)
195 | }
196 | fileIDs = gulu.Str.RemoveDuplicatedElem(files)
197 | if 1 > len(fileIDs) {
198 | fileIDs = []string{}
199 | }
200 | return
201 | }
202 |
203 | func (webdav *WebDAV) GetChunks(checkChunkIDs []string) (chunkIDs []string, err error) {
204 | repoObjects := path.Join(webdav.Dir, "siyuan", "repo", "objects")
205 | var keys []string
206 | for _, chunk := range checkChunkIDs {
207 | key := path.Join(repoObjects, chunk[:2], chunk[2:])
208 | keys = append(keys, key)
209 | }
210 |
211 | notFound, err := webdav.getNotFound(keys)
212 | if nil != err {
213 | return
214 | }
215 |
216 | var notFoundChunkIDs []string
217 | for _, key := range notFound {
218 | chunkID := strings.TrimPrefix(key, repoObjects)
219 | chunkID = strings.ReplaceAll(chunkID, "/", "")
220 | notFoundChunkIDs = append(notFoundChunkIDs, chunkID)
221 | }
222 |
223 | chunkIDs = append(chunkIDs, notFoundChunkIDs...)
224 | chunkIDs = gulu.Str.RemoveDuplicatedElem(chunkIDs)
225 | if 1 > len(chunkIDs) {
226 | chunkIDs = []string{}
227 | }
228 | return
229 | }
230 |
231 | func (webdav *WebDAV) GetIndex(id string) (index *entity.Index, err error) {
232 | repoKey := path.Join(webdav.Dir, "siyuan", "repo")
233 | index, err = webdav.repoIndex(repoKey, id)
234 | if nil != err {
235 | logging.LogErrorf("get index [%s] failed: %s", id, err)
236 | return
237 | }
238 | if nil == index {
239 | err = ErrCloudObjectNotFound
240 | return
241 | }
242 | return
243 | }
244 |
245 | func (webdav *WebDAV) GetConcurrentReqs() (ret int) {
246 | ret = webdav.Conf.WebDAV.ConcurrentReqs
247 | if 1 > ret {
248 | ret = 1
249 | }
250 | if 16 < ret {
251 | ret = 16
252 | }
253 | return
254 | }
255 |
256 | func (webdav *WebDAV) ListObjects(pathPrefix string) (ret map[string]*entity.ObjectInfo, err error) {
257 | ret = map[string]*entity.ObjectInfo{}
258 |
259 | endWithSlash := strings.HasSuffix(pathPrefix, "/")
260 | pathPrefix = path.Join(webdav.Dir, "siyuan", "repo", pathPrefix)
261 | if endWithSlash {
262 | pathPrefix += "/"
263 | }
264 |
265 | infos, err := webdav.Client.ReadDir(pathPrefix)
266 | if nil != err {
267 | logging.LogErrorf("list objects [%s] failed: %s", pathPrefix, err)
268 | return
269 | }
270 |
271 | for _, entry := range infos {
272 | filePath := entry.Name()
273 | ret[filePath] = &entity.ObjectInfo{
274 | Path: filePath,
275 | Size: entry.Size(),
276 | }
277 | }
278 |
279 | if nil != err {
280 | logging.LogErrorf("list objects failed: %s", err)
281 | return
282 | }
283 | return
284 | }
285 |
286 | func (webdav *WebDAV) listRepoRefs(refPrefix string) (ret []*Ref, err error) {
287 | keyPath := path.Join(webdav.Dir, "siyuan", "repo", "refs", refPrefix)
288 | infos, err := webdav.Client.ReadDir(keyPath)
289 | if nil != err {
290 | err = webdav.parseErr(err)
291 | return
292 | }
293 |
294 | for _, info := range infos {
295 | if info.IsDir() {
296 | continue
297 | }
298 |
299 | data, ReadErr := webdav.Client.Read(path.Join(keyPath, info.Name()))
300 | if nil != ReadErr {
301 | err = webdav.parseErr(ReadErr)
302 | return
303 | }
304 | id := string(data)
305 | ref := &Ref{
306 | Name: info.Name(),
307 | ID: id,
308 | Updated: info.ModTime().Local().Format("2006-01-02 15:04:05"),
309 | }
310 | ret = append(ret, ref)
311 | }
312 | return
313 | }
314 |
315 | func (webdav *WebDAV) listRepos() (ret []*Repo, err error) {
316 | infos, err := webdav.Client.ReadDir("/")
317 | if nil != err {
318 | err = webdav.parseErr(err)
319 | if ErrCloudObjectNotFound == err {
320 | err = nil
321 | }
322 | return
323 | }
324 |
325 | for _, repoInfo := range infos {
326 | if !repoInfo.IsDir() {
327 | continue
328 | }
329 |
330 | ret = append(ret, &Repo{
331 | Name: repoInfo.Name(),
332 | Size: 0,
333 | Updated: repoInfo.ModTime().Local().Format("2006-01-02 15:04:05"),
334 | })
335 | }
336 | sort.Slice(ret, func(i, j int) bool { return ret[i].Name < ret[j].Name })
337 | return
338 | }
339 |
340 | func (webdav *WebDAV) repoLatest(repoDir string) (id string, err error) {
341 | latestPath := path.Join(repoDir, "refs", "latest")
342 | _, err = webdav.Client.Stat(latestPath)
343 | if nil != err {
344 | err = webdav.parseErr(err)
345 | return
346 | }
347 |
348 | data, err := webdav.Client.Read(latestPath)
349 | if nil != err {
350 | return
351 | }
352 | id = string(data)
353 | return
354 | }
355 |
356 | func (webdav *WebDAV) getNotFound(keys []string) (ret []string, err error) {
357 | if 1 > len(keys) {
358 | return
359 | }
360 | for _, key := range keys {
361 | _, statErr := webdav.Client.Stat(key)
362 | statErr = webdav.parseErr(statErr)
363 | if ErrCloudObjectNotFound == statErr {
364 | ret = append(ret, key)
365 | }
366 | }
367 | return
368 | }
369 |
370 | func (webdav *WebDAV) repoIndex(repoDir, id string) (ret *entity.Index, err error) {
371 | indexPath := path.Join(repoDir, "indexes", id)
372 | info, err := webdav.Client.Stat(indexPath)
373 | if nil != err {
374 | err = webdav.parseErr(err)
375 | return
376 | }
377 | if 1 > info.Size() {
378 | return
379 | }
380 |
381 | data, err := webdav.Client.Read(indexPath)
382 | if nil != err {
383 | return
384 | }
385 | data, err = compressDecoder.DecodeAll(data, nil)
386 | if nil != err {
387 | return
388 | }
389 | ret = &entity.Index{}
390 | err = gulu.JSON.UnmarshalJSON(data, ret)
391 | return
392 | }
393 |
394 | func (webdav *WebDAV) parseErr(err error) error {
395 | if nil == err {
396 | return nil
397 | }
398 |
399 | switch err.(type) {
400 | case *fs.PathError:
401 | if e := errors.Unwrap(err); nil != e {
402 | switch e.(type) {
403 | case gowebdav.StatusError:
404 | statusErr := e.(gowebdav.StatusError)
405 | if 404 == statusErr.Status {
406 | return ErrCloudObjectNotFound
407 | } else if 503 == statusErr.Status || 502 == statusErr.Status || 500 == statusErr.Status {
408 | return ErrCloudServiceUnavailable
409 | } else if 200 == statusErr.Status {
410 | return nil
411 | }
412 | }
413 | }
414 | }
415 | msg := strings.ToLower(err.Error())
416 | if strings.Contains(msg, "404") || strings.Contains(msg, "no such file") {
417 | err = ErrCloudObjectNotFound
418 | }
419 | return err
420 | }
421 |
422 | func (webdav *WebDAV) mkdirAll(folder string) (err error) {
423 | cacheKey := "webdav.dir." + folder
424 | _, ok := cache.Get(cacheKey)
425 | if ok {
426 | return
427 | }
428 |
429 | webdav.lock.Lock()
430 | defer webdav.lock.Unlock()
431 |
432 | info, err := webdav.Client.Stat(folder)
433 | if nil != err {
434 | err = webdav.parseErr(err)
435 | if nil == err {
436 | cache.Set(cacheKey, true, 1)
437 | return
438 | }
439 |
440 | if ErrCloudObjectNotFound != err {
441 | return
442 | }
443 | }
444 | i := info.(*gowebdav.File)
445 | if nil != i && i.IsDir() {
446 | cache.Set(cacheKey, true, 1)
447 | return
448 | }
449 |
450 | paths := strings.Split(folder, "/")
451 | sub := "/"
452 | for _, e := range paths {
453 | if e == "" {
454 | continue
455 | }
456 | sub += e + "/"
457 |
458 | if _, ok := cache.Get("webdav.dir." + sub); ok {
459 | continue
460 | }
461 |
462 | err = webdav.Client.Mkdir(sub, 0755)
463 | err = webdav.parseErr(err)
464 | if nil != err {
465 | logging.LogErrorf("mkdir [%s] failed: %s", folder, err)
466 | } else {
467 | cache.Set("webdav.dir."+sub, true, 1)
468 | }
469 | }
470 | return
471 | }
472 |
--------------------------------------------------------------------------------
/store.go:
--------------------------------------------------------------------------------
1 | // DejaVu - Data snapshot and sync.
2 | // Copyright (c) 2022-present, b3log.org
3 | //
4 | // This program is free software: you can redistribute it and/or modify
5 | // it under the terms of the GNU Affero General Public License as published by
6 | // the Free Software Foundation, either version 3 of the License, or
7 | // (at your option) any later version.
8 | //
9 | // This program is distributed in the hope that it will be useful,
10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | // GNU Affero General Public License for more details.
13 | //
14 | // You should have received a copy of the GNU Affero General Public License
15 | // along with this program. If not, see .
16 |
17 | package dejavu
18 |
19 | import (
20 | "errors"
21 | "os"
22 | "path/filepath"
23 | "strings"
24 | "time"
25 |
26 | "github.com/88250/gulu"
27 | "github.com/dgraph-io/ristretto"
28 | "github.com/klauspost/compress/zstd"
29 | "github.com/siyuan-note/dejavu/entity"
30 | "github.com/siyuan-note/encryption"
31 | "github.com/siyuan-note/logging"
32 | )
33 |
34 | var ErrNotFoundObject = errors.New("not found object")
35 |
36 | // Store 描述了存储库。
37 | type Store struct {
38 | Path string // 存储库文件夹的绝对路径,如:F:\\SiYuan\\repo\\
39 | AesKey []byte
40 |
41 | compressEncoder *zstd.Encoder
42 | compressDecoder *zstd.Decoder
43 | }
44 |
45 | func NewStore(path string, aesKey []byte) (ret *Store, err error) {
46 | ret = &Store{Path: path, AesKey: aesKey}
47 |
48 | ret.compressEncoder, err = zstd.NewWriter(nil,
49 | zstd.WithEncoderLevel(zstd.SpeedDefault),
50 | zstd.WithEncoderCRC(false),
51 | zstd.WithWindowSize(512*1024))
52 | if nil != err {
53 | return
54 | }
55 | ret.compressDecoder, err = zstd.NewReader(nil,
56 | zstd.WithDecoderMaxMemory(16*1024*1024*1024))
57 | return
58 | }
59 |
60 | func (store *Store) Purge(retentionIndexIDs ...string) (ret *entity.PurgeStat, err error) {
61 | logging.LogInfof("purging data repo [%s], retention indexes [%d]", store.Path, len(retentionIndexIDs))
62 |
63 | objectsDir := filepath.Join(store.Path, "objects")
64 | if !gulu.File.IsDir(objectsDir) {
65 | logging.LogWarnf("objects dir [%s] is not a dir", objectsDir)
66 | return
67 | }
68 |
69 | entries, err := os.ReadDir(objectsDir)
70 | if nil != err {
71 | logging.LogErrorf("read objects dir [%s] failed: %s", objectsDir, err)
72 | return
73 | }
74 |
75 | // 收集所有数据对象
76 | objIDs := map[string]bool{}
77 | for _, entry := range entries {
78 | if !entry.IsDir() {
79 | continue
80 | }
81 |
82 | dirName := entry.Name()
83 | dir := filepath.Join(objectsDir, dirName)
84 | objs, readErr := os.ReadDir(dir)
85 | if nil != readErr {
86 | err = readErr
87 | logging.LogErrorf("read objects dir [%s] failed: %s", dir, err)
88 | return
89 | }
90 |
91 | for _, obj := range objs {
92 | id := dirName + obj.Name()
93 | objIDs[id] = true
94 | }
95 | }
96 |
97 | // 收集所有索引对象
98 | indexIDs := map[string]bool{}
99 | indexesDir := filepath.Join(store.Path, "indexes")
100 | if gulu.File.IsDir(indexesDir) {
101 | entries, err = os.ReadDir(indexesDir)
102 | if nil != err {
103 | logging.LogErrorf("read indexes dir [%s] failed: %s", indexesDir, err)
104 | return
105 | }
106 |
107 | for _, entry := range entries {
108 | id := entry.Name()
109 | if 40 != len(id) {
110 | continue
111 | }
112 |
113 | indexIDs[id] = true
114 | }
115 | }
116 |
117 | // 收集所有引用的索引
118 | refIndexIDs, err := store.readRefs()
119 | if nil != err {
120 | logging.LogErrorf("read refs failed: %s", err)
121 | return
122 | }
123 | for _, retentionIndexID := range retentionIndexIDs { // 指定保留的索引算作被引用
124 | refIndexIDs[retentionIndexID] = true
125 | }
126 | unreferencedIndexIDs := map[string]bool{}
127 | for indexID := range indexIDs {
128 | if !refIndexIDs[indexID] {
129 | unreferencedIndexIDs[indexID] = true
130 | }
131 | }
132 |
133 | // 收集所有引用的数据对象
134 | referencedObjIDs := map[string]bool{}
135 | for refID := range refIndexIDs {
136 | index, getErr := store.GetIndex(refID)
137 | if nil != getErr {
138 | logging.LogWarnf("get index [%s] failed: %s", refID, getErr)
139 | continue
140 | }
141 |
142 | for _, fileID := range index.Files {
143 | referencedObjIDs[fileID] = true
144 | file, getFileErr := store.GetFile(fileID)
145 | if nil != getFileErr {
146 | logging.LogWarnf("get file [%s] failed: %s", fileID, getFileErr)
147 | continue
148 | }
149 |
150 | for _, chunkID := range file.Chunks {
151 | referencedObjIDs[chunkID] = true
152 | }
153 | }
154 | }
155 |
156 | // 收集所有未引用的数据对象
157 | unreferencedObjIDs := map[string]bool{}
158 | for objID := range objIDs {
159 | if !referencedObjIDs[objID] {
160 | unreferencedObjIDs[objID] = true
161 | }
162 | }
163 |
164 | ret = &entity.PurgeStat{}
165 | ret.Indexes = len(unreferencedIndexIDs)
166 |
167 | // 清理未引用的索引对象
168 | for unreferencedIndexID := range unreferencedIndexIDs {
169 | indexPath := filepath.Join(store.Path, "indexes", unreferencedIndexID)
170 | if err = os.RemoveAll(indexPath); nil != err {
171 | logging.LogErrorf("remove unreferenced index [%s] failed: %s", unreferencedIndexID, err)
172 | return
173 | }
174 | }
175 |
176 | // 清理校验索引
177 | // Clear check index when purging data repo https://github.com/siyuan-note/siyuan/issues/9665
178 | checkIndexesDir := filepath.Join(store.Path, "check", "indexes")
179 | if gulu.File.IsDir(checkIndexesDir) {
180 | entries, err = os.ReadDir(checkIndexesDir)
181 | if nil != err {
182 | logging.LogErrorf("read check indexes dir [%s] failed: %s", checkIndexesDir, err)
183 | } else {
184 | for _, entry := range entries {
185 | id := entry.Name()
186 | if 40 != len(id) {
187 | continue
188 | }
189 |
190 | data, readErr := os.ReadFile(filepath.Join(checkIndexesDir, id))
191 | if nil != readErr {
192 | logging.LogErrorf("read check index [%s] failed: %s", id, readErr)
193 | continue
194 | }
195 |
196 | if data, readErr = store.compressDecoder.DecodeAll(data, nil); nil != readErr {
197 | logging.LogErrorf("decode check index [%s] failed: %s", id, readErr)
198 | continue
199 | }
200 |
201 | checkIndex := &entity.CheckIndex{}
202 | if readErr = gulu.JSON.UnmarshalJSON(data, checkIndex); nil != readErr {
203 | logging.LogErrorf("unmarshal check index [%s] failed: %s", id, readErr)
204 | continue
205 | }
206 |
207 | if !unreferencedIndexIDs[checkIndex.IndexID] {
208 | continue
209 | }
210 |
211 | if _, statErr := os.Stat(filepath.Join(store.Path, "indexes", checkIndex.IndexID)); os.IsNotExist(statErr) {
212 | if removeErr := os.RemoveAll(filepath.Join(store.Path, "check", "indexes", checkIndex.ID)); nil != removeErr {
213 | logging.LogErrorf("remove check index [%s] failed: %s", checkIndex.ID, removeErr)
214 | }
215 | }
216 | }
217 | }
218 | }
219 |
220 | // 清理未引用的数据对象
221 | for unreferencedObjID := range unreferencedObjIDs {
222 | stat, statErr := store.Stat(unreferencedObjID)
223 | if nil != statErr {
224 | logging.LogErrorf("stat [%s] failed: %s", unreferencedObjID, statErr)
225 | continue
226 | }
227 |
228 | ret.Size += stat.Size()
229 | ret.Objects++
230 |
231 | if err = store.Remove(unreferencedObjID); nil != err {
232 | logging.LogErrorf("remove unreferenced object [%s] failed: %s", unreferencedObjID, err)
233 | return
234 | }
235 | }
236 |
237 | fileCache.Clear()
238 | indexCache.Clear()
239 |
240 | logging.LogInfof("purged data repo [%s], [%d] indexes, [%d] objects, [%d] bytes", store.Path, ret.Indexes, ret.Objects, ret.Size)
241 | return
242 | }
243 |
244 | func (store *Store) readRefs() (ret map[string]bool, err error) {
245 | ret = map[string]bool{}
246 | refsDir := filepath.Join(store.Path, "refs")
247 | if !gulu.File.IsDir(refsDir) {
248 | return
249 | }
250 |
251 | err = filepath.Walk(refsDir, func(path string, info os.FileInfo, err error) error {
252 | if nil != err {
253 | return err
254 | }
255 |
256 | if info.IsDir() {
257 | return nil
258 | }
259 |
260 | if 42 < info.Size() {
261 | logging.LogWarnf("ref file [%s] is invalid", path)
262 | return nil
263 | }
264 |
265 | data, err := os.ReadFile(path)
266 | if nil != err {
267 | return err
268 | }
269 |
270 | content := strings.TrimSpace(string(data))
271 | if 40 != len(content) {
272 | logging.LogWarnf("ref file [%s] is invalid", path)
273 | return nil
274 | }
275 |
276 | ret[content] = true
277 | return nil
278 | })
279 | return
280 | }
281 |
282 | func (store *Store) PutIndex(index *entity.Index) (err error) {
283 | if "" == index.ID {
284 | return errors.New("invalid id")
285 | }
286 | dir, file := store.IndexAbsPath(index.ID)
287 | if err = os.MkdirAll(dir, 0755); nil != err {
288 | return errors.New("put index failed: " + err.Error())
289 | }
290 |
291 | data, err := gulu.JSON.MarshalJSON(index)
292 | if nil != err {
293 | return errors.New("put index failed: " + err.Error())
294 | }
295 |
296 | // Index 仅压缩,不加密
297 | data = store.compressEncoder.EncodeAll(data, nil)
298 |
299 | err = gulu.File.WriteFileSafer(file, data, 0644)
300 | if nil != err {
301 | return errors.New("put index failed: " + err.Error())
302 | }
303 |
304 | created := time.UnixMilli(index.Created)
305 | if err = os.Chtimes(file, created, created); nil != err {
306 | logging.LogWarnf("change index [%s] time failed: %s", index.ID, err.Error())
307 | }
308 |
309 | indexCache.Set(index.ID, index, int64(len(data)))
310 | return
311 | }
312 |
313 | func (store *Store) GetIndex(id string) (ret *entity.Index, err error) {
314 | cached, _ := indexCache.Get(id)
315 | if nil != cached {
316 | ret = cached.(*entity.Index)
317 | return
318 | }
319 |
320 | _, file := store.IndexAbsPath(id)
321 | var data []byte
322 | data, err = os.ReadFile(file)
323 | if nil != err {
324 | return
325 | }
326 |
327 | // Index 没有加密,直接解压
328 | data, err = store.compressDecoder.DecodeAll(data, nil)
329 | if nil == err {
330 | ret = &entity.Index{}
331 | err = gulu.JSON.UnmarshalJSON(data, ret)
332 | }
333 | if nil != err {
334 | return
335 | }
336 |
337 | indexCache.Set(id, ret, int64(len(data)))
338 | return
339 | }
340 |
341 | func (store *Store) PutFile(file *entity.File) (err error) {
342 | if "" == file.ID {
343 | return errors.New("invalid id")
344 | }
345 | dir, f := store.AbsPath(file.ID)
346 | if gulu.File.IsExist(f) {
347 | return
348 | }
349 | if err = os.MkdirAll(dir, 0755); nil != err {
350 | return errors.New("put failed: " + err.Error())
351 | }
352 |
353 | data, err := gulu.JSON.MarshalJSON(file)
354 | if nil != err {
355 | return errors.New("put file failed: " + err.Error())
356 | }
357 | if data, err = store.encodeData(data); nil != err {
358 | return
359 | }
360 |
361 | err = gulu.File.WriteFileSafer(f, data, 0644)
362 | if nil != err {
363 | return errors.New("put file failed: " + err.Error())
364 | }
365 |
366 | fileCache.Set(file.ID, file, int64(len(data)))
367 | return
368 | }
369 |
370 | func (store *Store) GetFile(id string) (ret *entity.File, err error) {
371 | cached, _ := fileCache.Get(id)
372 | if nil != cached {
373 | ret = cached.(*entity.File)
374 | return
375 | }
376 |
377 | _, file := store.AbsPath(id)
378 | data, err := os.ReadFile(file)
379 | if nil != err {
380 | return
381 | }
382 | if data, err = store.decodeData(data); nil != err {
383 | return
384 | }
385 | ret = &entity.File{}
386 | err = gulu.JSON.UnmarshalJSON(data, ret)
387 | if nil != err {
388 | ret = nil
389 | return
390 | }
391 |
392 | fileCache.Set(id, ret, int64(len(data)))
393 | return
394 | }
395 |
396 | func (store *Store) PutChunk(chunk *entity.Chunk) (err error) {
397 | if "" == chunk.ID {
398 | return errors.New("invalid id")
399 | }
400 | dir, file := store.AbsPath(chunk.ID)
401 | if gulu.File.IsExist(file) {
402 | return
403 | }
404 |
405 | if err = os.MkdirAll(dir, 0755); nil != err {
406 | return errors.New("put chunk failed: " + err.Error())
407 | }
408 |
409 | data := chunk.Data
410 | if data, err = store.encodeData(data); nil != err {
411 | return
412 | }
413 |
414 | err = gulu.File.WriteFileSafer(file, data, 0644)
415 | if nil != err {
416 | return errors.New("put chunk failed: " + err.Error())
417 | }
418 | return
419 | }
420 |
421 | func (store *Store) GetChunk(id string) (ret *entity.Chunk, err error) {
422 | _, file := store.AbsPath(id)
423 | data, err := os.ReadFile(file)
424 | if nil != err {
425 | return
426 | }
427 | if data, err = store.decodeData(data); nil != err {
428 | return
429 | }
430 | ret = &entity.Chunk{ID: id, Data: data}
431 | return
432 | }
433 |
434 | func (store *Store) Remove(id string) (err error) {
435 | _, file := store.AbsPath(id)
436 | err = os.RemoveAll(file)
437 | return
438 | }
439 |
440 | func (store *Store) Stat(id string) (stat os.FileInfo, err error) {
441 | _, file := store.AbsPath(id)
442 | stat, err = os.Stat(file)
443 | return
444 | }
445 |
446 | func (store *Store) IndexAbsPath(id string) (dir, file string) {
447 | dir = filepath.Join(store.Path, "indexes")
448 | file = filepath.Join(dir, id)
449 | return
450 | }
451 |
452 | func (store *Store) AbsPath(id string) (dir, file string) {
453 | dir, file = id[0:2], id[2:]
454 | dir = filepath.Join(store.Path, "objects", dir)
455 | file = filepath.Join(dir, file)
456 | return
457 | }
458 |
459 | func (store *Store) encodeData(data []byte) ([]byte, error) {
460 | data = store.compressEncoder.EncodeAll(data, nil)
461 | return encryption.AesEncrypt(data, store.AesKey)
462 | }
463 |
464 | func (store *Store) decodeData(data []byte) (ret []byte, err error) {
465 | ret, err = encryption.AesDecrypt(data, store.AesKey)
466 | if nil != err {
467 | return
468 | }
469 | ret, err = store.compressDecoder.DecodeAll(ret, nil)
470 | return
471 | }
472 |
473 | var fileCache, _ = ristretto.NewCache(&ristretto.Config{
474 | NumCounters: 200000,
475 | MaxCost: 1000 * 1000 * 32, // 1 个文件按 300 字节计算,32MB 大概可以缓存 10W 个文件实例
476 | BufferItems: 64,
477 | })
478 |
479 | var indexCache, _ = ristretto.NewCache(&ristretto.Config{
480 | NumCounters: 200000,
481 | MaxCost: 1000 * 1000 * 128, // 1 个文件按 300K 字节(大约 1.5W 个文件)计算,128MB 大概可以缓存 400 个索引
482 | BufferItems: 64,
483 | })
484 |
485 | func (store *Store) cacheFile(file *entity.File) {
486 | fileCache.Set(file.ID, file, 256 /* 直接使用合理的均值以免进行实际计算消耗性能 */)
487 | }
488 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | github.com/88250/go-humanize v0.0.0-20240424102817-4f78fac47ea7 h1:MafIFwSS0x6A4hqNtl0ObDG2cx8kcafqWu2xxkwZ3rI=
2 | github.com/88250/go-humanize v0.0.0-20240424102817-4f78fac47ea7/go.mod h1:HrKCCTin3YNDSLBD02K0AOljjV6eNwc3/zyEI+xyV1I=
3 | github.com/88250/gulu v1.2.3-0.20251119142510-7b1583ab4aa0 h1:ip0IQCJCLtJEDHil+2XSnh3NP39i98SYV5qhWoUeMnA=
4 | github.com/88250/gulu v1.2.3-0.20251119142510-7b1583ab4aa0/go.mod h1:IQ5dXW9CjVmx6B7OfK1Y4ZBKTPMe9q1AkVoLGGzRbS8=
5 | github.com/88250/lute v1.7.7-0.20250801084148-32f2ef961381 h1:On7e0kQ5WCCbY+B+ItjkUyqwzChPXiMpI9hlpbTYwfc=
6 | github.com/88250/lute v1.7.7-0.20250801084148-32f2ef961381/go.mod h1:WYyUw//5yVw9BJnoVjx7rI/3szsISxNZCYGOqTIrV0o=
7 | github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
8 | github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
9 | github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
10 | github.com/alecthomas/chroma v0.10.0 h1:7XDcGkCQopCNKjZHfYrNLraA+M7e0fMiJ/Mfikbfjek=
11 | github.com/alecthomas/chroma v0.10.0/go.mod h1:jtJATyUxlIORhUOFNA9NZDWGAQ8wpxQQqNSB4rjA/1s=
12 | github.com/alex-ant/gomath v0.0.0-20160516115720-89013a210a82 h1:7dONQ3WNZ1zy960TmkxJPuwoolZwL7xKtpcM04MBnt4=
13 | github.com/alex-ant/gomath v0.0.0-20160516115720-89013a210a82/go.mod h1:nLnM0KdK1CmygvjpDUO6m1TjSsiQtL61juhNsvV/JVI=
14 | github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
15 | github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
16 | github.com/asaskevich/EventBus v0.0.0-20200907212545-49d423059eef h1:2JGTg6JapxP9/R33ZaagQtAM4EkkSYnIAlOG5EI8gkM=
17 | github.com/asaskevich/EventBus v0.0.0-20200907212545-49d423059eef/go.mod h1:JS7hed4L1fj0hXcyEejnW57/7LCetXggd+vwrRnYeII=
18 | github.com/aws/aws-sdk-go-v2 v1.41.0 h1:tNvqh1s+v0vFYdA1xq0aOJH+Y5cRyZ5upu6roPgPKd4=
19 | github.com/aws/aws-sdk-go-v2 v1.41.0/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0=
20 | github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 h1:489krEF9xIGkOaaX3CE/Be2uWjiXrkCH6gUX+bZA/BU=
21 | github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4/go.mod h1:IOAPF6oT9KCsceNTvvYMNHy0+kMF8akOjeDvPENWxp4=
22 | github.com/aws/aws-sdk-go-v2/config v1.32.6 h1:hFLBGUKjmLAekvi1evLi5hVvFQtSo3GYwi+Bx4lpJf8=
23 | github.com/aws/aws-sdk-go-v2/config v1.32.6/go.mod h1:lcUL/gcd8WyjCrMnxez5OXkO3/rwcNmvfno62tnXNcI=
24 | github.com/aws/aws-sdk-go-v2/credentials v1.19.6 h1:F9vWao2TwjV2MyiyVS+duza0NIRtAslgLUM0vTA1ZaE=
25 | github.com/aws/aws-sdk-go-v2/credentials v1.19.6/go.mod h1:SgHzKjEVsdQr6Opor0ihgWtkWdfRAIwxYzSJ8O85VHY=
26 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 h1:80+uETIWS1BqjnN9uJ0dBUaETh+P1XwFy5vwHwK5r9k=
27 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16/go.mod h1:wOOsYuxYuB/7FlnVtzeBYRcjSRtQpAW0hCP7tIULMwo=
28 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 h1:rgGwPzb82iBYSvHMHXc8h9mRoOUBZIGFgKb9qniaZZc=
29 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16/go.mod h1:L/UxsGeKpGoIj6DxfhOWHWQ/kGKcd4I1VncE4++IyKA=
30 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 h1:1jtGzuV7c82xnqOVfx2F0xmJcOw5374L7N6juGW6x6U=
31 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16/go.mod h1:M2E5OQf+XLe+SZGmmpaI2yy+J326aFf6/+54PoxSANc=
32 | github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk=
33 | github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc=
34 | github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.16 h1:CjMzUs78RDDv4ROu3JnJn/Ig1r6ZD7/T2DXLLRpejic=
35 | github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.16/go.mod h1:uVW4OLBqbJXSHJYA9svT9BluSvvwbzLQ2Crf6UPzR3c=
36 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E=
37 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow=
38 | github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.7 h1:DIBqIrJ7hv+e4CmIk2z3pyKT+3B6qVMgRsawHiR3qso=
39 | github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.7/go.mod h1:vLm00xmBke75UmpNvOcZQ/Q30ZFjbczeLFqGx5urmGo=
40 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 h1:oHjJHeUy0ImIV0bsrX0X91GkV5nJAyv1l1CC9lnO0TI=
41 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16/go.mod h1:iRSNGgOYmiYwSCXxXaKb9HfOEj40+oTKn8pTxMlYkRM=
42 | github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16 h1:NSbvS17MlI2lurYgXnCOLvCFX38sBW4eiVER7+kkgsU=
43 | github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16/go.mod h1:SwT8Tmqd4sA6G1qaGdzWCJN99bUmPGHfRwwq3G5Qb+A=
44 | github.com/aws/aws-sdk-go-v2/service/s3 v1.94.0 h1:SWTxh/EcUCDVqi/0s26V6pVUq0BBG7kx0tDTmF/hCgA=
45 | github.com/aws/aws-sdk-go-v2/service/s3 v1.94.0/go.mod h1:79S2BdqCJpScXZA2y+cpZuocWsjGjJINyXnOsf5DTz8=
46 | github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 h1:HpI7aMmJ+mm1wkSHIA2t5EaFFv5EFYXePW30p1EIrbQ=
47 | github.com/aws/aws-sdk-go-v2/service/signin v1.0.4/go.mod h1:C5RdGMYGlfM0gYq/tifqgn4EbyX99V15P2V3R+VHbQU=
48 | github.com/aws/aws-sdk-go-v2/service/sso v1.30.8 h1:aM/Q24rIlS3bRAhTyFurowU8A0SMyGDtEOY/l/s/1Uw=
49 | github.com/aws/aws-sdk-go-v2/service/sso v1.30.8/go.mod h1:+fWt2UHSb4kS7Pu8y+BMBvJF0EWx+4H0hzNwtDNRTrg=
50 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 h1:AHDr0DaHIAo8c9t1emrzAlVDFp+iMMKnPdYy6XO4MCE=
51 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12/go.mod h1:GQ73XawFFiWxyWXMHWfhiomvP3tXtdNar/fi8z18sx0=
52 | github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 h1:SciGFVNZ4mHdm7gpD1dgZYnCuVdX1s+lFTg4+4DOy70=
53 | github.com/aws/aws-sdk-go-v2/service/sts v1.41.5/go.mod h1:iW40X4QBmUxdP+fZNOpfmkdMZqsovezbAeO+Ubiv2pk=
54 | github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk=
55 | github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
56 | github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
57 | github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
58 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
59 | github.com/dave/jennifer v1.6.1/go.mod h1:nXbxhEmQfOZhWml3D1cDK5M1FLnMSozpbFN/m3RmGZc=
60 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
61 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
62 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
63 | github.com/dgraph-io/ristretto v0.2.0 h1:XAfl+7cmoUDWW/2Lx8TGZQjjxIQ2Ley9DSf52dru4WE=
64 | github.com/dgraph-io/ristretto v0.2.0/go.mod h1:8uBHCU/PBV4Ag0CJrP47b9Ofby5dqWNh4FicAdoqFNU=
65 | github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 h1:fAjc9m62+UWV/WAFKLNi6ZS0675eEUC9y3AlwSbQu1Y=
66 | github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
67 | github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
68 | github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
69 | github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
70 | github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
71 | github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
72 | github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A=
73 | github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
74 | github.com/gammazero/toposort v0.1.1 h1:OivGxsWxF3U3+U80VoLJ+f50HcPU1MIqE1JlKzoJ2Eg=
75 | github.com/gammazero/toposort v0.1.1/go.mod h1:H2cozTnNpMw0hg2VHAYsAxmkHXBYroNangj2NTBQDvw=
76 | github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
77 | github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
78 | github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
79 | github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
80 | github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8=
81 | github.com/go-playground/locales v0.14.0/go.mod h1:sawfccIbzZTqEDETgFXqTho0QybSa7l++s0DH+LDiLs=
82 | github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA=
83 | github.com/go-playground/universal-translator v0.18.0/go.mod h1:UvRDBj+xPUEGrFYl+lu/H90nyDXpg0fqeB/AQUGNTVA=
84 | github.com/go-playground/validator/v10 v10.7.0/go.mod h1:xm76BBt941f7yWdGnI2DVPFFg1UK3YY04qifoXU3lOk=
85 | github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
86 | github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
87 | github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU=
88 | github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw=
89 | github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0=
90 | github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
91 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
92 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
93 | github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
94 | github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
95 | github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g=
96 | github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k=
97 | github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
98 | github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
99 | github.com/iancoleman/strcase v0.3.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho=
100 | github.com/icholy/digest v1.1.0 h1:HfGg9Irj7i+IX1o1QAmPfIBNu/Q5A5Tu3n/MED9k9H4=
101 | github.com/icholy/digest v1.1.0/go.mod h1:QNrsSGQ5v7v9cReDI0+eyjsXGUoRSUZQHeQ5C4XLa0Y=
102 | github.com/imroc/req/v3 v3.57.0 h1:LMTUjNRUybUkTPn8oJDq8Kg3JRBOBTcnDhKu7mzupKI=
103 | github.com/imroc/req/v3 v3.57.0/go.mod h1:JL62ey1nvSLq81HORNcosvlf7SxZStONNqOprg0Pz00=
104 | github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
105 | github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
106 | github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
107 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
108 | github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
109 | github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
110 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
111 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
112 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
113 | github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII=
114 | github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY=
115 | github.com/panjf2000/ants/v2 v2.11.3 h1:AfI0ngBoXJmYOpDh9m516vjqoUu2sLrIVgppI9TZVpg=
116 | github.com/panjf2000/ants/v2 v2.11.3/go.mod h1:8u92CYMUc6gyvTIw8Ru7Mt7+/ESnJahz5EVtqfrilek=
117 | github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
118 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
119 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
120 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
121 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
122 | github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
123 | github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
124 | github.com/qiniu/dyn v1.3.0/go.mod h1:E8oERcm8TtwJiZvkQPbcAh0RL8jO1G0VXJMW3FAWdkk=
125 | github.com/qiniu/go-sdk/v7 v7.25.5 h1:BZAZhrYC7vrw9NPnNbFi1K8xucoz29s7sEGWap77i2Q=
126 | github.com/qiniu/go-sdk/v7 v7.25.5/go.mod h1:dmKtJ2ahhPWFVi9o1D5GemmWoh/ctuB9peqTowyTO8o=
127 | github.com/qiniu/x v1.10.5/go.mod h1:03Ni9tj+N2h2aKnAz+6N0Xfl8FwMEDRC2PAlxekASDs=
128 | github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
129 | github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
130 | github.com/quic-go/quic-go v0.57.1 h1:25KAAR9QR8KZrCZRThWMKVAwGoiHIrNbT72ULHTuI10=
131 | github.com/quic-go/quic-go v0.57.1/go.mod h1:ly4QBAjHA2VhdnxhojRsCUOeJwKYg+taDlos92xb1+s=
132 | github.com/refraction-networking/utls v1.8.1 h1:yNY1kapmQU8JeM1sSw2H2asfTIwWxIkrMJI0pRUOCAo=
133 | github.com/refraction-networking/utls v1.8.1/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
134 | github.com/restic/chunker v0.4.0 h1:YUPYCUn70MYP7VO4yllypp2SjmsRhRJaad3xKu1QFRw=
135 | github.com/restic/chunker v0.4.0/go.mod h1:z0cH2BejpW636LXw0R/BGyv+Ey8+m9QGiOanDHItzyw=
136 | github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
137 | github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
138 | github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 h1:OkMGxebDjyw0ULyrTYWeN0UNCCkmCWfjPnIA2W6oviI=
139 | github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06/go.mod h1:+ePHsJ1keEjQtpvf9HHw0f4ZeJ0TLRsxhunSI2hYJSs=
140 | github.com/shirou/gopsutil/v4 v4.25.11 h1:X53gB7muL9Gnwwo2evPSE+SfOrltMoR6V3xJAXZILTY=
141 | github.com/shirou/gopsutil/v4 v4.25.11/go.mod h1:EivAfP5x2EhLp2ovdpKSozecVXn1TmuG7SMzs/Wh4PU=
142 | github.com/siyuan-note/dataparser v0.0.0-20251203120213-59c16535cb56 h1:aKzEVlOOSwvYqpbWTAQpGrpAZT8JrpAvaY9ek4PXIAQ=
143 | github.com/siyuan-note/dataparser v0.0.0-20251203120213-59c16535cb56/go.mod h1:UGIytXL3Ge9iFj9RVDNAnfEOCPmzMPzpYTZyLtGC6tQ=
144 | github.com/siyuan-note/encryption v0.0.0-20251120032857-3ddc3c2cc49f h1:HSgJKIAMgokJDAvBBfRj47SzRSm6mNGssY0Wv7rcEtg=
145 | github.com/siyuan-note/encryption v0.0.0-20251120032857-3ddc3c2cc49f/go.mod h1:JE3S9VuJqTggyfhjesNDuqvqrRvwG3IctFjXXchLx1M=
146 | github.com/siyuan-note/eventbus v0.0.0-20240627125516-396fdb0f0f97 h1:lM5v8BfNtbOL5jYwhCdMYBcYtr06IYBKjjSLAPMKTM8=
147 | github.com/siyuan-note/eventbus v0.0.0-20240627125516-396fdb0f0f97/go.mod h1:1/nGgthl89FPA7GzAcEWKl6zRRnfgyTjzLZj9bW7kuw=
148 | github.com/siyuan-note/filelock v0.0.0-20251212095217-08318833e008 h1:3wEmNS4eZkxwm1rhXDhVK5Y0o/GKAZtfe1VV584BF+A=
149 | github.com/siyuan-note/filelock v0.0.0-20251212095217-08318833e008/go.mod h1:9OhXAyOkSXwuLvNCZk2aFMo0nOldyO3f2hMJEnkuT30=
150 | github.com/siyuan-note/httpclient v0.0.0-20251217011734-7f49de93158a h1:AHhTKVbZ1lCssLu6qgj2VgGwwnhMoMa3HfoEXVgGQp8=
151 | github.com/siyuan-note/httpclient v0.0.0-20251217011734-7f49de93158a/go.mod h1:USEy1/f1vT64cXQHd6GsiP0UkjDUxgMobg4RE1f91Fc=
152 | github.com/siyuan-note/logging v0.0.0-20251209020516-52f1a2f65ec5 h1:bIMoJAAf3tV0xYcN+N2Vw7Ot/LbVxuz715o1rn1GDto=
153 | github.com/siyuan-note/logging v0.0.0-20251209020516-52f1a2f65ec5/go.mod h1:U6DyWKvtIPW9WrUoUikPCwFUzUoHGtEJjjeLNYae1nc=
154 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
155 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
156 | github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
157 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
158 | github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
159 | github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
160 | github.com/studio-b12/gowebdav v0.11.0 h1:qbQzq4USxY28ZYsGJUfO5jR+xkFtcnwWgitp4Zp1irU=
161 | github.com/studio-b12/gowebdav v0.11.0/go.mod h1:bHA7t77X/QFExdeAnDzK6vKM34kEZAcE1OX4MfiwjkE=
162 | github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8=
163 | github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
164 | github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
165 | github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
166 | github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
167 | github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
168 | github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
169 | github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
170 | go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
171 | go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
172 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
173 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
174 | golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
175 | golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
176 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
177 | golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
178 | golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
179 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
180 | golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
181 | golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
182 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
183 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
184 | golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
185 | golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
186 | golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
187 | golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
188 | golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
189 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
190 | golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
191 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
192 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
193 | golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
194 | golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
195 | golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
196 | golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
197 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
198 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
199 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
200 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
201 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
202 | gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
203 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
204 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
205 | gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
206 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
207 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
208 | gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU=
209 | gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU=
210 | modernc.org/fileutil v1.0.0/go.mod h1:JHsWpkrk/CnVV1H/eGlFf85BEpfkrp56ro8nojIq9Q8=
211 | modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
212 | modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
213 |
--------------------------------------------------------------------------------
/cloud/s3.go:
--------------------------------------------------------------------------------
1 | // DejaVu - Data snapshot and sync.
2 | // Copyright (c) 2022-present, b3log.org
3 | //
4 | // This program is free software: you can redistribute it and/or modify
5 | // it under the terms of the GNU Affero General Public License as published by
6 | // the Free Software Foundation, either version 3 of the License, or
7 | // (at your option) any later version.
8 | //
9 | // This program is distributed in the hope that it will be useful,
10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | // GNU Affero General Public License for more details.
13 | //
14 | // You should have received a copy of the GNU Affero General Public License
15 | // along with this program. If not, see .
16 |
17 | package cloud
18 |
19 | import (
20 | "bytes"
21 | "context"
22 | "errors"
23 | "fmt"
24 | "io"
25 | "math"
26 | "net/http"
27 | "os"
28 | "path"
29 | "path/filepath"
30 | "sort"
31 | "strings"
32 | "sync"
33 | "time"
34 |
35 | "github.com/88250/gulu"
36 | "github.com/aws/aws-sdk-go-v2/aws"
37 | asSigner "github.com/aws/aws-sdk-go-v2/aws/signer/v4"
38 | "github.com/aws/aws-sdk-go-v2/config"
39 | "github.com/aws/aws-sdk-go-v2/credentials"
40 | as3 "github.com/aws/aws-sdk-go-v2/service/s3"
41 | as3Types "github.com/aws/aws-sdk-go-v2/service/s3/types"
42 | "github.com/aws/smithy-go"
43 | "github.com/aws/smithy-go/middleware"
44 | smithyhttp "github.com/aws/smithy-go/transport/http"
45 | "github.com/panjf2000/ants/v2"
46 | "github.com/siyuan-note/dejavu/entity"
47 | "github.com/siyuan-note/logging"
48 | )
49 |
50 | // S3 描述了 S3 协议兼容的对象存储服务实现。
51 | type S3 struct {
52 | *BaseCloud
53 | HTTPClient *http.Client
54 | service *as3.Client // 用于缓存 S3 客户端
55 | mux sync.Mutex // 用于保护 service 字段的并发访问
56 | }
57 |
58 | func NewS3(baseCloud *BaseCloud, httpClient *http.Client) *S3 {
59 | return &S3{BaseCloud: baseCloud, HTTPClient: httpClient}
60 | }
61 |
62 | func (s3 *S3) GetRepos() (repos []*Repo, size int64, err error) {
63 | repos, err = s3.listRepos()
64 | if nil != err {
65 | return
66 | }
67 |
68 | for _, repo := range repos {
69 | size += repo.Size
70 | }
71 | return
72 | }
73 |
74 | func (s3 *S3) UploadObject(filePath string, overwrite bool) (length int64, err error) {
75 | svc := s3.getService()
76 | ctx, cancelFn := context.WithTimeout(context.Background(), time.Duration(s3.S3.Timeout)*time.Second)
77 | defer cancelFn()
78 |
79 | absFilePath := filepath.Join(s3.Conf.RepoPath, filePath)
80 | info, err := os.Stat(absFilePath)
81 | if nil != err {
82 | logging.LogErrorf("stat failed: %s", err)
83 | return
84 | }
85 | length = info.Size()
86 |
87 | file, err := os.Open(absFilePath)
88 | if nil != err {
89 | return
90 | }
91 | defer file.Close()
92 | key := path.Join("repo", filePath)
93 | _, err = svc.PutObject(ctx, &as3.PutObjectInput{
94 | Bucket: aws.String(s3.Conf.S3.Bucket),
95 | Key: aws.String(key),
96 | CacheControl: aws.String("no-cache"),
97 | Body: file,
98 | })
99 | if nil != err {
100 | return
101 | }
102 |
103 | //logging.LogInfof("uploaded object [%s]", key)
104 | return
105 | }
106 |
107 | func (s3 *S3) UploadBytes(filePath string, data []byte, overwrite bool) (length int64, err error) {
108 | length = int64(len(data))
109 | svc := s3.getService()
110 | ctx, cancelFn := context.WithTimeout(context.Background(), time.Duration(s3.S3.Timeout)*time.Second)
111 | defer cancelFn()
112 |
113 | key := path.Join("repo", filePath)
114 | _, err = svc.PutObject(ctx, &as3.PutObjectInput{
115 | Bucket: aws.String(s3.Conf.S3.Bucket),
116 | Key: aws.String(key),
117 | CacheControl: aws.String("no-cache"),
118 | Body: bytes.NewReader(data),
119 | })
120 | if nil != err {
121 | return
122 | }
123 |
124 | //logging.LogInfof("uploaded object [%s]", key)
125 | return
126 | }
127 |
128 | func (s3 *S3) DownloadObject(filePath string) (data []byte, err error) {
129 | svc := s3.getService()
130 | ctx, cancelFn := context.WithTimeout(context.Background(), time.Duration(s3.S3.Timeout)*time.Second)
131 | defer cancelFn()
132 | key := path.Join("repo", filePath)
133 | input := &as3.GetObjectInput{
134 | Bucket: aws.String(s3.Conf.S3.Bucket),
135 | Key: aws.String(key),
136 | ResponseCacheControl: aws.String("no-cache"),
137 | }
138 | resp, err := svc.GetObject(ctx, input)
139 | if nil != err {
140 | if s3.isErrNotFound(err) {
141 | err = ErrCloudObjectNotFound
142 | }
143 | return
144 | }
145 | defer resp.Body.Close()
146 | data, err = io.ReadAll(resp.Body)
147 | if nil != err {
148 | return
149 | }
150 |
151 | //logging.LogInfof("downloaded object [%s]", key)
152 | return
153 | }
154 |
155 | func (s3 *S3) RemoveObject(key string) (err error) {
156 | key = path.Join("repo", key)
157 | svc := s3.getService()
158 | ctx, cancelFn := context.WithTimeout(context.Background(), time.Duration(s3.S3.Timeout)*time.Second)
159 | defer cancelFn()
160 | _, err = svc.DeleteObject(ctx, &as3.DeleteObjectInput{
161 | Bucket: aws.String(s3.Conf.S3.Bucket),
162 | Key: aws.String(key),
163 | })
164 | if nil != err {
165 | return
166 | }
167 |
168 | //logging.LogInfof("removed object [%s]", key)
169 | return
170 | }
171 |
172 | func (s3 *S3) GetTags() (tags []*Ref, err error) {
173 | tags, err = s3.listRepoRefs("tags")
174 | if nil != err {
175 | logging.LogErrorf("list repo tags failed: %s", err)
176 | return
177 | }
178 | if 1 > len(tags) {
179 | tags = []*Ref{}
180 | }
181 | return
182 | }
183 |
184 | const pageSize = 32
185 |
186 | func (s3 *S3) GetIndexes(page int) (ret []*entity.Index, pageCount, totalCount int, err error) {
187 | ret = []*entity.Index{}
188 | data, err := s3.DownloadObject("indexes-v2.json")
189 | if nil != err {
190 | if s3.isErrNotFound(err) {
191 | err = nil
192 | }
193 | return
194 | }
195 |
196 | data, err = compressDecoder.DecodeAll(data, nil)
197 | if nil != err {
198 | return
199 | }
200 |
201 | indexesJSON := &Indexes{}
202 | if err = gulu.JSON.UnmarshalJSON(data, indexesJSON); nil != err {
203 | return
204 | }
205 |
206 | totalCount = len(indexesJSON.Indexes)
207 | pageCount = int(math.Ceil(float64(totalCount) / float64(pageSize)))
208 |
209 | start := (page - 1) * pageSize
210 | end := page * pageSize
211 | if end > totalCount {
212 | end = totalCount
213 | }
214 |
215 | for i := start; i < end; i++ {
216 | index, getErr := s3.repoIndex(indexesJSON.Indexes[i].ID)
217 | if nil != getErr {
218 | logging.LogWarnf("get index [%s] failed: %s", indexesJSON.Indexes[i], getErr)
219 | continue
220 | }
221 | if nil == index {
222 | continue
223 | }
224 |
225 | index.Files = nil // Optimize the performance of obtaining cloud snapshots https://github.com/siyuan-note/siyuan/issues/8387
226 | ret = append(ret, index)
227 | }
228 | return
229 | }
230 |
231 | func (s3 *S3) GetRefsFiles() (fileIDs []string, refs []*Ref, err error) {
232 | refs, err = s3.listRepoRefs("")
233 | if nil != err {
234 | logging.LogErrorf("list repo refs failed: %s", err)
235 | return
236 | }
237 |
238 | var files []string
239 | for _, ref := range refs {
240 | index, getErr := s3.repoIndex(ref.ID)
241 | if nil != getErr {
242 | err = getErr
243 | return
244 | }
245 | if nil == index {
246 | continue
247 | }
248 |
249 | files = append(files, index.Files...)
250 | }
251 | fileIDs = gulu.Str.RemoveDuplicatedElem(files)
252 | if 1 > len(fileIDs) {
253 | fileIDs = []string{}
254 | }
255 | return
256 | }
257 |
258 | func (s3 *S3) GetChunks(checkChunkIDs []string) (chunkIDs []string, err error) {
259 | var keys []string
260 | repoObjects := path.Join("repo", "objects")
261 | for _, chunk := range checkChunkIDs {
262 | key := path.Join(repoObjects, chunk[:2], chunk[2:])
263 | keys = append(keys, key)
264 | }
265 |
266 | notFound, err := s3.getNotFound(keys)
267 | if nil != err {
268 | return
269 | }
270 |
271 | var notFoundChunkIDs []string
272 | for _, key := range notFound {
273 | chunkID := strings.TrimPrefix(key, repoObjects)
274 | chunkID = strings.ReplaceAll(chunkID, "/", "")
275 | notFoundChunkIDs = append(notFoundChunkIDs, chunkID)
276 | }
277 |
278 | chunkIDs = append(chunkIDs, notFoundChunkIDs...)
279 | chunkIDs = gulu.Str.RemoveDuplicatedElem(chunkIDs)
280 | if 1 > len(chunkIDs) {
281 | chunkIDs = []string{}
282 | }
283 | return
284 | }
285 |
286 | func (s3 *S3) GetIndex(id string) (index *entity.Index, err error) {
287 | index, err = s3.repoIndex(id)
288 | if nil != err {
289 | logging.LogErrorf("get index [%s] failed: %s", id, err)
290 | return
291 | }
292 | if nil == index {
293 | err = ErrCloudObjectNotFound
294 | return
295 | }
296 | return
297 | }
298 |
299 | func (s3 *S3) GetConcurrentReqs() (ret int) {
300 | ret = s3.S3.ConcurrentReqs
301 | if 1 > ret {
302 | ret = 8
303 | }
304 | if 16 < ret {
305 | ret = 16
306 | }
307 | return
308 | }
309 |
310 | func (s3 *S3) ListObjects(pathPrefix string) (ret map[string]*entity.ObjectInfo, err error) {
311 | ret = map[string]*entity.ObjectInfo{}
312 | svc := s3.getService()
313 |
314 | endWithSlash := strings.HasSuffix(pathPrefix, "/")
315 | pathPrefix = path.Join("repo", pathPrefix)
316 | if endWithSlash {
317 | pathPrefix += "/"
318 | }
319 | limit := int32(1000)
320 | ctx, cancelFn := context.WithTimeout(context.Background(), time.Duration(s3.S3.Timeout)*time.Second)
321 | defer cancelFn()
322 |
323 | paginator := as3.NewListObjectsV2Paginator(svc, &as3.ListObjectsV2Input{
324 | Bucket: &s3.Conf.S3.Bucket,
325 | Prefix: &pathPrefix,
326 | MaxKeys: &limit,
327 | })
328 |
329 | for paginator.HasMorePages() {
330 | output, pErr := paginator.NextPage(ctx)
331 | if nil != pErr {
332 | logging.LogErrorf("list objects failed: %s", pErr)
333 | return nil, pErr
334 | }
335 |
336 | for _, entry := range output.Contents {
337 | filePath := strings.TrimPrefix(*entry.Key, pathPrefix)
338 | if "" == filePath {
339 | logging.LogWarnf("skip empty file path for key [%s]", *entry.Key)
340 | continue
341 | }
342 |
343 | ret[filePath] = &entity.ObjectInfo{
344 | Path: filePath,
345 | Size: *entry.Size,
346 | }
347 | }
348 | }
349 | return
350 | }
351 |
352 | func (s3 *S3) repoIndex(id string) (ret *entity.Index, err error) {
353 | indexPath := path.Join("repo", "indexes", id)
354 | info, err := s3.statFile(indexPath)
355 | if nil != err {
356 | if s3.isErrNotFound(err) {
357 | err = nil
358 | }
359 | return
360 | }
361 | if 1 > info.Size {
362 | return
363 | }
364 |
365 | data, err := s3.DownloadObject(path.Join("indexes", id))
366 | if nil != err {
367 | logging.LogErrorf("download index [%s] failed: %s", id, err)
368 | return
369 | }
370 | data, err = compressDecoder.DecodeAll(data, nil)
371 | if nil != err {
372 | logging.LogErrorf("decompress index [%s] failed: %s", id, err)
373 | return
374 | }
375 | ret = &entity.Index{}
376 | err = gulu.JSON.UnmarshalJSON(data, ret)
377 | return
378 | }
379 |
380 | func (s3 *S3) listRepoRefs(refPrefix string) (ret []*Ref, err error) {
381 | svc := s3.getService()
382 | ctx, cancelFn := context.WithTimeout(context.Background(), time.Duration(s3.S3.Timeout)*time.Second)
383 | defer cancelFn()
384 |
385 | prefix := path.Join("repo", "refs", refPrefix)
386 | limit := int32(32)
387 | marker := ""
388 | for {
389 | output, listErr := svc.ListObjects(ctx, &as3.ListObjectsInput{
390 | Bucket: &s3.Conf.S3.Bucket,
391 | Prefix: &prefix,
392 | Marker: &marker,
393 | MaxKeys: &limit,
394 | })
395 | if nil != listErr {
396 | return
397 | }
398 |
399 | marker = *output.Marker
400 |
401 | for _, entry := range output.Contents {
402 | filePath := strings.TrimPrefix(*entry.Key, "repo/")
403 | data, getErr := s3.DownloadObject(filePath)
404 | if nil != getErr {
405 | err = getErr
406 | return
407 | }
408 |
409 | id := string(data)
410 | info, statErr := s3.statFile(path.Join("repo", "indexes", id))
411 | if nil != statErr {
412 | err = statErr
413 | return
414 | }
415 | if 1 > info.Size {
416 | continue
417 | }
418 |
419 | ret = append(ret, &Ref{
420 | Name: path.Base(*entry.Key),
421 | ID: id,
422 | Updated: entry.LastModified.Format("2006-01-02 15:04:05"),
423 | })
424 | }
425 |
426 | if !(*output.IsTruncated) {
427 | break
428 | }
429 | }
430 | return
431 | }
432 |
433 | func (s3 *S3) listRepos() (ret []*Repo, err error) {
434 | svc := s3.getService()
435 | ctx, cancelFn := context.WithTimeout(context.Background(), time.Duration(s3.S3.Timeout)*time.Second)
436 | defer cancelFn()
437 |
438 | output, err := svc.ListBuckets(ctx, &as3.ListBucketsInput{})
439 | if nil != err {
440 | return
441 | }
442 |
443 | ret = []*Repo{}
444 | for _, bucket := range output.Buckets {
445 | if *bucket.Name != s3.S3.Bucket {
446 | continue
447 | }
448 |
449 | ret = append(ret, &Repo{
450 | Name: *bucket.Name,
451 | Size: 0,
452 | Updated: (*bucket.CreationDate).Format("2006-01-02 15:04:05"),
453 | })
454 | }
455 | sort.Slice(ret, func(i, j int) bool { return ret[i].Name < ret[j].Name })
456 | return
457 | }
458 |
459 | func (s3 *S3) statFile(key string) (info *objectInfo, err error) {
460 | svc := s3.getService()
461 | ctx, cancelFn := context.WithTimeout(context.Background(), time.Duration(s3.S3.Timeout)*time.Second)
462 | defer cancelFn()
463 |
464 | header, err := svc.HeadObject(ctx, &as3.HeadObjectInput{
465 | Bucket: &s3.Conf.S3.Bucket,
466 | Key: &key,
467 | })
468 | if nil != err {
469 | return
470 | }
471 |
472 | updated := time.Now().Format("2006-01-02 15:04:05")
473 | info = &objectInfo{Key: key, Updated: updated, Size: 0}
474 | if nil == header {
475 | logging.LogWarnf("stat file [%s] header is nil", key)
476 | return
477 | }
478 | info.Size = *header.ContentLength
479 | if 1 > info.Size {
480 | logging.LogWarnf("stat file [%s] size is [%d]", key, info.Size)
481 | }
482 | if nil == header.LastModified {
483 | logging.LogWarnf("stat file [%s] header last modified is nil", key)
484 | } else {
485 | updated = header.LastModified.Format("2006-01-02 15:04:05")
486 | }
487 | info.Updated = updated
488 | return
489 | }
490 |
491 | func (s3 *S3) getNotFound(keys []string) (ret []string, err error) {
492 | if 1 > len(keys) {
493 | return
494 | }
495 |
496 | poolSize := s3.GetConcurrentReqs()
497 | if poolSize > len(keys) {
498 | poolSize = len(keys)
499 | }
500 |
501 | waitGroup := &sync.WaitGroup{}
502 | p, _ := ants.NewPoolWithFunc(poolSize, func(arg interface{}) {
503 | defer waitGroup.Done()
504 | key := arg.(string)
505 | info, statErr := s3.statFile(key)
506 | if nil == info || nil != statErr {
507 | ret = append(ret, key)
508 | }
509 | })
510 |
511 | for _, key := range keys {
512 | waitGroup.Add(1)
513 | err = p.Invoke(key)
514 | if nil != err {
515 | logging.LogErrorf("invoke failed: %s", err)
516 | return
517 | }
518 | }
519 | waitGroup.Wait()
520 | p.Release()
521 | return
522 | }
523 |
524 | func (s3 *S3) getService() *as3.Client {
525 | s3.mux.Lock()
526 | defer s3.mux.Unlock()
527 |
528 | if nil != s3.service {
529 | return s3.service
530 | }
531 |
532 | cfg, err := config.LoadDefaultConfig(context.TODO())
533 | if err != nil {
534 | logging.LogErrorf("load default config failed: %s", err)
535 | }
536 |
537 | s3.service = as3.NewFromConfig(cfg, func(o *as3.Options) {
538 | o.Credentials = aws.NewCredentialsCache(credentials.NewStaticCredentialsProvider(s3.Conf.S3.AccessKey, s3.Conf.S3.SecretKey, ""))
539 | o.BaseEndpoint = aws.String(s3.Conf.S3.Endpoint)
540 | o.Region = s3.Conf.S3.Region
541 | o.UsePathStyle = s3.Conf.S3.PathStyle
542 | o.HTTPClient = s3.HTTPClient
543 | o.RequestChecksumCalculation = aws.RequestChecksumCalculationWhenRequired
544 | o.ResponseChecksumValidation = aws.ResponseChecksumValidationWhenRequired
545 |
546 | // --- START: S3 Compatibility Fix for SigV4 (Cloudflare Tunnel/Proxies) ---
547 | // https://github.com/siyuan-note/siyuan/issues/16199
548 | // This fix addresses the 'SignatureDoesNotMatch' error encountered when using
549 | // S3-compatible endpoints proxied through services like Cloudflare Tunnel.
550 | // Proxies may modify headers (like Accept-Encoding), which invalidates the
551 | // AWS Signature Version 4 calculation.
552 | endpoint := strings.ToLower(s3.Conf.S3.Endpoint)
553 |
554 | // Only apply the compatibility middleware if the endpoint is NOT an official AWS S3 endpoint.
555 | if !strings.Contains(endpoint, "amazonaws.com") {
556 | // ignoreSigningHeaders and HeadersToIgnore are defined in s3_middleware.go (same package).
557 | ignoreSigningHeaders(o, HeadersToIgnore)
558 | // logging.LogDebugf("applied S3 compatibility fix for non-AWS endpoint: %s", s3.Conf.S3.Endpoint)
559 | }
560 | // --- END: S3 Compatibility Fix ---
561 | })
562 | return s3.service
563 | }
564 |
565 | func (s3 *S3) isErrNotFound(err error) bool {
566 | var nsk *as3Types.NoSuchKey
567 | if errors.As(err, &nsk) {
568 | return true
569 | }
570 |
571 | var nf *as3Types.NotFound
572 | if errors.As(err, &nf) {
573 | return true
574 | }
575 |
576 | var apiErr smithy.APIError
577 | if errors.As(err, &apiErr) {
578 | msg := strings.ToLower(apiErr.ErrorMessage())
579 | return strings.Contains(msg, "does not exist") || strings.Contains(msg, "404") || strings.Contains(msg, "no such file or directory")
580 | }
581 | return false
582 | }
583 |
584 | // HeadersToIgnore lists headers that frequently cause SignatureDoesNotMatch errors
585 | // when used with S3-compatible providers behind proxies (like Cloudflare Tunnel or GCS).
586 | // These headers are temporarily removed before the SigV4 signing process and restored afterwards.
587 | var HeadersToIgnore = []string{
588 | "Accept-Encoding", // The primary culprit, often modified by proxies.
589 | "Amz-Sdk-Invocation-Id",
590 | "Amz-Sdk-Request",
591 | }
592 |
593 | type ignoredHeadersKey struct{}
594 |
595 | // ignoreSigningHeaders is a helper to inject middleware that excludes specified headers
596 | // from the Signature Version 4 calculation by temporarily removing them.
597 | // This function should be called only for non-AWS S3 endpoints.
598 | func ignoreSigningHeaders(o *as3.Options, headers []string) {
599 | o.APIOptions = append(o.APIOptions, func(stack *middleware.Stack) error {
600 | // 1. Insert ignoreHeaders BEFORE the "Signing" middleware
601 | if err := stack.Finalize.Insert(ignoreHeaders(headers), "Signing", middleware.Before); err != nil {
602 | return fmt.Errorf("failed to insert S3CompatIgnoreHeaders: %w", err)
603 | }
604 |
605 | // 2. Insert restoreIgnored AFTER the "Signing" middleware
606 | if err := stack.Finalize.Insert(restoreIgnored(), "Signing", middleware.After); err != nil {
607 | return fmt.Errorf("failed to insert S3CompatRestoreHeaders: %w", err)
608 | }
609 | return nil
610 | })
611 | }
612 |
613 | // ignoreHeaders removes specified headers and stores them in context for later restoration.
614 | func ignoreHeaders(headers []string) middleware.FinalizeMiddleware {
615 | return middleware.FinalizeMiddlewareFunc(
616 | "S3CompatIgnoreHeaders",
617 | func(ctx context.Context, in middleware.FinalizeInput, next middleware.FinalizeHandler) (out middleware.FinalizeOutput, metadata middleware.Metadata, err error) {
618 | req, ok := in.Request.(*smithyhttp.Request)
619 | if !ok {
620 | return out, metadata, &asSigner.SigningError{Err: errors.New("unexpected request middleware type for ignoreHeaders")}
621 | }
622 |
623 | // Store removed headers and their values
624 | ignored := make(map[string]string, len(headers))
625 | for _, h := range headers {
626 | // Use canonical form for map key (e.g., "Accept-Encoding")
627 | // strings.Title is necessary for older Go versions to ensure canonicalization.
628 | canonicalKey := strings.Title(strings.ToLower(h))
629 | ignored[canonicalKey] = req.Header.Get(h)
630 | req.Header.Del(h) // Remove header before signing
631 | }
632 |
633 | // Store the ignored headers in the context
634 | ctx = middleware.WithStackValue(ctx, ignoredHeadersKey{}, ignored)
635 | return next.HandleFinalize(ctx, in)
636 | },
637 | )
638 | }
639 |
640 | // restoreIgnored retrieves headers from context and restores them to the request
641 | // after the signing (Finalize) and before sending.
642 | func restoreIgnored() middleware.FinalizeMiddleware {
643 | return middleware.FinalizeMiddlewareFunc(
644 | "S3CompatRestoreHeaders",
645 | func(ctx context.Context, in middleware.FinalizeInput, next middleware.FinalizeHandler) (out middleware.FinalizeOutput, metadata middleware.Metadata, err error) {
646 | req, ok := in.Request.(*smithyhttp.Request)
647 | if !ok {
648 | return out, metadata, errors.New("unexpected request middleware type for restoreIgnored")
649 | }
650 |
651 | // Execute the next Handler (which includes signing and the actual network request)
652 | out, metadata, err = next.HandleFinalize(ctx, in)
653 |
654 | // Retrieve ignored headers from the context
655 | ignored, _ := middleware.GetStackValue(ctx, ignoredHeadersKey{}).(map[string]string)
656 | // Restore the headers to the request
657 | for k, v := range ignored {
658 | if v != "" {
659 | req.Header.Set(k, v)
660 | }
661 | }
662 | return out, metadata, err
663 | },
664 | )
665 | }
666 |
--------------------------------------------------------------------------------
/cloud/siyuan.go:
--------------------------------------------------------------------------------
1 | // DejaVu - Data snapshot and sync.
2 | // Copyright (c) 2022-present, b3log.org
3 | //
4 | // This program is free software: you can redistribute it and/or modify
5 | // it under the terms of the GNU Affero General Public License as published by
6 | // the Free Software Foundation, either version 3 of the License, or
7 | // (at your option) any later version.
8 | //
9 | // This program is distributed in the hope that it will be useful,
10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | // GNU Affero General Public License for more details.
13 | //
14 | // You should have received a copy of the GNU Affero General Public License
15 | // along with this program. If not, see .
16 |
17 | package cloud
18 |
19 | import (
20 | "bytes"
21 | "context"
22 | "fmt"
23 | "os"
24 | "path"
25 | "path/filepath"
26 | "sort"
27 | "strings"
28 | "sync"
29 | "time"
30 |
31 | "github.com/88250/gulu"
32 | "github.com/qiniu/go-sdk/v7/client"
33 | "github.com/qiniu/go-sdk/v7/storage"
34 | "github.com/siyuan-note/dejavu/entity"
35 | "github.com/siyuan-note/httpclient"
36 | "github.com/siyuan-note/logging"
37 | )
38 |
39 | var clientInit = sync.Once{}
40 |
41 | // SiYuan 描述了思源笔记官方云端存储服务实现。
42 | type SiYuan struct {
43 | *BaseCloud
44 | }
45 |
46 | func NewSiYuan(baseCloud *BaseCloud) *SiYuan {
47 | clientInit.Do(func() {
48 | client.DefaultClient = client.Client{Client: httpclient.GetCloudFileClient2Min()}
49 | storage.DefaultClient = client.DefaultClient
50 | })
51 | return &SiYuan{BaseCloud: baseCloud}
52 | }
53 |
54 | func (siyuan *SiYuan) UploadObject(filePath string, overwrite bool) (length int64, err error) {
55 | absFilePath := filepath.Join(siyuan.Conf.RepoPath, filePath)
56 | info, err := os.Stat(absFilePath)
57 | if nil != err {
58 | logging.LogErrorf("stat failed: %s", err)
59 | return
60 | }
61 | length = info.Size()
62 |
63 | key := path.Join("siyuan", siyuan.Conf.UserID, "repo", siyuan.Conf.Dir, filePath)
64 | keyUploadToken, scopeUploadToken, err := siyuan.requestScopeKeyUploadToken(key, overwrite)
65 | if nil != err {
66 | return
67 | }
68 |
69 | uploadToken := keyUploadToken
70 | if !overwrite {
71 | uploadToken = scopeUploadToken
72 | }
73 |
74 | formUploader := storage.NewFormUploader(&storage.Config{UseHTTPS: true})
75 | ret := storage.PutRet{}
76 | err = formUploader.PutFile(context.Background(), &ret, uploadToken, key, absFilePath, nil)
77 | if nil != err {
78 | if msg := fmt.Sprintf("%s", err); strings.Contains(msg, "file exists") {
79 | err = nil
80 | return
81 | }
82 |
83 | logging.LogErrorf("upload object [%s] failed: %s", key, err)
84 | if e, ok := err.(*client.ErrorInfo); ok {
85 | if 614 == e.Code || strings.Contains(e.Err, "file exists") {
86 | err = nil
87 | return
88 | }
89 | logging.LogErrorf("error detail: %s", e.ErrorDetail())
90 | }
91 |
92 | time.Sleep(1 * time.Second)
93 | err = formUploader.PutFile(context.Background(), &ret, uploadToken, key, absFilePath, nil)
94 | if nil != err {
95 | if msg := fmt.Sprintf("%s", err); strings.Contains(msg, "file exists") {
96 | err = nil
97 | return
98 | }
99 |
100 | logging.LogErrorf("upload object [%s] failed: %s", key, err)
101 | if e, ok := err.(*client.ErrorInfo); ok {
102 | if 614 == e.Code || strings.Contains(e.Err, "file exists") {
103 | err = nil
104 | return
105 | }
106 |
107 | logging.LogErrorf("error detail: %s", e.ErrorDetail())
108 | }
109 | }
110 | return
111 | }
112 |
113 | //logging.LogInfof("uploaded object [%s]", key)
114 | return
115 | }
116 |
117 | func (siyuan *SiYuan) UploadBytes(filePath string, data []byte, overwrite bool) (length int64, err error) {
118 | length = int64(len(data))
119 |
120 | key := path.Join("siyuan", siyuan.Conf.UserID, "repo", siyuan.Conf.Dir, filePath)
121 | keyUploadToken, scopeUploadToken, err := siyuan.requestScopeKeyUploadToken(key, overwrite)
122 | if nil != err {
123 | return
124 | }
125 |
126 | uploadToken := keyUploadToken
127 | if !overwrite {
128 | uploadToken = scopeUploadToken
129 | }
130 |
131 | formUploader := storage.NewFormUploader(&storage.Config{UseHTTPS: true})
132 | ret := storage.PutRet{}
133 | err = formUploader.Put(context.Background(), &ret, uploadToken, key, bytes.NewReader(data), length, &storage.PutExtra{})
134 | if nil != err {
135 | if msg := fmt.Sprintf("%s", err); strings.Contains(msg, "file exists") {
136 | err = nil
137 | return
138 | }
139 |
140 | logging.LogErrorf("upload object [%s] failed: %s", key, err)
141 | if e, ok := err.(*client.ErrorInfo); ok {
142 | if 614 == e.Code || strings.Contains(e.Err, "file exists") {
143 | err = nil
144 | return
145 | }
146 | logging.LogErrorf("error detail: %s", e.ErrorDetail())
147 | }
148 |
149 | time.Sleep(1 * time.Second)
150 | err = formUploader.Put(context.Background(), &ret, uploadToken, key, bytes.NewReader(data), length, &storage.PutExtra{})
151 | if nil != err {
152 | if msg := fmt.Sprintf("%s", err); strings.Contains(msg, "file exists") {
153 | err = nil
154 | return
155 | }
156 |
157 | logging.LogErrorf("upload object [%s] failed: %s", key, err)
158 | if e, ok := err.(*client.ErrorInfo); ok {
159 | if 614 == e.Code || strings.Contains(e.Err, "file exists") {
160 | err = nil
161 | return
162 | }
163 |
164 | logging.LogErrorf("error detail: %s", e.ErrorDetail())
165 | }
166 | }
167 | return
168 | }
169 |
170 | //logging.LogInfof("uploaded object [%s]", key)
171 | return
172 | }
173 |
174 | func (siyuan *SiYuan) DownloadObject(filePath string) (ret []byte, err error) {
175 | key := path.Join("siyuan", siyuan.Conf.UserID, "repo", siyuan.Conf.Dir, filePath)
176 | resp, err := httpclient.NewCloudFileRequest2m().Get(siyuan.Endpoint + key)
177 | if nil != err {
178 | err = fmt.Errorf("download object [%s] failed: %s", key, err)
179 | return
180 | }
181 | if 200 != resp.StatusCode {
182 | if 404 == resp.StatusCode {
183 | err = ErrCloudObjectNotFound
184 | return
185 | }
186 | err = fmt.Errorf("download object [%s] failed [%d]", key, resp.StatusCode)
187 | return
188 | }
189 |
190 | ret, err = resp.ToBytes()
191 | if nil != err {
192 | err = fmt.Errorf("download read data failed: %s", err)
193 | return
194 | }
195 |
196 | //logging.LogInfof("downloaded object [%s]", key)
197 | return
198 | }
199 |
200 | func (siyuan *SiYuan) RemoveObject(filePath string) (err error) {
201 | userId := siyuan.Conf.UserID
202 | dir := siyuan.Conf.Dir
203 | token := siyuan.Conf.Token
204 | server := siyuan.Conf.Server
205 |
206 | key := path.Join("siyuan", userId, "repo", dir, filePath)
207 | result := gulu.Ret.NewResult()
208 | request := httpclient.NewCloudRequest30s()
209 | resp, err := request.
210 | SetSuccessResult(&result).
211 | SetBody(map[string]string{"repo": dir, "token": token, "key": key}).
212 | Post(server + "/apis/siyuan/dejavu/removeRepoObject?uid=" + userId)
213 | if nil != err {
214 | return
215 | }
216 |
217 | if 200 != resp.StatusCode {
218 | if 401 == resp.StatusCode {
219 | err = ErrCloudAuthFailed
220 | return
221 | }
222 | err = fmt.Errorf("remove cloud repo object failed [%d]", resp.StatusCode)
223 | return
224 | }
225 |
226 | if 0 != result.Code {
227 | err = fmt.Errorf("remove cloud repo object failed: %s", result.Msg)
228 | return
229 | }
230 |
231 | //logging.LogInfof("removed object [%s]", key)
232 | return
233 | }
234 |
235 | func (siyuan *SiYuan) ListObjects(pathPrefix string) (objInfos map[string]*entity.ObjectInfo, err error) {
236 | objInfos = map[string]*entity.ObjectInfo{}
237 |
238 | token := siyuan.Conf.Token
239 | dir := siyuan.Conf.Dir
240 | userId := siyuan.Conf.UserID
241 | server := siyuan.Conf.Server
242 |
243 | result := gulu.Ret.NewResult()
244 | request := httpclient.NewCloudRequest30s()
245 | resp, err := request.
246 | SetSuccessResult(&result).
247 | SetBody(map[string]string{"repo": dir, "token": token, "pathPrefix": pathPrefix}).
248 | Post(server + "/apis/siyuan/dejavu/listRepoObjects?uid=" + userId)
249 | if nil != err {
250 | err = fmt.Errorf("list cloud repo objects failed: %s", err)
251 | return
252 | }
253 |
254 | if 200 != resp.StatusCode {
255 | if 401 == resp.StatusCode {
256 | err = ErrCloudAuthFailed
257 | return
258 | }
259 | err = fmt.Errorf("list cloud repo objects failed [%d]", resp.StatusCode)
260 | return
261 | }
262 |
263 | if 0 != result.Code {
264 | err = fmt.Errorf("list cloud repo objects failed: %s", result.Msg)
265 | return
266 | }
267 |
268 | retData := result.Data.(map[string]interface{})
269 | retObjects := retData["objects"].([]interface{})
270 | for _, retObj := range retObjects {
271 | data, marshalErr := gulu.JSON.MarshalJSON(retObj)
272 | if nil != marshalErr {
273 | logging.LogErrorf("marshal obj failed: %s", marshalErr)
274 | continue
275 | }
276 | obj := &entity.ObjectInfo{}
277 | if unmarshalErr := gulu.JSON.UnmarshalJSON(data, obj); nil != unmarshalErr {
278 | logging.LogErrorf("unmarshal obj failed: %s", unmarshalErr)
279 | continue
280 | }
281 | objInfos[obj.Path] = obj
282 | }
283 | return
284 | }
285 |
286 | func (siyuan *SiYuan) GetTags() (tags []*Ref, err error) {
287 | token := siyuan.Conf.Token
288 | dir := siyuan.Conf.Dir
289 | userId := siyuan.Conf.UserID
290 | server := siyuan.Conf.Server
291 |
292 | result := gulu.Ret.NewResult()
293 | request := httpclient.NewCloudRequest30s()
294 | resp, err := request.
295 | SetSuccessResult(&result).
296 | SetBody(map[string]string{"repo": dir, "token": token}).
297 | Post(server + "/apis/siyuan/dejavu/getRepoTags?uid=" + userId)
298 | if nil != err {
299 | err = fmt.Errorf("get cloud repo tags failed: %s", err)
300 | return
301 | }
302 |
303 | if 200 != resp.StatusCode {
304 | if 401 == resp.StatusCode {
305 | err = ErrCloudAuthFailed
306 | return
307 | }
308 | err = fmt.Errorf("get cloud repo tags failed [%d]", resp.StatusCode)
309 | return
310 | }
311 |
312 | if 0 != result.Code {
313 | err = fmt.Errorf("get cloud repo tags failed: %s", result.Msg)
314 | return
315 | }
316 |
317 | retData := result.Data.(map[string]interface{})
318 | retTags := retData["tags"].([]interface{})
319 | for _, retTag := range retTags {
320 | data, marshalErr := gulu.JSON.MarshalJSON(retTag)
321 | if nil != marshalErr {
322 | logging.LogErrorf("marshal tag failed: %s", marshalErr)
323 | continue
324 | }
325 | tag := &Ref{}
326 | if unmarshalErr := gulu.JSON.UnmarshalJSON(data, tag); nil != unmarshalErr {
327 | logging.LogErrorf("unmarshal tag failed: %s", unmarshalErr)
328 | continue
329 | }
330 | tags = append(tags, tag)
331 | }
332 | return
333 | }
334 |
335 | func (siyuan *SiYuan) GetIndexes(page int) (indexes []*entity.Index, pageCount, totalCount int, err error) {
336 | token := siyuan.Conf.Token
337 | dir := siyuan.Conf.Dir
338 | userId := siyuan.Conf.UserID
339 | server := siyuan.Conf.Server
340 |
341 | result := gulu.Ret.NewResult()
342 | request := httpclient.NewCloudRequest30s()
343 | resp, err := request.
344 | SetSuccessResult(&result).
345 | SetBody(map[string]interface{}{"repo": dir, "token": token, "page": page}).
346 | Post(server + "/apis/siyuan/dejavu/getRepoIndexes?uid=" + userId)
347 | if nil != err {
348 | err = fmt.Errorf("get cloud repo indexes failed: %s", err)
349 | return
350 | }
351 |
352 | if 200 != resp.StatusCode {
353 | if 401 == resp.StatusCode {
354 | err = ErrCloudAuthFailed
355 | return
356 | }
357 | err = fmt.Errorf("get cloud repo indexes failed [%d]", resp.StatusCode)
358 | return
359 | }
360 |
361 | if 0 != result.Code {
362 | err = fmt.Errorf("get cloud repo indexes failed: %s", result.Msg)
363 | return
364 | }
365 |
366 | retData := result.Data.(map[string]interface{})
367 | retIndexes := retData["indexes"].([]interface{})
368 | for _, retIndex := range retIndexes {
369 | data, marshalErr := gulu.JSON.MarshalJSON(retIndex)
370 | if nil != marshalErr {
371 | logging.LogErrorf("marshal index failed: %s", marshalErr)
372 | continue
373 | }
374 | index := &entity.Index{}
375 | if unmarshalErr := gulu.JSON.UnmarshalJSON(data, index); nil != unmarshalErr {
376 | logging.LogErrorf("unmarshal index failed: %s", unmarshalErr)
377 | continue
378 | }
379 | indexes = append(indexes, index)
380 | }
381 |
382 | pageCount = int(retData["pageCount"].(float64))
383 | totalCount = int(retData["totalCount"].(float64))
384 | return
385 | }
386 |
387 | func (siyuan *SiYuan) GetRefsFiles() (fileIDs []string, refs []*Ref, err error) {
388 | token := siyuan.Conf.Token
389 | dir := siyuan.Conf.Dir
390 | userId := siyuan.Conf.UserID
391 | server := siyuan.Conf.Server
392 |
393 | result := gulu.Ret.NewResult()
394 | request := httpclient.NewCloudFileRequest2m()
395 | resp, err := request.
396 | SetSuccessResult(&result).
397 | SetBody(map[string]string{"repo": dir, "token": token}).
398 | Post(server + "/apis/siyuan/dejavu/getRepoRefsFiles?uid=" + userId)
399 | if nil != err {
400 | err = fmt.Errorf("get cloud repo refs files failed: %s", err)
401 | return
402 | }
403 |
404 | if 200 != resp.StatusCode {
405 | if 401 == resp.StatusCode {
406 | err = ErrCloudAuthFailed
407 | return
408 | }
409 | err = fmt.Errorf("get cloud repo refs files failed [%d]", resp.StatusCode)
410 | return
411 | }
412 |
413 | if 0 != result.Code {
414 | err = fmt.Errorf("get cloud repo refs files failed: %s", result.Msg)
415 | return
416 | }
417 |
418 | retData := result.Data.(map[string]interface{})
419 | retFiles := retData["files"].([]interface{})
420 | for _, retFile := range retFiles {
421 | fileIDs = append(fileIDs, retFile.(string))
422 | }
423 | retRefs := retData["refs"].([]interface{})
424 | for _, retRef := range retRefs {
425 | data, marshalErr := gulu.JSON.MarshalJSON(retRef)
426 | if nil != marshalErr {
427 | logging.LogErrorf("marshal ref failed: %s", marshalErr)
428 | continue
429 | }
430 | ref := &Ref{}
431 | if unmarshalErr := gulu.JSON.UnmarshalJSON(data, ref); nil != unmarshalErr {
432 | logging.LogErrorf("unmarshal ref failed: %s", unmarshalErr)
433 | continue
434 | }
435 | refs = append(refs, ref)
436 | }
437 | return
438 | }
439 |
440 | func (siyuan *SiYuan) GetChunks(excludeChunkIDs []string) (chunkIDs []string, err error) {
441 | if 1 > len(excludeChunkIDs) {
442 | return
443 | }
444 |
445 | token := siyuan.Conf.Token
446 | dir := siyuan.Conf.Dir
447 | userId := siyuan.Conf.UserID
448 | server := siyuan.Conf.Server
449 |
450 | result := gulu.Ret.NewResult()
451 | request := httpclient.NewCloudFileRequest2m()
452 | resp, err := request.
453 | SetSuccessResult(&result).
454 | SetBody(map[string]interface{}{"repo": dir, "token": token, "chunks": excludeChunkIDs}).
455 | Post(server + "/apis/siyuan/dejavu/getRepoUploadChunks?uid=" + userId)
456 | if nil != err {
457 | return
458 | }
459 |
460 | if 200 != resp.StatusCode {
461 | if 401 == resp.StatusCode {
462 | err = ErrCloudAuthFailed
463 | return
464 | }
465 | err = fmt.Errorf("get cloud repo refs chunks failed [%d]", resp.StatusCode)
466 | return
467 | }
468 |
469 | if 0 != result.Code {
470 | err = fmt.Errorf("get cloud repo refs chunks failed: %s", result.Msg)
471 | return
472 | }
473 |
474 | retData := result.Data.(map[string]interface{})
475 | retChunks := retData["chunks"].([]interface{})
476 | for _, retChunk := range retChunks {
477 | chunkIDs = append(chunkIDs, retChunk.(string))
478 | }
479 | return
480 | }
481 |
482 | func (siyuan *SiYuan) GetStat() (stat *Stat, err error) {
483 | token := siyuan.Conf.Token
484 | dir := siyuan.Conf.Dir
485 | userId := siyuan.Conf.UserID
486 | server := siyuan.Conf.Server
487 |
488 | result := gulu.Ret.NewResult()
489 | request := httpclient.NewCloudRequest30s()
490 | resp, err := request.
491 | SetSuccessResult(&result).
492 | SetBody(map[string]string{"repo": dir, "token": token}).
493 | Post(server + "/apis/siyuan/dejavu/getRepoStat?uid=" + userId)
494 | if nil != err {
495 | err = fmt.Errorf("get cloud repo stat failed: %s", err)
496 | return
497 | }
498 |
499 | if 200 != resp.StatusCode {
500 | if 401 == resp.StatusCode {
501 | err = ErrCloudAuthFailed
502 | return
503 | }
504 | err = fmt.Errorf("get cloud repo stat failed [%d]", resp.StatusCode)
505 | return
506 | }
507 |
508 | if 0 != result.Code {
509 | err = fmt.Errorf("get cloud repo stat failed: %s", result.Msg)
510 | return
511 | }
512 |
513 | data, marshalErr := gulu.JSON.MarshalJSON(result.Data)
514 | if nil != marshalErr {
515 | err = fmt.Errorf("marshal stat failed: %s", marshalErr)
516 | return
517 | }
518 | stat = &Stat{}
519 | if unmarshalErr := gulu.JSON.UnmarshalJSON(data, stat); nil != unmarshalErr {
520 | err = fmt.Errorf("unmarshal stat failed: %s", unmarshalErr)
521 | return
522 | }
523 | return
524 | }
525 |
526 | func (siyuan *SiYuan) AddTraffic(traffic *Traffic) {
527 | if nil == traffic {
528 | return
529 | }
530 |
531 | if 0 == traffic.UploadBytes && 0 == traffic.DownloadBytes && 0 == traffic.APIGet && 0 == traffic.APIPut {
532 | return
533 | }
534 |
535 | token := siyuan.Conf.Token
536 | server := siyuan.Conf.Server
537 |
538 | request := httpclient.NewCloudRequest30s()
539 | resp, err := request.
540 | SetBody(map[string]interface{}{
541 | "token": token,
542 | "uploadBytes": traffic.UploadBytes,
543 | "downloadBytes": traffic.DownloadBytes,
544 | "apiGet": traffic.APIGet,
545 | "apiPut": traffic.APIPut,
546 | }).
547 | Post(server + "/apis/siyuan/dejavu/addTraffic")
548 | if nil != err {
549 | logging.LogErrorf("add traffic failed: %s", err)
550 | return
551 | }
552 |
553 | if 200 != resp.StatusCode {
554 | logging.LogErrorf("add traffic failed: %d", resp.StatusCode)
555 | return
556 | }
557 | return
558 | }
559 |
560 | func (siyuan *SiYuan) RemoveRepo(name string) (err error) {
561 | token := siyuan.Conf.Token
562 | server := siyuan.Conf.Server
563 |
564 | request := httpclient.NewCloudFileRequest2m()
565 | resp, err := request.
566 | SetBody(map[string]string{"name": name, "token": token}).
567 | Post(server + "/apis/siyuan/dejavu/removeRepo")
568 | if nil != err {
569 | err = fmt.Errorf("remove cloud repo failed: %s", err)
570 | return
571 | }
572 |
573 | if 200 != resp.StatusCode {
574 | if 401 == resp.StatusCode {
575 | err = ErrCloudAuthFailed
576 | return
577 | }
578 | err = fmt.Errorf("remove cloud repo failed [%d]", resp.StatusCode)
579 | return
580 | }
581 | return
582 | }
583 |
584 | func (siyuan *SiYuan) CreateRepo(name string) (err error) {
585 | token := siyuan.Conf.Token
586 | server := siyuan.Conf.Server
587 |
588 | result := map[string]interface{}{}
589 | request := httpclient.NewCloudRequest30s()
590 | resp, err := request.
591 | SetSuccessResult(&result).
592 | SetBody(map[string]string{"name": name, "token": token}).
593 | Post(server + "/apis/siyuan/dejavu/createRepo")
594 | if nil != err {
595 | err = fmt.Errorf("create cloud repo failed: %s", err)
596 | return
597 | }
598 |
599 | if 200 != resp.StatusCode {
600 | if 401 == resp.StatusCode {
601 | err = ErrCloudAuthFailed
602 | return
603 | }
604 | err = fmt.Errorf("create cloud repo failed [%d]", resp.StatusCode)
605 | return
606 | }
607 |
608 | code := result["code"].(float64)
609 | if 0 != code {
610 | err = fmt.Errorf("create cloud repo failed: %s", result["msg"])
611 | return
612 | }
613 | return
614 | }
615 |
616 | func (siyuan *SiYuan) GetRepos() (repos []*Repo, size int64, err error) {
617 | token := siyuan.Conf.Token
618 | server := siyuan.Conf.Server
619 | userId := siyuan.Conf.UserID
620 |
621 | result := map[string]interface{}{}
622 | request := httpclient.NewCloudRequest30s()
623 | resp, err := request.
624 | SetBody(map[string]interface{}{"token": token}).
625 | SetSuccessResult(&result).
626 | Post(server + "/apis/siyuan/dejavu/getRepos?uid=" + userId)
627 | if nil != err {
628 | err = fmt.Errorf("get cloud repos failed: %s", err)
629 | return
630 | }
631 |
632 | if 200 != resp.StatusCode {
633 | if 401 == resp.StatusCode {
634 | err = ErrCloudAuthFailed
635 | return
636 | }
637 | err = fmt.Errorf("request cloud repo list failed [%d]", resp.StatusCode)
638 | return
639 | }
640 |
641 | code := result["code"].(float64)
642 | if 0 != code {
643 | err = fmt.Errorf("request cloud repo list failed: %s", result["msg"].(string))
644 | return
645 | }
646 |
647 | retData := result["data"].(map[string]interface{})
648 | retRepos := retData["repos"].([]interface{})
649 | for _, d := range retRepos {
650 | data, marshalErr := gulu.JSON.MarshalJSON(d)
651 | if nil != marshalErr {
652 | logging.LogErrorf("marshal repo failed: %s", marshalErr)
653 | continue
654 | }
655 | repo := &Repo{}
656 | if unmarshalErr := gulu.JSON.UnmarshalJSON(data, repo); nil != unmarshalErr {
657 | logging.LogErrorf("unmarshal repo failed: %s", unmarshalErr)
658 | continue
659 | }
660 |
661 | repos = append(repos, repo)
662 | }
663 | if 1 > len(repos) {
664 | repos = []*Repo{}
665 | }
666 | sort.Slice(repos, func(i, j int) bool { return repos[i].Name < repos[j].Name })
667 | size = int64(retData["size"].(float64))
668 | return
669 | }
670 |
671 | type UploadToken struct {
672 | key, token string
673 | expired int64
674 | }
675 |
676 | var (
677 | keyUploadTokenMap = map[string]*UploadToken{}
678 | scopeUploadTokenMap = map[string]*UploadToken{}
679 | uploadTokenMapLock = &sync.Mutex{}
680 | )
681 |
682 | func (siyuan *SiYuan) requestScopeKeyUploadToken(key string, overwrite bool) (keyToken, scopeToken string, err error) {
683 | userId := siyuan.Conf.UserID
684 | now := time.Now().UnixMilli()
685 | keyPrefix := path.Join("siyuan", userId)
686 |
687 | uploadTokenMapLock.Lock()
688 | cachedKeyToken := keyUploadTokenMap[key]
689 | if nil != cachedKeyToken {
690 | if now < cachedKeyToken.expired {
691 | keyToken = cachedKeyToken.token
692 | } else {
693 | delete(keyUploadTokenMap, key)
694 | }
695 | }
696 | if overwrite && "" != keyToken {
697 | uploadTokenMapLock.Unlock()
698 | return
699 | }
700 |
701 | cachedScopeToken := scopeUploadTokenMap[keyPrefix]
702 | if nil != cachedScopeToken {
703 | if now < cachedScopeToken.expired {
704 | scopeToken = cachedScopeToken.token
705 | } else {
706 | delete(scopeUploadTokenMap, keyPrefix)
707 | }
708 | }
709 | if !overwrite && "" != scopeToken {
710 | uploadTokenMapLock.Unlock()
711 | return
712 | }
713 | uploadTokenMapLock.Unlock()
714 |
715 | token := siyuan.Conf.Token
716 | server := siyuan.Conf.Server
717 | var result map[string]interface{}
718 | req := httpclient.NewCloudRequest30s().SetSuccessResult(&result)
719 | req.SetBody(map[string]interface{}{
720 | "token": token,
721 | "key": key,
722 | "keyPrefix": keyPrefix,
723 | "time": now, // 数据同步加入系统时间校验 https://github.com/siyuan-note/siyuan/issues/7669
724 | })
725 | resp, err := req.Post(server + "/apis/siyuan/dejavu/getRepoScopeKeyUploadToken?uid=" + userId)
726 | if nil != err {
727 | err = fmt.Errorf("request repo upload token failed: %s", err)
728 | return
729 | }
730 |
731 | if 200 != resp.StatusCode {
732 | if 401 == resp.StatusCode {
733 | err = ErrCloudAuthFailed
734 | return
735 | }
736 | err = fmt.Errorf("request repo upload token failed [%d]", resp.StatusCode)
737 | return
738 | }
739 |
740 | code := result["code"].(float64)
741 | if 0 != code {
742 | msg := result["msg"].(string)
743 | err = fmt.Errorf("request repo upload token failed: %s", msg)
744 | switch code {
745 | case 1:
746 | err = ErrSystemTimeIncorrect
747 | case 2:
748 | err = ErrDeprecatedVersion
749 | case -1:
750 | err = ErrCloudCheckFailed
751 | }
752 | return
753 | }
754 |
755 | resultData := result["data"].(map[string]interface{})
756 | keyToken = resultData["keyToken"].(string)
757 | scopeToken = resultData["scopeToken"].(string)
758 | expired := now + 1000*60*60*24 - 60*1000
759 | uploadTokenMapLock.Lock()
760 | keyUploadTokenMap[key] = &UploadToken{
761 | key: key,
762 | token: keyToken,
763 | expired: expired,
764 | }
765 | scopeUploadTokenMap[keyPrefix] = &UploadToken{
766 | key: keyPrefix,
767 | token: scopeToken,
768 | expired: expired,
769 | }
770 | uploadTokenMapLock.Unlock()
771 | return
772 | }
773 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | GNU AFFERO GENERAL PUBLIC LICENSE
2 | Version 3, 19 November 2007
3 |
4 | Copyright (C) 2007 Free Software Foundation, Inc.
5 | Everyone is permitted to copy and distribute verbatim copies
6 | of this license document, but changing it is not allowed.
7 |
8 | Preamble
9 |
10 | The GNU Affero General Public License is a free, copyleft license for
11 | software and other kinds of works, specifically designed to ensure
12 | cooperation with the community in the case of network server software.
13 |
14 | The licenses for most software and other practical works are designed
15 | to take away your freedom to share and change the works. By contrast,
16 | our General Public Licenses are intended to guarantee your freedom to
17 | share and change all versions of a program--to make sure it remains free
18 | software for all its users.
19 |
20 | When we speak of free software, we are referring to freedom, not
21 | price. Our General Public Licenses are designed to make sure that you
22 | have the freedom to distribute copies of free software (and charge for
23 | them if you wish), that you receive source code or can get it if you
24 | want it, that you can change the software or use pieces of it in new
25 | free programs, and that you know you can do these things.
26 |
27 | Developers that use our General Public Licenses protect your rights
28 | with two steps: (1) assert copyright on the software, and (2) offer
29 | you this License which gives you legal permission to copy, distribute
30 | and/or modify the software.
31 |
32 | A secondary benefit of defending all users' freedom is that
33 | improvements made in alternate versions of the program, if they
34 | receive widespread use, become available for other developers to
35 | incorporate. Many developers of free software are heartened and
36 | encouraged by the resulting cooperation. However, in the case of
37 | software used on network servers, this result may fail to come about.
38 | The GNU General Public License permits making a modified version and
39 | letting the public access it on a server without ever releasing its
40 | source code to the public.
41 |
42 | The GNU Affero General Public License is designed specifically to
43 | ensure that, in such cases, the modified source code becomes available
44 | to the community. It requires the operator of a network server to
45 | provide the source code of the modified version running there to the
46 | users of that server. Therefore, public use of a modified version, on
47 | a publicly accessible server, gives the public access to the source
48 | code of the modified version.
49 |
50 | An older license, called the Affero General Public License and
51 | published by Affero, was designed to accomplish similar goals. This is
52 | a different license, not a version of the Affero GPL, but Affero has
53 | released a new version of the Affero GPL which permits relicensing under
54 | this license.
55 |
56 | The precise terms and conditions for copying, distribution and
57 | modification follow.
58 |
59 | TERMS AND CONDITIONS
60 |
61 | 0. Definitions.
62 |
63 | "This License" refers to version 3 of the GNU Affero General Public License.
64 |
65 | "Copyright" also means copyright-like laws that apply to other kinds of
66 | works, such as semiconductor masks.
67 |
68 | "The Program" refers to any copyrightable work licensed under this
69 | License. Each licensee is addressed as "you". "Licensees" and
70 | "recipients" may be individuals or organizations.
71 |
72 | To "modify" a work means to copy from or adapt all or part of the work
73 | in a fashion requiring copyright permission, other than the making of an
74 | exact copy. The resulting work is called a "modified version" of the
75 | earlier work or a work "based on" the earlier work.
76 |
77 | A "covered work" means either the unmodified Program or a work based
78 | on the Program.
79 |
80 | To "propagate" a work means to do anything with it that, without
81 | permission, would make you directly or secondarily liable for
82 | infringement under applicable copyright law, except executing it on a
83 | computer or modifying a private copy. Propagation includes copying,
84 | distribution (with or without modification), making available to the
85 | public, and in some countries other activities as well.
86 |
87 | To "convey" a work means any kind of propagation that enables other
88 | parties to make or receive copies. Mere interaction with a user through
89 | a computer network, with no transfer of a copy, is not conveying.
90 |
91 | An interactive user interface displays "Appropriate Legal Notices"
92 | to the extent that it includes a convenient and prominently visible
93 | feature that (1) displays an appropriate copyright notice, and (2)
94 | tells the user that there is no warranty for the work (except to the
95 | extent that warranties are provided), that licensees may convey the
96 | work under this License, and how to view a copy of this License. If
97 | the interface presents a list of user commands or options, such as a
98 | menu, a prominent item in the list meets this criterion.
99 |
100 | 1. Source Code.
101 |
102 | The "source code" for a work means the preferred form of the work
103 | for making modifications to it. "Object code" means any non-source
104 | form of a work.
105 |
106 | A "Standard Interface" means an interface that either is an official
107 | standard defined by a recognized standards body, or, in the case of
108 | interfaces specified for a particular programming language, one that
109 | is widely used among developers working in that language.
110 |
111 | The "System Libraries" of an executable work include anything, other
112 | than the work as a whole, that (a) is included in the normal form of
113 | packaging a Major Component, but which is not part of that Major
114 | Component, and (b) serves only to enable use of the work with that
115 | Major Component, or to implement a Standard Interface for which an
116 | implementation is available to the public in source code form. A
117 | "Major Component", in this context, means a major essential component
118 | (kernel, window system, and so on) of the specific operating system
119 | (if any) on which the executable work runs, or a compiler used to
120 | produce the work, or an object code interpreter used to run it.
121 |
122 | The "Corresponding Source" for a work in object code form means all
123 | the source code needed to generate, install, and (for an executable
124 | work) run the object code and to modify the work, including scripts to
125 | control those activities. However, it does not include the work's
126 | System Libraries, or general-purpose tools or generally available free
127 | programs which are used unmodified in performing those activities but
128 | which are not part of the work. For example, Corresponding Source
129 | includes interface definition files associated with source files for
130 | the work, and the source code for shared libraries and dynamically
131 | linked subprograms that the work is specifically designed to require,
132 | such as by intimate data communication or control flow between those
133 | subprograms and other parts of the work.
134 |
135 | The Corresponding Source need not include anything that users
136 | can regenerate automatically from other parts of the Corresponding
137 | Source.
138 |
139 | The Corresponding Source for a work in source code form is that
140 | same work.
141 |
142 | 2. Basic Permissions.
143 |
144 | All rights granted under this License are granted for the term of
145 | copyright on the Program, and are irrevocable provided the stated
146 | conditions are met. This License explicitly affirms your unlimited
147 | permission to run the unmodified Program. The output from running a
148 | covered work is covered by this License only if the output, given its
149 | content, constitutes a covered work. This License acknowledges your
150 | rights of fair use or other equivalent, as provided by copyright law.
151 |
152 | You may make, run and propagate covered works that you do not
153 | convey, without conditions so long as your license otherwise remains
154 | in force. You may convey covered works to others for the sole purpose
155 | of having them make modifications exclusively for you, or provide you
156 | with facilities for running those works, provided that you comply with
157 | the terms of this License in conveying all material for which you do
158 | not control copyright. Those thus making or running the covered works
159 | for you must do so exclusively on your behalf, under your direction
160 | and control, on terms that prohibit them from making any copies of
161 | your copyrighted material outside their relationship with you.
162 |
163 | Conveying under any other circumstances is permitted solely under
164 | the conditions stated below. Sublicensing is not allowed; section 10
165 | makes it unnecessary.
166 |
167 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
168 |
169 | No covered work shall be deemed part of an effective technological
170 | measure under any applicable law fulfilling obligations under article
171 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or
172 | similar laws prohibiting or restricting circumvention of such
173 | measures.
174 |
175 | When you convey a covered work, you waive any legal power to forbid
176 | circumvention of technological measures to the extent such circumvention
177 | is effected by exercising rights under this License with respect to
178 | the covered work, and you disclaim any intention to limit operation or
179 | modification of the work as a means of enforcing, against the work's
180 | users, your or third parties' legal rights to forbid circumvention of
181 | technological measures.
182 |
183 | 4. Conveying Verbatim Copies.
184 |
185 | You may convey verbatim copies of the Program's source code as you
186 | receive it, in any medium, provided that you conspicuously and
187 | appropriately publish on each copy an appropriate copyright notice;
188 | keep intact all notices stating that this License and any
189 | non-permissive terms added in accord with section 7 apply to the code;
190 | keep intact all notices of the absence of any warranty; and give all
191 | recipients a copy of this License along with the Program.
192 |
193 | You may charge any price or no price for each copy that you convey,
194 | and you may offer support or warranty protection for a fee.
195 |
196 | 5. Conveying Modified Source Versions.
197 |
198 | You may convey a work based on the Program, or the modifications to
199 | produce it from the Program, in the form of source code under the
200 | terms of section 4, provided that you also meet all of these conditions:
201 |
202 | a) The work must carry prominent notices stating that you modified
203 | it, and giving a relevant date.
204 |
205 | b) The work must carry prominent notices stating that it is
206 | released under this License and any conditions added under section
207 | 7. This requirement modifies the requirement in section 4 to
208 | "keep intact all notices".
209 |
210 | c) You must license the entire work, as a whole, under this
211 | License to anyone who comes into possession of a copy. This
212 | License will therefore apply, along with any applicable section 7
213 | additional terms, to the whole of the work, and all its parts,
214 | regardless of how they are packaged. This License gives no
215 | permission to license the work in any other way, but it does not
216 | invalidate such permission if you have separately received it.
217 |
218 | d) If the work has interactive user interfaces, each must display
219 | Appropriate Legal Notices; however, if the Program has interactive
220 | interfaces that do not display Appropriate Legal Notices, your
221 | work need not make them do so.
222 |
223 | A compilation of a covered work with other separate and independent
224 | works, which are not by their nature extensions of the covered work,
225 | and which are not combined with it such as to form a larger program,
226 | in or on a volume of a storage or distribution medium, is called an
227 | "aggregate" if the compilation and its resulting copyright are not
228 | used to limit the access or legal rights of the compilation's users
229 | beyond what the individual works permit. Inclusion of a covered work
230 | in an aggregate does not cause this License to apply to the other
231 | parts of the aggregate.
232 |
233 | 6. Conveying Non-Source Forms.
234 |
235 | You may convey a covered work in object code form under the terms
236 | of sections 4 and 5, provided that you also convey the
237 | machine-readable Corresponding Source under the terms of this License,
238 | in one of these ways:
239 |
240 | a) Convey the object code in, or embodied in, a physical product
241 | (including a physical distribution medium), accompanied by the
242 | Corresponding Source fixed on a durable physical medium
243 | customarily used for software interchange.
244 |
245 | b) Convey the object code in, or embodied in, a physical product
246 | (including a physical distribution medium), accompanied by a
247 | written offer, valid for at least three years and valid for as
248 | long as you offer spare parts or customer support for that product
249 | model, to give anyone who possesses the object code either (1) a
250 | copy of the Corresponding Source for all the software in the
251 | product that is covered by this License, on a durable physical
252 | medium customarily used for software interchange, for a price no
253 | more than your reasonable cost of physically performing this
254 | conveying of source, or (2) access to copy the
255 | Corresponding Source from a network server at no charge.
256 |
257 | c) Convey individual copies of the object code with a copy of the
258 | written offer to provide the Corresponding Source. This
259 | alternative is allowed only occasionally and noncommercially, and
260 | only if you received the object code with such an offer, in accord
261 | with subsection 6b.
262 |
263 | d) Convey the object code by offering access from a designated
264 | place (gratis or for a charge), and offer equivalent access to the
265 | Corresponding Source in the same way through the same place at no
266 | further charge. You need not require recipients to copy the
267 | Corresponding Source along with the object code. If the place to
268 | copy the object code is a network server, the Corresponding Source
269 | may be on a different server (operated by you or a third party)
270 | that supports equivalent copying facilities, provided you maintain
271 | clear directions next to the object code saying where to find the
272 | Corresponding Source. Regardless of what server hosts the
273 | Corresponding Source, you remain obligated to ensure that it is
274 | available for as long as needed to satisfy these requirements.
275 |
276 | e) Convey the object code using peer-to-peer transmission, provided
277 | you inform other peers where the object code and Corresponding
278 | Source of the work are being offered to the general public at no
279 | charge under subsection 6d.
280 |
281 | A separable portion of the object code, whose source code is excluded
282 | from the Corresponding Source as a System Library, need not be
283 | included in conveying the object code work.
284 |
285 | A "User Product" is either (1) a "consumer product", which means any
286 | tangible personal property which is normally used for personal, family,
287 | or household purposes, or (2) anything designed or sold for incorporation
288 | into a dwelling. In determining whether a product is a consumer product,
289 | doubtful cases shall be resolved in favor of coverage. For a particular
290 | product received by a particular user, "normally used" refers to a
291 | typical or common use of that class of product, regardless of the status
292 | of the particular user or of the way in which the particular user
293 | actually uses, or expects or is expected to use, the product. A product
294 | is a consumer product regardless of whether the product has substantial
295 | commercial, industrial or non-consumer uses, unless such uses represent
296 | the only significant mode of use of the product.
297 |
298 | "Installation Information" for a User Product means any methods,
299 | procedures, authorization keys, or other information required to install
300 | and execute modified versions of a covered work in that User Product from
301 | a modified version of its Corresponding Source. The information must
302 | suffice to ensure that the continued functioning of the modified object
303 | code is in no case prevented or interfered with solely because
304 | modification has been made.
305 |
306 | If you convey an object code work under this section in, or with, or
307 | specifically for use in, a User Product, and the conveying occurs as
308 | part of a transaction in which the right of possession and use of the
309 | User Product is transferred to the recipient in perpetuity or for a
310 | fixed term (regardless of how the transaction is characterized), the
311 | Corresponding Source conveyed under this section must be accompanied
312 | by the Installation Information. But this requirement does not apply
313 | if neither you nor any third party retains the ability to install
314 | modified object code on the User Product (for example, the work has
315 | been installed in ROM).
316 |
317 | The requirement to provide Installation Information does not include a
318 | requirement to continue to provide support service, warranty, or updates
319 | for a work that has been modified or installed by the recipient, or for
320 | the User Product in which it has been modified or installed. Access to a
321 | network may be denied when the modification itself materially and
322 | adversely affects the operation of the network or violates the rules and
323 | protocols for communication across the network.
324 |
325 | Corresponding Source conveyed, and Installation Information provided,
326 | in accord with this section must be in a format that is publicly
327 | documented (and with an implementation available to the public in
328 | source code form), and must require no special password or key for
329 | unpacking, reading or copying.
330 |
331 | 7. Additional Terms.
332 |
333 | "Additional permissions" are terms that supplement the terms of this
334 | License by making exceptions from one or more of its conditions.
335 | Additional permissions that are applicable to the entire Program shall
336 | be treated as though they were included in this License, to the extent
337 | that they are valid under applicable law. If additional permissions
338 | apply only to part of the Program, that part may be used separately
339 | under those permissions, but the entire Program remains governed by
340 | this License without regard to the additional permissions.
341 |
342 | When you convey a copy of a covered work, you may at your option
343 | remove any additional permissions from that copy, or from any part of
344 | it. (Additional permissions may be written to require their own
345 | removal in certain cases when you modify the work.) You may place
346 | additional permissions on material, added by you to a covered work,
347 | for which you have or can give appropriate copyright permission.
348 |
349 | Notwithstanding any other provision of this License, for material you
350 | add to a covered work, you may (if authorized by the copyright holders of
351 | that material) supplement the terms of this License with terms:
352 |
353 | a) Disclaiming warranty or limiting liability differently from the
354 | terms of sections 15 and 16 of this License; or
355 |
356 | b) Requiring preservation of specified reasonable legal notices or
357 | author attributions in that material or in the Appropriate Legal
358 | Notices displayed by works containing it; or
359 |
360 | c) Prohibiting misrepresentation of the origin of that material, or
361 | requiring that modified versions of such material be marked in
362 | reasonable ways as different from the original version; or
363 |
364 | d) Limiting the use for publicity purposes of names of licensors or
365 | authors of the material; or
366 |
367 | e) Declining to grant rights under trademark law for use of some
368 | trade names, trademarks, or service marks; or
369 |
370 | f) Requiring indemnification of licensors and authors of that
371 | material by anyone who conveys the material (or modified versions of
372 | it) with contractual assumptions of liability to the recipient, for
373 | any liability that these contractual assumptions directly impose on
374 | those licensors and authors.
375 |
376 | All other non-permissive additional terms are considered "further
377 | restrictions" within the meaning of section 10. If the Program as you
378 | received it, or any part of it, contains a notice stating that it is
379 | governed by this License along with a term that is a further
380 | restriction, you may remove that term. If a license document contains
381 | a further restriction but permits relicensing or conveying under this
382 | License, you may add to a covered work material governed by the terms
383 | of that license document, provided that the further restriction does
384 | not survive such relicensing or conveying.
385 |
386 | If you add terms to a covered work in accord with this section, you
387 | must place, in the relevant source files, a statement of the
388 | additional terms that apply to those files, or a notice indicating
389 | where to find the applicable terms.
390 |
391 | Additional terms, permissive or non-permissive, may be stated in the
392 | form of a separately written license, or stated as exceptions;
393 | the above requirements apply either way.
394 |
395 | 8. Termination.
396 |
397 | You may not propagate or modify a covered work except as expressly
398 | provided under this License. Any attempt otherwise to propagate or
399 | modify it is void, and will automatically terminate your rights under
400 | this License (including any patent licenses granted under the third
401 | paragraph of section 11).
402 |
403 | However, if you cease all violation of this License, then your
404 | license from a particular copyright holder is reinstated (a)
405 | provisionally, unless and until the copyright holder explicitly and
406 | finally terminates your license, and (b) permanently, if the copyright
407 | holder fails to notify you of the violation by some reasonable means
408 | prior to 60 days after the cessation.
409 |
410 | Moreover, your license from a particular copyright holder is
411 | reinstated permanently if the copyright holder notifies you of the
412 | violation by some reasonable means, this is the first time you have
413 | received notice of violation of this License (for any work) from that
414 | copyright holder, and you cure the violation prior to 30 days after
415 | your receipt of the notice.
416 |
417 | Termination of your rights under this section does not terminate the
418 | licenses of parties who have received copies or rights from you under
419 | this License. If your rights have been terminated and not permanently
420 | reinstated, you do not qualify to receive new licenses for the same
421 | material under section 10.
422 |
423 | 9. Acceptance Not Required for Having Copies.
424 |
425 | You are not required to accept this License in order to receive or
426 | run a copy of the Program. Ancillary propagation of a covered work
427 | occurring solely as a consequence of using peer-to-peer transmission
428 | to receive a copy likewise does not require acceptance. However,
429 | nothing other than this License grants you permission to propagate or
430 | modify any covered work. These actions infringe copyright if you do
431 | not accept this License. Therefore, by modifying or propagating a
432 | covered work, you indicate your acceptance of this License to do so.
433 |
434 | 10. Automatic Licensing of Downstream Recipients.
435 |
436 | Each time you convey a covered work, the recipient automatically
437 | receives a license from the original licensors, to run, modify and
438 | propagate that work, subject to this License. You are not responsible
439 | for enforcing compliance by third parties with this License.
440 |
441 | An "entity transaction" is a transaction transferring control of an
442 | organization, or substantially all assets of one, or subdividing an
443 | organization, or merging organizations. If propagation of a covered
444 | work results from an entity transaction, each party to that
445 | transaction who receives a copy of the work also receives whatever
446 | licenses to the work the party's predecessor in interest had or could
447 | give under the previous paragraph, plus a right to possession of the
448 | Corresponding Source of the work from the predecessor in interest, if
449 | the predecessor has it or can get it with reasonable efforts.
450 |
451 | You may not impose any further restrictions on the exercise of the
452 | rights granted or affirmed under this License. For example, you may
453 | not impose a license fee, royalty, or other charge for exercise of
454 | rights granted under this License, and you may not initiate litigation
455 | (including a cross-claim or counterclaim in a lawsuit) alleging that
456 | any patent claim is infringed by making, using, selling, offering for
457 | sale, or importing the Program or any portion of it.
458 |
459 | 11. Patents.
460 |
461 | A "contributor" is a copyright holder who authorizes use under this
462 | License of the Program or a work on which the Program is based. The
463 | work thus licensed is called the contributor's "contributor version".
464 |
465 | A contributor's "essential patent claims" are all patent claims
466 | owned or controlled by the contributor, whether already acquired or
467 | hereafter acquired, that would be infringed by some manner, permitted
468 | by this License, of making, using, or selling its contributor version,
469 | but do not include claims that would be infringed only as a
470 | consequence of further modification of the contributor version. For
471 | purposes of this definition, "control" includes the right to grant
472 | patent sublicenses in a manner consistent with the requirements of
473 | this License.
474 |
475 | Each contributor grants you a non-exclusive, worldwide, royalty-free
476 | patent license under the contributor's essential patent claims, to
477 | make, use, sell, offer for sale, import and otherwise run, modify and
478 | propagate the contents of its contributor version.
479 |
480 | In the following three paragraphs, a "patent license" is any express
481 | agreement or commitment, however denominated, not to enforce a patent
482 | (such as an express permission to practice a patent or covenant not to
483 | sue for patent infringement). To "grant" such a patent license to a
484 | party means to make such an agreement or commitment not to enforce a
485 | patent against the party.
486 |
487 | If you convey a covered work, knowingly relying on a patent license,
488 | and the Corresponding Source of the work is not available for anyone
489 | to copy, free of charge and under the terms of this License, through a
490 | publicly available network server or other readily accessible means,
491 | then you must either (1) cause the Corresponding Source to be so
492 | available, or (2) arrange to deprive yourself of the benefit of the
493 | patent license for this particular work, or (3) arrange, in a manner
494 | consistent with the requirements of this License, to extend the patent
495 | license to downstream recipients. "Knowingly relying" means you have
496 | actual knowledge that, but for the patent license, your conveying the
497 | covered work in a country, or your recipient's use of the covered work
498 | in a country, would infringe one or more identifiable patents in that
499 | country that you have reason to believe are valid.
500 |
501 | If, pursuant to or in connection with a single transaction or
502 | arrangement, you convey, or propagate by procuring conveyance of, a
503 | covered work, and grant a patent license to some of the parties
504 | receiving the covered work authorizing them to use, propagate, modify
505 | or convey a specific copy of the covered work, then the patent license
506 | you grant is automatically extended to all recipients of the covered
507 | work and works based on it.
508 |
509 | A patent license is "discriminatory" if it does not include within
510 | the scope of its coverage, prohibits the exercise of, or is
511 | conditioned on the non-exercise of one or more of the rights that are
512 | specifically granted under this License. You may not convey a covered
513 | work if you are a party to an arrangement with a third party that is
514 | in the business of distributing software, under which you make payment
515 | to the third party based on the extent of your activity of conveying
516 | the work, and under which the third party grants, to any of the
517 | parties who would receive the covered work from you, a discriminatory
518 | patent license (a) in connection with copies of the covered work
519 | conveyed by you (or copies made from those copies), or (b) primarily
520 | for and in connection with specific products or compilations that
521 | contain the covered work, unless you entered into that arrangement,
522 | or that patent license was granted, prior to 28 March 2007.
523 |
524 | Nothing in this License shall be construed as excluding or limiting
525 | any implied license or other defenses to infringement that may
526 | otherwise be available to you under applicable patent law.
527 |
528 | 12. No Surrender of Others' Freedom.
529 |
530 | If conditions are imposed on you (whether by court order, agreement or
531 | otherwise) that contradict the conditions of this License, they do not
532 | excuse you from the conditions of this License. If you cannot convey a
533 | covered work so as to satisfy simultaneously your obligations under this
534 | License and any other pertinent obligations, then as a consequence you may
535 | not convey it at all. For example, if you agree to terms that obligate you
536 | to collect a royalty for further conveying from those to whom you convey
537 | the Program, the only way you could satisfy both those terms and this
538 | License would be to refrain entirely from conveying the Program.
539 |
540 | 13. Remote Network Interaction; Use with the GNU General Public License.
541 |
542 | Notwithstanding any other provision of this License, if you modify the
543 | Program, your modified version must prominently offer all users
544 | interacting with it remotely through a computer network (if your version
545 | supports such interaction) an opportunity to receive the Corresponding
546 | Source of your version by providing access to the Corresponding Source
547 | from a network server at no charge, through some standard or customary
548 | means of facilitating copying of software. This Corresponding Source
549 | shall include the Corresponding Source for any work covered by version 3
550 | of the GNU General Public License that is incorporated pursuant to the
551 | following paragraph.
552 |
553 | Notwithstanding any other provision of this License, you have
554 | permission to link or combine any covered work with a work licensed
555 | under version 3 of the GNU General Public License into a single
556 | combined work, and to convey the resulting work. The terms of this
557 | License will continue to apply to the part which is the covered work,
558 | but the work with which it is combined will remain governed by version
559 | 3 of the GNU General Public License.
560 |
561 | 14. Revised Versions of this License.
562 |
563 | The Free Software Foundation may publish revised and/or new versions of
564 | the GNU Affero General Public License from time to time. Such new versions
565 | will be similar in spirit to the present version, but may differ in detail to
566 | address new problems or concerns.
567 |
568 | Each version is given a distinguishing version number. If the
569 | Program specifies that a certain numbered version of the GNU Affero General
570 | Public License "or any later version" applies to it, you have the
571 | option of following the terms and conditions either of that numbered
572 | version or of any later version published by the Free Software
573 | Foundation. If the Program does not specify a version number of the
574 | GNU Affero General Public License, you may choose any version ever published
575 | by the Free Software Foundation.
576 |
577 | If the Program specifies that a proxy can decide which future
578 | versions of the GNU Affero General Public License can be used, that proxy's
579 | public statement of acceptance of a version permanently authorizes you
580 | to choose that version for the Program.
581 |
582 | Later license versions may give you additional or different
583 | permissions. However, no additional obligations are imposed on any
584 | author or copyright holder as a result of your choosing to follow a
585 | later version.
586 |
587 | 15. Disclaimer of Warranty.
588 |
589 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
590 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
591 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
592 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
593 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
594 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
595 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
596 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
597 |
598 | 16. Limitation of Liability.
599 |
600 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
601 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
602 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
603 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
604 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
605 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
606 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
607 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
608 | SUCH DAMAGES.
609 |
610 | 17. Interpretation of Sections 15 and 16.
611 |
612 | If the disclaimer of warranty and limitation of liability provided
613 | above cannot be given local legal effect according to their terms,
614 | reviewing courts shall apply local law that most closely approximates
615 | an absolute waiver of all civil liability in connection with the
616 | Program, unless a warranty or assumption of liability accompanies a
617 | copy of the Program in return for a fee.
618 |
619 | END OF TERMS AND CONDITIONS
620 |
621 | How to Apply These Terms to Your New Programs
622 |
623 | If you develop a new program, and you want it to be of the greatest
624 | possible use to the public, the best way to achieve this is to make it
625 | free software which everyone can redistribute and change under these terms.
626 |
627 | To do so, attach the following notices to the program. It is safest
628 | to attach them to the start of each source file to most effectively
629 | state the exclusion of warranty; and each file should have at least
630 | the "copyright" line and a pointer to where the full notice is found.
631 |
632 |
633 | Copyright (C)
634 |
635 | This program is free software: you can redistribute it and/or modify
636 | it under the terms of the GNU Affero General Public License as published by
637 | the Free Software Foundation, either version 3 of the License, or
638 | (at your option) any later version.
639 |
640 | This program is distributed in the hope that it will be useful,
641 | but WITHOUT ANY WARRANTY; without even the implied warranty of
642 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
643 | GNU Affero General Public License for more details.
644 |
645 | You should have received a copy of the GNU Affero General Public License
646 | along with this program. If not, see .
647 |
648 | Also add information on how to contact you by electronic and paper mail.
649 |
650 | If your software can interact with users remotely through a computer
651 | network, you should also make sure that it provides a way for users to
652 | get its source. For example, if your program is a web application, its
653 | interface could display a "Source" link that leads users to an archive
654 | of the code. There are many ways you could offer source, and different
655 | solutions will be better for different programs; see section 13 for the
656 | specific requirements.
657 |
658 | You should also get your employer (if you work as a programmer) or school,
659 | if any, to sign a "copyright disclaimer" for the program, if necessary.
660 | For more information on this, and how to apply and follow the GNU AGPL, see
661 | .
662 |
--------------------------------------------------------------------------------