Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/script-drive-template.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
// My Drive
// @version 1.0.0
// > Here and below is the drive's description
// > It supports `markdown`
// > It will be shown above the configuration form
Expand Down
10 changes: 10 additions & 0 deletions docs/site/extensions/script-drives.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,16 @@ A custom repository returns an array in the style of the GitHub Contents API, wi
]
```

The server script can declare a version in its leading comments:

```js
// Example Cloud
// @version 1.0.0
// Example Cloud REST API adapter.
```

When the repository is refreshed, go-drive downloads each server script to read its display name, description, and version. The management page uses this version to offer an update without uninstalling the script Drive. If the uploader changes, also bump the server script version.

After installation, create the corresponding type on the Drive management page and reload the Drives.

## Development entry points
Expand Down
12 changes: 11 additions & 1 deletion docs/site/zh-CN/extensions/script-drives.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ title: 脚本 Drive 开发与安装
description: 安装第三方脚本 Drive,或使用 JavaScript 开发 go-drive 存储适配器和浏览器直传集成。
lang: zh-CN
translation_key: script-drives
source_hash: 22245c0b56314dd215cb21cc5940e240d8b8297e041d50f846899d9237165854
source_hash: 3a69abcd8810baca58e4dcd5d03bc38f5c411909f4bb68e74ce15ce824d59e99
---

# 脚本 Drive 开发与安装
Expand Down Expand Up @@ -42,6 +42,16 @@ drive-repository-url: https://example.com/my-drives.json
]
```

服务器端脚本可以在开头注释中声明版本号:

```js
// Example Cloud
// @version 1.0.0
// Example Cloud REST API adapter.
```

刷新仓库时,go-drive 会下载每个服务器端脚本,解析显示名、说明和版本号。管理页会根据版本号提供更新按钮,无需先卸载脚本 Drive。如果上传器发生变化,也需要同步提升服务器端脚本版本号。

安装后在 Drive 管理页创建对应类型并重新加载。

