diff --git a/common/config.go b/common/config.go index 21d84255..7fb455ad 100644 --- a/common/config.go +++ b/common/config.go @@ -104,6 +104,7 @@ type Config struct { SignatureTTL time.Duration `yaml:"signature-ttl"` WebDav WebDavConfig `yaml:"web-dav"` + WOPI WOPIConfig `yaml:"wopi"` Search SearchConfig `yaml:"search"` @@ -162,6 +163,12 @@ type WebDavConfig struct { MaxCacheItems int `yaml:"max-cache-items"` } +// WOPIConfig configures the WOPI client discovery endpoint. WOPI support is +// disabled when DiscoveryURL is empty. +type WOPIConfig struct { + DiscoveryURL string `yaml:"discovery-url"` +} + type SearchConfig struct { Enabled bool `yaml:"enabled"` Type string `yaml:"type"` diff --git a/common/registry/keys.go b/common/registry/keys.go index 2159a7c3..b3c8337f 100644 --- a/common/registry/keys.go +++ b/common/registry/keys.go @@ -22,6 +22,7 @@ var ( KeyThumbnail = componentKey{k: "thumbnail"} KeySearchService = componentKey{k: "searchService"} KeyTokenStore = componentKey{k: "tokenStore"} + KeyWOPI = componentKey{k: "wopi"} KeyUserDAO = componentKey{k: "userDAO"} KeySessionDAO = componentKey{k: "sessionDAO"} diff --git a/docs/config.yml b/docs/config.yml index 7dfaea56..a9db4bda 100644 --- a/docs/config.yml +++ b/docs/config.yml @@ -206,6 +206,10 @@ auth: # maximum number of files to be cached at the same time, default is 1000 # max-cache-items: 1000 +# WOPI Office document editing. WOPI is disabled when discovery-url is empty. +#wopi: +# discovery-url: "https://office.example.com/hosting/discovery" + # Search configuration search: enabled: false diff --git a/docs/site/.vitepress/config.ts b/docs/site/.vitepress/config.ts index a3c3f36b..15adfa89 100644 --- a/docs/site/.vitepress/config.ts +++ b/docs/site/.vitepress/config.ts @@ -110,6 +110,7 @@ const enSidebar = [ items: [ { text: 'Search', link: '/features/search' }, { text: 'WebDAV Access', link: '/features/webdav' }, + { text: 'Office Editing with WOPI', link: '/features/wopi' }, { text: 'File Buckets', link: '/features/file-buckets' }, { text: 'Site Settings', link: '/features/site-settings' }, { text: 'Custom Themes', link: '/features/custom-themes' }, @@ -171,6 +172,7 @@ const zhSidebar = [ items: [ { text: '搜索与索引', link: '/zh-CN/features/search' }, { text: 'WebDAV 访问', link: '/zh-CN/features/webdav' }, + { text: 'WOPI Office 编辑', link: '/zh-CN/features/wopi' }, { text: '文件桶', link: '/zh-CN/features/file-buckets' }, { text: '站点设置', link: '/zh-CN/features/site-settings' }, { text: '自定义主题', link: '/zh-CN/features/custom-themes' }, diff --git a/docs/site/configuration/index.md b/docs/site/configuration/index.md index 0a906035..30343ada 100644 --- a/docs/site/configuration/index.md +++ b/docs/site/configuration/index.md @@ -63,6 +63,9 @@ auth: # allow-anonymous: false # max-cache-items: 1000 +# wopi: +# discovery-url: https://office.example.com/hosting/discovery + search: enabled: false type: sqlite @@ -137,11 +140,12 @@ auth: Handler types are `image`, `text`, and `shell`. Shell handlers accept `shell`, `mime-type`, `write-content`, `max-size`, `timeout`, and related settings; see [Preview and thumbnails](../features/preview-thumbnail.html). The official Docker configuration enables libvips and ffmpeg. Extract the configuration from the image to get those templates. -## WebDAV, search, and cache +## WebDAV, WOPI, search, and cache - WebDAV is disabled by default. `allow-anonymous` remains subject to path permissions; test anonymous access before public deployment. - The current search engine is `sqlite`; the old `bleve` setting is invalid. - `web-dav.max-cache-items` limits the WebDAV file-object cache. +- WOPI is disabled when `wopi.discovery-url` is empty. The URL must point to the Office service discovery XML. - The global `cache` currently uses an in-memory implementation; `clean-period` controls periodic cleanup. See also: @@ -150,3 +154,4 @@ See also: - [Security guide](./security.html) - [Search and indexing](../features/search.html) - [WebDAV](../features/webdav.html) +- [Office editing with WOPI](../features/wopi.html) diff --git a/docs/site/features/wopi.md b/docs/site/features/wopi.md new file mode 100644 index 00000000..2757696b --- /dev/null +++ b/docs/site/features/wopi.md @@ -0,0 +1,59 @@ +--- +title: Office Editing with WOPI +description: Connect go-drive to a self-hosted Collabora Online service for browser-based Office document viewing and editing. +lang: en +translation_key: wopi +--- + +# Office Editing with WOPI + +go-drive implements the WOPI host endpoints needed to view and edit documents with a discovery-compatible Office service. The initial supported deployment target is self-hosted Collabora Online. + +## Enable WOPI + +Configure the discovery document exposed by the Office service: + +```yaml +wopi: + discovery-url: https://office.example.com/hosting/discovery +``` + +Restart go-drive. The Web UI obtains supported file extensions and `view`/`edit` actions from discovery and displays **Open in Office** for matching files. Missing or empty `discovery-url` disables WOPI. + +The discovery endpoint must be reachable from the go-drive process. Conversely, the Office service must be able to reach every public go-drive domain used to open documents. + +## Reverse proxy and multiple domains + +go-drive creates a WOPISrc from the browser's current `Origin`, so one instance can be opened through more than one domain without a fixed public URL setting. For every public domain: + +- Use HTTPS in production. +- Preserve the original `Host` header when proxying to go-drive. +- Route `api-path/wopi/*` to the same go-drive instance. +- Allow that host or alias in the Collabora `alias_groups` configuration. + +The browser `Origin` must match the request `Host`. A proxy that rewrites `Host` to an internal service name will cause session creation to fail. + +For a subpath deployment, configure `api-path` normally. For example, `api-path: /drive` produces WOPI endpoints below `/drive/wopi/`. + +## Authentication and permissions + +Only signed-in users can open the Office handler. A separate random WOPI token is issued for one user and one file; the normal go-drive login token is not sent to the Office service. Each WOPI callback reloads the user and passes through the normal user/group root, path-permission, and path-metadata wrappers. + +WOPI sessions expire after 10 hours. Tokens and locks are process-local, so restarting go-drive invalidates open editors and requires users to reopen the document. + +## Lock scope and external changes + +WOPI locks coordinate WOPI clients only. They do not block: + +- WebDAV writes; +- normal Web UI uploads or text editing; +- automated jobs; or +- direct changes in an underlying third-party storage service. + +When a WOPI lock is created, go-drive records a version derived from the underlying entry's path, modification time, and size. If those values change before a WOPI save, `PutFile` returns a conflict instead of silently overwriting the external change. This is best-effort: a backend that doesn't report reliable modification times cannot provide complete conflict detection. + +Locks expire after 30 minutes unless refreshed. Sessions and locks are not shared between multiple go-drive processes; use one application instance when WOPI editing is enabled. + +## Compatibility boundary + +The implementation includes CheckFileInfo, GetFile, PutFile, PutRelativeFile, Lock, GetLock, RefreshLock, Unlock, and UnlockAndRelock. It doesn't currently validate Microsoft proof keys. Microsoft 365 for the web also requires Cloud Storage Partner Program onboarding, registered domains, and stricter global conflict handling; use Collabora Online as the supported deployment target for this version. diff --git a/docs/site/zh-CN/configuration/index.md b/docs/site/zh-CN/configuration/index.md index d629d28e..fd59e819 100644 --- a/docs/site/zh-CN/configuration/index.md +++ b/docs/site/zh-CN/configuration/index.md @@ -3,7 +3,7 @@ title: 配置文件参考 description: 查阅 go-drive 的网络、数据库、存储、搜索、WebDAV、缩略图、自动任务和安全配置选项。 lang: zh-CN translation_key: configuration -source_hash: 507e3efd917833d29527a148c1209165a1c9fb98ed3aafbe81cdd0cb6d79af89 +source_hash: e5d11df81b2fa48741c917e58e1f5ea7b4d6df4c221e17c2e3e08408a2dc5474 --- # 配置文件参考 @@ -64,6 +64,9 @@ auth: # allow-anonymous: false # max-cache-items: 1000 +# wopi: +# discovery-url: https://office.example.com/hosting/discovery + search: enabled: false type: sqlite @@ -138,11 +141,12 @@ auth: 处理器类型为 `image`、`text` 或 `shell`。Shell 处理器支持 `shell`、`mime-type`、`write-content`、`max-size` 和 `timeout` 等配置,详见[预览与缩略图](../features/preview-thumbnail.html)。官方 Docker 镜像中的配置会启用 libvips/ffmpeg;从镜像提取配置可以获得对应模板。 -## WebDAV、搜索和缓存 +## WebDAV、WOPI、搜索和缓存 - WebDAV 默认关闭。`allow-anonymous` 仍受路径权限约束;公开启用前务必测试匿名权限。 - 搜索器当前为 `sqlite`,旧的 `bleve` 配置已经无效。 - `web-dav.max-cache-items` 控制 WebDAV 文件对象缓存上限。 +- `wopi.discovery-url` 为空时关闭 WOPI;该 URL 必须指向 Office 服务的 discovery XML。 - 全局 `cache` 当前使用内存实现,`clean-period` 控制定期清理周期。 更多内容: @@ -151,3 +155,4 @@ auth: - [安全指南](./security.html) - [搜索与索引](../features/search.html) - [WebDAV](../features/webdav.html) +- [通过 WOPI 编辑 Office 文档](../features/wopi.html) diff --git a/docs/site/zh-CN/features/wopi.md b/docs/site/zh-CN/features/wopi.md new file mode 100644 index 00000000..325e5a9a --- /dev/null +++ b/docs/site/zh-CN/features/wopi.md @@ -0,0 +1,60 @@ +--- +title: 通过 WOPI 编辑 Office 文档 +description: 将 go-drive 连接到自托管 Collabora Online,在浏览器中查看和编辑 Office 文档。 +lang: zh-CN +translation_key: wopi +source_hash: 344670fca042f98d0a129647b1889e67bbbb9ab09f7be82f5d19a392bf0591da +--- + +# 通过 WOPI 编辑 Office 文档 + +go-drive 实现了查看和编辑文档所需的 WOPI Host 接口,可连接提供标准 discovery 的 Office 服务。当前首个受支持的部署目标是自托管 Collabora Online。 + +## 启用 WOPI + +配置 Office 服务暴露的 discovery 文档: + +```yaml +wopi: + discovery-url: https://office.example.com/hosting/discovery +``` + +重启 go-drive。Web 界面会从 discovery 获取支持的扩展名和 `view`/`edit` action,并为匹配文件显示“使用 Office 打开”。未配置或留空 `discovery-url` 时关闭 WOPI。 + +go-drive 进程必须能访问 discovery 地址;反过来,Office 服务也必须能访问用户打开文档时使用的每个 go-drive 公网域名。 + +## 反向代理和多域名 + +go-drive 根据浏览器当前 `Origin` 生成 WOPISrc,因此同一实例可以通过多个域名访问,不需要固定的公网 URL 配置。每个公网域名都需要: + +- 生产环境使用 HTTPS; +- 反向代理保留原始 `Host` 请求头; +- 将 `api-path/wopi/*` 转发到同一个 go-drive 实例; +- 在 Collabora 的 `alias_groups` 中允许对应 host 或 alias。 + +浏览器 `Origin` 必须与请求 `Host` 一致。如果代理把 `Host` 改写成内部服务名,创建编辑会话会失败。 + +子路径部署仍按正常方式配置 `api-path`。例如 `api-path: /drive` 会把 WOPI 接口放在 `/drive/wopi/` 下。 + +## 身份认证和权限 + +只有已登录用户才能打开 Office Handler。go-drive 会针对单个用户和单个文件签发独立的随机 WOPI token,普通登录 token 不会发送给 Office 服务。每次 WOPI 回调都会重新加载用户,并经过正常的用户/组根路径、路径权限和路径属性包装层。 + +WOPI 会话在 10 小时后过期。token 和锁只保存在当前进程中;重启 go-drive 会使已打开的编辑器失效,用户需要重新打开文档。 + +## 锁范围和外部修改 + +WOPI 锁只协调 WOPI 客户端,不会阻止: + +- WebDAV 写入; +- 普通 Web UI 上传或文本编辑; +- 自动任务; +- 直接修改底层第三方存储中的文件。 + +创建 WOPI 锁时,go-drive 会根据底层条目的路径、修改时间和大小记录一个版本。WOPI 保存前如果这些值发生变化,`PutFile` 会返回冲突,而不是静默覆盖外部修改。这只是尽力检测:如果后端不能提供可靠的修改时间,就无法完整发现冲突。 + +锁在 30 分钟内没有刷新就会过期。多个 go-drive 进程之间不会共享会话和锁;启用 WOPI 编辑时应使用单个应用实例。 + +## 兼容性边界 + +当前实现包括 CheckFileInfo、GetFile、PutFile、PutRelativeFile、Lock、GetLock、RefreshLock、Unlock 和 UnlockAndRelock,暂未校验 Microsoft proof key。Microsoft 365 for the web 还要求加入 Cloud Storage Partner Program、登记域名并提供更严格的全局冲突处理;此版本应使用 Collabora Online 作为受支持的部署目标。 diff --git a/server/api_wopi.go b/server/api_wopi.go new file mode 100644 index 00000000..5aa3618c --- /dev/null +++ b/server/api_wopi.go @@ -0,0 +1,538 @@ +package server + +import ( + "fmt" + "go-drive/common" + "go-drive/common/driveutil" + "go-drive/common/registry" + "go-drive/common/task" + "go-drive/common/types" + "go-drive/common/utils" + "go-drive/drive" + "go-drive/storage" + "mime" + "net/http" + "net/url" + "os" + pathpkg "path" + "strconv" + "strings" + "time" + "unicode/utf8" + + "github.com/gin-gonic/gin" +) + +const ( + headerWOPIOverride = "X-WOPI-Override" + headerWOPILock = "X-WOPI-Lock" + headerWOPIOldLock = "X-WOPI-OldLock" + headerWOPILockFailureReason = "X-WOPI-LockFailureReason" + headerWOPIItemVersion = "X-WOPI-ItemVersion" +) + +type wopiRoute struct { + service *wopiService +} + +type wopiSessionResponse struct { + ActionURL string `json:"actionUrl"` + AccessToken string `json:"accessToken"` + AccessTokenTTL int64 `json:"accessTokenTtl"` + Mode string `json:"mode"` + UserID string `json:"userId"` + OwnerID string `json:"ownerId"` +} + +func InitWOPIRoutes(router gin.IRouter, config common.Config, access *drive.Access, + tokenStore types.TokenStore, userDAO *storage.UserDAO, + ch *registry.ComponentsHolder) error { + service, e := newWOPIService(config, access, userDAO, ch) + if e != nil { + return e + } + route := &wopiRoute{service: service} + + wopi := router.Group("/wopi") + wopi.POST("/session/*path", TokenAuth(tokenStore), route.createSession) + wopi.GET("/files/:id", route.checkFileInfo) + wopi.GET("/files/:id/contents", route.getFile) + wopi.POST("/files/:id/contents", route.putFile) + wopi.POST("/files/:id", route.fileOperation) + return nil +} + +func (r *wopiRoute) createSession(c *gin.Context) { + principal := GetPrincipal(c) + if principal.IsAnonymous() { + c.AbortWithStatusJSON(http.StatusUnauthorized, types.M{"message": "authentication required"}) + return + } + origin, e := validateWOPIOrigin(c.GetHeader("Origin"), c.Request.Host) + if e != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, types.M{"message": e.Error()}) + return + } + path := utils.CleanPath(c.Param("path")) + d, e := r.service.access.GetDrive(principal) + if e != nil { + c.AbortWithStatus(http.StatusInternalServerError) + return + } + entry, e := d.Get(c.Request.Context(), path) + if e != nil || !entry.Type().IsFile() || !entry.Meta().Readable { + c.AbortWithStatus(http.StatusNotFound) + return + } + + discovery, e := r.service.discovery.get(c.Request.Context()) + if e != nil { + c.AbortWithStatusJSON(http.StatusBadGateway, types.M{"message": e.Error()}) + return + } + ext := strings.ToLower(strings.TrimPrefix(pathpkg.Ext(entry.Name()), ".")) + action, ok := discovery.action(ext, entry.Meta().Writable) + if !ok { + c.AbortWithStatusJSON(http.StatusUnsupportedMediaType, types.M{"message": "no WOPI action for this file type"}) + return + } + session, token, e := r.service.createSession( + principal.User.Username, path, origin, canonicalWOPIResourceKey(entry), action.Name == "edit", time.Time{}, + ) + if e != nil { + c.AbortWithStatus(http.StatusInternalServerError) + return + } + actionURL, e := makeWOPIActionURL(action.URLSrc, r.service.fileURL(origin, session.id)) + if e != nil { + r.service.deleteSession(session.id) + c.AbortWithStatusJSON(http.StatusBadGateway, types.M{"message": e.Error()}) + return + } + c.Header("Cache-Control", "no-cache, no-store") + c.Header("Pragma", "no-cache") + c.Header("Expires", "-1") + c.JSON(http.StatusOK, wopiSessionResponse{ + ActionURL: actionURL, + AccessToken: token, + AccessTokenTTL: session.expiresAt.UnixMilli(), + Mode: action.Name, + UserID: principal.User.Username, + OwnerID: principal.User.Username, + }) +} + +func (r *wopiRoute) authenticated(c *gin.Context) (wopiSession, types.IDrive, types.IEntry, bool) { + session, ok := r.service.validateSession(c.Param("id"), c.Query("access_token")) + if !ok { + c.AbortWithStatus(http.StatusUnauthorized) + return wopiSession{}, nil, nil, false + } + principal, e := r.service.principal(session) + if e != nil { + c.AbortWithStatus(http.StatusUnauthorized) + return wopiSession{}, nil, nil, false + } + d, e := r.service.access.GetDrive(principal) + if e != nil { + c.AbortWithStatus(http.StatusInternalServerError) + return wopiSession{}, nil, nil, false + } + entry, e := d.Get(c.Request.Context(), session.path) + if e != nil || !entry.Type().IsFile() || !entry.Meta().Readable { + c.AbortWithStatus(http.StatusNotFound) + return wopiSession{}, nil, nil, false + } + return session, d, entry, true +} + +func (r *wopiRoute) checkFileInfo(c *gin.Context) { + session, _, entry, ok := r.authenticated(c) + if !ok { + return + } + discovery, e := r.service.discovery.get(c.Request.Context()) + if e != nil { + c.AbortWithStatus(http.StatusInternalServerError) + return + } + ext := strings.ToLower(strings.TrimPrefix(pathpkg.Ext(entry.Name()), ".")) + _, editable := discovery.actions[ext]["edit"] + canWrite := session.writable && entry.Meta().Writable && editable + username := session.username + c.Header("Cache-Control", "no-cache, no-store") + c.JSON(http.StatusOK, types.M{ + "BaseFileName": entry.Name(), + "LastModifiedTime": wopiLastModifiedTime(entry), + "OwnerId": username, + "Size": entry.Size(), + "UserId": username, + "UserFriendlyName": username, + "Version": wopiVersion(entry), + "ReadOnly": !canWrite, + "UserCanWrite": canWrite, + "UserCanNotWriteRelative": !canWrite, + "SupportsUpdate": canWrite, + "SupportsLocks": canWrite, + "SupportsGetLock": canWrite, + "SupportsExtendedLockLength": true, + }) +} + +func (r *wopiRoute) getFile(c *gin.Context) { + _, _, entry, ok := r.authenticated(c) + if !ok { + return + } + maxExpected := int64(1<<31 - 1) + if raw := c.GetHeader("X-WOPI-MaxExpectedSize"); raw != "" { + parsed, e := strconv.ParseInt(raw, 10, 64) + if e != nil || parsed < 0 { + c.AbortWithStatus(http.StatusBadRequest) + return + } + maxExpected = parsed + } + if entry.Size() > maxExpected { + c.AbortWithStatus(http.StatusPreconditionFailed) + return + } + reader, e := entry.GetReader(c.Request.Context(), -1, -1) + if e != nil { + c.AbortWithStatus(http.StatusInternalServerError) + return + } + defer func() { _ = reader.Close() }() + contentType := mime.TypeByExtension(pathpkg.Ext(entry.Name())) + if contentType == "" { + contentType = "application/octet-stream" + } + c.Header(headerWOPIItemVersion, wopiVersion(entry)) + c.DataFromReader(http.StatusOK, entry.Size(), contentType, reader, nil) +} + +func (r *wopiRoute) fileOperation(c *gin.Context) { + session, d, entry, ok := r.authenticated(c) + if !ok { + return + } + switch strings.ToUpper(c.GetHeader(headerWOPIOverride)) { + case "LOCK": + if !session.writable || !entry.Meta().Writable { + c.AbortWithStatus(http.StatusNotFound) + return + } + r.lock(c, session, entry) + case "REFRESH_LOCK": + if !session.writable || !entry.Meta().Writable { + c.AbortWithStatus(http.StatusNotFound) + return + } + r.refreshLock(c, session) + case "UNLOCK": + if !session.writable || !entry.Meta().Writable { + c.AbortWithStatus(http.StatusNotFound) + return + } + r.unlock(c, session, entry) + case "GET_LOCK": + r.getLock(c, session) + case "PUT_RELATIVE": + r.putRelativeFile(c, session, d, entry) + default: + c.AbortWithStatus(http.StatusNotImplemented) + } +} + +func validWOPILock(value string) bool { + if value == "" || len(value) > 1024 || !utf8.ValidString(value) { + return false + } + for _, ch := range value { + if ch > 127 { + return false + } + } + return true +} + +func writeWOPILockMismatch(c *gin.Context, current, reason string) { + c.Header(headerWOPILock, current) + if reason != "" { + c.Header(headerWOPILockFailureReason, reason) + } + c.AbortWithStatus(http.StatusConflict) +} + +func (r *wopiRoute) lock(c *gin.Context, session wopiSession, entry types.IEntry) { + value := c.GetHeader(headerWOPILock) + oldValue := c.GetHeader(headerWOPIOldLock) + if !validWOPILock(value) || (oldValue != "" && !validWOPILock(oldValue)) { + c.AbortWithStatus(http.StatusBadRequest) + return + } + r.service.resourceLock.Lock(session.resourceKey) + defer r.service.resourceLock.UnLock(session.resourceKey) + current, exists := r.service.currentLock(session.resourceKey, time.Now()) + if oldValue != "" { + if !exists || current.value != oldValue { + writeWOPILockMismatch(c, current.value, "lock mismatch") + return + } + current.value = value + current.expiresAt = time.Now().Add(wopiLockTTL) + r.service.setLock(session.resourceKey, current) + c.Header(headerWOPIItemVersion, wopiVersion(entry)) + c.Status(http.StatusOK) + return + } + if exists && current.value != value { + writeWOPILockMismatch(c, current.value, "lock mismatch") + return + } + if !exists { + current = wopiLock{value: value, version: wopiVersion(entry)} + } + current.expiresAt = time.Now().Add(wopiLockTTL) + r.service.setLock(session.resourceKey, current) + c.Header(headerWOPIItemVersion, wopiVersion(entry)) + c.Status(http.StatusOK) +} + +func (r *wopiRoute) refreshLock(c *gin.Context, session wopiSession) { + value := c.GetHeader(headerWOPILock) + if !validWOPILock(value) { + c.AbortWithStatus(http.StatusBadRequest) + return + } + r.service.resourceLock.Lock(session.resourceKey) + defer r.service.resourceLock.UnLock(session.resourceKey) + current, exists := r.service.currentLock(session.resourceKey, time.Now()) + if !exists || current.value != value { + writeWOPILockMismatch(c, current.value, "lock mismatch") + return + } + current.expiresAt = time.Now().Add(wopiLockTTL) + r.service.setLock(session.resourceKey, current) + c.Status(http.StatusOK) +} + +func (r *wopiRoute) unlock(c *gin.Context, session wopiSession, entry types.IEntry) { + value := c.GetHeader(headerWOPILock) + if !validWOPILock(value) { + c.AbortWithStatus(http.StatusBadRequest) + return + } + r.service.resourceLock.Lock(session.resourceKey) + defer r.service.resourceLock.UnLock(session.resourceKey) + current, exists := r.service.currentLock(session.resourceKey, time.Now()) + if !exists || current.value != value { + writeWOPILockMismatch(c, current.value, "lock mismatch") + return + } + r.service.deleteLock(session.resourceKey) + c.Header(headerWOPIItemVersion, wopiVersion(entry)) + c.Status(http.StatusOK) +} + +func (r *wopiRoute) getLock(c *gin.Context, session wopiSession) { + r.service.resourceLock.Lock(session.resourceKey) + defer r.service.resourceLock.UnLock(session.resourceKey) + current, _ := r.service.currentLock(session.resourceKey, time.Now()) + c.Header(headerWOPILock, current.value) + c.Status(http.StatusOK) +} + +func (r *wopiRoute) putFile(c *gin.Context) { + session, d, entry, ok := r.authenticated(c) + if !ok { + return + } + if !session.writable || !entry.Meta().Writable { + c.AbortWithStatus(http.StatusNotFound) + return + } + if !strings.EqualFold(c.GetHeader(headerWOPIOverride), "PUT") { + c.AbortWithStatus(http.StatusNotImplemented) + return + } + tempFile, size, e := ReadRequestBodyToTempFile(c, r.service.config.TempDir) + if e != nil { + c.AbortWithStatus(http.StatusBadRequest) + return + } + if !validateWOPIBodySize(c, size) { + _ = tempFile.Close() + _ = os.Remove(tempFile.Name()) + return + } + defer func() { + _ = tempFile.Close() + _ = os.Remove(tempFile.Name()) + }() + + r.service.resourceLock.Lock(session.resourceKey) + defer r.service.resourceLock.UnLock(session.resourceKey) + entry, e = d.Get(c.Request.Context(), session.path) + if e != nil || !entry.Type().IsFile() || !entry.Meta().Readable { + c.AbortWithStatus(http.StatusNotFound) + return + } + if !entry.Meta().Writable { + c.AbortWithStatus(http.StatusNotFound) + return + } + current, locked := r.service.currentLock(session.resourceKey, time.Now()) + requestedLock := c.GetHeader(headerWOPILock) + if !locked { + if entry.Size() != 0 { + writeWOPILockMismatch(c, "", "file is not locked") + return + } + } else if requestedLock == "" || current.value != requestedLock { + writeWOPILockMismatch(c, current.value, "lock mismatch") + return + } else if canonicalWOPIResourceKey(entry) != session.resourceKey || current.version != wopiVersion(entry) { + writeWOPILockMismatch(c, current.value, "file changed outside WOPI") + return + } + + saved, e := d.Save(task.NewContextWrapper(c.Request.Context()), session.path, size, true, tempFile) + if e != nil { + c.AbortWithStatus(http.StatusInternalServerError) + return + } + version := wopiVersion(saved) + if locked { + current.version = version + current.expiresAt = time.Now().Add(wopiLockTTL) + r.service.setLock(session.resourceKey, current) + } + c.Header(headerWOPIItemVersion, version) + c.JSON(http.StatusOK, types.M{"LastModifiedTime": wopiLastModifiedTime(saved)}) +} + +func wopiLastModifiedTime(entry types.IEntry) string { + return time.UnixMilli(entry.ModTime()).UTC().Format(time.RFC3339Nano) +} + +func validateWOPIBodySize(c *gin.Context, size int64) bool { + raw := c.GetHeader("X-WOPI-Size") + if raw == "" { + return true + } + expected, e := strconv.ParseInt(raw, 10, 64) + if e != nil || expected < 0 || expected != size { + c.AbortWithStatus(http.StatusBadRequest) + return false + } + return true +} + +func sanitizeWOPISuggestedName(name string) string { + name = strings.Map(func(ch rune) rune { + if ch < 32 || strings.ContainsRune("/\\\\\x00:*\"<>|", ch) { + return '_' + } + return ch + }, name) + name = strings.Trim(name, " .") + if name == "" { + return "document" + } + return name +} + +func (r *wopiRoute) putRelativeFile(c *gin.Context, session wopiSession, + d types.IDrive, source types.IEntry) { + if !session.writable || !source.Meta().Writable { + c.AbortWithStatus(http.StatusNotFound) + return + } + relativeTarget := c.GetHeader("X-WOPI-RelativeTarget") + suggestedTarget := c.GetHeader("X-WOPI-SuggestedTarget") + if (relativeTarget == "") == (suggestedTarget == "") { + c.AbortWithStatus(http.StatusBadRequest) + return + } + name := relativeTarget + if suggestedTarget != "" { + name = suggestedTarget + if strings.HasPrefix(name, ".") { + base := strings.TrimSuffix(source.Name(), pathpkg.Ext(source.Name())) + name = base + name + } + name = sanitizeWOPISuggestedName(name) + } + targetPath, e := r.service.relativePath(session, name) + if e != nil { + c.Header("X-WOPI-InvalidFileNameError", e.Error()) + c.AbortWithStatus(http.StatusBadRequest) + return + } + if suggestedTarget != "" { + targetPath, e = driveutil.FindNonExistsEntryName(c.Request.Context(), d, targetPath) + if e != nil { + c.AbortWithStatus(http.StatusInternalServerError) + return + } + } + overwrite := strings.EqualFold(c.GetHeader("X-WOPI-OverwriteRelativeTarget"), "true") + if suggestedTarget != "" { + overwrite = false + } + + var targetLockKey string + if existing, getError := d.Get(c.Request.Context(), targetPath); getError == nil { + targetLockKey = canonicalWOPIResourceKey(existing) + r.service.resourceLock.Lock(targetLockKey) + defer r.service.resourceLock.UnLock(targetLockKey) + if current, locked := r.service.currentLock(targetLockKey, time.Now()); locked { + writeWOPILockMismatch(c, current.value, "relative target is locked") + return + } + if !overwrite { + writeWOPILockMismatch(c, "", "relative target exists") + return + } + } + tempFile, size, e := ReadRequestBodyToTempFile(c, r.service.config.TempDir) + if e != nil { + c.AbortWithStatus(http.StatusBadRequest) + return + } + if !validateWOPIBodySize(c, size) { + _ = tempFile.Close() + _ = os.Remove(tempFile.Name()) + return + } + defer func() { + _ = tempFile.Close() + _ = os.Remove(tempFile.Name()) + }() + saved, e := d.Save(task.NewContextWrapper(c.Request.Context()), targetPath, size, overwrite, tempFile) + if e != nil { + if suggestedTarget != "" { + c.AbortWithStatus(http.StatusInternalServerError) + } else { + writeWOPILockMismatch(c, "", "relative target could not be saved") + } + return + } + newSession, token, e := r.service.createSession( + session.username, targetPath, session.origin, canonicalWOPIResourceKey(saved), session.writable, session.expiresAt, + ) + if e != nil { + c.AbortWithStatus(http.StatusInternalServerError) + return + } + wopiSrc := r.service.fileURL(session.origin, newSession.id) + query := url.Values{ + "access_token": []string{token}, + "access_token_ttl": []string{strconv.FormatInt(newSession.expiresAt.UnixMilli(), 10)}, + } + c.JSON(http.StatusOK, types.M{ + "Name": saved.Name(), + "Url": fmt.Sprintf("%s?%s", wopiSrc, query.Encode()), + }) +} diff --git a/server/api_wopi_test.go b/server/api_wopi_test.go new file mode 100644 index 00000000..7c50aac4 --- /dev/null +++ b/server/api_wopi_test.go @@ -0,0 +1,409 @@ +package server + +import ( + "bytes" + "context" + "encoding/json" + "go-drive/common" + err "go-drive/common/errors" + "go-drive/common/types" + "go-drive/common/utils" + "io" + "net/http" + "net/http/httptest" + "net/url" + pathpkg "path" + "strings" + "sync" + "testing" + "time" + + "github.com/gin-gonic/gin" +) + +func TestWOPIFileLifecycleAndExternalConflict(t *testing.T) { + gin.SetMode(gin.TestMode) + d := newWOPIMemoryDrive() + d.set("docs/file.docx", []byte("initial")) + entry, _ := d.Get(context.Background(), "docs/file.docx") + service := &wopiService{ + config: common.Config{APIPath: "/api", TempDir: t.TempDir()}, + access: wopiStaticAccess{drive: d}, + userDAO: wopiStaticUsers{}, + sessions: make(map[string]wopiSession), + locks: make(map[string]wopiLock), + resourceLock: utils.NewKeyLock(4), + discovery: &wopiDiscoveryClient{cache: &wopiDiscovery{ + loaded: time.Now(), + actions: map[string]map[string]wopiDiscoveryAction{ + "docx": { + "view": {Name: "view", URLSrc: "https://office.test/view"}, + "edit": {Name: "edit", URLSrc: "https://office.test/edit"}, + }, + }, + }}, + } + session, token, e := service.createSession( + "alice", "docs/file.docx", "https://drive.test", canonicalWOPIResourceKey(entry), true, time.Time{}, + ) + if e != nil { + t.Fatal(e) + } + + route := &wopiRoute{service: service} + engine := gin.New() + engine.GET("/wopi/files/:id", route.checkFileInfo) + engine.GET("/wopi/files/:id/contents", route.getFile) + engine.POST("/wopi/files/:id", route.fileOperation) + engine.POST("/wopi/files/:id/contents", route.putFile) + baseURL := "/wopi/files/" + session.id + "?access_token=" + url.QueryEscape(token) + + response := serveWOPIRequest(engine, http.MethodGet, baseURL, nil, nil) + if response.Code != http.StatusOK { + t.Fatalf("CheckFileInfo status=%d, body=%s", response.Code, response.Body.String()) + } + var info map[string]any + if e := json.Unmarshal(response.Body.Bytes(), &info); e != nil { + t.Fatal(e) + } + if info["BaseFileName"] != "file.docx" || info["UserCanWrite"] != true || info["SupportsLocks"] != true { + t.Fatalf("unexpected CheckFileInfo: %#v", info) + } + + response = serveWOPIRequest(engine, http.MethodGet, + "/wopi/files/"+session.id+"/contents?access_token="+url.QueryEscape(token), nil, nil) + if response.Code != http.StatusOK || response.Body.String() != "initial" { + t.Fatalf("GetFile status=%d, body=%q", response.Code, response.Body.String()) + } + + response = serveWOPIRequest(engine, http.MethodPost, baseURL, nil, map[string]string{ + headerWOPIOverride: "LOCK", + headerWOPILock: "lock-1", + }) + if response.Code != http.StatusOK { + t.Fatalf("Lock status=%d", response.Code) + } + + putURL := "/wopi/files/" + session.id + "/contents?access_token=" + url.QueryEscape(token) + response = serveWOPIRequest(engine, http.MethodPost, putURL, strings.NewReader("updated"), map[string]string{ + headerWOPIOverride: "PUT", + headerWOPILock: "lock-1", + "X-WOPI-Size": "7", + }) + if response.Code != http.StatusOK || response.Header().Get(headerWOPIItemVersion) == "" { + t.Fatalf("PutFile status=%d, headers=%v", response.Code, response.Header()) + } + var putResult map[string]any + if e := json.Unmarshal(response.Body.Bytes(), &putResult); e != nil { + t.Fatal(e) + } + if putResult["LastModifiedTime"] == "" { + t.Fatalf("PutFile response missing LastModifiedTime: %#v", putResult) + } + if got := string(d.content("docs/file.docx")); got != "updated" { + t.Fatalf("saved content=%q", got) + } + + d.onNextGet(func() { d.set("docs/file.docx", []byte("external")) }) + response = serveWOPIRequest(engine, http.MethodPost, putURL, strings.NewReader("overwrite"), map[string]string{ + headerWOPIOverride: "PUT", + headerWOPILock: "lock-1", + "X-WOPI-Size": "9", + }) + if response.Code != http.StatusConflict || response.Header().Get(headerWOPILock) != "lock-1" || + !strings.Contains(response.Header().Get(headerWOPILockFailureReason), "outside WOPI") { + t.Fatalf("external conflict status=%d, headers=%v", response.Code, response.Header()) + } + if got := string(d.content("docs/file.docx")); got != "external" { + t.Fatalf("external content was overwritten: %q", got) + } + + response = serveWOPIRequest(engine, http.MethodPost, baseURL, strings.NewReader("copy"), map[string]string{ + headerWOPIOverride: "PUT_RELATIVE", + "X-WOPI-SuggestedTarget": ".pdf", + "X-WOPI-Size": "4", + }) + if response.Code != http.StatusOK { + t.Fatalf("PutRelativeFile status=%d, body=%s", response.Code, response.Body.String()) + } + if got := string(d.content("docs/file.pdf")); got != "copy" { + t.Fatalf("relative content=%q", got) + } + var relative map[string]any + if e := json.Unmarshal(response.Body.Bytes(), &relative); e != nil { + t.Fatal(e) + } + if relative["Name"] != "file.pdf" || !strings.Contains(relative["Url"].(string), "access_token=") { + t.Fatalf("unexpected PutRelativeFile response: %#v", relative) + } + + response = serveWOPIRequest(engine, http.MethodPost, baseURL, nil, map[string]string{ + headerWOPIOverride: "UNLOCK", + headerWOPILock: "lock-1", + }) + if response.Code != http.StatusOK { + t.Fatalf("Unlock status=%d", response.Code) + } +} + +func TestWOPIRejectsWrongTokenAndSize(t *testing.T) { + gin.SetMode(gin.TestMode) + d := newWOPIMemoryDrive() + d.set("file.docx", []byte("x")) + entry, _ := d.Get(context.Background(), "file.docx") + service := &wopiService{ + config: common.Config{TempDir: t.TempDir()}, + access: wopiStaticAccess{drive: d}, + userDAO: wopiStaticUsers{}, + sessions: make(map[string]wopiSession), + locks: make(map[string]wopiLock), + resourceLock: utils.NewKeyLock(2), + } + session, token, _ := service.createSession("alice", "file.docx", "https://drive.test", canonicalWOPIResourceKey(entry), true, time.Time{}) + route := &wopiRoute{service: service} + engine := gin.New() + engine.GET("/wopi/files/:id/contents", route.getFile) + + response := serveWOPIRequest(engine, http.MethodGet, + "/wopi/files/"+session.id+"/contents?access_token=wrong", nil, nil) + if response.Code != http.StatusUnauthorized { + t.Fatalf("wrong-token status=%d", response.Code) + } + response = serveWOPIRequest(engine, http.MethodGet, + "/wopi/files/"+session.id+"/contents?access_token="+url.QueryEscape(token), nil, + map[string]string{"X-WOPI-MaxExpectedSize": "0"}) + if response.Code != http.StatusPreconditionFailed { + t.Fatalf("max-size status=%d", response.Code) + } +} + +func TestWOPIReadOnlySessionCannotLockOrWrite(t *testing.T) { + gin.SetMode(gin.TestMode) + d := newWOPIMemoryDrive() + d.set("file.docx", []byte("initial")) + entry, _ := d.Get(context.Background(), "file.docx") + service := &wopiService{ + config: common.Config{TempDir: t.TempDir()}, + access: wopiStaticAccess{drive: d}, + userDAO: wopiStaticUsers{}, + sessions: make(map[string]wopiSession), + locks: make(map[string]wopiLock), + resourceLock: utils.NewKeyLock(2), + } + session, token, _ := service.createSession( + "alice", "file.docx", "https://drive.test", canonicalWOPIResourceKey(entry), false, time.Time{}, + ) + route := &wopiRoute{service: service} + engine := gin.New() + engine.POST("/wopi/files/:id", route.fileOperation) + engine.POST("/wopi/files/:id/contents", route.putFile) + baseURL := "/wopi/files/" + session.id + "?access_token=" + url.QueryEscape(token) + + response := serveWOPIRequest(engine, http.MethodPost, baseURL, nil, map[string]string{ + headerWOPIOverride: "LOCK", + headerWOPILock: "lock-1", + }) + if response.Code != http.StatusNotFound { + t.Fatalf("read-only Lock status=%d", response.Code) + } + response = serveWOPIRequest(engine, http.MethodPost, + "/wopi/files/"+session.id+"/contents?access_token="+url.QueryEscape(token), + strings.NewReader("updated"), nil) + if response.Code != http.StatusNotFound { + t.Fatalf("read-only PutFile status=%d", response.Code) + } +} + +func TestWOPICreateSessionUsesCurrentOrigin(t *testing.T) { + gin.SetMode(gin.TestMode) + d := newWOPIMemoryDrive() + d.set("docs/file.docx", []byte("initial")) + service := &wopiService{ + config: common.Config{APIPath: "/api"}, + access: wopiStaticAccess{drive: d}, + userDAO: wopiStaticUsers{}, + sessions: make(map[string]wopiSession), + locks: make(map[string]wopiLock), + resourceLock: utils.NewKeyLock(2), + discovery: &wopiDiscoveryClient{cache: &wopiDiscovery{ + loaded: time.Now(), + actions: map[string]map[string]wopiDiscoveryAction{ + "docx": {"edit": {Name: "edit", URLSrc: "https://office.test/edit?WOPISrc=WOPI_SOURCE"}}, + }, + }}, + } + route := &wopiRoute{service: service} + engine := gin.New() + engine.POST("/wopi/session/*path", func(c *gin.Context) { + SetPrincipal(c, types.Principal{User: types.User{Username: "alice"}, AuthType: types.AuthTypeToken}) + c.Next() + }, route.createSession) + + for _, host := range []string{"a.example", "b.example"} { + req := httptest.NewRequest(http.MethodPost, "/wopi/session/docs/file.docx", nil) + req.Host = host + req.Header.Set("Origin", "https://"+host) + response := httptest.NewRecorder() + engine.ServeHTTP(response, req) + if response.Code != http.StatusOK { + t.Fatalf("host %s: status=%d, body=%s", host, response.Code, response.Body.String()) + } + var result wopiSessionResponse + if e := json.Unmarshal(response.Body.Bytes(), &result); e != nil { + t.Fatal(e) + } + actionURL, e := url.Parse(result.ActionURL) + if e != nil { + t.Fatal(e) + } + wopiSrc := actionURL.Query().Get("WOPISrc") + if !strings.HasPrefix(wopiSrc, "https://"+host+"/api/wopi/files/") { + t.Fatalf("host %s: WOPISrc=%q", host, wopiSrc) + } + if strings.Contains(result.ActionURL, result.AccessToken) { + t.Fatal("access token leaked into the action URL") + } + } + + req := httptest.NewRequest(http.MethodPost, "/wopi/session/docs/file.docx", nil) + req.Host = "a.example" + req.Header.Set("Origin", "https://evil.example") + response := httptest.NewRecorder() + engine.ServeHTTP(response, req) + if response.Code != http.StatusBadRequest { + t.Fatalf("mismatched origin status=%d", response.Code) + } +} + +func serveWOPIRequest(handler http.Handler, method, target string, body io.Reader, + headers map[string]string) *httptest.ResponseRecorder { + req := httptest.NewRequest(method, target, body) + for key, value := range headers { + req.Header.Set(key, value) + } + response := httptest.NewRecorder() + handler.ServeHTTP(response, req) + return response +} + +type wopiStaticAccess struct{ drive types.IDrive } + +func (a wopiStaticAccess) GetDrive(types.Principal) (types.IDrive, error) { return a.drive, nil } + +type wopiStaticUsers struct{} + +func (wopiStaticUsers) GetUser(username string) (types.User, error) { + return types.User{Username: username}, nil +} + +type wopiMemoryDrive struct { + mu sync.Mutex + files map[string][]byte + version int64 + getHook func() +} + +func newWOPIMemoryDrive() *wopiMemoryDrive { + return &wopiMemoryDrive{files: make(map[string][]byte)} +} + +func (d *wopiMemoryDrive) set(path string, content []byte) { + d.mu.Lock() + d.version++ + d.files[path] = bytes.Clone(content) + d.mu.Unlock() +} + +func (d *wopiMemoryDrive) content(path string) []byte { + d.mu.Lock() + defer d.mu.Unlock() + return bytes.Clone(d.files[path]) +} + +func (d *wopiMemoryDrive) onNextGet(hook func()) { + d.mu.Lock() + d.getHook = hook + d.mu.Unlock() +} + +func (d *wopiMemoryDrive) Meta(context.Context) (types.DriveMeta, error) { + return types.DriveMeta{Writable: true}, nil +} + +func (d *wopiMemoryDrive) Get(_ context.Context, path string) (types.IEntry, error) { + d.mu.Lock() + content, ok := d.files[path] + if !ok { + d.mu.Unlock() + return nil, err.NewNotFoundError() + } + entry := &wopiMemoryEntry{drive: d, path: path, content: bytes.Clone(content), modTime: d.version} + hook := d.getHook + d.getHook = nil + d.mu.Unlock() + if hook != nil { + hook() + } + return entry, nil +} + +func (d *wopiMemoryDrive) Save(_ types.TaskCtx, path string, size int64, override bool, + reader io.Reader) (types.IEntry, error) { + content, e := io.ReadAll(reader) + if e != nil { + return nil, e + } + if int64(len(content)) != size { + return nil, err.NewBadRequestError("size mismatch") + } + d.mu.Lock() + if _, exists := d.files[path]; exists && !override { + d.mu.Unlock() + return nil, err.NewNotAllowedError() + } + d.version++ + d.files[path] = bytes.Clone(content) + version := d.version + d.mu.Unlock() + return &wopiMemoryEntry{drive: d, path: path, content: bytes.Clone(content), modTime: version}, nil +} + +func (d *wopiMemoryDrive) MakeDir(context.Context, string) (types.IEntry, error) { + panic("not used") +} +func (d *wopiMemoryDrive) Copy(types.TaskCtx, types.IEntry, string, bool) (types.IEntry, error) { + panic("not used") +} +func (d *wopiMemoryDrive) Move(types.TaskCtx, types.IEntry, string, bool) (types.IEntry, error) { + panic("not used") +} +func (d *wopiMemoryDrive) List(context.Context, string) ([]types.IEntry, error) { + panic("not used") +} +func (d *wopiMemoryDrive) Delete(types.TaskCtx, string) error { panic("not used") } +func (d *wopiMemoryDrive) Upload(context.Context, string, int64, bool, types.SM) (*types.DriveUploadConfig, error) { + panic("not used") +} + +type wopiMemoryEntry struct { + drive *wopiMemoryDrive + path string + content []byte + modTime int64 +} + +func (e *wopiMemoryEntry) Path() string { return e.path } +func (e *wopiMemoryEntry) Name() string { return pathpkg.Base(e.path) } +func (e *wopiMemoryEntry) Type() types.EntryType { return types.TypeFile } +func (e *wopiMemoryEntry) Size() int64 { return int64(len(e.content)) } +func (e *wopiMemoryEntry) Meta() types.EntryMeta { + return types.EntryMeta{Readable: true, Writable: true} +} +func (e *wopiMemoryEntry) ModTime() int64 { return e.modTime } +func (e *wopiMemoryEntry) Drive() types.IDrive { return e.drive } +func (e *wopiMemoryEntry) GetReader(context.Context, int64, int64) (io.ReadCloser, error) { + return io.NopCloser(bytes.NewReader(e.content)), nil +} +func (e *wopiMemoryEntry) GetURL(context.Context) (*types.ContentURL, error) { + return nil, err.NewUnsupportedError() +} diff --git a/server/server.go b/server/server.go index 7f186074..b7bb5930 100644 --- a/server/server.go +++ b/server/server.go @@ -18,8 +18,10 @@ import ( "go-drive/storage" "io/fs" "net/http" + "net/url" "os" "runtime" + "strings" "time" "github.com/gin-gonic/gin" @@ -105,6 +107,12 @@ func InitServer(config common.Config, return nil, e } + if config.WOPI.DiscoveryURL != "" { + if e := InitWOPIRoutes(router, config, driveAccess, tokenStore, userDAO, ch); e != nil { + return nil, e + } + } + if config.WebDav.Enabled { if e := InitWebdavAccess(engine, config, driveAccess, userAuth); e != nil { return nil, e @@ -170,7 +178,26 @@ func writeJSON(c *gin.Context, ms i18n.MessageSource, code int, v any) { } func Logger() gin.HandlerFunc { - logger := gin.Logger() + logger := gin.LoggerWithFormatter(func(param gin.LogFormatterParams) string { + var statusColor, methodColor, resetColor string + if param.IsOutputColor() { + statusColor = param.StatusCodeColor() + methodColor = param.MethodColor() + resetColor = param.ResetColor() + } + if param.Latency > time.Minute { + param.Latency = param.Latency.Truncate(time.Second) + } + return fmt.Sprintf("[GIN] %v |%s %3d %s| %13v | %15s |%s %-7s %s %#v\n%s", + param.TimeStamp.Format("2006/01/02 - 15:04:05"), + statusColor, param.StatusCode, resetColor, + param.Latency, + param.ClientIP, + methodColor, param.Method, resetColor, + redactWOPIAccessToken(param.Path), + param.ErrorMessage, + ) + }) return func(c *gin.Context) { if c.FullPath() == "" { // NoRoute static files @@ -181,6 +208,22 @@ func Logger() gin.HandlerFunc { } } +func redactWOPIAccessToken(requestPath string) string { + i := strings.IndexByte(requestPath, '?') + if i < 0 { + return requestPath + } + query, e := url.ParseQuery(requestPath[i+1:]) + if e != nil { + return requestPath[:i] + } + if _, exists := query["access_token"]; !exists { + return requestPath + } + query.Set("access_token", "[REDACTED]") + return requestPath[:i] + "?" + query.Encode() +} + type runtimeStat struct { } diff --git a/server/wopi_discovery.go b/server/wopi_discovery.go new file mode 100644 index 00000000..ff3e81ff --- /dev/null +++ b/server/wopi_discovery.go @@ -0,0 +1,196 @@ +package server + +import ( + "context" + "encoding/xml" + "fmt" + "go-drive/common/types" + "io" + "net/http" + "net/url" + "regexp" + "strings" + "sync" + "time" +) + +const ( + wopiDiscoveryMaxSize = 2 << 20 + wopiDiscoveryTTL = 12 * time.Hour +) + +var wopiActionPlaceholderPattern = regexp.MustCompile(`<[^>]*>`) + +type wopiDiscoveryDocument struct { + NetZones []wopiDiscoveryNetZone `xml:"net-zone"` +} + +type wopiDiscoveryNetZone struct { + Apps []wopiDiscoveryApp `xml:"app"` +} + +type wopiDiscoveryApp struct { + Name string `xml:"name,attr"` + FaviconURL string `xml:"favIconUrl,attr"` + Actions []wopiDiscoveryAction `xml:"action"` +} + +type wopiDiscoveryAction struct { + Name string `xml:"name,attr"` + Ext string `xml:"ext,attr"` + URLSrc string `xml:"urlsrc,attr"` + Requires string `xml:"requires,attr"` + Favicon string `xml:"-"` +} + +type wopiDiscovery struct { + actions map[string]map[string]wopiDiscoveryAction + loaded time.Time +} + +type wopiDiscoveryClient struct { + url string + client *http.Client + + mu sync.Mutex + cache *wopiDiscovery +} + +func newWOPIDiscoveryClient(discoveryURL string) (*wopiDiscoveryClient, error) { + u, e := url.Parse(discoveryURL) + if e != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || u.User != nil { + return nil, fmt.Errorf("invalid WOPI discovery URL %q", discoveryURL) + } + c := &wopiDiscoveryClient{ + url: discoveryURL, + client: &http.Client{ + Timeout: 10 * time.Second, + CheckRedirect: func(_ *http.Request, via []*http.Request) error { + if len(via) >= 3 { + return fmt.Errorf("too many WOPI discovery redirects") + } + return nil + }, + }, + } + if _, e := c.get(context.Background()); e != nil { + return nil, e + } + return c, nil +} + +func (c *wopiDiscoveryClient) get(ctx context.Context) (*wopiDiscovery, error) { + c.mu.Lock() + defer c.mu.Unlock() + if c.cache != nil && time.Since(c.cache.loaded) < wopiDiscoveryTTL { + return c.cache, nil + } + + req, e := http.NewRequestWithContext(ctx, http.MethodGet, c.url, nil) + if e != nil { + return nil, e + } + resp, e := c.client.Do(req) + if e != nil { + return nil, fmt.Errorf("load WOPI discovery: %w", e) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("load WOPI discovery: unexpected status %s", resp.Status) + } + + limited := io.LimitReader(resp.Body, wopiDiscoveryMaxSize+1) + data, e := io.ReadAll(limited) + if e != nil { + return nil, fmt.Errorf("read WOPI discovery: %w", e) + } + if len(data) > wopiDiscoveryMaxSize { + return nil, fmt.Errorf("WOPI discovery exceeds %d bytes", wopiDiscoveryMaxSize) + } + parsed, e := parseWOPIDiscovery(data) + if e != nil { + return nil, e + } + c.cache = parsed + return parsed, nil +} + +func parseWOPIDiscovery(data []byte) (*wopiDiscovery, error) { + var document wopiDiscoveryDocument + if e := xml.Unmarshal(data, &document); e != nil { + return nil, fmt.Errorf("parse WOPI discovery: %w", e) + } + actions := make(map[string]map[string]wopiDiscoveryAction) + for _, zone := range document.NetZones { + for _, app := range zone.Apps { + for _, action := range app.Actions { + ext := strings.ToLower(strings.TrimPrefix(strings.TrimSpace(action.Ext), ".")) + name := strings.ToLower(strings.TrimSpace(action.Name)) + if ext == "" || (name != "view" && name != "edit") || action.URLSrc == "" || + !wopiRequirementsSupported(action.Requires) { + continue + } + if _, ok := actions[ext]; !ok { + actions[ext] = make(map[string]wopiDiscoveryAction) + } + if _, exists := actions[ext][name]; exists { + continue + } + action.Ext = ext + action.Name = name + action.Favicon = app.FaviconURL + actions[ext][name] = action + } + } + } + if len(actions) == 0 { + return nil, fmt.Errorf("WOPI discovery contains no view or edit actions") + } + return &wopiDiscovery{actions: actions, loaded: time.Now()}, nil +} + +func wopiRequirementsSupported(raw string) bool { + for _, requirement := range strings.FieldsFunc(strings.ToLower(raw), func(ch rune) bool { + return ch == ',' || ch == ' ' || ch == ';' + }) { + if requirement != "locks" && requirement != "update" { + return false + } + } + return true +} + +func (d *wopiDiscovery) action(ext string, writable bool) (wopiDiscoveryAction, bool) { + ext = strings.ToLower(strings.TrimPrefix(ext, ".")) + byName := d.actions[ext] + if writable { + if action, ok := byName["edit"]; ok { + return action, true + } + } + action, ok := byName["view"] + return action, ok +} + +func (d *wopiDiscovery) sysConfig() types.M { + extensions := make(types.M, len(d.actions)) + for ext, actions := range d.actions { + extensions[ext] = types.M{ + "view": actions["view"].URLSrc != "", + "edit": actions["edit"].URLSrc != "", + } + } + return types.M{"enabled": true, "extensions": extensions} +} + +func makeWOPIActionURL(urlSrc, wopiSrc string) (string, error) { + cleaned := wopiActionPlaceholderPattern.ReplaceAllString(urlSrc, "") + u, e := url.Parse(cleaned) + if e != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { + return "", fmt.Errorf("invalid WOPI action URL") + } + query := u.Query() + query.Set("WOPISrc", wopiSrc) + u.RawQuery = query.Encode() + return u.String(), nil +} diff --git a/server/wopi_discovery_test.go b/server/wopi_discovery_test.go new file mode 100644 index 00000000..9520e172 --- /dev/null +++ b/server/wopi_discovery_test.go @@ -0,0 +1,82 @@ +package server + +import ( + "go-drive/common/types" + "net/url" + "strings" + "testing" +) + +func TestParseWOPIDiscoveryAndBuildActionURL(t *testing.T) { + discoveryXML := []byte(` + + + + + + + +`) + + discovery, e := parseWOPIDiscovery(discoveryXML) + if e != nil { + t.Fatal(e) + } + action, ok := discovery.action(".DOCX", true) + if !ok || action.Name != "edit" || action.Favicon != "https://office.test/favicon.ico" { + t.Fatalf("unexpected action: %#v, ok=%v", action, ok) + } + wopiSrc := "https://drive.example/api/wopi/files/file-id" + actionURL, e := makeWOPIActionURL(action.URLSrc, wopiSrc) + if e != nil { + t.Fatal(e) + } + parsed, e := url.Parse(actionURL) + if e != nil { + t.Fatal(e) + } + if got := parsed.Query().Get("WOPISrc"); got != wopiSrc { + t.Fatalf("WOPISrc=%q, want %q", got, wopiSrc) + } + if got := parsed.Query().Get("foo"); got != "bar" { + t.Fatalf("foo=%q, want bar", got) + } + if strings.Contains(actionURL, "UI_LLCC") || strings.Contains(actionURL, "WOPI_SOURCE") { + t.Fatalf("placeholders were not removed: %s", actionURL) + } + + config := discovery.sysConfig() + extensions := config["extensions"].(types.M) + docx := extensions["docx"].(types.M) + if docx["view"] != true || docx["edit"] != true { + t.Fatalf("unexpected config: %#v", config) + } +} + +func TestParseWOPIDiscoveryRejectsEmptyActions(t *testing.T) { + if _, e := parseWOPIDiscovery([]byte(``)); e == nil { + t.Fatal("expected an error") + } +} + +func TestParseWOPIDiscoverySkipsUnsupportedRequirements(t *testing.T) { + discovery, e := parseWOPIDiscovery([]byte(`` + + `` + + `` + + ``)) + if e != nil { + t.Fatal(e) + } + if _, ok := discovery.actions["docx"]["edit"]; ok { + t.Fatal("action with unsupported requirements was retained") + } + if action, ok := discovery.action("docx", true); !ok || action.Name != "view" { + t.Fatalf("view fallback missing: %#v, ok=%v", action, ok) + } +} + +func TestMakeWOPIActionURLRejectsNonHTTPURL(t *testing.T) { + if _, e := makeWOPIActionURL("javascript:alert(1)", "https://drive.example/wopi/files/id"); e == nil { + t.Fatal("expected an error") + } +} diff --git a/server/wopi_service.go b/server/wopi_service.go new file mode 100644 index 00000000..5e31d206 --- /dev/null +++ b/server/wopi_service.go @@ -0,0 +1,268 @@ +package server + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "encoding/hex" + "fmt" + "go-drive/common" + "go-drive/common/driveutil" + "go-drive/common/registry" + "go-drive/common/types" + "go-drive/common/utils" + "net/url" + pathpkg "path" + "strings" + "sync" + "time" + + "github.com/google/uuid" +) + +const ( + wopiSessionTTL = 10 * time.Hour + wopiLockTTL = 30 * time.Minute +) + +type wopiSession struct { + id string + tokenHash [sha256.Size]byte + username string + path string + origin string + resourceKey string + writable bool + expiresAt time.Time +} + +type wopiLock struct { + value string + version string + expiresAt time.Time +} + +type wopiService struct { + config common.Config + access wopiDriveAccess + userDAO wopiUserStore + discovery *wopiDiscoveryClient + + sessionsMu sync.RWMutex + sessions map[string]wopiSession + + locksMu sync.Mutex + locks map[string]wopiLock + resourceLock *utils.KeyLock + + stopCleaner func() +} + +type wopiDriveAccess interface { + GetDrive(types.Principal) (types.IDrive, error) +} + +type wopiUserStore interface { + GetUser(string) (types.User, error) +} + +func newWOPIService(config common.Config, access wopiDriveAccess, userDAO wopiUserStore, + ch *registry.ComponentsHolder) (*wopiService, error) { + discovery, e := newWOPIDiscoveryClient(config.WOPI.DiscoveryURL) + if e != nil { + return nil, e + } + s := &wopiService{ + config: config, + access: access, + userDAO: userDAO, + discovery: discovery, + sessions: make(map[string]wopiSession), + locks: make(map[string]wopiLock), + resourceLock: utils.NewKeyLock(32), + } + s.stopCleaner = utils.TimeTick(s.clean, time.Minute) + ch.Add(registry.KeyWOPI, s) + return s, nil +} + +func (s *wopiService) Dispose() error { + s.stopCleaner() + s.sessionsMu.Lock() + clear(s.sessions) + s.sessionsMu.Unlock() + s.locksMu.Lock() + clear(s.locks) + s.locksMu.Unlock() + return nil +} + +func (s *wopiService) SysConfig() (string, types.M, error) { + discovery, e := s.discovery.get(context.Background()) + if e != nil { + return "", nil, e + } + return "wopi", discovery.sysConfig(), nil +} + +func newWOPIToken() (string, [sha256.Size]byte, error) { + raw := make([]byte, 32) + if _, e := rand.Read(raw); e != nil { + return "", [sha256.Size]byte{}, e + } + token := base64.RawURLEncoding.EncodeToString(raw) + return token, sha256.Sum256([]byte(token)), nil +} + +func (s *wopiService) createSession(username, path, origin, resourceKey string, + writable bool, notAfter time.Time) (wopiSession, string, error) { + if notAfter.IsZero() || time.Until(notAfter) > wopiSessionTTL { + notAfter = time.Now().Add(wopiSessionTTL) + } + token, tokenHash, e := newWOPIToken() + if e != nil { + return wopiSession{}, "", e + } + session := wopiSession{ + id: uuid.NewString(), + tokenHash: tokenHash, + username: username, + path: utils.CleanPath(path), + origin: origin, + resourceKey: resourceKey, + writable: writable, + expiresAt: notAfter, + } + s.sessionsMu.Lock() + s.sessions[session.id] = session + s.sessionsMu.Unlock() + return session, token, nil +} + +func (s *wopiService) validateSession(id, token string) (wopiSession, bool) { + if id == "" || token == "" { + return wopiSession{}, false + } + s.sessionsMu.RLock() + session, ok := s.sessions[id] + s.sessionsMu.RUnlock() + if !ok || !session.expiresAt.After(time.Now()) { + if ok { + s.sessionsMu.Lock() + delete(s.sessions, id) + s.sessionsMu.Unlock() + } + return wopiSession{}, false + } + hash := sha256.Sum256([]byte(token)) + if subtle.ConstantTimeCompare(hash[:], session.tokenHash[:]) != 1 { + return wopiSession{}, false + } + return session, true +} + +func (s *wopiService) deleteSession(id string) { + s.sessionsMu.Lock() + delete(s.sessions, id) + s.sessionsMu.Unlock() +} + +func (s *wopiService) principal(session wopiSession) (types.Principal, error) { + user, e := s.userDAO.GetUser(session.username) + if e != nil { + return types.Principal{}, e + } + return types.Principal{User: user, AuthType: types.AuthTypeToken}, nil +} + +func canonicalWOPIResourceKey(entry types.IEntry) string { + dispatched := driveutil.GetIEntry(entry, func(candidate types.IEntry) bool { + _, ok := candidate.(types.IDispatcherEntry) + return ok + }) + if dispatched != nil { + return dispatched.(types.IDispatcherEntry).GetRealPath() + } + unwrapped := driveutil.UnwrapIEntry(entry) + return fmt.Sprintf("%T:%p:%s", unwrapped.Drive(), unwrapped.Drive(), unwrapped.Path()) +} + +func wopiVersion(entry types.IEntry) string { + data := fmt.Sprintf("%s\x00%d\x00%d", canonicalWOPIResourceKey(entry), entry.ModTime(), entry.Size()) + sum := sha256.Sum256([]byte(data)) + return hex.EncodeToString(sum[:16]) +} + +func validateWOPIOrigin(rawOrigin, requestHost string) (string, error) { + u, e := url.Parse(rawOrigin) + if e != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || + u.User != nil || (u.Path != "" && u.Path != "/") || u.RawQuery != "" || u.Fragment != "" { + return "", fmt.Errorf("invalid request origin") + } + if !strings.EqualFold(u.Host, requestHost) { + return "", fmt.Errorf("request origin does not match host") + } + u.Path = "" + return strings.TrimSuffix(u.String(), "/"), nil +} + +func (s *wopiService) fileURL(origin, id string) string { + apiPath := strings.Trim(s.config.APIPath, "/") + parts := []string{origin} + if apiPath != "" { + parts = append(parts, apiPath) + } + parts = append(parts, "wopi", "files", url.PathEscape(id)) + return strings.Join(parts, "/") +} + +func (s *wopiService) relativePath(session wopiSession, name string) (string, error) { + name = strings.TrimSpace(name) + if name == "" || name != pathpkg.Base(name) || name == "." || name == ".." || strings.ContainsAny(name, `/\\`) { + return "", fmt.Errorf("invalid relative file name") + } + return pathpkg.Join(pathpkg.Dir(session.path), name), nil +} + +func (s *wopiService) currentLock(resourceKey string, now time.Time) (wopiLock, bool) { + s.locksMu.Lock() + defer s.locksMu.Unlock() + lock, ok := s.locks[resourceKey] + if ok && !lock.expiresAt.After(now) { + delete(s.locks, resourceKey) + return wopiLock{}, false + } + return lock, ok +} + +func (s *wopiService) setLock(resourceKey string, lock wopiLock) { + s.locksMu.Lock() + s.locks[resourceKey] = lock + s.locksMu.Unlock() +} + +func (s *wopiService) deleteLock(resourceKey string) { + s.locksMu.Lock() + delete(s.locks, resourceKey) + s.locksMu.Unlock() +} + +func (s *wopiService) clean() { + now := time.Now() + s.sessionsMu.Lock() + for id, session := range s.sessions { + if !session.expiresAt.After(now) { + delete(s.sessions, id) + } + } + s.sessionsMu.Unlock() + s.locksMu.Lock() + for key, lock := range s.locks { + if !lock.expiresAt.After(now) { + delete(s.locks, key) + } + } + s.locksMu.Unlock() +} diff --git a/server/wopi_service_test.go b/server/wopi_service_test.go new file mode 100644 index 00000000..c68844be --- /dev/null +++ b/server/wopi_service_test.go @@ -0,0 +1,153 @@ +package server + +import ( + "context" + "go-drive/common" + "go-drive/common/types" + "go-drive/common/utils" + "io" + "strings" + "testing" + "time" +) + +func newTestWOPIService() *wopiService { + return &wopiService{ + config: common.Config{APIPath: "/api"}, + sessions: make(map[string]wopiSession), + locks: make(map[string]wopiLock), + resourceLock: utils.NewKeyLock(4), + } +} + +func TestWOPISessionTokenScopeAndExpiry(t *testing.T) { + service := newTestWOPIService() + session, token, e := service.createSession("alice", "docs/file.docx", "https://a.example", "drive/docs/file.docx", true, time.Time{}) + if e != nil { + t.Fatal(e) + } + if token == "" || strings.Contains(service.fileURL(session.origin, session.id), token) { + t.Fatal("raw access token must not be part of the WOPISrc") + } + if got, ok := service.validateSession(session.id, token); !ok || got.username != "alice" || !got.writable { + t.Fatalf("session validation failed: %#v, ok=%v", got, ok) + } + if _, ok := service.validateSession(session.id, token+"x"); ok { + t.Fatal("wrong token validated") + } + + service.sessionsMu.Lock() + expired := service.sessions[session.id] + expired.expiresAt = time.Now().Add(-time.Second) + service.sessions[session.id] = expired + service.sessionsMu.Unlock() + if _, ok := service.validateSession(session.id, token); ok { + t.Fatal("expired token validated") + } +} + +func TestWOPIMultipleOriginsAndAPIPath(t *testing.T) { + service := newTestWOPIService() + for _, origin := range []string{"https://a.example", "https://b.example"} { + validated, e := validateWOPIOrigin(origin, strings.TrimPrefix(origin, "https://")) + if e != nil { + t.Fatalf("validate origin %q: %v", origin, e) + } + got := service.fileURL(validated, "id") + want := origin + "/api/wopi/files/id" + if got != want { + t.Fatalf("file URL=%q, want %q", got, want) + } + } + if _, e := validateWOPIOrigin("https://evil.example", "a.example"); e == nil { + t.Fatal("mismatched origin was accepted") + } +} + +func TestWOPILockValidationAndExpiry(t *testing.T) { + service := newTestWOPIService() + if !validWOPILock("lock-id") || validWOPILock("") || validWOPILock("锁") || validWOPILock(strings.Repeat("x", 1025)) { + t.Fatal("unexpected lock validation result") + } + service.setLock("resource", wopiLock{value: "lock-id", expiresAt: time.Now().Add(-time.Second)}) + if lock, ok := service.currentLock("resource", time.Now()); ok || lock.value != "" { + t.Fatalf("expired lock remained active: %#v", lock) + } +} + +func TestRedactWOPIAccessToken(t *testing.T) { + got := redactWOPIAccessToken("/wopi/files/id?access_token=secret&x=1") + if strings.Contains(got, "secret") || !strings.Contains(got, "access_token=") || !strings.Contains(got, "x=1") { + t.Fatalf("unexpected redacted path: %s", got) + } + plain := "/entries/path?x=1" + if got := redactWOPIAccessToken(plain); got != plain { + t.Fatalf("unrelated query changed: %s", got) + } +} + +func TestWOPIVersionUsesCanonicalDispatchedPath(t *testing.T) { + drive := &wopiTestDrive{} + entry := &wopiTestEntry{drive: drive, path: "inner/file.docx", size: 12, modTime: 34} + wrapper := &wopiTestDispatcherEntry{IEntry: entry, realPath: "drive/inner/file.docx"} + if got := canonicalWOPIResourceKey(wrapper); got != "drive/inner/file.docx" { + t.Fatalf("resource key=%q", got) + } + if wopiVersion(wrapper) == "" { + t.Fatal("empty WOPI version") + } +} + +type wopiTestDispatcherEntry struct { + types.IEntry + realPath string +} + +func (e *wopiTestDispatcherEntry) GetIEntry() types.IEntry { return e.IEntry } +func (e *wopiTestDispatcherEntry) GetDispatchedDrive() (string, types.IDrive) { + return "drive", e.IEntry.Drive() +} +func (e *wopiTestDispatcherEntry) GetRealPath() string { return e.realPath } + +type wopiTestEntry struct { + drive types.IDrive + path string + size int64 + modTime int64 +} + +func (e *wopiTestEntry) Path() string { return e.path } +func (e *wopiTestEntry) Name() string { return "file.docx" } +func (e *wopiTestEntry) Type() types.EntryType { return types.TypeFile } +func (e *wopiTestEntry) Size() int64 { return e.size } +func (e *wopiTestEntry) Meta() types.EntryMeta { + return types.EntryMeta{Readable: true, Writable: true} +} +func (e *wopiTestEntry) ModTime() int64 { return e.modTime } +func (e *wopiTestEntry) Drive() types.IDrive { return e.drive } +func (e *wopiTestEntry) GetReader(context.Context, int64, int64) (io.ReadCloser, error) { + return nil, nil +} +func (e *wopiTestEntry) GetURL(context.Context) (*types.ContentURL, error) { return nil, nil } + +type wopiTestDrive struct{} + +func (*wopiTestDrive) Meta(context.Context) (types.DriveMeta, error) { panic("not used") } +func (*wopiTestDrive) Get(context.Context, string) (types.IEntry, error) { + panic("not used") +} +func (*wopiTestDrive) Save(types.TaskCtx, string, int64, bool, io.Reader) (types.IEntry, error) { + panic("not used") +} +func (*wopiTestDrive) MakeDir(context.Context, string) (types.IEntry, error) { panic("not used") } +func (*wopiTestDrive) Copy(types.TaskCtx, types.IEntry, string, bool) (types.IEntry, error) { + panic("not used") +} +func (*wopiTestDrive) Move(types.TaskCtx, types.IEntry, string, bool) (types.IEntry, error) { + panic("not used") +} +func (*wopiTestDrive) List(context.Context, string) ([]types.IEntry, error) { panic("not used") } +func (*wopiTestDrive) Delete(types.TaskCtx, string) error { panic("not used") } +func (*wopiTestDrive) Upload(context.Context, string, int64, bool, types.SM) (*types.DriveUploadConfig, error) { + panic("not used") +} diff --git a/web/src/api/index.ts b/web/src/api/index.ts index 3ebe98f3..1f92bfe2 100644 --- a/web/src/api/index.ts +++ b/web/src/api/index.ts @@ -183,3 +183,18 @@ export function getConfig(optKeys: string[]) { }, }) } + +export interface WOPISession { + actionUrl: string + accessToken: string + accessTokenTtl: number + mode: 'view' | 'edit' + userId: string + ownerId: string +} + +export function createWOPISession(path: string) { + return http.post(`/wopi/session/${path}`, undefined, { + headers: pathPasswordHeaders(path), + }) +} diff --git a/web/src/handlers/office/OfficeView.vue b/web/src/handlers/office/OfficeView.vue new file mode 100644 index 00000000..1e3b3aac --- /dev/null +++ b/web/src/handlers/office/OfficeView.vue @@ -0,0 +1,147 @@ + + + + + + {{ $t(`handler.office.${session.mode}`) }} + + + + + + {{ $t('handler.office.load_failed') }} + {{ error }} + + + {{ $t('handler.office.loading') }} + + + + + + + + + + + {{ $t('handler.office.loading') }} + + + + + + + + diff --git a/web/src/handlers/office/index.ts b/web/src/handlers/office/index.ts new file mode 100644 index 00000000..cd031fb4 --- /dev/null +++ b/web/src/handlers/office/index.ts @@ -0,0 +1,23 @@ +import { wrapAsyncComponent } from '@/components/async' +import { T } from '@/i18n' +import { filenameExt } from '@/utils' +import { EntryHandler } from '../types' + +export default { + name: 'office', + display: { + name: T('handler.office.name'), + description: T('handler.office.desc'), + icon: 'document', + }, + style: { fullscreen: true }, + view: { + name: 'OfficeView', + component: wrapAsyncComponent(() => import('./OfficeView.vue')), + }, + supports: ({ entry }, { config, user }) => { + if (!user || entry.type !== 'file' || !config.wopi?.enabled) return false + const actions = config.wopi.extensions[filenameExt(entry.name)] + return !!actions && (actions.view || (entry.meta.writable && actions.edit)) + }, +} as EntryHandler diff --git a/web/src/i18n/lang/en-US.json b/web/src/i18n/lang/en-US.json index 776967b3..1787266b 100644 --- a/web/src/i18n/lang/en-US.json +++ b/web/src/i18n/lang/en-US.json @@ -444,6 +444,15 @@ "name": "Preview", "desc": "Preview this file" }, + "office": { + "name": "Open in Office", + "desc": "Open this document in the configured Office service", + "edit": "Editing", + "view": "Viewing", + "loading": "Loading Office editor...", + "load_failed": "Unable to load Office editor", + "frame_title": "Office document editor" + }, "audio": { "name": "Play", "desc": "Play music", diff --git a/web/src/i18n/lang/ko-KR.json b/web/src/i18n/lang/ko-KR.json index d409d97d..192c143e 100644 --- a/web/src/i18n/lang/ko-KR.json +++ b/web/src/i18n/lang/ko-KR.json @@ -444,6 +444,15 @@ "name": "미리보기", "desc": "이 파일 미리보기" }, + "office": { + "name": "Office에서 열기", + "desc": "구성된 Office 서비스에서 이 문서를 엽니다", + "edit": "편집 중", + "view": "보는 중", + "loading": "Office 편집기 로드 중...", + "load_failed": "Office 편집기를 불러올 수 없습니다", + "frame_title": "Office 문서 편집기" + }, "audio": { "name": "재생", "desc": "음악 재생", diff --git a/web/src/i18n/lang/zh-CN.json b/web/src/i18n/lang/zh-CN.json index 4d861a0d..9bf24c4d 100644 --- a/web/src/i18n/lang/zh-CN.json +++ b/web/src/i18n/lang/zh-CN.json @@ -444,6 +444,15 @@ "name": "预览", "desc": "预览这个文件" }, + "office": { + "name": "使用 Office 打开", + "desc": "使用已配置的 Office 服务打开这个文档", + "edit": "编辑", + "view": "查看", + "loading": "正在加载 Office 编辑器...", + "load_failed": "无法加载 Office 编辑器", + "frame_title": "Office 文档编辑器" + }, "audio": { "name": "播放", "desc": "播放音乐", diff --git a/web/src/types/model/config.ts b/web/src/types/model/config.ts index 5d1587be..ca1aadef 100644 --- a/web/src/types/model/config.ts +++ b/web/src/types/model/config.ts @@ -26,6 +26,11 @@ export interface AuthConfig { providers: AuthProvider[] } +export interface WOPIConfig { + enabled: boolean + extensions: Record +} + export interface Config { auth: AuthConfig version: VersionConfig @@ -33,6 +38,7 @@ export interface Config { options: O search?: SearchConfig + wopi?: WOPIConfig } export interface ExternalFilePreviewer {