## 开发入口
Expand Down
117 changes: 67 additions & 50 deletions drive/script/script_utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ type driveRepositoryListResp struct {

type AvailableDriveScript struct {
Name string `json:"name"`
DisplayName string `json:"displayName,omitempty"`
Description string `json:"description,omitempty"`
Version string `json:"version,omitempty"`
DriveURL string `json:"driveUrl"`
DriveUploaderURL string `json:"driveUploaderUrl,omitempty"`
}
Expand Down Expand Up @@ -55,9 +58,20 @@ func ListAvailableScriptsFromRepository(ctx context.Context, repoURL string) ([]
continue
}
name := strings.TrimSuffix(item.Name, ".js")
content, e := downloadScriptContent(ctx, item.DownloadURL)
if e != nil {
return nil, e
}
meta, e := parseDriveScriptMeta(content, item.Name)
if e != nil {
return nil, e
}
resultItem := AvailableDriveScript{
Name: name,
DriveURL: item.DownloadURL,
Name: name,
DisplayName: meta.DisplayName,
Description: meta.Description,
Version: meta.Version,
DriveURL: item.DownloadURL,
}
if uploaderItem, ok := itemsMap[name+"-uploader.js"]; ok {
resultItem.DriveUploaderURL = uploaderItem.DownloadURL
Expand All @@ -71,6 +85,7 @@ type DriveScript struct {
// Name is the script name without `.js`` suffix
Name string `json:"name"`
DisplayName string `json:"displayName"`
Version string `json:"version,omitempty"`
Description string `json:"description"`
}

Expand Down Expand Up @@ -145,21 +160,31 @@ func InstallDriveScript(ctx context.Context, config common.Config, s AvailableDr
return err.NewBadRequestError("invalid installation request")
}

drivesDir, e := config.GetDir(config.DrivesDir, true)
driveContent, e := downloadScriptContent(ctx, s.DriveURL)
if e != nil {
return e
}
e = downloadFile(ctx, s.DriveURL, filepath.Join(drivesDir, s.Name+".js"))
var uploaderContent []byte
if s.DriveUploaderURL != "" {
uploaderContent, e = downloadScriptContent(ctx, s.DriveUploaderURL)
if e != nil {
return e
}
}

drivesDir, e := config.GetDir(config.DrivesDir, true)
if e != nil {
return e
}
if e = os.WriteFile(filepath.Join(drivesDir, s.Name+".js"), driveContent, 0644); e != nil {
return e
}
if s.DriveUploaderURL != "" {
driveUploadersDir, e := config.GetDir(config.DriveUploadersDir, true)
if e != nil {
return e
}
e = downloadFile(ctx, s.DriveUploaderURL, filepath.Join(driveUploadersDir, s.Name+".js"))
if e != nil {
if e = os.WriteFile(filepath.Join(driveUploadersDir, s.Name+".js"), uploaderContent, 0644); e != nil {
return e
}
}
Expand Down Expand Up @@ -192,23 +217,20 @@ func UninstallDriveScript(config common.Config, name string) error {
return nil
}

func downloadFile(ctx context.Context, url string, name string) error {
func downloadScriptContent(ctx context.Context, url string) ([]byte, error) {
req, e := http.NewRequestWithContext(ctx, "GET", url, nil)
if e != nil {
return e
return nil, e
}
resp, e := http.DefaultClient.Do(req)
if e != nil {
return e
return nil, e
}
defer func() { _ = resp.Body.Close() }()
f, e := os.OpenFile(name, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644)
if e != nil {
return e
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return nil, err.NewRemoteApiError(resp.StatusCode, "failed to download script")
}
defer func() { _ = f.Close() }()
_, e = io.Copy(f, resp.Body)
return e
return io.ReadAll(resp.Body)
}

func ListDriveScripts(config common.Config) ([]DriveScript, error) {
Expand All @@ -234,51 +256,46 @@ func ListDriveScripts(config common.Config) ([]DriveScript, error) {

func readDriveScriptMeta(file string, config common.Config) (DriveScript, error) {
scriptsPath, _ := config.GetDir(config.DrivesDir, false)
scriptFile, e := os.Open(filepath.Join(scriptsPath, file))
content, e := os.ReadFile(filepath.Join(scriptsPath, file))
if e != nil {
return DriveScript{}, e
}
defer func() {
_ = scriptFile.Close()
}()
r := bufio.NewReader(scriptFile)
name := readMetaValue(r, true, file)
description := readMetaValue(r, false, "")
return DriveScript{
Name: strings.TrimSuffix(file, ".js"),
DisplayName: name,
Description: description,
}, nil
return parseDriveScriptMeta(content, file)
}

var metaPrefixRegexp = regexp.MustCompile(`^\s*//\s*`)
var (
metaPrefixRegexp = regexp.MustCompile(`^\s*//\s?`)
versionMetaRegexp = regexp.MustCompile(`(?i)^(?:@version|version)\s*:?\s*(\S+)\s*$`)
)

func readMetaValue(r *bufio.Reader, oneLine bool, def string) string {
sb := strings.Builder{}
for {
line, e := r.ReadBytes('\n')
if e != nil {
func parseDriveScriptMeta(content []byte, file string) (DriveScript, error) {
scanner := bufio.NewScanner(bytes.NewReader(content))
lines := make([]string, 0, 8)
for scanner.Scan() {
line := scanner.Text()
if !strings.HasPrefix(line, "//") {
break
}
if !bytes.HasPrefix(line, []byte("//")) {
break
}

temp := strings.TrimSpace(string(metaPrefixRegexp.ReplaceAll(line, []byte{})))
sb.WriteString(temp)
lines = append(lines, strings.TrimSpace(metaPrefixRegexp.ReplaceAllString(line, "")))
}
if e := scanner.Err(); e != nil {
return DriveScript{}, e
}

if oneLine {
break
}
name := strings.TrimSuffix(filepath.Base(file), ".js")
meta := DriveScript{Name: name, DisplayName: name}
if len(lines) > 0 && lines[0] != "" {
meta.DisplayName = lines[0]
}

if len(bytes.TrimSpace(line)) == 0 {
break
description := make([]string, 0, len(lines))
for _, line := range lines[1:] {
if match := versionMetaRegexp.FindStringSubmatch(line); match != nil {
meta.Version = match[1]
continue
}

sb.WriteRune('\n')
}
if sb.Len() == 0 {
return def
description = append(description, line)
}
return sb.String()
meta.Description = strings.TrimSpace(strings.Join(description, "\n"))
return meta, nil
}
114 changes: 114 additions & 0 deletions drive/script/script_utils_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
package script

import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"reflect"
"testing"

"go-drive/common"
)

func TestParseDriveScriptMeta(t *testing.T) {
meta, e := parseDriveScriptMeta([]byte("// Example Cloud\n// @version 1.2.3\n// Example description.\n//\n// More details.\n\nfunction example() {}\n"), "example.js")
if e != nil {
t.Fatalf("parseDriveScriptMeta() error = %v", e)
}

want := DriveScript{
Name: "example",
DisplayName: "Example Cloud",
Version: "1.2.3",
Description: "Example description.\n\nMore details.",
}
if !reflect.DeepEqual(meta, want) {
t.Fatalf("parseDriveScriptMeta() = %#v, want %#v", meta, want)
}
}

func TestParseDriveScriptMetaKeepsLegacyScriptsCompatible(t *testing.T) {
meta, e := parseDriveScriptMeta([]byte("// Legacy Drive\n// Legacy description\n\nfunction legacy() {}\n"), "legacy.js")
if e != nil {
t.Fatalf("parseDriveScriptMeta() error = %v", e)
}

if meta.Name != "legacy" || meta.DisplayName != "Legacy Drive" ||
meta.Version != "" || meta.Description != "Legacy description" {
t.Fatalf("unexpected legacy metadata: %#v", meta)
}
}

func TestListAvailableScriptsFromRepositoryReadsScriptMetadata(t *testing.T) {
mux := http.NewServeMux()
var server *httptest.Server
mux.HandleFunc("/repo", func(w http.ResponseWriter, _ *http.Request) {
_ = json.NewEncoder(w).Encode([]driveRepositoryListResp{
{Name: "example.js", DownloadURL: server.URL + "/example.js"},
{Name: "example-uploader.js", DownloadURL: server.URL + "/example-uploader.js"},
})
})
mux.HandleFunc("/example.js", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("// Example Cloud\n// @version 2.0.0\n// Remote description\n\nfunction example() {}\n"))
})
mux.HandleFunc("/example-uploader.js", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("function uploader() {}\n"))
})
server = httptest.NewServer(mux)
defer server.Close()

items, e := ListAvailableScriptsFromRepository(context.Background(), server.URL+"/repo")
if e != nil {
t.Fatalf("ListAvailableScriptsFromRepository() error = %v", e)
}

want := []AvailableDriveScript{{
Name: "example",
DisplayName: "Example Cloud",
Description: "Remote description",
Version: "2.0.0",
}}
if len(items) != 1 {
t.Fatalf("got %d available scripts, want 1", len(items))
}
if items[0].Name != want[0].Name || items[0].DisplayName != want[0].DisplayName ||
items[0].Description != want[0].Description || items[0].Version != want[0].Version {
t.Fatalf("metadata = %#v, want %#v", items[0], want[0])
}
}

func TestListDriveScriptsReadsVersion(t *testing.T) {
dataDir := t.TempDir()
config := common.Config{DataDir: dataDir, DrivesDir: "script-drives"}
drivesDir := filepath.Join(dataDir, config.DrivesDir)
if e := os.MkdirAll(drivesDir, 0755); e != nil {
t.Fatal(e)
}
if e := os.WriteFile(filepath.Join(drivesDir, "example.js"), []byte("// Example\n// Version: 3.0.0\n// Description\n\nfunction example() {}\n"), 0644); e != nil {
t.Fatal(e)
}

items, e := ListDriveScripts(config)
if e != nil {
t.Fatalf("ListDriveScripts() error = %v", e)
}
if len(items) != 1 || items[0].Version != "3.0.0" {
t.Fatalf("installed metadata = %#v", items)
}
}

func TestDownloadScriptContentRejectsNonSuccessStatus(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
defer server.Close()

if _, e := downloadScriptContent(context.Background(), server.URL); e == nil {
t.Fatal("downloadScriptContent() error = nil, want error")
}
}
3 changes: 3 additions & 0 deletions script-drives/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ Use a stable, short, lowercase identifier for `<name>`. Files with the same base

```js
// Example Cloud
// @version 1.0.0
// Example Cloud REST API adapter.
//
// Create an API token with file read/write permissions.
Expand All @@ -78,6 +79,7 @@ Use a stable, short, lowercase identifier for `<name>`. Files with the same base
```

- The first line is the display name shown in the UI.
- An optional `// @version 1.0.0` line declares the script version. The main server script version is the version of the extension; bump it when the uploader changes.
- Following `//` lines, up to the empty comment line, are the Markdown description.
- The `reference` directive provides editor completion only; it does not change runtime behavior.
- After saving a script, create or reload the Drive from the administration UI.
Expand Down Expand Up @@ -395,6 +397,7 @@ It demonstrates the interface contract and does not represent a real service:

```js
// Example REST Drive
// @version 1.0.0
// Example of a complete HTTP API based adapter.
//
// Enter the API endpoint and a token with file read/write permissions.
Expand Down
1 change: 1 addition & 0 deletions script-drives/dropbox.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
// Dropbox
// @version 1.0.0
// Dropbox drive

/// <reference path="../docs/scripts/env/drive.d.ts"/>
Expand Down
1 change: 1 addition & 0 deletions script-drives/qiniu.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
// Qiniu
// @version 1.0.0
// Qiniu Kodo

/// <reference path="../docs/scripts/env/drive.d.ts"/>
Expand Down
2 changes: 1 addition & 1 deletion server/api_admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ func InitAdminRoutes(
// region script drives

scriptDriveRoutesGroup := r.Group("/drive-scripts")
sdr := &scriptDrivesRoute{config: config}
sdr := &scriptDrivesRoute{config: config, rootDrive: rootDrive}
// get available drives from repository
scriptDriveRoutesGroup.GET("/available", sdr.getAvailableDrives)
// get installed drives
Expand Down
Loading