diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..061b19cce --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,60 @@ +# Agent Instructions + +## Issues + +Before creating an issue, review the available issue templates in the `.github` directory. + +When drafting the issue: + +- Use the most appropriate template. +- Follow the template structure. +- Fill in all required sections. +- Remove sections that the template explicitly marks as optional or not applicable. +- Do not invent reproduction steps, logs, screenshots, or expected behavior. + +## Pull Requests + +Before creating a pull request, read `.github/PULL_REQUEST_TEMPLATE.md`. + +When drafting the pull request: + +- Follow the template structure. +- Use the title format required by the template. +- Fill in or remove each section according to the template guidance. +- Include testing details, or explain why testing was not run. +- Do not invent testing results. +- Do not claim validation, verification, or review steps that were not actually performed. + +## Automated Contributions + +Fully automated contributions are not considered equivalent to normal community participation. + +A contribution may be considered fully automated if it is submitted through an automated agent, or if the submitting account participates in project discussions through an automated agent, without meaningful human review or intervention. + +When making this determination, maintainers may consider the overall behavior of the account, including but not limited to disclosed agent usage, interaction patterns, response characteristics, and other available evidence. No single factor is determinative. + +Maintainers reserve the right to accept, reject, modify, or reimplement any contribution independently of any action taken against the submitting account. Acceptance of a contribution does not imply acceptance of the submitting account or its contribution method. If an account is determined to be primarily operated through automated processes, we may need to restrict its future participation in contributions until that determination is rescinded. + +## Git Commits + +When creating commits, follow the repository `git-commit` skill rules: + +- Use Conventional Commits title format: `type(scope): subject`. +- Allowed types: `feat`, `fix`, `refactor`, `perf`, `docs`, `style`, `test`, `build`, `ci`, `chore`, `revert`. +- Use a meaningful scope based on the main module, package, or feature. +- Write the subject in imperative mood and describe the actual change. +- Use a concise Markdown list in the commit body, with each item describing one key change. +- Do not invent changes that are not present in the diff. +- Do not describe behavior, refactors, fixes, or tests that are not reflected in the commit. + +Include at most one `Co-authored-by` trailer that matches the AI assistant actually used to produce the change. + +Examples: + +- `Co-authored-by: Codex <267193182+codex@users.noreply.github.com>` +- `Co-authored-by: GitHub Copilot ` +- `Co-authored-by: Claude <81847+claude@users.noreply.github.com>` + +If you are not one of the listed assistants, do not add a `Co-authored-by` trailer. + +Instead, ask the human collaborator to provide the exact `Co-authored-by` trailer to use. Do not invent, infer, or generate one yourself. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 57317c629..854dd4499 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -86,6 +86,16 @@ At least 1 approving review is required by reviewers with write access. You can If your pull request includes substantial AI-assisted content, disclose it in the PR description. +The pull request description must follow the repository's pull request template. + +Fully automated contributions are not considered equivalent to normal community participation. + +A contribution may be considered fully automated if it is submitted through an automated agent, or if the submitting account participates in project discussions through an automated agent, without meaningful human review or intervention. + +When making this determination, maintainers may consider the overall behavior of the account, including but not limited to disclosed agent usage, interaction patterns, response characteristics, and other available evidence. No single factor is determinative. + +Maintainers reserve the right to accept, reject, modify, or reimplement any contribution independently of any action taken against the submitting account. Acceptance of a contribution does not imply acceptance of the submitting account or its contribution method. If an account is determined to be primarily operated through automated processes, we may need to restrict its future participation in contributions until that determination is rescinded. + Please include: - Tools used, such as ChatGPT, GitHub Copilot, Claude, Cursor, or other AI tools. diff --git a/README.md b/README.md index 4a94dbfcf..00d7fe058 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ --- -- English | [中文](./README_cn.md) | [日本語](./README_ja.md) | [Dutch](./README_nl.md) +- English | [中文](./README/README_cn.md) | [日本語](./README/README_ja.md) | [Dutch](./README/README_nl.md) | [한국어](./README/README_ko.md) | [Deutsch](./README/README_de.md) | [Русский](./README/README_ru.md) | [Français](./README/README_fr.md) | [Español](./README/README_es.md) | [العربية](./README/README_ar.md) - [Contributing](./CONTRIBUTING.md) - [CODE OF CONDUCT](./CODE_OF_CONDUCT.md) @@ -95,6 +95,7 @@ Thank you for your support and understanding of the OpenList project. - [x] [OpenList](https://github.com/OpenListTeam/OpenList) - [x] [Teldrive](https://github.com/tgdrive/teldrive) - [x] [Weiyun](https://www.weiyun.com) + - [x] [DingTalk Docs](https://alidocs.dingtalk.com/) - [x] Easy to deploy and out-of-the-box - [x] File preview (PDF, markdown, code, plain text, ...) - [x] Image preview in gallery mode @@ -134,6 +135,14 @@ Please refer to [*Discussions*](https://github.com/OpenListTeam/OpenList/discuss [![VPS.Town](https://vps.town/static/images/sponsor.png)](https://vps.town "VPS.Town - Trust, Effortlessly. Your Cloud, Reimagined.") +## Donors + +Thanks to the following donors for their generous support: + +- [HisAtri](https://github.com/HisAtri) +- 爱发电用户_7jTh +- suka + ## License The `OpenList` is open-source software licensed under the [AGPL-3.0](https://www.gnu.org/licenses/agpl-3.0.txt) license. diff --git a/README/README_ar.md b/README/README_ar.md new file mode 100644 index 000000000..32d9e9599 --- /dev/null +++ b/README/README_ar.md @@ -0,0 +1,136 @@ +
+ logo + +

OpenList هو تفرّع من AList يتميز بالمرونة والحوكمة طويلة الأمد ويقوده المجتمع، وقد بُني للدفاع عن البرمجيات مفتوحة المصدر ضد الهجمات القائمة على الثقة.

+ + latest version + License + Build status + latest version + + discussions + Downloads +
+ +--- + +- [English](../README.md) | [中文](./README_cn.md) | [日本語](./README_ja.md) | [Dutch](./README_nl.md) | [한국어](./README_ko.md) | [Deutsch](./README_de.md) | [Русский](./README_ru.md) | [Français](./README_fr.md) | [Español](./README_es.md) | العربية + +- [Contributing](../CONTRIBUTING.md) +- [CODE OF CONDUCT](../CODE_OF_CONDUCT.md) +- [LICENSE](../LICENSE) + +## إخلاء المسؤولية + +OpenList is an open-source project independently maintained by the OpenList Team under the AGPL-3.0 license, with a commitment to code openness and modification transparency. + +OpenList has no official association with third-party derivative projects that use similar names, including OpenListApp/OpenListApp or paid proprietary software using the same or similar names. + +- All software, code, and services of this project are maintained by the OpenList Team and are freely available on GitHub. +- Project documentation and API services primarily rely on charitable resources provided by Cloudflare. There are currently no paid plans or commercial deployments. +- Downstream projects should not use the "OpenList" name for impersonation or commercial confusion, and must not distribute OpenList-based code as closed source in violation of the AGPL license. +- This software is provided "as is" without warranties. Please comply with applicable laws and regulations when using it. + +## الميزات + +- [x] Multiple storages + - [x] Local storage + - [x] [Aliyundrive](https://www.alipan.com) + - [x] OneDrive / Sharepoint ([Global](https://www.microsoft.com/en-us/microsoft-365/onedrive/online-cloud-storage), [CN](https://portal.partner.microsoftonline.cn), DE, US) + - [x] [189cloud](https://cloud.189.cn) (Personal, Family) + - [x] [GoogleDrive](https://drive.google.com) + - [x] [123pan](https://www.123pan.com) + - [x] [FTP / SFTP](https://en.wikipedia.org/wiki/File_Transfer_Protocol) + - [x] [PikPak](https://www.mypikpak.com) + - [x] [S3](https://aws.amazon.com/s3) + - [x] [Seafile](https://seafile.com) + - [x] [UPYUN Storage Service](https://www.upyun.com/products/file-storage) + - [x] [WebDAV](https://en.wikipedia.org/wiki/WebDAV) + - [x] Teambition([China](https://www.teambition.com), [International](https://us.teambition.com)) + - [x] [MediaFire](https://www.mediafire.com) + - [x] [Mediatrack](https://www.mediatrack.cn) + - [x] [ProtonDrive](https://proton.me/drive) + - [x] [139yun](https://yun.139.com) (Personal, Family, Group) + - [x] [YandexDisk](https://disk.yandex.com) + - [x] [BaiduNetdisk](http://pan.baidu.com) + - [x] [Terabox](https://www.terabox.com/main) + - [x] [UC](https://drive.uc.cn) + - [x] [Quark](https://pan.quark.cn) + - [x] [Thunder](https://pan.xunlei.com) + - [x] [Lanzou](https://www.lanzou.com) + - [x] [ILanzou](https://www.ilanzou.com) + - [x] [Google photo](https://photos.google.com) + - [x] [Mega.nz](https://mega.nz) + - [x] [Baidu photo](https://photo.baidu.com) + - [x] [SMB](https://en.wikipedia.org/wiki/Server_Message_Block) + - [x] [115](https://115.com) + - [x] [Cloudreve](https://cloudreve.org) + - [x] [Dropbox](https://www.dropbox.com) + - [x] [FeijiPan](https://www.feijipan.com) + - [x] [dogecloud](https://www.dogecloud.com/product/oss) + - [x] [Azure Blob Storage](https://azure.microsoft.com/products/storage/blobs) + - [x] [Chaoxing](https://www.chaoxing.com) + - [x] [CNB](https://cnb.cool/) + - [x] [Degoo](https://degoo.com) + - [x] [Doubao](https://www.doubao.com) + - [x] [Febbox](https://www.febbox.com) + - [x] [GitHub](https://github.com) + - [x] [OpenList](https://github.com/OpenListTeam/OpenList) + - [x] [Teldrive](https://github.com/tgdrive/teldrive) + - [x] [Weiyun](https://www.weiyun.com) + - [x] [DingTalk Docs](https://alidocs.dingtalk.com/) +- [x] Easy to deploy and out-of-the-box +- [x] File preview (PDF, markdown, code, plain text, ...) +- [x] Image preview in gallery mode +- [x] Video and audio preview, with lyrics and subtitles support +- [x] Office document preview (docx, pptx, xlsx, ...) +- [x] `README.md` preview rendering +- [x] File permalink copy and direct file download +- [x] Dark mode and i18n +- [x] Protected routes, WebDAV, Docker deploy, and Cloudflare Workers proxy +- [x] File/folder package download and web upload operations +- [x] Offline download, cross-storage copy, and multi-thread download acceleration + +## الوثائق + +- 📘 [Docs](https://doc.oplist.org) +- 🌏 [CN Mirror](https://doc.oplist.org.cn) +- ⚖️ [Terms of Use](https://doc.oplist.org/terms) +- 🔒 [Privacy Policy](https://doc.oplist.org/privacy) + +## العرض التجريبي + +- 🌎 [Global Demo](https://demo.oplist.org) +- 🇨🇳 [CN Demo](https://demo.oplist.org.cn) + +## النقاش + +Please refer to [Discussions](https://github.com/OpenListTeam/OpenList/discussions) for general questions; Issues are for bug reports and feature requests only. + +## الراعي + +[![VPS.Town](https://vps.town/static/images/sponsor.png)](https://vps.town "VPS.Town - Trust, Effortlessly. Your Cloud, Reimagined.") + +## المتبرعون + +Thanks to the following donors for their generous support: + +- [HisAtri](https://github.com/HisAtri) +- 爱发电用户_7jTh +- suka + +## الترخيص + +OpenList is open-source software licensed under the [AGPL-3.0](https://www.gnu.org/licenses/agpl-3.0.txt) license. + +## تواصل معنا + +- [@GitHub](https://github.com/OpenListTeam) +- [Telegram Group](https://t.me/OpenListTeam) +- [Telegram Channel](https://t.me/OpenListOfficial) + +## المساهمون + +We sincerely thank [Xhofe](https://github.com/Xhofe), author of the original project [AlistGo/alist](https://github.com/AlistGo/alist), and all other contributors. + +[![Contributors](https://contrib.rocks/image?repo=OpenListTeam/OpenList)](https://github.com/OpenListTeam/OpenList/graphs/contributors) diff --git a/README_cn.md b/README/README_cn.md similarity index 92% rename from README_cn.md rename to README/README_cn.md index 55fe22106..5bf8af499 100644 --- a/README_cn.md +++ b/README/README_cn.md @@ -14,11 +14,11 @@ --- -- [English](./README.md) | 中文 | [日本語](./README_ja.md) | [Dutch](./README_nl.md) +- [English](../README.md) | 中文 | [日本語](./README_ja.md) | [Dutch](./README_nl.md) | [한국어](./README_ko.md) | [Deutsch](./README_de.md) | [Русский](./README_ru.md) | [Français](./README_fr.md) | [Español](./README_es.md) | [العربية](./README_ar.md) -- [贡献指南](./CONTRIBUTING.md) -- [行为准则](./CODE_OF_CONDUCT.md) -- [许可证](./LICENSE) +- [贡献指南](../CONTRIBUTING.md) +- [行为准则](../CODE_OF_CONDUCT.md) +- [许可证](../LICENSE) ## 免责声明 @@ -95,6 +95,7 @@ OpenList 是一个由 OpenList 团队独立维护的开源项目,遵循 AGPL-3 - [x] [OpenList](https://github.com/OpenListTeam/OpenList) - [x] [Teldrive](https://github.com/tgdrive/teldrive) - [x] [微云](https://www.weiyun.com) + - [x] [钉钉文档](https://alidocs.dingtalk.com/) - [x] 部署方便,开箱即用 - [x] 文件预览(PDF、markdown、代码、纯文本等) - [x] 画廊模式下的图片预览 @@ -134,6 +135,14 @@ OpenList 是一个由 OpenList 团队独立维护的开源项目,遵循 AGPL-3 [![VPS.Town](https://vps.town/static/images/sponsor.png)](https://vps.town "VPS.Town - Trust, Effortlessly. Your Cloud, Reimagined.") +## 捐赠者 + +感谢以下捐赠者的慷慨支持: + +- [HisAtri](https://github.com/HisAtri) +- 爱发电用户_7jTh +- suka + ## 许可证 `OpenList` 是基于 [AGPL-3.0](https://www.gnu.org/licenses/agpl-3.0.txt) 许可证的开源软件。 @@ -148,7 +157,7 @@ OpenList 是一个由 OpenList 团队独立维护的开源项目,遵循 AGPL-3 - 本软件按“原样”提供,不附带任何明示或暗示的担保,包括但不限于适销性或特定用途的适用性。 - 维护者不对因使用或无法使用本软件而导致的任何直接或间接损失负责。 - 您需自行承担使用本软件的所有风险,包括但不限于账号被封、下载限速等。 -- 本项目遵循 [AGPL-3.0](https://www.gnu.org/licenses/agpl-3.0.txt) 许可证,详情请参见 [LICENSE](./LICENSE) 文件。 +- 本项目遵循 [AGPL-3.0](https://www.gnu.org/licenses/agpl-3.0.txt) 许可证,详情请参见 [LICENSE](../LICENSE) 文件。 ## 联系我们 diff --git a/README/README_de.md b/README/README_de.md new file mode 100644 index 000000000..a0e5a9414 --- /dev/null +++ b/README/README_de.md @@ -0,0 +1,136 @@ +
+ logo + +

OpenList ist ein widerstandsfähiger, langfristig verwalteter und Community-getriebener Fork von AList, entwickelt zum Schutz von Open Source vor vertrauensbasierten Angriffen.

+ + latest version + License + Build status + latest version + + discussions + Downloads +
+ +--- + +- [English](../README.md) | [中文](./README_cn.md) | [日本語](./README_ja.md) | [Dutch](./README_nl.md) | [한국어](./README_ko.md) | Deutsch | [Русский](./README_ru.md) | [Français](./README_fr.md) | [Español](./README_es.md) | [العربية](./README_ar.md) + +- [Contributing](../CONTRIBUTING.md) +- [CODE OF CONDUCT](../CODE_OF_CONDUCT.md) +- [LICENSE](../LICENSE) + +## Haftungsausschluss + +OpenList is an open-source project independently maintained by the OpenList Team under the AGPL-3.0 license, with a commitment to code openness and modification transparency. + +OpenList has no official association with third-party derivative projects that use similar names, including OpenListApp/OpenListApp or paid proprietary software using the same or similar names. + +- All software, code, and services of this project are maintained by the OpenList Team and are freely available on GitHub. +- Project documentation and API services primarily rely on charitable resources provided by Cloudflare. There are currently no paid plans or commercial deployments. +- Downstream projects should not use the "OpenList" name for impersonation or commercial confusion, and must not distribute OpenList-based code as closed source in violation of the AGPL license. +- This software is provided "as is" without warranties. Please comply with applicable laws and regulations when using it. + +## Funktionen + +- [x] Multiple storages + - [x] Local storage + - [x] [Aliyundrive](https://www.alipan.com) + - [x] OneDrive / Sharepoint ([Global](https://www.microsoft.com/en-us/microsoft-365/onedrive/online-cloud-storage), [CN](https://portal.partner.microsoftonline.cn), DE, US) + - [x] [189cloud](https://cloud.189.cn) (Personal, Family) + - [x] [GoogleDrive](https://drive.google.com) + - [x] [123pan](https://www.123pan.com) + - [x] [FTP / SFTP](https://en.wikipedia.org/wiki/File_Transfer_Protocol) + - [x] [PikPak](https://www.mypikpak.com) + - [x] [S3](https://aws.amazon.com/s3) + - [x] [Seafile](https://seafile.com) + - [x] [UPYUN Storage Service](https://www.upyun.com/products/file-storage) + - [x] [WebDAV](https://en.wikipedia.org/wiki/WebDAV) + - [x] Teambition([China](https://www.teambition.com), [International](https://us.teambition.com)) + - [x] [MediaFire](https://www.mediafire.com) + - [x] [Mediatrack](https://www.mediatrack.cn) + - [x] [ProtonDrive](https://proton.me/drive) + - [x] [139yun](https://yun.139.com) (Personal, Family, Group) + - [x] [YandexDisk](https://disk.yandex.com) + - [x] [BaiduNetdisk](http://pan.baidu.com) + - [x] [Terabox](https://www.terabox.com/main) + - [x] [UC](https://drive.uc.cn) + - [x] [Quark](https://pan.quark.cn) + - [x] [Thunder](https://pan.xunlei.com) + - [x] [Lanzou](https://www.lanzou.com) + - [x] [ILanzou](https://www.ilanzou.com) + - [x] [Google photo](https://photos.google.com) + - [x] [Mega.nz](https://mega.nz) + - [x] [Baidu photo](https://photo.baidu.com) + - [x] [SMB](https://en.wikipedia.org/wiki/Server_Message_Block) + - [x] [115](https://115.com) + - [x] [Cloudreve](https://cloudreve.org) + - [x] [Dropbox](https://www.dropbox.com) + - [x] [FeijiPan](https://www.feijipan.com) + - [x] [dogecloud](https://www.dogecloud.com/product/oss) + - [x] [Azure Blob Storage](https://azure.microsoft.com/products/storage/blobs) + - [x] [Chaoxing](https://www.chaoxing.com) + - [x] [CNB](https://cnb.cool/) + - [x] [Degoo](https://degoo.com) + - [x] [Doubao](https://www.doubao.com) + - [x] [Febbox](https://www.febbox.com) + - [x] [GitHub](https://github.com) + - [x] [OpenList](https://github.com/OpenListTeam/OpenList) + - [x] [Teldrive](https://github.com/tgdrive/teldrive) + - [x] [Weiyun](https://www.weiyun.com) + - [x] [DingTalk Docs](https://alidocs.dingtalk.com/) +- [x] Easy to deploy and out-of-the-box +- [x] File preview (PDF, markdown, code, plain text, ...) +- [x] Image preview in gallery mode +- [x] Video and audio preview, with lyrics and subtitles support +- [x] Office document preview (docx, pptx, xlsx, ...) +- [x] `README.md` preview rendering +- [x] File permalink copy and direct file download +- [x] Dark mode and i18n +- [x] Protected routes, WebDAV, Docker deploy, and Cloudflare Workers proxy +- [x] File/folder package download and web upload operations +- [x] Offline download, cross-storage copy, and multi-thread download acceleration + +## Dokumentation + +- 📘 [Docs](https://doc.oplist.org) +- 🌏 [CN Mirror](https://doc.oplist.org.cn) +- ⚖️ [Terms of Use](https://doc.oplist.org/terms) +- 🔒 [Privacy Policy](https://doc.oplist.org/privacy) + +## Demo + +- 🌎 [Global Demo](https://demo.oplist.org) +- 🇨🇳 [CN Demo](https://demo.oplist.org.cn) + +## Diskussion + +Please refer to [Discussions](https://github.com/OpenListTeam/OpenList/discussions) for general questions; Issues are for bug reports and feature requests only. + +## Sponsor + +[![VPS.Town](https://vps.town/static/images/sponsor.png)](https://vps.town "VPS.Town - Trust, Effortlessly. Your Cloud, Reimagined.") + +## Spender + +Thanks to the following donors for their generous support: + +- [HisAtri](https://github.com/HisAtri) +- 爱发电用户_7jTh +- suka + +## Lizenz + +OpenList is open-source software licensed under the [AGPL-3.0](https://www.gnu.org/licenses/agpl-3.0.txt) license. + +## Kontakt + +- [@GitHub](https://github.com/OpenListTeam) +- [Telegram Group](https://t.me/OpenListTeam) +- [Telegram Channel](https://t.me/OpenListOfficial) + +## Mitwirkende + +We sincerely thank [Xhofe](https://github.com/Xhofe), author of the original project [AlistGo/alist](https://github.com/AlistGo/alist), and all other contributors. + +[![Contributors](https://contrib.rocks/image?repo=OpenListTeam/OpenList)](https://github.com/OpenListTeam/OpenList/graphs/contributors) diff --git a/README/README_es.md b/README/README_es.md new file mode 100644 index 000000000..fe62a456b --- /dev/null +++ b/README/README_es.md @@ -0,0 +1,136 @@ +
+ logo + +

OpenList es un fork de AList resiliente, con gobernanza a largo plazo e impulsado por la comunidad, creado para defender el código abierto frente a ataques basados en la confianza.

+ + latest version + License + Build status + latest version + + discussions + Downloads +
+ +--- + +- [English](../README.md) | [中文](./README_cn.md) | [日本語](./README_ja.md) | [Dutch](./README_nl.md) | [한국어](./README_ko.md) | [Deutsch](./README_de.md) | [Русский](./README_ru.md) | [Français](./README_fr.md) | Español | [العربية](./README_ar.md) + +- [Contributing](../CONTRIBUTING.md) +- [CODE OF CONDUCT](../CODE_OF_CONDUCT.md) +- [LICENSE](../LICENSE) + +## Aviso legal + +OpenList is an open-source project independently maintained by the OpenList Team under the AGPL-3.0 license, with a commitment to code openness and modification transparency. + +OpenList has no official association with third-party derivative projects that use similar names, including OpenListApp/OpenListApp or paid proprietary software using the same or similar names. + +- All software, code, and services of this project are maintained by the OpenList Team and are freely available on GitHub. +- Project documentation and API services primarily rely on charitable resources provided by Cloudflare. There are currently no paid plans or commercial deployments. +- Downstream projects should not use the "OpenList" name for impersonation or commercial confusion, and must not distribute OpenList-based code as closed source in violation of the AGPL license. +- This software is provided "as is" without warranties. Please comply with applicable laws and regulations when using it. + +## Características + +- [x] Multiple storages + - [x] Local storage + - [x] [Aliyundrive](https://www.alipan.com) + - [x] OneDrive / Sharepoint ([Global](https://www.microsoft.com/en-us/microsoft-365/onedrive/online-cloud-storage), [CN](https://portal.partner.microsoftonline.cn), DE, US) + - [x] [189cloud](https://cloud.189.cn) (Personal, Family) + - [x] [GoogleDrive](https://drive.google.com) + - [x] [123pan](https://www.123pan.com) + - [x] [FTP / SFTP](https://en.wikipedia.org/wiki/File_Transfer_Protocol) + - [x] [PikPak](https://www.mypikpak.com) + - [x] [S3](https://aws.amazon.com/s3) + - [x] [Seafile](https://seafile.com) + - [x] [UPYUN Storage Service](https://www.upyun.com/products/file-storage) + - [x] [WebDAV](https://en.wikipedia.org/wiki/WebDAV) + - [x] Teambition([China](https://www.teambition.com), [International](https://us.teambition.com)) + - [x] [MediaFire](https://www.mediafire.com) + - [x] [Mediatrack](https://www.mediatrack.cn) + - [x] [ProtonDrive](https://proton.me/drive) + - [x] [139yun](https://yun.139.com) (Personal, Family, Group) + - [x] [YandexDisk](https://disk.yandex.com) + - [x] [BaiduNetdisk](http://pan.baidu.com) + - [x] [Terabox](https://www.terabox.com/main) + - [x] [UC](https://drive.uc.cn) + - [x] [Quark](https://pan.quark.cn) + - [x] [Thunder](https://pan.xunlei.com) + - [x] [Lanzou](https://www.lanzou.com) + - [x] [ILanzou](https://www.ilanzou.com) + - [x] [Google photo](https://photos.google.com) + - [x] [Mega.nz](https://mega.nz) + - [x] [Baidu photo](https://photo.baidu.com) + - [x] [SMB](https://en.wikipedia.org/wiki/Server_Message_Block) + - [x] [115](https://115.com) + - [x] [Cloudreve](https://cloudreve.org) + - [x] [Dropbox](https://www.dropbox.com) + - [x] [FeijiPan](https://www.feijipan.com) + - [x] [dogecloud](https://www.dogecloud.com/product/oss) + - [x] [Azure Blob Storage](https://azure.microsoft.com/products/storage/blobs) + - [x] [Chaoxing](https://www.chaoxing.com) + - [x] [CNB](https://cnb.cool/) + - [x] [Degoo](https://degoo.com) + - [x] [Doubao](https://www.doubao.com) + - [x] [Febbox](https://www.febbox.com) + - [x] [GitHub](https://github.com) + - [x] [OpenList](https://github.com/OpenListTeam/OpenList) + - [x] [Teldrive](https://github.com/tgdrive/teldrive) + - [x] [Weiyun](https://www.weiyun.com) + - [x] [DingTalk Docs](https://alidocs.dingtalk.com/) +- [x] Easy to deploy and out-of-the-box +- [x] File preview (PDF, markdown, code, plain text, ...) +- [x] Image preview in gallery mode +- [x] Video and audio preview, with lyrics and subtitles support +- [x] Office document preview (docx, pptx, xlsx, ...) +- [x] `README.md` preview rendering +- [x] File permalink copy and direct file download +- [x] Dark mode and i18n +- [x] Protected routes, WebDAV, Docker deploy, and Cloudflare Workers proxy +- [x] File/folder package download and web upload operations +- [x] Offline download, cross-storage copy, and multi-thread download acceleration + +## Documentación + +- 📘 [Docs](https://doc.oplist.org) +- 🌏 [CN Mirror](https://doc.oplist.org.cn) +- ⚖️ [Terms of Use](https://doc.oplist.org/terms) +- 🔒 [Privacy Policy](https://doc.oplist.org/privacy) + +## Demo + +- 🌎 [Global Demo](https://demo.oplist.org) +- 🇨🇳 [CN Demo](https://demo.oplist.org.cn) + +## Discusión + +Please refer to [Discussions](https://github.com/OpenListTeam/OpenList/discussions) for general questions; Issues are for bug reports and feature requests only. + +## Patrocinador + +[![VPS.Town](https://vps.town/static/images/sponsor.png)](https://vps.town "VPS.Town - Trust, Effortlessly. Your Cloud, Reimagined.") + +## Donantes + +Thanks to the following donors for their generous support: + +- [HisAtri](https://github.com/HisAtri) +- 爱发电用户_7jTh +- suka + +## Licencia + +OpenList is open-source software licensed under the [AGPL-3.0](https://www.gnu.org/licenses/agpl-3.0.txt) license. + +## Contacto + +- [@GitHub](https://github.com/OpenListTeam) +- [Telegram Group](https://t.me/OpenListTeam) +- [Telegram Channel](https://t.me/OpenListOfficial) + +## Colaboradores + +We sincerely thank [Xhofe](https://github.com/Xhofe), author of the original project [AlistGo/alist](https://github.com/AlistGo/alist), and all other contributors. + +[![Contributors](https://contrib.rocks/image?repo=OpenListTeam/OpenList)](https://github.com/OpenListTeam/OpenList/graphs/contributors) diff --git a/README/README_fr.md b/README/README_fr.md new file mode 100644 index 000000000..686de9811 --- /dev/null +++ b/README/README_fr.md @@ -0,0 +1,136 @@ +
+ logo + +

OpenList est un fork d’AList résilient, gouverné sur le long terme et porté par la communauté, conçu pour protéger l’open source contre les attaques fondées sur la confiance.

+ + latest version + License + Build status + latest version + + discussions + Downloads +
+ +--- + +- [English](../README.md) | [中文](./README_cn.md) | [日本語](./README_ja.md) | [Dutch](./README_nl.md) | [한국어](./README_ko.md) | [Deutsch](./README_de.md) | [Русский](./README_ru.md) | Français | [Español](./README_es.md) | [العربية](./README_ar.md) + +- [Contributing](../CONTRIBUTING.md) +- [CODE OF CONDUCT](../CODE_OF_CONDUCT.md) +- [LICENSE](../LICENSE) + +## Avertissement + +OpenList is an open-source project independently maintained by the OpenList Team under the AGPL-3.0 license, with a commitment to code openness and modification transparency. + +OpenList has no official association with third-party derivative projects that use similar names, including OpenListApp/OpenListApp or paid proprietary software using the same or similar names. + +- All software, code, and services of this project are maintained by the OpenList Team and are freely available on GitHub. +- Project documentation and API services primarily rely on charitable resources provided by Cloudflare. There are currently no paid plans or commercial deployments. +- Downstream projects should not use the "OpenList" name for impersonation or commercial confusion, and must not distribute OpenList-based code as closed source in violation of the AGPL license. +- This software is provided "as is" without warranties. Please comply with applicable laws and regulations when using it. + +## Fonctionnalités + +- [x] Multiple storages + - [x] Local storage + - [x] [Aliyundrive](https://www.alipan.com) + - [x] OneDrive / Sharepoint ([Global](https://www.microsoft.com/en-us/microsoft-365/onedrive/online-cloud-storage), [CN](https://portal.partner.microsoftonline.cn), DE, US) + - [x] [189cloud](https://cloud.189.cn) (Personal, Family) + - [x] [GoogleDrive](https://drive.google.com) + - [x] [123pan](https://www.123pan.com) + - [x] [FTP / SFTP](https://en.wikipedia.org/wiki/File_Transfer_Protocol) + - [x] [PikPak](https://www.mypikpak.com) + - [x] [S3](https://aws.amazon.com/s3) + - [x] [Seafile](https://seafile.com) + - [x] [UPYUN Storage Service](https://www.upyun.com/products/file-storage) + - [x] [WebDAV](https://en.wikipedia.org/wiki/WebDAV) + - [x] Teambition([China](https://www.teambition.com), [International](https://us.teambition.com)) + - [x] [MediaFire](https://www.mediafire.com) + - [x] [Mediatrack](https://www.mediatrack.cn) + - [x] [ProtonDrive](https://proton.me/drive) + - [x] [139yun](https://yun.139.com) (Personal, Family, Group) + - [x] [YandexDisk](https://disk.yandex.com) + - [x] [BaiduNetdisk](http://pan.baidu.com) + - [x] [Terabox](https://www.terabox.com/main) + - [x] [UC](https://drive.uc.cn) + - [x] [Quark](https://pan.quark.cn) + - [x] [Thunder](https://pan.xunlei.com) + - [x] [Lanzou](https://www.lanzou.com) + - [x] [ILanzou](https://www.ilanzou.com) + - [x] [Google photo](https://photos.google.com) + - [x] [Mega.nz](https://mega.nz) + - [x] [Baidu photo](https://photo.baidu.com) + - [x] [SMB](https://en.wikipedia.org/wiki/Server_Message_Block) + - [x] [115](https://115.com) + - [x] [Cloudreve](https://cloudreve.org) + - [x] [Dropbox](https://www.dropbox.com) + - [x] [FeijiPan](https://www.feijipan.com) + - [x] [dogecloud](https://www.dogecloud.com/product/oss) + - [x] [Azure Blob Storage](https://azure.microsoft.com/products/storage/blobs) + - [x] [Chaoxing](https://www.chaoxing.com) + - [x] [CNB](https://cnb.cool/) + - [x] [Degoo](https://degoo.com) + - [x] [Doubao](https://www.doubao.com) + - [x] [Febbox](https://www.febbox.com) + - [x] [GitHub](https://github.com) + - [x] [OpenList](https://github.com/OpenListTeam/OpenList) + - [x] [Teldrive](https://github.com/tgdrive/teldrive) + - [x] [Weiyun](https://www.weiyun.com) + - [x] [DingTalk Docs](https://alidocs.dingtalk.com/) +- [x] Easy to deploy and out-of-the-box +- [x] File preview (PDF, markdown, code, plain text, ...) +- [x] Image preview in gallery mode +- [x] Video and audio preview, with lyrics and subtitles support +- [x] Office document preview (docx, pptx, xlsx, ...) +- [x] `README.md` preview rendering +- [x] File permalink copy and direct file download +- [x] Dark mode and i18n +- [x] Protected routes, WebDAV, Docker deploy, and Cloudflare Workers proxy +- [x] File/folder package download and web upload operations +- [x] Offline download, cross-storage copy, and multi-thread download acceleration + +## Documentation + +- 📘 [Docs](https://doc.oplist.org) +- 🌏 [CN Mirror](https://doc.oplist.org.cn) +- ⚖️ [Terms of Use](https://doc.oplist.org/terms) +- 🔒 [Privacy Policy](https://doc.oplist.org/privacy) + +## Démo + +- 🌎 [Global Demo](https://demo.oplist.org) +- 🇨🇳 [CN Demo](https://demo.oplist.org.cn) + +## Discussion + +Please refer to [Discussions](https://github.com/OpenListTeam/OpenList/discussions) for general questions; Issues are for bug reports and feature requests only. + +## Sponsor + +[![VPS.Town](https://vps.town/static/images/sponsor.png)](https://vps.town "VPS.Town - Trust, Effortlessly. Your Cloud, Reimagined.") + +## Donateurs + +Thanks to the following donors for their generous support: + +- [HisAtri](https://github.com/HisAtri) +- 爱发电用户_7jTh +- suka + +## Licence + +OpenList is open-source software licensed under the [AGPL-3.0](https://www.gnu.org/licenses/agpl-3.0.txt) license. + +## Nous contacter + +- [@GitHub](https://github.com/OpenListTeam) +- [Telegram Group](https://t.me/OpenListTeam) +- [Telegram Channel](https://t.me/OpenListOfficial) + +## Contributeurs + +We sincerely thank [Xhofe](https://github.com/Xhofe), author of the original project [AlistGo/alist](https://github.com/AlistGo/alist), and all other contributors. + +[![Contributors](https://contrib.rocks/image?repo=OpenListTeam/OpenList)](https://github.com/OpenListTeam/OpenList/graphs/contributors) diff --git a/README_ja.md b/README/README_ja.md similarity index 93% rename from README_ja.md rename to README/README_ja.md index 261223de3..d1e5b8380 100644 --- a/README_ja.md +++ b/README/README_ja.md @@ -14,11 +14,11 @@ --- -- [English](./README.md) | [中文](./README_cn.md) | 日本語 | [Dutch](./README_nl.md) +- [English](../README.md) | [中文](./README_cn.md) | 日本語 | [Dutch](./README_nl.md) | [한국어](./README_ko.md) | [Deutsch](./README_de.md) | [Русский](./README_ru.md) | [Français](./README_fr.md) | [Español](./README_es.md) | [العربية](./README_ar.md) -- [コントリビュート](./CONTRIBUTING.md) -- [行動規範](./CODE_OF_CONDUCT.md) -- [ライセンス](./LICENSE) +- [コントリビュート](../CONTRIBUTING.md) +- [行動規範](../CODE_OF_CONDUCT.md) +- [ライセンス](../LICENSE) ## 免責事項 @@ -94,6 +94,7 @@ OpenListプロジェクトへのご支援とご理解をありがとうござい - [x] [OpenList](https://github.com/OpenListTeam/OpenList) - [x] [Teldrive](https://github.com/tgdrive/teldrive) - [x] [Weiyun](https://www.weiyun.com) + - [x] [DingTalk ドキュメント](https://alidocs.dingtalk.com/) - [x] [MediaFire](https://www.mediafire.com) - [x] 簡単にデプロイでき、すぐに使える - [x] ファイルプレビュー(PDF、markdown、コード、テキストなど) @@ -134,6 +135,14 @@ OpenListプロジェクトへのご支援とご理解をありがとうござい [![VPS.Town](https://vps.town/static/images/sponsor.png)](https://vps.town "VPS.Town - Trust, Effortlessly. Your Cloud, Reimagined.") +## 寄付者 + +以下の方々の寛大なご支援に感謝いたします: + +- [HisAtri](https://github.com/HisAtri) +- 爱发电用户_7jTh +- suka + ## ライセンス 「OpenList」は [AGPL-3.0](https://www.gnu.org/licenses/agpl-3.0.txt) ライセンスの下で公開されているオープンソースソフトウェアです。 @@ -148,7 +157,7 @@ OpenListプロジェクトへのご支援とご理解をありがとうござい - 本ソフトウェアは「現状有姿」で提供されており、商品性や特定目的への適合性を含むいかなる保証もありません。 - 本ソフトウェアの使用または使用不能によるいかなる直接的・間接的損害についても、メンテナは責任を負いません。 - 本ソフトウェアの利用に伴うすべてのリスク(アカウントの凍結やダウンロード速度制限などを含む)は、利用者自身が負うものとします。 -- 本プロジェクトは [AGPL-3.0](https://www.gnu.org/licenses/agpl-3.0.txt) ライセンスに従います。詳細は [LICENSE](./LICENSE) ファイルをご覧ください。 +- 本プロジェクトは [AGPL-3.0](https://www.gnu.org/licenses/agpl-3.0.txt) ライセンスに従います。詳細は [LICENSE](../LICENSE) ファイルをご覧ください。 ## お問い合わせ diff --git a/README/README_ko.md b/README/README_ko.md new file mode 100644 index 000000000..647eb22c5 --- /dev/null +++ b/README/README_ko.md @@ -0,0 +1,136 @@ +
+ logo + +

OpenList는 신뢰 기반 공격으로부터 오픈 소스를 보호하기 위해 만들어진, 탄력적이고 장기 거버넌스를 지향하는 커뮤니티 주도 AList 포크입니다.

+ + latest version + License + Build status + latest version + + discussions + Downloads +
+ +--- + +- [English](../README.md) | [中文](./README_cn.md) | [日本語](./README_ja.md) | [Dutch](./README_nl.md) | 한국어 | [Deutsch](./README_de.md) | [Русский](./README_ru.md) | [Français](./README_fr.md) | [Español](./README_es.md) | [العربية](./README_ar.md) + +- [Contributing](../CONTRIBUTING.md) +- [CODE OF CONDUCT](../CODE_OF_CONDUCT.md) +- [LICENSE](../LICENSE) + +## 면책 조항 + +OpenList is an open-source project independently maintained by the OpenList Team under the AGPL-3.0 license, with a commitment to code openness and modification transparency. + +OpenList has no official association with third-party derivative projects that use similar names, including OpenListApp/OpenListApp or paid proprietary software using the same or similar names. + +- All software, code, and services of this project are maintained by the OpenList Team and are freely available on GitHub. +- Project documentation and API services primarily rely on charitable resources provided by Cloudflare. There are currently no paid plans or commercial deployments. +- Downstream projects should not use the "OpenList" name for impersonation or commercial confusion, and must not distribute OpenList-based code as closed source in violation of the AGPL license. +- This software is provided "as is" without warranties. Please comply with applicable laws and regulations when using it. + +## 기능 + +- [x] Multiple storages + - [x] Local storage + - [x] [Aliyundrive](https://www.alipan.com) + - [x] OneDrive / Sharepoint ([Global](https://www.microsoft.com/en-us/microsoft-365/onedrive/online-cloud-storage), [CN](https://portal.partner.microsoftonline.cn), DE, US) + - [x] [189cloud](https://cloud.189.cn) (Personal, Family) + - [x] [GoogleDrive](https://drive.google.com) + - [x] [123pan](https://www.123pan.com) + - [x] [FTP / SFTP](https://en.wikipedia.org/wiki/File_Transfer_Protocol) + - [x] [PikPak](https://www.mypikpak.com) + - [x] [S3](https://aws.amazon.com/s3) + - [x] [Seafile](https://seafile.com) + - [x] [UPYUN Storage Service](https://www.upyun.com/products/file-storage) + - [x] [WebDAV](https://en.wikipedia.org/wiki/WebDAV) + - [x] Teambition([China](https://www.teambition.com), [International](https://us.teambition.com)) + - [x] [MediaFire](https://www.mediafire.com) + - [x] [Mediatrack](https://www.mediatrack.cn) + - [x] [ProtonDrive](https://proton.me/drive) + - [x] [139yun](https://yun.139.com) (Personal, Family, Group) + - [x] [YandexDisk](https://disk.yandex.com) + - [x] [BaiduNetdisk](http://pan.baidu.com) + - [x] [Terabox](https://www.terabox.com/main) + - [x] [UC](https://drive.uc.cn) + - [x] [Quark](https://pan.quark.cn) + - [x] [Thunder](https://pan.xunlei.com) + - [x] [Lanzou](https://www.lanzou.com) + - [x] [ILanzou](https://www.ilanzou.com) + - [x] [Google photo](https://photos.google.com) + - [x] [Mega.nz](https://mega.nz) + - [x] [Baidu photo](https://photo.baidu.com) + - [x] [SMB](https://en.wikipedia.org/wiki/Server_Message_Block) + - [x] [115](https://115.com) + - [x] [Cloudreve](https://cloudreve.org) + - [x] [Dropbox](https://www.dropbox.com) + - [x] [FeijiPan](https://www.feijipan.com) + - [x] [dogecloud](https://www.dogecloud.com/product/oss) + - [x] [Azure Blob Storage](https://azure.microsoft.com/products/storage/blobs) + - [x] [Chaoxing](https://www.chaoxing.com) + - [x] [CNB](https://cnb.cool/) + - [x] [Degoo](https://degoo.com) + - [x] [Doubao](https://www.doubao.com) + - [x] [Febbox](https://www.febbox.com) + - [x] [GitHub](https://github.com) + - [x] [OpenList](https://github.com/OpenListTeam/OpenList) + - [x] [Teldrive](https://github.com/tgdrive/teldrive) + - [x] [Weiyun](https://www.weiyun.com) + - [x] [DingTalk Docs](https://alidocs.dingtalk.com/) +- [x] Easy to deploy and out-of-the-box +- [x] File preview (PDF, markdown, code, plain text, ...) +- [x] Image preview in gallery mode +- [x] Video and audio preview, with lyrics and subtitles support +- [x] Office document preview (docx, pptx, xlsx, ...) +- [x] `README.md` preview rendering +- [x] File permalink copy and direct file download +- [x] Dark mode and i18n +- [x] Protected routes, WebDAV, Docker deploy, and Cloudflare Workers proxy +- [x] File/folder package download and web upload operations +- [x] Offline download, cross-storage copy, and multi-thread download acceleration + +## 문서 + +- 📘 [Docs](https://doc.oplist.org) +- 🌏 [CN Mirror](https://doc.oplist.org.cn) +- ⚖️ [Terms of Use](https://doc.oplist.org/terms) +- 🔒 [Privacy Policy](https://doc.oplist.org/privacy) + +## 데모 + +- 🌎 [Global Demo](https://demo.oplist.org) +- 🇨🇳 [CN Demo](https://demo.oplist.org.cn) + +## 토론 + +Please refer to [Discussions](https://github.com/OpenListTeam/OpenList/discussions) for general questions; Issues are for bug reports and feature requests only. + +## 후원 + +[![VPS.Town](https://vps.town/static/images/sponsor.png)](https://vps.town "VPS.Town - Trust, Effortlessly. Your Cloud, Reimagined.") + +## 후원자 + +Thanks to the following donors for their generous support: + +- [HisAtri](https://github.com/HisAtri) +- 爱发电用户_7jTh +- suka + +## 라이선스 + +OpenList is open-source software licensed under the [AGPL-3.0](https://www.gnu.org/licenses/agpl-3.0.txt) license. + +## 문의 + +- [@GitHub](https://github.com/OpenListTeam) +- [Telegram Group](https://t.me/OpenListTeam) +- [Telegram Channel](https://t.me/OpenListOfficial) + +## 기여자 + +We sincerely thank [Xhofe](https://github.com/Xhofe), author of the original project [AlistGo/alist](https://github.com/AlistGo/alist), and all other contributors. + +[![Contributors](https://contrib.rocks/image?repo=OpenListTeam/OpenList)](https://github.com/OpenListTeam/OpenList/graphs/contributors) diff --git a/README_nl.md b/README/README_nl.md similarity index 93% rename from README_nl.md rename to README/README_nl.md index d3be2703f..af7a9afcc 100644 --- a/README_nl.md +++ b/README/README_nl.md @@ -14,11 +14,11 @@ --- -- [English](./README.md) | [中文](./README_cn.md) | [日本語](./README_ja.md) | Dutch +- [English](../README.md) | [中文](./README_cn.md) | [日本語](./README_ja.md) | Dutch | [한국어](./README_ko.md) | [Deutsch](./README_de.md) | [Русский](./README_ru.md) | [Français](./README_fr.md) | [Español](./README_es.md) | [العربية](./README_ar.md) -- [Bijdragen](./CONTRIBUTING.md) -- [Gedragscode](./CODE_OF_CONDUCT.md) -- [Licentie](./LICENSE) +- [Bijdragen](../CONTRIBUTING.md) +- [Gedragscode](../CODE_OF_CONDUCT.md) +- [Licentie](../LICENSE) ## Disclaimer @@ -95,6 +95,7 @@ Dank u voor uw ondersteuning en begrip - [x] [OpenList](https://github.com/OpenListTeam/OpenList) - [x] [Teldrive](https://github.com/tgdrive/teldrive) - [x] [Weiyun](https://www.weiyun.com) + - [x] [DingTalk-documenten](https://alidocs.dingtalk.com/) - [x] Eenvoudig te implementeren en direct te gebruiken - [x] Bestandsvoorbeeld (PDF, markdown, code, platte tekst, ...) - [x] Afbeeldingsvoorbeeld in galerijweergave @@ -134,6 +135,14 @@ Stel algemene vragen in [*Discussions*](https://github.com/OpenListTeam/OpenList [![VPS.Town](https://vps.town/static/images/sponsor.png)](https://vps.town "VPS.Town - Trust, Effortlessly. Your Cloud, Reimagined.") +## Donatie Bijdragers + +Dank aan de volgende donateurs voor hun steun: + +- [HisAtri](https://github.com/HisAtri) +- 爱发电用户_7jTh +- suka + ## Licentie `OpenList` is open-source software onder de [AGPL-3.0](https://www.gnu.org/licenses/agpl-3.0.txt) licentie. @@ -148,7 +157,7 @@ Stel algemene vragen in [*Discussions*](https://github.com/OpenListTeam/OpenList - De software wordt geleverd "zoals deze is", zonder enige vorm van garantie, expliciet of impliciet, inclusief maar niet beperkt tot garanties van verkoopbaarheid of geschiktheid voor een bepaald doel. - De beheerders zijn niet aansprakelijk voor enige directe of indirecte schade die voortvloeit uit het gebruik van of het onvermogen om deze software te gebruiken. - U bent zelf verantwoordelijk voor alle risico's die gepaard gaan met het gebruik van deze software, inclusief maar niet beperkt tot accountblokkades of downloadbeperkingen. -- Dit project valt onder de [AGPL-3.0](https://www.gnu.org/licenses/agpl-3.0.txt) licentie. Zie het [LICENSE](./LICENSE) bestand voor details. +- Dit project valt onder de [AGPL-3.0](https://www.gnu.org/licenses/agpl-3.0.txt) licentie. Zie het [LICENSE](../LICENSE) bestand voor details. ## Contact diff --git a/README/README_ru.md b/README/README_ru.md new file mode 100644 index 000000000..782282e33 --- /dev/null +++ b/README/README_ru.md @@ -0,0 +1,136 @@ +
+ logo + +

OpenList — устойчивый, управляемый сообществом форк AList с долгосрочным управлением, созданный для защиты open source от атак, основанных на доверии.

+ + latest version + License + Build status + latest version + + discussions + Downloads +
+ +--- + +- [English](../README.md) | [中文](./README_cn.md) | [日本語](./README_ja.md) | [Dutch](./README_nl.md) | [한국어](./README_ko.md) | [Deutsch](./README_de.md) | Русский | [Français](./README_fr.md) | [Español](./README_es.md) | [العربية](./README_ar.md) + +- [Contributing](../CONTRIBUTING.md) +- [CODE OF CONDUCT](../CODE_OF_CONDUCT.md) +- [LICENSE](../LICENSE) + +## Отказ от ответственности + +OpenList is an open-source project independently maintained by the OpenList Team under the AGPL-3.0 license, with a commitment to code openness and modification transparency. + +OpenList has no official association with third-party derivative projects that use similar names, including OpenListApp/OpenListApp or paid proprietary software using the same or similar names. + +- All software, code, and services of this project are maintained by the OpenList Team and are freely available on GitHub. +- Project documentation and API services primarily rely on charitable resources provided by Cloudflare. There are currently no paid plans or commercial deployments. +- Downstream projects should not use the "OpenList" name for impersonation or commercial confusion, and must not distribute OpenList-based code as closed source in violation of the AGPL license. +- This software is provided "as is" without warranties. Please comply with applicable laws and regulations when using it. + +## Возможности + +- [x] Multiple storages + - [x] Local storage + - [x] [Aliyundrive](https://www.alipan.com) + - [x] OneDrive / Sharepoint ([Global](https://www.microsoft.com/en-us/microsoft-365/onedrive/online-cloud-storage), [CN](https://portal.partner.microsoftonline.cn), DE, US) + - [x] [189cloud](https://cloud.189.cn) (Personal, Family) + - [x] [GoogleDrive](https://drive.google.com) + - [x] [123pan](https://www.123pan.com) + - [x] [FTP / SFTP](https://en.wikipedia.org/wiki/File_Transfer_Protocol) + - [x] [PikPak](https://www.mypikpak.com) + - [x] [S3](https://aws.amazon.com/s3) + - [x] [Seafile](https://seafile.com) + - [x] [UPYUN Storage Service](https://www.upyun.com/products/file-storage) + - [x] [WebDAV](https://en.wikipedia.org/wiki/WebDAV) + - [x] Teambition([China](https://www.teambition.com), [International](https://us.teambition.com)) + - [x] [MediaFire](https://www.mediafire.com) + - [x] [Mediatrack](https://www.mediatrack.cn) + - [x] [ProtonDrive](https://proton.me/drive) + - [x] [139yun](https://yun.139.com) (Personal, Family, Group) + - [x] [YandexDisk](https://disk.yandex.com) + - [x] [BaiduNetdisk](http://pan.baidu.com) + - [x] [Terabox](https://www.terabox.com/main) + - [x] [UC](https://drive.uc.cn) + - [x] [Quark](https://pan.quark.cn) + - [x] [Thunder](https://pan.xunlei.com) + - [x] [Lanzou](https://www.lanzou.com) + - [x] [ILanzou](https://www.ilanzou.com) + - [x] [Google photo](https://photos.google.com) + - [x] [Mega.nz](https://mega.nz) + - [x] [Baidu photo](https://photo.baidu.com) + - [x] [SMB](https://en.wikipedia.org/wiki/Server_Message_Block) + - [x] [115](https://115.com) + - [x] [Cloudreve](https://cloudreve.org) + - [x] [Dropbox](https://www.dropbox.com) + - [x] [FeijiPan](https://www.feijipan.com) + - [x] [dogecloud](https://www.dogecloud.com/product/oss) + - [x] [Azure Blob Storage](https://azure.microsoft.com/products/storage/blobs) + - [x] [Chaoxing](https://www.chaoxing.com) + - [x] [CNB](https://cnb.cool/) + - [x] [Degoo](https://degoo.com) + - [x] [Doubao](https://www.doubao.com) + - [x] [Febbox](https://www.febbox.com) + - [x] [GitHub](https://github.com) + - [x] [OpenList](https://github.com/OpenListTeam/OpenList) + - [x] [Teldrive](https://github.com/tgdrive/teldrive) + - [x] [Weiyun](https://www.weiyun.com) + - [x] [DingTalk Docs](https://alidocs.dingtalk.com/) +- [x] Easy to deploy and out-of-the-box +- [x] File preview (PDF, markdown, code, plain text, ...) +- [x] Image preview in gallery mode +- [x] Video and audio preview, with lyrics and subtitles support +- [x] Office document preview (docx, pptx, xlsx, ...) +- [x] `README.md` preview rendering +- [x] File permalink copy and direct file download +- [x] Dark mode and i18n +- [x] Protected routes, WebDAV, Docker deploy, and Cloudflare Workers proxy +- [x] File/folder package download and web upload operations +- [x] Offline download, cross-storage copy, and multi-thread download acceleration + +## Документация + +- 📘 [Docs](https://doc.oplist.org) +- 🌏 [CN Mirror](https://doc.oplist.org.cn) +- ⚖️ [Terms of Use](https://doc.oplist.org/terms) +- 🔒 [Privacy Policy](https://doc.oplist.org/privacy) + +## Демо + +- 🌎 [Global Demo](https://demo.oplist.org) +- 🇨🇳 [CN Demo](https://demo.oplist.org.cn) + +## Обсуждение + +Please refer to [Discussions](https://github.com/OpenListTeam/OpenList/discussions) for general questions; Issues are for bug reports and feature requests only. + +## Спонсор + +[![VPS.Town](https://vps.town/static/images/sponsor.png)](https://vps.town "VPS.Town - Trust, Effortlessly. Your Cloud, Reimagined.") + +## Доноры + +Thanks to the following donors for their generous support: + +- [HisAtri](https://github.com/HisAtri) +- 爱发电用户_7jTh +- suka + +## Лицензия + +OpenList is open-source software licensed under the [AGPL-3.0](https://www.gnu.org/licenses/agpl-3.0.txt) license. + +## Связаться с нами + +- [@GitHub](https://github.com/OpenListTeam) +- [Telegram Group](https://t.me/OpenListTeam) +- [Telegram Channel](https://t.me/OpenListOfficial) + +## Участники + +We sincerely thank [Xhofe](https://github.com/Xhofe), author of the original project [AlistGo/alist](https://github.com/AlistGo/alist), and all other contributors. + +[![Contributors](https://contrib.rocks/image?repo=OpenListTeam/OpenList)](https://github.com/OpenListTeam/OpenList/graphs/contributors) diff --git a/build.sh b/build.sh index e42a99f12..ff769ce1a 100644 --- a/build.sh +++ b/build.sh @@ -31,7 +31,7 @@ else # webVersion=$(eval "curl -fsSL --max-time 2 $githubAuthArgs \"https://api.github.com/repos/$frontendRepo/releases/latest\"" | grep "tag_name" | head -n 1 | awk -F ":" '{print $2}' | sed 's/\"//g;s/,//g;s/ //g') fi -webVersion=4.2.2 +webVersion=4.2.3 echo "backend version: $version" echo "frontend version: $webVersion" diff --git a/docs/superpowers/plans/2026-07-11-openlist-post-4.2.2-sync.md b/docs/superpowers/plans/2026-07-11-openlist-post-4.2.2-sync.md new file mode 100644 index 000000000..9383ff785 --- /dev/null +++ b/docs/superpowers/plans/2026-07-11-openlist-post-4.2.2-sync.md @@ -0,0 +1,170 @@ +# OpenList 4.2.2 后续改动同步实施计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 将 OpenList `3b1760e9..eb486712` 的累计业务代码改动同步到 PowerList,同时保持本仓库构建脚本和 GitHub Actions 完全不变。 + +**Architecture:** 对已确认的上游范围生成累计差异,并通过 Git 三方应用融合到 PowerList。保护路径从补丁输入中排除,冲突逐文件保留 PowerList 定制并接入上游逻辑,最后以保护文件对象校验、依赖一致性和全量 Go 测试验证结果。 + +**Tech Stack:** Git、Go、gofmt、Go modules + +--- + +### Task 1: 固定基线与保护清单 + +**Files:** +- Inspect: `.github/workflows/**` +- Inspect: `build.sh` +- Inspect: `build/**` +- Inspect: `Dockerfile*` +- Create temporary snapshot: `/tmp/powerlist-openlist-sync-protected.before` + +- [ ] **Step 1: 验证同步端点** + +```bash +git rev-parse 3b1760e9 openlist/main +git rev-list --count 3b1760e9..openlist/main +``` + +Expected: 起点为 `3b1760e9...`,终点为 `eb486712...`,范围包含 75 个提交。 + +- [ ] **Step 2: 记录保护文件对象 ID** + +```bash +git ls-files -s -- '.github/workflows/**' 'build.sh' 'build/**' 'Dockerfile*' '**/Dockerfile*' > /tmp/powerlist-openlist-sync-protected.before +``` + +Expected: 文件列出 PowerList 当前受保护的已跟踪文件及 blob ID。 + +- [ ] **Step 3: 确认上游范围内的构建类路径** + +```bash +git diff --name-status 3b1760e9..openlist/main -- '.github/workflows/**' 'build.sh' 'build/**' 'Dockerfile*' '**/Dockerfile*' +``` + +Expected: 仅用于确认将被排除的上游改动,不修改工作区。 + +### Task 2: 应用上游累计差异 + +**Files:** +- Modify: `drivers/**` +- Modify: `internal/**` +- Modify: `pkg/**` +- Modify: `server/**` +- Modify: `go.mod` +- Modify: `go.sum` +- Modify: 上游范围内的普通文档和配置文件 +- Preserve: `.github/workflows/**`, `build.sh`, `build/**`, `Dockerfile*` + +- [ ] **Step 1: 三方应用排除保护路径后的累计差异** + +```bash +git diff --binary 3b1760e9..openlist/main -- . ':(exclude).github/workflows/**' ':(exclude)build.sh' ':(exclude)build/**' ':(exclude)Dockerfile*' ':(exclude)**/Dockerfile*' | git apply --3way --index - +``` + +Expected: 无冲突文件自动进入暂存区;有冲突时 Git 标记冲突路径,保护路径不出现于暂存区或工作区差异。 + +- [ ] **Step 2: 枚举并逐项解决冲突** + +```bash +git diff --name-only --diff-filter=U +``` + +Expected: 对每个输出文件比较 stage 1/2/3 版本;保留 PowerList 定制逻辑并融合上游变更,移除所有冲突标记后仅暂存本次同步文件。 + +- [ ] **Step 3: 检查没有构建或 Actions 路径进入差异** + +```bash +git diff --name-only HEAD -- '.github/workflows/**' 'build.sh' 'build/**' 'Dockerfile*' '**/Dockerfile*' +``` + +Expected: 无输出。 + +### Task 3: 格式化与依赖一致性 + +**Files:** +- Modify: 本次同步涉及的 `*.go` +- Modify: `go.mod` +- Modify: `go.sum` + +- [ ] **Step 1: 格式化本次新增或修改的 Go 文件** + +```bash +gofmt -w $(git diff --name-only --diff-filter=ACMR HEAD -- '*.go') +git add -- $(git diff --name-only --diff-filter=ACMR HEAD -- '*.go') +``` + +Expected: 所有合入 Go 文件符合 gofmt,且不触碰未跟踪文件。 + +- [ ] **Step 2: 整理依赖并重新暂存** + +```bash +go mod tidy +git add go.mod go.sum +``` + +Expected: 命令退出码为 0,`go.mod` 与 `go.sum` 包含全部合入代码所需依赖。 + +- [ ] **Step 3: 检查补丁格式** + +```bash +git diff --cached --check +``` + +Expected: 无空白错误或残留冲突标记。 + +### Task 4: 验证同步结果 + +**Files:** +- Verify: 全部暂存改动 +- Verify: `/tmp/powerlist-openlist-sync-protected.before` + +- [ ] **Step 1: 运行全量 Go 测试** + +```bash +go test ./... +``` + +Expected: 所有包通过;若存在环境或基线失败,记录具体包和错误,不将其描述为通过。 + +- [ ] **Step 2: 再次校验保护文件对象 ID** + +```bash +git ls-files -s -- '.github/workflows/**' 'build.sh' 'build/**' 'Dockerfile*' '**/Dockerfile*' > /tmp/powerlist-openlist-sync-protected.after +diff -u /tmp/powerlist-openlist-sync-protected.before /tmp/powerlist-openlist-sync-protected.after +``` + +Expected: `diff` 无输出且退出码为 0。 + +- [ ] **Step 3: 审核最终文件列表和差异统计** + +```bash +git status --short +git diff --cached --stat +git diff --cached --name-status +``` + +Expected: 用户原有未跟踪文件仍保持未跟踪;暂存区只包含上游同步代码、测试、依赖和普通文档,不包含保护路径。 + +### Task 5: 创建同步提交 + +**Files:** +- Commit: 所有已验证的暂存改动 + +- [ ] **Step 1: 提交同步结果** + +```bash +git commit -m "merge: sync OpenList changes after 4.2.2" +``` + +Expected: 创建单个同步提交,不包含用户原有未跟踪文件。 + +- [ ] **Step 2: 提交后最终验证** + +```bash +git status --short --branch +git show --stat --oneline --summary HEAD +git diff HEAD^ HEAD --name-only -- '.github/workflows/**' 'build.sh' 'build/**' 'Dockerfile*' '**/Dockerfile*' +``` + +Expected: 分支领先远端两个提交(设计文档和同步提交),最后一条保护路径检查无输出。 diff --git a/docs/superpowers/specs/2026-07-11-openlist-post-4.2.2-sync-design.md b/docs/superpowers/specs/2026-07-11-openlist-post-4.2.2-sync-design.md new file mode 100644 index 000000000..1d2e46818 --- /dev/null +++ b/docs/superpowers/specs/2026-07-11-openlist-post-4.2.2-sync-design.md @@ -0,0 +1,47 @@ +# OpenList 4.2.2 后续改动同步设计 + +## 目标与范围 + +将 PowerList 当前已同步的上游基线 `3b1760e9` 之后,到 `openlist/main` 当前提交 `eb486712` 为止的 OpenList 改动合入本仓库。`3b1760e9` 已包含在 PowerList 的上一轮 squash 同步中,因此不重复应用。 + +同步包含上游业务代码、测试、依赖清单和普通文档的变更。PowerList 自有驱动、缓存、索引及播放逻辑继续保留;发生冲突时,在保留本仓库定制行为的基础上融合上游修复和功能。 + +## 合并方式 + +以 `3b1760e9..eb486712` 的累计差异为输入,形成一个 PowerList 同步提交。相比逐提交 cherry-pick,这与仓库上一轮上游同步的 squash 方式一致,也避免在约 75 个提交中反复处理同一区域冲突。 + +使用三方合并信息处理累计补丁。对自动合并失败的文件逐一检查共同祖先、本仓库版本和上游版本,不以整文件覆盖方式丢弃任一侧有效功能。 + +## 保护范围 + +以下内容不得因本次同步发生变化: + +- `.github/workflows/**` +- `build.sh` +- `build/**` +- 根目录及子目录中的 `Dockerfile*` +- 其他仅用于构建、打包或发布的脚本;若在上游差异中发现,加入保护清单后再继续 + +`go.mod` 和 `go.sum` 属于 Go 依赖清单,需要随上游业务代码同步,不归入上述构建脚本保护范围。 + +合并前记录所有已跟踪保护文件的对象 ID,合并后逐项比较,保证内容和文件存在状态不变。上游新增加的 GitHub Actions 或构建脚本也不引入。 + +## 冲突处理原则 + +1. PowerList 独有功能和本地修复优先保留。 +2. 上游安全修复、崩溃修复、接口适配和新增驱动在不破坏本地功能的前提下合入。 +3. 同一逻辑已由 PowerList 等价实现时,避免重复代码。 +4. 依赖版本以能够编译全部合入代码为准,并通过 `go mod tidy` 检查一致性;不修改受保护的构建脚本来迁就依赖问题。 +5. 用户现有未跟踪文件不纳入提交,也不删除或覆盖。 + +## 验证 + +完成合并后执行: + +- 检查工作区和最终提交只包含预期文件。 +- 对比保护文件合并前后的对象 ID,并确认没有新增上游工作流或构建脚本。 +- 运行 `gofmt` 检查合入的 Go 文件。 +- 运行 `go test ./...`;若完整测试受环境或既有问题阻塞,再运行相关包测试并明确报告未验证范围。 +- 检查 `go mod tidy` 是否会产生额外差异,保证依赖清单一致。 + +最终提交采用单个上游同步提交,并在提交信息中记录同步范围和保护约束。 diff --git a/docs/superpowers/specs/2026-07-16-thunder-share-cas-playback-design.md b/docs/superpowers/specs/2026-07-16-thunder-share-cas-playback-design.md new file mode 100644 index 000000000..2236d3918 --- /dev/null +++ b/docs/superpowers/specs/2026-07-16-thunder-share-cas-playback-design.md @@ -0,0 +1,225 @@ +# Thunder Share CAS Playback Design + +## Summary + +Add transparent playback support for `.cas` files stored in `ThunderShare`. When a user requests a link for a shared `.cas` file, PowerList should read and parse the CAS metadata through an available `ThunderBrowser` account, use an available `189CloudPC` account to restore the original payload through provider-side rapid upload, and return the restored 189CloudPC file link. + +The original file in the Thunder share is metadata only and must remain untouched. Playback succeeds only when 189CloudPC accepts the hashes and size from the CAS payload for rapid restore. + +## Goals + +- Make `.cas` files in `ThunderShare` playable through the normal `Link` path. +- Keep ordinary Thunder share playback unchanged. +- Reuse the existing CAS parser and 189CloudPC restore implementation. +- Try distinct configured 189CloudPC accounts until one restores and links the payload successfully. +- Clean up the temporary Thunder CAS copy and the restored 189CloudPC payload after the configured delay. +- Return errors that identify the failed stage instead of returning an unplayable `.cas` link. + +## Non-Goals + +- Do not treat `.cas` as a media format. +- Do not upload the `.cas` file into 189CloudPC before restoring its payload. +- Do not modify or delete the original `.cas` object in the Thunder share. +- Do not add a generic cross-storage CAS orchestration subsystem. +- Do not change normal Thunder share link caching. +- Do not add persistent caching for restored CAS playback links. +- Do not guarantee that every valid CAS payload can be restored; the source data must be accepted by 189CloudPC rapid upload. + +## User-Facing Behavior + +### Ordinary Thunder share file + +- Preserve the existing flow: save the shared file into a ThunderBrowser temporary directory, obtain its Thunder link, cache the result by shared file ID, and schedule deletion of the temporary Thunder file. + +### Thunder share `.cas` file + +1. Save the shared `.cas` file into a ThunderBrowser temporary directory. +2. Obtain a link for the temporary Thunder object. +3. Read and parse the CAS metadata with a strict metadata size limit. +4. Try the preferred 189CloudPC account first, followed by the remaining distinct 189CloudPC accounts. +5. For each account, reuse an existing same-named payload in its restore temp directory or restore the payload through provider-side rapid upload. +6. Return the first successfully generated 189CloudPC payload link. +7. Schedule delayed deletion of the temporary Thunder `.cas` copy and the restored or reused 189CloudPC payload. +8. Do not cache the resulting CAS playback link in `thunderShareLinkCache`. + +If every 189CloudPC account fails, return an error. Never fall back to the Thunder `.cas` download link. + +## Approaches Considered + +### Recommended: orchestrate from `ThunderShare` + +`ThunderShare` reads the metadata and asks `189CloudPC` to restore from the parsed CAS information. This performs no unnecessary cross-provider upload and keeps provider responsibilities clear: + +- ThunderShare owns access to the shared metadata file. +- `internal/casfile` owns CAS parsing and validation. +- 189CloudPC owns rapid restore, payload linking, and its own temporary-file cleanup. + +### Upload the CAS file to 189CloudPC first + +PowerList could download the Thunder `.cas`, upload it into 189CloudPC, and invoke the existing 189CloudPC `Link` behavior. This reuses the current public link path but adds an unnecessary upload, another temporary object, more cleanup states, and more failure points. + +### Add a generic cross-storage CAS service + +A generic service could read CAS metadata from any source storage and dispatch it to any compatible restore provider. This would be extensible but is broader than the current Thunder-to-189CloudPC requirement and would introduce interfaces without a second concrete consumer. + +## Architecture + +### `internal/casfile` + +Add a reader-based parsing helper with a maximum content size. Generated CAS metadata is small, so the playback path should reject input larger than 1 MiB instead of reading an arbitrarily large shared object into memory. + +The helper should: + +- accept an `io.Reader` +- read no more than 1 MiB plus one detection byte +- return a specific metadata-too-large error when the limit is exceeded +- delegate accepted content to the existing `Parse` function + +Existing `Parse([]byte)` behavior and accepted payload formats remain unchanged. + +### `drivers/189pc` + +Expose a focused playback restore method that accepts an already parsed `casfile.Info` and a CAS file name. The method should encapsulate all 189CloudPC-specific behavior: + +- force payload-name semantics regardless of `RestoreSourceUseCurrentName` +- resolve the existing personal or family restore temp directory +- reject invalid payload names through the existing name validation +- reuse a same-named payload object when one already exists +- otherwise call the existing provider-side rapid restore flow +- create a direct link for the selected payload object +- schedule delayed cleanup of that payload object + +Refactor the existing 189CloudPC `.cas` playback path to call the same metadata-based helper after opening and parsing its local CAS object. The upload-time and auto-restore paths keep their current configuration semantics and are not changed to use forced playback naming. + +The cleanup context used by the playback helper must ignore request cancellation so cleanup still runs after the client disconnects. + +### `drivers/thunder_share` + +Branch on the file suffix before consulting `thunderShareLinkCache`: + +- non-`.cas` files keep the existing cached resolver +- `.cas` files call a dedicated CAS playback resolver and bypass the ordinary link cache + +The CAS resolver has two phases. + +#### Phase 1: read CAS metadata through Thunder + +- Try available ThunderBrowser accounts using the existing share-save and direct-link behavior. +- Schedule deletion of the saved Thunder temporary object with a cancellation-independent context. +- Open the returned link as a stream using the shared file's name and size metadata. +- Parse the stream through the bounded reader helper in `internal/casfile`. +- Stop and return the real Thunder or parsing error if no account can provide valid CAS metadata. + +#### Phase 2: restore through 189CloudPC + +- Build a distinct candidate list with the configured preferred 189CloudPC account first, followed by all remaining configured 189CloudPC storages. +- Call the new metadata-based playback restore method on each candidate. +- Return immediately when an account restores and links the payload successfully. +- Record the account ID and error for failed candidates. +- If every candidate fails, return a combined error preserving the individual failure information. + +## Data Flow + +```text +ThunderShare.Link(.cas) + -> Thunder share save into ThunderBrowser temp directory + -> Thunder direct link + -> bounded CAS read and parse + -> try 189CloudPC account candidates + -> find or rapid-restore payload in 189 temp directory + -> create 189 payload link + -> schedule 189 payload cleanup + -> schedule Thunder temp CAS cleanup + -> return 189 payload link +``` + +The original Thunder share object is read-only throughout this flow. + +## Naming Semantics + +- Use the CAS payload `name` field for playback restore. +- Ignore the target 189CloudPC storage's `RestoreSourceUseCurrentName` setting for this path. +- Reject empty names and names containing `/` or `\\` through the existing 189CloudPC restore-name validation. +- Do not derive the payload name from the Thunder share object's current `.cas` filename. + +This matches the existing 189CloudPC direct CAS playback and transferred-share CAS playback behavior. + +## Cache and Concurrency + +Ordinary Thunder share files retain the existing one-hour link cache. + +CAS playback links must bypass that cache because the returned link refers to a temporary 189CloudPC payload that may be deleted before the cache entry expires. A later playback request should repeat metadata parsing and restore or reuse a current payload instead of returning a stale link. + +No persistent CAS playback cache or new concurrency lock is required. Concurrent requests for the same shared CAS may both attempt restore. They must not modify the original share object, and provider errors should be returned normally. + +## Temporary File Cleanup + +Two independent temporary objects may be created: + +- the saved `.cas` object in the ThunderBrowser temp directory +- the restored or reused payload in the 189CloudPC restore temp directory + +Cleanup rules: + +- use the existing global `conf.DeleteDelayTime` setting +- skip cleanup when the configured delay is zero +- use `context.WithoutCancel` or an equivalent cancellation-independent context +- delete only the temporary objects owned by each provider +- never delete the original Thunder share object +- treat delayed cleanup failures as log-only after a link has already been returned + +## Error Handling + +- If no ThunderBrowser account is available, return the existing missing-Thunder-account error. +- If Thunder share save or link creation fails, retry according to the existing Thunder account selection behavior and return the real final error. +- If CAS content is empty, malformed, invalid, or larger than 1 MiB, return a parsing or size-limit error and do not call 189CloudPC. +- If no 189CloudPC account is configured, return `找不到天翼云盘帐号`. +- If one 189CloudPC account fails to restore or link, continue with the next distinct candidate. +- If all 189CloudPC candidates fail, return a combined error containing the relevant account IDs and causes. +- If 189CloudPC reports that source data is unavailable, preserve a clear error explaining that the CAS payload could not be rapid-restored. +- Never return the Thunder `.cas` link as playback fallback. + +## Testing + +### `internal/casfile` + +- reader helper parses valid raw JSON and base64 CAS payloads +- reader helper rejects content larger than 1 MiB +- reader helper preserves existing empty and malformed payload errors + +### `drivers/189pc` + +- metadata-based playback restore forces payload-name semantics +- existing payload is reused before rapid restore +- missing payload is restored and linked +- the payload object, not the CAS metadata object, is scheduled for cleanup +- cleanup context survives cancellation of the playback request +- existing direct 189CloudPC `.cas` playback uses the shared helper + +### `drivers/thunder_share` + +- ordinary files keep using the current resolver and cache +- `.cas` files bypass reads and writes to `thunderShareLinkCache` +- valid Thunder CAS metadata is passed to 189CloudPC and returns the 189 link +- a failed preferred 189CloudPC account is followed by another distinct account +- no 189CloudPC accounts returns a clear error +- invalid, empty, and oversized CAS input prevents restore attempts +- all-account restore failure preserves the actual causes +- the saved Thunder CAS object is the Thunder cleanup target +- cancellation of the request does not cancel delayed Thunder cleanup + +Use overridable function seams for provider selection, Thunder save/link, stream opening, CAS parsing, 189CloudPC restoration, and cleanup scheduling so unit tests remain deterministic and make no network calls. + +## Verification + +Run the focused package tests: + +```bash +go test ./internal/casfile ./drivers/189pc ./drivers/thunder_share +``` + +Then run the full regression suite: + +```bash +go test ./... +``` diff --git a/docs/upstream-sync.md b/docs/upstream-sync.md new file mode 100644 index 000000000..95d3a72a9 --- /dev/null +++ b/docs/upstream-sync.md @@ -0,0 +1,291 @@ +# 定期同步 OpenList 上游指南 + +本文记录 PowerList 定期同步 OpenList 上游的标准流程。目标是在引入上游功能、安全修复和依赖更新的同时,保留 PowerList 自有功能,并避免覆盖本仓库的发布与 CI 配置。 + +## 同步原则 + +- 后端同步来源为 `OpenListTeam/OpenList` 的 `main` 分支。 +- 每次从“上一次已合入 PowerList 的最后一个上游提交”开始,只同步其后的累计改动,避免重复应用已经 squash 合入的提交。 +- PowerList 自有驱动、索引、缓存、播放和 CAS 行为优先保留;冲突时融合上游修复,不使用整文件覆盖丢弃本地定制。 +- `.github/workflows/**` 保持 PowerList 版本,不引入上游 GitHub Actions 改动或新增工作流。 +- 构建脚本的逻辑保持 PowerList 版本。`build.sh` 中固定的 `webVersion` 是例外:每次同步必须更新为本次 OpenList 最新稳定版本。 +- `go.mod` 和 `go.sum` 是业务代码依赖清单,需要随上游代码同步并执行 `go mod tidy`。 +- 用户已有的未跟踪文件不参与同步,不删除、不覆盖、不提交。 + +## 1. 检查工作区和远端 + +```bash +git status --short --branch +git remote -v +git fetch openlist --tags --prune +``` + +确认: + +- 当前分支和预期一致。 +- 工作区中的已有改动已识别并受到保护。 +- `openlist/main` 已刷新到本机 OpenList 仓库或官方上游的最新状态。 + +如果尚未配置上游远端: + +```bash +git remote add openlist https://github.com/OpenListTeam/OpenList.git +git fetch openlist --tags +``` + +## 2. 确定后端同步范围 + +不要仅根据版本标签猜测起点。PowerList 通常以 squash 方式同步上游,因此上游 tag 未必是当前分支的祖先。 + +先查看最近一次同步提交的说明和历史: + +```bash +git log --oneline --decorate -20 +git log --graph --oneline --decorate --all --simplify-by-decoration -80 +``` + +确定上次已包含的最后一个上游提交,记为 ``。检查新的同步范围: + +```bash +git rev-parse openlist/main +git rev-list --count ..openlist/main +git log --oneline ..openlist/main +git diff --stat ..openlist/main +git diff --name-status ..openlist/main +``` + +同步文档和提交信息必须记录实际的起止 SHA,便于下一次继续。 + +## 3. 确定 Web 版本 + +OpenList-Frontend 的最新版本与 OpenList 当前最新稳定版本保持一致,不需要单独查询 Frontend 仓库。 + +获取本次 OpenList 同步目标对应的最新稳定 tag: + +```bash +git describe --tags --abbrev=0 openlist/main +``` + +也可以查看同步范围内的版本 tag: + +```bash +git tag --contains --merged openlist/main --sort=-v:refname +``` + +将 OpenList 最新稳定 tag 原样记录到同步记录。`build.sh` 的 `webVersion` 使用去掉开头 `v` 的版本号。例如 OpenList 最新稳定 tag 为 `v4.2.3` 时,写入值为 `4.2.3`: + +```bash +webVersion= +``` + +```bash +webVersion=4.2.3 +``` + +只修改该版本值。不得借同步之机改写下载、打包、静态链接检查或其他构建逻辑。 + +## 4. 建立保护清单 + +应用上游补丁前记录受保护文件: + +```bash +git ls-files -s -- \ + '.github/workflows/**' \ + 'build/**' \ + 'Dockerfile*' \ + '**/Dockerfile*' +``` + +`build.sh` 需要单独处理:除 `webVersion` 更新为本次 OpenList 最新稳定版本外,其余内容必须保持同步前版本。 + +先检查上游涉及哪些受保护路径: + +```bash +git diff --name-status ..openlist/main -- \ + '.github/workflows/**' \ + 'build.sh' \ + 'build/**' \ + 'Dockerfile*' \ + '**/Dockerfile*' +``` + +## 5. 应用累计改动 + +推荐应用累计差异并形成一个同步提交。这与 PowerList 既有的 squash 同步方式一致,也能减少逐提交反复解决相同冲突的成本。 + +```bash +git diff --binary ..openlist/main -- . \ + ':(exclude).github/workflows/**' \ + ':(exclude)build.sh' \ + ':(exclude)build/**' \ + ':(exclude)Dockerfile*' \ + ':(exclude)**/Dockerfile*' \ + | git apply --3way --index - +``` + +补丁完成后,再单独把 `build.sh` 的 `webVersion` 更新到第 3 节确定的 OpenList 最新稳定版本。 + +如果上游修改了 PowerList 中不存在的子系统文件,应先确认: + +- 该子系统是否已被 PowerList 删除或替代。 +- 上游修复是否仍适用于 PowerList 的替代实现。 +- 是否需要迁移修复,而不是重新引入整个已删除子系统。 + +确认不适用后,才可从补丁输入中排除对应路径,并在同步记录中说明原因。 + +## 6. 解决冲突 + +列出冲突: + +```bash +git diff --name-only --diff-filter=U +git ls-files -u +``` + +逐文件比较三方版本: + +```bash +git show :1:path/to/file +git show :2:path/to/file +git show :3:path/to/file +``` + +处理顺序: + +1. 理解上游提交要解决的问题。 +2. 判断 PowerList 是否已有等价实现。 +3. 保留 PowerList 独有接口和行为。 +4. 融合仍适用的上游安全修复、错误处理和新功能。 +5. 对函数签名、依赖版本和调用方做全局搜索,避免只解决冲突标记而留下接口不一致。 + +禁止简单地对所有冲突统一选择 `ours` 或 `theirs`。 + +## 7. 格式化和依赖整理 + +```bash +gofmt -w <本次修改的 Go 文件> +go mod tidy +git diff --check +``` + +如果 Go 缓存目录在沙箱中不可写,可以只为命令设置可写缓存: + +```bash +GOCACHE=/tmp/powerlist-go-build-cache go test ./... +``` + +不要通过修改构建脚本绕过本机环境问题。 + +## 8. 验证 + +至少执行: + +```bash +go test ./... +git diff --check +git diff --name-only --diff-filter=U +``` + +如果全量测试在同步前已经失败,应先保存合并前基线,再比较合并后的失败集合。必须明确区分: + +- 本次同步新增的失败:必须修复后再提交。 +- 合并前已存在的失败:可以在用户明确同意后继续,但必须在提交或 PR 中如实记录。 +- 环境依赖失败:例如缺少系统头文件或外部测试服务,必须记录具体依赖,不能描述为测试通过。 + +对新增功能和冲突区域运行相关包测试。例如: + +```bash +go test ./drivers/189pc ./drivers/115_open ./drivers/123 +go test ./server/mcp ./server/s3 ./internal/net +``` + +## 9. 验证保护项 + +确认 GitHub Actions 没有变化: + +```bash +git diff --name-only ..HEAD -- '.github/workflows/**' +``` + +确认构建文件只有允许的 Web tag 更新: + +```bash +git diff ..HEAD -- build.sh build Dockerfile Dockerfile.ci Dockerfile.ci-host +``` + +预期: + +- `.github/workflows/**` 无输出。 +- `build/**` 和 Dockerfile 无输出。 +- `build.sh` 最多只有 `webVersion=` 一处变化。 + +## 10. 提交与 PR + +推荐结构: + +1. 可选:同步设计文档提交。 +2. 可选:实施计划提交。 +3. 上游同步提交。 + +同步提交示例: + +```text +chore(upstream): sync OpenList after vX.Y.Z + +- Merge upstream changes through while preserving PowerList customizations. +- Keep GitHub Actions and PowerList build logic unchanged. +- Update build.sh webVersion to the current OpenList stable version. +- Add upstream features, security fixes, tests, and dependency updates. +``` + +创建 PR 前确认: + +```bash +git log --oneline origin/main..HEAD +git diff --stat origin/main..HEAD +git diff origin/main..HEAD -- '.github/workflows/**' +git diff origin/main..HEAD -- build.sh build Dockerfile Dockerfile.ci Dockerfile.ci-host +``` + +PR 描述必须包含: + +- 后端同步起止 SHA。 +- OpenList 最新稳定 tag 及其对应的 `webVersion`。 +- 被保护和被排除的文件。 +- 主要冲突及解决原则。 +- 实际执行的测试和未通过原因。 +- AI 使用披露(如适用)。 + +## 11. 同步记录模板 + +每次同步在 PR 描述或维护记录中填写: + +```markdown +## Upstream sync record + +- Sync date: YYYY-MM-DD +- Previous upstream SHA: +- New upstream SHA: +- OpenList release/tag reference: +- OpenList latest stable tag: +- build.sh webVersion: +- Protected paths: .github/workflows/**, build/**, Dockerfile* +- Allowed build change: build.sh webVersion= +- Excluded non-applicable upstream paths: +- PowerList customizations retained: +- Conflict resolutions: +- Tests passed: +- Known baseline/environment failures: +- PR: +``` + +## 本次同步示例 + +- 同步日期:2026-07-11 至 2026-07-12。 +- 已包含的最后一个上游提交:`3b1760e9`。 +- 新上游提交:`eb486712`。 +- 同步提交范围:75 个上游提交。 +- OpenList 最新稳定 release tag:`v4.2.3`。 +- GitHub Actions:未修改。 +- 构建变更:仅将 `build.sh` 的 `webVersion` 从 `4.2.2` 更新为 `4.2.3`。 +- PR:。 diff --git a/drivers/115_open/driver.go b/drivers/115_open/driver.go index 0ea1270d2..3dd87d3e7 100644 --- a/drivers/115_open/driver.go +++ b/drivers/115_open/driver.go @@ -2,6 +2,7 @@ package _115_open import ( "context" + "errors" "fmt" "net/http" stdpath "path" @@ -15,6 +16,7 @@ import ( "github.com/OpenListTeam/OpenList/v4/drivers/base" "github.com/OpenListTeam/OpenList/v4/internal/conf" "github.com/OpenListTeam/OpenList/v4/internal/driver" + "github.com/OpenListTeam/OpenList/v4/internal/errs" "github.com/OpenListTeam/OpenList/v4/internal/model" "github.com/OpenListTeam/OpenList/v4/internal/op" "github.com/OpenListTeam/OpenList/v4/internal/stream" @@ -176,6 +178,9 @@ func (d *Open115) Get(ctx context.Context, path string) (model.Obj, error) { path = stdpath.Join(d.parentPath, path) resp, err := d.client.GetFolderInfoByPath(ctx, path) if err != nil { + if errors.Is(err, sdk.ErrObjectNotFound) { + return nil, errs.ObjectNotFound + } return nil, err } return &Obj{ @@ -206,18 +211,15 @@ func (d *Open115) MakeDir(ctx context.Context, parentDir model.Obj, dirName stri }, nil } -func (d *Open115) Move(ctx context.Context, srcObj, dstDir model.Obj) (model.Obj, error) { +func (d *Open115) Move(ctx context.Context, srcObj, dstDir model.Obj) error { if err := d.WaitLimit(ctx); err != nil { - return nil, err + return err } _, err := d.client.Move(ctx, &sdk.MoveReq{ FileIDs: srcObj.GetID(), ToCid: dstDir.GetID(), }) - if err != nil { - return nil, err - } - return srcObj, nil + return err } func (d *Open115) Rename(ctx context.Context, srcObj model.Obj, newName string) (model.Obj, error) { @@ -225,8 +227,8 @@ func (d *Open115) Rename(ctx context.Context, srcObj model.Obj, newName string) return nil, err } _, err := d.client.UpdateFile(ctx, &sdk.UpdateFileReq{ - FileID: srcObj.GetID(), - FileNma: newName, + FileID: srcObj.GetID(), + FileName: newName, }) if err != nil { return nil, err @@ -234,23 +236,21 @@ func (d *Open115) Rename(ctx context.Context, srcObj model.Obj, newName string) obj, ok := srcObj.(*Obj) if ok { obj.Fn = newName + return srcObj, nil } - return srcObj, nil + return nil, nil } -func (d *Open115) Copy(ctx context.Context, srcObj, dstDir model.Obj) (model.Obj, error) { +func (d *Open115) Copy(ctx context.Context, srcObj, dstDir model.Obj) error { if err := d.WaitLimit(ctx); err != nil { - return nil, err + return err } _, err := d.client.Copy(ctx, &sdk.CopyReq{ PID: dstDir.GetID(), FileID: srcObj.GetID(), NoDupli: "1", }) - if err != nil { - return nil, err - } - return srcObj, nil + return err } func (d *Open115) Remove(ctx context.Context, obj model.Obj) error { diff --git a/drivers/123/util.go b/drivers/123/util.go index 0af45b4e7..201e3eec0 100644 --- a/drivers/123/util.go +++ b/drivers/123/util.go @@ -200,8 +200,8 @@ func (d *Pan123) login() error { req := base.RestyClient.R() req.SetHeaders(map[string]string{ - /* "origin": "https://www.123pan.com", - "referer": "https://www.123pan.com/",*/ + "origin": "https://yun.123pan.com", + "referer": "https://yun.123pan.com/", "user-agent": d.params.UserAgent, "platform": d.params.Platform, "app-version": d.params.AppVersion, @@ -238,7 +238,7 @@ func (d *Pan123) login() error { return err } if utils.Json.Get(res.Body(), "code").ToInt() != 200 { - err = fmt.Errorf(utils.Json.Get(res.Body(), "message").ToString()) + err = errors.New(utils.Json.Get(res.Body(), "message").ToString()) } else { d.AccessToken = utils.Json.Get(res.Body(), "data", "token").ToString() } @@ -327,8 +327,8 @@ func (d *Pan123) Request(url string, method string, callback base.ReqCallback, r do: req := base.RestyClient.R() req.SetHeaders(map[string]string{ - /* "origin": "https://www.123pan.com", - "referer": "https://www.123pan.com/",*/ + "origin": "https://yun.123pan.com", + "referer": "https://yun.123pan.com/", "authorization": "Bearer " + d.AccessToken, "user-agent": d.params.UserAgent, "platform": d.params.Platform, diff --git a/drivers/139/driver.go b/drivers/139/driver.go index 65f214c67..c0d5ee50a 100644 --- a/drivers/139/driver.go +++ b/drivers/139/driver.go @@ -950,38 +950,14 @@ func (d *Yun139) GetDetails(ctx context.Context) (*model.StorageDetails, error) if d.UserDomainID == "" { return nil, errs.NotImplement } - var total, used int64 - if d.isFamily() { - diskInfo, err := d.getFamilyDiskInfo(ctx) - if err != nil { - return nil, err - } - totalMb, err := strconv.ParseInt(diskInfo.Data.DiskSize, 10, 64) - if err != nil { - return nil, fmt.Errorf("failed convert disk size into integer: %+v", err) - } - usedMb, err := strconv.ParseInt(diskInfo.Data.UsedSize, 10, 64) - if err != nil { - return nil, fmt.Errorf("failed convert used size into integer: %+v", err) - } - total = totalMb * 1024 * 1024 - used = usedMb * 1024 * 1024 - } else { - diskInfo, err := d.getPersonalDiskInfo(ctx) - if err != nil { - return nil, err - } - totalMb, err := strconv.ParseInt(diskInfo.Data.DiskSize, 10, 64) - if err != nil { - return nil, fmt.Errorf("failed convert disk size into integer: %+v", err) - } - freeMb, err := strconv.ParseInt(diskInfo.Data.FreeDiskSize, 10, 64) - if err != nil { - return nil, fmt.Errorf("failed convert free size into integer: %+v", err) - } - total = totalMb * 1024 * 1024 - used = total - (freeMb * 1024 * 1024) + detail, err := d.getDiskQuotaDetail(ctx) + if err != nil { + return nil, err } + + total := detail.Data.DiskSize * utils.MB + used := (detail.Data.DiskSize - detail.Data.FreeDiskSize) * utils.MB + return &model.StorageDetails{ DiskUsage: model.DiskUsage{ TotalSpace: total, diff --git a/drivers/139/types.go b/drivers/139/types.go index 499cdbc4d..8c23cdcbb 100644 --- a/drivers/139/types.go +++ b/drivers/139/types.go @@ -313,20 +313,16 @@ type RefreshTokenResp struct { Desc string `xml:"desc"` } -type PersonalDiskInfoResp struct { +type DiskQuotaDetail struct { BaseResp Data struct { - FreeDiskSize string `json:"freeDiskSize"` - DiskSize string `json:"diskSize"` - IsInfinitePicStorage *bool `json:"isInfinitePicStorage"` - } `json:"data"` -} - -type FamilyDiskInfoResp struct { - BaseResp - Data struct { - UsedSize string `json:"usedSize"` - DiskSize string `json:"diskSize"` + FreeDiskSize int64 `json:"freeDiskSize"` + DiskSize int64 `json:"diskSize"` + // QuotaList []struct { + // DriveType int `json:"driveType"` + // DriveName string `json:"driveName"` + // UsedSize int64 `json:"usedSize"` + // } `json:"quotaList"` } `json:"data"` } diff --git a/drivers/139/util.go b/drivers/139/util.go index 266946338..0f6fe4399 100644 --- a/drivers/139/util.go +++ b/drivers/139/util.go @@ -5,7 +5,6 @@ import ( "context" "crypto/aes" "crypto/cipher" - "crypto/md5" crypto_rand "crypto/rand" "crypto/sha1" "encoding/base64" @@ -208,9 +207,7 @@ func (d *Yun139) requestRoute(data interface{}, resp interface{}) ([]byte, error callback := func(req *resty.Request) { req.SetBody(data) } - if callback != nil { - callback(req) - } + callback(req) body, err := utils.Json.Marshal(req.Body) if err != nil { return nil, err @@ -731,12 +728,12 @@ func (d *Yun139) uploadPersonalParts(ctx context.Context, partInfos []PartInfo, return nil } -func (d *Yun139) getPersonalDiskInfo(ctx context.Context) (*PersonalDiskInfoResp, error) { +func (d *Yun139) getDiskQuotaDetail(ctx context.Context) (*DiskQuotaDetail, error) { data := map[string]interface{}{ "userDomainId": d.UserDomainID, } - var resp PersonalDiskInfoResp - _, err := d.request("https://user-njs.yun.139.com/user/disk/getPersonalDiskInfo", http.MethodPost, func(req *resty.Request) { + var resp DiskQuotaDetail + _, err := d.request("https://user-njs.yun.139.com/user/disk/quota/detail", http.MethodPost, func(req *resty.Request) { req.SetBody(data) req.SetContext(ctx) }, &resp) @@ -746,26 +743,6 @@ func (d *Yun139) getPersonalDiskInfo(ctx context.Context) (*PersonalDiskInfoResp return &resp, nil } -func (d *Yun139) getFamilyDiskInfo(ctx context.Context) (*FamilyDiskInfoResp, error) { - data := map[string]interface{}{ - "userDomainId": d.UserDomainID, - } - var resp FamilyDiskInfoResp - _, err := d.request("https://user-njs.yun.139.com/user/disk/getFamilyDiskInfo", http.MethodPost, func(req *resty.Request) { - req.SetBody(data) - req.SetContext(ctx) - }, &resp) - if err != nil { - return nil, err - } - return &resp, nil -} - -func getMd5(dataStr string) string { - hash := md5.Sum([]byte(dataStr)) - return fmt.Sprintf("%x", hash) -} - func (d *Yun139) step1_password_login() (string, error) { log.Debugf("--- 执行步骤 1: 登录 API ---") loginURL := "https://mail.10086.cn/Login/Login.ashx" diff --git a/drivers/189pc/driver.go b/drivers/189pc/driver.go index b481b110b..c4ce35e30 100644 --- a/drivers/189pc/driver.go +++ b/drivers/189pc/driver.go @@ -179,12 +179,17 @@ func (y *Cloud189PC) List(ctx context.Context, dir model.Obj, args model.ListArg func (y *Cloud189PC) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (*model.Link, error) { if strings.HasSuffix(strings.ToLower(file.GetName()), ".cas") { - link, cleanupTarget, err := y.resolveExistingCASFile(ctx, file) + casStream, err := openTransferredCASStream(ctx, y, file) if err != nil { return nil, err } - scheduleResolvedTempCleanup(ctx, y, cleanupTarget) - return link, nil + defer casStream.Close() + + info, err := readTransferredCASInfo(casStream) + if err != nil { + return nil, err + } + return y.RestoreCASForPlayback(ctx, file.GetName(), info) } return y.directLink(ctx, file) } @@ -301,6 +306,16 @@ func (y *Cloud189PC) Move(ctx context.Context, srcObj, dstDir model.Obj) (model. if err = y.WaitBatchTask("MOVE", resp.TaskID, time.Millisecond*400); err != nil { return nil, err } + + // 跟随移动 torrent 文件 + if !srcObj.IsDir() { + var srcFolderId string + if f, ok := srcObj.(*Cloud189File); ok { + srcFolderId = f.ParentID + } + y.torrentFollowMove(srcFolderId, srcObj.GetName(), dstDir) + } + return srcObj, nil } @@ -330,13 +345,19 @@ func (y *Cloud189PC) Rename(ctx context.Context, srcObj model.Obj, newName strin var resp RenameResp _, err := y.request(fullUrl, method, func(req *resty.Request) { req.SetContext(ctx).SetQueryParams(queryParam) - }, nil, resp, isFamily) + }, nil, &resp, isFamily) if err != nil { if code, ok := resp.ResCode.(string); ok && code == "FileAlreadyExists" { return nil, errs.ObjectAlreadyExists } return nil, err } + + // 跟随重命名 torrent 文件 + if f, ok := srcObj.(*Cloud189File); ok { + y.torrentFollowRename(f.ParentID, srcObj.GetName(), newName) + } + switch f := srcObj.(type) { case *Cloud189File: return resp.toFile(f), nil @@ -358,7 +379,20 @@ func (y *Cloud189PC) Copy(ctx context.Context, srcObj, dstDir model.Obj) error { if err != nil { return err } - return y.WaitBatchTask("COPY", resp.TaskID, time.Second) + if err = y.WaitBatchTask("COPY", resp.TaskID, time.Second); err != nil { + return err + } + + // 跟随复制 torrent 文件 + if !srcObj.IsDir() { + var srcFolderId string + if f, ok := srcObj.(*Cloud189File); ok { + srcFolderId = f.ParentID + } + y.torrentFollowCopy(srcFolderId, srcObj.GetName(), dstDir) + } + + return nil } func (y *Cloud189PC) Remove(ctx context.Context, obj model.Obj) error { diff --git a/drivers/189pc/extension.go b/drivers/189pc/extension.go index e236d3dae..5411ecedc 100644 --- a/drivers/189pc/extension.go +++ b/drivers/189pc/extension.go @@ -192,18 +192,7 @@ func (y *Cloud189PC) casRestoreTempDir() model.Obj { } } -func (y *Cloud189PC) resolveExistingCASFile(ctx context.Context, casFile model.Obj) (*model.Link, model.Obj, error) { - casStream, err := openTransferredCASStream(ctx, y, casFile) - if err != nil { - return nil, nil, err - } - defer casStream.Close() - - info, err := readTransferredCASInfo(casStream) - if err != nil { - return nil, nil, err - } - +func (y *Cloud189PC) resolveCASInfoForPlayback(ctx context.Context, casFileName string, info *casfile.Info) (*model.Link, model.Obj, *Cloud189PC, error) { forcedDriver := cloneDriverForCASRestore(y) forcedDriver.RestoreSourceUseCurrentName = false if y.FamilyID != "" { @@ -211,27 +200,52 @@ func (y *Cloud189PC) resolveExistingCASFile(ctx context.Context, casFile model.O } dstDir := forcedDriver.casRestoreTempDir() - restoredName, err := forcedDriver.resolveRestoreSourceName(casFile.GetName(), info) + restoredName, err := forcedDriver.resolveRestoreSourceName(casFileName, info) if err != nil { - return nil, nil, err + return nil, nil, nil, err } resolvedObj, err := findResolvedCASFileByName(ctx, forcedDriver, restoredName, dstDir.GetID()) if err != nil { if !errs.IsObjectNotFound(err) { - return nil, nil, err + return nil, nil, nil, err } - resolvedObj, err = restoreTransferredCASFromInfo(ctx, forcedDriver, dstDir, casFile.GetName(), info) + resolvedObj, err = restoreTransferredCASFromInfo(ctx, forcedDriver, dstDir, casFileName, info) if err != nil { - return nil, nil, err + return nil, nil, nil, err } } link, err := directLinkObj(ctx, forcedDriver, resolvedObj) + if err != nil { + return nil, nil, nil, err + } + return link, resolvedObj, forcedDriver, nil +} + +func (y *Cloud189PC) RestoreCASForPlayback(ctx context.Context, casFileName string, info *casfile.Info) (*model.Link, error) { + link, cleanupTarget, cleanupDriver, err := y.resolveCASInfoForPlayback(ctx, casFileName, info) + if err != nil { + return nil, err + } + scheduleResolvedTempCleanup(context.WithoutCancel(ctx), cleanupDriver, cleanupTarget) + return link, nil +} + +func (y *Cloud189PC) resolveExistingCASFile(ctx context.Context, casFile model.Obj) (*model.Link, model.Obj, error) { + casStream, err := openTransferredCASStream(ctx, y, casFile) if err != nil { return nil, nil, err } - return link, resolvedObj, nil + defer casStream.Close() + + info, err := readTransferredCASInfo(casStream) + if err != nil { + return nil, nil, err + } + + link, cleanupTarget, _, err := y.resolveCASInfoForPlayback(ctx, casFile.GetName(), info) + return link, cleanupTarget, err } func (y *Cloud189PC) scheduleDelayedResolvedTempCleanup(ctx context.Context, cleanupTarget model.Obj) { diff --git a/drivers/189pc/extension_test.go b/drivers/189pc/extension_test.go index db9f6a19e..24aeceb3c 100644 --- a/drivers/189pc/extension_test.go +++ b/drivers/189pc/extension_test.go @@ -387,6 +387,74 @@ func TestResolveExistingCASFile_RestoreFailureDoesNotFallback(t *testing.T) { } } +func TestRestoreCASForPlayback_RestoresLinksAndSchedulesDetachedCleanup(t *testing.T) { + type contextKey string + const key contextKey = "request-id" + + driver := &Cloud189PC{ + Storage: model.Storage{ID: 189}, + Addition: Addition{RestoreSourceUseCurrentName: true}, + TempDirId: "temp-dir-id", + } + info := &casfile.Info{Name: "payload.mkv", Size: 7, MD5: "abc", SliceMD5: "def"} + restoredObj := &Cloud189File{ID: "restored-id", Name: "payload.mkv"} + parent := context.WithValue(context.Background(), key, "req-1") + parent, cancel := context.WithCancel(parent) + cancel() + + linkSeamMu.Lock() + origFind := findResolvedCASFileByName + origRestore := restoreTransferredCASFromInfo + origLink := directLinkObj + origSchedule := scheduleResolvedTempCleanup + findResolvedCASFileByName = func(ctx context.Context, y *Cloud189PC, name string, folderID string) (model.Obj, error) { + if name != "payload.mkv" || folderID != "temp-dir-id" { + t.Fatalf("unexpected lookup name=%q folder=%q", name, folderID) + } + return nil, errs.ObjectNotFound + } + restoreTransferredCASFromInfo = func(ctx context.Context, y *Cloud189PC, dstDir model.Obj, casFileName string, got *casfile.Info) (model.Obj, error) { + if y.RestoreSourceUseCurrentName { + t.Fatal("expected payload-name semantics") + } + if casFileName != "renamed.cas" || got != info { + t.Fatalf("unexpected restore input name=%q info=%#v", casFileName, got) + } + return restoredObj, nil + } + directLinkObj = func(ctx context.Context, y *Cloud189PC, obj model.Obj) (*model.Link, error) { + return &model.Link{URL: "https://example.com/payload.mkv"}, nil + } + scheduled := false + scheduleResolvedTempCleanup = func(ctx context.Context, y *Cloud189PC, obj model.Obj) { + scheduled = true + if err := ctx.Err(); err != nil { + t.Fatalf("cleanup context remained canceled: %v", err) + } + if got := ctx.Value(key); got != "req-1" { + t.Fatalf("expected request value, got %v", got) + } + if obj != restoredObj { + t.Fatalf("unexpected cleanup target: %#v", obj) + } + } + t.Cleanup(func() { + findResolvedCASFileByName = origFind + restoreTransferredCASFromInfo = origRestore + directLinkObj = origLink + scheduleResolvedTempCleanup = origSchedule + linkSeamMu.Unlock() + }) + + link, err := driver.RestoreCASForPlayback(parent, "renamed.cas", info) + if err != nil { + t.Fatalf("restore CAS for playback: %v", err) + } + if link.URL != "https://example.com/payload.mkv" || !scheduled { + t.Fatalf("unexpected link or cleanup state: link=%#v scheduled=%v", link, scheduled) + } +} + func TestCloneDriverForCASRestore_ClonesCurrentFieldsAndResetsAutoRestoreState(t *testing.T) { cleanupCalled := 0 cleanup := func() { diff --git a/drivers/189pc/torrent.go b/drivers/189pc/torrent.go index 3068a0f7b..d37186627 100644 --- a/drivers/189pc/torrent.go +++ b/drivers/189pc/torrent.go @@ -1,6 +1,7 @@ package _189pc import ( + "bytes" "context" "crypto/sha1" "encoding/hex" @@ -8,10 +9,13 @@ import ( "io" "net/url" "strings" + "time" "github.com/go-resty/resty/v2" "github.com/OpenListTeam/OpenList/v4/internal/model" + "github.com/OpenListTeam/OpenList/v4/internal/op" + "github.com/OpenListTeam/OpenList/v4/internal/stream" "github.com/OpenListTeam/OpenList/v4/pkg/torrent" "github.com/OpenListTeam/OpenList/v4/pkg/utils" ) @@ -89,7 +93,6 @@ func (y *Cloud189PC) RapidUploadFromTorrent(ctx context.Context, dstDir model.Ob sliceMd5Hex = strings.ToUpper(utils.GetMD5EncodeStr(strings.Join(upperSliceMD5s, "\n"))) } - // 使用与 Web 端一致的三步秒传流程 fullUrl := "https://upload.cloud.189.cn" if isFamily { @@ -110,7 +113,6 @@ func (y *Cloud189PC) RapidUploadFromTorrent(ctx context.Context, dstDir model.Ob initParams.Set("familyId", y.FamilyID) } - var uploadInfo InitMultiUploadResp _, err = y.request(fullUrl+"/initMultiUpload", "GET", func(req *resty.Request) { req.SetContext(ctx) @@ -119,7 +121,6 @@ func (y *Cloud189PC) RapidUploadFromTorrent(ctx context.Context, dstDir model.Ob return nil, fmt.Errorf("initMultiUpload 失败: %w", err) } - uploadFileId := uploadInfo.Data.UploadFileID // Step 2: checkTransSecond(用 fileMd5 + sliceMd5 + uploadFileId 检查秒传) @@ -129,7 +130,6 @@ func (y *Cloud189PC) RapidUploadFromTorrent(ctx context.Context, dstDir model.Ob "uploadFileId": uploadFileId, } - var checkResp struct { Data struct { FileDataExists int `json:"fileDataExists"` @@ -143,7 +143,6 @@ func (y *Cloud189PC) RapidUploadFromTorrent(ctx context.Context, dstDir model.Ob return nil, fmt.Errorf("秒传检查失败: %w", err) } - if checkResp.Data.FileDataExists != 1 { return nil, fmt.Errorf("秒传失败:云端不存在该文件(fileMD5=%s, sliceMD5=%s, size=%d)", fileMD5Upper, sliceMd5Hex, fileSize) } @@ -167,6 +166,36 @@ func (y *Cloud189PC) RapidUploadFromTorrent(ctx context.Context, dstDir model.Ob return nil, fmt.Errorf("提交上传失败: %w", err) } + // 秒传成功后,将 torrent 文件上传到目标目录(异步,不影响秒传结果) + if y.Addition.GenerateTorrent { + capturedDstDir := dstDir + capturedIsFamily := isFamily + go func() { + torrentName := fileName + ".cas.torrent" + infoHash, _ := GetInfoHashHex(torrentData) + utils.Log.Infof("秒传成功,上传 torrent: %s (info_hash: %s, size: %d bytes)", + torrentName, infoHash, len(torrentData)) + + torrentFileStream := &stream.FileStream{ + Ctx: context.Background(), + Obj: &model.Object{ + Name: torrentName, + Size: int64(len(torrentData)), + IsFolder: false, + }, + Reader: bytes.NewReader(torrentData), + Mimetype: "application/x-bittorrent", + } + _, _, uploadErr := y.fastUpload(context.Background(), capturedDstDir, torrentFileStream, func(p float64) {}, capturedIsFamily, false, false) + if uploadErr != nil { + utils.Log.Warnf("上传 torrent 文件失败: %v", uploadErr) + } else { + utils.Log.Infof("torrent 文件已上传: %s", torrentName) + op.Cache.DeleteDirectory(y, capturedDstDir.GetPath()) + } + }() + } + return resp.toFile(), nil } @@ -263,6 +292,10 @@ func GetInfoHashHex(torrentData []byte) (string, error) { return hex.EncodeToString(t.InfoHash), nil } +func isCASTorrentFile(fileName string) bool { + return strings.HasSuffix(fileName, ".cas.torrent") +} + // ComputeSliceMD5sFromReader 从 reader 中计算每个 10MB 分片的 MD5 // 返回:整文件 MD5、分片 MD5 列表 func ComputeSliceMD5sFromReader(reader io.Reader, sliceSize int64) (string, []string, error) { @@ -294,3 +327,123 @@ func ComputeSliceMD5sFromReader(reader io.Reader, sliceSize int64) (string, []st fileMD5Hex := strings.ToUpper(hex.EncodeToString(fileMD5Hash.Sum(nil))) return fileMD5Hex, sliceMD5s, nil } + +// torrentFollowCopy 跟随复制 torrent 文件(异步,不影响主操作) +// srcFolderId: 源文件所在目录 ID +// srcFileName: 源文件名 +// dstDir: 目标目录 +func (y *Cloud189PC) torrentFollowCopy(srcFolderId string, srcFileName string, dstDir model.Obj) { + if !y.Addition.GenerateTorrent { + return + } + if srcFolderId == "" { + return + } + torrentName := srcFileName + ".cas.torrent" + isFamily := y.isFamily() + + go func() { + torrentFile, err := y.findFileByName(context.Background(), torrentName, srcFolderId, isFamily) + if err != nil { + utils.Log.Debugf("查找 torrent 文件失败(跟随复制): %v", err) + return + } + // 复制 torrent 文件到目标目录 + resp, copyErr := y.CreateBatchTask("COPY", IF(isFamily, y.FamilyID, ""), dstDir.GetID(), + map[string]string{"targetFileName": dstDir.GetName()}, + BatchTaskInfo{ + FileId: torrentFile.GetID(), + FileName: torrentFile.GetName(), + IsFolder: 0, + }) + if copyErr != nil { + utils.Log.Warnf("跟随复制 torrent 文件失败: %v", copyErr) + return + } + if err = y.WaitBatchTask("COPY", resp.TaskID, time.Second); err != nil { + utils.Log.Warnf("等待跟随复制 torrent 文件失败: %v", err) + } + }() +} + +// torrentFollowMove 跟随移动 torrent 文件(异步,不影响主操作) +// srcFolderId: 源文件所在目录 ID +// srcFileName: 源文件名 +// dstDir: 目标目录 +func (y *Cloud189PC) torrentFollowMove(srcFolderId string, srcFileName string, dstDir model.Obj) { + if !y.Addition.GenerateTorrent { + return + } + if srcFolderId == "" { + return + } + torrentName := srcFileName + ".cas.torrent" + isFamily := y.isFamily() + + go func() { + torrentFile, err := y.findFileByName(context.Background(), torrentName, srcFolderId, isFamily) + if err != nil { + utils.Log.Debugf("查找 torrent 文件失败(跟随移动): %v", err) + return + } + // 移动 torrent 文件到目标目录 + resp, moveErr := y.CreateBatchTask("MOVE", IF(isFamily, y.FamilyID, ""), dstDir.GetID(), + map[string]string{"targetFileName": dstDir.GetName()}, + BatchTaskInfo{ + FileId: torrentFile.GetID(), + FileName: torrentFile.GetName(), + IsFolder: 0, + }) + if moveErr != nil { + utils.Log.Warnf("跟随移动 torrent 文件失败: %v", moveErr) + return + } + if err = y.WaitBatchTask("MOVE", resp.TaskID, time.Millisecond*400); err != nil { + utils.Log.Warnf("等待跟随移动 torrent 文件失败: %v", err) + } + }() +} + +// torrentFollowRename 跟随重命名 torrent 文件(异步,不影响主操作) +// folderId: 文件所在目录 ID +// oldFileName: 原文件名 +// newFileName: 新文件名 +func (y *Cloud189PC) torrentFollowRename(folderId string, oldFileName string, newFileName string) { + if !y.Addition.GenerateTorrent { + return + } + if folderId == "" { + return + } + oldTorrentName := oldFileName + ".cas.torrent" + newTorrentName := newFileName + ".cas.torrent" + isFamily := y.isFamily() + + go func() { + torrentFile, err := y.findFileByName(context.Background(), oldTorrentName, folderId, isFamily) + if err != nil { + utils.Log.Debugf("查找 torrent 文件失败(跟随重命名): %v", err) + return + } + + // 重命名 torrent 文件 + queryParam := make(map[string]string) + fullUrl := API_URL + method := "POST" + if isFamily { + fullUrl += "/family/file" + method = "GET" + queryParam["familyId"] = y.FamilyID + } + fullUrl += "/renameFile.action" + queryParam["fileId"] = torrentFile.GetID() + queryParam["destFileName"] = newTorrentName + + _, renameErr := y.request(fullUrl, method, func(req *resty.Request) { + req.SetContext(context.Background()).SetQueryParams(queryParam) + }, nil, &RenameResp{}, isFamily) + if renameErr != nil { + utils.Log.Warnf("跟随重命名 torrent 文件失败: %v", renameErr) + } + }() +} diff --git a/drivers/189pc/types.go b/drivers/189pc/types.go index faeafa529..f6a9e678f 100644 --- a/drivers/189pc/types.go +++ b/drivers/189pc/types.go @@ -166,10 +166,11 @@ type FamilyInfoResp struct { /*文件部分*/ // 文件 type Cloud189File struct { - ID String `json:"id"` - Name string `json:"name"` - Size int64 `json:"size"` - Md5 string `json:"md5"` + ID String `json:"id"` + Name string `json:"name"` + Size int64 `json:"size"` + Md5 string `json:"md5"` + ParentID string `json:"-"` // 由 getFiles 设置,不从 JSON 解析 LastOpTime Time `json:"lastOpTime"` CreateDate Time `json:"createDate"` @@ -434,7 +435,7 @@ type RenameResp struct { ResMsg string `json:"res_message"` CreateDate Time `json:"createDate"` FileCate int `json:"fileCata"` - ID string `json:"id"` + ID String `json:"id"` LastOpTime Time `json:"lastOpTime"` MD5 string `json:"md5"` MediaType int `json:"mediaType"` @@ -448,7 +449,7 @@ type RenameResp struct { func (r *RenameResp) toFile(f *Cloud189File) *Cloud189File { return &Cloud189File{ - ID: String(r.ID), + ID: r.ID, Name: r.Name, Size: r.Size, Md5: r.MD5, @@ -460,7 +461,7 @@ func (r *RenameResp) toFile(f *Cloud189File) *Cloud189File { func (r *RenameResp) toFolder() *Cloud189Folder { return &Cloud189Folder{ - ID: String(r.ID), + ID: r.ID, Name: r.Name, ParentID: r.ParentID, LastOpTime: r.LastOpTime, diff --git a/drivers/189pc/utils.go b/drivers/189pc/utils.go index 0e3306770..b0026b841 100644 --- a/drivers/189pc/utils.go +++ b/drivers/189pc/utils.go @@ -210,6 +210,7 @@ func (y *Cloud189PC) getFiles(ctx context.Context, fileId string, isFamily bool) res = append(res, &resp.FileListAO.FolderList[i]) } for i := 0; i < len(resp.FileListAO.FileList); i++ { + resp.FileListAO.FileList[i].ParentID = fileId res = append(res, &resp.FileListAO.FileList[i]) } } @@ -920,6 +921,11 @@ func (y *Cloud189PC) RapidUpload(ctx context.Context, dstDir model.Obj, stream m // 快传 func (y *Cloud189PC) FastUpload(ctx context.Context, dstDir model.Obj, file model.FileStreamer, up driver.UpdateProgress, isFamily bool, overwrite bool) (model.Obj, *casUploadInfo, error) { + generateTorrent := y.Addition.GenerateTorrent && !isCASTorrentFile(file.GetName()) + return y.fastUpload(ctx, dstDir, file, up, isFamily, overwrite, generateTorrent) +} + +func (y *Cloud189PC) fastUpload(ctx context.Context, dstDir model.Obj, file model.FileStreamer, up driver.UpdateProgress, isFamily bool, overwrite bool, generateTorrent bool) (model.Obj, *casUploadInfo, error) { var ( cache = file.GetFile() tmpF *os.File @@ -957,6 +963,9 @@ func (y *Cloud189PC) FastUpload(ctx context.Context, dstDir model.Obj, file mode if tmpF != nil { writers = append(writers, tmpF) } + + pieceSHA1Hashes := make([]byte, 0, count*20) + written := int64(0) for i := 1; i <= count; i++ { if utils.IsCanceled(ctx) { @@ -967,7 +976,17 @@ func (y *Cloud189PC) FastUpload(ctx context.Context, dstDir model.Obj, file mode byteSize = lastSliceSize } - n, err := utils.CopyWithBufferN(io.MultiWriter(writers...), file, byteSize) + // 如果需要生成 torrent,同时计算 SHA-1 + var sha1Writer hash.Hash + var multiWriter io.Writer + if generateTorrent { + sha1Writer = sha1Pkg.New() + multiWriter = io.MultiWriter(append(writers, sha1Writer)...) + } else { + multiWriter = io.MultiWriter(writers...) + } + + n, err := utils.CopyWithBufferN(multiWriter, file, byteSize) written += n if err != nil && err != io.EOF { return nil, nil, err @@ -976,6 +995,11 @@ func (y *Cloud189PC) FastUpload(ctx context.Context, dstDir model.Obj, file mode sliceMd5Hexs = append(sliceMd5Hexs, strings.ToUpper(hex.EncodeToString(md5Byte))) partInfos = append(partInfos, fmt.Sprint(i, "-", base64.StdEncoding.EncodeToString(md5Byte))) sliceMd5.Reset() + + // 收集 SHA-1 piece hash(仅在本次分片实际写入了数据时追加) + if generateTorrent && n > 0 { + pieceSHA1Hashes = append(pieceSHA1Hashes, sha1Writer.Sum(nil)...) + } } if tmpF != nil { @@ -1091,6 +1115,44 @@ func (y *Cloud189PC) FastUpload(ctx context.Context, dstDir model.Obj, file mode if err != nil { return nil, nil, err } + + // 生成 torrent 文件(异步,不影响上传结果) + if generateTorrent && size > 0 && len(pieceSHA1Hashes) > 0 { + capturedDstDir := dstDir + capturedIsFamily := isFamily + capturedFileName := file.GetName() + go func() { + torrentData, err := GenerateTorrent(capturedFileName, size, fileMd5Hex, sliceMd5Hexs, sliceSize, pieceSHA1Hashes) + if err != nil { + utils.Log.Warnf("生成 torrent 失败: %v", err) + return + } + infoHash, _ := GetInfoHashHex(torrentData) + torrentName := capturedFileName + ".cas.torrent" + utils.Log.Infof("已生成 torrent: %s (info_hash: %s, size: %d bytes)", + torrentName, infoHash, len(torrentData)) + + // 将 torrent 文件上传到同一目录 + torrentFileStream := &stream.FileStream{ + Ctx: context.Background(), + Obj: &model.Object{ + Name: torrentName, + Size: int64(len(torrentData)), + IsFolder: false, + }, + Reader: bytes.NewReader(torrentData), + Mimetype: "application/x-bittorrent", + } + _, _, uploadErr := y.fastUpload(context.Background(), capturedDstDir, torrentFileStream, func(p float64) {}, capturedIsFamily, false, false) + if uploadErr != nil { + utils.Log.Warnf("上传 torrent 文件失败: %v", uploadErr) + } else { + utils.Log.Infof("torrent 文件已上传: %s", torrentName) + op.Cache.DeleteDirectory(y, capturedDstDir.GetPath()) + } + }() + } + return resp.toFile(), &casUploadInfo{ Name: file.GetName(), Size: size, diff --git a/drivers/alias/driver.go b/drivers/alias/driver.go index e1ba41eb6..7008495ce 100644 --- a/drivers/alias/driver.go +++ b/drivers/alias/driver.go @@ -249,6 +249,7 @@ func (d *Alias) Link(ctx context.Context, file model.Obj, args model.LinkArgs) ( } linkClosers := make([]io.Closer, 0, len(files)) rrf := make([]model.RangeReaderIF, 0, len(files)) + requireReference := false for _, f := range files { link, fi, err := d.link(ctx, f.GetPath(), args) if err != nil { @@ -258,9 +259,13 @@ func (d *Alias) Link(ctx context.Context, file model.Obj, args model.LinkArgs) ( _ = link.Close() continue } - l := *link // 复制一份,避免修改到原始link - if l.ContentLength == 0 { - l.ContentLength = fi.GetSize() + l := &model.Link{ + URL: link.URL, + Header: link.Header, + RangeReader: link.RangeReader, + Concurrency: link.Concurrency, + PartSize: link.PartSize, + ContentLength: link.ContentLength, } if d.DownloadConcurrency > 0 { l.Concurrency = d.DownloadConcurrency @@ -268,20 +273,27 @@ func (d *Alias) Link(ctx context.Context, file model.Obj, args model.LinkArgs) ( if d.DownloadPartSize > 0 { l.PartSize = d.DownloadPartSize * utils.KB } - rr, err := stream.GetRangeReaderFromLink(l.ContentLength, &l) + if l.ContentLength == 0 { + l.ContentLength = fi.GetSize() + } + rr, err := stream.GetRangeReaderFromLink(l.ContentLength, l) if err != nil { _ = link.Close() continue } linkClosers = append(linkClosers, link) + if link.RequireReference { + requireReference = true + } rrf = append(rrf, rr) } rr := func(ctx context.Context, httpRange http_range.Range) (io.ReadCloser, error) { return rrf[rand.Intn(len(rrf))].RangeRead(ctx, httpRange) } return &model.Link{ - RangeReader: stream.RangeReaderFunc(rr), - SyncClosers: utils.NewSyncClosers(linkClosers...), + RangeReader: stream.RangeReaderFunc(rr), + SyncClosers: utils.NewSyncClosers(linkClosers...), + RequireReference: requireReference, }, nil } @@ -315,14 +327,10 @@ func (d *Alias) Link(ctx context.Context, file model.Obj, args model.LinkArgs) ( if err != nil { return nil, err } - resultLink := *link // 复制一份,避免修改到原始link + resultLink := link.Clone() // 复制一份,避免修改到原始link resultLink.Expiration = nil - resultLink.SyncClosers = utils.NewSyncClosers(link) if args.Redirect { - return &resultLink, nil - } - if resultLink.ContentLength == 0 { - resultLink.ContentLength = fi.GetSize() + return resultLink, nil } if d.DownloadConcurrency > 0 { resultLink.Concurrency = d.DownloadConcurrency @@ -330,7 +338,10 @@ func (d *Alias) Link(ctx context.Context, file model.Obj, args model.LinkArgs) ( if d.DownloadPartSize > 0 { resultLink.PartSize = d.DownloadPartSize * utils.KB } - return &resultLink, nil + if resultLink.ContentLength == 0 { + resultLink.ContentLength = fi.GetSize() + } + return resultLink, nil } func (d *Alias) Other(ctx context.Context, args model.OtherArgs) (interface{}, error) { @@ -501,9 +512,7 @@ func (d *Alias) Extract(ctx context.Context, obj model.Obj, args model.ArchiveIn sign.SignArchive(reqPath)), }, nil } - resultLink := *link - resultLink.SyncClosers = utils.NewSyncClosers(link) - return &resultLink, nil + return link.Clone(), nil } func (d *Alias) ArchiveDecompress(ctx context.Context, srcObj, dstDir model.Obj, args model.ArchiveDecompressArgs) error { diff --git a/drivers/alidoc/driver.go b/drivers/alidoc/driver.go new file mode 100644 index 000000000..3d1a35112 --- /dev/null +++ b/drivers/alidoc/driver.go @@ -0,0 +1,147 @@ +package alidoc + +import ( + "context" + "fmt" + "net/http" + "strings" + + "github.com/OpenListTeam/OpenList/v4/drivers/base" + "github.com/OpenListTeam/OpenList/v4/internal/driver" + "github.com/OpenListTeam/OpenList/v4/internal/model" + "github.com/go-resty/resty/v2" +) + +type AliDoc struct { + model.Storage + Addition + + client *resty.Client +} + +func (d *AliDoc) Config() driver.Config { + return config +} + +func (d *AliDoc) GetAddition() driver.Additional { + return &d.Addition +} + +func (d *AliDoc) Init(ctx context.Context) error { + d.Cookie = strings.TrimSpace(d.Cookie) + d.RootFolderID = strings.TrimSpace(d.RootFolderID) + if d.Cookie == "" { + return fmt.Errorf("cookie is empty") + } + if d.RootFolderID == "" { + return fmt.Errorf("root folder id is empty") + } + d.client = newClient() + if err := d.checkCookie(ctx); err != nil { + return err + } + return nil +} + +func (d *AliDoc) Drop(ctx context.Context) error { + d.client = nil + return nil +} + +func (d *AliDoc) List(ctx context.Context, dir model.Obj, args model.ListArgs) ([]model.Obj, error) { + items, err := d.list(ctx, dir.GetID()) + if err != nil { + return nil, err + } + + objs := make([]model.Obj, 0, len(items)) + for _, item := range items { + if strings.TrimSpace(item.DentryUUID) == "" || strings.TrimSpace(item.Name) == "" { + continue + } + objs = append(objs, toObj(item)) + } + return objs, nil +} + +func (d *AliDoc) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (*model.Link, error) { + if file.IsDir() { + return nil, fmt.Errorf("alidoc does not support directory links") + } + resp, err := d.download(ctx, file.GetID()) + if err != nil { + return nil, err + } + url, err := firstDownloadURL(resp) + if err != nil { + return nil, err + } + return &model.Link{ + URL: url, + Header: http.Header{ + "User-Agent": []string{base.UserAgent}, + "Referer": []string{apiBase + "/"}, + }, + }, nil +} + +func (d *AliDoc) MakeDir(ctx context.Context, parentDir model.Obj, dirName string) (model.Obj, error) { + err := d.post(ctx, "/box/api/v2/dentry/createfolder", map[string]string{ + "dentryType": "folder", + "name": dirName, + "parentDentryUuid": parentDir.GetID(), + "conflictHandleStrategy": "auto_rename", + }) + if err != nil { + return nil, err + } + return nil, nil +} + +func (d *AliDoc) Move(ctx context.Context, srcObj, dstDir model.Obj) (model.Obj, error) { + err := d.post(ctx, "/box/api/v2/dentry/move", map[string]interface{}{ + "targetParentDentryUuid": dstDir.GetID(), + "sourceDentryUuid": srcObj.GetID(), + "operateFrom": 1, + }) + if err != nil { + return nil, err + } + return srcObj, nil +} + +func (d *AliDoc) Rename(ctx context.Context, srcObj model.Obj, newName string) (model.Obj, error) { + err := d.post(ctx, "/box/api/v2/dentry/rename", map[string]string{ + "dentryUuid": srcObj.GetID(), + "name": newName, + }) + if err != nil { + return nil, err + } + srcObj.(*model.Object).Name = newName + return srcObj, nil +} + +func (d *AliDoc) Copy(ctx context.Context, srcObj, dstDir model.Obj) error { + return d.post(ctx, "/box/api/v2/dentry/copy", map[string]interface{}{ + "sourceDentryUuid": srcObj.GetID(), + "targetParentDentryUuid": dstDir.GetID(), + "operateFrom": 1, + "onlyCopyMeta": false, + }) +} + +func (d *AliDoc) Remove(ctx context.Context, obj model.Obj) error { + return d.post(ctx, "/box/api/v1/dentry/recycle", map[string]string{ + "dentryUuid": obj.GetID(), + }) +} + +var ( + _ driver.Driver = (*AliDoc)(nil) + _ driver.MkdirResult = (*AliDoc)(nil) + _ driver.MoveResult = (*AliDoc)(nil) + _ driver.RenameResult = (*AliDoc)(nil) + _ driver.Copy = (*AliDoc)(nil) + _ driver.Remove = (*AliDoc)(nil) +) diff --git a/drivers/alidoc/meta.go b/drivers/alidoc/meta.go new file mode 100644 index 000000000..3c0fa19a9 --- /dev/null +++ b/drivers/alidoc/meta.go @@ -0,0 +1,23 @@ +package alidoc + +import ( + "github.com/OpenListTeam/OpenList/v4/internal/driver" + "github.com/OpenListTeam/OpenList/v4/internal/op" +) + +type Addition struct { + driver.RootID + Cookie string `json:"cookie" type:"text" required:"true" help:"钉钉文档网页 Cookie"` +} + +var config = driver.Config{ + Name: "AliDoc", + LocalSort: true, + DefaultRoot: "", +} + +func init() { + op.RegisterDriver(func() driver.Driver { + return &AliDoc{} + }) +} diff --git a/drivers/alidoc/types.go b/drivers/alidoc/types.go new file mode 100644 index 000000000..171d401a0 --- /dev/null +++ b/drivers/alidoc/types.go @@ -0,0 +1,87 @@ +package alidoc + +type apiResp struct { + Status int `json:"status"` + IsSuccess bool `json:"isSuccess"` + Message string `json:"message"` + Msg string `json:"msg"` +} + +func (r apiResp) ErrMessage() string { + if r.Message != "" { + return r.Message + } + if r.Msg != "" { + return r.Msg + } + return "" +} + +type listResp struct { + apiResp + Data listData `json:"data"` +} + +type listData struct { + Children []dentry `json:"children"` +} + +type dentry struct { + DentryType string `json:"dentryType"` + DentryUUID string `json:"dentryUuid"` + ParentDentryUUID string `json:"parentDentryUuid"` + Name string `json:"name"` + Path string `json:"path"` + FileSize int64 `json:"fileSize"` + CreatedTime int64 `json:"createdTime"` + UpdatedTime int64 `json:"updatedTime"` + ContentType string `json:"contentType"` + Extension string `json:"extension"` + DentryStatistic struct { + ChildrenCount int `json:"childrenCount"` + } `json:"dentryStatistic"` + URL struct { + PCChildAppPreviewURL string `json:"pcChildAppPreviewUrl"` + PCChildAppURL string `json:"pcChildAppUrl"` + } `json:"url"` +} + +type downloadResp struct { + apiResp + Data downloadData `json:"data"` +} + +type downloadData struct { + OSSURLPreSignatureInfo struct { + PreSignURLs []string `json:"preSignUrls"` + } `json:"ossUrlPreSignatureInfo"` +} + +type uploadInfoResp struct { + apiResp + Data uploadInfoData `json:"data"` +} + +type uploadInfoData struct { + CurrentTimestamp int64 `json:"currentTimestamp"` + FileUploadProtocolConfig uploadProtocolConfig `json:"fileUploadProtocolConfig"` + STSSignatureInfo uploadSTSSignatureInfo `json:"stsSignatureInfo"` + UploadKey string `json:"uploadKey"` + UploadType string `json:"uploadType"` +} + +type uploadProtocolConfig struct { + MinPartSize int64 `json:"minPartSize"` +} + +type uploadSTSSignatureInfo struct { + AccelerateCname string `json:"accelerateCname"` + AccessKeyID string `json:"accessKeyId"` + AccessKeySecret string `json:"accessKeySecret"` + AccessToken string `json:"accessToken"` + AccessTokenExpiration int64 `json:"accessTokenExpiration"` + Bucket string `json:"bucket"` + Cname string `json:"cname"` + EndPoint string `json:"endPoint"` + ObjectKey string `json:"objectKey"` +} diff --git a/drivers/alidoc/upload.go b/drivers/alidoc/upload.go new file mode 100644 index 000000000..a8c2eacb7 --- /dev/null +++ b/drivers/alidoc/upload.go @@ -0,0 +1,263 @@ +package alidoc + +import ( + "context" + "fmt" + "io" + "math" + "strings" + "time" + + "github.com/OpenListTeam/OpenList/v4/internal/driver" + "github.com/OpenListTeam/OpenList/v4/internal/model" + netutil "github.com/OpenListTeam/OpenList/v4/internal/net" + streamPkg "github.com/OpenListTeam/OpenList/v4/internal/stream" + "github.com/aliyun/aliyun-oss-go-sdk/oss" + "github.com/avast/retry-go" + "github.com/google/uuid" +) + +const ( + defaultAliDocMultipartThreshold = 16 * 1024 * 1024 + defaultAliDocPartSize = 100 * 1024 + maxAliDocMultipartParts = 10000 +) + +func (d *AliDoc) Put(ctx context.Context, dstDir model.Obj, file model.FileStreamer, up driver.UpdateProgress) error { + size := file.GetSize() + useMultipart := size > defaultAliDocMultipartThreshold + info, err := d.getUploadInfo(ctx, dstDir.GetID(), file.GetName(), size, useMultipart) + if err != nil { + return err + } + if size > 0 { + partSize := calcAliDocPartSize(size, info.Data.FileUploadProtocolConfig.MinPartSize) + if size > partSize && !useMultipart { + useMultipart = true + info, err = d.getUploadInfo(ctx, dstDir.GetID(), file.GetName(), size, true) + if err != nil { + return err + } + } + } + + if useMultipart { + err = d.multipartUpload(ctx, file, size, info, up) + } else { + err = d.singleUpload(ctx, file, size, info, up) + } + if err != nil { + return err + } + if err := d.commitUpload(ctx, dstDir.GetID(), file.GetName(), size, info.Data.UploadKey); err != nil { + return err + } + return nil +} + +func (d *AliDoc) getUploadInfo(ctx context.Context, parentDentryUUID, name string, fileSize int64, multipart bool) (uploadInfoResp, error) { + var result uploadInfoResp + body := map[string]interface{}{ + "uploadType": "STS_SIGNATURE", + "supportUploadTypes": []string{"STS_SIGNATURE", "HTTP_TO_CENTER"}, + "parentDentryUuid": parentDentryUUID, + "fileSize": fileSize, + "name": name, + "multipart": multipart, + } + resp, err := d.request(ctx). + SetBody(body). + SetResult(&result). + SetError(&result). + Post(apiBase + "/box/api/v2/file/uploadinfo") + if err != nil { + return result, err + } + if err := checkResp(resp, result.apiResp); err != nil { + return result, err + } + if strings.TrimSpace(result.Data.STSSignatureInfo.Bucket) == "" { + return result, fmt.Errorf("empty upload bucket") + } + return result, nil +} + +func (d *AliDoc) commitUpload(ctx context.Context, parentDentryUUID, name string, fileSize int64, uploadKey string) error { + uploadKey = strings.TrimSpace(uploadKey) + if uploadKey == "" { + return fmt.Errorf("empty upload key") + } + + var result apiResp + body := map[string]interface{}{ + "parentDentryUuid": parentDentryUUID, + "uploadKey": uploadKey, + "fileSize": fileSize, + "name": name, + "toPrevDentryUuid": nil, + "toNextDentryUuid": nil, + "batchId": uuid.NewString(), + "batchUploadType": 1, + "batchParentDentryUuid": parentDentryUUID, + } + resp, err := d.request(ctx). + SetBody(body). + SetResult(&result). + SetError(&result). + Post(apiBase + "/box/api/v2/file/commit") + if err != nil { + return err + } + return checkResp(resp, result) +} + +func calcAliDocPartSize(fileSize, minPartSize int64) int64 { + partSize := minPartSize + if partSize <= 0 { + partSize = defaultAliDocPartSize + } + if fileSize <= 0 { + return partSize + } + minRequired := int64(math.Ceil(float64(fileSize) / maxAliDocMultipartParts)) + if minRequired > partSize { + partSize = minRequired + } + return partSize +} + +func (d *AliDoc) singleUpload(ctx context.Context, src model.FileStreamer, size int64, info uploadInfoResp, up driver.UpdateProgress) error { + bucket, objectKey, err := d.newOSSBucket(info) + if err != nil { + return err + } + err = bucket.PutObject( + objectKey, + driver.NewLimitedUploadStream(ctx, io.TeeReader(src, driver.NewProgress(size, up))), + ) + if err != nil { + return err + } + up(100) + return nil +} + +func (d *AliDoc) multipartUpload(ctx context.Context, src model.FileStreamer, size int64, info uploadInfoResp, up driver.UpdateProgress) error { + bucket, objectKey, err := d.newOSSBucket(info) + if err != nil { + return err + } + + imur, err := bucket.InitiateMultipartUpload(objectKey, oss.Sequential()) + if err != nil { + return err + } + + partSize := calcAliDocPartSize(size, info.Data.FileUploadProtocolConfig.MinPartSize) + partNum := int((size + partSize - 1) / partSize) + parts := make([]oss.UploadPart, 0, partNum) + ss, err := streamPkg.NewStreamSectionReader(src, int(partSize), &up) + if err != nil { + return err + } + + var offset int64 + for partNumber := 1; partNumber <= partNum; partNumber++ { + if err := ctx.Err(); err != nil { + return err + } + length := partSize + if remain := size - offset; remain < length { + length = remain + } + + reader, err := ss.GetSectionReader(offset, length) + if err != nil { + return err + } + var part oss.UploadPart + var uploadErr error + err = retry.Do(func() error { + if _, err := reader.Seek(0, io.SeekStart); err != nil { + return err + } + part, uploadErr = bucket.UploadPart( + imur, + driver.NewLimitedUploadStream(ctx, reader), + length, + partNumber, + ) + return uploadErr + }, + retry.Context(ctx), + retry.Attempts(3), + retry.DelayType(retry.BackOffDelay), + retry.Delay(time.Second), + ) + ss.FreeSectionReader(reader) + if err != nil { + return err + } + parts = append(parts, part) + up(100 * float64(len(parts)) / float64(partNum+1)) + offset += length + } + + _, err = bucket.CompleteMultipartUpload(imur, parts) + if err != nil { + return err + } + up(100) + return nil +} + +func (d *AliDoc) newOSSBucket(info uploadInfoResp) (*oss.Bucket, string, error) { + sts := info.Data.STSSignatureInfo + objectKey := strings.TrimSpace(sts.ObjectKey) + if objectKey == "" { + objectKey = strings.TrimSpace(info.Data.UploadKey) + } + if objectKey == "" { + return nil, "", fmt.Errorf("empty upload object key") + } + + endpoint, useCname := pickAliDocOSSEndpoint(sts) + if endpoint == "" { + return nil, "", fmt.Errorf("empty upload endpoint") + } + if !strings.HasPrefix(endpoint, "http://") && !strings.HasPrefix(endpoint, "https://") { + endpoint = "https://" + endpoint + } + + options := []oss.ClientOption{oss.SecurityToken(sts.AccessToken)} + if useCname { + options = append(options, oss.UseCname(true)) + } + client, err := netutil.NewOSSClient( + endpoint, + sts.AccessKeyID, + sts.AccessKeySecret, + options..., + ) + if err != nil { + return nil, "", err + } + bucket, err := client.Bucket(sts.Bucket) + if err != nil { + return nil, "", err + } + return bucket, objectKey, nil +} + +func pickAliDocOSSEndpoint(sts uploadSTSSignatureInfo) (endpoint string, useCname bool) { + if endpoint = strings.TrimSpace(sts.EndPoint); endpoint != "" { + return endpoint, false + } + if endpoint = strings.TrimSpace(sts.Cname); endpoint != "" { + return endpoint, true + } + if endpoint = strings.TrimSpace(sts.AccelerateCname); endpoint != "" { + return endpoint, true + } + return "", false +} diff --git a/drivers/alidoc/util.go b/drivers/alidoc/util.go new file mode 100644 index 000000000..5e7d8cfa6 --- /dev/null +++ b/drivers/alidoc/util.go @@ -0,0 +1,135 @@ +package alidoc + +import ( + "context" + "fmt" + "time" + + "github.com/OpenListTeam/OpenList/v4/drivers/base" + "github.com/OpenListTeam/OpenList/v4/internal/model" + "github.com/go-resty/resty/v2" +) + +const apiBase = "https://alidocs.dingtalk.com" + +func (d *AliDoc) request(ctx context.Context) *resty.Request { + return d.client.R(). + SetContext(ctx). + SetHeader("Cookie", d.Cookie). + SetHeader("Accept", "application/json, text/plain, */*"). + SetHeader("Referer", apiBase+"/"). + SetHeader("Origin", apiBase) +} + +func msToTime(v int64) time.Time { + if v <= 0 { + return time.Time{} + } + return time.UnixMilli(v) +} + +func checkResp(resp *resty.Response, result apiResp) error { + if resp != nil && resp.IsError() { + if msg := result.ErrMessage(); msg != "" { + return fmt.Errorf("%s", msg) + } + return fmt.Errorf("http error: %d", resp.StatusCode()) + } + if !result.IsSuccess || result.Status != 200 { + msg := result.ErrMessage() + if msg == "" { + msg = "request failed" + } + return fmt.Errorf("%s", msg) + } + return nil +} + +func toObj(item dentry) model.Obj { + return &model.Object{ + ID: item.DentryUUID, + Name: item.Name, + Size: item.FileSize, + Modified: msToTime(item.UpdatedTime), + Ctime: msToTime(item.CreatedTime), + IsFolder: item.DentryType == "folder", + } +} + +func firstDownloadURL(resp downloadResp) (string, error) { + if len(resp.Data.OSSURLPreSignatureInfo.PreSignURLs) == 0 { + return "", fmt.Errorf("empty download url") + } + return resp.Data.OSSURLPreSignatureInfo.PreSignURLs[0], nil +} + +func newClient() *resty.Client { + client := base.NewRestyClient() + client.SetHeader("User-Agent", base.UserAgent) + return client +} + +func (d *AliDoc) post(ctx context.Context, path string, body interface{}) error { + var result apiResp + resp, err := d.request(ctx). + SetBody(body). + SetResult(&result). + SetError(&result). + Post(apiBase + path) + if err != nil { + return err + } + return checkResp(resp, result) +} + +func (d *AliDoc) checkCookie(ctx context.Context) error { + var result apiResp + resp, err := d.request(ctx). + SetResult(&result). + SetError(&result). + Get(apiBase + "/portal/api/v1/mine/info") + if err != nil { + return err + } + return checkResp(resp, result) +} + +func (d *AliDoc) list(ctx context.Context, dentryUUID string) ([]dentry, error) { + var result listResp + resp, err := d.request(ctx). + SetQueryParam("dentryUuid", dentryUUID). + SetQueryParam("withParentAncestors", "true"). + SetQueryParam("orderType", "SORT_KEY"). + SetQueryParam("sortType", "desc"). + SetQueryParam("listDentrySource", "2"). + SetQueryParam("pageSize", "1000"). + SetResult(&result). + SetError(&result). + Get(apiBase + "/box/api/v2/dentry/list") + if err != nil { + return nil, err + } + if err := checkResp(resp, result.apiResp); err != nil { + return nil, err + } + return result.Data.Children, nil +} + +func (d *AliDoc) download(ctx context.Context, dentryUUID string) (downloadResp, error) { + var result downloadResp + resp, err := d.request(ctx). + SetQueryParam("dentryUuid", dentryUUID). + SetQueryParam("version", "1"). + SetQueryParam("supportDownloadTypes", "URL_PRE_SIGNATURE,HTTP_TO_CENTER"). + SetQueryParam("downloadType", "URL_PRE_SIGNATURE"). + SetResult(&result). + SetError(&result). + Get(apiBase + "/box/api/v2/file/download") + if err != nil { + return result, err + } + if err := checkResp(resp, result.apiResp); err != nil { + return result, err + } + return result, nil +} diff --git a/drivers/all.go b/drivers/all.go index 5800f88fd..ddf86130c 100644 --- a/drivers/all.go +++ b/drivers/all.go @@ -14,6 +14,7 @@ import ( _ "github.com/OpenListTeam/OpenList/v4/drivers/189_tv" _ "github.com/OpenListTeam/OpenList/v4/drivers/189pc" _ "github.com/OpenListTeam/OpenList/v4/drivers/alias" + _ "github.com/OpenListTeam/OpenList/v4/drivers/alidoc" _ "github.com/OpenListTeam/OpenList/v4/drivers/alist_v3" _ "github.com/OpenListTeam/OpenList/v4/drivers/aliyundrive" _ "github.com/OpenListTeam/OpenList/v4/drivers/aliyundrive_open" @@ -24,6 +25,7 @@ import ( _ "github.com/OpenListTeam/OpenList/v4/drivers/baidu_photo" _ "github.com/OpenListTeam/OpenList/v4/drivers/chaoxing" _ "github.com/OpenListTeam/OpenList/v4/drivers/chunk" + _ "github.com/OpenListTeam/OpenList/v4/drivers/cloudflare_imgbed" _ "github.com/OpenListTeam/OpenList/v4/drivers/cloudreve" _ "github.com/OpenListTeam/OpenList/v4/drivers/cloudreve_v4" _ "github.com/OpenListTeam/OpenList/v4/drivers/cnb_releases" @@ -33,6 +35,7 @@ import ( _ "github.com/OpenListTeam/OpenList/v4/drivers/doubao_new" _ "github.com/OpenListTeam/OpenList/v4/drivers/doubao_share" _ "github.com/OpenListTeam/OpenList/v4/drivers/dropbox" + _ "github.com/OpenListTeam/OpenList/v4/drivers/emby" _ "github.com/OpenListTeam/OpenList/v4/drivers/febbox" _ "github.com/OpenListTeam/OpenList/v4/drivers/ftp" _ "github.com/OpenListTeam/OpenList/v4/drivers/github" diff --git a/drivers/azure_blob/driver.go b/drivers/azure_blob/driver.go index 1c0bb9a9c..7e5ce9435 100644 --- a/drivers/azure_blob/driver.go +++ b/drivers/azure_blob/driver.go @@ -2,6 +2,7 @@ package azure_blob import ( "context" + "encoding/hex" "fmt" "io" "path" @@ -12,10 +13,13 @@ import ( "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" "github.com/Azure/azure-sdk-for-go/sdk/storage/azblob" + "github.com/Azure/azure-sdk-for-go/sdk/storage/azblob/blob" "github.com/Azure/azure-sdk-for-go/sdk/storage/azblob/container" "github.com/Azure/azure-sdk-for-go/sdk/storage/azblob/sas" "github.com/OpenListTeam/OpenList/v4/internal/driver" "github.com/OpenListTeam/OpenList/v4/internal/model" + "github.com/OpenListTeam/OpenList/v4/pkg/utils" + log "github.com/sirupsen/logrus" ) // Azure Blob Storage based on the blob APIs @@ -283,6 +287,26 @@ func (d *AzureBlob) Put(ctx context.Context, dstDir model.Obj, stream model.File // Determine optimal upload options based on file size options := optimizedUploadOptions(stream.GetSize()) + // Preserve the file content type for direct Azure Blob downloads. + contentType := stream.GetMimetype() + if contentType == "" { + contentType = "application/octet-stream" + } + options.HTTPHeaders = &blob.HTTPHeaders{ + BlobContentType: &contentType, + } + + // Preserve the MD5 checksum when it is available. + md5Hex := stream.GetHash().GetHash(utils.MD5) + if md5Hex != "" { + md5, err := hex.DecodeString(md5Hex) + if err == nil && len(md5) == 16 { + options.HTTPHeaders.BlobContentMD5 = md5 + } else { + log.Warnf("Invalid MD5 hash: %s, error: %v", md5Hex, err) + } + } + // Track upload progress progressTracker := &progressTracker{ total: stream.GetSize(), diff --git a/drivers/baidu_netdisk/util.go b/drivers/baidu_netdisk/util.go index 5391e80e1..96a2ee886 100644 --- a/drivers/baidu_netdisk/util.go +++ b/drivers/baidu_netdisk/util.go @@ -233,11 +233,13 @@ func (d *BaiduNetdisk) linkOfficial(file model.Obj, _ model.LinkArgs) (*model.Li u = res.Header().Get("location") //} + exp := time.Hour return &model.Link{ URL: u, Header: http.Header{ "User-Agent": []string{"pan.baidu.com"}, }, + Expiration: &exp, }, nil } diff --git a/drivers/baidu_photo/types.go b/drivers/baidu_photo/types.go index 821010849..6e3628417 100644 --- a/drivers/baidu_photo/types.go +++ b/drivers/baidu_photo/types.go @@ -73,7 +73,7 @@ func (c *File) Thumb() string { } func (c *File) GetHash() utils.HashInfo { - return utils.NewHashInfo(utils.MD5, DecryptMd5(c.Md5)) + return utils.HashInfo{} } /*相册部分*/ diff --git a/drivers/baidu_photo/utils.go b/drivers/baidu_photo/utils.go index f47eb1748..a7cee84b8 100644 --- a/drivers/baidu_photo/utils.go +++ b/drivers/baidu_photo/utils.go @@ -7,6 +7,7 @@ import ( "net/http" "strconv" "strings" + "time" "unicode" "github.com/OpenListTeam/OpenList/v4/drivers/base" @@ -411,12 +412,14 @@ func (d *BaiduPhoto) linkFile(ctx context.Context, file *File, args model.LinkAr // } // location := resp.Header().Get("Location") + exp := time.Hour link := &model.Link{ URL: downloadUrl.Dlink, Header: http.Header{ "User-Agent": []string{headers["User-Agent"]}, "Referer": []string{"https://photo.baidu.com/"}, }, + Expiration: &exp, } return link, nil } diff --git a/drivers/chaoxing/types.go b/drivers/chaoxing/types.go index ca171c4d5..dcbded0d8 100644 --- a/drivers/chaoxing/types.go +++ b/drivers/chaoxing/types.go @@ -105,44 +105,82 @@ func (ios *int_str) UnmarshalJSON(data []byte) error { return nil } +// 时间戳同样存在字符串/数字两种形式,且毫秒级时间戳会超出 int32 范围,需用 int64 解析 +// 超星 API 返回的 uploadDate 有三种格式: +// 1. 数字时间戳:1780191356415 +// 2. 字符串时间戳:"1780191356415" +// 3. 日期字符串:"2024-11-06 07:49" +type int64_str int64 + +func (ios *int64_str) UnmarshalJSON(data []byte) error { + // 去除引号 + str := string(bytes.Trim(data, "\"")) + + // 尝试直接解析为数字(处理格式 1 和 2) + intValue, err := strconv.ParseInt(str, 10, 64) + if err == nil { + *ios = int64_str(intValue) + return nil + } + + // 尝试解析日期字符串(处理格式 3) + // 支持格式:"2024-11-06 07:49" 或 "2024-11-06 07:49:30" + layouts := []string{ + "2006-01-02 15:04", + "2006-01-02 15:04:05", + } + + for _, layout := range layouts { + if t, err := time.Parse(layout, str); err == nil { + // 转换为毫秒时间戳 + *ios = int64_str(t.UnixMilli()) + return nil + } + } + + // 所有格式都失败,返回 0(避免因时间格式问题导致整个解析失败) + *ios = 0 + return nil +} + type File struct { Cataid int `json:"cataid"` Cfid int `json:"cfid"` Content struct { - Cfid int `json:"cfid"` - Pid int `json:"pid"` - FolderName string `json:"folderName"` - ShareType int `json:"shareType"` - Preview string `json:"preview"` - Filetype string `json:"filetype"` - PreviewURL string `json:"previewUrl"` - IsImg bool `json:"isImg"` - ParentPath string `json:"parentPath"` - Icon string `json:"icon"` - Suffix string `json:"suffix"` - Duration int `json:"duration"` - Pantype string `json:"pantype"` - Puid int_str `json:"puid"` - Filepath string `json:"filepath"` - Crc string `json:"crc"` - Isfile bool `json:"isfile"` - Residstr string `json:"residstr"` - ObjectID string `json:"objectId"` - Extinfo string `json:"extinfo"` - Thumbnail string `json:"thumbnail"` - Creator int `json:"creator"` - ResTypeValue int `json:"resTypeValue"` - UploadDateFormat string `json:"uploadDateFormat"` - DisableOpt bool `json:"disableOpt"` - DownPath string `json:"downPath"` - Sort int `json:"sort"` - Topsort int `json:"topsort"` - Restype string `json:"restype"` - Size int_str `json:"size"` - UploadDate int64 `json:"uploadDate"` - FileSize string `json:"fileSize"` - Name string `json:"name"` - FileID string `json:"fileId"` + Cfid int `json:"cfid"` + Pid int `json:"pid"` + FolderName string `json:"folderName"` + ShareType int `json:"shareType"` + Preview string `json:"preview"` + Filetype string `json:"filetype"` + PreviewURL string `json:"previewUrl"` + IsImg bool `json:"isImg"` + ParentPath string `json:"parentPath"` + Icon string `json:"icon"` + Suffix string `json:"suffix"` + Duration int `json:"duration"` + Pantype string `json:"pantype"` + Puid int_str `json:"puid"` + Filepath string `json:"filepath"` + Crc string `json:"crc"` + Isfile bool `json:"isfile"` + Residstr string `json:"residstr"` + ObjectID string `json:"objectId"` + Extinfo string `json:"extinfo"` + Thumbnail string `json:"thumbnail"` + Creator int `json:"creator"` + ResTypeValue int `json:"resTypeValue"` + UploadDateFormat string `json:"uploadDateFormat"` + DisableOpt bool `json:"disableOpt"` + DownPath string `json:"downPath"` + Sort int `json:"sort"` + Topsort int `json:"topsort"` + Restype string `json:"restype"` + Size int_str `json:"size"` + UploadDate int64_str `json:"uploadDate"` + FileSize string `json:"fileSize"` + Name string `json:"name"` + FileID string `json:"fileId"` } `json:"content"` CreatorID int `json:"creatorId"` DesID string `json:"des_id"` @@ -265,7 +303,7 @@ func fileToObj(f File) *model.Object { IsFolder: true, } } - paserTime := time.UnixMilli(f.Content.UploadDate) + paserTime := time.UnixMilli(int64(f.Content.UploadDate)) return &model.Object{ ID: fmt.Sprintf("%d$%s", f.ID, f.Content.FileID), Name: f.Content.Name, diff --git a/drivers/chunk/driver.go b/drivers/chunk/driver.go index b544391dd..1ebc8aef6 100644 --- a/drivers/chunk/driver.go +++ b/drivers/chunk/driver.go @@ -53,8 +53,8 @@ func (d *Chunk) Drop(ctx context.Context) error { return nil } -func (Addition) GetRootPath() string { - return "/" +func (*Chunk) GetRootPath() string { + return "" } func (d *Chunk) Get(ctx context.Context, path string) (model.Obj, error) { @@ -80,7 +80,7 @@ func (d *Chunk) Get(ctx context.Context, path string) (model.Obj, error) { if err != nil { return nil, err } - var totalSize int64 = 0 + var totalSize int64 // 0号块默认为-1 以支持空文件 chunkSizes := []int64{-1} h := make(map[*utils.HashType]string) @@ -119,6 +119,25 @@ func (d *Chunk) Get(ctx context.Context, path string) (model.Obj, error) { } } reqDir, _ := stdpath.Split(path) + // 文件块不完整时,返回文件夹对象 + if chunkSizes[0] == -1 { + return &model.Object{ + Path: stdpath.Join(reqDir, chunkName), + Name: chunkName, + IsFolder: true, + Modified: d.Modified, + }, nil + } + for i, l := 1, len(chunkSizes)-1; i < l; i++ { + if chunkSizes[i] == 0 { + return &model.Object{ + Path: stdpath.Join(reqDir, chunkName), + Name: chunkName, + IsFolder: true, + Modified: d.Modified, + }, nil + } + } objRes := chunkObject{ Object: model.Object{ Path: stdpath.Join(reqDir, chunkName), @@ -161,37 +180,69 @@ func (d *Chunk) List(ctx context.Context, dir model.Obj, args model.ListArgs) ([ result = append(result, nil) listG.Go(func(ctx context.Context) error { chunkObjs, err := op.List(ctx, remoteStorage, stdpath.Join(remoteActualDir, rawName), model.ListArgs{ - ReqPath: stdpath.Join(args.ReqPath, rawName), Refresh: args.Refresh, }) if err != nil { return err } - totalSize := int64(0) + var totalSize int64 + // 0号块默认为-1 以支持空文件 + chunkSizes := []int64{-1} h := make(map[*utils.HashType]string) - first := obj + var first model.Obj for _, o := range chunkObjs { if o.IsDir() { continue } - if after, ok := strings.CutPrefix(strings.TrimSuffix(o.GetName(), d.CustomExt), "hash_"); ok { - hn, value, ok := strings.Cut(after, "_") + if after, ok := strings.CutPrefix(o.GetName(), "hash_"); ok { + hn, value, ok := strings.Cut(strings.TrimSuffix(after, d.CustomExt), "_") if ok { ht, ok := utils.GetHashByName(hn) if ok { h[ht] = value } - continue } + continue } idx, err := strconv.Atoi(strings.TrimSuffix(o.GetName(), d.CustomExt)) if err != nil { continue } - if idx == 0 { - first = o - } totalSize += o.GetSize() + if len(chunkSizes) > idx { + if idx == 0 { + first = o + } + chunkSizes[idx] = o.GetSize() + } else if len(chunkSizes) == idx { + chunkSizes = append(chunkSizes, o.GetSize()) + } else { + newChunkSizes := make([]int64, idx+1) + copy(newChunkSizes, chunkSizes) + chunkSizes = newChunkSizes + chunkSizes[idx] = o.GetSize() + } + } + // 文件块不完整时,返回文件夹对象 + if chunkSizes[0] == -1 { + result[resultIdx] = &model.Object{ + Name: rawName, + Size: obj.GetSize(), + Modified: obj.ModTime(), + IsFolder: true, + } + return nil + } + for i, l := 1, len(chunkSizes)-1; i < l; i++ { + if chunkSizes[i] == 0 { + result[resultIdx] = &model.Object{ + Name: rawName, + Size: obj.GetSize(), + Modified: obj.ModTime(), + IsFolder: true, + } + return nil + } } objRes := model.Object{ Name: name, @@ -263,9 +314,7 @@ func (d *Chunk) Link(ctx context.Context, file model.Obj, args model.LinkArgs) ( if err != nil { return nil, err } - resultLink := *l - resultLink.SyncClosers = utils.NewSyncClosers(l) - return &resultLink, nil + return l.Clone(), nil } // 检查0号块不等于-1 以支持空文件 // 如果块数量大于1 最后一块不可能为0 diff --git a/drivers/cloudflare_imgbed/driver.go b/drivers/cloudflare_imgbed/driver.go new file mode 100644 index 000000000..e4b7c97f9 --- /dev/null +++ b/drivers/cloudflare_imgbed/driver.go @@ -0,0 +1,168 @@ +package cloudflare_imgbed + +import ( + "context" + "fmt" + "net/http" + "path" + "strings" + + "github.com/OpenListTeam/OpenList/v4/drivers/base" + "github.com/OpenListTeam/OpenList/v4/internal/cache" + "github.com/OpenListTeam/OpenList/v4/internal/driver" + "github.com/OpenListTeam/OpenList/v4/internal/errs" + "github.com/OpenListTeam/OpenList/v4/internal/model" + "github.com/OpenListTeam/OpenList/v4/pkg/utils" + "github.com/go-resty/resty/v2" +) + +type CFImgBed struct { + model.Storage + Addition + client *resty.Client + virtualDir *cache.WeakCacheMap[string, model.Object] + publicUrlPrefix string +} + +func (d *CFImgBed) Config() driver.Config { return config } +func (d *CFImgBed) GetAddition() driver.Additional { return &d.Addition } + +func (d *CFImgBed) Init(ctx context.Context) error { + d.UploadThread = min(d.UploadThread, 32) + if d.UploadThread < 1 { + d.UploadThread = 3 + } + d.Address = strings.TrimRight(d.Address, "/") + + d.client = base.NewRestyClient(). + SetBaseURL(d.Address). + SetHeader("Authorization", "Bearer "+d.Token). + SetDebug(false) + + // 连通性测试:尝试获取根目录单条数据 + _, err := d.doRequest(ctx, http.MethodGet, listApi, func(req *resty.Request) { + req.SetQueryParams(map[string]string{ + "start": "0", + "count": "1", + "dir": "/", + }) + }, nil) + if err != nil { + return fmt.Errorf("init verification failed: %w", err) + } + d.virtualDir = cache.NewWeakCacheMap[string, model.Object]() + return nil +} + +func (d *CFImgBed) Drop(ctx context.Context) error { + if d.virtualDir != nil { + d.virtualDir.Clear() + } + return nil +} + +func (d *CFImgBed) List(ctx context.Context, dir model.Obj, args model.ListArgs) ([]model.Obj, error) { + // if !args.Refresh && model.ObjHasMask(dir, model.Virtual) { + // if _, ok := d.virtualDir.Load(dir.GetPath()); ok { + // return nil, nil + // } + // } + + var dirSeen map[string]bool + var fileSeen map[string]bool + var objs []model.Obj + + start := 0 + for { + var resp ListResponse + _, err := d.doRequest(ctx, http.MethodGet, listApi, func(req *resty.Request) { + req.SetQueryParams(map[string]string{ + "dir": dir.GetPath(), + "start": fmt.Sprintf("%d", start), + "count": fmt.Sprintf("%d", listPageSize), + }) + }, &resp) + if err != nil { + return nil, err + } + if len(resp.Files) == 0 && len(resp.Directories) == 0 { + break + } + + if start == 0 { + dirSeen = make(map[string]bool, len(resp.Directories)) + fileSeen = make(map[string]bool, len(resp.Files)) + objs = make([]model.Obj, 0, len(resp.Directories)+len(resp.Files)) + } + + for _, rawDir := range resp.Directories { + rawDir = "/" + strings.TrimRight(rawDir, "/") + if !dirSeen[rawDir] { + dirSeen[rawDir] = true + objs = append(objs, &model.Object{ + Path: rawDir, + Name: path.Base(rawDir), + Modified: d.Modified, + IsFolder: true, + }) + } + } + + for _, item := range resp.Files { + if !fileSeen[item.Name] { + fileSeen[item.Name] = true + objs = append(objs, parseFile(item)) + } + } + + // 如果当前获取的数量少于分页大小,说明已加载完毕 + if len(resp.Files)+len(resp.Directories) < listPageSize { + break + } + start += listPageSize + } + return objs, nil +} + +func (d *CFImgBed) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (*model.Link, error) { + if d.publicUrlPrefix != "" { + return &model.Link{URL: d.publicUrlPrefix + utils.EncodePath(file.GetPath())}, nil + } + return &model.Link{URL: d.Address + "/file" + utils.EncodePath(file.GetPath())}, nil +} + +func (d *CFImgBed) Get(ctx context.Context, pathStr string) (model.Obj, error) { + fullPath := path.Join(d.RootFolderPath, pathStr) + if obj, found := d.virtualDir.Load(fullPath); found { + return obj, nil + } + return nil, errs.NotSupport +} + +// MakeDir 在图床中通常是虚拟的,此处返回虚拟目录对象以支持上传时的路径展示 +func (d *CFImgBed) MakeDir(ctx context.Context, parentDir model.Obj, dirName string) (model.Obj, error) { + fullPath := path.Join(parentDir.GetPath(), dirName) + temp := &model.Object{ + Path: fullPath, + Name: dirName, + IsFolder: true, + Modified: d.Modified, + Mask: model.Virtual, + } + d.virtualDir.Store(fullPath, temp) + return temp, nil +} + +func (d *CFImgBed) Remove(ctx context.Context, obj model.Obj) error { + reqPath := obj.GetPath() + if model.ObjHasMask(obj, model.Virtual) { + d.virtualDir.Delete(reqPath) + return nil + } + _, err := d.doRequest(ctx, http.MethodPost, deleteApi+utils.EncodePath(reqPath), func(req *resty.Request) { + req.SetQueryParam("folder", fmt.Sprintf("%t", obj.IsDir())) + }, nil) + return err +} + +var _ driver.Driver = (*CFImgBed)(nil) diff --git a/drivers/cloudflare_imgbed/meta.go b/drivers/cloudflare_imgbed/meta.go new file mode 100644 index 000000000..73ba96540 --- /dev/null +++ b/drivers/cloudflare_imgbed/meta.go @@ -0,0 +1,27 @@ +package cloudflare_imgbed + +import ( + "github.com/OpenListTeam/OpenList/v4/internal/driver" + "github.com/OpenListTeam/OpenList/v4/internal/op" +) + +type Addition struct { + driver.RootPath + Address string `json:"address" required:"true" help:"Backend API address of the image hosting service, e.g., https://img.example.com"` + Token string `json:"token" required:"true" help:"Authentication Token"` + SmallChannelName string `json:"smallChannelName" help:"Channel name for regular files (typically <20MB)"` + LargeChannelName string `json:"largeChannelName" help:"Channel name for large files"` + LargeChannelType string `json:"largeChannelType" type:"select" options:",huggingface,telegram,cfr2,s3,discord" help:"Large File Channel Type: Hugging Face (Direct Upload)、telegram/cfr2/s3/discord(Multipart Upload)"` + UploadThread int `json:"uploadThread" type:"number" default:"3" help:"Concurrent thread count for HuggingFace chunked direct upload"` +} + +var config = driver.Config{ + Name: "cloudflare_imgbed", + LocalSort: true, + NoUpload: false, + DefaultRoot: "/", +} + +func init() { + op.RegisterDriver(func() driver.Driver { return &CFImgBed{} }) +} diff --git a/drivers/cloudflare_imgbed/types.go b/drivers/cloudflare_imgbed/types.go new file mode 100644 index 000000000..297fbf7b0 --- /dev/null +++ b/drivers/cloudflare_imgbed/types.go @@ -0,0 +1,117 @@ +package cloudflare_imgbed + +import ( + "fmt" + "path" + "strconv" + "strings" + "time" + + "github.com/OpenListTeam/OpenList/v4/internal/model" +) + +const listPageSize = 1000 + +// ListResponse 列表接口响应 +type ListResponse struct { + Files []FileItem `json:"files"` + Directories []string `json:"directories"` +} + +type FileItem struct { + Name string `json:"name"` + Metadata map[string]interface{} `json:"metadata"` // 存储文件大小、哈希、时间戳等 +} + +type apiError struct { + Error string `json:"error"` + Message string `json:"message"` +} + +// standardUploadResp 标准上传成功返回的数组 +type standardUploadResp []struct { + Src string `json:"src"` + PublicUrl string `json:"publicUrl"` +} + +// hfGetUrlResp 获取 HF 直传授权地址的响应 +type hfGetUrlResp struct { + Success bool `json:"success"` + FullID string `json:"fullId"` + FilePath string `json:"filePath"` + ChannelName string `json:"channelName"` + Repo string `json:"repo"` + NeedsLfs bool `json:"needsLfs"` // 是否需要进行 LFS 物理上传 + AlreadyExists bool `json:"alreadyExists"` // 是否秒传成功 + Oid string `json:"oid"` // Git LFS 对象 ID (SHA256) + UploadAction *UploadAction `json:"uploadAction"` +} + +type UploadAction struct { + Href string `json:"href"` + Header map[string]string `json:"header"` +} + +type hfCommitResp struct { + Success bool `json:"success"` + Src string `json:"src"` + PublicUrl string `json:"publicUrl"` + FileUrl string `json:"fileUrl"` + FullID string `json:"fullId"` +} + +// 辅助函数:从 map 中安全提取字符串/数值 +func getString(m map[string]interface{}, keys ...string) string { + for _, k := range keys { + if v, ok := m[k]; ok { + switch val := v.(type) { + case string: + return val + case float64: + return strconv.FormatInt(int64(val), 10) + default: + return fmt.Sprintf("%v", val) + } + } + } + return "" +} + +func getInt64(m map[string]interface{}, keys ...string) int64 { + for _, k := range keys { + if v, ok := m[k]; ok { + switch val := v.(type) { + case string: + n, _ := strconv.ParseInt(val, 10, 64) + return n + case float64: + return int64(val) + case int64: + return val + } + } + } + return 0 +} + +func parseFile(item FileItem) *model.Object { + name := path.Base(item.Name) + var size int64 + var modTime time.Time + + if item.Metadata != nil { + size = getInt64(item.Metadata, "FileSizeBytes", "File-Size") + ts := getInt64(item.Metadata, "TimeStamp") + if ts > 0 { + modTime = time.UnixMilli(ts) + } + } + + return &model.Object{ + Path: "/" + strings.TrimRight(item.Name, "/"), + Name: name, + Size: size, + Modified: modTime, + IsFolder: false, + } +} diff --git a/drivers/cloudflare_imgbed/upload.go b/drivers/cloudflare_imgbed/upload.go new file mode 100644 index 000000000..0de9542c5 --- /dev/null +++ b/drivers/cloudflare_imgbed/upload.go @@ -0,0 +1,560 @@ +package cloudflare_imgbed + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/url" + "path" + "strconv" + "strings" + "time" + + "github.com/OpenListTeam/OpenList/v4/drivers/base" + "github.com/OpenListTeam/OpenList/v4/internal/driver" + "github.com/OpenListTeam/OpenList/v4/internal/model" + "github.com/OpenListTeam/OpenList/v4/internal/stream" + "github.com/OpenListTeam/OpenList/v4/pkg/errgroup" + "github.com/OpenListTeam/OpenList/v4/pkg/http_range" + "github.com/OpenListTeam/OpenList/v4/pkg/utils" + "github.com/avast/retry-go" + "github.com/go-resty/resty/v2" + log "github.com/sirupsen/logrus" +) + +func (d *CFImgBed) Put(ctx context.Context, dstDir model.Obj, file model.FileStreamer, up driver.UpdateProgress) (newObj model.Obj, err error) { + if file.GetSize() < hfDirectThreshold { + newObj, err = d.standardUpload(ctx, dstDir, file, up) + } else { + switch d.LargeChannelType { + case "huggingface": + newObj, err = d.hfDirectUpload(ctx, dstDir, file, up) + case "telegram", "cfr2", "s3", "discord": + newObj, err = d.chunkedUpload(ctx, dstDir, file, up, d.LargeChannelType, d.LargeChannelName) + default: + newObj, err = d.standardUpload(ctx, dstDir, file, up) + } + } + if newObj != nil && model.ObjHasMask(dstDir, model.Virtual) { + key := dstDir.GetPath() + for d.virtualDir.Delete(key) { + key = path.Dir(key) + } + } + return +} + +// standardUpload 通过普通 multipart 表单上传。 +// 使用 io.MultiReader 实现虚拟拼接,避免将整个大文件读入内存构建表单。 +func (d *CFImgBed) standardUpload(ctx context.Context, dstDir model.Obj, file model.FileStreamer, up driver.UpdateProgress) (model.Obj, error) { + + channelName := d.SmallChannelName + if file.GetSize() >= hfDirectThreshold { + channelName = d.LargeChannelName + log.WithField("size", file.GetSize()).Warn("large file falls back to standard upload, consider configuring LargeChannelType") + } + if channelName == "" { + return nil, fmt.Errorf("channel name not configured") + } + + // 1. 将参数放入 Query String + reqUrl, err := url.Parse(d.Address + uploadApi) + if err != nil { + return nil, err + } + q := reqUrl.Query() + q.Set("returnFormat", "default") + q.Set("channelName", channelName) + q.Set("uploadFolder", dstDir.GetPath()) + q.Set("autoRetry", "true") + reqUrl.RawQuery = q.Encode() + + // 2. 构建 multipart 表单的头部 + b := bytes.NewBuffer(make([]byte, 0, 164+len(file.GetName()))) // 预估头部大小,避免频繁扩容 + w := multipart.NewWriter(b) + _, err = w.CreateFormFile("file", file.GetName()) + if err != nil { + return nil, err + } + headSize := b.Len() + err = w.Close() + if err != nil { + return nil, err + } + head := bytes.NewReader(b.Bytes()[:headSize]) + tail := bytes.NewReader(b.Bytes()[headSize:]) + + // 3. 将 [表单头 + 文件流 + 表单尾] 组合成单一 Reader + rateLimitedReader := driver.NewLimitedUploadStream(ctx, &driver.ReaderUpdatingProgress{ + Reader: &driver.SimpleReaderWithSize{ + Reader: io.MultiReader(head, file, tail), + Size: int64(b.Len()) + file.GetSize(), + }, + UpdateProgress: up, + }) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqUrl.String(), rateLimitedReader) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", w.FormDataContentType()) + req.Header.Set("Authorization", "Bearer "+d.Token) + req.ContentLength = int64(b.Len()) + file.GetSize() + res, err := base.HttpClient.Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + + b.Reset() + _, err = b.ReadFrom(res.Body) + if err != nil { + return nil, err + } + if res.StatusCode != http.StatusOK { + return nil, fmt.Errorf("upload failed %d: %s", res.StatusCode, b.String()) + } + + var resp standardUploadResp + if err := json.Unmarshal(b.Bytes(), &resp); err != nil { + return nil, err + } + if len(resp) == 0 || resp[0].Src == "" { + return nil, fmt.Errorf("no src returned") + } + + srcPath := strings.TrimPrefix(resp[0].Src, "/file/") + srcPath = strings.TrimPrefix(srcPath, "/") + + if resp[0].PublicUrl != "" { + if u, err := url.Parse(resp[0].PublicUrl); err == nil { + d.publicUrlPrefix = u.Scheme + "://" + u.Host + } + } + + return &model.Object{ + Path: srcPath, + Name: file.GetName(), + Size: file.GetSize(), + Modified: file.ModTime(), + IsFolder: false, + }, nil +} + +func (d *CFImgBed) chunkedUpload(ctx context.Context, dstDir model.Obj, file model.FileStreamer, up driver.UpdateProgress, channelType, channelName string) (model.Obj, error) { + if channelName == "" { + return nil, fmt.Errorf("channel name not configured for chunked upload") + } + + fileSize := file.GetSize() + fileMime := file.GetMimetype() + fileName := file.GetName() + + var chunkSizeMap = map[string]int64{ + + } + chunkSize := chunkSizeMap[channelType] + if chunkSize == 0 { + chunkSize = 5 * 1024 * 1024 + } + totalChunks := int((fileSize + chunkSize - 1) / chunkSize) + + // 第一步:initChunked + var initResp struct { + Success bool `json:"success"` + UploadId string `json:"uploadId"` + } + _, err := d.doRequest(ctx, http.MethodPost, uploadApi, func(req *resty.Request) { + req.SetQueryParams(map[string]string{ + "initChunked": "true", + "uploadChannel": channelType, + "channelName": channelName, + }) + req.SetFormData(map[string]string{ + "originalFileName": fileName, + "originalFileType": fileMime, + "totalChunks": strconv.Itoa(totalChunks), + }) + }, &initResp) + if err != nil { + return nil, fmt.Errorf("initChunked failed: %w", err) + } + if !initResp.Success || initResp.UploadId == "" { + return nil, fmt.Errorf("initChunked returned no uploadId") + } + uploadId := initResp.UploadId + + // 第二步:逐块上传 + ss, err := stream.NewStreamSectionReader(file, int(chunkSize), nil) + if err != nil { + return nil, err + } + + reqUrl := d.Address + uploadApi + "?chunked=true&uploadChannel=" + channelType + "&channelName=" + channelName + b := bytes.NewBuffer(make([]byte, 0, 2048)) + + g, uploadCtx := errgroup.NewOrderedGroupWithContext(ctx, min(d.UploadThread, totalChunks), + retry.Attempts(3), + retry.Delay(time.Second), + retry.DelayType(retry.BackOffDelay)) + + for i := 0; i < totalChunks; i++ { + if utils.IsCanceled(uploadCtx) { + break + } + chunkIndex := i + offset := int64(chunkIndex) * chunkSize + sizeToRead := chunkSize + if offset+sizeToRead > fileSize { + sizeToRead = fileSize - offset + } + + var reader io.ReadSeeker + g.GoWithLifecycle(errgroup.Lifecycle{ + Before: func(ctx context.Context) (err error) { + reader, err = ss.GetSectionReader(offset, sizeToRead) + return + }, + After: func(err error) { + ss.FreeSectionReader(reader) + }, + Do: func(ctx context.Context) (err error) { + _, err = reader.Seek(0, io.SeekStart) + if err != nil { + return err + } + + b.Reset() + w := multipart.NewWriter(b) + _ = w.WriteField("uploadId", uploadId) + _ = w.WriteField("chunkIndex", strconv.Itoa(chunkIndex)) + _ = w.WriteField("totalChunks", strconv.Itoa(totalChunks)) + _ = w.WriteField("originalFileName", fileName) + _ = w.WriteField("originalFileType", fileMime) + _, _ = w.CreateFormFile("file", fileName) + headSize := b.Len() + _ = w.Close() + head := bytes.NewReader(b.Bytes()[:headSize]) + tail := bytes.NewReader(b.Bytes()[headSize:]) + + rateLimitedRd := driver.NewLimitedUploadStream(ctx, io.MultiReader(head, reader, tail)) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqUrl, rateLimitedRd) + if err != nil { + return err + } + req.Header.Set("Content-Type", w.FormDataContentType()) + req.Header.Set("Authorization", "Bearer "+d.Token) + req.ContentLength = int64(headSize) + sizeToRead + int64(b.Len()-headSize) + + res, err := base.HttpClient.Do(req) + if err != nil { + return err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return fmt.Errorf("chunk %d upload failed: %d", chunkIndex, res.StatusCode) + } + + up(90 * float64(chunkIndex+1) / float64(totalChunks)) + return nil + }, + }) + } + + if err := g.Wait(); err != nil { + return nil, err + } + + // 第三步:merge + var mergeResp standardUploadResp + _, err = d.doRequest(ctx, http.MethodPost, uploadApi, func(req *resty.Request) { + req.SetQueryParams(map[string]string{ + "chunked": "true", + "merge": "true", + "uploadChannel": channelType, + "channelName": channelName, + "returnFormat": "default", + "uploadFolder": dstDir.GetPath(), + }) + req.SetFormData(map[string]string{ + "uploadId": uploadId, + "totalChunks": strconv.Itoa(totalChunks), + "originalFileName": fileName, + "originalFileType": fileMime, + }) + }, &mergeResp) + if err != nil { + return nil, fmt.Errorf("merge failed: %w", err) + } + if len(mergeResp) == 0 || mergeResp[0].Src == "" { + return nil, fmt.Errorf("merge returned no src") + } + + up(95) + + srcPath := strings.TrimPrefix(mergeResp[0].Src, "/file/") + srcPath = strings.TrimPrefix(srcPath, "/") + + if mergeResp[0].PublicUrl != "" { + if u, err := url.Parse(mergeResp[0].PublicUrl); err == nil { + d.publicUrlPrefix = u.Scheme + "://" + u.Host + } + } + + return &model.Object{ + Path: srcPath, + Name: fileName, + Size: fileSize, + Modified: file.ModTime(), + IsFolder: false, + }, nil +} + +// hfDirectUpload 处理 HuggingFace 的 LFS 直传逻辑(申请授权 -> 物理上传 -> 后端 Commit) +func (d *CFImgBed) hfDirectUpload(ctx context.Context, dstDir model.Obj, file model.FileStreamer, up driver.UpdateProgress) (model.Obj, error) { + channelName := d.LargeChannelName + if channelName == "" { + return nil, errors.New("LargeChannelName not configured") + } + + sha256Hash := file.GetHash().GetHash(utils.SHA256) + if len(sha256Hash) != utils.SHA256.Width { + var err error + _, sha256Hash, err = stream.CacheFullAndHash(file, &up, utils.SHA256) + if err != nil { + return nil, err + } + } + + fileSize := file.GetSize() + sampleSize := min(fileSize, fileSampleSize) + sampleRd, err := file.RangeRead(http_range.Range{Start: 0, Length: sampleSize}) + if err != nil { + return nil, err + } + sampleBuf := make([]byte, sampleSize) + _, err = io.ReadFull(sampleRd, sampleBuf) + if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) { + return nil, err + } + fileSample := base64.StdEncoding.EncodeToString(sampleBuf) + + fileMime := file.GetMimetype() + // 1. 请求图床后端获取 HF 授权地址 + reqBody := map[string]interface{}{ + "fileName": file.GetName(), + "fileType": fileMime, + "fileSize": fileSize, + "sha256": sha256Hash, + "fileSample": fileSample, + "channelName": channelName, + "uploadFolder": dstDir.GetPath(), + } + + var getUrlResp hfGetUrlResp + _, err = d.doRequest(ctx, http.MethodPost, hfGetUrlApi, func(req *resty.Request) { + req.SetBody(reqBody) + req.SetHeader("Content-Type", "application/json") + }, &getUrlResp) + if err != nil { + return nil, err + } + + // 秒传逻辑 + if getUrlResp.AlreadyExists || !getUrlResp.NeedsLfs { + up(100) + return d.hfCommit(ctx, getUrlResp, file.GetName(), fileSize, fileMime, file.ModTime()) + } + + if getUrlResp.UploadAction == nil { + return nil, fmt.Errorf("HF upload action is nil") + } + + headers := getUrlResp.UploadAction.Header + href := getUrlResp.UploadAction.Href + + // 2. 根据响应判断是执行分片上传还是单文件上传 + chunkSizeStr, needChunk := headers["chunk_size"] + if needChunk { + // 分片直传 (AWS S3 Multipart 风格) + chunkSize, _ := strconv.ParseInt(chunkSizeStr, 10, 64) + if chunkSize <= 0 { + chunkSize = 20 * 1024 * 1024 + } + + partUrls := make(map[int]string) + for k, v := range headers { + if k != "chunk_size" { + if idx, err := strconv.Atoi(k); err == nil { + partUrls[idx] = v + } + } + } + totalParts := len(partUrls) + + ss, err := stream.NewStreamSectionReader(file, int(chunkSize), &up) + if err != nil { + return nil, err + } + + g, uploadCtx := errgroup.NewOrderedGroupWithContext(ctx, min(d.UploadThread, totalParts), + retry.Attempts(3), + retry.Delay(time.Second), + retry.DelayType(retry.BackOffDelay)) + + parts := make([]map[string]any, totalParts) + + for partNumber := range partUrls { + if utils.IsCanceled(uploadCtx) { + break + } + partUrl := partUrls[partNumber] + offset := int64(partNumber-1) * chunkSize + sizeToRead := chunkSize + if offset+sizeToRead > fileSize { + sizeToRead = fileSize - offset + } + + var reader io.ReadSeeker + g.GoWithLifecycle(errgroup.Lifecycle{ + Before: func(ctx context.Context) (err error) { + reader, err = ss.GetSectionReader(offset, sizeToRead) + return + }, + After: func(err error) { + ss.FreeSectionReader(reader) + }, + Do: func(ctx context.Context) (err error) { + _, err = reader.Seek(0, io.SeekStart) + if err != nil { + return err + } + limitedReader := driver.NewLimitedUploadStream(ctx, reader) + req, err := http.NewRequestWithContext(ctx, http.MethodPut, partUrl, limitedReader) + if err != nil { + return err + } + + req.ContentLength = sizeToRead + + res, err := base.HttpClient.Do(req) + if err != nil { + return err + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return fmt.Errorf("chunk %d failed: %d", partNumber, res.StatusCode) + } + + etag := res.Header.Get("ETag") + parts[partNumber-1] = map[string]any{"partNumber": partNumber, "etag": etag} + + up(95 * float64(g.Success()+1) / float64(totalParts)) + return nil + }, + }) + } + + if err := g.Wait(); err != nil { + return nil, err + } + + // 合并分片 + // sort.Slice(parts, func(i, j int) bool { return parts[i]["partNumber"].(int) < parts[j]["partNumber"].(int) }) + mergeBody, _ := json.Marshal(map[string]any{"oid": getUrlResp.Oid, "parts": parts}) + mergeReq, err := http.NewRequestWithContext(ctx, http.MethodPost, href, bytes.NewReader(mergeBody)) + if err != nil { + return nil, err + } + mergeReq.Header.Set("Content-Type", "application/vnd.git-lfs+json") + if d.Token != "" { + mergeReq.Header.Set("Authorization", "Bearer "+d.Token) + } + + res, err := base.HttpClient.Do(mergeReq) + if err != nil { + return nil, err + } + defer res.Body.Close() + body, _ := io.ReadAll(res.Body) // 读取 HF 返回的错误详情 + if res.StatusCode != http.StatusOK { + log.WithField("status", res.StatusCode).WithField("response", string(body)).Error("HF merge chunks failed") + return nil, fmt.Errorf("merge chunks failed: %s", string(body)) + } + up(97) + + } else { + // 单文件直传 (PUT) + limitedReader := driver.NewLimitedUploadStream(ctx, &driver.ReaderUpdatingProgress{ + Reader: file, + UpdateProgress: model.UpdateProgressWithRange(up, 0, 97), + }) + + req, err := http.NewRequestWithContext(ctx, http.MethodPut, href, limitedReader) + if err != nil { + return nil, err + } + req.ContentLength = fileSize + for k, v := range headers { + req.Header.Set(k, v) + } + res, err := base.HttpClient.Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return nil, fmt.Errorf("direct upload failed") + } + } + + defer up(100) + + // 3. 通知图床后端完成文件登记 + return d.hfCommit(ctx, getUrlResp, file.GetName(), fileSize, fileMime, file.ModTime()) +} + +func (d *CFImgBed) hfCommit(ctx context.Context, getUrlResp hfGetUrlResp, fileName string, fileSize int64, fileMime string, modTime time.Time) (model.Obj, error) { + commitBody := map[string]interface{}{ + "fullId": getUrlResp.FullID, + "filePath": getUrlResp.FilePath, + "sha256": getUrlResp.Oid, + "fileSize": fileSize, + "fileName": fileName, + "fileType": fileMime, + "channelName": getUrlResp.ChannelName, + } + var commitResp hfCommitResp + _, err := d.doRequest(ctx, http.MethodPost, hfCommitApi, func(req *resty.Request) { + req.SetBody(commitBody) + }, &commitResp) + if err != nil { + return nil, fmt.Errorf("HF commit request failed: %w", err) + } + if !commitResp.Success { + return nil, fmt.Errorf("HF commit failed: success=false") + } + + srcPath := strings.TrimPrefix(commitResp.Src, "/file/") + srcPath = strings.TrimPrefix(srcPath, "/") + + if commitResp.PublicUrl != "" { + if u, err := url.Parse(commitResp.PublicUrl); err == nil { + d.publicUrlPrefix = u.Scheme + "://" + u.Host + } + } + + return &model.Object{ + Path: srcPath, + Name: fileName, + Size: fileSize, + Modified: modTime, + IsFolder: false, + }, nil +} diff --git a/drivers/cloudflare_imgbed/util.go b/drivers/cloudflare_imgbed/util.go new file mode 100644 index 000000000..f9eae974c --- /dev/null +++ b/drivers/cloudflare_imgbed/util.go @@ -0,0 +1,70 @@ +package cloudflare_imgbed + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/go-resty/resty/v2" + log "github.com/sirupsen/logrus" +) + +const ( + listApi = "/api/manage/list" + deleteApi = "/api/manage/delete" + uploadApi = "/upload" + hfGetUrlApi = "/upload/huggingface/getUploadUrl" + hfCommitApi = "/upload/huggingface/commitUpload" + hfDirectThreshold int64 = 20 * 1024 * 1024 + fileSampleSize = 512 // HF 申请上传地址时需提供文件前 512 字节的 Sample +) + +// doRequest 通用请求封装,包含重试和 API 错误解析 +func (d *CFImgBed) doRequest(ctx context.Context, method, urlPath string, callback func(*resty.Request), resp interface{}) ([]byte, error) { + maxRetries := 3 + for i := 0; i < maxRetries; i++ { + req := d.client.R() + req.SetContext(ctx) + if callback != nil { + callback(req) + } + if resp != nil { + req.SetResult(resp) + } + + res, err := req.Execute(method, urlPath) + if err != nil { + log.WithError(err).Warnf("request %s %s failed, attempt %d/%d", method, urlPath, i+1, maxRetries) + if i < maxRetries-1 { + time.Sleep(time.Duration(i+1) * time.Second) + continue + } + return nil, err + } + + // Retry on rate limit before attempting to interpret the body as an API error. + if res.StatusCode() == 429 { + time.Sleep(time.Duration(i+1) * 2 * time.Second) + continue + } + + body := res.Body() + var apiErr apiError + if err := json.Unmarshal(body, &apiErr); err == nil { + if apiErr.Error != "" || apiErr.Message != "" { + msg := apiErr.Error + if msg == "" { + msg = apiErr.Message + } + return nil, fmt.Errorf("API error: %s", msg) + } + } + + if res.IsError() { + return nil, fmt.Errorf("HTTP %d", res.StatusCode()) + } + return body, nil + } + return nil, fmt.Errorf("max retries exceeded") +} diff --git a/drivers/doubao_new/driver.go b/drivers/doubao_new/driver.go index 2a90cf16c..f84828f0e 100644 --- a/drivers/doubao_new/driver.go +++ b/drivers/doubao_new/driver.go @@ -12,6 +12,7 @@ import ( "io" "net/http" "net/url" + "os" "sort" "strings" "sync" @@ -300,7 +301,10 @@ func (d *DoubaoNew) Put(ctx context.Context, dstDir model.Obj, file model.FileSt if err != nil { return nil, err } - defer tmpFile.Close() + defer func() { + _ = tmpFile.Close() + _ = os.Remove(tmpFile.Name()) + }() blockSize := uploadPrep.BlockSize totalSize := file.GetSize() diff --git a/drivers/emby/driver.go b/drivers/emby/driver.go new file mode 100644 index 000000000..b2406aa6e --- /dev/null +++ b/drivers/emby/driver.go @@ -0,0 +1,261 @@ +package emby + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "path" + "strings" + "time" + + "github.com/OpenListTeam/OpenList/v4/drivers/base" + "github.com/OpenListTeam/OpenList/v4/internal/driver" + "github.com/OpenListTeam/OpenList/v4/internal/errs" + "github.com/OpenListTeam/OpenList/v4/internal/model" + "github.com/OpenListTeam/OpenList/v4/internal/op" +) + +type Emby struct { + model.Storage + Addition + + client *http.Client + token string + userID string +} + +func (d *Emby) Config() driver.Config { + return config +} + +func (d *Emby) GetAddition() driver.Additional { + return &d.Addition +} + +func (d *Emby) Init(ctx context.Context) error { + d.URL = strings.TrimRight(strings.TrimSpace(d.URL), "/") + if d.URL == "" { + return fmt.Errorf("url is required") + } + + if strings.TrimSpace(d.RootFolderID) == "" { + d.RootFolderID = "1" + } + + d.client = base.HttpClient + d.token = strings.TrimSpace(d.ApiKey) + d.userID = strings.TrimSpace(d.UserID) + + if d.token != "" { + if d.userID == "" { + return fmt.Errorf("user_id is required when api_key is set") + } + op.MustSaveDriverStorage(d) + return nil + } + + if strings.TrimSpace(d.Username) == "" || strings.TrimSpace(d.Password) == "" { + return fmt.Errorf("please provide api_key+user_id or username+password") + } + + if err := d.login(ctx); err != nil { + return err + } + + d.ApiKey = d.token + d.UserID = d.userID + op.MustSaveDriverStorage(d) + return nil +} + +func (d *Emby) Drop(ctx context.Context) error { + return nil +} + +func (d *Emby) List(ctx context.Context, dir model.Obj, args model.ListArgs) ([]model.Obj, error) { + parentID := strings.TrimSpace(d.RootFolderID) + if dir != nil && strings.TrimSpace(dir.GetID()) != "" { + parentID = strings.TrimSpace(dir.GetID()) + } + + items, err := d.getItems(ctx, parentID) + if err != nil { + return nil, err + } + + parentPath := "/" + if dir != nil && strings.TrimSpace(dir.GetPath()) != "" { + parentPath = dir.GetPath() + } + + objs := make([]model.Obj, 0, len(items.Items)) + for _, it := range items.Items { + modified := time.Now() + if it.DateCreated != "" { + if t, parseErr := time.Parse(time.RFC3339Nano, it.DateCreated); parseErr == nil { + modified = t + } + } + + name := strings.TrimSpace(it.Name) + id := strings.TrimSpace(it.ID) + displayName := name + if name != "" && id != "" { + if it.IsFolder { + displayName = fmt.Sprintf("%s (ID%s)", name, id) + } else { + ext := path.Ext(strings.TrimSpace(it.Path)) + if ext == "" { + ext = path.Ext(name) + } + + base := strings.TrimSpace(strings.TrimSuffix(name, ext)) + episodeCode := "" + if m := episodeCodeRegexp.FindString(base); m != "" { + episodeCode = strings.ToUpper(m) + } else if it.ParentIndex > 0 && it.IndexNumber > 0 { + episodeCode = fmt.Sprintf("S%02dE%02d", it.ParentIndex, it.IndexNumber) + } + + title := strings.TrimSpace(base) + if episodeCode != "" { + title = strings.TrimSpace(episodeCodeRegexp.ReplaceAllString(title, "")) + title = strings.TrimSpace(strings.Trim(title, "-_:[]() ")) + } + + series := strings.TrimSpace(it.SeriesName) + if series == "" && episodeCode != "" { + if idx := strings.Index(title, " - "); idx > 0 { + series = strings.TrimSpace(title[:idx]) + title = strings.TrimSpace(title[idx+3:]) + } + } + + core := title + if series != "" { + if title == "" || strings.EqualFold(series, title) { + core = series + } else { + core = series + " " + title + } + } + if core == "" { + core = base + } + + if episodeCode != "" { + core = fmt.Sprintf("%s - [%s]", core, episodeCode) + } + if ext == "" { + displayName = fmt.Sprintf("%s (ID%s)", core, id) + } else { + displayName = fmt.Sprintf("%s (ID%s)%s", core, id, ext) + } + } + } + + obj := &model.Object{ + ID: id, + Name: displayName, + Path: path.Join(parentPath, displayName), + Size: it.Size, + Modified: modified, + IsFolder: it.IsFolder, + } + if it.IsFolder { + obj.Size = 0 + } + objs = append(objs, obj) + } + return objs, nil +} + +func (d *Emby) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (*model.Link, error) { + if file.IsDir() { + return nil, errs.NotFile + } + fileID := strings.TrimSpace(file.GetID()) + if fileID == "" { + return nil, fmt.Errorf("invalid file id") + } + + u, err := url.Parse(d.URL) + if err != nil { + return nil, err + } + linkMethod := strings.ToLower(strings.TrimSpace(d.LinkMethod)) + useDownload := linkMethod == "download" + + mediaSourceID := "" + mediaContainer := "" + if !useDownload { + detailURL, parseErr := url.Parse(d.URL + "/Users/" + d.userID + "/Items/" + fileID) + if parseErr == nil { + q := detailURL.Query() + q.Set("Fields", "MediaSources") + q.Set("api_key", d.token) + detailURL.RawQuery = q.Encode() + + req, reqErr := http.NewRequestWithContext(ctx, http.MethodGet, detailURL.String(), nil) + if reqErr == nil { + resp, doErr := d.client.Do(req) + if doErr == nil { + func() { + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return + } + var detail itemDetailResp + if decodeErr := json.NewDecoder(resp.Body).Decode(&detail); decodeErr != nil || len(detail.MediaSources) == 0 { + return + } + for i := range detail.MediaSources { + if strings.TrimSpace(detail.MediaSources[i].ID) != "" && detail.MediaSources[i].SupportsDirectStream { + mediaSourceID = strings.TrimSpace(detail.MediaSources[i].ID) + mediaContainer = strings.TrimSpace(detail.MediaSources[i].Container) + return + } + } + for i := range detail.MediaSources { + if strings.TrimSpace(detail.MediaSources[i].ID) != "" { + mediaSourceID = strings.TrimSpace(detail.MediaSources[i].ID) + mediaContainer = strings.TrimSpace(detail.MediaSources[i].Container) + return + } + } + }() + } + } + } + } + + if useDownload { + u.Path = path.Join(u.Path, "/Items", fileID, "Download") + } else { + if mediaContainer != "" { + u.Path = path.Join(u.Path, "/Videos", fileID, "stream."+mediaContainer) + } else { + u.Path = path.Join(u.Path, "/Videos", fileID, "stream") + } + } + q := u.Query() + q.Set("api_key", d.token) + if mediaSourceID != "" { + q.Set("MediaSourceId", mediaSourceID) + } + if !useDownload { + q.Set("Static", "true") + } + u.RawQuery = q.Encode() + + return &model.Link{ + URL: u.String(), + Header: http.Header{ + "User-Agent": []string{base.UserAgent}, + }, + }, nil +} + +var _ driver.Driver = (*Emby)(nil) diff --git a/drivers/emby/meta.go b/drivers/emby/meta.go new file mode 100644 index 000000000..632c98b7a --- /dev/null +++ b/drivers/emby/meta.go @@ -0,0 +1,30 @@ +package emby + +import ( + "github.com/OpenListTeam/OpenList/v4/internal/driver" + "github.com/OpenListTeam/OpenList/v4/internal/op" +) + +type Addition struct { + driver.RootID + URL string `json:"url" required:"true"` + ApiKey string `json:"api_key"` + UserID string `json:"user_id"` + Username string `json:"username"` + Password string `json:"password"` + LinkMethod string `json:"link_method" type:"select" options:"stream,download" default:"stream"` +} + +var config = driver.Config{ + Name: "Emby", + LocalSort: true, + NoUpload: true, + DefaultRoot: "1", + CheckStatus: true, +} + +func init() { + op.RegisterDriver(func() driver.Driver { + return &Emby{} + }) +} diff --git a/drivers/emby/types.go b/drivers/emby/types.go new file mode 100644 index 000000000..5f0515829 --- /dev/null +++ b/drivers/emby/types.go @@ -0,0 +1,41 @@ +package emby + +type authReq struct { + Username string `json:"Username"` + Pw string `json:"Pw"` +} + +type authResp struct { + AccessToken string `json:"AccessToken"` + User struct { + ID string `json:"Id"` + } `json:"User"` +} + +type listResp struct { + Items []embyItem `json:"Items"` + TotalRecordCount int `json:"TotalRecordCount"` +} + +type embyItem struct { + Name string `json:"Name"` + ID string `json:"Id"` + Type string `json:"Type"` + Path string `json:"Path"` + SeriesName string `json:"SeriesName"` + IndexNumber int `json:"IndexNumber"` + ParentIndex int `json:"ParentIndexNumber"` + IsFolder bool `json:"IsFolder"` + Size int64 `json:"Size"` + DateCreated string `json:"DateCreated"` +} + +type itemDetailResp struct { + MediaSources []embyMediaSource `json:"MediaSources"` +} + +type embyMediaSource struct { + ID string `json:"Id"` + Container string `json:"Container"` + SupportsDirectStream bool `json:"SupportsDirectStream"` +} diff --git a/drivers/emby/util.go b/drivers/emby/util.go new file mode 100644 index 000000000..788fb4be4 --- /dev/null +++ b/drivers/emby/util.go @@ -0,0 +1,91 @@ +package emby + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "regexp" + "strings" +) + +var episodeCodeRegexp = regexp.MustCompile(`(?i)\bS\d{1,2}E\d{1,2}\b`) + +func (d *Emby) login(ctx context.Context) error { + payload, err := json.Marshal(authReq{ + Username: d.Username, + Pw: d.Password, + }) + if err != nil { + return err + } + + endpoint := d.URL + "/Users/AuthenticateByName" + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payload)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Emby-Authorization", `MediaBrowser Client="OpenList", Device="OpenList", DeviceId="openlist-emby", Version="1.0.0"`) + + resp, err := d.client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("emby auth failed: status=%d body=%s", resp.StatusCode, strings.TrimSpace(string(body))) + } + + var data authResp + if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { + return err + } + if strings.TrimSpace(data.AccessToken) == "" || strings.TrimSpace(data.User.ID) == "" { + return fmt.Errorf("emby auth response missing access token or user id") + } + + d.token = data.AccessToken + d.userID = data.User.ID + return nil +} + +func (d *Emby) getItems(ctx context.Context, parentID string) (*listResp, error) { + u, err := url.Parse(d.URL + "/Users/" + d.userID + "/Items") + if err != nil { + return nil, err + } + q := u.Query() + q.Set("ParentId", parentID) + q.Set("Recursive", "false") + q.Set("Fields", "Path,Size,DateCreated,SeriesName,IndexNumber,ParentIndexNumber") + q.Set("api_key", d.token) + u.RawQuery = q.Encode() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) + if err != nil { + return nil, err + } + + resp, err := d.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("emby list failed: status=%d body=%s", resp.StatusCode, strings.TrimSpace(string(body))) + } + + var data listResp + if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { + return nil, err + } + return &data, nil +} diff --git a/drivers/ftp/driver.go b/drivers/ftp/driver.go index 484533786..0d3e803e0 100644 --- a/drivers/ftp/driver.go +++ b/drivers/ftp/driver.go @@ -5,6 +5,7 @@ import ( "errors" "io" stdpath "path" + "strings" "github.com/OpenListTeam/OpenList/v4/internal/driver" "github.com/OpenListTeam/OpenList/v4/internal/errs" @@ -51,13 +52,35 @@ func (d *FTP) List(ctx context.Context, dir model.Obj, args model.ListArgs) ([]m if err := d.login(); err != nil { return nil, err } - entries, err := d.conn.List(encode(dir.GetPath(), d.Encoding)) + + path := encode(dir.GetPath(), d.Encoding) + + var entries []*ftp.Entry + var err error + + if d.CwdList { + origDir, cwdErr := d.conn.CurrentDir() + if cwdErr != nil { + return nil, cwdErr + } + if cwdErr = d.conn.ChangeDir(path); cwdErr != nil { + return nil, cwdErr + } + entries, err = d.conn.List("") + if restoreErr := d.conn.ChangeDir(origDir); restoreErr != nil { + d.conn = nil + } + } else { + entries, err = d.conn.List(path) + } + if err != nil { return nil, err } + res := make([]model.Obj, 0) for _, entry := range entries { - if entry.Name == "." || entry.Name == ".." { + if entry.Name == "." || entry.Name == ".." || strings.Contains(entry.Name, "/") { continue } name := decode(entry.Name, d.Encoding) diff --git a/drivers/ftp/meta.go b/drivers/ftp/meta.go index 0ec0e735e..6dc0d2cb6 100644 --- a/drivers/ftp/meta.go +++ b/drivers/ftp/meta.go @@ -27,6 +27,7 @@ type Addition struct { Encoding string `json:"encoding" required:"true"` Username string `json:"username" required:"true"` Password string `json:"password" required:"true"` + CwdList bool `json:"cwd_list" type:"bool" default:"false" help:"enter directory before listing"` driver.RootPath } diff --git a/drivers/github_releases/driver.go b/drivers/github_releases/driver.go index 8a8025c5a..92d94b4dc 100644 --- a/drivers/github_releases/driver.go +++ b/drivers/github_releases/driver.go @@ -11,6 +11,7 @@ import ( "github.com/OpenListTeam/OpenList/v4/internal/errs" "github.com/OpenListTeam/OpenList/v4/internal/model" "github.com/OpenListTeam/OpenList/v4/pkg/utils" + log "github.com/sirupsen/logrus" ) type GithubReleases struct { @@ -45,12 +46,25 @@ func (d *GithubReleases) List(ctx context.Context, dir model.Obj, args model.Lis point := &d.points[i] if !d.Addition.ShowAllVersion { // latest - point.RequestRelease(d.GetRequest, args.Refresh) + err := point.RequestRelease(d.GetRequest, args.Refresh) + if err != nil { + log.Warnf("failed to request release for %s: %v", point.Repo, err) + } if point.Point == path { // 与仓库路径相同 + if point.Release == nil { + if err != nil { + return nil, fmt.Errorf("failed to get release for %s: %w", point.Repo, err) + } + return nil, fmt.Errorf("failed to get release for %s: unknown error", point.Repo) + } files = append(files, point.GetLatestRelease()...) if d.Addition.ShowReadme { - files = append(files, point.GetOtherFile(d.GetRequest, args.Refresh)...) + otherFiles, err := point.GetOtherFile(d.GetRequest, args.Refresh) + if err != nil { + return nil, fmt.Errorf("failed to get other files for %s: %w", point.Repo, err) + } + files = append(files, otherFiles...) } if d.Addition.ShowSourceCode { files = append(files, point.GetSourceCode()...) @@ -60,6 +74,9 @@ func (d *GithubReleases) List(ctx context.Context, dir model.Obj, args model.Lis if nextDir == "" { continue } + if err != nil { + return nil, fmt.Errorf("failed to get release for %s: %w", point.Repo, err) + } hasSameDir := false for index := range files { @@ -70,30 +87,51 @@ func (d *GithubReleases) List(ctx context.Context, dir model.Obj, args model.Lis } } if !hasSameDir { + var updateAt, createAt string + if point.Release != nil { + updateAt = point.Release.PublishedAt + createAt = point.Release.CreatedAt + } files = append(files, File{ Path: stdpath.Join(path, nextDir), FileName: nextDir, Size: point.GetLatestSize(), - UpdateAt: point.Release.PublishedAt, - CreateAt: point.Release.CreatedAt, + UpdateAt: updateAt, + CreateAt: createAt, Type: "dir", Url: "", }) } } } else { // all version - point.RequestReleases(d.GetRequest, args.Refresh) + err := point.RequestReleases(d.GetRequest, args.Refresh) + if err != nil { + log.Warnf("failed to request releases for %s: %v", point.Repo, err) + } if point.Point == path { // 与仓库路径相同 + if point.Releases == nil { + if err != nil { + return nil, fmt.Errorf("failed to get releases for %s: %w", point.Repo, err) + } + return nil, fmt.Errorf("failed to get releases for %s: unknown error", point.Repo) + } files = append(files, point.GetAllVersion()...) if d.Addition.ShowReadme { - files = append(files, point.GetOtherFile(d.GetRequest, args.Refresh)...) + otherFiles, err := point.GetOtherFile(d.GetRequest, args.Refresh) + if err != nil { + return nil, fmt.Errorf("failed to get other files for %s: %w", point.Repo, err) + } + files = append(files, otherFiles...) } } else if strings.HasPrefix(point.Point, path) { // 仓库目录的父目录 nextDir := GetNextDir(point.Point, path) if nextDir == "" { continue } + if err != nil { + return nil, fmt.Errorf("failed to get releases for %s: %w", point.Repo, err) + } hasSameDir := false for index := range files { @@ -104,12 +142,17 @@ func (d *GithubReleases) List(ctx context.Context, dir model.Obj, args model.Lis } } if !hasSameDir { + var updateAt, createAt string + if point.Releases != nil && len(*point.Releases) > 0 { + updateAt = (*point.Releases)[0].PublishedAt + createAt = (*point.Releases)[0].CreatedAt + } files = append(files, File{ FileName: nextDir, Path: stdpath.Join(path, nextDir), Size: point.GetAllVersionSize(), - UpdateAt: (*point.Releases)[0].PublishedAt, - CreateAt: (*point.Releases)[0].CreatedAt, + UpdateAt: updateAt, + CreateAt: createAt, Type: "dir", Url: "", }) @@ -119,6 +162,12 @@ func (d *GithubReleases) List(ctx context.Context, dir model.Obj, args model.Lis if tagName == "" { continue } + if point.Releases == nil { + if err != nil { + return nil, fmt.Errorf("failed to get releases for %s: %w", point.Repo, err) + } + return nil, fmt.Errorf("failed to get releases for %s: unknown error", point.Repo) + } files = append(files, point.GetReleaseByTagName(tagName)...) diff --git a/drivers/github_releases/types.go b/drivers/github_releases/types.go index 5ceed7276..38db702ac 100644 --- a/drivers/github_releases/types.go +++ b/drivers/github_releases/types.go @@ -19,33 +19,54 @@ type MountPoint struct { } // 请求最新版本 -func (m *MountPoint) RequestRelease(get func(url string) (*resty.Response, error), refresh bool) { +func (m *MountPoint) RequestRelease(get func(url string) (*resty.Response, error), refresh bool) error { if m.Repo == "" { - return + return nil } if m.Release == nil || refresh { - resp, _ := get("https://api.github.com/repos/" + m.Repo + "/releases/latest") - m.Release = new(Release) - json.Unmarshal(resp.Body(), m.Release) + resp, err := get("https://api.github.com/repos/" + m.Repo + "/releases/latest") + if err != nil { + m.Release = nil + return err + } + release := new(Release) + if err := json.Unmarshal(resp.Body(), release); err != nil { + m.Release = nil + return err + } + m.Release = release } + return nil } // 请求所有版本 -func (m *MountPoint) RequestReleases(get func(url string) (*resty.Response, error), refresh bool) { +func (m *MountPoint) RequestReleases(get func(url string) (*resty.Response, error), refresh bool) error { if m.Repo == "" { - return + return nil } if m.Releases == nil || refresh { - resp, _ := get("https://api.github.com/repos/" + m.Repo + "/releases") - m.Releases = new([]Release) - json.Unmarshal(resp.Body(), m.Releases) + resp, err := get("https://api.github.com/repos/" + m.Repo + "/releases") + if err != nil { + m.Releases = nil + return err + } + releases := new([]Release) + if err := json.Unmarshal(resp.Body(), releases); err != nil { + m.Releases = nil + return err + } + m.Releases = releases } + return nil } // 获取最新版本 func (m *MountPoint) GetLatestRelease() []File { + if m.Release == nil { + return nil + } files := make([]File, 0, len(m.Release.Assets)) for _, asset := range m.Release.Assets { files = append(files, File{ @@ -63,6 +84,9 @@ func (m *MountPoint) GetLatestRelease() []File { // 获取最新版本大小 func (m *MountPoint) GetLatestSize() int64 { + if m.Release == nil { + return 0 + } size := int64(0) for _, asset := range m.Release.Assets { size += asset.Size @@ -72,6 +96,9 @@ func (m *MountPoint) GetLatestSize() int64 { // 获取所有版本 func (m *MountPoint) GetAllVersion() []File { + if m.Releases == nil { + return nil + } files := make([]File, 0) for _, release := range *m.Releases { file := File{ @@ -93,6 +120,9 @@ func (m *MountPoint) GetAllVersion() []File { // 根据版本号获取版本 func (m *MountPoint) GetReleaseByTagName(tagName string) []File { + if m.Releases == nil { + return nil + } for _, item := range *m.Releases { if item.TagName == tagName { files := make([]File, 0) @@ -145,6 +175,9 @@ func (m *MountPoint) GetAllVersionSize() int64 { } func (m *MountPoint) GetSourceCode() []File { + if m.Release == nil { + return nil + } files := make([]File, 0) // 无法获取文件大小,此处设为 1 @@ -171,6 +204,9 @@ func (m *MountPoint) GetSourceCode() []File { } func (m *MountPoint) GetSourceCodeByTagName(tagName string) []File { + if m.Releases == nil { + return nil + } for _, item := range *m.Releases { if item.TagName == tagName { files := make([]File, 0) @@ -198,11 +234,19 @@ func (m *MountPoint) GetSourceCodeByTagName(tagName string) []File { return nil } -func (m *MountPoint) GetOtherFile(get func(url string) (*resty.Response, error), refresh bool) []File { +func (m *MountPoint) GetOtherFile(get func(url string) (*resty.Response, error), refresh bool) ([]File, error) { if m.OtherFile == nil || refresh { - resp, _ := get("https://api.github.com/repos/" + m.Repo + "/contents") - m.OtherFile = new([]FileInfo) - json.Unmarshal(resp.Body(), m.OtherFile) + resp, err := get("https://api.github.com/repos/" + m.Repo + "/contents") + if err != nil { + m.OtherFile = nil + return nil, err + } + otherFile := new([]FileInfo) + if err := json.Unmarshal(resp.Body(), otherFile); err != nil { + m.OtherFile = nil + return nil, err + } + m.OtherFile = otherFile } files := make([]File, 0) @@ -220,7 +264,7 @@ func (m *MountPoint) GetOtherFile(get func(url string) (*resty.Response, error), }) } } - return files + return files, nil } type File struct { diff --git a/drivers/github_releases/util.go b/drivers/github_releases/util.go index 91a4b3a23..a6401e6cd 100644 --- a/drivers/github_releases/util.go +++ b/drivers/github_releases/util.go @@ -7,7 +7,6 @@ import ( "github.com/OpenListTeam/OpenList/v4/drivers/base" "github.com/go-resty/resty/v2" - log "github.com/sirupsen/logrus" ) // 发送 GET 请求 @@ -23,7 +22,7 @@ func (d *GithubReleases) GetRequest(url string) (*resty.Response, error) { return nil, err } if res.StatusCode() != 200 { - log.Warn("failed to get request: ", res.StatusCode(), res.String()) + return nil, fmt.Errorf("github api error: status %d", res.StatusCode()) } return res, nil } diff --git a/drivers/ilanzou/driver.go b/drivers/ilanzou/driver.go index e58e01bc2..73b31014e 100644 --- a/drivers/ilanzou/driver.go +++ b/drivers/ilanzou/driver.go @@ -120,7 +120,10 @@ func (d *ILanZou) Link(ctx context.Context, file model.Obj, args model.LinkArgs) if err != nil { return nil, err } - ts, ts_str, _ := getTimestamp(d.conf.secret) + ts, tsStr, err := getTimestamp(d.conf.secret) + if err != nil { + return nil, err + } params := []string{ "uuid=" + url.QueryEscape(d.UUID), @@ -129,9 +132,9 @@ func (d *ILanZou) Link(ctx context.Context, file model.Obj, args model.LinkArgs) "devModel=chrome", "devVersion=" + url.QueryEscape(d.conf.devVersion), "appVersion=", - "timestamp=" + ts_str, + "timestamp=" + tsStr, "appToken=" + url.QueryEscape(d.Token), - "enable=0", + "enable=1", } downloadId, err := mopan.AesEncrypt([]byte(fmt.Sprintf("%s|%s", file.GetID(), d.userID)), d.conf.secret) @@ -149,10 +152,12 @@ func (d *ILanZou) Link(ctx context.Context, file model.Obj, args model.LinkArgs) u.RawQuery = strings.Join(params, "&") realURL := u.String() // get the url after redirect - req := base.NoRedirectClient.R() - + req := base.NoRedirectClient.R().SetContext(ctx) req.SetHeaders(map[string]string{ - "Referer": d.conf.site + "/", + "Origin": d.conf.site, + "Referer": d.conf.site + "/", + "Accept-Encoding": "gzip", + "Accept-Language": "zh-CN,zh;q=0.9,en-US,en;q=0.8", }) if d.Addition.Ip != "" { req.SetHeader("X-Forwarded-For", d.Addition.Ip) @@ -162,10 +167,13 @@ func (d *ILanZou) Link(ctx context.Context, file model.Obj, args model.LinkArgs) if err != nil { return nil, err } - if res.StatusCode() == 302 { - realURL = res.Header().Get("location") + location := res.Header().Get("location") + if location != "" && utils.SliceContains([]int{http.StatusMovedPermanently, http.StatusFound, http.StatusSeeOther, http.StatusTemporaryRedirect, http.StatusPermanentRedirect}, res.StatusCode()) { + realURL = location + } else if res.StatusCode() == http.StatusOK && location != "" { + realURL = location } else { - return nil, fmt.Errorf("redirect failed, status: %d, msg: %s", res.StatusCode(), utils.Json.Get(res.Body(), "msg").ToString()) + return nil, fmt.Errorf("redirect failed, status: %d, location: %s, msg: %s", res.StatusCode(), location, utils.Json.Get(res.Body(), "msg").ToString()) } link := model.Link{URL: realURL} return &link, nil diff --git a/drivers/ilanzou/meta.go b/drivers/ilanzou/meta.go index 5b38f1c2e..35637aa51 100644 --- a/drivers/ilanzou/meta.go +++ b/drivers/ilanzou/meta.go @@ -35,7 +35,7 @@ func init() { NoOverwriteUpload: true, }, conf: Conf{ - base: "https://api.ilanzou.com", + base: "https://apis.ilanzou.com", secret: []byte("lanZouY-disk-app"), bucket: "wpanstore-lanzou", unproved: "unproved", diff --git a/drivers/lanzou/driver.go b/drivers/lanzou/driver.go index e143cde20..0a7213e10 100644 --- a/drivers/lanzou/driver.go +++ b/drivers/lanzou/driver.go @@ -3,6 +3,7 @@ package lanzou import ( "context" "net/http" + "sync/atomic" "github.com/OpenListTeam/OpenList/v4/drivers/base" "github.com/OpenListTeam/OpenList/v4/internal/driver" @@ -18,7 +19,7 @@ type LanZou struct { uid string vei string - flag int32 + flag atomic.Int32 } func (d *LanZou) Config() driver.Config { diff --git a/drivers/lanzou/util.go b/drivers/lanzou/util.go index 9a15d428e..e70154402 100644 --- a/drivers/lanzou/util.go +++ b/drivers/lanzou/util.go @@ -10,7 +10,6 @@ import ( "strconv" "strings" "sync" - "sync/atomic" "time" "github.com/OpenListTeam/OpenList/v4/drivers/base" @@ -43,9 +42,9 @@ func (d *LanZou) get(url string, callback base.ReqCallback) ([]byte, error) { func (d *LanZou) post(url string, callback base.ReqCallback, resp interface{}) ([]byte, error) { data, err := d._post(url, callback, resp, false) if err == ErrCookieExpiration && d.IsAccount() { - if atomic.CompareAndSwapInt32(&d.flag, 0, 1) { + if d.flag.CompareAndSwap(0, 1) { _, err2 := d.Login() - atomic.SwapInt32(&d.flag, 0) + d.flag.Swap(0) if err2 != nil { err = errors.Join(err, err2) d.Status = err.Error() @@ -53,7 +52,7 @@ func (d *LanZou) post(url string, callback base.ReqCallback, resp interface{}) ( return data, err } } - for atomic.LoadInt32(&d.flag) != 0 { + for d.flag.Load() != 0 { runtime.Gosched() } return d._post(url, callback, resp, false) diff --git a/drivers/local/driver.go b/drivers/local/driver.go index 73b75d86d..b3515f805 100644 --- a/drivers/local/driver.go +++ b/drivers/local/driver.go @@ -390,6 +390,9 @@ func (d *Local) Remove(ctx context.Context, obj model.Obj) error { if err != nil { return err } + if !obj.IsDir() { + d.removeThumbCache(obj.GetPath()) + } if obj.IsDir() { if d.directoryMap.Has(obj.GetPath()) { d.directoryMap.DeleteDirNode(obj.GetPath()) diff --git a/drivers/local/remove_test.go b/drivers/local/remove_test.go new file mode 100644 index 000000000..86ec51081 --- /dev/null +++ b/drivers/local/remove_test.go @@ -0,0 +1,44 @@ +package local + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/OpenListTeam/OpenList/v4/internal/model" +) + +func TestRemoveDeletesThumbCache(t *testing.T) { + root := t.TempDir() + cacheDir := filepath.Join(root, "thumbs") + if err := os.Mkdir(cacheDir, 0o755); err != nil { + t.Fatal(err) + } + filePath := filepath.Join(root, "photo.jpg") + if err := os.WriteFile(filePath, []byte("image"), 0o666); err != nil { + t.Fatal(err) + } + + driver := &Local{ + Addition: Addition{ + ThumbCacheFolder: cacheDir, + }, + } + thumbPath := driver.thumbCachePath(filePath) + if err := os.WriteFile(thumbPath, []byte("thumb"), 0o666); err != nil { + t.Fatal(err) + } + + err := driver.Remove(context.Background(), &model.Object{ + Path: filePath, + Name: filepath.Base(filePath), + }) + if err != nil { + t.Fatal(err) + } + + if _, err = os.Stat(thumbPath); !os.IsNotExist(err) { + t.Fatalf("expected thumb cache to be removed, got %v", err) + } +} diff --git a/drivers/local/util.go b/drivers/local/util.go index be86d9c9b..f9f4f4c45 100644 --- a/drivers/local/util.go +++ b/drivers/local/util.go @@ -24,6 +24,8 @@ import ( ffmpeg "github.com/u2takey/ffmpeg-go" ) +const thumbPrefix = "openlist_thumb_" + func isSymlinkDir(f fs.FileInfo, path string) bool { if f.Mode()&os.ModeSymlink == os.ModeSymlink || (runtime.GOOS == "windows" && f.Mode()&os.ModeIrregular == os.ModeIrregular) { // os.ModeIrregular is Junction bit in Windows @@ -110,16 +112,29 @@ func readDir(dirname string) ([]fs.FileInfo, error) { return list, nil } +func (d *Local) thumbCachePath(fullPath string) string { + if d.ThumbCacheFolder == "" { + return "" + } + return filepath.Join(d.ThumbCacheFolder, thumbPrefix+utils.GetMD5EncodeStr(fullPath)+".png") +} + +func (d *Local) removeThumbCache(fullPath string) { + thumbPath := d.thumbCachePath(fullPath) + if thumbPath == "" { + return + } + _ = os.Remove(thumbPath) +} + func (d *Local) getThumb(file model.Obj) (*bytes.Buffer, *string, error) { fullPath := file.GetPath() - thumbPrefix := "openlist_thumb_" - thumbName := thumbPrefix + utils.GetMD5EncodeStr(fullPath) + ".png" if d.ThumbCacheFolder != "" { // skip if the file is a thumbnail if strings.HasPrefix(file.GetName(), thumbPrefix) { return nil, &fullPath, nil } - thumbPath := filepath.Join(d.ThumbCacheFolder, thumbName) + thumbPath := d.thumbCachePath(fullPath) if utils.Exists(thumbPath) { return nil, &thumbPath, nil } @@ -151,7 +166,7 @@ func (d *Local) getThumb(file model.Obj) (*bytes.Buffer, *string, error) { return nil, nil, err } if d.ThumbCacheFolder != "" { - err = os.WriteFile(filepath.Join(d.ThumbCacheFolder, thumbName), buf.Bytes(), 0o666) + err = os.WriteFile(d.thumbCachePath(fullPath), buf.Bytes(), 0o666) if err != nil { return nil, nil, err } diff --git a/drivers/onedrive_sharelink/driver.go b/drivers/onedrive_sharelink/driver.go index 0bc3e79b5..a975772ac 100644 --- a/drivers/onedrive_sharelink/driver.go +++ b/drivers/onedrive_sharelink/driver.go @@ -1,27 +1,41 @@ package onedrive_sharelink import ( + "bytes" "context" + "encoding/json" "fmt" "io" "net/http" + "net/url" stdpath "path" + "regexp" "strings" "sync" "time" + "github.com/OpenListTeam/OpenList/v4/drivers/base" "github.com/OpenListTeam/OpenList/v4/internal/driver" "github.com/OpenListTeam/OpenList/v4/internal/errs" "github.com/OpenListTeam/OpenList/v4/internal/model" "github.com/OpenListTeam/OpenList/v4/internal/net" + streamPkg "github.com/OpenListTeam/OpenList/v4/internal/stream" "github.com/OpenListTeam/OpenList/v4/pkg/cron" "github.com/OpenListTeam/OpenList/v4/pkg/http_range" "github.com/OpenListTeam/OpenList/v4/pkg/singleflight" "github.com/OpenListTeam/OpenList/v4/pkg/utils" + "github.com/avast/retry-go" + "github.com/pkg/errors" log "github.com/sirupsen/logrus" ) -const headerTTL = 25 * time.Minute +const ( + headerTTL = 25 * time.Minute + driveTokenTTL = 20 * time.Minute + directLinkTTL = 20 * time.Minute + simpleUploadLimit = 250 * 1024 * 1024 + uploadSessionChunk = 10 * 1024 * 1024 +) type OnedriveSharelink struct { model.Storage @@ -78,10 +92,17 @@ func (d *OnedriveSharelink) List(ctx context.Context, dir model.Obj, args model. if err != nil { return nil, err } + folderSizes, err := d.driveChildrenFolderSizes(ctx, dir.GetPath()) + if err != nil { + log.Warnf("onedrive_sharelink: failed to get folder sizes for %s: %+v", dir.GetPath(), err) + } // Convert the slice of files to the required model.Obj format return utils.SliceConvert(files, func(src Item) (model.Obj, error) { obj := fileToObj(src) + if size, ok := folderSizes[obj.GetName()]; ok { + obj.Size = size + } obj.Path = stdpath.Join(dir.GetPath(), obj.GetName()) return obj, nil }) @@ -99,6 +120,18 @@ func (d *OnedriveSharelink) Link(ctx context.Context, file model.Obj, args model return nil, err } + if args.Redirect { + directURL, err := d.resolveDirectDownloadURL(ctx, file, url, header) + if err != nil { + return nil, err + } + expiration := directLinkTTL + return &model.Link{ + URL: directURL, + Expiration: &expiration, + }, nil + } + return &model.Link{ URL: url, Header: header, @@ -109,8 +142,30 @@ func (d *OnedriveSharelink) Link(ctx context.Context, file model.Obj, args model } func (d *OnedriveSharelink) MakeDir(ctx context.Context, parentDir model.Obj, dirName string) error { - // TODO create folder, optional - return errs.NotImplement + token, err := d.getValidDriveAccessToken(ctx) + if err != nil { + return err + } + apiURL := injectAccessToken(d.drivePathAPIURL(parentDir.GetPath())+"/children", token) + body := map[string]any{ + "name": dirName, + "folder": map[string]any{}, + "@microsoft.graph.conflictBehavior": "fail", + } + resp, err := d.doJSON(ctx, http.MethodPost, apiURL, body) + if err != nil { + return err + } + defer resp.Body.Close() + switch resp.StatusCode { + case http.StatusOK, http.StatusCreated: + return nil + case http.StatusConflict: + return errs.ObjectAlreadyExists + default: + data, _ := io.ReadAll(resp.Body) + return fmt.Errorf("failed to create folder, status code: %d, body: %s", resp.StatusCode, string(data)) + } } func (d *OnedriveSharelink) Move(ctx context.Context, srcObj, dstDir model.Obj) error { @@ -134,8 +189,422 @@ func (d *OnedriveSharelink) Remove(ctx context.Context, obj model.Obj) error { } func (d *OnedriveSharelink) Put(ctx context.Context, dstDir model.Obj, stream model.FileStreamer, up driver.UpdateProgress) error { - // TODO upload file, optional - return errs.NotImplement + info, err := d.createUploadInfo(ctx, stdpath.Join(dstDir.GetPath(), stream.GetName()), stream.GetSize()) + if err != nil { + return err + } + if info.ChunkSize == 0 { + return d.uploadContent(ctx, info.UploadURL, stream, up) + } + return d.uploadToSession(ctx, info.UploadURL, stream, up) +} + +func (d *OnedriveSharelink) GetDetails(ctx context.Context) (*model.StorageDetails, error) { + if d.DisableDiskUsage { + return nil, errs.NotImplement + } + size, err := d.driveItemSize(ctx, "/") + if err != nil { + return nil, err + } + return &model.StorageDetails{ + DiskUsage: model.DiskUsage{ + TotalSpace: size, + UsedSpace: size, + }, + }, nil +} + +func (d *OnedriveSharelink) driveItemSize(ctx context.Context, path string) (int64, error) { + token, err := d.getValidDriveAccessToken(ctx) + if err != nil { + return 0, err + } + apiURL := injectAccessToken(d.drivePathAPIURL(path), token) + resp, err := d.doJSON(ctx, http.MethodGet, apiURL, nil) + if err != nil { + return 0, err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + data, _ := io.ReadAll(resp.Body) + return 0, fmt.Errorf("failed to get folder details, status code: %d, body: %s", resp.StatusCode, string(data)) + } + var item struct { + Size int64 `json:"size"` + } + if err := json.NewDecoder(resp.Body).Decode(&item); err != nil { + return 0, err + } + return item.Size, nil +} + +func (d *OnedriveSharelink) driveChildrenFolderSizes(ctx context.Context, path string) (map[string]int64, error) { + token, err := d.getValidDriveAccessToken(ctx) + if err != nil { + return nil, err + } + rawURL := d.drivePathAPIURL(path) + "/children?$select=name,size,folder" + sizes := make(map[string]int64) + for rawURL != "" { + resp, err := d.doJSON(ctx, http.MethodGet, injectAccessToken(rawURL, token), nil) + if err != nil { + return nil, err + } + var data struct { + Value []struct { + Name string `json:"name"` + Size int64 `json:"size"` + Folder any `json:"folder"` + } `json:"value"` + NextLink string `json:"@odata.nextLink"` + } + func() { + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + body, _ := io.ReadAll(resp.Body) + err = fmt.Errorf("failed to get children details, status code: %d, body: %s", resp.StatusCode, string(body)) + return + } + err = json.NewDecoder(resp.Body).Decode(&data) + }() + if err != nil { + return nil, err + } + for _, item := range data.Value { + if item.Folder != nil { + sizes[item.Name] = item.Size + } + } + rawURL = data.NextLink + } + return sizes, nil +} + +func (d *OnedriveSharelink) GetDirectUploadTools() []string { + if !d.EnableDirectUpload { + return nil + } + return []string{"HttpDirect"} +} + +func (d *OnedriveSharelink) GetDirectUploadInfo(ctx context.Context, tool string, dstDir model.Obj, fileName string, fileSize int64) (any, error) { + if !d.EnableDirectUpload { + return nil, errs.NotImplement + } + if tool != "HttpDirect" { + return nil, errs.NotImplement + } + return d.createUploadInfo(ctx, stdpath.Join(dstDir.GetPath(), fileName), fileSize) +} + +func (d *OnedriveSharelink) createUploadInfo(ctx context.Context, path string, fileSize int64) (*model.HttpDirectUploadInfo, error) { + token, err := d.getValidDriveAccessToken(ctx) + if err != nil { + return nil, err + } + if fileSize >= 0 && fileSize <= simpleUploadLimit { + return &model.HttpDirectUploadInfo{ + UploadURL: injectAccessToken(d.drivePathAPIURL(path)+"/content", token), + Method: http.MethodPut, + }, nil + } + apiURL := injectAccessToken(d.drivePathAPIURL(path)+"/createUploadSession", token) + body := map[string]any{ + "item": map[string]any{ + "@microsoft.graph.conflictBehavior": "rename", + }, + } + resp, err := d.doJSON(ctx, http.MethodPost, apiURL, body) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + data, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("failed to create upload session, status code: %d, body: %s", resp.StatusCode, string(data)) + } + var data uploadSessionResp + if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { + return nil, err + } + if data.UploadURL == "" { + return nil, fmt.Errorf("failed to get upload URL from response") + } + return &model.HttpDirectUploadInfo{ + UploadURL: data.UploadURL, + ChunkSize: uploadSessionChunk, + Method: http.MethodPut, + }, nil +} + +func (d *OnedriveSharelink) uploadContent(ctx context.Context, uploadURL string, file model.FileStreamer, up driver.UpdateProgress) error { + if up == nil { + up = func(float64) {} + } + reader := driver.NewLimitedUploadStream(ctx, &driver.ReaderUpdatingProgress{ + Reader: &driver.SimpleReaderWithSize{ + Reader: file, + Size: file.GetSize(), + }, + UpdateProgress: up, + }) + req, err := http.NewRequestWithContext(ctx, http.MethodPut, uploadURL, reader) + if err != nil { + return err + } + req.ContentLength = file.GetSize() + resp, err := base.HttpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { + data, _ := io.ReadAll(resp.Body) + return fmt.Errorf("failed to upload content, status code: %d, body: %s", resp.StatusCode, string(data)) + } + return nil +} + +func (d *OnedriveSharelink) uploadToSession(ctx context.Context, uploadURL string, file model.FileStreamer, up driver.UpdateProgress) error { + if up == nil { + up = func(float64) {} + } + if file.GetSize() <= 0 { + return d.uploadSessionChunk(ctx, uploadURL, file, 0, 0, file.GetSize()) + } + ss, err := streamPkg.NewStreamSectionReader(file, uploadSessionChunk, &up) + if err != nil { + return err + } + var finish int64 + for finish < file.GetSize() { + if utils.IsCanceled(ctx) { + return ctx.Err() + } + left := file.GetSize() - finish + byteSize := min(left, int64(uploadSessionChunk)) + rd, err := ss.GetSectionReader(finish, byteSize) + if err != nil { + return err + } + err = retry.Do( + func() error { + if _, err := rd.Seek(0, io.SeekStart); err != nil { + return err + } + return d.uploadSessionChunk(ctx, uploadURL, rd, finish, byteSize, file.GetSize()) + }, + retry.Context(ctx), + retry.Attempts(3), + retry.DelayType(retry.BackOffDelay), + retry.Delay(time.Second), + ) + ss.FreeSectionReader(rd) + if err != nil { + return err + } + finish += byteSize + up(float64(finish) * 100 / float64(file.GetSize())) + } + return nil +} + +func (d *OnedriveSharelink) uploadSessionChunk(ctx context.Context, uploadURL string, reader io.Reader, start, size, total int64) error { + req, err := http.NewRequestWithContext(ctx, http.MethodPut, uploadURL, driver.NewLimitedUploadStream(ctx, reader)) + if err != nil { + return err + } + req.ContentLength = size + if total > 0 { + req.Header.Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, start+size-1, total)) + } + resp, err := base.HttpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + switch { + case resp.StatusCode >= 500 && resp.StatusCode <= 504: + return fmt.Errorf("server error: %d", resp.StatusCode) + case resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusAccepted: + data, _ := io.ReadAll(resp.Body) + return errors.New(string(data)) + default: + return nil + } +} + +func (d *OnedriveSharelink) drivePathAPIURL(path string) string { + drivePath := stdpath.Join(d.driveRootPath, path) + drivePath = utils.FixAndCleanPath(drivePath) + if drivePath == "/" { + return d.DriveURL + "/root" + } + return fmt.Sprintf("%s/root:%s:", d.DriveURL, utils.EncodePath(drivePath, true)) +} + +func injectAccessToken(rawURL, token string) string { + if token == "" { + return rawURL + } + u, err := url.Parse(rawURL) + if err != nil { + return rawURL + } + values := u.Query() + if strings.HasPrefix(token, "access_token=") { + values.Set("access_token", strings.TrimPrefix(token, "access_token=")) + } else { + values.Set("access_token", token) + } + u.RawQuery = values.Encode() + return u.String() +} + +func (d *OnedriveSharelink) doJSON(ctx context.Context, method, rawURL string, body any) (*http.Response, error) { + var reader io.Reader + if body != nil { + data, err := json.Marshal(body) + if err != nil { + return nil, err + } + reader = bytes.NewReader(data) + } + req, err := http.NewRequestWithContext(ctx, method, rawURL, reader) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/json;odata.metadata=minimal") + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + return base.HttpClient.Do(req) +} + +func (d *OnedriveSharelink) getValidDriveAccessToken(ctx context.Context) (string, error) { + d.headerMu.RLock() + token := d.DriveAccessToken + expired := time.Since(time.Unix(d.DriveTokenTime, 0)) > driveTokenTTL + d.headerMu.RUnlock() + if token != "" && !expired { + return token, nil + } + if err := d.refreshDriveContext(ctx); err != nil { + d.headerMu.RLock() + token = d.DriveAccessToken + d.headerMu.RUnlock() + if token != "" { + log.Warnf("onedrive_sharelink: use cached drive access token after refresh failure: %+v", err) + return token, nil + } + return "", err + } + d.headerMu.RLock() + defer d.headerMu.RUnlock() + return d.DriveAccessToken, nil +} + +func (d *OnedriveSharelink) refreshDriveContext(ctx context.Context) error { + header, err := d.getValidHeaders(ctx) + if err != nil { + return err + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, d.ShareLinkURL, nil) + if err != nil { + return err + } + req.Header = cloneHeader(header) + resp, err := NewNoRedirectCLient().Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + redirectURL := resp.Header.Get("Location") + if redirectURL == "" && resp.Request != nil && resp.Request.URL != nil { + redirectURL = resp.Request.URL.String() + } + if redirectURL == "" { + return fmt.Errorf("share link did not return redirect URL") + } + return d.refreshDriveContextFromRedirect(ctx, redirectURL, header) +} + +func (d *OnedriveSharelink) refreshDriveContextFromRedirect(ctx context.Context, redirectURL string, header http.Header) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, redirectURL, nil) + if err != nil { + return err + } + req.Header = cloneHeader(header) + resp, err := base.HttpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("onedrive page request failed, status code: %d", resp.StatusCode) + } + body, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + ctxInfo, err := parsePageContext(body) + if err != nil { + return err + } + rootPath, err := driveRootPathFromRedirect(redirectURL, ctxInfo.ListURL) + if err != nil { + return err + } + d.headerMu.Lock() + d.DriveURL = ctxInfo.DriveInfo.DriveURL + d.DriveAccessToken = ctxInfo.DriveInfo.DriveAccessToken + d.DriveTokenTime = time.Now().Unix() + d.driveRootPath = rootPath + d.headerMu.Unlock() + return nil +} + +func driveRootPathFromRedirect(redirectURL, listURL string) (string, error) { + u, err := url.Parse(redirectURL) + if err != nil { + return "", err + } + id := u.Query().Get("id") + if id == "" { + return "/", nil + } + if listURL == "" { + return "/", nil + } + if id == listURL { + return "/", nil + } + prefix := strings.TrimRight(listURL, "/") + "/" + if strings.HasPrefix(id, prefix) { + return utils.FixAndCleanPath(strings.TrimPrefix(id, strings.TrimRight(listURL, "/"))), nil + } + return "/", nil +} + +var pageContextRE = regexp.MustCompile(`(?s)var _spPageContextInfo=(\{.*?\});_spPageContextInfo`) + +func parsePageContext(body []byte) (*pageContextInfo, error) { + match := pageContextRE.FindSubmatch(body) + if len(match) < 2 { + return nil, fmt.Errorf("failed to find _spPageContextInfo") + } + var info pageContextInfo + if err := json.Unmarshal(match[1], &info); err != nil { + return nil, err + } + if info.DriveInfo.DriveURL == "" { + return nil, fmt.Errorf("failed to get drive URL from page context") + } + if info.DriveInfo.DriveAccessToken == "" { + return nil, fmt.Errorf("failed to get drive access token from page context") + } + return &info, nil } //func (d *OnedriveSharelink) Other(ctx context.Context, args model.OtherArgs) (interface{}, error) { @@ -194,6 +663,96 @@ func cloneHeader(header http.Header) http.Header { return header.Clone() } +func (d *OnedriveSharelink) resolveDirectDownloadURL(ctx context.Context, file model.Obj, rawURL string, header http.Header) (string, error) { + var errs []error + if obj, ok := unwrapObject(file); ok { + if obj.SPItemURL != "" { + directURL, err := d.resolveSPItemDownloadURL(ctx, obj.SPItemURL, header) + if err == nil { + return directURL, nil + } + errs = append(errs, err) + } + if obj.ContentDownloadURL != "" { + return obj.ContentDownloadURL, nil + } + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) + if err != nil { + return "", err + } + req.Header = cloneHeader(header) + if req.Header == nil { + req.Header = http.Header{} + } + + resp, err := NewNoRedirectCLient().Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + location := resp.Header.Get("Location") + if location == "" { + errs = append(errs, fmt.Errorf("download.aspx returned no redirect location, status code: %d", resp.StatusCode)) + return "", fmt.Errorf("onedrive_sharelink: direct download URL unavailable: %v", errs) + } + u, err := req.URL.Parse(location) + if err != nil { + return "", err + } + return u.String(), nil +} + +type spItemDownloadResp struct { + ContentDownloadURL string `json:"@content.downloadUrl"` +} + +func unwrapObject(obj model.Obj) (*Object, bool) { + for { + switch o := obj.(type) { + case *Object: + return o, true + case model.ObjUnwrap: + obj = o.Unwrap() + default: + return nil, false + } + } +} + +func (d *OnedriveSharelink) resolveSPItemDownloadURL(ctx context.Context, spItemURL string, header http.Header) (string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, spItemURL, nil) + if err != nil { + return "", err + } + req.Header = cloneHeader(header) + if req.Header == nil { + req.Header = http.Header{} + } + req.Header.Set("Accept", "application/json;odata.metadata=minimal") + + resp, err := NewNoRedirectCLient().Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return "", fmt.Errorf("sp item metadata request failed, status code: %d", resp.StatusCode) + } + + var data spItemDownloadResp + if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { + return "", err + } + if data.ContentDownloadURL == "" { + return "", fmt.Errorf("sp item metadata response missing @content.downloadUrl") + } + return data.ContentDownloadURL, nil +} + func (d *OnedriveSharelink) headerSnapshot() http.Header { d.headerMu.RLock() defer d.headerMu.RUnlock() diff --git a/drivers/onedrive_sharelink/meta.go b/drivers/onedrive_sharelink/meta.go index 10bea87af..1aed1b0ba 100644 --- a/drivers/onedrive_sharelink/meta.go +++ b/drivers/onedrive_sharelink/meta.go @@ -11,16 +11,21 @@ type Addition struct { driver.RootPath ShareLinkURL string `json:"url" required:"true"` ShareLinkPassword string `json:"password"` + DisableDiskUsage bool `json:"disable_disk_usage" default:"false"` + EnableDirectUpload bool `json:"enable_direct_upload" default:"false" help:"Allow uploading directly to OneDrive without going through OpenList"` IsSharepoint bool downloadLinkPrefix string Headers http.Header HeaderTime int64 + DriveURL string + DriveAccessToken string + DriveTokenTime int64 + driveRootPath string } var config = driver.Config{ Name: "Onedrive Sharelink", - OnlyProxy: true, - NoUpload: true, + LocalSort: true, DefaultRoot: "/", } diff --git a/drivers/onedrive_sharelink/types.go b/drivers/onedrive_sharelink/types.go index 0b7d74645..4405324d8 100644 --- a/drivers/onedrive_sharelink/types.go +++ b/drivers/onedrive_sharelink/types.go @@ -23,30 +23,55 @@ type FolderResp struct { // Item represents an individual item in the folder. type Item struct { - ObjType string `json:"FSObjType"` // ObjType indicates if the item is a file or folder. - Name string `json:"FileLeafRef"` // Name is the name of the item. - ModifiedTime time.Time `json:"Modified."` // ModifiedTime is the last modified time of the item. - Size string `json:"File_x0020_Size"` // Size is the size of the item in string format. - Id string `json:"UniqueId"` // Id is the unique identifier of the item. + ObjType string `json:"FSObjType"` // ObjType indicates if the item is a file or folder. + Name string `json:"FileLeafRef"` // Name is the name of the item. + ModifiedTime time.Time `json:"Modified."` // ModifiedTime is the last modified time of the item. + Size string `json:"File_x0020_Size"` // Size is the size of the item in string format. + Id string `json:"UniqueId"` // Id is the unique identifier of the item. + SPItemURL string `json:".spItemUrl"` // SPItemURL points to the SharePoint item metadata API. + ContentDownloadURL string `json:"@content.downloadUrl"` // ContentDownloadURL is a temporary cookie-free download URL. } -// fileToObj converts an Item to an ObjThumb. -func fileToObj(f Item) *model.ObjThumb { +type Object struct { + model.ObjThumb + SPItemURL string + ContentDownloadURL string +} + +type uploadSessionResp struct { + UploadURL string `json:"uploadUrl"` +} + +type pageContextInfo struct { + ListURL string `json:"listUrl"` + DriveInfo struct { + DriveURL string `json:".driveUrl"` + DriveAccessToken string `json:".driveAccessToken"` + DriveAccessTokenV2 string `json:".driveAccessTokenV21"` + } `json:"driveInfo"` +} + +// fileToObj converts an Item to an Object. +func fileToObj(f Item) *Object { // Convert Size from string to int64. size, _ := strconv.ParseInt(f.Size, 10, 64) // Convert ObjType from string to int. objtype, _ := strconv.Atoi(f.ObjType) // Create a new ObjThumb with the converted values. - file := &model.ObjThumb{ - Object: model.Object{ - Name: f.Name, - Modified: f.ModifiedTime, - Size: size, - IsFolder: objtype == 1, // Check if the item is a folder. - ID: f.Id, + file := &Object{ + ObjThumb: model.ObjThumb{ + Object: model.Object{ + Name: f.Name, + Modified: f.ModifiedTime, + Size: size, + IsFolder: objtype == 1, // Check if the item is a folder. + ID: f.Id, + }, + Thumbnail: model.Thumbnail{}, }, - Thumbnail: model.Thumbnail{}, + SPItemURL: f.SPItemURL, + ContentDownloadURL: f.ContentDownloadURL, } return file } diff --git a/drivers/onedrive_sharelink/util.go b/drivers/onedrive_sharelink/util.go index d4cd4229a..13785939f 100644 --- a/drivers/onedrive_sharelink/util.go +++ b/drivers/onedrive_sharelink/util.go @@ -310,38 +310,25 @@ func (d *OnedriveSharelink) getFiles(ctx context.Context, path string) ([]Item, json.NewDecoder(resp.Body).Decode(&graphqlReq) log.Debugln("graphqlReq:", graphqlReq) filesData := graphqlReq.Data.Legacy.RenderListDataAsStream.ListData.Row - if graphqlReq.Data.Legacy.RenderListDataAsStream.ListData.NextHref != "" { - nextHref := graphqlReq.Data.Legacy.RenderListDataAsStream.ListData.NextHref + "&@a1=REPLACEME&TryNewExperienceSingle=TRUE" - nextHref = strings.Replace(nextHref, "REPLACEME", "%27"+relativeUrl+"%27", -1) - log.Debugln("nextHref:", nextHref) - filesData = append(filesData, graphqlReq.Data.Legacy.RenderListDataAsStream.ListData.Row...) + nextHref := graphqlReq.Data.Legacy.RenderListDataAsStream.ListData.NextHref + if nextHref != "" { + listViewXml := strings.Replace(graphqlReq.Data.Legacy.RenderListDataAsStream.ViewMetadata.ListViewXml, `"`, `\"`, -1) + renderListDataAsStreamVar := strings.Replace( + `{"parameters":{"__metadata":{"type":"SP.RenderListDataParameters"},"RenderOptions":1216519,"ViewXml":"REPLACEME","AllowMultipleValueFilterForTaxonomyFields":true,"AddRequiredFields":true}}`, + "REPLACEME", + listViewXml, + -1, + ) - listViewXml := graphqlReq.Data.Legacy.RenderListDataAsStream.ViewMetadata.ListViewXml - log.Debugln("listViewXml:", listViewXml) - renderListDataAsStreamVar := `{"parameters":{"__metadata":{"type":"SP.RenderListDataParameters"},"RenderOptions":1216519,"ViewXml":"REPLACEME","AllowMultipleValueFilterForTaxonomyFields":true,"AddRequiredFields":true}}` - listViewXml = strings.Replace(listViewXml, `"`, `\"`, -1) - renderListDataAsStreamVar = strings.Replace(renderListDataAsStreamVar, "REPLACEME", listViewXml, -1) - - graphqlReqNEW := GraphQLNEWRequest{} - postUrl = strings.Join(redirectSplitURL[:len(redirectSplitURL)-3], "/") + "/_api/web/GetListUsingPath(DecodedUrl=@a1)/RenderListDataAsStream" + nextHref - req, _ = http.NewRequest(http.MethodPost, postUrl, strings.NewReader(renderListDataAsStreamVar)) - req.Header = tempHeader - - resp, err := client.Do(req) - if err != nil { - d.Headers, err = d.getHeaders(ctx) - if err != nil { - return nil, err - } - return d.getFiles(ctx, path) - } - defer resp.Body.Close() - json.NewDecoder(resp.Body).Decode(&graphqlReqNEW) - for graphqlReqNEW.ListData.NextHref != "" { - graphqlReqNEW = GraphQLNEWRequest{} + for nextHref != "" { + nextHref = nextHref + "&@a1=REPLACEME&TryNewExperienceSingle=TRUE" + nextHref = strings.Replace(nextHref, "REPLACEME", "%27"+relativeUrl+"%27", -1) + log.Debugln("nextHref:", nextHref) + graphqlReqNEW := GraphQLNEWRequest{} postUrl = strings.Join(redirectSplitURL[:len(redirectSplitURL)-3], "/") + "/_api/web/GetListUsingPath(DecodedUrl=@a1)/RenderListDataAsStream" + nextHref req, _ = http.NewRequest(http.MethodPost, postUrl, strings.NewReader(renderListDataAsStreamVar)) req.Header = tempHeader + resp, err := client.Do(req) if err != nil { d.Headers, err = d.getHeaders(ctx) @@ -352,13 +339,9 @@ func (d *OnedriveSharelink) getFiles(ctx context.Context, path string) ([]Item, } defer resp.Body.Close() json.NewDecoder(resp.Body).Decode(&graphqlReqNEW) - nextHref = graphqlReqNEW.ListData.NextHref + "&@a1=REPLACEME&TryNewExperienceSingle=TRUE" - nextHref = strings.Replace(nextHref, "REPLACEME", "%27"+relativeUrl+"%27", -1) filesData = append(filesData, graphqlReqNEW.ListData.Row...) + nextHref = graphqlReqNEW.ListData.NextHref } - filesData = append(filesData, graphqlReqNEW.ListData.Row...) - } else { - filesData = append(filesData, graphqlReq.Data.Legacy.RenderListDataAsStream.ListData.Row...) } return filesData, nil } diff --git a/drivers/s3/driver.go b/drivers/s3/driver.go index a037912f6..46d7f414a 100644 --- a/drivers/s3/driver.go +++ b/drivers/s3/driver.go @@ -193,7 +193,7 @@ func (d *S3) Remove(ctx context.Context, obj model.Obj) error { if obj.IsDir() { return d.removeDir(ctx, obj.GetPath()) } - return d.removeFile(obj.GetPath()) + return d.removeFile(ctx, obj.GetPath()) } func (d *S3) Put(ctx context.Context, dstDir model.Obj, s model.FileStreamer, up driver.UpdateProgress) error { @@ -249,7 +249,15 @@ func (d *S3) GetDirectUploadInfo(ctx context.Context, _ string, dstDir model.Obj // implements driver.Getter interface func (d *S3) Get(ctx context.Context, path string) (model.Obj, error) { // try to get object as a file using HeadObject - path = stdpath.Join(d.GetRootPath(), path) + rootPath := d.GetRootPath() + // Avoid double-prepending root path when path already contains it. + // This happens when obj.GetPath() from a previous Get call is passed back + // to op.List → op.Get → d.Get. + if rootPath != "/" && utils.IsSubPath(rootPath, path) { + // path already includes the root prefix + } else { + path = stdpath.Join(rootPath, path) + } key := getKey(path, false) headInput := &s3.HeadObjectInput{ Bucket: &d.Bucket, diff --git a/drivers/s3/meta.go b/drivers/s3/meta.go index b2439c683..4243f12d5 100644 --- a/drivers/s3/meta.go +++ b/drivers/s3/meta.go @@ -23,6 +23,7 @@ type Addition struct { AddFilenameToDisposition bool `json:"add_filename_to_disposition" help:"Add filename to Content-Disposition header."` EnableDirectUpload bool `json:"enable_direct_upload" default:"false"` DirectUploadHost string `json:"direct_upload_host" required:"false"` + UserAgent string `json:"user_agent" required:"false" default:"" help:"Custom User-Agent for S3 requests."` } func init() { diff --git a/drivers/s3/util.go b/drivers/s3/util.go index c84380786..8500ed264 100644 --- a/drivers/s3/util.go +++ b/drivers/s3/util.go @@ -49,6 +49,11 @@ const ( func (d *S3) getClient(clientType int) *s3.S3 { client := s3.New(d.Session) + if d.UserAgent != "" { + client.Handlers.Build.PushBack(func(r *request.Request) { + r.HTTPRequest.Header.Set("User-Agent", d.UserAgent) + }) + } if clientType == ClientTypeLink && d.CustomHost != "" { client.Handlers.Build.PushBack(func(r *request.Request) { if r.HTTPRequest.Method != http.MethodGet { @@ -223,7 +228,7 @@ func (d *S3) copyFile(ctx context.Context, src string, dst string) error { CopySource: aws.String(encodedKey), Key: &dstKey, } - _, err := d.client.CopyObject(input) + _, err := d.client.CopyObjectWithContext(ctx, input) return err } @@ -257,23 +262,23 @@ func (d *S3) removeDir(ctx context.Context, src string) error { if obj.IsDir() { err = d.removeDir(ctx, cSrc) } else { - err = d.removeFile(cSrc) + err = d.removeFile(ctx, cSrc) } if err != nil { return err } } - _ = d.removeFile(path.Join(src, getPlaceholderName(d.Placeholder))) - _ = d.removeFile(path.Join(src, d.Placeholder)) + _ = d.removeFile(ctx, path.Join(src, getPlaceholderName(d.Placeholder))) + _ = d.removeFile(ctx, path.Join(src, d.Placeholder)) return nil } -func (d *S3) removeFile(src string) error { +func (d *S3) removeFile(ctx context.Context, src string) error { key := getKey(src, false) input := &s3.DeleteObjectInput{ Bucket: &d.Bucket, Key: &key, } - _, err := d.client.DeleteObject(input) + _, err := d.client.DeleteObjectWithContext(ctx, input) return err } diff --git a/drivers/sftp/util.go b/drivers/sftp/util.go index 293df8fa1..482208999 100644 --- a/drivers/sftp/util.go +++ b/drivers/sftp/util.go @@ -3,6 +3,7 @@ package sftp import ( "fmt" "path" + "time" "github.com/OpenListTeam/OpenList/v4/pkg/singleflight" "github.com/pkg/sftp" @@ -39,6 +40,7 @@ func (d *SFTP) _initClient() error { User: d.Username, Auth: []ssh.AuthMethod{auth}, HostKeyCallback: ssh.InsecureIgnoreHostKey(), + Timeout: 10 * time.Second, } conn, err := ssh.Dial("tcp", d.Address, config) if err != nil { diff --git a/drivers/smb/driver.go b/drivers/smb/driver.go index 88325e5cf..35e220747 100644 --- a/drivers/smb/driver.go +++ b/drivers/smb/driver.go @@ -6,6 +6,7 @@ import ( "path" "path/filepath" "strings" + "sync/atomic" "github.com/OpenListTeam/OpenList/v4/internal/driver" "github.com/OpenListTeam/OpenList/v4/internal/model" @@ -16,7 +17,7 @@ import ( ) type SMB struct { - lastConnTime int64 + lastConnTime atomic.Int64 model.Storage Addition fs *smb2.Share diff --git a/drivers/smb/util.go b/drivers/smb/util.go index 6ae365f8f..60db36b27 100644 --- a/drivers/smb/util.go +++ b/drivers/smb/util.go @@ -6,7 +6,6 @@ import ( "io/fs" "os" "path/filepath" - "sync/atomic" "time" "github.com/OpenListTeam/OpenList/v4/pkg/singleflight" @@ -16,15 +15,15 @@ import ( ) func (d *SMB) updateLastConnTime() { - atomic.StoreInt64(&d.lastConnTime, time.Now().Unix()) + d.lastConnTime.Store(time.Now().Unix()) } func (d *SMB) cleanLastConnTime() { - atomic.StoreInt64(&d.lastConnTime, 0) + d.lastConnTime.Store(0) } func (d *SMB) getLastConnTime() time.Time { - return time.Unix(atomic.LoadInt64(&d.lastConnTime), 0) + return time.Unix(d.lastConnTime.Load(), 0) } func (d *SMB) initFS(ctx context.Context) error { diff --git a/drivers/strm/driver.go b/drivers/strm/driver.go index 4736ff1cf..1189fd9a7 100644 --- a/drivers/strm/driver.go +++ b/drivers/strm/driver.go @@ -219,9 +219,7 @@ func (d *Strm) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (* }, nil } - resultLink := *link - resultLink.SyncClosers = utils.NewSyncClosers(link) - return &resultLink, nil + return link.Clone(), nil } var _ driver.Driver = (*Strm)(nil) diff --git a/drivers/teldrive/driver.go b/drivers/teldrive/driver.go index d420eb4d0..ea0093b7c 100644 --- a/drivers/teldrive/driver.go +++ b/drivers/teldrive/driver.go @@ -68,6 +68,12 @@ func (d *Teldrive) List(ctx context.Context, dir model.Obj, args model.ListArgs) return nil, err } + // An empty directory reports totalPages == 0, which would make + // pagesData a zero-length slice and panic on pagesData[0] below. + if firstResp.Meta.TotalPages < 1 { + return []model.Obj{}, nil + } + pagesData := make([][]Object, firstResp.Meta.TotalPages) pagesData[0] = firstResp.Items diff --git a/drivers/teldrive/driver_test.go b/drivers/teldrive/driver_test.go new file mode 100644 index 000000000..a5e4de9dc --- /dev/null +++ b/drivers/teldrive/driver_test.go @@ -0,0 +1,38 @@ +package teldrive + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/OpenListTeam/OpenList/v4/drivers/base" + "github.com/OpenListTeam/OpenList/v4/internal/model" + "github.com/go-resty/resty/v2" +) + +// An empty directory makes the Teldrive API report totalPages == 0. List used +// to size pagesData by that value and then write pagesData[0], which panicked +// with "index out of range [0] with length 0" on every empty folder. +func TestListEmptyDir(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"items":[],"meta":{"count":0,"totalPages":0,"currentPage":1}}`)) + })) + defer srv.Close() + + oldClient := base.RestyClient + base.RestyClient = resty.New() + defer func() { base.RestyClient = oldClient }() + + d := &Teldrive{} + d.Address = srv.URL + + objs, err := d.List(context.Background(), &model.Object{Path: "/"}, model.ListArgs{}) + if err != nil { + t.Fatalf("List returned error: %v", err) + } + if len(objs) != 0 { + t.Fatalf("expected no entries for an empty dir, got %d", len(objs)) + } +} diff --git a/drivers/thunder/driver.go b/drivers/thunder/driver.go index 492b9814f..df4113c71 100644 --- a/drivers/thunder/driver.go +++ b/drivers/thunder/driver.go @@ -88,6 +88,9 @@ func (x *Thunder) Init(ctx context.Context) (err error) { // 清空 信任密钥 x.Addition.CreditKey = "" } + if token == nil { + return err + } x.SetTokenResp(token) return err }, @@ -521,6 +524,9 @@ func (xc *XunLeiCommon) SetCoreTokenResp(tr *CoreLoginResp) { // 携带Authorization和CaptchaToken的请求 func (xc *XunLeiCommon) Request(url string, method string, callback base.ReqCallback, resp interface{}) ([]byte, error) { + if xc.TokenResp == nil { + return nil, errs.EmptyToken + } data, err := xc.Common.Request(url, method, func(req *resty.Request) { req.SetHeaders(map[string]string{ "Authorization": xc.Token(), diff --git a/drivers/thunder/driver_test.go b/drivers/thunder/driver_test.go new file mode 100644 index 000000000..5cea7f471 --- /dev/null +++ b/drivers/thunder/driver_test.go @@ -0,0 +1,18 @@ +package thunder + +import ( + "errors" + "net/http" + "testing" + + "github.com/OpenListTeam/OpenList/v4/internal/errs" +) + +func TestRequestReturnsErrorWhenTokenIsMissing(t *testing.T) { + xc := &XunLeiCommon{} + + _, err := xc.Request(API_URL+"/about", http.MethodGet, nil, nil) + if !errors.Is(err, errs.EmptyToken) { + t.Fatalf("expected EmptyToken, got %v", err) + } +} diff --git a/drivers/thunder_browser/driver.go b/drivers/thunder_browser/driver.go index 944c9a833..d105f86bb 100644 --- a/drivers/thunder_browser/driver.go +++ b/drivers/thunder_browser/driver.go @@ -16,6 +16,7 @@ import ( "github.com/OpenListTeam/OpenList/v4/drivers/base" "github.com/OpenListTeam/OpenList/v4/internal/driver" + "github.com/OpenListTeam/OpenList/v4/internal/errs" "github.com/OpenListTeam/OpenList/v4/internal/model" "github.com/OpenListTeam/OpenList/v4/internal/op" streamPkg "github.com/OpenListTeam/OpenList/v4/internal/stream" @@ -98,6 +99,9 @@ func (x *ThunderBrowser) Init(ctx context.Context) (err error) { // 清空 信任密钥 x.Addition.CreditKey = "" } + if token == nil { + return err + } x.SetTokenResp(token) return err }, @@ -667,6 +671,9 @@ func (xc *XunLeiBrowserCommon) SetSpaceTokenResp(spaceToken string) { // Request 携带Authorization和CaptchaToken的请求 func (xc *XunLeiBrowserCommon) Request(url string, method string, callback base.ReqCallback, resp interface{}) ([]byte, error) { + if xc.TokenResp == nil { + return nil, errs.EmptyToken + } data, err := xc.Common.Request(url, method, func(req *resty.Request) { req.SetHeaders(map[string]string{ "Authorization": xc.GetToken(), diff --git a/drivers/thunder_browser/driver_test.go b/drivers/thunder_browser/driver_test.go new file mode 100644 index 000000000..edf004927 --- /dev/null +++ b/drivers/thunder_browser/driver_test.go @@ -0,0 +1,18 @@ +package thunder_browser + +import ( + "errors" + "net/http" + "testing" + + "github.com/OpenListTeam/OpenList/v4/internal/errs" +) + +func TestRequestReturnsErrorWhenTokenIsMissing(t *testing.T) { + xc := &XunLeiBrowserCommon{} + + _, err := xc.Request(API_URL+"/about", http.MethodGet, nil, nil) + if !errors.Is(err, errs.EmptyToken) { + t.Fatalf("expected EmptyToken, got %v", err) + } +} diff --git a/drivers/thunder_share/cas.go b/drivers/thunder_share/cas.go new file mode 100644 index 000000000..78bea6e56 --- /dev/null +++ b/drivers/thunder_share/cas.go @@ -0,0 +1,108 @@ +package thunder_share + +import ( + "context" + "errors" + "fmt" + + _189pc "github.com/OpenListTeam/OpenList/v4/drivers/189pc" + "github.com/OpenListTeam/OpenList/v4/internal/casfile" + "github.com/OpenListTeam/OpenList/v4/internal/model" + "github.com/OpenListTeam/OpenList/v4/internal/op" + "github.com/OpenListTeam/OpenList/v4/internal/stream" +) + +var resolveThunderShareCASSourceLink = func(ctx context.Context, d *ThunderShare, file model.Obj, args model.LinkArgs) (*model.Link, error) { + return resolveThunderShareLink(ctx, d, file, args) +} + +var openThunderShareCASStream = func(ctx context.Context, file model.Obj, link *model.Link) (model.FileStreamer, error) { + return stream.NewSeekableStream(&stream.FileStream{Ctx: ctx, Obj: file}, link) +} + +var get189CloudPCCandidates = list189CloudPCCandidates + +var restore189CloudPCCAS = func(ctx context.Context, cloud *_189pc.Cloud189PC, casFileName string, info *casfile.Info) (*model.Link, error) { + return cloud.RestoreCASForPlayback(ctx, casFileName, info) +} + +var readThunderShareCASInfo = func(ctx context.Context, d *ThunderShare, file model.Obj, args model.LinkArgs) (*casfile.Info, error) { + return d.readSharedCASInfo(ctx, file, args) +} + +var restoreThunderShareCASInfo = restoreSharedCASInfo + +var resolveThunderShareCASLink = func(ctx context.Context, d *ThunderShare, file model.Obj, args model.LinkArgs) (*model.Link, error) { + return d.resolveCASPlayback(ctx, file, args) +} + +func list189CloudPCCandidates() []*_189pc.Cloud189PC { + all := make([]*_189pc.Cloud189PC, 0) + for _, storage := range op.GetStorages("189CloudPC") { + if cloud, ok := storage.(*_189pc.Cloud189PC); ok { + all = append(all, cloud) + } + } + preferred, _ := op.GetFirstDriver("189CloudPC", 0).(*_189pc.Cloud189PC) + return distinct189CloudPCCandidates(preferred, all) +} + +func distinct189CloudPCCandidates(preferred *_189pc.Cloud189PC, all []*_189pc.Cloud189PC) []*_189pc.Cloud189PC { + candidates := make([]*_189pc.Cloud189PC, 0, len(all)+1) + seen := make(map[uint]struct{}) + appendCandidate := func(cloud *_189pc.Cloud189PC) { + if cloud == nil { + return + } + if _, ok := seen[cloud.ID]; ok { + return + } + seen[cloud.ID] = struct{}{} + candidates = append(candidates, cloud) + } + appendCandidate(preferred) + for _, cloud := range all { + appendCandidate(cloud) + } + return candidates +} + +func restoreSharedCASInfo(ctx context.Context, casFileName string, info *casfile.Info) (*model.Link, error) { + candidates := get189CloudPCCandidates() + if len(candidates) == 0 { + return nil, errors.New("找不到天翼云盘帐号") + } + failures := make([]error, 0, len(candidates)) + for _, cloud := range candidates { + link, err := restore189CloudPCCAS(ctx, cloud, casFileName, info) + if err == nil { + return link, nil + } + failures = append(failures, fmt.Errorf("189CloudPC[%d]: %w", cloud.ID, err)) + } + return nil, fmt.Errorf("所有天翼云盘帐号均无法还原 CAS: %w", errors.Join(failures...)) +} + +func (d *ThunderShare) resolveCASPlayback(ctx context.Context, file model.Obj, args model.LinkArgs) (*model.Link, error) { + info, err := readThunderShareCASInfo(ctx, d, file, args) + if err != nil { + return nil, err + } + return restoreThunderShareCASInfo(ctx, file.GetName(), info) +} + +func (d *ThunderShare) readSharedCASInfo(ctx context.Context, file model.Obj, args model.LinkArgs) (*casfile.Info, error) { + link, err := resolveThunderShareCASSourceLink(ctx, d, file, args) + if err != nil { + return nil, err + } + if link == nil { + return nil, errors.New("找不到迅雷云盘帐号") + } + casStream, err := openThunderShareCASStream(ctx, file, link) + if err != nil { + return nil, err + } + defer casStream.Close() + return casfile.ParseReader(casStream) +} diff --git a/drivers/thunder_share/cas_test.go b/drivers/thunder_share/cas_test.go new file mode 100644 index 000000000..001fc3830 --- /dev/null +++ b/drivers/thunder_share/cas_test.go @@ -0,0 +1,240 @@ +package thunder_share + +import ( + "bytes" + "context" + "errors" + "fmt" + "reflect" + "strings" + "testing" + + _189pc "github.com/OpenListTeam/OpenList/v4/drivers/189pc" + "github.com/OpenListTeam/OpenList/v4/internal/casfile" + "github.com/OpenListTeam/OpenList/v4/internal/model" + "github.com/OpenListTeam/OpenList/v4/internal/stream" +) + +func TestReadSharedCASInfo_ParsesThunderLinkStream(t *testing.T) { + origLink := resolveThunderShareCASSourceLink + origOpen := openThunderShareCASStream + resolveThunderShareCASSourceLink = func(ctx context.Context, d *ThunderShare, file model.Obj, args model.LinkArgs) (*model.Link, error) { + return &model.Link{URL: "https://example.com/movie.cas"}, nil + } + openThunderShareCASStream = func(ctx context.Context, file model.Obj, link *model.Link) (model.FileStreamer, error) { + return &stream.FileStream{Ctx: ctx, Obj: file, Reader: strings.NewReader(`{"name":"payload.mkv","size":7,"md5":"abc","sliceMd5":"def"}`)}, nil + } + t.Cleanup(func() { + resolveThunderShareCASSourceLink = origLink + openThunderShareCASStream = origOpen + }) + + info, err := (&ThunderShare{}).readSharedCASInfo(context.Background(), &model.Object{ID: "cas-id", Name: "movie.cas", Size: 80}, model.LinkArgs{}) + if err != nil { + t.Fatalf("read shared CAS info: %v", err) + } + if info.Name != "payload.mkv" || info.Size != 7 || info.MD5 != "abc" || info.SliceMD5 != "def" { + t.Fatalf("unexpected info: %#v", info) + } +} + +func TestReadSharedCASInfo_PropagatesParseError(t *testing.T) { + origLink := resolveThunderShareCASSourceLink + origOpen := openThunderShareCASStream + resolveThunderShareCASSourceLink = func(ctx context.Context, d *ThunderShare, file model.Obj, args model.LinkArgs) (*model.Link, error) { + return &model.Link{URL: "https://example.com/bad.cas"}, nil + } + openThunderShareCASStream = func(ctx context.Context, file model.Obj, link *model.Link) (model.FileStreamer, error) { + return &stream.FileStream{Ctx: ctx, Obj: file, Reader: strings.NewReader("not-cas")}, nil + } + t.Cleanup(func() { + resolveThunderShareCASSourceLink = origLink + openThunderShareCASStream = origOpen + }) + + _, err := (&ThunderShare{}).readSharedCASInfo(context.Background(), &model.Object{Name: "bad.cas"}, model.LinkArgs{}) + if err == nil || errors.Is(err, casfile.ErrMetadataTooLarge) { + t.Fatalf("expected malformed payload error, got %v", err) + } +} + +func TestReadSharedCASInfo_ReturnsMissingThunderAccountForNilLink(t *testing.T) { + origLink := resolveThunderShareCASSourceLink + origOpen := openThunderShareCASStream + resolveThunderShareCASSourceLink = func(ctx context.Context, d *ThunderShare, file model.Obj, args model.LinkArgs) (*model.Link, error) { + return nil, nil + } + openCalls := 0 + openThunderShareCASStream = func(ctx context.Context, file model.Obj, link *model.Link) (model.FileStreamer, error) { + openCalls++ + return nil, errors.New("stream must not open") + } + t.Cleanup(func() { + resolveThunderShareCASSourceLink = origLink + openThunderShareCASStream = origOpen + }) + + _, err := (&ThunderShare{}).readSharedCASInfo(context.Background(), &model.Object{Name: "movie.cas"}, model.LinkArgs{}) + if err == nil || err.Error() != "找不到迅雷云盘帐号" { + t.Fatalf("expected missing Thunder account error, got %v", err) + } + if openCalls != 0 { + t.Fatalf("expected no stream open for nil link, got %d", openCalls) + } +} + +func TestReadSharedCASInfo_RejectsOversizedMetadata(t *testing.T) { + origLink := resolveThunderShareCASSourceLink + origOpen := openThunderShareCASStream + resolveThunderShareCASSourceLink = func(ctx context.Context, d *ThunderShare, file model.Obj, args model.LinkArgs) (*model.Link, error) { + return &model.Link{URL: "https://example.com/large.cas"}, nil + } + openThunderShareCASStream = func(ctx context.Context, file model.Obj, link *model.Link) (model.FileStreamer, error) { + return &stream.FileStream{Ctx: ctx, Obj: file, Reader: bytes.NewReader(bytes.Repeat([]byte("x"), casfile.MaxMetadataSize+1))}, nil + } + t.Cleanup(func() { + resolveThunderShareCASSourceLink = origLink + openThunderShareCASStream = origOpen + }) + + _, err := (&ThunderShare{}).readSharedCASInfo(context.Background(), &model.Object{Name: "large.cas"}, model.LinkArgs{}) + if !errors.Is(err, casfile.ErrMetadataTooLarge) { + t.Fatalf("expected ErrMetadataTooLarge, got %v", err) + } +} + +func TestRestoreSharedCASInfo_TriesAccountsUntilSuccess(t *testing.T) { + first := &_189pc.Cloud189PC{Storage: model.Storage{ID: 101}} + second := &_189pc.Cloud189PC{Storage: model.Storage{ID: 202}} + info := &casfile.Info{Name: "payload.mkv", Size: 7, MD5: "abc", SliceMD5: "def"} + origCandidates := get189CloudPCCandidates + origRestore := restore189CloudPCCAS + get189CloudPCCandidates = func() []*_189pc.Cloud189PC { return []*_189pc.Cloud189PC{first, second} } + var calls []uint + restore189CloudPCCAS = func(ctx context.Context, cloud *_189pc.Cloud189PC, name string, got *casfile.Info) (*model.Link, error) { + calls = append(calls, cloud.ID) + if name != "movie.cas" || got != info { + t.Fatalf("unexpected input name=%q info=%#v", name, got) + } + if cloud == first { + return nil, errors.New("first failed") + } + return &model.Link{URL: "https://example.com/189/payload.mkv"}, nil + } + t.Cleanup(func() { + get189CloudPCCandidates = origCandidates + restore189CloudPCCAS = origRestore + }) + + link, err := restoreSharedCASInfo(context.Background(), "movie.cas", info) + if err != nil { + t.Fatalf("restore shared CAS: %v", err) + } + if link.URL != "https://example.com/189/payload.mkv" || !reflect.DeepEqual(calls, []uint{101, 202}) { + t.Fatalf("unexpected link or calls: link=%#v calls=%v", link, calls) + } +} + +func TestDistinct189CloudPCCandidates_PrefersAndDeduplicates(t *testing.T) { + preferred := &_189pc.Cloud189PC{Storage: model.Storage{ID: 202}} + first := &_189pc.Cloud189PC{Storage: model.Storage{ID: 101}} + duplicatePreferred := &_189pc.Cloud189PC{Storage: model.Storage{ID: 202}} + third := &_189pc.Cloud189PC{Storage: model.Storage{ID: 303}} + + got := distinct189CloudPCCandidates(preferred, []*_189pc.Cloud189PC{first, duplicatePreferred, third}) + want := []*_189pc.Cloud189PC{preferred, first, third} + if !reflect.DeepEqual(got, want) { + t.Fatalf("unexpected candidates: got=%v want=%v", got, want) + } +} + +func TestRestoreSharedCASInfo_ReturnsMissingAccountError(t *testing.T) { + origCandidates := get189CloudPCCandidates + get189CloudPCCandidates = func() []*_189pc.Cloud189PC { return nil } + t.Cleanup(func() { get189CloudPCCandidates = origCandidates }) + + _, err := restoreSharedCASInfo(context.Background(), "movie.cas", &casfile.Info{Name: "payload.mkv"}) + if err == nil || err.Error() != "找不到天翼云盘帐号" { + t.Fatalf("expected missing account error, got %v", err) + } +} + +func TestRestoreSharedCASInfo_JoinsAllFailures(t *testing.T) { + firstErr := errors.New("first failed") + secondErr := errors.New("second failed") + first := &_189pc.Cloud189PC{Storage: model.Storage{ID: 101}} + second := &_189pc.Cloud189PC{Storage: model.Storage{ID: 202}} + origCandidates := get189CloudPCCandidates + origRestore := restore189CloudPCCAS + get189CloudPCCandidates = func() []*_189pc.Cloud189PC { return []*_189pc.Cloud189PC{first, second} } + restore189CloudPCCAS = func(ctx context.Context, cloud *_189pc.Cloud189PC, name string, info *casfile.Info) (*model.Link, error) { + if cloud == first { + return nil, firstErr + } + return nil, secondErr + } + t.Cleanup(func() { + get189CloudPCCandidates = origCandidates + restore189CloudPCCAS = origRestore + }) + + _, err := restoreSharedCASInfo(context.Background(), "movie.cas", &casfile.Info{Name: "payload.mkv"}) + if !errors.Is(err, firstErr) || !errors.Is(err, secondErr) { + t.Fatalf("expected both causes, got %v", err) + } + for _, id := range []uint{101, 202} { + if !strings.Contains(err.Error(), fmt.Sprintf("189CloudPC[%d]", id)) { + t.Fatalf("expected account id %d in %q", id, err.Error()) + } + } +} + +func TestResolveCASPlayback_ReadsThenRestores(t *testing.T) { + info := &casfile.Info{Name: "payload.mkv", Size: 7, MD5: "abc", SliceMD5: "def"} + origRead := readThunderShareCASInfo + origRestore := restoreThunderShareCASInfo + readThunderShareCASInfo = func(ctx context.Context, d *ThunderShare, file model.Obj, args model.LinkArgs) (*casfile.Info, error) { + return info, nil + } + restoreThunderShareCASInfo = func(ctx context.Context, name string, got *casfile.Info) (*model.Link, error) { + if name != "movie.cas" || got != info { + t.Fatalf("unexpected restore input name=%q info=%#v", name, got) + } + return &model.Link{URL: "https://example.com/189/payload.mkv"}, nil + } + t.Cleanup(func() { + readThunderShareCASInfo = origRead + restoreThunderShareCASInfo = origRestore + }) + + link, err := (&ThunderShare{}).resolveCASPlayback(context.Background(), &model.Object{Name: "movie.cas"}, model.LinkArgs{}) + if err != nil { + t.Fatalf("resolve CAS playback: %v", err) + } + if link.URL != "https://example.com/189/payload.mkv" { + t.Fatalf("unexpected link: %q", link.URL) + } +} + +func TestResolveCASPlayback_ParseFailureDoesNotRestore(t *testing.T) { + parseErr := errors.New("invalid CAS") + origRead := readThunderShareCASInfo + origRestore := restoreThunderShareCASInfo + readThunderShareCASInfo = func(ctx context.Context, d *ThunderShare, file model.Obj, args model.LinkArgs) (*casfile.Info, error) { + return nil, parseErr + } + restoreCalls := 0 + restoreThunderShareCASInfo = func(ctx context.Context, name string, info *casfile.Info) (*model.Link, error) { + restoreCalls++ + return nil, nil + } + t.Cleanup(func() { + readThunderShareCASInfo = origRead + restoreThunderShareCASInfo = origRestore + }) + + _, err := (&ThunderShare{}).resolveCASPlayback(context.Background(), &model.Object{Name: "bad.cas"}, model.LinkArgs{}) + if !errors.Is(err, parseErr) || restoreCalls != 0 { + t.Fatalf("unexpected error or restore calls: err=%v calls=%d", err, restoreCalls) + } +} diff --git a/drivers/thunder_share/driver.go b/drivers/thunder_share/driver.go index 61ad865ab..8bb502594 100755 --- a/drivers/thunder_share/driver.go +++ b/drivers/thunder_share/driver.go @@ -3,6 +3,7 @@ package thunder_share import ( "context" "errors" + "strings" "time" "github.com/OpenListTeam/OpenList/v4/drivers/thunder_browser" @@ -64,6 +65,10 @@ func (d *ThunderShare) List(ctx context.Context, dir model.Obj, args model.ListA } func (d *ThunderShare) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (*model.Link, error) { + if strings.HasSuffix(strings.ToLower(file.GetName()), ".cas") { + return resolveThunderShareCASLink(ctx, d, file, args) + } + key := file.GetID() if link, ok := thunderShareLinkCache.Get(key); ok { return link, nil diff --git a/drivers/thunder_share/driver_test.go b/drivers/thunder_share/driver_test.go index 439413ccb..51f0232e7 100644 --- a/drivers/thunder_share/driver_test.go +++ b/drivers/thunder_share/driver_test.go @@ -3,6 +3,7 @@ package thunder_share import ( "context" "errors" + "fmt" "testing" "time" @@ -79,3 +80,43 @@ func TestThunderShareLink_DoesNotCacheErrors(t *testing.T) { t.Fatalf("expected resolver twice after errors, got %d", resolveCalls) } } + +func TestThunderShareLink_CASBypassesOrdinaryLinkCache(t *testing.T) { + origCache := thunderShareLinkCache + origCASResolver := resolveThunderShareCASLink + origResolver := resolveThunderShareLink + thunderShareLinkCache = cache.NewKeyedCache[*model.Link](time.Hour) + thunderShareLinkCache.Set("file-1", &model.Link{URL: "https://example.com/stale.cas"}) + casCalls := 0 + resolveThunderShareCASLink = func(ctx context.Context, d *ThunderShare, file model.Obj, args model.LinkArgs) (*model.Link, error) { + casCalls++ + return &model.Link{URL: fmt.Sprintf("https://example.com/restored/%d", casCalls)}, nil + } + ordinaryCalls := 0 + resolveThunderShareLink = func(ctx context.Context, d *ThunderShare, file model.Obj, args model.LinkArgs) (*model.Link, error) { + ordinaryCalls++ + return nil, errors.New("ordinary resolver must not run") + } + t.Cleanup(func() { + thunderShareLinkCache = origCache + resolveThunderShareCASLink = origCASResolver + resolveThunderShareLink = origResolver + }) + + d := &ThunderShare{} + file := &model.Object{ID: "file-1", Name: "movie.CAS"} + first, err := d.Link(context.Background(), file, model.LinkArgs{}) + if err != nil { + t.Fatalf("first CAS link: %v", err) + } + second, err := d.Link(context.Background(), file, model.LinkArgs{}) + if err != nil { + t.Fatalf("second CAS link: %v", err) + } + if first.URL != "https://example.com/restored/1" || second.URL != "https://example.com/restored/2" { + t.Fatalf("expected uncached links, got %q and %q", first.URL, second.URL) + } + if casCalls != 2 || ordinaryCalls != 0 { + t.Fatalf("unexpected calls cas=%d ordinary=%d", casCalls, ordinaryCalls) + } +} diff --git a/drivers/thunderx/driver.go b/drivers/thunderx/driver.go index acbb82513..457a9c536 100644 --- a/drivers/thunderx/driver.go +++ b/drivers/thunderx/driver.go @@ -69,13 +69,16 @@ func (x *ThunderX) Init(ctx context.Context) (err error) { token, err = x.Login(x.Username, x.Password) if err != nil { x.GetStorage().SetStatus(fmt.Sprintf("%+v", err.Error())) - if token.UserID != "" { + if token != nil && token.UserID != "" { x.SetUserID(token.UserID) x.UserAgent = BuildCustomUserAgent(utils.GetMD5EncodeStr(x.Username+x.Password), ClientID, PackageName, SdkVersion, ClientVersion, PackageName, token.UserID) } op.MustSaveDriverStorage(x) } } + if token == nil { + return err + } x.SetTokenResp(token) return err }, @@ -496,6 +499,9 @@ func (xc *XunLeiXCommon) SetTokenResp(tr *TokenResp) { // Request 携带Authorization和CaptchaToken的请求 func (xc *XunLeiXCommon) Request(url string, method string, callback base.ReqCallback, resp interface{}) ([]byte, error) { + if xc.TokenResp == nil { + return nil, errs.EmptyToken + } data, err := xc.Common.Request(url, method, func(req *resty.Request) { req.SetHeaders(map[string]string{ "Authorization": xc.Token(), diff --git a/drivers/thunderx/driver_test.go b/drivers/thunderx/driver_test.go new file mode 100644 index 000000000..d3f9ef9c2 --- /dev/null +++ b/drivers/thunderx/driver_test.go @@ -0,0 +1,18 @@ +package thunderx + +import ( + "errors" + "net/http" + "testing" + + "github.com/OpenListTeam/OpenList/v4/internal/errs" +) + +func TestRequestReturnsErrorWhenTokenIsMissing(t *testing.T) { + xc := &XunLeiXCommon{} + + _, err := xc.Request(API_URL+"/about", http.MethodGet, nil, nil) + if !errors.Is(err, errs.EmptyToken) { + t.Fatalf("expected EmptyToken, got %v", err) + } +} diff --git a/drivers/url_tree/meta.go b/drivers/url_tree/meta.go index 9d2f182ba..e2d0e4fe9 100644 --- a/drivers/url_tree/meta.go +++ b/drivers/url_tree/meta.go @@ -6,7 +6,7 @@ import ( ) type Addition struct { - UrlStructure string `json:"url_structure" type:"text" required:"true" default:"https://raw.githubusercontent.com/OpenListTeam/OpenList/main/README.md\nhttps://raw.githubusercontent.com/OpenListTeam/OpenList/main/README_cn.md\nfolder:\n CONTRIBUTING.md:1635:https://raw.githubusercontent.com/OpenListTeam/OpenList/main/CONTRIBUTING.md\n CODE_OF_CONDUCT.md:2093:https://raw.githubusercontent.com/OpenListTeam/OpenList/main/CODE_OF_CONDUCT.md" help:"structure:FolderName:\n [FileName:][FileSize:][Modified:]Url"` + UrlStructure string `json:"url_structure" type:"text" required:"true" default:"https://raw.githubusercontent.com/OpenListTeam/OpenList/main/README.md\nhttps://raw.githubusercontent.com/OpenListTeam/OpenList/main/README/README_cn.md\nfolder:\n CONTRIBUTING.md:1635:https://raw.githubusercontent.com/OpenListTeam/OpenList/main/CONTRIBUTING.md\n CODE_OF_CONDUCT.md:2093:https://raw.githubusercontent.com/OpenListTeam/OpenList/main/CODE_OF_CONDUCT.md" help:"structure:FolderName:\n [FileName:][FileSize:][Modified:]Url"` HeadSize bool `json:"head_size" type:"bool" default:"false" help:"Use head method to get file size, but it may be failed."` Writable bool `json:"writable" type:"bool" default:"false"` } diff --git a/drivers/webdav/driver.go b/drivers/webdav/driver.go index 61e4a6160..778d810b4 100644 --- a/drivers/webdav/driver.go +++ b/drivers/webdav/driver.go @@ -10,6 +10,7 @@ import ( "github.com/OpenListTeam/OpenList/v4/drivers/base" "github.com/OpenListTeam/OpenList/v4/internal/driver" + "github.com/OpenListTeam/OpenList/v4/internal/errs" "github.com/OpenListTeam/OpenList/v4/internal/model" "github.com/OpenListTeam/OpenList/v4/pkg/cron" "github.com/OpenListTeam/OpenList/v4/pkg/gowebdav" @@ -130,6 +131,9 @@ func (d *WebDav) Get(ctx context.Context, _path string) (model.Obj, error) { _path = path.Join(d.GetRootPath(), _path) info, err := d.client.Stat(_path) if err != nil { + if gowebdav.IsErrNotFound(err) { + return nil, errs.ObjectNotFound + } return nil, err } diff --git a/drivers/webdav/driver_test.go b/drivers/webdav/driver_test.go new file mode 100644 index 000000000..cccd16d5e --- /dev/null +++ b/drivers/webdav/driver_test.go @@ -0,0 +1,89 @@ +package webdav + +import ( + "context" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + + "github.com/OpenListTeam/OpenList/v4/internal/driver" + "github.com/OpenListTeam/OpenList/v4/internal/errs" + "github.com/OpenListTeam/OpenList/v4/internal/op" +) + +func TestGetMapsMissingPathToObjectNotFound(t *testing.T) { + d, cleanup := newTestDriver(t, nil) + defer cleanup() + + _, err := d.Get(context.Background(), "/missing") + if !errs.IsObjectNotFound(err) { + t.Fatalf("expected object not found, got %v", err) + } +} + +func TestMakeDirAfterMissingWebDAVStat(t *testing.T) { + var mkcolCount atomic.Int32 + d, cleanup := newTestDriver(t, func(w http.ResponseWriter, r *http.Request) bool { + if r.Method == "MKCOL" && (r.URL.Path == "/new" || r.URL.Path == "/new/") { + mkcolCount.Add(1) + w.WriteHeader(http.StatusCreated) + return true + } + return false + }) + defer cleanup() + + if err := op.MakeDir(context.Background(), d, "/new"); err != nil { + t.Fatalf("MakeDir failed: %v", err) + } + if got := mkcolCount.Load(); got != 1 { + t.Fatalf("expected one MKCOL request, got %d", got) + } +} + +func newTestDriver(t *testing.T, extra func(http.ResponseWriter, *http.Request) bool) (*WebDav, func()) { + t.Helper() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if extra != nil && extra(w, r) { + return + } + switch r.Method { + case "PROPFIND": + if r.URL.Path == "/" { + w.Header().Set("Content-Type", "application/xml; charset=utf-8") + w.WriteHeader(http.StatusMultiStatus) + _, _ = w.Write([]byte(` + + + / + + + / + + + HTTP/1.1 200 OK + + +`)) + return + } + http.NotFound(w, r) + case "MKCOL": + w.WriteHeader(http.StatusConflict) + default: + w.WriteHeader(http.StatusMethodNotAllowed) + } + })) + + d := &WebDav{Addition: Addition{Address: srv.URL, RootPath: driver.RootPath{RootFolderPath: "/"}}} + if err := d.Init(context.Background()); err != nil { + srv.Close() + t.Fatalf("init driver: %v", err) + } + return d, func() { + _ = d.Drop(context.Background()) + srv.Close() + } +} diff --git a/drivers/wps/driver.go b/drivers/wps/driver.go index 847425b11..8d76e1069 100644 --- a/drivers/wps/driver.go +++ b/drivers/wps/driver.go @@ -38,25 +38,20 @@ func (d *Wps) Init(ctx context.Context) error { d.client = base.NewRestyClient() - resp, err := d.request(ctx).SetResult(&d.login).Get("https://account.kdocs.cn/api/v3/islogin") + d.login = &loginState{} + resp, err := d.request(ctx).SetResult(d.login).Get("https://account.kdocs.cn/api/v3/islogin") if err != nil { return err } if !resp.IsSuccess() { return fmt.Errorf("failed to check login status, status code: %d, body: %s", resp.StatusCode(), resp.String()) } - return nil } func (d *Wps) Drop(ctx context.Context) error { - - if d.client != nil { - d.client = nil - } - if d.login != nil { - d.login = nil - } + d.client = nil + d.login = nil return nil } diff --git a/drivers/wps/meta.go b/drivers/wps/meta.go index a520bb433..1befb7cd0 100644 --- a/drivers/wps/meta.go +++ b/drivers/wps/meta.go @@ -16,7 +16,7 @@ var config = driver.Config{ Name: "WPS", LocalSort: true, DefaultRoot: "/", - Alert: "", + CheckStatus: true, } func init() { diff --git a/go.mod b/go.mod index ec165186c..ed7bd092d 100644 --- a/go.mod +++ b/go.mod @@ -2,6 +2,8 @@ module github.com/OpenListTeam/OpenList/v4 go 1.25.0 +toolchain go1.26.4 + require ( github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.1 github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.2 @@ -15,29 +17,29 @@ require ( github.com/ProtonMail/go-crypto v1.3.0 github.com/ProtonMail/gopenpgp/v2 v2.9.0 github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible - github.com/antchfx/htmlquery v1.3.5 - github.com/antchfx/xpath v1.3.5 + github.com/antchfx/htmlquery v1.3.6 + github.com/antchfx/xpath v1.3.6 github.com/avast/retry-go v3.0.0+incompatible - github.com/aws/aws-sdk-go v1.55.7 + github.com/aws/aws-sdk-go v1.55.8 github.com/blevesearch/bleve/v2 v2.6.0 github.com/bmatcuk/doublestar/v4 v4.9.1 github.com/caarlos0/env/v9 v9.0.0 - github.com/charmbracelet/bubbles v0.21.0 - github.com/charmbracelet/bubbletea v1.3.6 + github.com/charmbracelet/bubbles v0.21.1 + github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 github.com/city404/v6-public-rpc-proto/go v0.0.0-20240817070657-90f8e24b653e - github.com/cloudsoda/go-smb2 v0.0.0-20250228001242-d4c70e6251cc + github.com/cloudsoda/go-smb2 v0.0.0-20260701064823-d8c5600d73b8 github.com/coreos/go-oidc v2.3.0+incompatible github.com/deckarep/golang-set/v2 v2.8.0 github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 github.com/disintegration/imaging v1.6.2 - github.com/dlclark/regexp2 v1.11.5 + github.com/dlclark/regexp2 v1.12.0 github.com/dustinxie/ecc v0.0.0-20210511000915-959544187564 github.com/fclairamb/ftpserverlib v0.26.1-0.20250709223522-4a925d79caf6 github.com/foxxorcat/mopan-sdk-go v0.1.6 github.com/foxxorcat/weiyun-sdk-go v0.1.4 - github.com/gin-contrib/cors v1.7.6 - github.com/gin-gonic/gin v1.10.1 + github.com/gin-contrib/cors v1.7.7 + github.com/gin-gonic/gin v1.12.0 github.com/glebarez/go-sqlite v1.21.2 github.com/glebarez/sqlite v1.11.0 github.com/go-resty/resty/v2 v2.16.5 @@ -53,39 +55,39 @@ require ( github.com/jlaffaye/ftp v0.2.1-0.20240918233326-1b970516f5d3 github.com/json-iterator/go v1.1.12 github.com/kdomanski/iso9660 v0.4.0 - github.com/maruel/natural v1.1.1 + github.com/maruel/natural v1.3.0 github.com/meilisearch/meilisearch-go v0.32.0 - github.com/mholt/archives v0.1.3 + github.com/mholt/archives v0.1.5 github.com/natefinch/lumberjack v2.0.0+incompatible - github.com/ncw/swift/v2 v2.0.4 + github.com/ncw/swift/v2 v2.0.5 github.com/pkg/errors v0.9.1 - github.com/pkg/sftp v1.13.9 + github.com/pkg/sftp v1.13.10 github.com/power721/115driver v0.2.6 github.com/pquerna/otp v1.5.0 - github.com/quic-go/quic-go v0.54.1 + github.com/quic-go/quic-go v0.60.0 github.com/rclone/rclone v1.70.3 github.com/shirou/gopsutil/v4 v4.25.5 - github.com/sirupsen/logrus v1.9.3 - github.com/spf13/afero v1.14.0 + github.com/sirupsen/logrus v1.9.4 + github.com/spf13/afero v1.15.0 github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.11.1 - github.com/t3rm1n4l/go-mega v0.0.0-20241213151442-a19cff0ec7b5 + github.com/t3rm1n4l/go-mega v0.0.0-20260630094356-4de2f127f34a github.com/tchap/go-patricia/v2 v2.3.3 github.com/u2takey/ffmpeg-go v0.5.0 github.com/upyun/go-sdk/v3 v3.0.4 github.com/winfsp/cgofuse v1.6.0 github.com/zzzhr1990/go-common-entity v0.0.0-20250202070650-1a200048f0d3 - golang.org/x/crypto v0.48.0 + golang.org/x/crypto v0.51.0 golang.org/x/image v0.29.0 - golang.org/x/net v0.51.0 - golang.org/x/oauth2 v0.34.0 + golang.org/x/net v0.55.0 + golang.org/x/oauth2 v0.36.0 golang.org/x/time v0.14.0 google.golang.org/appengine v1.6.8 gopkg.in/ldap.v3 v3.1.0 gorm.io/driver/mysql v1.5.7 - gorm.io/driver/postgres v1.5.9 - gorm.io/driver/sqlite v1.5.6 - gorm.io/gorm v1.25.11 + gorm.io/driver/postgres v1.6.0 + gorm.io/driver/sqlite v1.6.0 + gorm.io/gorm v1.30.0 ) require ( @@ -100,8 +102,12 @@ require ( github.com/andybalholm/cascadia v1.3.3 // indirect github.com/blevesearch/zapx/v17 v17.1.2 // indirect github.com/bradenaw/juniper v0.15.3 // indirect - github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect - github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/bytedance/gopkg v0.1.3 // indirect + github.com/charmbracelet/colorprofile v0.4.1 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/clipperhouse/displaywidth v0.9.0 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.5.0 // indirect github.com/cloudsoda/sddl v0.0.0-20250224235906-926454e91efc // indirect github.com/coreos/go-systemd/v22 v22.5.0 // indirect github.com/cronokirby/saferith v0.33.0 // indirect @@ -110,6 +116,7 @@ require ( github.com/emersion/go-message v0.18.2 // indirect github.com/emersion/go-vcard v0.0.0-20241024213814-c9703dde27ff // indirect github.com/geoffgarside/ber v1.2.0 // indirect + github.com/goccy/go-yaml v1.19.2 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/hashicorp/go-uuid v1.0.3 // indirect github.com/jcmturner/aescts/v2 v2.0.0 // indirect @@ -120,16 +127,16 @@ require ( github.com/jcmturner/rpc/v2 v2.0.3 // indirect github.com/lanrat/extsort v1.0.2 // indirect github.com/mikelolasagasti/xz v1.0.1 // indirect - github.com/minio/minlz v1.0.0 // indirect + github.com/minio/minlz v1.0.1 // indirect github.com/minio/xxml v0.0.3 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect - github.com/quic-go/qpack v0.5.1 // indirect + github.com/quic-go/qpack v0.6.0 // indirect github.com/relvacode/iso8601 v1.6.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/stangelandcl/ppmd v0.1.1 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - go.uber.org/mock v0.5.0 // indirect + go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect golang.org/x/exp v0.0.0-20250606033433-dcc06ee1d476 // indirect - golang.org/x/mod v0.33.0 // indirect gopkg.in/go-jose/go-jose.v2 v2.6.3 // indirect modernc.org/libc v1.22.5 // indirect modernc.org/mathutil v1.5.0 // indirect @@ -138,19 +145,19 @@ require ( ) require ( - github.com/OpenListTeam/115-sdk-go v0.2.2 - github.com/STARRY-S/zip v0.2.1 // indirect + github.com/OpenListTeam/115-sdk-go v0.2.4 + github.com/STARRY-S/zip v0.2.3 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/blevesearch/go-faiss v1.1.0 // indirect github.com/blevesearch/zapx/v16 v16.3.4 // indirect github.com/bodgit/plumbing v1.3.0 // indirect - github.com/bodgit/sevenzip v1.6.1 + github.com/bodgit/sevenzip v1.6.4 github.com/bodgit/windows v1.0.1 // indirect - github.com/bytedance/sonic/loader v0.2.4 // indirect - github.com/charmbracelet/x/ansi v0.9.3 // indirect - github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/bytedance/sonic/loader v0.5.0 // indirect + github.com/charmbracelet/x/ansi v0.11.5 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect github.com/cloudflare/circl v1.6.1 // indirect - github.com/cloudwego/base64x v0.1.5 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/fclairamb/go-log v0.6.0 // indirect @@ -159,14 +166,14 @@ require ( github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/hekmon/cunits/v2 v2.1.0 // indirect github.com/ipfs/boxo v0.12.0 // indirect - github.com/jackc/puddle/v2 v2.2.1 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/klauspost/pgzip v1.2.6 // indirect github.com/matoous/go-nanoid/v2 v2.1.0 // indirect github.com/microcosm-cc/bluemonday v1.0.27 - github.com/nwaples/rardecode/v2 v2.1.1 - github.com/sorairolake/lzip-go v0.3.5 // indirect + github.com/nwaples/rardecode/v2 v2.2.0 + github.com/sorairolake/lzip-go v0.3.8 // indirect github.com/taruti/bytepool v0.0.0-20160310082835-5e3a9ea56543 // indirect - github.com/ulikunitz/xz v0.5.12 // indirect + github.com/ulikunitz/xz v0.5.15 // indirect go4.org v0.0.0-20260112195520-a5071408f32f resty.dev/v3 v3.0.0-beta.2 // indirect ) @@ -177,12 +184,12 @@ require ( github.com/abbot/go-http-auth v0.4.0 // indirect github.com/aead/ecdh v0.2.0 // indirect github.com/andreburgaud/crypt2go v1.8.0 // indirect - github.com/andybalholm/brotli v1.1.2-0.20250424173009-453214e765f3 // indirect + github.com/andybalholm/brotli v1.2.1 // indirect github.com/axgle/mahonia v0.0.0-20180208002826-3358181d7394 github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/benbjohnson/clock v1.3.0 // indirect github.com/beorn7/perks v1.0.1 // indirect - github.com/bits-and-blooms/bitset v1.24.2 // indirect + github.com/bits-and-blooms/bitset v1.24.4 // indirect github.com/blang/semver/v4 v4.0.0 // indirect github.com/blevesearch/bleve_index_api v1.3.11 // indirect github.com/blevesearch/geo v0.2.5 // indirect @@ -200,20 +207,20 @@ require ( github.com/blevesearch/zapx/v14 v14.4.3 // indirect github.com/blevesearch/zapx/v15 v15.4.3 // indirect github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect - github.com/bytedance/sonic v1.13.3 // indirect + github.com/bytedance/sonic v1.15.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/coreos/go-semver v0.3.1 // indirect github.com/crackcomm/go-gitignore v0.0.0-20170627025303-887ab5e44cc3 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.1.0 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect - github.com/gabriel-vasile/mimetype v1.4.9 // indirect + github.com/gabriel-vasile/mimetype v1.4.12 // indirect github.com/gin-contrib/sse v1.1.0 // indirect github.com/go-chi/chi/v5 v5.2.2 // indirect github.com/go-ole/go-ole v1.3.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect - github.com/go-playground/validator/v10 v10.26.0 // indirect + github.com/go-playground/validator/v10 v10.30.1 // indirect github.com/go-sql-driver/mysql v1.7.0 // indirect github.com/go-webauthn/x v0.1.23 // indirect github.com/goccy/go-json v0.10.5 // indirect @@ -228,27 +235,27 @@ require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/ipfs/go-cid v0.5.0 github.com/jackc/pgpassfile v1.0.0 // indirect - github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect - github.com/jackc/pgx/v5 v5.5.5 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.6.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/jmespath/go-jmespath v0.4.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/jzelinskie/whirlpool v0.0.0-20201016144138-0675e54bb004 // indirect - github.com/klauspost/compress v1.18.0 // indirect - github.com/klauspost/cpuid/v2 v2.2.10 // indirect + github.com/klauspost/compress v1.18.6 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/kr/fs v0.1.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/libp2p/go-buffer-pool v0.1.0 // indirect github.com/libp2p/go-flow-metrics v0.1.0 // indirect github.com/libp2p/go-libp2p v0.27.8 // indirect - github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 // indirect github.com/mailru/easyjson v0.9.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect - github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect github.com/mattn/go-sqlite3 v1.14.22 // indirect github.com/minio/sha256-simd v1.0.1 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect @@ -269,7 +276,7 @@ require ( github.com/multiformats/go-multistream v0.4.1 // indirect github.com/multiformats/go-varint v0.0.7 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect - github.com/pierrec/lz4/v4 v4.1.22 // indirect + github.com/pierrec/lz4/v4 v4.1.26 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/pquerna/cachecontrol v0.1.0 // indirect @@ -288,18 +295,18 @@ require ( github.com/tklauser/numcpus v0.10.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/u2takey/go-utils v0.3.1 // indirect - github.com/ugorji/go/codec v1.3.0 // indirect + github.com/ugorji/go/codec v1.3.1 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect go.etcd.io/bbolt v1.4.0 // indirect - golang.org/x/arch v0.18.0 // indirect + golang.org/x/arch v0.23.0 // indirect golang.org/x/sync v0.20.0 - golang.org/x/sys v0.42.0 - golang.org/x/term v0.40.0 // indirect - golang.org/x/text v0.35.0 - golang.org/x/tools v0.42.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect - google.golang.org/grpc v1.78.0 + golang.org/x/sys v0.45.0 + golang.org/x/term v0.43.0 // indirect + golang.org/x/text v0.37.0 + golang.org/x/tools v0.44.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect + google.golang.org/grpc v1.82.0 google.golang.org/protobuf v1.36.11 // indirect gopkg.in/asn1-ber.v1 v1.0.0-20181015200546-f715ec2f112d // indirect gopkg.in/natefinch/lumberjack.v2 v2.0.0 // indirect @@ -313,4 +320,4 @@ replace github.com/cronokirby/saferith => github.com/Da3zKi7/saferith v0.33.0-fi // replace github.com/OpenListTeam/115-sdk-go => ../../OpenListTeam/115-sdk-go -replace github.com/KarpelesLab/reflink => github.com/OpenListTeam/reflink v0.0.0-20260520031008-ed3c0dbe8009 +replace github.com/KarpelesLab/reflink => github.com/OpenListTeam/reflink v0.0.0-20260701021214-78760eaeafef diff --git a/go.sum b/go.sum index 6c5b91336..02c1ae6a9 100644 --- a/go.sum +++ b/go.sum @@ -27,14 +27,14 @@ github.com/Masterminds/semver/v3 v3.2.0 h1:3MEsd0SM6jqZojhjLWWeBY+Kcjy9i6MQAeY7Y github.com/Masterminds/semver/v3 v3.2.0/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= github.com/Max-Sum/base32768 v0.0.0-20230304063302-18e6ce5945fd h1:nzE1YQBdx1bq9IlZinHa+HVffy+NmVRoKr+wHN8fpLE= github.com/Max-Sum/base32768 v0.0.0-20230304063302-18e6ce5945fd/go.mod h1:C8yoIfvESpM3GD07OCHU7fqI7lhwyZ2Td1rbNbTAhnc= -github.com/OpenListTeam/115-sdk-go v0.2.2 h1:JCrGHqQjBX3laOA6Hw4CuBovSg7g+FC5s0LEAYsRciU= -github.com/OpenListTeam/115-sdk-go v0.2.2/go.mod h1:cfvitk2lwe6036iNi2h+iNxwxWDifKZsSvNtrur5BqU= +github.com/OpenListTeam/115-sdk-go v0.2.4 h1:dVoEz3Pm996n/ZCuRckQvz36w938uNHjPrS7E/SVpBM= +github.com/OpenListTeam/115-sdk-go v0.2.4/go.mod h1:cfvitk2lwe6036iNi2h+iNxwxWDifKZsSvNtrur5BqU= github.com/OpenListTeam/go-cache v0.1.0 h1:eV2+FCP+rt+E4OCJqLUW7wGccWZNJMV0NNkh+uChbAI= github.com/OpenListTeam/go-cache v0.1.0/go.mod h1:AHWjKhNK3LE4rorVdKyEALDHoeMnP8SjiNyfVlB+Pz4= github.com/OpenListTeam/gsync v0.1.0 h1:ywzGybOvA3lW8K1BUjKZ2IUlT2FSlzPO4DOazfYXjcs= github.com/OpenListTeam/gsync v0.1.0/go.mod h1:h/Rvv9aX/6CdW/7B8di3xK3xNV8dUg45Fehrd/ksZ9s= -github.com/OpenListTeam/reflink v0.0.0-20260520031008-ed3c0dbe8009 h1:qLqJPr/FAsZTJiqy65JKKuFJP0V9pRVtSaIE0kqaQ8w= -github.com/OpenListTeam/reflink v0.0.0-20260520031008-ed3c0dbe8009/go.mod h1:WGkTOKNjd1FsJKBw3mu4JvrPEDJyJJ+JPtxBkbPoCok= +github.com/OpenListTeam/reflink v0.0.0-20260701021214-78760eaeafef h1:67uGHancMF/abMrnkc8abVUWQiG73Wk5d8CKt3RzkFo= +github.com/OpenListTeam/reflink v0.0.0-20260701021214-78760eaeafef/go.mod h1:WGkTOKNjd1FsJKBw3mu4JvrPEDJyJJ+JPtxBkbPoCok= github.com/OpenListTeam/sftpd-openlist v1.0.1 h1:j4S3iPFOpnXCUKRPS7uCT4mF2VCl34GyqvH6lqwnkUU= github.com/OpenListTeam/sftpd-openlist v1.0.1/go.mod h1:uO/wKnbvbdq3rBLmClMTZXuCnw7XW4wlAq4dZe91a40= github.com/OpenListTeam/tache v0.2.2 h1:CWFn6sr1AIYaEjC8ONdKs+LrxHyuErheenAjEqRhh4k= @@ -61,8 +61,8 @@ github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiU github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y= github.com/RoaringBitmap/roaring/v2 v2.14.5 h1:ckd0o545JqDPeVJDgeFoaM21eBixUnlWfYgjE5VnyWw= github.com/RoaringBitmap/roaring/v2 v2.14.5/go.mod h1:eq4wdNXxtJIS/oikeCzdX1rBzek7ANzbth041hrU8Q4= -github.com/STARRY-S/zip v0.2.1 h1:pWBd4tuSGm3wtpoqRZZ2EAwOmcHK6XFf7bU9qcJXyFg= -github.com/STARRY-S/zip v0.2.1/go.mod h1:xNvshLODWtC4EJ702g7cTYn13G53o1+X9BWnPFpcWV4= +github.com/STARRY-S/zip v0.2.3 h1:luE4dMvRPDOWQdeDdUxUoZkzUIpTccdKdhHHsQJ1fm4= +github.com/STARRY-S/zip v0.2.3/go.mod h1:lqJ9JdeRipyOQJrYSOtpNAiaesFO6zVDsE8GIGFaoSk= github.com/abbot/go-http-auth v0.4.0 h1:QjmvZ5gSC7jm3Zg54DqWE/T5m1t2AfDu6QlXJT0EVT0= github.com/abbot/go-http-auth v0.4.0/go.mod h1:Cz6ARTIzApMJDzh5bRMSUou6UMSp0IEXg9km/ci7TJM= github.com/aead/ecdh v0.2.0 h1:pYop54xVaq/CEREFEcukHRZfTdjiWvYIsZDXXrBapQQ= @@ -72,19 +72,19 @@ github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible/go.mod h1:T/Aws4fEfogEE9 github.com/andreburgaud/crypt2go v1.8.0 h1:J73vGTb1P6XL69SSuumbKs0DWn3ulbl9L92ZXBjw6pc= github.com/andreburgaud/crypt2go v1.8.0/go.mod h1:L5nfShQ91W78hOWhUH2tlGRPO+POAPJAF5fKOLB9SXg= github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA= -github.com/andybalholm/brotli v1.1.2-0.20250424173009-453214e765f3 h1:8PmGpDEZl9yDpcdEr6Odf23feCxK3LNUNMxjXg41pZQ= -github.com/andybalholm/brotli v1.1.2-0.20250424173009-453214e765f3/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA= +github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro= +github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= -github.com/antchfx/htmlquery v1.3.5 h1:aYthDDClnG2a2xePf6tys/UyyM/kRcsFRm+ifhFKoU0= -github.com/antchfx/htmlquery v1.3.5/go.mod h1:5oyIPIa3ovYGtLqMPNjBF2Uf25NPCKsMjCnQ8lvjaoA= -github.com/antchfx/xpath v1.3.5 h1:PqbXLC3TkfeZyakF5eeh3NTWEbYl4VHNVeufANzDbKQ= -github.com/antchfx/xpath v1.3.5/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs= +github.com/antchfx/htmlquery v1.3.6 h1:RNHHL7YehO5XdO8IM8CynwLKONwRHWkrghbYhQIk9ag= +github.com/antchfx/htmlquery v1.3.6/go.mod h1:kcVUqancxPygm26X2rceEcagZFFVkLEE7xgLkGSDl/4= +github.com/antchfx/xpath v1.3.6 h1:s0y+ElRRtTQdfHP609qFu0+c6bglDv20pqOViQjjdPI= +github.com/antchfx/xpath v1.3.6/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs= github.com/avast/retry-go v3.0.0+incompatible h1:4SOWQ7Qs+oroOTQOYnAHqelpCO0biHSxpiH9JdtuBj0= github.com/avast/retry-go v3.0.0+incompatible/go.mod h1:XtSnn+n/sHqQIpZ10K1qAevBhOOCWBLXXy3hyiqqBrY= github.com/aws/aws-sdk-go v1.38.20/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro= -github.com/aws/aws-sdk-go v1.55.7 h1:UJrkFq7es5CShfBwlWAC8DA077vp8PyVbQd3lqLiztE= -github.com/aws/aws-sdk-go v1.55.7/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU= +github.com/aws/aws-sdk-go v1.55.8 h1:JRmEUbU52aJQZ2AjX4q4Wu7t4uZjOu71uyNmaWlUkJQ= +github.com/aws/aws-sdk-go v1.55.8/go.mod h1:ZkViS9AqA6otK+JBBNH2++sx1sgxrPKcSzPPvQkUtXk= github.com/aws/aws-sdk-go-v2 v1.36.3 h1:mJoei2CxPutQVxaATCzDUjcZEjVRdpsiiXi2o38yqWM= github.com/aws/aws-sdk-go-v2 v1.36.3/go.mod h1:LLXuLpgzEbD766Z5ECcRmi8AzSwfZItDtmABVkRLGzg= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10 h1:zAybnyUQXIZ5mok5Jqwlf58/TFE7uvd3IAsa1aF9cXs= @@ -115,16 +115,16 @@ github.com/axgle/mahonia v0.0.0-20180208002826-3358181d7394 h1:OYA+5W64v3OgClL+I github.com/axgle/mahonia v0.0.0-20180208002826-3358181d7394/go.mod h1:Q8n74mJTIgjX4RBBcHnJ05h//6/k6foqmgE45jTQtxg= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= -github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= -github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= +github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= +github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/benbjohnson/clock v1.3.0 h1:ip6w0uFQkncKQ979AypyG0ER7mqUSBdKLOgAle/AT8A= github.com/benbjohnson/clock v1.3.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= -github.com/bits-and-blooms/bitset v1.24.2 h1:M7/NzVbsytmtfHbumG+K2bremQPMJuqv1JD3vOaFxp0= -github.com/bits-and-blooms/bitset v1.24.2/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= +github.com/bits-and-blooms/bitset v1.24.4 h1:95H15Og1clikBrKr/DuzMXkQzECs1M6hhoGXLwLQOZE= +github.com/bits-and-blooms/bitset v1.24.4/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= github.com/blevesearch/bleve/v2 v2.6.0 h1:Cyd3dd4q5tCbOV8MnKUVRUDYMHOir9xn12NZzXVSEd4= @@ -169,8 +169,8 @@ github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/ github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/bodgit/plumbing v1.3.0 h1:pf9Itz1JOQgn7vEOE7v7nlEfBykYqvUYioC61TwWCFU= github.com/bodgit/plumbing v1.3.0/go.mod h1:JOTb4XiRu5xfnmdnDJo6GmSbSbtSyufrsyZFByMtKEs= -github.com/bodgit/sevenzip v1.6.1 h1:kikg2pUMYC9ljU7W9SaqHXhym5HyKm8/M/jd31fYan4= -github.com/bodgit/sevenzip v1.6.1/go.mod h1:GVoYQbEVbOGT8n2pfqCIMRUaRjQ8F9oSqoBEqZh5fQ8= +github.com/bodgit/sevenzip v1.6.4 h1:iHiVJfxbrB6RF4X+snI2MpVgNBKmVfGaTqZGNlMQIU0= +github.com/bodgit/sevenzip v1.6.4/go.mod h1:ZtNi5KNgHXeXg1G7WiF0IWSuFE2eG6lt/cTGlvuirO0= github.com/bodgit/windows v1.0.1 h1:tF7K6KOluPYygXa3Z2594zxlkbKPAOvqr97etrGNIz4= github.com/bodgit/windows v1.0.1/go.mod h1:a6JLwrB4KrTR5hBpp8FI9/9W9jJfeQ2h4XDXU74ZCdM= github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI= @@ -178,45 +178,51 @@ github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBW github.com/bradenaw/juniper v0.15.3 h1:RHIAMEDTpvmzV1wg1jMAHGOoI2oJUSPx3lxRldXnFGo= github.com/bradenaw/juniper v0.15.3/go.mod h1:UX4FX57kVSaDp4TPqvSjkAAewmRFAfXf27BOs5z9dq8= github.com/bwesterb/go-ristretto v1.2.0/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0= -github.com/bytedance/sonic v1.13.3 h1:MS8gmaH16Gtirygw7jV91pDCN33NyMrPbN7qiYhEsF0= -github.com/bytedance/sonic v1.13.3/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4= -github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= -github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY= -github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= +github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= +github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= +github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= +github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= +github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= +github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= github.com/caarlos0/env/v9 v9.0.0 h1:SI6JNsOA+y5gj9njpgybykATIylrRMklbs5ch6wO6pc= github.com/caarlos0/env/v9 v9.0.0/go.mod h1:ye5mlCVMYh6tZ+vCgrs/B95sj88cg5Tlnc0XIzgZ020= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= -github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= -github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU= -github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc= -github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= -github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/bubbles v0.21.1 h1:nj0decPiixaZeL9diI4uzzQTkkz1kYY8+jgzCZXSmW0= +github.com/charmbracelet/bubbles v0.21.1/go.mod h1:HHvIYRCpbkCJw2yo0vNX1O5loCwSr9/mWS8GYSg50Sk= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= +github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= -github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0= -github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= -github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= -github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/ansi v0.11.5 h1:NBWeBpj/lJPE3Q5l+Lusa4+mH6v7487OP8K0r1IhRg4= +github.com/charmbracelet/x/ansi v0.11.5/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= -github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= -github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/cheekybits/is v0.0.0-20150225183255-68e9c0620927 h1:SKI1/fuSdodxmNNyVBR8d7X/HuLnRpvvFO0AgyQk764= github.com/cheekybits/is v0.0.0-20150225183255-68e9c0620927/go.mod h1:h/aW8ynjgkuj+NQRlZcDbAbM1ORAbXjXX77sX7T289U= github.com/city404/v6-public-rpc-proto/go v0.0.0-20240817070657-90f8e24b653e h1:GLC8iDDcbt1H8+RkNao2nRGjyNTIo81e1rAJT9/uWYA= github.com/city404/v6-public-rpc-proto/go v0.0.0-20240817070657-90f8e24b653e/go.mod h1:ln9Whp+wVY/FTbn2SK0ag+SKD2fC0yQCF/Lqowc1LmU= +github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= +github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= +github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/cloudflare/circl v1.1.0/go.mod h1:prBCrKB9DV4poKZY1l9zBXg2QJY7mvgRvtMxxK7fi4I= github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= -github.com/cloudsoda/go-smb2 v0.0.0-20250228001242-d4c70e6251cc h1:t8YjNUCt1DimB4HCIXBztwWMhgxr5yG5/YaRl9Afdfg= -github.com/cloudsoda/go-smb2 v0.0.0-20250228001242-d4c70e6251cc/go.mod h1:CgWpFCFWzzEA5hVkhAc6DZZzGd3czx+BblvOzjmg6KA= +github.com/cloudsoda/go-smb2 v0.0.0-20260701064823-d8c5600d73b8 h1:+KC2I+emnT6ccWmo7IHbtchGc/vFqAxUuovo2eUgMuM= +github.com/cloudsoda/go-smb2 v0.0.0-20260701064823-d8c5600d73b8/go.mod h1:1pQXB0vAlzRlqcY7LYKOOZMw0wKfJPFxTLsJRF2Gswo= github.com/cloudsoda/sddl v0.0.0-20250224235906-926454e91efc h1:0xCWmFKBmarCqqqLeM7jFBSw/Or81UEElFqO8MY+GDs= github.com/cloudsoda/sddl v0.0.0-20250224235906-926454e91efc/go.mod h1:uvR42Hb/t52HQd7x5/ZLzZEK8oihrFpgnodIJ1vte2E= -github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4= -github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= -github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= github.com/coreos/go-oidc v2.3.0+incompatible h1:+5vEsrgprdLjjQ9FzIKAzQz1wwPD+83hQRfUIPh7rO0= github.com/coreos/go-oidc v2.3.0+incompatible/go.mod h1:CgnwVTmzoESiwO9qyAFEMiHoZ1nMCKZlZ9V6mm3/LKc= github.com/coreos/go-semver v0.3.1 h1:yi21YpKnrx1gt5R+la8n5WgS0kCrsPp33dmEyHReZr4= @@ -240,8 +246,8 @@ github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 h1:OtSeLS5y0Uy01jaKK4m github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8/go.mod h1:apkPC/CR3s48O2D7Y++n1XWEpgPNNCjXYga3PPbJe2E= github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= -github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= -github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8= +github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 h1:2tV76y6Q9BB+NEBasnqvs7e49aEBFI8ejC89PSnWH+4= github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707/go.mod h1:qssHWj60/X5sZFNxpG4HBPDHVqxNm4DfnCKgrbZOT+s= github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY= @@ -270,16 +276,16 @@ github.com/foxxorcat/weiyun-sdk-go v0.1.4/go.mod h1:TPxzN0d2PahweUEHlOBWlwZSA+rE github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= -github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY= -github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok= +github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= +github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/geoffgarside/ber v1.2.0 h1:/loowoRcs/MWLYmGX9QtIAbA+V/FrnVLsMMPhwiRm64= github.com/geoffgarside/ber v1.2.0/go.mod h1:jVPKeCbj6MvQZhwLYsGwaGI52oUorHoHKNecGT85ZCc= -github.com/gin-contrib/cors v1.7.6 h1:3gQ8GMzs1Ylpf70y8bMw4fVpycXIeX1ZemuSQIsnQQY= -github.com/gin-contrib/cors v1.7.6/go.mod h1:Ulcl+xN4jel9t1Ry8vqph23a60FwH9xVLd+3ykmTjOk= +github.com/gin-contrib/cors v1.7.7 h1:Oh9joP463x7Mw72vhvJ61YQm8ODh9b04YR7vsOErD0Q= +github.com/gin-contrib/cors v1.7.7/go.mod h1:K5tW0RkzJtWSiOdikXloy8VEZlgdVNpHNw8FpjUPNrE= github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= -github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ= -github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= +github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo= github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k= github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw= @@ -306,8 +312,8 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= -github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k= -github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= +github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= +github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= github.com/go-resty/resty/v2 v2.16.5 h1:hBKqmWrr7uRc3euHVqmh1HTHcKn99Smr7o5spptdhTM= github.com/go-resty/resty/v2 v2.16.5/go.mod h1:hkJtXbA2iKHzJheXYvQ8snQES5ZLGKMwQ07xAwp/fiA= github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc= @@ -318,6 +324,8 @@ github.com/go-webauthn/x v0.1.23 h1:9lEO0s+g8iTyz5Vszlg/rXTGrx3CjcD0RZQ1GPZCaxI= github.com/go-webauthn/x v0.1.23/go.mod h1:AJd3hI7NfEp/4fI6T4CHD753u91l510lglU7/NMN6+E= github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gogo/protobuf v1.3.1/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o= github.com/golang-jwt/jwt/v4 v4.5.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= @@ -396,12 +404,12 @@ github.com/itsHenry35/gofakes3 v0.0.8 h1:1AgOl04IgoUV5r/WSK7ycnvwfpgharYLfVTmnzk github.com/itsHenry35/gofakes3 v0.0.8/go.mod h1:gQwOJ7LoH5QSpCVmjzC6oKp+MS71utLS7GHtonsvD0c= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= -github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= -github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= -github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw= -github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A= -github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= -github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY= +github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8= github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs= github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo= @@ -436,15 +444,14 @@ github.com/kdomanski/iso9660 v0.4.0/go.mod h1:OxUSupHsO9ceI8lBLPJKWBTphLemjrCQY8 github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A= -github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= -github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= +github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= -github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= -github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU= github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs= -github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -463,14 +470,14 @@ github.com/libp2p/go-flow-metrics v0.1.0 h1:0iPhMI8PskQwzh57jB9WxIuIOQ0r+15PChFG github.com/libp2p/go-flow-metrics v0.1.0/go.mod h1:4Xi8MX8wj5aWNDAZttg6UPmc0ZrnFNsMtpsYUClFtro= github.com/libp2p/go-libp2p v0.27.8 h1:IX5x/4yKwyPQeVS2AXHZ3J4YATM9oHBGH1gBc23jBAI= github.com/libp2p/go-libp2p v0.27.8/go.mod h1:eCFFtd0s5i/EVKR7+5Ki8bM7qwkNW3TPTTSSW9sz8NE= -github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= -github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 h1:PpXWgLPs+Fqr325bN2FD2ISlRRztXibcX6e8f5FR5Dc= github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= -github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo= -github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= +github.com/maruel/natural v1.3.0 h1:VsmCsBmEyrR46RomtgHs5hbKADGRVtliHTyCOLFBpsg= +github.com/maruel/natural v1.3.0/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= github.com/matoous/go-nanoid/v2 v2.1.0 h1:P64+dmq21hhWdtvZfEAofnvJULaRR1Yib0+PnU669bE= github.com/matoous/go-nanoid/v2 v2.1.0/go.mod h1:KlbGNQ+FhrUNIHUxZdL63t7tl4LaPkZNpUULS8H4uVM= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= @@ -479,20 +486,20 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= -github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= -github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/meilisearch/meilisearch-go v0.32.0 h1:cWcycpONSH3VLTZ5npUl1O5aXPkNM0vUx6bywnYqGbE= github.com/meilisearch/meilisearch-go v0.32.0/go.mod h1:aNtyuwurDg/ggxQIcKqWH6G9g2ptc8GyY7PLY4zMn/g= -github.com/mholt/archives v0.1.3 h1:aEAaOtNra78G+TvV5ohmXrJOAzf++dIlYeDW3N9q458= -github.com/mholt/archives v0.1.3/go.mod h1:LUCGp++/IbV/I0Xq4SzcIR6uwgeh2yjnQWamjRQfLTU= +github.com/mholt/archives v0.1.5 h1:Fh2hl1j7VEhc6DZs2DLMgiBNChUux154a1G+2esNvzQ= +github.com/mholt/archives v0.1.5/go.mod h1:3TPMmBLPsgszL+1As5zECTuKwKvIfj6YcwWPpeTAXF4= github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/mikelolasagasti/xz v1.0.1 h1:Q2F2jX0RYJUG3+WsM+FJknv+6eVjsjXNDV0KJXZzkD0= github.com/mikelolasagasti/xz v1.0.1/go.mod h1:muAirjiOUxPRXwm9HdDtB3uoRPrGnL85XHtokL9Hcgc= -github.com/minio/minlz v1.0.0 h1:Kj7aJZ1//LlTP1DM8Jm7lNKvvJS2m74gyyXXn3+uJWQ= -github.com/minio/minlz v1.0.0/go.mod h1:qT0aEB35q79LLornSzeDH75LBf3aH1MV+jB5w9Wasec= +github.com/minio/minlz v1.0.1 h1:OUZUzXcib8diiX+JYxyRLIdomyZYzHct6EShOKtQY2A= +github.com/minio/minlz v1.0.1/go.mod h1:qT0aEB35q79LLornSzeDH75LBf3aH1MV+jB5w9Wasec= github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM= github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8= github.com/minio/xxml v0.0.3 h1:ZIpPQpfyG5uZQnqqC0LZuWtPk/WT8G/qkxvO6jb7zMU= @@ -538,22 +545,22 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/natefinch/lumberjack v2.0.0+incompatible h1:4QJd3OLAMgj7ph+yZTuX13Ld4UpgHp07nNdFX7mqFfM= github.com/natefinch/lumberjack v2.0.0+incompatible/go.mod h1:Wi9p2TTF5DG5oU+6YfsmYQpsTIOm0B1VNzQg9Mw6nPk= -github.com/ncw/swift/v2 v2.0.4 h1:hHWVFxn5/YaTWAASmn4qyq2p6OyP/Hm3vMLzkjEqR7w= -github.com/ncw/swift/v2 v2.0.4/go.mod h1:cbAO76/ZwcFrFlHdXPjaqWZ9R7Hdar7HpjRXBfbjigk= -github.com/nwaples/rardecode/v2 v2.1.1 h1:OJaYalXdliBUXPmC8CZGQ7oZDxzX1/5mQmgn0/GASew= -github.com/nwaples/rardecode/v2 v2.1.1/go.mod h1:7uz379lSxPe6j9nvzxUZ+n7mnJNgjsRNb6IbvGVHRmw= +github.com/ncw/swift/v2 v2.0.5 h1:9o5Gsd7bInAFEqsGPcaUdsboMbqf8lnNtxqWKFT9iz8= +github.com/ncw/swift/v2 v2.0.5/go.mod h1:cbAO76/ZwcFrFlHdXPjaqWZ9R7Hdar7HpjRXBfbjigk= +github.com/nwaples/rardecode/v2 v2.2.0 h1:4ufPGHiNe1rYJxYfehALLjup4Ls3ck42CWwjKiOqu0A= +github.com/nwaples/rardecode/v2 v2.2.0/go.mod h1:7uz379lSxPe6j9nvzxUZ+n7mnJNgjsRNb6IbvGVHRmw= github.com/panjf2000/ants/v2 v2.4.2/go.mod h1:f6F0NZVFsGCp5A7QW/Zj/m92atWwOkY0OIhFxRNFr4A= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= -github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU= -github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= +github.com/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY= +github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pkg/sftp v1.13.9 h1:4NGkvGudBL7GteO3m6qnaQ4pC0Kvf0onSVc9gR3EWBw= -github.com/pkg/sftp v1.13.9/go.mod h1:OBN7bVXdstkFFN/gdnHPUb5TE8eb8G1Rp9wCItqjkkA= +github.com/pkg/sftp v1.13.10 h1:+5FbKNTe5Z9aspU88DPIKJ9z2KZoaGCu6Sr6kKR/5mU= +github.com/pkg/sftp v1.13.10/go.mod h1:bJ1a7uDhrX/4OII+agvy28lzRvQrmIQuaHrcI1HbeGA= github.com/pkg/xattr v0.4.10 h1:Qe0mtiNFHQZ296vRgUjRCoPHPqH7VdTOrZx3g0T+pGA= github.com/pkg/xattr v0.4.10/go.mod h1:di8WF84zAKk8jzR1UBTEWh9AUlIZZ7M/JNt8e9B6ktU= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -575,10 +582,12 @@ github.com/prometheus/common v0.64.0 h1:pdZeA+g617P7oGv1CzdTzyeShxAGrTBsolKNOLQP github.com/prometheus/common v0.64.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8= github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= -github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= -github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= -github.com/quic-go/quic-go v0.54.1 h1:4ZAWm0AhCb6+hE+l5Q1NAL0iRn/ZrMwqHRGQiFwj2eg= -github.com/quic-go/quic-go v0.54.1/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY= +github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0= +github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.60.0 h1:xcQioE8OM66UQLeUMHltK1CCcOu3JbVB4JAQdDQSB+0= +github.com/quic-go/quic-go v0.60.0/go.mod h1:wpKpjmPpftl30sL6pFh7REVpjbcCVy4zt2vDyK1TuJk= github.com/rclone/rclone v1.70.3 h1:rg/WNh4DmSVZyKP2tHZ4lAaWEyMi7h/F0r7smOMA3IE= github.com/rclone/rclone v1.70.3/go.mod h1:nLyN+hpxAsQn9Rgt5kM774lcRDad82x/KqQeBZ83cMo= github.com/relvacode/iso8601 v1.6.0 h1:eFXUhMJN3Gz8Rcq82f9DTMW0svjtAVuIEULglM7QHTU= @@ -588,7 +597,6 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rfjakob/eme v1.1.2 h1:SxziR8msSOElPayZNFfQw4Tjx/Sbaeeh3eRvrHVMUs4= github.com/rfjakob/eme v1.1.2/go.mod h1:cVvpasglm/G3ngEfcfT/Wt0GwhkuO32pf/poW6Nyk1k= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= @@ -602,23 +610,25 @@ github.com/shabbyrobe/gocovmerge v0.0.0-20230507112040-c3350d9342df h1:S77Pf5fIG github.com/shabbyrobe/gocovmerge v0.0.0-20230507112040-c3350d9342df/go.mod h1:dcuzJZ83w/SqN9k4eQqwKYMgmKWzg/KzJAURBhRL1tc= github.com/shirou/gopsutil/v4 v4.25.5 h1:rtd9piuSMGeU8g1RMXjZs9y9luK5BwtnG7dZaQUJAsc= github.com/shirou/gopsutil/v4 v4.25.5/go.mod h1:PfybzyydfZcN+JMMjkF6Zb8Mq1A/VcogFFg7hj50W9c= -github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= -github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EEf9cgbU6AtGPK4CTG3Zf6CKMNqf0MHTggAUA= github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog= -github.com/sorairolake/lzip-go v0.3.5 h1:ms5Xri9o1JBIWvOFAorYtUNik6HI3HgBTkISiqu0Cwg= -github.com/sorairolake/lzip-go v0.3.5/go.mod h1:N0KYq5iWrMXI0ZEXKXaS9hCyOjZUQdBDEIbXfoUwbdk= +github.com/sorairolake/lzip-go v0.3.8 h1:j5Q2313INdTA80ureWYRhX+1K78mUXfMoPZCw/ivWik= +github.com/sorairolake/lzip-go v0.3.8/go.mod h1:JcBqGMV0frlxwrsE9sMWXDjqn3EeVf0/54YPsw66qkU= github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= github.com/spf13/afero v1.2.2/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk= -github.com/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA= -github.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stangelandcl/ppmd v0.1.1 h1:c25QazhlWUn5nmR1QOzafKhQxBicAr7GGCKER2aJ8H8= +github.com/stangelandcl/ppmd v0.1.1/go.mod h1:Rrv7M+/2P5jYr/GMLhBl7Ug3uJ1bUiVzr5LbbaV6xgY= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -633,10 +643,12 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/t3rm1n4l/go-mega v0.0.0-20241213151442-a19cff0ec7b5 h1:Sa+sR8aaAMFwxhXWENEnE6ZpqhZ9d7u1RT2722Rw6hc= -github.com/t3rm1n4l/go-mega v0.0.0-20241213151442-a19cff0ec7b5/go.mod h1:UdZiFUFu6e2WjjtjxivwXWcwc1N/8zgbkBR9QNucUOY= +github.com/t3rm1n4l/go-mega v0.0.0-20260630094356-4de2f127f34a h1:VQ9CmpYeTgdFvg748N6tIOHBZn+hXjJe2xNuOJHW07E= +github.com/t3rm1n4l/go-mega v0.0.0-20260630094356-4de2f127f34a/go.mod h1:BF/l2jNyK+2h/BJZ7VLMAz6m/IWjA2F67gTjV1C/+Bo= github.com/taruti/bytepool v0.0.0-20160310082835-5e3a9ea56543 h1:6Y51mutOvRGRx6KqyMNo//xk8B8o6zW9/RVmy1VamOs= github.com/taruti/bytepool v0.0.0-20160310082835-5e3a9ea56543/go.mod h1:jpwqYA8KUVEvSUJHkCXsnBRJCSKP1BMa81QZ6kvRpow= github.com/tchap/go-patricia/v2 v2.3.3 h1:xfNEsODumaEcCcY3gI0hYPZ/PcpVv5ju6RMAhgwZDDc= @@ -651,11 +663,11 @@ github.com/u2takey/ffmpeg-go v0.5.0 h1:r7d86XuL7uLWJ5mzSeQ03uvjfIhiJYvsRAJFCW4uk github.com/u2takey/ffmpeg-go v0.5.0/go.mod h1:ruZWkvC1FEiUNjmROowOAps3ZcWxEiOpFoHCvk97kGc= github.com/u2takey/go-utils v0.3.1 h1:TaQTgmEZZeDHQFYfd+AdUT1cT4QJgJn/XVPELhHw4ys= github.com/u2takey/go-utils v0.3.1/go.mod h1:6e+v5vEZ/6gu12w/DC2ixZdZtCrNokVxD0JUklcqdCs= -github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= -github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= +github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= github.com/ulikunitz/xz v0.5.8/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= -github.com/ulikunitz/xz v0.5.12 h1:37Nm15o69RwBkXM0J6A5OlE67RZTfzUxTj8fB3dfcsc= -github.com/ulikunitz/xz v0.5.12/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= +github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY= +github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= github.com/unknwon/goconfig v1.0.0 h1:rS7O+CmUdli1T+oDm7fYj1MwqNWtEJfNj+FqcUHML8U= github.com/unknwon/goconfig v1.0.0/go.mod h1:qu2ZQ/wcC/if2u32263HTVC39PeOQRSmidQk3DuDFQ8= github.com/upyun/go-sdk/v3 v3.0.4 h1:2DCJa/Yi7/3ZybT9UCPATSzvU3wpPPxhXinNlb1Hi8Q= @@ -675,30 +687,32 @@ github.com/zzzhr1990/go-common-entity v0.0.0-20250202070650-1a200048f0d3 h1:PSRw github.com/zzzhr1990/go-common-entity v0.0.0-20250202070650-1a200048f0d3/go.mod h1:CKriYB8bkNgSbYUQF1khSpejKb5IsV6cR7MdaAR7Fc0= go.etcd.io/bbolt v1.4.0 h1:TU77id3TnN/zKr7CO/uk+fBCwF2jGcMuw2B/FMAzYIk= go.etcd.io/bbolt v1.4.0/go.mod h1:AsD+OCi/qPN1giOX1aiLAha3o1U8rAz65bvN4j0sRuk= +go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= +go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= -go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= -go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= -go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= -go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= -go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= -go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= -go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= -go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= -go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= -go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= go.uber.org/goleak v1.2.1 h1:NBol2c7O1ZokfZ0LEU9K6Whx/KnwvepVetCUhtKja4A= go.uber.org/goleak v1.2.1/go.mod h1:qlT2yGI9QafXHhZZLxlSuNsMw3FFLxBr+tBRlmO1xH4= -go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= -go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go4.org v0.0.0-20260112195520-a5071408f32f h1:ziUVAjmTPwQMBmYR1tbdRFJPtTcQUI12fH9QQjfb0Sw= go4.org v0.0.0-20260112195520-a5071408f32f/go.mod h1:ZRJnO5ZI4zAwMFp+dS1+V6J6MSyAowhRqAE+DPa1Xp0= gocv.io/x/gocv v0.25.0/go.mod h1:Rar2PS6DV+T4FL+PM535EImD/h13hGVaHhnCu1xarBs= -golang.org/x/arch v0.18.0 h1:WN9poc33zL4AzGxqf8VtpKUnGvMi8O9lhNyBMF/85qc= -golang.org/x/arch v0.18.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= +golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg= +golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= golang.org/x/crypto v0.0.0-20190211182817-74369b46fc67/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= @@ -709,8 +723,8 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= -golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= -golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= +golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= golang.org/x/exp v0.0.0-20250606033433-dcc06ee1d476 h1:bsqhLWFR6G6xiQcb+JoGqdKdRU6WzPWmK8E0jxTjzo4= golang.org/x/exp v0.0.0-20250606033433-dcc06ee1d476/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= @@ -721,8 +735,6 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= -golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -737,10 +749,10 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= -golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= -golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= -golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= -golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -762,7 +774,6 @@ golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -772,8 +783,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -784,8 +795,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= -golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= -golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= +golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= +golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= @@ -798,8 +809,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= -golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= @@ -811,20 +822,20 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= -golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= -golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= +golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= -gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/api v0.259.0 h1:90TaGVIxScrh1Vn/XI2426kRpBqHwWIzVBzJsVZ5XrQ= google.golang.org/api v0.259.0/go.mod h1:LC2ISWGWbRoyQVpxGntWwLWN/vLNxxKBK9KuJRI8Te4= google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= -google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= -google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.82.0 h1:vguDnZUPjE26w09A63VoxZPnvPjB5Riyc0mkXPFmAIU= +google.golang.org/grpc v1.82.0/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= @@ -850,13 +861,13 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo= gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM= -gorm.io/driver/postgres v1.5.9 h1:DkegyItji119OlcaLjqN11kHoUgZ/j13E0jkJZgD6A8= -gorm.io/driver/postgres v1.5.9/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI= -gorm.io/driver/sqlite v1.5.6 h1:fO/X46qn5NUEEOZtnjJRWRzZMe8nqJiQ9E+0hi+hKQE= -gorm.io/driver/sqlite v1.5.6/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4= +gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= +gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= +gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= +gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= -gorm.io/gorm v1.25.11 h1:/Wfyg1B/je1hnDx3sMkX+gAlxrlZpn6X0BXRlwXlvHg= -gorm.io/gorm v1.25.11/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ= +gorm.io/gorm v1.30.0 h1:qbT5aPv1UH8gI99OsRlvDToLxW5zR7FzS9acZDOZcgs= +gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= lukechampine.com/blake3 v1.1.7 h1:GgRMhmdsuK8+ii6UZFDL8Nb+VyMwadAgcJyfYHxG6n0= lukechampine.com/blake3 v1.1.7/go.mod h1:tkKEOtDkNtklkXtLNEOGNq5tcV90tJiA1vAA12R78LA= modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE= @@ -867,7 +878,6 @@ modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds= modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM= modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk= -nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= resty.dev/v3 v3.0.0-beta.2 h1:xu4mGAdbCLuc3kbk7eddWfWm4JfhwDtdapwss5nCjnQ= resty.dev/v3 v3.0.0-beta.2/go.mod h1:OgkqiPvTDtOuV4MGZuUDhwOpkY8enjOsjjMzeOHefy4= sigs.k8s.io/yaml v1.2.0/go.mod h1:yfXDCHCao9+ENCvLSE62v9VSji2MKu5jeNfTrofGhJc= diff --git a/internal/archive/zip/utils.go b/internal/archive/zip/utils.go index 4f367a631..e5d8fe20e 100644 --- a/internal/archive/zip/utils.go +++ b/internal/archive/zip/utils.go @@ -5,6 +5,7 @@ import ( "io" "io/fs" "strings" + "unicode/utf8" "github.com/KirCute/zip" "github.com/OpenListTeam/OpenList/v4/internal/archive/tool" @@ -101,6 +102,9 @@ func decodeName(name string, efs bool) string { if efs { return name } + if utf8.ValidString(name) { + return name + } enc, err := ianaindex.IANA.Encoding(setting.GetStr(conf.NonEFSZipEncoding)) if err != nil { return name diff --git a/internal/archive/zip/utils_test.go b/internal/archive/zip/utils_test.go new file mode 100644 index 000000000..efd248030 --- /dev/null +++ b/internal/archive/zip/utils_test.go @@ -0,0 +1,47 @@ +package zip + +import ( + "testing" + + "github.com/OpenListTeam/OpenList/v4/internal/archive/tool" + "github.com/OpenListTeam/OpenList/v4/internal/conf" + "github.com/OpenListTeam/OpenList/v4/internal/model" + "github.com/OpenListTeam/OpenList/v4/internal/op" + "github.com/OpenListTeam/OpenList/v4/internal/setting" + "github.com/stretchr/testify/require" +) + +func TestZipAcceptsLivpExtension(t *testing.T) { + _, ok := tool.Tools[".livp"] + require.True(t, ok) +} + +func setNonEFSZipEncoding(value string) { + op.Cache.SetSetting(conf.NonEFSZipEncoding, &model.SettingItem{ + Key: conf.NonEFSZipEncoding, + Value: value, + }) +} + +func TestDecodeNamePrefersValidUTF8WhenEFSDisabled(t *testing.T) { + setNonEFSZipEncoding("IBM437") + + name := "中文.txt" + require.Equal(t, name, decodeName(name, false)) + // Ensure the setting still exists to verify we did not bypass config due to missing setup. + require.Equal(t, "IBM437", setting.GetStr(conf.NonEFSZipEncoding)) +} + +func TestDecodeNameFallsBackToConfiguredEncoding(t *testing.T) { + setNonEFSZipEncoding("GB18030") + + name := string([]byte{0xd6, 0xd0, 0xce, 0xc4, '.', 't', 'x', 't'}) + require.Equal(t, "中文.txt", decodeName(name, false)) +} + +func TestDecodeNameRespectsEFSFlag(t *testing.T) { + setNonEFSZipEncoding("GB18030") + + name := "utf8-name.txt" + require.Equal(t, name, decodeName(name, true)) +} diff --git a/internal/archive/zip/zip.go b/internal/archive/zip/zip.go index 1dcb904d7..7adb5a988 100644 --- a/internal/archive/zip/zip.go +++ b/internal/archive/zip/zip.go @@ -17,7 +17,7 @@ type Zip struct { } func (z *Zip) AcceptedExtensions() []string { - return []string{} + return []string{".livp"} } func (z *Zip) AcceptedMultipartExtensions() map[string]tool.MultipartExtension { diff --git a/internal/cache/weak_cache.go b/internal/cache/weak_cache.go new file mode 100644 index 000000000..03b95d24f --- /dev/null +++ b/internal/cache/weak_cache.go @@ -0,0 +1,78 @@ +package cache + +import ( + "runtime" + "sync" + "weak" +) + +// WeakCacheMap is a map that holds weak references to values. +// Use for shared expensive objects and automatic cleanup when no longer used. +// This object can be GC and no goroutine is used for cleanup. +type WeakCacheMap[K comparable, V any] struct { + mu sync.Mutex + m map[K]*weakCacheEntry[V] +} + +type weakCacheEntry[V any] struct { + weakPtr weak.Pointer[V] + cleanup runtime.Cleanup +} + +func NewWeakCacheMap[K comparable, V any]() *WeakCacheMap[K, V] { + return &WeakCacheMap[K, V]{ + m: make(map[K]*weakCacheEntry[V]), + } +} + +func (c *WeakCacheMap[K, V]) Load(key K) (value *V, ok bool) { + c.mu.Lock() + defer c.mu.Unlock() + entry, exists := c.m[key] + if !exists { + return nil, false + } + value = entry.weakPtr.Value() + return value, value != nil +} + +func (c *WeakCacheMap[K, V]) Store(key K, value *V) { + c.mu.Lock() + defer c.mu.Unlock() + entry, exists := c.m[key] + if exists { + entry.cleanup.Stop() + } else { + entry = &weakCacheEntry[V]{} + c.m[key] = entry + } + entry.weakPtr = weak.Make(value) + entry.cleanup = runtime.AddCleanup(value, func(struct{}) { + c.mu.Lock() + defer c.mu.Unlock() + if c.m[key] == entry { + delete(c.m, key) + } + }, struct{}{}) +} + +func (c *WeakCacheMap[K, V]) Delete(key K) bool { + c.mu.Lock() + defer c.mu.Unlock() + entry, exists := c.m[key] + if !exists { + return false + } + entry.cleanup.Stop() + delete(c.m, key) + return true +} + +func (c *WeakCacheMap[K, V]) Clear() { + c.mu.Lock() + defer c.mu.Unlock() + for _, entry := range c.m { + entry.cleanup.Stop() + } + clear(c.m) +} diff --git a/internal/casfile/cas.go b/internal/casfile/cas.go index fbe0e1998..bd7483e52 100644 --- a/internal/casfile/cas.go +++ b/internal/casfile/cas.go @@ -4,6 +4,7 @@ import ( "encoding/base64" "errors" "fmt" + "io" "strings" "github.com/OpenListTeam/OpenList/v4/pkg/utils" @@ -26,6 +27,24 @@ type payload struct { CreateTime string `json:"create_time"` } +const MaxMetadataSize = 1 << 20 + +var ErrMetadataTooLarge = errors.New(".cas metadata exceeds 1 MiB") + +func ParseReader(r io.Reader) (*Info, error) { + if r == nil { + return nil, errors.New("nil .cas reader") + } + data, err := io.ReadAll(io.LimitReader(r, MaxMetadataSize+1)) + if err != nil { + return nil, fmt.Errorf("read .cas content: %w", err) + } + if len(data) > MaxMetadataSize { + return nil, ErrMetadataTooLarge + } + return Parse(data) +} + func Parse(data []byte) (*Info, error) { raw := strings.TrimSpace(strings.TrimPrefix(string(data), "\ufeff")) if raw == "" { diff --git a/internal/casfile/cas_test.go b/internal/casfile/cas_test.go index 2e2d0af0f..8b767d205 100644 --- a/internal/casfile/cas_test.go +++ b/internal/casfile/cas_test.go @@ -1,7 +1,10 @@ package casfile import ( + "bytes" "encoding/base64" + "errors" + "strings" "testing" ) @@ -43,3 +46,21 @@ func TestParseRejectsIncompletePayload(t *testing.T) { t.Fatal("expected missing slice md5 error") } } + +func TestParseReaderParsesValidPayload(t *testing.T) { + info, err := ParseReader(strings.NewReader(`{"name":"hello.txt","size":12,"md5":"abc","sliceMd5":"def"}`)) + if err != nil { + t.Fatalf("parse reader .cas: %v", err) + } + if info.Name != "hello.txt" || info.Size != 12 || info.MD5 != "abc" || info.SliceMD5 != "def" { + t.Fatalf("unexpected info: %#v", info) + } +} + +func TestParseReaderRejectsOversizedMetadata(t *testing.T) { + content := bytes.Repeat([]byte("x"), MaxMetadataSize+1) + _, err := ParseReader(bytes.NewReader(content)) + if !errors.Is(err, ErrMetadataTooLarge) { + t.Fatalf("expected ErrMetadataTooLarge, got %v", err) + } +} diff --git a/internal/conf/config.go b/internal/conf/config.go index ca8c1dd50..0c77bcd18 100644 --- a/internal/conf/config.go +++ b/internal/conf/config.go @@ -111,6 +111,10 @@ type Index115 struct { BleveDir string `json:"bleve_dir" env:"BLEVE_DIR"` } +type MCP struct { + Enable bool `json:"enable" env:"ENABLE"` +} + type Config struct { Force bool `json:"force" env:"FORCE"` SiteURL string `json:"site_url" env:"SITE_URL"` @@ -136,6 +140,7 @@ type Config struct { FTP FTP `json:"ftp" envPrefix:"FTP_"` SFTP SFTP `json:"sftp" envPrefix:"SFTP_"` Index115 Index115 `json:"index115" envPrefix:"INDEX115_"` + MCP MCP `json:"mcp" envPrefix:"MCP_"` LastLaunchedVersion string `json:"last_launched_version"` ProxyAddress string `json:"proxy_address" env:"PROXY_ADDRESS"` } @@ -255,6 +260,9 @@ func DefaultConfig(dataDir string) *Config { Enable: false, Listen: ":5222", }, + MCP: MCP{ + Enable: false, + }, LastLaunchedVersion: "", ProxyAddress: "", } diff --git a/internal/fs/fs.go b/internal/fs/fs.go index 3d459c6be..67a1ac065 100644 --- a/internal/fs/fs.go +++ b/internal/fs/fs.go @@ -199,8 +199,8 @@ func PutURL(ctx context.Context, path, dstName, urlStr string) error { return op.PutURL(ctx, storage, dstDirActualPath, dstName, urlStr) } -func GetDirectUploadInfo(ctx context.Context, tool, path, dstName string, fileSize int64) (any, error) { - info, err := getDirectUploadInfo(ctx, tool, path, dstName, fileSize) +func GetDirectUploadInfo(ctx context.Context, tool, path, dstName string, fileSize int64, overwrite bool) (any, error) { + info, err := getDirectUploadInfo(ctx, tool, path, dstName, fileSize, overwrite) if err != nil { log.Errorf("failed get %s direct upload info for %s(%d bytes): %+v", path, dstName, fileSize, err) } diff --git a/internal/fs/list_test.go b/internal/fs/list_test.go index ebaf4371e..d8c8e47fe 100644 --- a/internal/fs/list_test.go +++ b/internal/fs/list_test.go @@ -23,8 +23,8 @@ func TestWhetherHide(t *testing.T) { Hide: "secret", HSub: true, }, - path: "/folder", - want: false, + path: "/folder", + want: false, reason: "nil user (treated as admin) should not hide", }, { @@ -38,8 +38,8 @@ func TestWhetherHide(t *testing.T) { Hide: "secret", HSub: true, }, - path: "/folder", - want: false, + path: "/folder", + want: false, reason: "user with can_see_hides permission should not hide", }, { @@ -47,9 +47,9 @@ func TestWhetherHide(t *testing.T) { user: &model.User{ Role: model.GUEST, }, - meta: nil, - path: "/folder", - want: false, + meta: nil, + path: "/folder", + want: false, reason: "nil meta should not hide", }, { @@ -62,8 +62,8 @@ func TestWhetherHide(t *testing.T) { Hide: "", HSub: true, }, - path: "/folder", - want: false, + path: "/folder", + want: false, reason: "empty hide string should not hide", }, { @@ -76,8 +76,8 @@ func TestWhetherHide(t *testing.T) { Hide: "secret", HSub: false, }, - path: "/folder", - want: true, + path: "/folder", + want: true, reason: "exact path match should hide for guest", }, { @@ -90,8 +90,8 @@ func TestWhetherHide(t *testing.T) { Hide: "secret", HSub: true, }, - path: "/folder/subfolder", - want: true, + path: "/folder/subfolder", + want: true, reason: "sub path with HSub=true should hide for guest", }, { @@ -104,8 +104,8 @@ func TestWhetherHide(t *testing.T) { Hide: "secret", HSub: false, }, - path: "/folder/subfolder", - want: false, + path: "/folder/subfolder", + want: false, reason: "sub path with HSub=false should not hide", }, { @@ -118,8 +118,8 @@ func TestWhetherHide(t *testing.T) { Hide: "secret", HSub: true, }, - path: "/other", - want: false, + path: "/other", + want: false, reason: "non-sub path should not hide even with HSub=true", }, { @@ -133,8 +133,8 @@ func TestWhetherHide(t *testing.T) { Hide: "secret", HSub: true, }, - path: "/folder", - want: true, + path: "/folder", + want: true, reason: "user without can_see_hides permission should hide", }, } diff --git a/internal/fs/put.go b/internal/fs/put.go index be829ae47..0b905be08 100644 --- a/internal/fs/put.go +++ b/internal/fs/put.go @@ -110,10 +110,10 @@ func putDirectly(ctx context.Context, dstDirPath string, file model.FileStreamer return op.Put(ctx, storage, dstDirActualPath, file, nil) } -func getDirectUploadInfo(ctx context.Context, tool, dstDirPath, dstName string, fileSize int64) (any, error) { +func getDirectUploadInfo(ctx context.Context, tool, dstDirPath, dstName string, fileSize int64, overwrite bool) (any, error) { storage, dstDirActualPath, err := op.GetStorageAndActualPath(dstDirPath) if err != nil { return nil, errors.WithMessage(err, "failed get storage") } - return op.GetDirectUploadInfo(ctx, tool, storage, dstDirActualPath, dstName, fileSize) + return op.GetDirectUploadInfo(ctx, tool, storage, dstDirActualPath, dstName, fileSize, overwrite) } diff --git a/internal/model/args.go b/internal/model/args.go index 073c94a63..393a716fa 100644 --- a/internal/model/args.go +++ b/internal/model/args.go @@ -42,6 +42,20 @@ type Link struct { RequireReference bool `json:"-"` } +func (l *Link) Clone() *Link { + return &Link{ + URL: l.URL, + Header: l.Header, + RangeReader: l.RangeReader, + Expiration: l.Expiration, + Concurrency: l.Concurrency, + PartSize: l.PartSize, + ContentLength: l.ContentLength, + SyncClosers: utils.NewSyncClosers(l), + RequireReference: l.RequireReference, + } +} + type OtherArgs struct { Obj Obj Method string diff --git a/internal/net/oss_test.go b/internal/net/oss_test.go index 9001cd39d..d5c5203fc 100644 --- a/internal/net/oss_test.go +++ b/internal/net/oss_test.go @@ -31,9 +31,13 @@ func TestNewOSSClientUsesEnvironmentHTTPSProxy(t *testing.T) { t.Fatal("expected OSS client to use a custom HTTP client") } - transport, ok := client.HTTPClient.Transport.(*http.Transport) + roundTripper := client.HTTPClient.Transport + if guarded, ok := roundTripper.(*safeTransport); ok { + roundTripper = guarded.base + } + transport, ok := roundTripper.(*http.Transport) if !ok { - t.Fatalf("expected *http.Transport, got %T", client.HTTPClient.Transport) + t.Fatalf("expected *http.Transport, got %T", roundTripper) } if transport.Proxy == nil { diff --git a/internal/net/serve.go b/internal/net/serve.go index 6a20460b1..89a209d88 100644 --- a/internal/net/serve.go +++ b/internal/net/serve.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "mime/multipart" + gonet "net" "net/http" "strconv" "strings" @@ -292,6 +293,31 @@ func NewHttpClient() *http.Client { return &http.Client{ Timeout: time.Hour * 48, - Transport: transport, + Transport: &safeTransport{base: transport}, } } + +type safeTransport struct { + base http.RoundTripper +} + +func (t *safeTransport) RoundTrip(req *http.Request) (*http.Response, error) { + host := req.URL.Hostname() + addrs, err := gonet.DefaultResolver.LookupIPAddr(req.Context(), host) + if err != nil || len(addrs) == 0 { + return nil, errors.Wrapf(err, "failed to resolve host: %s", host) + } + for _, addr := range addrs { + if isCloudMetadataIP(addr.IP) { + return nil, ErrCloudMetadataEndpoint + } + } + return t.base.RoundTrip(req) +} + +var ErrCloudMetadataEndpoint = errors.New("access to cloud metadata endpoint is not allowed") + +func isCloudMetadataIP(ip gonet.IP) bool { + ip = ip.To4() + return ip != nil && ip[0] == 169 && ip[1] == 254 && ip[2] == 169 && ip[3] == 254 +} diff --git a/internal/offline_download/115/client.go b/internal/offline_download/115/client.go index 9e9f702d5..d31971d7c 100644 --- a/internal/offline_download/115/client.go +++ b/internal/offline_download/115/client.go @@ -62,18 +62,16 @@ func (p *Cloud115) AddURL(args *tool.AddUrlArgs) (string, error) { return "", fmt.Errorf("unsupported storage driver for offline download, only 115 Cloud is supported") } - ctx := context.Background() - - if err := op.MakeDir(ctx, storage, actualPath); err != nil { + if err := op.MakeDir(args.Ctx, storage, actualPath); err != nil { return "", err } - parentDir, err := op.GetUnwrap(ctx, storage, actualPath) + parentDir, err := op.GetUnwrap(args.Ctx, storage, actualPath) if err != nil { return "", err } - hashs, err := driver115.OfflineDownload(ctx, []string{args.Url}, parentDir) + hashs, err := driver115.OfflineDownload(args.Ctx, []string{args.Url}, parentDir) if err != nil || len(hashs) < 1 { return "", fmt.Errorf("failed to add offline download task: %w", err) } diff --git a/internal/offline_download/115_open/client.go b/internal/offline_download/115_open/client.go index d12e02ec5..6f4c7fd04 100644 --- a/internal/offline_download/115_open/client.go +++ b/internal/offline_download/115_open/client.go @@ -58,18 +58,16 @@ func (o *Open115) AddURL(args *tool.AddUrlArgs) (string, error) { return "", fmt.Errorf("unsupported storage driver for offline download, only 115 Cloud is supported") } - ctx := context.Background() - - if err := op.MakeDir(ctx, storage, actualPath); err != nil { + if err := op.MakeDir(args.Ctx, storage, actualPath); err != nil { return "", err } - parentDir, err := op.GetUnwrap(ctx, storage, actualPath) + parentDir, err := op.GetUnwrap(args.Ctx, storage, actualPath) if err != nil { return "", err } - hashs, err := driver115Open.OfflineDownload(ctx, []string{args.Url}, parentDir) + hashs, err := driver115Open.OfflineDownload(args.Ctx, []string{args.Url}, parentDir) if err != nil || len(hashs) < 1 { return "", fmt.Errorf("failed to add offline download task: %w", err) } diff --git a/internal/offline_download/123/client.go b/internal/offline_download/123/client.go index 2c4f47048..613860890 100644 --- a/internal/offline_download/123/client.go +++ b/internal/offline_download/123/client.go @@ -58,15 +58,14 @@ func (*Pan123) AddURL(args *tool.AddUrlArgs) (string, error) { if !ok { return "", fmt.Errorf("unsupported storage driver for offline download, only 123Pan is supported") } - ctx := context.Background() - if err := op.MakeDir(ctx, storage, actualPath); err != nil { + if err := op.MakeDir(args.Ctx, storage, actualPath); err != nil { return "", err } - parentDir, err := op.GetUnwrap(ctx, storage, actualPath) + parentDir, err := op.GetUnwrap(args.Ctx, storage, actualPath) if err != nil { return "", err } - taskID, err := driver123.OfflineDownload(ctx, args.Url, parentDir) + taskID, err := driver123.OfflineDownload(args.Ctx, args.Url, parentDir) if err != nil { return "", fmt.Errorf("failed to add offline download task: %w", err) } diff --git a/internal/offline_download/123_open/client.go b/internal/offline_download/123_open/client.go index ce1453c32..997eeabef 100644 --- a/internal/offline_download/123_open/client.go +++ b/internal/offline_download/123_open/client.go @@ -56,16 +56,15 @@ func (*Open123) AddURL(args *tool.AddUrlArgs) (string, error) { if !ok { return "", fmt.Errorf("unsupported storage driver for offline download, only 123 Open is supported") } - ctx := context.Background() - if err := op.MakeDir(ctx, storage, actualPath); err != nil { + if err := op.MakeDir(args.Ctx, storage, actualPath); err != nil { return "", err } - parentDir, err := op.GetUnwrap(ctx, storage, actualPath) + parentDir, err := op.GetUnwrap(args.Ctx, storage, actualPath) if err != nil { return "", err } cb := setting.GetStr(conf.Pan123OpenOfflineDownloadCallbackUrl) - taskID, err := driver123Open.OfflineDownload(ctx, args.Url, parentDir, cb) + taskID, err := driver123Open.OfflineDownload(args.Ctx, args.Url, parentDir, cb) if err != nil { return "", fmt.Errorf("failed to add offline download task: %w", err) } diff --git a/internal/offline_download/http/client.go b/internal/offline_download/http/client.go index 5dcb6d590..6b3c45a5f 100644 --- a/internal/offline_download/http/client.go +++ b/internal/offline_download/http/client.go @@ -5,20 +5,19 @@ import ( "math/rand/v2" "net/http" "os" - "path" "path/filepath" "strings" "time" "github.com/OpenListTeam/OpenList/v4/drivers/base" "github.com/OpenListTeam/OpenList/v4/internal/model" + "github.com/OpenListTeam/OpenList/v4/internal/net" "github.com/OpenListTeam/OpenList/v4/internal/offline_download/tool" "github.com/OpenListTeam/OpenList/v4/pkg/http_range" "github.com/OpenListTeam/OpenList/v4/pkg/utils" ) type SimpleHttp struct { - client http.Client } func (s SimpleHttp) Name() string { @@ -63,7 +62,7 @@ func (s SimpleHttp) Run(task *tool.DownloadTask) error { if streamPut { req.Header.Set("Range", "bytes=0-") } - resp, err := s.client.Do(req) + resp, err := net.HttpClient().Do(req) if err != nil { return err } @@ -73,11 +72,11 @@ func (s SimpleHttp) Run(task *tool.DownloadTask) error { } filename, err := parseFilenameFromContentDisposition(resp.Header.Get("Content-Disposition")) if err != nil { - filename = path.Base(resp.Request.URL.Path) + filename, err = sanitizeFilename(resp.Request.URL.Path) } - filename = strings.Trim(filename, "/") - if len(filename) == 0 { - filename = fmt.Sprintf("%s-%d-%x", strings.ReplaceAll(req.URL.Host, ".", "_"), time.Now().UnixMilli(), rand.Uint32()) + if err != nil { + filename = strings.ReplaceAll(req.URL.Host, ":", "_") + filename = fmt.Sprintf("%s-%d-%x", filename, time.Now().UnixMilli(), rand.Uint32()) } fileSize := resp.ContentLength if streamPut { @@ -91,8 +90,14 @@ func (s SimpleHttp) Run(task *tool.DownloadTask) error { } task.SetTotalBytes(fileSize) // save to temp dir - _ = os.MkdirAll(task.TempDir, os.ModePerm) + if err := os.MkdirAll(task.TempDir, os.ModePerm); err != nil { + return err + } filePath := filepath.Join(task.TempDir, filename) + cleanTempDir := filepath.Clean(task.TempDir) + string(filepath.Separator) + if !strings.HasPrefix(filepath.Clean(filePath)+string(filepath.Separator), cleanTempDir) { + return fmt.Errorf("filename illegal") + } file, err := os.Create(filePath) if err != nil { return err diff --git a/internal/offline_download/http/util.go b/internal/offline_download/http/util.go index eefefec24..9622c58be 100644 --- a/internal/offline_download/http/util.go +++ b/internal/offline_download/http/util.go @@ -3,8 +3,22 @@ package http import ( "fmt" "mime" + "path/filepath" + "strings" ) +func sanitizeFilename(raw string) (string, error) { + raw = strings.TrimSpace(raw) + sep := string(filepath.Separator) + raw = strings.ReplaceAll(raw, "/", sep) + raw = strings.ReplaceAll(raw, "\\", sep) + filename := filepath.Base(filepath.Clean(raw)) + if filename == "." || filename == sep || !filepath.IsLocal(filename) { + return "", fmt.Errorf("invalid filename: %q", raw) + } + return filename, nil +} + func parseFilenameFromContentDisposition(contentDisposition string) (string, error) { if contentDisposition == "" { return "", fmt.Errorf("Content-Disposition is empty") @@ -14,8 +28,15 @@ func parseFilenameFromContentDisposition(contentDisposition string) (string, err return "", err } filename := params["filename"] + if filename == "" { + filename = params["filename*"] + } if filename == "" { return "", fmt.Errorf("filename not found in Content-Disposition: [%s]", contentDisposition) } + filename, err = sanitizeFilename(filename) + if err != nil { + return "", fmt.Errorf("invalid filename in Content-Disposition: [%s]", contentDisposition) + } return filename, nil } diff --git a/internal/offline_download/pikpak/pikpak.go b/internal/offline_download/pikpak/pikpak.go index f48ed9951..8a6693091 100644 --- a/internal/offline_download/pikpak/pikpak.go +++ b/internal/offline_download/pikpak/pikpak.go @@ -63,18 +63,16 @@ func (p *PikPak) AddURL(args *tool.AddUrlArgs) (string, error) { return "", fmt.Errorf("unsupported storage driver for offline download, only Pikpak is supported") } - ctx := context.Background() - - if err := op.MakeDir(ctx, storage, actualPath); err != nil { + if err := op.MakeDir(args.Ctx, storage, actualPath); err != nil { return "", err } - parentDir, err := op.GetUnwrap(ctx, storage, actualPath) + parentDir, err := op.GetUnwrap(args.Ctx, storage, actualPath) if err != nil { return "", err } - t, err := pikpakDriver.OfflineDownload(ctx, args.Url, parentDir, "") + t, err := pikpakDriver.OfflineDownload(args.Ctx, args.Url, parentDir, "") if err != nil { return "", fmt.Errorf("failed to add offline download task: %w", err) } diff --git a/internal/offline_download/thunder/thunder.go b/internal/offline_download/thunder/thunder.go index 7cbff89df..1a134f34a 100644 --- a/internal/offline_download/thunder/thunder.go +++ b/internal/offline_download/thunder/thunder.go @@ -64,18 +64,16 @@ func (t *Thunder) AddURL(args *tool.AddUrlArgs) (string, error) { return "", fmt.Errorf("unsupported storage driver for offline download, only Thunder is supported") } - ctx := context.Background() - - if err := op.MakeDir(ctx, storage, actualPath); err != nil { + if err := op.MakeDir(args.Ctx, storage, actualPath); err != nil { return "", err } - parentDir, err := op.GetUnwrap(ctx, storage, actualPath) + parentDir, err := op.GetUnwrap(args.Ctx, storage, actualPath) if err != nil { return "", err } - task, err := thunderDriver.OfflineDownload(ctx, args.Url, parentDir, "") + task, err := thunderDriver.OfflineDownload(args.Ctx, args.Url, parentDir, "") if err != nil { return "", fmt.Errorf("failed to add offline download task: %w", err) } diff --git a/internal/offline_download/thunder_browser/thunder_browser.go b/internal/offline_download/thunder_browser/thunder_browser.go index 9324d7a76..d1c767122 100644 --- a/internal/offline_download/thunder_browser/thunder_browser.go +++ b/internal/offline_download/thunder_browser/thunder_browser.go @@ -63,13 +63,11 @@ func (t *ThunderBrowser) AddURL(args *tool.AddUrlArgs) (string, error) { return "", err } - ctx := context.Background() - - if err := op.MakeDir(ctx, storage, actualPath); err != nil { + if err := op.MakeDir(args.Ctx, storage, actualPath); err != nil { return "", err } - parentDir, err := op.GetUnwrap(ctx, storage, actualPath) + parentDir, err := op.GetUnwrap(args.Ctx, storage, actualPath) if err != nil { return "", err } @@ -77,9 +75,9 @@ func (t *ThunderBrowser) AddURL(args *tool.AddUrlArgs) (string, error) { var task *thunder_browser.OfflineTask switch v := storage.(type) { case *thunder_browser.ThunderBrowser: - task, err = v.OfflineDownload(ctx, args.Url, parentDir, "") + task, err = v.OfflineDownload(args.Ctx, args.Url, parentDir, "") case *thunder_browser.ThunderBrowserExpert: - task, err = v.OfflineDownload(ctx, args.Url, parentDir, "") + task, err = v.OfflineDownload(args.Ctx, args.Url, parentDir, "") default: return "", fmt.Errorf("unsupported storage driver for offline download, only ThunderBrowser is supported") } diff --git a/internal/offline_download/thunderx/thunderx.go b/internal/offline_download/thunderx/thunderx.go index 3ba4a3680..9ba4c7eb8 100644 --- a/internal/offline_download/thunderx/thunderx.go +++ b/internal/offline_download/thunderx/thunderx.go @@ -58,18 +58,16 @@ func (t *ThunderX) AddURL(args *tool.AddUrlArgs) (string, error) { return "", fmt.Errorf("unsupported storage driver for offline download, only ThunderX is supported") } - ctx := context.Background() - - if err := op.MakeDir(ctx, storage, actualPath); err != nil { + if err := op.MakeDir(args.Ctx, storage, actualPath); err != nil { return "", err } - parentDir, err := op.GetUnwrap(ctx, storage, actualPath) + parentDir, err := op.GetUnwrap(args.Ctx, storage, actualPath) if err != nil { return "", err } - task, err := thunderXDriver.OfflineDownload(ctx, args.Url, parentDir, "") + task, err := thunderXDriver.OfflineDownload(args.Ctx, args.Url, parentDir, "") if err != nil { return "", fmt.Errorf("failed to add offline download task: %w", err) } diff --git a/internal/offline_download/tool/base.go b/internal/offline_download/tool/base.go index 6de05e5b2..823bac526 100644 --- a/internal/offline_download/tool/base.go +++ b/internal/offline_download/tool/base.go @@ -1,6 +1,8 @@ package tool import ( + "context" + "github.com/OpenListTeam/OpenList/v4/internal/model" ) @@ -9,6 +11,7 @@ type AddUrlArgs struct { UID string TempDir string Signal chan int + Ctx context.Context } type Status struct { diff --git a/internal/offline_download/tool/download.go b/internal/offline_download/tool/download.go index 5ee6ef4ff..b7f7a1a9d 100644 --- a/internal/offline_download/tool/download.go +++ b/internal/offline_download/tool/download.go @@ -54,6 +54,7 @@ func (t *DownloadTask) Run() error { t.Signal = nil }() gid, err := t.tool.AddURL(&AddUrlArgs{ + Ctx: t.Ctx(), Url: t.Url, UID: t.ID, TempDir: t.TempDir, diff --git a/internal/offline_download/transmission/client.go b/internal/offline_download/transmission/client.go index f86390fb8..49e48aa68 100644 --- a/internal/offline_download/transmission/client.go +++ b/internal/offline_download/transmission/client.go @@ -9,9 +9,11 @@ import ( "net/url" "strconv" + "github.com/OpenListTeam/OpenList/v4/drivers/base" "github.com/OpenListTeam/OpenList/v4/internal/conf" "github.com/OpenListTeam/OpenList/v4/internal/errs" "github.com/OpenListTeam/OpenList/v4/internal/model" + "github.com/OpenListTeam/OpenList/v4/internal/net" "github.com/OpenListTeam/OpenList/v4/internal/offline_download/tool" "github.com/OpenListTeam/OpenList/v4/internal/setting" "github.com/OpenListTeam/OpenList/v4/pkg/utils" @@ -84,7 +86,12 @@ func (t *Transmission) AddURL(args *tool.AddUrlArgs) (string, error) { } // http url for .torrent file if endpoint.Scheme == "http" || endpoint.Scheme == "https" { - resp, err := http.Get(args.Url) + resp, err := net.RequestHttp( + args.Ctx, + http.MethodGet, + http.Header{"User-Agent": []string{base.UserAgent}}, + args.Url, + ) if err != nil { return "", errors.Wrap(err, "failed to get .torrent file") } @@ -106,7 +113,7 @@ func (t *Transmission) AddURL(args *tool.AddUrlArgs) (string, error) { rpcPayload.Filename = &args.Url } - torrent, err := t.client.TorrentAdd(context.TODO(), rpcPayload) + torrent, err := t.client.TorrentAdd(args.Ctx, rpcPayload) if err != nil { return "", err } @@ -123,7 +130,7 @@ func (t *Transmission) Remove(task *tool.DownloadTask) error { if err != nil { return err } - err = t.client.TorrentRemove(context.TODO(), transmissionrpc.TorrentRemovePayload{ + err = t.client.TorrentRemove(task.Ctx(), transmissionrpc.TorrentRemovePayload{ IDs: []int64{gid}, DeleteLocalData: false, }) @@ -135,7 +142,7 @@ func (t *Transmission) Status(task *tool.DownloadTask) (*tool.Status, error) { if err != nil { return nil, err } - infos, err := t.client.TorrentGetAllFor(context.TODO(), []int64{gid}) + infos, err := t.client.TorrentGetAllFor(task.Ctx(), []int64{gid}) if err != nil { return nil, err } diff --git a/internal/op/fs.go b/internal/op/fs.go index 3d0705576..a628796fd 100644 --- a/internal/op/fs.go +++ b/internal/op/fs.go @@ -463,6 +463,7 @@ func Rename(ctx context.Context, storage driver.Driver, srcPath, dstName string) if model.ObjHasMask(srcRawObj, model.NoRename) { return errors.WithStack(errs.PermissionDenied) } + oldName := srcRawObj.GetName() srcObj := model.UnwrapObjName(srcRawObj) var newObj model.Obj @@ -480,19 +481,19 @@ func Rename(ctx context.Context, storage driver.Driver, srcPath, dstName string) dirKey := Key(storage, stdpath.Dir(srcPath)) if !srcRawObj.IsDir() { - Cache.linkCache.DeleteKey(stdpath.Join(dirKey, srcRawObj.GetName())) + Cache.linkCache.DeleteKey(stdpath.Join(dirKey, oldName)) Cache.linkCache.DeleteKey(stdpath.Join(dirKey, dstName)) } if !storage.Config().NoCache { if cache, exist := Cache.dirCache.Get(dirKey); exist { if srcRawObj.IsDir() { - Cache.deleteDirectoryTree(stdpath.Join(dirKey, srcRawObj.GetName())) + Cache.deleteDirectoryTree(stdpath.Join(dirKey, oldName)) } if newObj == nil { newObj = &model.ObjWrapMask{Obj: &model.ObjWrapName{Name: dstName, Obj: srcObj}, Mask: model.Temp} } newObj = wrapObjName(storage, newObj) - cache.UpdateObject(srcRawObj.GetName(), newObj) + cache.UpdateObject(oldName, newObj) } } @@ -781,7 +782,7 @@ func GetDirectUploadTools(storage driver.Driver) []string { return du.GetDirectUploadTools() } -func GetDirectUploadInfo(ctx context.Context, tool string, storage driver.Driver, dstDirPath, dstName string, fileSize int64) (any, error) { +func GetDirectUploadInfo(ctx context.Context, tool string, storage driver.Driver, dstDirPath, dstName string, fileSize int64, overwrite bool) (any, error) { du, ok := storage.(driver.DirectUploader) if !ok { return nil, errors.WithStack(errs.NotImplement) @@ -791,9 +792,15 @@ func GetDirectUploadInfo(ctx context.Context, tool string, storage driver.Driver } dstDirPath = utils.FixAndCleanPath(dstDirPath) dstPath := stdpath.Join(dstDirPath, dstName) - _, err := Get(ctx, storage, dstPath) - if err == nil { - return nil, errors.WithStack(errs.ObjectAlreadyExists) + var err error + if !overwrite { + _, err = Get(ctx, storage, dstPath) + if err == nil { + return nil, errors.WithStack(errs.ObjectAlreadyExists) + } + if !errs.IsObjectNotFound(err) { + return nil, errors.WithMessage(err, "failed to check if object exists") + } } err = MakeDir(ctx, storage, dstDirPath) if err != nil { diff --git a/internal/op/sharing.go b/internal/op/sharing.go index 5be0f8e6a..234ca415c 100644 --- a/internal/op/sharing.go +++ b/internal/op/sharing.go @@ -91,6 +91,16 @@ func GetSharingUnwrapPath(sharing *model.Sharing, path string) (unwrapPath strin if len(sharing.Files) == 0 { return "", errors.New("cannot get actual path of an invalid sharing") } + // Re-validate that the shared paths are still within the creator's current + // BasePath. This prevents access to files that fell out-of-scope after the + // creator's BasePath was changed by an admin. + if sharing.Creator != nil && !sharing.Creator.IsAdmin() { + for _, f := range sharing.Files { + if !utils.IsSubPath(sharing.Creator.BasePath, f) { + return "", errors.Errorf("sharing path [%s] is outside the creator's base path", f) + } + } + } if len(sharing.Files) == 1 { return stdpath.Join(sharing.Files[0], path), nil } diff --git a/pkg/aria2/rpc/call.go b/pkg/aria2/rpc/call.go index e8f7d4d6e..26358f1f3 100644 --- a/pkg/aria2/rpc/call.go +++ b/pkg/aria2/rpc/call.go @@ -262,8 +262,9 @@ type sendRequest struct { } var reqid = func() func() uint64 { - var id = uint64(time.Now().UnixNano()) + var id atomic.Uint64 + id.Store(uint64(time.Now().UnixNano())) return func() uint64 { - return atomic.AddUint64(&id, 1) + return id.Add(1) } }() diff --git a/pkg/errgroup/errgroup.go b/pkg/errgroup/errgroup.go index 7d72a1d8e..336af8623 100644 --- a/pkg/errgroup/errgroup.go +++ b/pkg/errgroup/errgroup.go @@ -15,7 +15,7 @@ type Group struct { ctx context.Context opts []retry.Option - success uint64 + success atomic.Uint64 wg sync.WaitGroup sem chan token @@ -41,7 +41,7 @@ func (g *Group) done() { <-g.sem } g.wg.Done() - atomic.AddUint64(&g.success, 1) + g.success.Add(1) } func (g *Group) Wait() error { @@ -140,7 +140,7 @@ func (g *Group) SetLimit(n int) *Group { } func (g *Group) Success() uint64 { - return atomic.LoadUint64(&g.success) + return g.success.Load() } func (g *Group) Err() error { diff --git a/pkg/generic_sync/map.go b/pkg/generic_sync/map.go index 96612f0cc..527b70626 100644 --- a/pkg/generic_sync/map.go +++ b/pkg/generic_sync/map.go @@ -72,12 +72,12 @@ var expunged = unsafe.Pointer(new(interface{})) // An entry is a slot in the map corresponding to a particular key. type entry[V any] struct { - // p points to the interface{} value stored for the entry. + // p points to the value stored for the entry. // // If p == nil, the entry has been deleted and m.dirty == nil. // - // If p == expunged, the entry has been deleted, m.dirty != nil, and the entry - // is missing from m.dirty. + // If p == expungedFor[V](), the entry has been deleted, m.dirty != nil, and + // the entry is missing from m.dirty. // // Otherwise, the entry is valid and recorded in m.read.m[key] and, if m.dirty // != nil, in m.dirty[key]. @@ -87,14 +87,20 @@ type entry[V any] struct { // m.dirty[key] unset. // // An entry's associated value can be updated by atomic replacement, provided - // p != expunged. If p == expunged, an entry's associated value can be updated + // p != expunged. If p == expungedFor[V](), an entry's associated value can be updated // only after first setting m.dirty[key] = e so that lookups using the dirty // map find the entry. - p unsafe.Pointer // *interface{} + p atomic.Pointer[V] +} + +func expungedFor[V any]() *V { + return (*V)(expunged) } func newEntry[V any](i V) *entry[V] { - return &entry[V]{p: unsafe.Pointer(&i)} + e := &entry[V]{} + e.p.Store(&i) + return e } // Load returns the value stored in the map for a key, or nil if no @@ -131,8 +137,8 @@ func (m *MapOf[K, V]) Has(key K) bool { } func (e *entry[V]) load() (value V, ok bool) { - p := atomic.LoadPointer(&e.p) - if p == nil || p == expunged { + p := e.p.Load() + if p == nil || p == expungedFor[V]() { return value, false } return *(*V)(p), true @@ -174,11 +180,11 @@ func (m *MapOf[K, V]) Store(key K, value V) { // unchanged. func (e *entry[V]) tryStore(i *V) bool { for { - p := atomic.LoadPointer(&e.p) - if p == expunged { + p := e.p.Load() + if p == expungedFor[V]() { return false } - if atomic.CompareAndSwapPointer(&e.p, p, unsafe.Pointer(i)) { + if e.p.CompareAndSwap(p, i) { return true } } @@ -189,14 +195,14 @@ func (e *entry[V]) tryStore(i *V) bool { // If the entry was previously expunged, it must be added to the dirty map // before m.mu is unlocked. func (e *entry[V]) unexpungeLocked() (wasExpunged bool) { - return atomic.CompareAndSwapPointer(&e.p, expunged, nil) + return e.p.CompareAndSwap(expungedFor[V](), nil) } // storeLocked unconditionally stores a value to the entry. // // The entry must be known not to be expunged. func (e *entry[V]) storeLocked(i *V) { - atomic.StorePointer(&e.p, unsafe.Pointer(i)) + e.p.Store(i) } // LoadOrStore returns the existing value for the key if present. @@ -243,24 +249,24 @@ func (m *MapOf[K, V]) LoadOrStore(key K, value V) (actual V, loaded bool) { // If the entry is expunged, tryLoadOrStore leaves the entry unchanged and // returns with ok==false. func (e *entry[V]) tryLoadOrStore(i V) (actual V, loaded, ok bool) { - p := atomic.LoadPointer(&e.p) - if p == expunged { + p := e.p.Load() + if p == expungedFor[V]() { return actual, false, false } if p != nil { return *(*V)(p), true, true } - // Copy the interface after the first load to make this method more amenable + // Copy the value after the first load to make this method more amenable // to escape analysis: if we hit the "load" path or the entry is expunged, we - // shouldn'V bother heap-allocating. + // shouldn't bother heap-allocating. ic := i for { - if atomic.CompareAndSwapPointer(&e.p, nil, unsafe.Pointer(&ic)) { + if e.p.CompareAndSwap(nil, &ic) { return i, false, true } - p = atomic.LoadPointer(&e.p) - if p == expunged { + p = e.p.Load() + if p == expungedFor[V]() { return actual, false, false } if p != nil { @@ -289,11 +295,11 @@ func (m *MapOf[K, V]) Delete(key K) { func (e *entry[V]) delete() (hadValue bool) { for { - p := atomic.LoadPointer(&e.p) - if p == nil || p == expunged { + p := e.p.Load() + if p == nil || p == expungedFor[V]() { return false } - if atomic.CompareAndSwapPointer(&e.p, p, nil) { + if e.p.CompareAndSwap(p, nil) { return true } } @@ -401,12 +407,12 @@ func (m *MapOf[K, V]) dirtyLocked() { } func (e *entry[V]) tryExpungeLocked() (isExpunged bool) { - p := atomic.LoadPointer(&e.p) + p := e.p.Load() for p == nil { - if atomic.CompareAndSwapPointer(&e.p, nil, expunged) { + if e.p.CompareAndSwap(nil, expungedFor[V]()) { return true } - p = atomic.LoadPointer(&e.p) + p = e.p.Load() } - return p == expunged + return p == expungedFor[V]() } diff --git a/pkg/singleflight/signleflight_test.go b/pkg/singleflight/signleflight_test.go index a6098333d..472fc1d06 100644 --- a/pkg/singleflight/signleflight_test.go +++ b/pkg/singleflight/signleflight_test.go @@ -112,9 +112,9 @@ func TestDoDupSuppress(t *testing.T) { var g Group[string] var wg1, wg2 sync.WaitGroup c := make(chan string, 1) - var calls int32 + var calls atomic.Int32 fn := func() (string, error) { - if atomic.AddInt32(&calls, 1) == 1 { + if calls.Add(1) == 1 { // First invocation. wg1.Done() } @@ -149,7 +149,7 @@ func TestDoDupSuppress(t *testing.T) { // least reached the line before the Do. c <- "bar" wg2.Wait() - if got := atomic.LoadInt32(&calls); got <= 0 || got >= n { + if got := calls.Load(); got <= 0 || got >= n { t.Errorf("number of calls = %d; want over 0 and less than %d", got, n) } } @@ -223,18 +223,19 @@ func TestPanicDo(t *testing.T) { } const n = 5 - waited := int32(n) - panicCount := int32(0) + waited := atomic.Int32{} + waited.Store(int32(n)) + panicCount := atomic.Int32{} done := make(chan struct{}) for i := 0; i < n; i++ { go func() { defer func() { if err := recover(); err != nil { t.Logf("Got panic: %v\n%s", err, debug.Stack()) - atomic.AddInt32(&panicCount, 1) + panicCount.Add(1) } - if atomic.AddInt32(&waited, -1) == 0 { + if waited.Add(-1) == 0 { close(done) } }() @@ -245,8 +246,9 @@ func TestPanicDo(t *testing.T) { select { case <-done: - if panicCount != n { - t.Errorf("Expect %d panic, but got %d", n, panicCount) + count := panicCount.Load() + if count != int32(n) { + t.Errorf("Expect %d panic, but got %d", n, count) } case <-time.After(time.Second): t.Fatalf("Do hangs") @@ -261,7 +263,8 @@ func TestGoexitDo(t *testing.T) { } const n = 5 - waited := int32(n) + waited := atomic.Int32{} + waited.Store(int32(n)) done := make(chan struct{}) for i := 0; i < n; i++ { go func() { @@ -270,7 +273,7 @@ func TestGoexitDo(t *testing.T) { if err != nil { t.Errorf("Error should be nil, but got: %v", err) } - if atomic.AddInt32(&waited, -1) == 0 { + if waited.Add(-1) == 0 { close(done) } }() diff --git a/pkg/task/task.go b/pkg/task/task.go index 5b634f10c..24a7232cf 100644 --- a/pkg/task/task.go +++ b/pkg/task/task.go @@ -108,11 +108,11 @@ func (t *Task[K]) Cancel() { if t.state == SUCCEEDED || t.state == CANCELED { return } + // maybe can't cancel + t.state = CANCELING if t.cancel != nil { t.cancel() } - // maybe can't cancel - t.state = CANCELING } func WithCancelCtx[K comparable](task *Task[K]) *Task[K] { diff --git a/pkg/task/task_test.go b/pkg/task/task_test.go index 94f283094..876f7b441 100644 --- a/pkg/task/task_test.go +++ b/pkg/task/task_test.go @@ -10,8 +10,9 @@ import ( ) func TestTask_Manager(t *testing.T) { + var next atomic.Uint64 tm := NewTaskManager(3, func(id *uint64) { - atomic.AddUint64(id, 1) + *id = next.Add(1) }) id := tm.Submit(WithCancelCtx(&Task[uint64]{ Name: "test", @@ -35,8 +36,9 @@ func TestTask_Manager(t *testing.T) { } func TestTask_Cancel(t *testing.T) { + var next atomic.Uint64 tm := NewTaskManager(3, func(id *uint64) { - atomic.AddUint64(id, 1) + *id = next.Add(1) }) id := tm.Submit(WithCancelCtx(&Task[uint64]{ Name: "test", @@ -63,8 +65,9 @@ func TestTask_Cancel(t *testing.T) { } func TestTask_Retry(t *testing.T) { + var next atomic.Uint64 tm := NewTaskManager(3, func(id *uint64) { - atomic.AddUint64(id, 1) + *id = next.Add(1) }) num := 0 id := tm.Submit(WithCancelCtx(&Task[uint64]{ diff --git a/pkg/utils/hash.go b/pkg/utils/hash.go index 596e61e54..b1859f3a6 100644 --- a/pkg/utils/hash.go +++ b/pkg/utils/hash.go @@ -134,7 +134,7 @@ func fromTypes(types []*HashType) map[*HashType]hash.Hash { // single multiwriter, where one write will update all // the hashers. func toMultiWriter(h map[*HashType]hash.Hash) io.Writer { - // Convert to to slice + // Convert to slice var w = make([]io.Writer, 0, len(h)) for _, v := range h { w = append(w, v) @@ -187,7 +187,7 @@ func (m *MultiHasher) Size() int64 { // A HashInfo contains hash string for one or more hashType type HashInfo struct { - h map[*HashType]string `json:"hashInfo"` + h map[*HashType]string } func NewHashInfoByMap(h map[*HashType]string) HashInfo { diff --git a/pkg/utils/io.go b/pkg/utils/io.go index f398161f6..e4c6ebaf3 100644 --- a/pkg/utils/io.go +++ b/pkg/utils/io.go @@ -189,17 +189,17 @@ func NewClosers(c ...io.Closer) Closers { type SyncClosers struct { closers []io.Closer - ref int32 + ref atomic.Int32 } // if closed, return false func (c *SyncClosers) AcquireReference() bool { - ref := atomic.AddInt32(&c.ref, 1) + ref := c.ref.Add(1) if ref > 0 { // log.Debugf("AcquireReference %p: %d", c, ref) return true } - atomic.StoreInt32(&c.ref, closersClosed) + c.ref.Store(closersClosed) return false } @@ -207,16 +207,16 @@ const closersClosed = math.MinInt32 func (c *SyncClosers) Close() error { for { - ref := atomic.LoadInt32(&c.ref) + ref := c.ref.Load() if ref < 0 { return nil } if ref > 1 { - if atomic.CompareAndSwapInt32(&c.ref, ref, ref-1) { + if c.ref.CompareAndSwap(ref, ref-1) { // log.Debugf("ReleaseReference %p: %d", c, ref) return nil } - } else if atomic.CompareAndSwapInt32(&c.ref, ref, closersClosed) { + } else if c.ref.CompareAndSwap(ref, closersClosed) { break } } @@ -235,7 +235,7 @@ func (c *SyncClosers) Close() error { func (c *SyncClosers) Add(closer io.Closer) { if closer != nil { - if atomic.LoadInt32(&c.ref) < 0 { + if c.ref.Load() < 0 { panic("Not reusable") } c.closers = append(c.closers, closer) @@ -244,7 +244,7 @@ func (c *SyncClosers) Add(closer io.Closer) { func (c *SyncClosers) AddIfCloser(a any) { if closer, ok := a.(io.Closer); ok { - if atomic.LoadInt32(&c.ref) < 0 { + if c.ref.Load() < 0 { panic("Not reusable") } c.closers = append(c.closers, closer) @@ -255,7 +255,7 @@ var _ ClosersIF = (*SyncClosers)(nil) // 实现cache.Expirable接口 func (c *SyncClosers) Expired() bool { - return atomic.LoadInt32(&c.ref) < 0 + return c.ref.Load() < 0 } func (c *SyncClosers) Length() int { return len(c.closers) diff --git a/pkg/utils/path.go b/pkg/utils/path.go index 4ac3dde24..0890cf2a7 100644 --- a/pkg/utils/path.go +++ b/pkg/utils/path.go @@ -111,13 +111,13 @@ func GetPathHierarchy(path string) []string { hierarchy := []string{"/"} - parts := strings.SplitSeq(path, "/") var currentPath strings.Builder - for part := range parts { + for part := range strings.SplitSeq(path, "/") { if part == "" { continue } - currentPath.WriteString("/" + part) + currentPath.WriteString("/") + currentPath.WriteString(part) hierarchy = append(hierarchy, currentPath.String()) } diff --git a/renovate.json b/renovate.json index 39a2b6e9a..48515b9fc 100644 --- a/renovate.json +++ b/renovate.json @@ -1,6 +1,6 @@ { "$schema": "https://docs.renovatebot.com/renovate-schema.json", "extends": [ - "config:base" + "config:best-practices" ] } diff --git a/server/handles/direct_upload.go b/server/handles/direct_upload.go index 69cfd2fa8..d77c3044c 100644 --- a/server/handles/direct_upload.go +++ b/server/handles/direct_upload.go @@ -2,8 +2,10 @@ package handles import ( "net/url" + stdpath "path" "github.com/OpenListTeam/OpenList/v4/internal/conf" + "github.com/OpenListTeam/OpenList/v4/internal/errs" "github.com/OpenListTeam/OpenList/v4/internal/fs" "github.com/OpenListTeam/OpenList/v4/internal/model" "github.com/OpenListTeam/OpenList/v4/server/common" @@ -38,15 +40,29 @@ func FsGetDirectUploadInfo(c *gin.Context) { common.ErrorResp(c, err, 403) return } + if err := checkRelativePath(req.FileName); err != nil { + common.ErrorResp(c, err, 403) + return + } overwrite := c.GetHeader("Overwrite") != "false" + dstPath := stdpath.Join(path, req.FileName) if !overwrite { - if res, _ := fs.Get(c.Request.Context(), path, &fs.GetArgs{NoLog: true}); res != nil { + res, err := fs.Get(c.Request.Context(), dstPath, &fs.GetArgs{NoLog: true}) + if err != nil && !errs.IsObjectNotFound(err) { + common.ErrorResp(c, err, 500) + return + } + if res != nil { common.ErrorStrResp(c, "file exists", 403) return } } - directUploadInfo, err := fs.GetDirectUploadInfo(c, req.Tool, path, req.FileName, req.FileSize) + directUploadInfo, err := fs.GetDirectUploadInfo(c, req.Tool, path, req.FileName, req.FileSize, overwrite) if err != nil { + if !overwrite && errs.IsObjectAlreadyExists(err) { + common.ErrorStrResp(c, "file exists", 403) + return + } common.ErrorResp(c, err, 500) return } diff --git a/server/handles/index.go b/server/handles/index.go index cd270f6f9..78096ddb7 100644 --- a/server/handles/index.go +++ b/server/handles/index.go @@ -2,6 +2,7 @@ package handles import ( "context" + "time" "github.com/OpenListTeam/OpenList/v4/internal/conf" "github.com/OpenListTeam/OpenList/v4/internal/model" @@ -55,18 +56,38 @@ func UpdateIndex(c *gin.Context) { } go func() { ctx := context.Background() + progress, err := search.Progress() + if err != nil { + log.Errorf("get index progress error: %+v", err) + progress = &model.IndexProgress{} + } for _, path := range req.Paths { - err := search.Del(ctx, path) + err = search.Del(ctx, path) if err != nil { log.Errorf("delete index on %s error: %+v", path, err) return } } - err := search.BuildIndex(context.Background(), req.Paths, + err = search.BuildIndex(context.Background(), req.Paths, conf.SlicesMap[conf.IgnorePaths], req.MaxDepth, false) if err != nil { log.Errorf("update index error: %+v", err) + now := time.Now() + search.WriteProgress(&model.IndexProgress{ + ObjCount: progress.ObjCount, + IsDone: true, + LastDoneTime: &now, + Error: err.Error(), + }) + return } + now := time.Now() + search.WriteProgress(&model.IndexProgress{ + ObjCount: progress.ObjCount, + IsDone: true, + LastDoneTime: &now, + Error: "", + }) }() common.SuccessResp(c) } diff --git a/server/handles/search.go b/server/handles/search.go index 32e4e9558..40b005ac1 100644 --- a/server/handles/search.go +++ b/server/handles/search.go @@ -2,7 +2,6 @@ package handles import ( "path" - "strings" "github.com/OpenListTeam/OpenList/v4/internal/conf" "github.com/OpenListTeam/OpenList/v4/internal/errs" @@ -51,7 +50,7 @@ func Search(c *gin.Context) { } var filteredNodes []model.SearchNode for _, node := range nodes { - if !strings.HasPrefix(node.Parent, user.BasePath) { + if !utils.IsSubPath(user.BasePath, node.Parent) { continue } meta, err := op.GetNearestMeta(node.Parent) diff --git a/server/handles/sharing.go b/server/handles/sharing.go index 6090fcbd9..ded8a20df 100644 --- a/server/handles/sharing.go +++ b/server/handles/sharing.go @@ -461,7 +461,7 @@ func UpdateSharing(c *gin.Context) { for i, s := range req.Files { s = utils.FixAndCleanPath(s) req.Files[i] = s - if !reqUser.IsAdmin() && !strings.HasPrefix(s, user.BasePath) { + if !reqUser.IsAdmin() && !utils.IsSubPath(user.BasePath, s) { common.ErrorStrResp(c, fmt.Sprintf("permission denied to share path [%s]", s), 500) return } @@ -545,7 +545,7 @@ func CreateSharing(c *gin.Context) { for i, s := range req.Files { s = utils.FixAndCleanPath(s) req.Files[i] = s - if !reqUser.IsAdmin() && !strings.HasPrefix(s, user.BasePath) { + if !reqUser.IsAdmin() && !utils.IsSubPath(user.BasePath, s) { common.ErrorStrResp(c, fmt.Sprintf("permission denied to share path [%s]", s), 500) return } diff --git a/server/handles/webauthn.go b/server/handles/webauthn.go index b2a0fbfb4..6848020fd 100644 --- a/server/handles/webauthn.go +++ b/server/handles/webauthn.go @@ -133,7 +133,10 @@ func BeginAuthnRegistration(c *gin.Context) { return } - options, sessionData, err := authnInstance.BeginRegistration(user) + options, sessionData, err := authnInstance.BeginRegistration( + user, + webauthn.WithResidentKeyRequirement(protocol.ResidentKeyRequirementRequired), + ) if err != nil { common.ErrorResp(c, err, 400) diff --git a/server/mcp.go b/server/mcp.go new file mode 100644 index 000000000..c036ce389 --- /dev/null +++ b/server/mcp.go @@ -0,0 +1,18 @@ +package server + +import ( + "github.com/OpenListTeam/OpenList/v4/internal/conf" + "github.com/OpenListTeam/OpenList/v4/server/common" + "github.com/OpenListTeam/OpenList/v4/server/mcp" + "github.com/gin-gonic/gin" +) + +func MCP(g *gin.RouterGroup) { + if !conf.Conf.MCP.Enable { + g.Any("/mcp", func(c *gin.Context) { + common.ErrorStrResp(c, "MCP server is not enabled", 403) + }) + return + } + mcp.Register(g) +} diff --git a/server/mcp/call.go b/server/mcp/call.go new file mode 100644 index 000000000..5a19b9b4b --- /dev/null +++ b/server/mcp/call.go @@ -0,0 +1,88 @@ +package mcp + +import ( + "encoding/json" + "net/http" + + "github.com/gin-gonic/gin" +) + +type toolCallParams struct { + Name string `json:"name"` + Arguments json.RawMessage `json:"arguments"` +} + +type toolResultContent struct { + Type string `json:"type"` + Text string `json:"text"` +} + +func (s *Server) handleToolsCall(c *gin.Context, req request) (int, response) { + var params toolCallParams + if len(req.Params) == 0 { + return http.StatusBadRequest, response{ + JSONRPC: "2.0", + ID: req.ID, + Error: &rpcError{Code: -32602, Message: "invalid tools/call params"}, + } + } + if err := json.Unmarshal(req.Params, ¶ms); err != nil || params.Name == "" { + return http.StatusBadRequest, response{ + JSONRPC: "2.0", + ID: req.ID, + Error: &rpcError{Code: -32602, Message: "invalid tools/call params"}, + } + } + + var ( + result any + err *rpcError + ) + switch params.Name { + case "openlist.fs.list": + result, err = s.callFSList(c, params.Arguments) + case "openlist.fs.get": + result, err = s.callFSGet(c, params.Arguments) + case "openlist.fs.link": + result, err = s.callFSLink(c, params.Arguments) + default: + return http.StatusOK, response{ + JSONRPC: "2.0", + ID: req.ID, + Error: &rpcError{Code: -32601, Message: "unknown tool"}, + } + } + + if err != nil { + return http.StatusOK, response{ + JSONRPC: "2.0", + ID: req.ID, + Result: map[string]any{ + "content": []toolResultContent{ + {Type: "text", Text: err.Message}, + }, + "isError": true, + }, + } + } + + resultJSON, marshalErr := json.Marshal(result) + if marshalErr != nil { + return http.StatusInternalServerError, response{ + JSONRPC: "2.0", + ID: req.ID, + Error: &rpcError{Code: -32603, Message: "failed to encode tool result"}, + } + } + + return http.StatusOK, response{ + JSONRPC: "2.0", + ID: req.ID, + Result: map[string]any{ + "content": []toolResultContent{ + {Type: "text", Text: string(resultJSON)}, + }, + "structuredContent": result, + }, + } +} diff --git a/server/mcp/call_test.go b/server/mcp/call_test.go new file mode 100644 index 000000000..b922ffd52 --- /dev/null +++ b/server/mcp/call_test.go @@ -0,0 +1,183 @@ +package mcp + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/OpenListTeam/OpenList/v4/internal/conf" + "github.com/OpenListTeam/OpenList/v4/internal/model" + "github.com/OpenListTeam/OpenList/v4/server/common" + "github.com/gin-gonic/gin" +) + +func TestToolsListRequiresInitializedSession(t *testing.T) { + gin.SetMode(gin.TestMode) + srv := newTestServer(map[string]*session{ + "s1": {id: "s1", userID: 1}, + }) + + r := gin.New() + r.POST("/mcp", func(c *gin.Context) { + common.GinAppendValues(c, conf.UserKey, &model.User{ID: 1, Role: model.ADMIN}) + srv.handlePost(c) + }) + + req := httptest.NewRequest(http.MethodPost, "http://example.com/mcp", strings.NewReader(`{ + "jsonrpc":"2.0", + "id":1, + "method":"tools/list" + }`)) + req.Header.Set("Accept", "application/json, text/event-stream") + req.Header.Set("Origin", "http://example.com") + req.Header.Set(ProtocolVersionHeader, ProtocolVersion) + req.Header.Set(SessionHeader, "s1") + + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("unexpected status: got %d want %d", w.Code, http.StatusBadRequest) + } + resp := decodeResponse(t, w) + if resp.Error == nil || resp.Error.Code != -32002 { + t.Fatalf("unexpected error response: %+v", resp.Error) + } +} + +func TestToolsListSuccess(t *testing.T) { + gin.SetMode(gin.TestMode) + srv := newTestServer(map[string]*session{ + "s2": {id: "s2", userID: 1, initialized: true}, + }) + + r := gin.New() + r.POST("/mcp", func(c *gin.Context) { + common.GinAppendValues(c, conf.UserKey, &model.User{ID: 1, Role: model.ADMIN}) + srv.handlePost(c) + }) + + req := httptest.NewRequest(http.MethodPost, "http://example.com/mcp", strings.NewReader(`{ + "jsonrpc":"2.0", + "id":2, + "method":"tools/list" + }`)) + req.Header.Set("Accept", "application/json, text/event-stream") + req.Header.Set("Origin", "http://example.com") + req.Header.Set(ProtocolVersionHeader, ProtocolVersion) + req.Header.Set(SessionHeader, "s2") + + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("unexpected status: got %d want %d", w.Code, http.StatusOK) + } + resp := decodeResponse(t, w) + if resp.Error != nil { + t.Fatalf("unexpected error response: %+v", resp.Error) + } + + result, ok := resp.Result.(map[string]any) + if !ok { + t.Fatalf("unexpected result type: %T", resp.Result) + } + tools, ok := result["tools"].([]any) + if !ok || len(tools) != 3 { + t.Fatalf("unexpected tools payload: %#v", result["tools"]) + } + names := map[string]bool{} + for _, rawTool := range tools { + currentTool, ok := rawTool.(map[string]any) + if !ok { + t.Fatalf("unexpected tool payload: %#v", rawTool) + } + name, _ := currentTool["name"].(string) + names[name] = true + } + if !names["openlist.fs.list"] || !names["openlist.fs.get"] || !names["openlist.fs.link"] { + t.Fatalf("unexpected tool names: %#v", names) + } +} + +func TestToolsCallUnknownTool(t *testing.T) { + gin.SetMode(gin.TestMode) + srv := newTestServer(map[string]*session{ + "s3": {id: "s3", userID: 1, initialized: true}, + }) + + r := gin.New() + r.POST("/mcp", func(c *gin.Context) { + common.GinAppendValues(c, conf.UserKey, &model.User{ID: 1, Role: model.ADMIN}) + srv.handlePost(c) + }) + + req := httptest.NewRequest(http.MethodPost, "http://example.com/mcp", strings.NewReader(`{ + "jsonrpc":"2.0", + "id":3, + "method":"tools/call", + "params":{"name":"openlist.fs.unknown","arguments":{"path":"/"}} + }`)) + req.Header.Set("Accept", "application/json, text/event-stream") + req.Header.Set("Origin", "http://example.com") + req.Header.Set(ProtocolVersionHeader, ProtocolVersion) + req.Header.Set(SessionHeader, "s3") + + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("unexpected status: got %d want %d", w.Code, http.StatusOK) + } + resp := decodeResponse(t, w) + if resp.Error == nil || resp.Error.Code != -32601 { + t.Fatalf("unexpected error response: %+v", resp.Error) + } +} + +func TestToolsCallInvalidParams(t *testing.T) { + gin.SetMode(gin.TestMode) + srv := newTestServer(map[string]*session{ + "s4": {id: "s4", userID: 1, initialized: true}, + }) + + r := gin.New() + r.POST("/mcp", func(c *gin.Context) { + common.GinAppendValues(c, conf.UserKey, &model.User{ID: 1, Role: model.ADMIN}) + srv.handlePost(c) + }) + + req := httptest.NewRequest(http.MethodPost, "http://example.com/mcp", strings.NewReader(`{ + "jsonrpc":"2.0", + "id":4, + "method":"tools/call", + "params":{"name":"openlist.fs.list","arguments":"bad"} + }`)) + req.Header.Set("Accept", "application/json, text/event-stream") + req.Header.Set("Origin", "http://example.com") + req.Header.Set(ProtocolVersionHeader, ProtocolVersion) + req.Header.Set(SessionHeader, "s4") + + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("unexpected status: got %d want %d", w.Code, http.StatusOK) + } + resp := decodeResponse(t, w) + if resp.Error != nil { + t.Fatalf("expected tool error result, got protocol error: %+v", resp.Error) + } +} + +func decodeResponse(t *testing.T, w *httptest.ResponseRecorder) response { + t.Helper() + + var resp response + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + return resp +} diff --git a/server/mcp/fs_get.go b/server/mcp/fs_get.go new file mode 100644 index 000000000..550cc036f --- /dev/null +++ b/server/mcp/fs_get.go @@ -0,0 +1,168 @@ +package mcp + +import ( + "context" + "encoding/json" + "fmt" + stdpath "path" + "strings" + + "github.com/OpenListTeam/OpenList/v4/internal/conf" + "github.com/OpenListTeam/OpenList/v4/internal/errs" + "github.com/OpenListTeam/OpenList/v4/internal/fs" + "github.com/OpenListTeam/OpenList/v4/internal/model" + "github.com/OpenListTeam/OpenList/v4/internal/op" + "github.com/OpenListTeam/OpenList/v4/internal/setting" + "github.com/OpenListTeam/OpenList/v4/internal/sign" + "github.com/OpenListTeam/OpenList/v4/pkg/utils" + "github.com/OpenListTeam/OpenList/v4/server/common" + "github.com/OpenListTeam/OpenList/v4/server/handles" + "github.com/gin-gonic/gin" + "github.com/pkg/errors" +) + +type fsGetArgs struct { + Path string `json:"path"` + Password string `json:"password"` +} + +func (s *Server) callFSGet(c *gin.Context, raw json.RawMessage) (any, *rpcError) { + args, mcpErr := parseFSGetArgs(raw) + if mcpErr != nil { + return nil, mcpErr + } + + user, ok := c.Request.Context().Value(conf.UserKey).(*model.User) + if !ok || user == nil { + return nil, &rpcError{Code: -32603, Message: "missing user context"} + } + if user.IsGuest() && user.Disabled { + return nil, &rpcError{Code: -32001, Message: "guest user is disabled"} + } + + reqPath, err := user.JoinPath(args.Path) + if err != nil { + return nil, &rpcError{Code: -32003, Message: err.Error()} + } + + meta, err := op.GetNearestMeta(reqPath) + if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) { + return nil, &rpcError{Code: -32603, Message: err.Error()} + } + if !common.CanAccess(user, meta, reqPath, args.Password) { + return nil, &rpcError{Code: -32003, Message: "password is incorrect or you have no permission"} + } + + ctx := context.WithValue(c.Request.Context(), conf.MetaKey, meta) + obj, err := fs.Get(ctx, reqPath, &fs.GetArgs{ + WithStorageDetails: !user.IsGuest() && !setting.GetBool(conf.HideStorageDetails), + }) + if err != nil { + return nil, &rpcError{Code: -32603, Message: err.Error()} + } + + rawURL, provider, err := buildFSGetRawURL(ctx, c, reqPath, obj, meta) + if err != nil { + return nil, &rpcError{Code: -32603, Message: err.Error()} + } + + parentPath := stdpath.Dir(reqPath) + var related []model.Obj + sameLevelFiles, err := fs.List(ctx, parentPath, &fs.ListArgs{}) + if err == nil { + related = filterRelatedObjs(sameLevelFiles, obj) + } + + parentMeta, _ := op.GetNearestMeta(parentPath) + thumb, _ := model.GetThumb(obj) + mountDetails, _ := model.GetStorageDetails(obj) + return handles.FsGetResp{ + ObjResp: handles.ObjResp{ + Name: obj.GetName(), + Size: obj.GetSize(), + IsDir: obj.IsDir(), + Modified: obj.ModTime(), + Created: obj.CreateTime(), + Sign: common.Sign(obj, parentPath, isEncrypt(meta, reqPath)), + Thumb: thumb, + Type: utils.GetFileType(obj.GetName()), + HashInfoStr: obj.GetHash().String(), + HashInfo: obj.GetHash().Export(), + MountDetails: mountDetails, + }, + RawURL: rawURL, + Readme: getReadme(meta, reqPath), + Header: getHeader(meta, reqPath), + Provider: provider, + Related: toObjResp(related, parentPath, isEncrypt(parentMeta, parentPath)), + }, nil +} + +func parseFSGetArgs(raw json.RawMessage) (*fsGetArgs, *rpcError) { + args := &fsGetArgs{} + if len(raw) == 0 || string(raw) == "null" { + return nil, &rpcError{Code: -32602, Message: "invalid openlist.fs.get arguments"} + } + + if err := json.Unmarshal(raw, args); err != nil { + return nil, &rpcError{Code: -32602, Message: "invalid openlist.fs.get arguments"} + } + if args.Path == "" { + return nil, &rpcError{Code: -32602, Message: "path is required"} + } + return args, nil +} + +func buildFSGetRawURL(ctx context.Context, c *gin.Context, reqPath string, obj model.Obj, meta *model.Meta) (string, string, error) { + storage, storageErr := fs.GetStorage(reqPath, &fs.GetStoragesArgs{}) + provider, ok := model.GetProvider(obj) + if !ok && storageErr == nil { + provider = storage.Config().Name + } + if obj.IsDir() { + return "", provider, nil + } + if storageErr != nil { + return "", provider, storageErr + } + + if storage.Config().MustProxy() || storage.GetStorage().WebProxy { + rawURL := common.GenerateDownProxyURL(storage.GetStorage(), reqPath) + if rawURL != "" { + return rawURL, provider, nil + } + query := "" + if isEncrypt(meta, reqPath) || setting.GetBool(conf.SignAll) { + query = "?sign=" + sign.Sign(reqPath) + } + return fmt.Sprintf("%s/p%s%s", common.GetApiUrl(ctx), utils.EncodePath(reqPath, true), query), provider, nil + } + + if url, ok := model.GetUrl(obj); ok { + return url, provider, nil + } + link, _, err := fs.Link(ctx, reqPath, model.LinkArgs{ + IP: c.ClientIP(), + Header: c.Request.Header, + Redirect: true, + }) + if err != nil { + return "", provider, err + } + defer link.Close() + return link.URL, provider, nil +} + +func filterRelatedObjs(objs []model.Obj, obj model.Obj) []model.Obj { + related := make([]model.Obj, 0) + nameWithoutExt := strings.TrimSuffix(obj.GetName(), stdpath.Ext(obj.GetName())) + for _, current := range objs { + if current.GetName() == obj.GetName() { + continue + } + if strings.HasPrefix(current.GetName(), nameWithoutExt) { + related = append(related, current) + } + } + return related +} diff --git a/server/mcp/fs_link.go b/server/mcp/fs_link.go new file mode 100644 index 000000000..43ddad852 --- /dev/null +++ b/server/mcp/fs_link.go @@ -0,0 +1,200 @@ +package mcp + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + stdpath "path" + "time" + + "github.com/OpenListTeam/OpenList/v4/internal/conf" + "github.com/OpenListTeam/OpenList/v4/internal/driver" + "github.com/OpenListTeam/OpenList/v4/internal/errs" + "github.com/OpenListTeam/OpenList/v4/internal/fs" + "github.com/OpenListTeam/OpenList/v4/internal/model" + "github.com/OpenListTeam/OpenList/v4/internal/op" + "github.com/OpenListTeam/OpenList/v4/internal/setting" + "github.com/OpenListTeam/OpenList/v4/internal/sign" + "github.com/OpenListTeam/OpenList/v4/pkg/utils" + "github.com/OpenListTeam/OpenList/v4/server/common" + "github.com/gin-gonic/gin" + "github.com/pkg/errors" +) + +type fsLinkArgs struct { + Path string `json:"path"` + Password string `json:"password"` + Type string `json:"type"` +} + +type fsLinkResp struct { + Path string `json:"path"` + Name string `json:"name"` + Size int64 `json:"size"` + IsDir bool `json:"is_dir"` + Modified time.Time `json:"modified"` + Provider string `json:"provider"` + URL string `json:"url"` + URLType string `json:"url_type"` + DirectURL string `json:"direct_url,omitempty"` + ProxyURL string `json:"proxy_url,omitempty"` + DownloadURL string `json:"download_url,omitempty"` + Header http.Header `json:"header,omitempty"` + ContentLength int64 `json:"content_length,omitempty"` + Concurrency int `json:"concurrency,omitempty"` + PartSize int `json:"part_size,omitempty"` +} + +func (s *Server) callFSLink(c *gin.Context, raw json.RawMessage) (any, *rpcError) { + args, mcpErr := parseFSLinkArgs(raw) + if mcpErr != nil { + return nil, mcpErr + } + + user, ok := c.Request.Context().Value(conf.UserKey).(*model.User) + if !ok || user == nil { + return nil, &rpcError{Code: -32603, Message: "missing user context"} + } + if user.IsGuest() && user.Disabled { + return nil, &rpcError{Code: -32001, Message: "guest user is disabled"} + } + + reqPath, err := user.JoinPath(args.Path) + if err != nil { + return nil, &rpcError{Code: -32003, Message: err.Error()} + } + + meta, err := op.GetNearestMeta(reqPath) + if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) { + return nil, &rpcError{Code: -32603, Message: err.Error()} + } + if !common.CanAccess(user, meta, reqPath, args.Password) { + return nil, &rpcError{Code: -32003, Message: "password is incorrect or you have no permission"} + } + + ctx := context.WithValue(c.Request.Context(), conf.MetaKey, meta) + obj, err := fs.Get(ctx, reqPath, &fs.GetArgs{ + WithStorageDetails: !user.IsGuest() && !setting.GetBool(conf.HideStorageDetails), + }) + if err != nil { + return nil, &rpcError{Code: -32603, Message: err.Error()} + } + if obj.IsDir() { + return nil, &rpcError{Code: -32003, Message: "path is a directory"} + } + + storage, err := fs.GetStorage(reqPath, &fs.GetStoragesArgs{}) + if err != nil { + return nil, &rpcError{Code: -32603, Message: err.Error()} + } + + linkInfo, err := buildFSLinkInfo(ctx, c, reqPath, args, obj, meta, storage) + if err != nil { + return nil, &rpcError{Code: -32603, Message: err.Error()} + } + return linkInfo, nil +} + +func parseFSLinkArgs(raw json.RawMessage) (*fsLinkArgs, *rpcError) { + args := &fsLinkArgs{} + if len(raw) == 0 || string(raw) == "null" { + return nil, &rpcError{Code: -32602, Message: "invalid openlist.fs.link arguments"} + } + if err := json.Unmarshal(raw, args); err != nil { + return nil, &rpcError{Code: -32602, Message: "invalid openlist.fs.link arguments"} + } + if args.Path == "" { + return nil, &rpcError{Code: -32602, Message: "path is required"} + } + return args, nil +} + +func buildFSLinkInfo(ctx context.Context, c *gin.Context, reqPath string, args *fsLinkArgs, obj model.Obj, meta *model.Meta, storage driver.Driver) (*fsLinkResp, error) { + provider, ok := model.GetProvider(obj) + if !ok { + provider = storage.Config().Name + } + + resp := &fsLinkResp{ + Path: reqPath, + Name: obj.GetName(), + Size: obj.GetSize(), + IsDir: obj.IsDir(), + Modified: obj.ModTime(), + Provider: provider, + DownloadURL: signedFileURL(ctx, "/d", reqPath, meta, args.Type), + } + + if canProxyFile(storage, stdpath.Base(reqPath)) { + proxyURL := proxyFileURL(ctx, reqPath, meta, storage.GetStorage(), args.Type) + resp.ProxyURL = proxyURL + } + + if common.ShouldProxy(storage, stdpath.Base(reqPath)) { + resp.URL = resp.ProxyURL + resp.URLType = "proxy" + return resp, nil + } + + link, _, err := fs.Link(ctx, reqPath, model.LinkArgs{ + IP: c.ClientIP(), + Header: c.Request.Header, + Type: args.Type, + Redirect: true, + }) + if err != nil { + return nil, err + } + defer link.Close() + + resp.DirectURL = link.URL + resp.URL = link.URL + resp.URLType = "direct" + resp.Header = link.Header + resp.ContentLength = link.ContentLength + resp.Concurrency = link.Concurrency + resp.PartSize = link.PartSize + return resp, nil +} + +func canProxyFile(storage driver.Driver, filename string) bool { + if storage.Config().MustProxy() || storage.GetStorage().WebProxy { + return true + } + if utils.SliceContains(conf.SlicesMap[conf.ProxyTypes], utils.Ext(filename)) { + return true + } + if utils.SliceContains(conf.SlicesMap[conf.TextTypes], utils.Ext(filename)) { + return true + } + return false +} + +func proxyFileURL(ctx context.Context, reqPath string, meta *model.Meta, storage *model.Storage, linkType string) string { + if url := common.GenerateDownProxyURL(storage, reqPath); url != "" { + return url + } + return signedFileURL(ctx, "/p", reqPath, meta, linkType) +} + +func signedFileURL(ctx context.Context, prefix, reqPath string, meta *model.Meta, linkType string) string { + query := url.Values{} + if isEncrypt(meta, reqPath) || setting.GetBool(conf.SignAll) { + query.Set("sign", sign.Sign(reqPath)) + } + if linkType != "" { + query.Set("type", linkType) + } + rawQuery := "" + if encoded := query.Encode(); encoded != "" { + rawQuery = "?" + encoded + } + return fmt.Sprintf("%s%s%s%s", + common.GetApiUrl(ctx), + prefix, + utils.EncodePath(reqPath, true), + rawQuery, + ) +} diff --git a/server/mcp/fs_list.go b/server/mcp/fs_list.go new file mode 100644 index 000000000..ac28cf756 --- /dev/null +++ b/server/mcp/fs_list.go @@ -0,0 +1,182 @@ +package mcp + +import ( + "context" + "encoding/json" + + "github.com/OpenListTeam/OpenList/v4/internal/conf" + "github.com/OpenListTeam/OpenList/v4/internal/errs" + "github.com/OpenListTeam/OpenList/v4/internal/fs" + "github.com/OpenListTeam/OpenList/v4/internal/model" + "github.com/OpenListTeam/OpenList/v4/internal/op" + "github.com/OpenListTeam/OpenList/v4/internal/setting" + "github.com/OpenListTeam/OpenList/v4/pkg/utils" + "github.com/OpenListTeam/OpenList/v4/server/common" + "github.com/OpenListTeam/OpenList/v4/server/handles" + "github.com/gin-gonic/gin" + "github.com/pkg/errors" +) + +type fsListArgs struct { + Path string `json:"path"` + Password string `json:"password"` + Refresh bool `json:"refresh"` + Page int `json:"page"` + PerPage int `json:"per_page"` +} + +func (s *Server) callFSList(c *gin.Context, raw json.RawMessage) (any, *rpcError) { + args, mcpErr := parseFSListArgs(raw) + if mcpErr != nil { + return nil, mcpErr + } + + user, ok := c.Request.Context().Value(conf.UserKey).(*model.User) + if !ok || user == nil { + return nil, &rpcError{Code: -32603, Message: "missing user context"} + } + if user.IsGuest() && user.Disabled { + return nil, &rpcError{Code: -32001, Message: "guest user is disabled"} + } + + reqPath, err := user.JoinPath(args.Path) + if err != nil { + return nil, &rpcError{Code: -32003, Message: err.Error()} + } + + meta, err := op.GetNearestMeta(reqPath) + if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) { + return nil, &rpcError{Code: -32603, Message: err.Error()} + } + if !common.CanAccess(user, meta, reqPath, args.Password) { + return nil, &rpcError{Code: -32003, Message: "password is incorrect or you have no permission"} + } + + write := common.CanWrite(user, meta, reqPath) + writeContentBypass := common.CanWriteContentBypassUserPerms(meta, reqPath) + canWriteContentAtPath := write && (user.CanWriteContent() || writeContentBypass) + if args.Refresh && !canWriteContentAtPath { + return nil, &rpcError{Code: -32003, Message: "refresh without permission"} + } + + ctx := context.WithValue(c.Request.Context(), conf.MetaKey, meta) + objs, err := fs.List(ctx, reqPath, &fs.ListArgs{ + Refresh: args.Refresh, + WithStorageDetails: !user.IsGuest() && !setting.GetBool(conf.HideStorageDetails), + }) + if err != nil { + return nil, &rpcError{Code: -32603, Message: err.Error()} + } + + total, paged := paginateObjs(objs, args.Page, args.PerPage) + return handles.FsListResp{ + Content: toObjResp(paged, reqPath, isEncrypt(meta, reqPath)), + Total: int64(total), + Write: write, + WriteContentBypass: writeContentBypass, + Provider: "unknown", + Readme: getReadme(meta, reqPath), + Header: getHeader(meta, reqPath), + }, nil +} + +func parseFSListArgs(raw json.RawMessage) (*fsListArgs, *rpcError) { + args := &fsListArgs{ + Page: 1, + PerPage: model.MaxInt, + } + if len(raw) == 0 || string(raw) == "null" { + return nil, &rpcError{Code: -32602, Message: "invalid openlist.fs.list arguments"} + } + + if err := json.Unmarshal(raw, args); err != nil { + return nil, &rpcError{Code: -32602, Message: "invalid openlist.fs.list arguments"} + } + if args.Path == "" { + return nil, &rpcError{Code: -32602, Message: "path is required"} + } + normalizeFSListArgs(args) + return args, nil +} + +func normalizeFSListArgs(args *fsListArgs) { + pageReq := model.PageReq{ + Page: args.Page, + PerPage: args.PerPage, + } + pageReq.Validate() + args.Page = pageReq.Page + args.PerPage = pageReq.PerPage +} + +func paginateObjs(objs []model.Obj, page, perPage int) (int, []model.Obj) { + total := len(objs) + if page < 1 { + page = 1 + } + if perPage < 1 { + perPage = model.MaxInt + } + offset := page - 1 + if offset > total/perPage { + return total, []model.Obj{} + } + start := offset * perPage + if start > total { + return total, []model.Obj{} + } + end := total + if perPage <= total-start { + end = start + perPage + } + if end > total { + end = total + } + return total, objs[start:end] +} + +func toObjResp(objs []model.Obj, parent string, encrypt bool) []handles.ObjResp { + resp := make([]handles.ObjResp, 0, len(objs)) + for _, obj := range objs { + thumb, _ := model.GetThumb(obj) + mountDetails, _ := model.GetStorageDetails(obj) + resp = append(resp, handles.ObjResp{ + Name: obj.GetName(), + Size: obj.GetSize(), + IsDir: obj.IsDir(), + Modified: obj.ModTime(), + Created: obj.CreateTime(), + Sign: common.Sign(obj, parent, encrypt), + Thumb: thumb, + Type: utils.GetObjType(obj.GetName(), obj.IsDir()), + HashInfoStr: obj.GetHash().String(), + HashInfo: obj.GetHash().Export(), + MountDetails: mountDetails, + }) + } + return resp +} + +func getReadme(meta *model.Meta, path string) string { + if meta != nil && common.MetaCoversPath(meta.Path, path, meta.RSub) { + return meta.Readme + } + return "" +} + +func getHeader(meta *model.Meta, path string) string { + if meta != nil && common.MetaCoversPath(meta.Path, path, meta.HeaderSub) { + return meta.Header + } + return "" +} + +func isEncrypt(meta *model.Meta, path string) bool { + if common.IsStorageSignEnabled(path) { + return true + } + if meta == nil || meta.Password == "" { + return false + } + return common.MetaCoversPath(meta.Path, path, meta.PSub) +} diff --git a/server/mcp/get_test.go b/server/mcp/get_test.go new file mode 100644 index 000000000..81fbe3900 --- /dev/null +++ b/server/mcp/get_test.go @@ -0,0 +1,39 @@ +package mcp + +import ( + "encoding/json" + "testing" +) + +func TestParseFSGetArgsRequiresPath(t *testing.T) { + _, err := parseFSGetArgs(json.RawMessage(`{"password":"secret"}`)) + if err == nil { + t.Fatal("expected error") + } + if err.Code != -32602 { + t.Fatalf("unexpected error: %+v", err) + } +} + +func TestParseFSGetArgs(t *testing.T) { + args, err := parseFSGetArgs(json.RawMessage(`{"path":"/movie.mkv","password":"secret"}`)) + if err != nil { + t.Fatalf("unexpected error: %+v", err) + } + if args.Path != "/movie.mkv" { + t.Fatalf("unexpected path: %q", args.Path) + } + if args.Password != "secret" { + t.Fatalf("unexpected password: %q", args.Password) + } +} + +func TestParseFSGetArgsRejectsInvalidJSON(t *testing.T) { + _, err := parseFSGetArgs(json.RawMessage(`"bad"`)) + if err == nil { + t.Fatal("expected error") + } + if err.Code != -32602 { + t.Fatalf("unexpected error: %+v", err) + } +} diff --git a/server/mcp/handler.go b/server/mcp/handler.go new file mode 100644 index 000000000..191977eb4 --- /dev/null +++ b/server/mcp/handler.go @@ -0,0 +1,536 @@ +package mcp + +import ( + "encoding/json" + "fmt" + "io" + "mime" + "net/http" + "net/url" + "strconv" + "strings" + "sync" + "time" + + "github.com/OpenListTeam/OpenList/v4/internal/conf" + "github.com/OpenListTeam/OpenList/v4/internal/model" + "github.com/OpenListTeam/OpenList/v4/pkg/utils/random" + "github.com/OpenListTeam/OpenList/v4/server/common" + "github.com/OpenListTeam/OpenList/v4/server/middlewares" + "github.com/gin-gonic/gin" +) + +const ( + ProtocolVersion = "2025-11-25" + ProtocolVersionHeader = "MCP-Protocol-Version" + SessionHeader = "MCP-Session-Id" + sessionTTL = 30 * time.Minute + maxSessions = 128 + maxUserSessions = 16 +) + +type session struct { + id string + userID uint + protocolVersion string + initialized bool + createdAt time.Time + lastUsedAt time.Time +} + +type Server struct { + mu sync.Mutex + sessions map[string]*session +} + +type request struct { + JSONRPC string `json:"jsonrpc"` + ID any `json:"id,omitempty"` + Method string `json:"method"` + Params json.RawMessage `json:"params,omitempty"` +} + +type response struct { + JSONRPC string `json:"jsonrpc"` + ID any `json:"id"` + Result any `json:"result,omitempty"` + Error *rpcError `json:"error,omitempty"` +} + +type rpcError struct { + Code int `json:"code"` + Message string `json:"message"` +} + +type initializeParams struct { + ProtocolVersion string `json:"protocolVersion"` + Capabilities map[string]any `json:"capabilities"` + ClientInfo map[string]any `json:"clientInfo"` +} + +var defaultServer = &Server{ + sessions: map[string]*session{}, +} + +var supportedProtocolVersions = map[string]struct{}{ + "2025-11-25": {}, + "2025-06-18": {}, +} + +func Register(g *gin.RouterGroup) { + mcpGroup := g.Group("/mcp", middlewares.Auth(false), middlewares.AuthAdmin) + mcpGroup.GET("", defaultServer.handleGet) + mcpGroup.POST("", defaultServer.handlePost) + mcpGroup.DELETE("", defaultServer.handleDelete) +} + +func (s *Server) handleGet(c *gin.Context) { + if !validateOrigin(c.Request) { + c.Status(http.StatusForbidden) + return + } + c.Header("Allow", "POST, DELETE") + c.Status(http.StatusMethodNotAllowed) +} + +func (s *Server) handlePost(c *gin.Context) { + if !validateOrigin(c.Request) { + c.Status(http.StatusForbidden) + return + } + if !acceptsStreamableHTTP(c.GetHeader("Accept")) { + c.JSON(http.StatusNotAcceptable, response{ + JSONRPC: "2.0", + Error: &rpcError{ + Code: -32000, + Message: "Not Acceptable: client must accept both application/json and text/event-stream", + }, + }) + return + } + + body, err := io.ReadAll(io.LimitReader(c.Request.Body, 1<<20)) + if err != nil { + c.JSON(http.StatusBadRequest, response{ + JSONRPC: "2.0", + Error: &rpcError{Code: -32700, Message: "failed to read request body"}, + }) + return + } + + var req request + if err := json.Unmarshal(body, &req); err != nil { + c.JSON(http.StatusBadRequest, response{ + JSONRPC: "2.0", + Error: &rpcError{Code: -32700, Message: "parse error"}, + }) + return + } + if req.JSONRPC != "2.0" { + c.JSON(http.StatusBadRequest, response{ + JSONRPC: "2.0", + ID: req.ID, + Error: &rpcError{Code: -32600, Message: "invalid request"}, + }) + return + } + + if req.Method == "initialize" { + s.handleInitialize(c, req) + return + } + sessionID := c.GetHeader(SessionHeader) + if sessionID == "" { + c.JSON(http.StatusBadRequest, response{ + JSONRPC: "2.0", + ID: req.ID, + Error: &rpcError{Code: -32000, Message: "missing MCP session"}, + }) + return + } + + currentSession, ok := s.getSession(sessionID) + if !ok { + c.JSON(http.StatusNotFound, response{ + JSONRPC: "2.0", + ID: req.ID, + Error: &rpcError{Code: -32001, Message: "session not found"}, + }) + return + } + + user := c.Request.Context().Value(conf.UserKey).(*model.User) + if currentSession.userID != user.ID { + c.JSON(http.StatusNotFound, response{ + JSONRPC: "2.0", + ID: req.ID, + Error: &rpcError{Code: -32001, Message: "session not found"}, + }) + return + } + + if !s.validateRequestProtocolVersion(c.GetHeader(ProtocolVersionHeader), currentSession.protocolVersion) { + c.JSON(http.StatusBadRequest, response{ + JSONRPC: "2.0", + ID: req.ID, + Error: &rpcError{Code: -32000, Message: "missing or unsupported MCP protocol version"}, + }) + return + } + + switch req.Method { + case "ping": + c.JSON(http.StatusOK, response{JSONRPC: "2.0", ID: req.ID, Result: map[string]any{}}) + case "notifications/initialized": + s.markSessionInitialized(sessionID) + c.Status(http.StatusAccepted) + case "tools/list": + if !s.sessionInitialized(sessionID) { + c.JSON(http.StatusBadRequest, response{ + JSONRPC: "2.0", + ID: req.ID, + Error: &rpcError{Code: -32002, Message: "MCP session not initialized"}, + }) + return + } + c.JSON(http.StatusOK, s.handleToolsList(req)) + case "tools/call": + if !s.sessionInitialized(sessionID) { + c.JSON(http.StatusBadRequest, response{ + JSONRPC: "2.0", + ID: req.ID, + Error: &rpcError{Code: -32002, Message: "MCP session not initialized"}, + }) + return + } + status, resp := s.handleToolsCall(c, req) + c.JSON(status, resp) + default: + c.JSON(http.StatusOK, response{ + JSONRPC: "2.0", + ID: req.ID, + Error: &rpcError{Code: -32601, Message: fmt.Sprintf("method %q not implemented yet", req.Method)}, + }) + } +} + +func (s *Server) handleInitialize(c *gin.Context, req request) { + var params initializeParams + if len(req.Params) > 0 { + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + c.JSON(http.StatusBadRequest, response{ + JSONRPC: "2.0", + ID: req.ID, + Error: &rpcError{Code: -32602, Message: "invalid initialize params"}, + }) + return + } + } + + protocolVersion := negotiateProtocolVersion(params.ProtocolVersion) + currentSession := s.initializeSession( + c.Request.Context().Value(conf.UserKey).(*model.User).ID, + c.GetHeader(SessionHeader), + protocolVersion, + ) + c.Header(SessionHeader, currentSession.id) + c.JSON(http.StatusOK, response{ + JSONRPC: "2.0", + ID: req.ID, + Result: map[string]any{ + "protocolVersion": protocolVersion, + "capabilities": map[string]any{ + "tools": map[string]any{ + "listChanged": false, + }, + }, + "serverInfo": map[string]any{ + "name": "OpenList MCP", + "version": conf.Version, + }, + "instructions": "Complete initialization with notifications/initialized, then use tools/list and tools/call. Available tools include openlist.fs.list, openlist.fs.get, and openlist.fs.link.", + }, + }) +} + +func (s *Server) initializeSession(userID uint, requestedID string, protocolVersion string) *session { + s.mu.Lock() + defer s.mu.Unlock() + + now := time.Now() + s.pruneExpiredSessionsLocked(now) + if requestedID != "" { + currentSession, ok := s.sessions[requestedID] + if ok && currentSession != nil && currentSession.userID == userID { + currentSession.initialized = false + currentSession.protocolVersion = protocolVersion + currentSession.lastUsedAt = now + return currentSession + } + } + + return s.createSessionLocked(userID, protocolVersion, now) +} + +func (s *Server) handleDelete(c *gin.Context) { + if !validateOrigin(c.Request) { + c.Status(http.StatusForbidden) + return + } + + currentSession, ok := s.getSession(c.GetHeader(SessionHeader)) + if !ok { + c.Status(http.StatusNotFound) + return + } + + user := c.Request.Context().Value(conf.UserKey).(*model.User) + if currentSession.userID != user.ID { + c.Status(http.StatusNotFound) + return + } + + s.deleteSession(currentSession.id) + c.Status(http.StatusNoContent) +} + +func (s *Server) createSession(userID uint) *session { + s.mu.Lock() + defer s.mu.Unlock() + + now := time.Now() + s.pruneExpiredSessionsLocked(now) + return s.createSessionLocked(userID, ProtocolVersion, now) +} + +func (s *Server) createSessionLocked(userID uint, protocolVersion string, now time.Time) *session { + s.pruneLeastRecentlyUsedUserSessionsLocked(userID, max(0, s.countUserSessionsLocked(userID)-maxUserSessions+1)) + s.pruneLeastRecentlyUsedSessionsLocked(max(0, len(s.sessions)-maxSessions+1)) + currentSession := &session{ + id: random.Token(), + userID: userID, + protocolVersion: protocolVersion, + createdAt: now, + lastUsedAt: now, + } + s.sessions[currentSession.id] = currentSession + return currentSession +} + +func (s *Server) validateRequestProtocolVersion(requestedVersion string, negotiatedVersion string) bool { + if requestedVersion == "" { + _, ok := supportedProtocolVersions[negotiatedVersion] + return ok + } + if _, ok := supportedProtocolVersions[requestedVersion]; !ok { + return false + } + return negotiatedVersion == "" || requestedVersion == negotiatedVersion +} + +func negotiateProtocolVersion(requestedVersion string) string { + if _, ok := supportedProtocolVersions[requestedVersion]; ok { + return requestedVersion + } + return ProtocolVersion +} + +func (s *Server) getSession(id string) (session, bool) { + if id == "" { + return session{}, false + } + s.mu.Lock() + defer s.mu.Unlock() + currentSession, ok := s.sessions[id] + if !ok || currentSession == nil { + return session{}, false + } + now := time.Now() + if sessionExpired(currentSession, now) { + delete(s.sessions, id) + return session{}, false + } + currentSession.lastUsedAt = now + return *currentSession, true +} + +func (s *Server) markSessionInitialized(id string) bool { + s.mu.Lock() + defer s.mu.Unlock() + currentSession, ok := s.sessions[id] + if !ok || currentSession == nil { + return false + } + now := time.Now() + if sessionExpired(currentSession, now) { + delete(s.sessions, id) + return false + } + currentSession.initialized = true + currentSession.lastUsedAt = now + return true +} + +func (s *Server) sessionInitialized(id string) bool { + s.mu.Lock() + defer s.mu.Unlock() + currentSession, ok := s.sessions[id] + if !ok || currentSession == nil { + return false + } + now := time.Now() + if sessionExpired(currentSession, now) { + delete(s.sessions, id) + return false + } + currentSession.lastUsedAt = now + return currentSession.initialized +} + +func (s *Server) deleteSession(id string) { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.sessions, id) +} + +func (s *Server) pruneExpiredSessionsLocked(now time.Time) { + for id, currentSession := range s.sessions { + if currentSession == nil || sessionExpired(currentSession, now) { + delete(s.sessions, id) + } + } +} + +func (s *Server) pruneLeastRecentlyUsedSessionsLocked(count int) { + for range count { + var ( + oldestID string + oldest time.Time + ) + for id, currentSession := range s.sessions { + if currentSession == nil { + oldestID = id + break + } + lastUsedAt := sessionLastUsedAt(currentSession) + if oldestID == "" || lastUsedAt.Before(oldest) { + oldestID = id + oldest = lastUsedAt + } + } + if oldestID == "" { + return + } + delete(s.sessions, oldestID) + } +} + +func (s *Server) countUserSessionsLocked(userID uint) int { + count := 0 + for _, currentSession := range s.sessions { + if currentSession != nil && currentSession.userID == userID { + count++ + } + } + return count +} + +func (s *Server) pruneLeastRecentlyUsedUserSessionsLocked(userID uint, count int) { + for range count { + var ( + oldestID string + oldest time.Time + ) + for id, currentSession := range s.sessions { + if currentSession == nil { + continue + } + if currentSession.userID != userID { + continue + } + lastUsedAt := sessionLastUsedAt(currentSession) + if oldestID == "" || lastUsedAt.Before(oldest) { + oldestID = id + oldest = lastUsedAt + } + } + if oldestID == "" { + return + } + delete(s.sessions, oldestID) + } +} + +func sessionExpired(currentSession *session, now time.Time) bool { + lastUsedAt := sessionLastUsedAt(currentSession) + return !lastUsedAt.IsZero() && now.Sub(lastUsedAt) > sessionTTL +} + +func sessionLastUsedAt(currentSession *session) time.Time { + if currentSession.lastUsedAt.IsZero() { + return currentSession.createdAt + } + return currentSession.lastUsedAt +} + +func acceptsStreamableHTTP(accept string) bool { + if accept == "" { + return false + } + hasJSON := false + hasSSE := false + for part := range strings.SplitSeq(accept, ",") { + mediaType, params, err := mime.ParseMediaType(strings.TrimSpace(part)) + if err != nil { + continue + } + if q, ok := params["q"]; ok { + quality, err := strconv.ParseFloat(q, 64) + if err == nil && quality == 0 { + continue + } + } + switch mediaType { + case "*/*": + hasJSON = true + hasSSE = true + case "application/*", "application/json": + hasJSON = true + case "text/*", "text/event-stream": + hasSSE = true + } + } + return hasJSON || hasSSE +} + +func validateOrigin(r *http.Request) bool { + origin := r.Header.Get("Origin") + if origin == "" { + return true + } + + originURL, err := url.Parse(origin) + if err != nil || originURL.Host == "" { + return false + } + if strings.EqualFold(originURL.Host, r.Host) { + return strings.EqualFold(originURL.Scheme, requestScheme(r)) + } + + siteURL := common.GetApiUrlFromRequest(r) + if siteURL == "" { + return false + } + siteParsed, err := url.Parse(siteURL) + if err != nil { + return false + } + return strings.EqualFold(originURL.Host, siteParsed.Host) && strings.EqualFold(originURL.Scheme, siteParsed.Scheme) +} + +func requestScheme(r *http.Request) string { + if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" { + return "https" + } + return "http" +} diff --git a/server/mcp/handler_test.go b/server/mcp/handler_test.go new file mode 100644 index 000000000..d3369576b --- /dev/null +++ b/server/mcp/handler_test.go @@ -0,0 +1,569 @@ +package mcp + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/OpenListTeam/OpenList/v4/internal/conf" + "github.com/OpenListTeam/OpenList/v4/internal/model" + "github.com/OpenListTeam/OpenList/v4/server/common" + "github.com/gin-gonic/gin" +) + +func TestInitializeCreatesSession(t *testing.T) { + gin.SetMode(gin.TestMode) + srv := newTestServer(nil) + + r := gin.New() + r.POST("/mcp", func(c *gin.Context) { + common.GinAppendValues(c, conf.UserKey, &model.User{ID: 1, Role: model.ADMIN}) + srv.handlePost(c) + }) + + req := httptest.NewRequest(http.MethodPost, "http://example.com/mcp", strings.NewReader(`{ + "jsonrpc":"2.0", + "id":1, + "method":"initialize", + "params":{ + "protocolVersion":"2025-11-25", + "capabilities":{}, + "clientInfo":{"name":"test-client","version":"1.0.0"} + } + }`)) + req.Header.Set("Accept", "application/json, text/event-stream") + req.Header.Set("Origin", "http://example.com") + + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("unexpected status: got %d want %d", w.Code, http.StatusOK) + } + if got := w.Header().Get(SessionHeader); got == "" { + t.Fatal("expected session header to be set") + } + + var resp response + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if resp.Error != nil { + t.Fatalf("unexpected error response: %+v", resp.Error) + } + result, ok := resp.Result.(map[string]any) + if !ok { + t.Fatalf("unexpected result type: %T", resp.Result) + } + if result["protocolVersion"] != ProtocolVersion { + t.Fatalf("unexpected protocol version: %v", result["protocolVersion"]) + } +} + +func TestInitializeNegotiatesSupportedOlderProtocolVersion(t *testing.T) { + gin.SetMode(gin.TestMode) + srv := newTestServer(nil) + + r := gin.New() + r.POST("/mcp", func(c *gin.Context) { + common.GinAppendValues(c, conf.UserKey, &model.User{ID: 1, Role: model.ADMIN}) + srv.handlePost(c) + }) + + req := httptest.NewRequest(http.MethodPost, "http://example.com/mcp", strings.NewReader(`{ + "jsonrpc":"2.0", + "id":1, + "method":"initialize", + "params":{ + "protocolVersion":"2025-06-18", + "capabilities":{}, + "clientInfo":{"name":"test-client","version":"1.0.0"} + } + }`)) + req.Header.Set("Accept", "application/json, text/event-stream") + req.Header.Set("Origin", "http://example.com") + + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("unexpected status: got %d want %d", w.Code, http.StatusOK) + } + resp := decodeResponse(t, w) + if resp.Error != nil { + t.Fatalf("unexpected error response: %+v", resp.Error) + } + result, ok := resp.Result.(map[string]any) + if !ok { + t.Fatalf("unexpected result type: %T", resp.Result) + } + if result["protocolVersion"] != "2025-06-18" { + t.Fatalf("unexpected protocol version: %v", result["protocolVersion"]) + } +} + +func TestInitializeReusesExistingSession(t *testing.T) { + gin.SetMode(gin.TestMode) + srv := newTestServer(nil) + currentSession := srv.createSession(1) + currentSession.initialized = true + + r := gin.New() + r.POST("/mcp", func(c *gin.Context) { + common.GinAppendValues(c, conf.UserKey, &model.User{ID: 1, Role: model.ADMIN}) + srv.handlePost(c) + }) + + req := httptest.NewRequest(http.MethodPost, "http://example.com/mcp", strings.NewReader(`{ + "jsonrpc":"2.0", + "id":1, + "method":"initialize", + "params":{ + "protocolVersion":"2025-06-18", + "capabilities":{}, + "clientInfo":{"name":"test-client","version":"1.0.0"} + } + }`)) + req.Header.Set("Accept", "application/json, text/event-stream") + req.Header.Set("Origin", "http://example.com") + req.Header.Set(SessionHeader, currentSession.id) + + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("unexpected status: got %d want %d", w.Code, http.StatusOK) + } + if got := w.Header().Get(SessionHeader); got != currentSession.id { + t.Fatalf("unexpected session header: got %q want %q", got, currentSession.id) + } + if len(srv.sessions) != 1 { + t.Fatalf("unexpected session count: got %d want %d", len(srv.sessions), 1) + } + reusedSession, ok := srv.getSession(currentSession.id) + if !ok { + t.Fatal("expected existing session to be reused") + } + if reusedSession.initialized { + t.Fatal("expected reused session to require initialized notification again") + } + if reusedSession.protocolVersion != "2025-06-18" { + t.Fatalf("unexpected protocol version: got %q want %q", reusedSession.protocolVersion, "2025-06-18") + } +} + +func TestInitializeNegotiatesUnsupportedProtocolVersion(t *testing.T) { + gin.SetMode(gin.TestMode) + srv := newTestServer(nil) + + r := gin.New() + r.POST("/mcp", func(c *gin.Context) { + common.GinAppendValues(c, conf.UserKey, &model.User{ID: 1, Role: model.ADMIN}) + srv.handlePost(c) + }) + + req := httptest.NewRequest(http.MethodPost, "http://example.com/mcp", strings.NewReader(`{ + "jsonrpc":"2.0", + "id":1, + "method":"initialize", + "params":{ + "protocolVersion":"2026-01-01", + "capabilities":{}, + "clientInfo":{"name":"test-client","version":"1.0.0"} + } + }`)) + req.Header.Set("Accept", "application/json, text/event-stream") + req.Header.Set("Origin", "http://example.com") + + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("unexpected status: got %d want %d", w.Code, http.StatusOK) + } + resp := decodeResponse(t, w) + if resp.Error != nil { + t.Fatalf("unexpected error response: %+v", resp.Error) + } + result, ok := resp.Result.(map[string]any) + if !ok { + t.Fatalf("unexpected result type: %T", resp.Result) + } + if result["protocolVersion"] != ProtocolVersion { + t.Fatalf("unexpected protocol version: %v", result["protocolVersion"]) + } +} + +func TestCreateSessionPrunesUserSessionLimit(t *testing.T) { + srv := newTestServer(nil) + var firstSessionID string + for i := range maxUserSessions { + currentSession := srv.createSession(1) + if i == 0 { + firstSessionID = currentSession.id + } + } + srv.createSession(2) + srv.createSession(1) + + if _, ok := srv.sessions[firstSessionID]; ok { + t.Fatal("expected oldest user session to be pruned") + } + if got := countSessionsForUser(srv, 1); got != maxUserSessions { + t.Fatalf("unexpected user session count: got %d want %d", got, maxUserSessions) + } + if got := len(srv.sessions); got != maxUserSessions+1 { + t.Fatalf("unexpected total session count: got %d want %d", got, maxUserSessions+1) + } +} + +func TestCreateSessionPrunesGlobalSessionLimit(t *testing.T) { + srv := newTestServer(nil) + var firstSessionID string + for i := range maxSessions { + currentSession := srv.createSession(uint(i + 1)) + if i == 0 { + firstSessionID = currentSession.id + } + } + srv.createSession(uint(maxSessions + 1)) + + if _, ok := srv.sessions[firstSessionID]; ok { + t.Fatal("expected oldest global session to be pruned") + } + if got := len(srv.sessions); got != maxSessions { + t.Fatalf("unexpected session count: got %d want %d", got, maxSessions) + } +} + +func TestPostAcceptsJSONOnly(t *testing.T) { + gin.SetMode(gin.TestMode) + srv := newTestServer(nil) + + r := gin.New() + r.POST("/mcp", func(c *gin.Context) { + common.GinAppendValues(c, conf.UserKey, &model.User{ID: 1, Role: model.ADMIN}) + srv.handlePost(c) + }) + + req := httptest.NewRequest(http.MethodPost, "http://example.com/mcp", strings.NewReader(`{ + "jsonrpc":"2.0", + "id":1, + "method":"initialize" + }`)) + req.Header.Set("Accept", "application/json") + req.Header.Set("Origin", "http://example.com") + + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("unexpected status: got %d want %d", w.Code, http.StatusOK) + } + resp := decodeResponse(t, w) + if resp.Error != nil { + t.Fatalf("unexpected error response: %+v", resp.Error) + } +} + +func TestPostAcceptsWildcardAccept(t *testing.T) { + gin.SetMode(gin.TestMode) + srv := newTestServer(nil) + + r := gin.New() + r.POST("/mcp", func(c *gin.Context) { + common.GinAppendValues(c, conf.UserKey, &model.User{ID: 1, Role: model.ADMIN}) + srv.handlePost(c) + }) + + req := httptest.NewRequest(http.MethodPost, "http://example.com/mcp", strings.NewReader(`{ + "jsonrpc":"2.0", + "id":1, + "method":"initialize" + }`)) + req.Header.Set("Accept", "*/*") + req.Header.Set("Origin", "http://example.com") + + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("unexpected status: got %d want %d", w.Code, http.StatusOK) + } + resp := decodeResponse(t, w) + if resp.Error != nil { + t.Fatalf("unexpected error response: %+v", resp.Error) + } +} + +func TestPostInvalidAcceptReturnsJSONRPCError(t *testing.T) { + gin.SetMode(gin.TestMode) + srv := newTestServer(nil) + + r := gin.New() + r.POST("/mcp", func(c *gin.Context) { + common.GinAppendValues(c, conf.UserKey, &model.User{ID: 1, Role: model.ADMIN}) + srv.handlePost(c) + }) + + req := httptest.NewRequest(http.MethodPost, "http://example.com/mcp", strings.NewReader(`{ + "jsonrpc":"2.0", + "id":1, + "method":"initialize" + }`)) + req.Header.Set("Accept", "image/png") + req.Header.Set("Origin", "http://example.com") + + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusNotAcceptable { + t.Fatalf("unexpected status: got %d want %d", w.Code, http.StatusNotAcceptable) + } + resp := decodeResponse(t, w) + if resp.Error == nil || resp.Error.Code != -32000 { + t.Fatalf("unexpected error response: %+v", resp.Error) + } +} + +func TestPostUsesNegotiatedProtocolVersionWhenHeaderMissing(t *testing.T) { + gin.SetMode(gin.TestMode) + srv := newTestServer(map[string]*session{ + "s1": {id: "s1", userID: 1, protocolVersion: "2025-06-18", initialized: true}, + }) + + r := gin.New() + r.POST("/mcp", func(c *gin.Context) { + common.GinAppendValues(c, conf.UserKey, &model.User{ID: 1, Role: model.ADMIN}) + srv.handlePost(c) + }) + + req := httptest.NewRequest(http.MethodPost, "http://example.com/mcp", strings.NewReader(`{ + "jsonrpc":"2.0", + "id":1, + "method":"tools/list" + }`)) + req.Header.Set("Accept", "application/json, text/event-stream") + req.Header.Set("Origin", "http://example.com") + req.Header.Set(SessionHeader, "s1") + + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("unexpected status: got %d want %d", w.Code, http.StatusOK) + } + resp := decodeResponse(t, w) + if resp.Error != nil { + t.Fatalf("unexpected error response: %+v", resp.Error) + } +} + +func TestPostRejectsUnsupportedProtocolVersion(t *testing.T) { + gin.SetMode(gin.TestMode) + srv := newTestServer(map[string]*session{ + "s1": {id: "s1", userID: 1, initialized: true}, + }) + + r := gin.New() + r.POST("/mcp", func(c *gin.Context) { + common.GinAppendValues(c, conf.UserKey, &model.User{ID: 1, Role: model.ADMIN}) + srv.handlePost(c) + }) + + req := httptest.NewRequest(http.MethodPost, "http://example.com/mcp", strings.NewReader(`{ + "jsonrpc":"2.0", + "id":1, + "method":"tools/list" + }`)) + req.Header.Set("Accept", "application/json, text/event-stream") + req.Header.Set("Origin", "http://example.com") + req.Header.Set(ProtocolVersionHeader, "2025-03-26") + req.Header.Set(SessionHeader, "s1") + + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("unexpected status: got %d want %d", w.Code, http.StatusBadRequest) + } + resp := decodeResponse(t, w) + if resp.Error == nil || resp.Error.Code != -32000 { + t.Fatalf("unexpected error response: %+v", resp.Error) + } +} + +func TestPostRejectsProtocolVersionMismatch(t *testing.T) { + gin.SetMode(gin.TestMode) + srv := newTestServer(map[string]*session{ + "s1": {id: "s1", userID: 1, protocolVersion: "2025-06-18", initialized: true}, + }) + + r := gin.New() + r.POST("/mcp", func(c *gin.Context) { + common.GinAppendValues(c, conf.UserKey, &model.User{ID: 1, Role: model.ADMIN}) + srv.handlePost(c) + }) + + req := httptest.NewRequest(http.MethodPost, "http://example.com/mcp", strings.NewReader(`{ + "jsonrpc":"2.0", + "id":1, + "method":"tools/list" + }`)) + req.Header.Set("Accept", "application/json, text/event-stream") + req.Header.Set("Origin", "http://example.com") + req.Header.Set(ProtocolVersionHeader, ProtocolVersion) + req.Header.Set(SessionHeader, "s1") + + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("unexpected status: got %d want %d", w.Code, http.StatusBadRequest) + } + resp := decodeResponse(t, w) + if resp.Error == nil || resp.Error.Code != -32000 { + t.Fatalf("unexpected error response: %+v", resp.Error) + } +} + +func TestPostRejectsMissingSession(t *testing.T) { + gin.SetMode(gin.TestMode) + srv := newTestServer(nil) + + r := gin.New() + r.POST("/mcp", func(c *gin.Context) { + common.GinAppendValues(c, conf.UserKey, &model.User{ID: 1, Role: model.ADMIN}) + srv.handlePost(c) + }) + + req := httptest.NewRequest(http.MethodPost, "http://example.com/mcp", strings.NewReader(`{ + "jsonrpc":"2.0", + "id":1, + "method":"tools/list" + }`)) + req.Header.Set("Accept", "application/json, text/event-stream") + req.Header.Set("Origin", "http://example.com") + req.Header.Set(ProtocolVersionHeader, ProtocolVersion) + + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("unexpected status: got %d want %d", w.Code, http.StatusBadRequest) + } + resp := decodeResponse(t, w) + if resp.Error == nil || resp.Error.Code != -32000 { + t.Fatalf("unexpected error response: %+v", resp.Error) + } +} + +func TestPostRejectsUnknownSessionWithNotFound(t *testing.T) { + gin.SetMode(gin.TestMode) + srv := newTestServer(nil) + + r := gin.New() + r.POST("/mcp", func(c *gin.Context) { + common.GinAppendValues(c, conf.UserKey, &model.User{ID: 1, Role: model.ADMIN}) + srv.handlePost(c) + }) + + req := httptest.NewRequest(http.MethodPost, "http://example.com/mcp", strings.NewReader(`{ + "jsonrpc":"2.0", + "id":1, + "method":"tools/list" + }`)) + req.Header.Set("Accept", "application/json, text/event-stream") + req.Header.Set("Origin", "http://example.com") + req.Header.Set(ProtocolVersionHeader, ProtocolVersion) + req.Header.Set(SessionHeader, "unknown") + + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Fatalf("unexpected status: got %d want %d", w.Code, http.StatusNotFound) + } + resp := decodeResponse(t, w) + if resp.Error == nil || resp.Error.Code != -32001 { + t.Fatalf("unexpected error response: %+v", resp.Error) + } +} + +func TestDeleteRemovesSession(t *testing.T) { + gin.SetMode(gin.TestMode) + srv := newTestServer(nil) + + currentSession := srv.createSession(1) + r := gin.New() + r.DELETE("/mcp", func(c *gin.Context) { + common.GinAppendValues(c, conf.UserKey, &model.User{ID: 1, Role: model.ADMIN}) + srv.handleDelete(c) + }) + + req := httptest.NewRequest(http.MethodDelete, "http://example.com/mcp", nil) + req.Header.Set("Origin", "http://example.com") + req.Header.Set(SessionHeader, currentSession.id) + + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusNoContent { + t.Fatalf("unexpected status: got %d want %d", w.Code, http.StatusNoContent) + } + if _, ok := srv.getSession(currentSession.id); ok { + t.Fatal("expected session to be deleted") + } +} + +func TestGetReturnsMethodNotAllowed(t *testing.T) { + gin.SetMode(gin.TestMode) + + r := gin.New() + r.GET("/mcp", func(c *gin.Context) { + common.GinAppendValues(c, conf.UserKey, &model.User{ID: 1, Role: model.ADMIN}) + defaultServer.handleGet(c) + }) + + req := httptest.NewRequest(http.MethodGet, "http://example.com/mcp", nil) + req.Header.Set("Origin", "http://example.com") + + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusMethodNotAllowed { + t.Fatalf("unexpected status: got %d want %d", w.Code, http.StatusMethodNotAllowed) + } + if allow := w.Header().Get("Allow"); allow != "POST, DELETE" { + t.Fatalf("unexpected Allow header: got %q want %q", allow, "POST, DELETE") + } +} + +func newTestServer(sessions map[string]*session) *Server { + if sessions == nil { + sessions = map[string]*session{} + } + now := time.Now() + for _, currentSession := range sessions { + if currentSession.createdAt.IsZero() { + currentSession.createdAt = now + } + if currentSession.lastUsedAt.IsZero() { + currentSession.lastUsedAt = now + } + } + return &Server{sessions: sessions} +} + +func countSessionsForUser(srv *Server, userID uint) int { + count := 0 + for _, currentSession := range srv.sessions { + if currentSession != nil && currentSession.userID == userID { + count++ + } + } + return count +} diff --git a/server/mcp/link_test.go b/server/mcp/link_test.go new file mode 100644 index 000000000..10896d80c --- /dev/null +++ b/server/mcp/link_test.go @@ -0,0 +1,143 @@ +package mcp + +import ( + "context" + "encoding/json" + "net/http/httptest" + "sync" + "testing" + + "github.com/OpenListTeam/OpenList/v4/internal/conf" + "github.com/OpenListTeam/OpenList/v4/internal/driver" + "github.com/OpenListTeam/OpenList/v4/internal/model" + "github.com/OpenListTeam/OpenList/v4/internal/op" + "github.com/gin-gonic/gin" +) + +var settingCacheMu sync.Mutex + +func TestParseFSLinkArgs(t *testing.T) { + args, err := parseFSLinkArgs(json.RawMessage(`{"path":"/file.txt","password":"pw","type":"thumb"}`)) + if err != nil { + t.Fatalf("unexpected error: %+v", err) + } + if args.Path != "/file.txt" || args.Password != "pw" || args.Type != "thumb" { + t.Fatalf("unexpected args: %+v", args) + } +} + +func TestParseFSLinkArgsRequiresPath(t *testing.T) { + _, err := parseFSLinkArgs(json.RawMessage(`{"type":"thumb"}`)) + if err == nil || err.Code != -32602 { + t.Fatalf("unexpected error: %+v", err) + } +} + +func TestCanProxyFile(t *testing.T) { + storage := &fsLinkTestDriver{ + config: driver.Config{Name: "Test"}, + storage: model.Storage{ + MountPath: "/", + }, + } + if canProxyFile(storage, "file.bin") { + t.Fatal("unexpected proxy support") + } + storage.config.OnlyProxy = true + if !canProxyFile(storage, "file.bin") { + t.Fatal("expected proxy support") + } +} + +func TestCanProxyFileIgnoresWebdavProxyURLPolicy(t *testing.T) { + storage := &fsLinkTestDriver{ + config: driver.Config{Name: "Test"}, + storage: model.Storage{ + MountPath: "/", + Proxy: model.Proxy{ + WebdavPolicy: "use_proxy_url", + }, + }, + } + + if canProxyFile(storage, "file.bin") { + t.Fatal("webdav proxy url policy should not enable MCP proxy support") + } +} + +func TestBuildFSLinkInfoUsesProxyWhenStorageRequiresProxy(t *testing.T) { + gin.SetMode(gin.TestMode) + settingCacheMu.Lock() + t.Cleanup(func() { + op.Cache.ClearAll() + settingCacheMu.Unlock() + }) + op.Cache.SetSetting(conf.LinkExpiration, &model.SettingItem{Key: conf.LinkExpiration, Value: "0"}) + op.Cache.SetSetting(conf.Token, &model.SettingItem{Key: conf.Token, Value: "test-token"}) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest("POST", "http://example.com/mcp", nil) + ctx := context.WithValue(c.Request.Context(), conf.ApiUrlKey, "http://openlist.test") + storage := &fsLinkTestDriver{ + config: driver.Config{Name: "Test", OnlyProxy: true}, + storage: model.Storage{ + MountPath: "/", + Proxy: model.Proxy{ + DownProxyURL: "http://proxy.test", + DisableProxySign: true, + }, + }, + } + obj := &model.ObjectURL{ + Object: model.Object{Name: "file.txt", Size: 12}, + Url: model.Url{Url: "http://direct.test/file.txt"}, + } + + meta := &model.Meta{Path: "/file.txt", Password: "secret"} + resp, err := buildFSLinkInfo(ctx, c, "/file.txt", &fsLinkArgs{Path: "/file.txt"}, obj, meta, storage) + if err != nil { + t.Fatalf("unexpected error: %+v", err) + } + if resp.URL != "http://proxy.test/file.txt" || resp.URLType != "proxy" { + t.Fatalf("unexpected selected link: %+v", resp) + } + if resp.DirectURL != "" { + t.Fatalf("direct link should not be resolved for proxy storage: %+v", resp) + } +} + +type fsLinkTestDriver struct { + config driver.Config + storage model.Storage +} + +func (d *fsLinkTestDriver) Config() driver.Config { + return d.config +} + +func (d *fsLinkTestDriver) GetStorage() *model.Storage { + return &d.storage +} + +func (d *fsLinkTestDriver) SetStorage(storage model.Storage) { + d.storage = storage +} + +func (d *fsLinkTestDriver) GetAddition() driver.Additional { + return nil +} + +func (d *fsLinkTestDriver) Init(context.Context) error { + return nil +} + +func (d *fsLinkTestDriver) Drop(context.Context) error { + return nil +} + +func (d *fsLinkTestDriver) List(context.Context, model.Obj, model.ListArgs) ([]model.Obj, error) { + return nil, nil +} + +func (d *fsLinkTestDriver) Link(context.Context, model.Obj, model.LinkArgs) (*model.Link, error) { + return nil, nil +} diff --git a/server/mcp/list_test.go b/server/mcp/list_test.go new file mode 100644 index 000000000..8079245aa --- /dev/null +++ b/server/mcp/list_test.go @@ -0,0 +1,54 @@ +package mcp + +import ( + "encoding/json" + "testing" +) + +func TestParseFSListArgsRequiresPath(t *testing.T) { + assertFSListPathRequired(t, json.RawMessage(`{"refresh":true}`)) +} + +func TestParseFSListArgsRejectsEmptyArguments(t *testing.T) { + for _, raw := range []json.RawMessage{nil, json.RawMessage(`null`)} { + assertFSListInvalidArguments(t, raw) + } +} + +func TestParseFSListArgsRejectsInvalidJSON(t *testing.T) { + assertFSListInvalidArguments(t, json.RawMessage(`"bad"`)) +} + +func TestParseFSListArgs(t *testing.T) { + args, err := parseFSListArgs(json.RawMessage(`{"path":"/movies","password":"secret","refresh":true,"page":2,"per_page":10}`)) + if err != nil { + t.Fatalf("unexpected error: %+v", err) + } + if args.Path != "/movies" || args.Password != "secret" || !args.Refresh || args.Page != 2 || args.PerPage != 10 { + t.Fatalf("unexpected args: %+v", args) + } +} + +func assertFSListPathRequired(t *testing.T, raw json.RawMessage) { + t.Helper() + + _, err := parseFSListArgs(raw) + if err == nil { + t.Fatal("expected error") + } + if err.Code != -32602 || err.Message != "path is required" { + t.Fatalf("unexpected error: %+v", err) + } +} + +func assertFSListInvalidArguments(t *testing.T, raw json.RawMessage) { + t.Helper() + + _, err := parseFSListArgs(raw) + if err == nil { + t.Fatal("expected error") + } + if err.Code != -32602 || err.Message != "invalid openlist.fs.list arguments" { + t.Fatalf("unexpected error: %+v", err) + } +} diff --git a/server/mcp/tools.go b/server/mcp/tools.go new file mode 100644 index 000000000..188c674ce --- /dev/null +++ b/server/mcp/tools.go @@ -0,0 +1,122 @@ +package mcp + +import "encoding/json" + +type tool struct { + Name string `json:"name"` + Title string `json:"title,omitempty"` + Description string `json:"description,omitempty"` + InputSchema toolInputSchema `json:"inputSchema"` +} + +type toolInputSchema struct { + Type string `json:"type"` + Properties map[string]schemaProperty `json:"properties,omitempty"` + Required []string `json:"required,omitempty"` +} + +type schemaProperty struct { + Type string `json:"type,omitempty"` + Description string `json:"description,omitempty"` +} + +type toolsListParams struct { + Cursor string `json:"cursor,omitempty"` +} + +var openListTools = []tool{ + { + Name: "openlist.fs.list", + Title: "OpenList FS List", + Description: "List files and directories under a mount path that the current user can access.", + InputSchema: toolInputSchema{ + Type: "object", + Properties: map[string]schemaProperty{ + "path": { + Type: "string", + Description: "Mount path to list, for example \"/\" or \"/movies\".", + }, + "refresh": { + Type: "boolean", + Description: "Refresh the directory listing before returning results.", + }, + "password": { + Type: "string", + Description: "Optional password for protected paths.", + }, + "page": { + Type: "integer", + Description: "1-based page number.", + }, + "per_page": { + Type: "integer", + Description: "Page size.", + }, + }, + Required: []string{"path"}, + }, + }, + { + Name: "openlist.fs.get", + Title: "OpenList FS Get", + Description: "Get file or directory details for a mount path that the current user can access.", + InputSchema: toolInputSchema{ + Type: "object", + Properties: map[string]schemaProperty{ + "path": { + Type: "string", + Description: "Mount path to inspect, for example \"/movies/demo.mp4\".", + }, + "password": { + Type: "string", + Description: "Optional password for protected paths.", + }, + }, + Required: []string{"path"}, + }, + }, + { + Name: "openlist.fs.link", + Title: "OpenList FS Link", + Description: "Return usable link information for a file path that the current user can access.", + InputSchema: toolInputSchema{ + Type: "object", + Properties: map[string]schemaProperty{ + "path": { + Type: "string", + Description: "File mount path, for example \"/movies/demo.mp4\".", + }, + "password": { + Type: "string", + Description: "Optional password for protected paths.", + }, + "type": { + Type: "string", + Description: "Optional link type forwarded to storage drivers.", + }, + }, + Required: []string{"path"}, + }, + }, +} + +func (s *Server) handleToolsList(req request) response { + var params toolsListParams + if len(req.Params) > 0 { + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + return response{ + JSONRPC: "2.0", + ID: req.ID, + Error: &rpcError{Code: -32602, Message: "invalid tools/list params"}, + } + } + } + + return response{ + JSONRPC: "2.0", + ID: req.ID, + Result: map[string]any{ + "tools": openListTools, + }, + } +} diff --git a/server/router.go b/server/router.go index 3eb958325..726b84c23 100644 --- a/server/router.go +++ b/server/router.go @@ -41,6 +41,7 @@ func Init(e *gin.Engine) { } WebDav(g.Group("/dav")) S3(g.Group("/s3")) + MCP(g) downloadLimiter := middlewares.DownloadRateLimiter(stream.ClientDownloadLimit) signCheck := middlewares.Down(sign.Verify) diff --git a/server/s3/redirect.go b/server/s3/redirect.go new file mode 100644 index 000000000..4031e171d --- /dev/null +++ b/server/s3/redirect.go @@ -0,0 +1,167 @@ +// Credits: https://pkg.go.dev/github.com/rclone/rclone@v1.65.2/cmd/serve/s3 +// Package s3 implements a fake s3 server for openlist +package s3 + +import ( + "context" + "net/http" + "path" + "strings" + + "github.com/OpenListTeam/OpenList/v4/internal/conf" + "github.com/OpenListTeam/OpenList/v4/internal/fs" + "github.com/OpenListTeam/OpenList/v4/internal/model" + "github.com/OpenListTeam/OpenList/v4/internal/op" + "github.com/OpenListTeam/OpenList/v4/pkg/utils" + "github.com/OpenListTeam/OpenList/v4/server/common" + "github.com/itsHenry35/gofakes3/signature" +) + +func redirectHandler(next http.Handler, authPairs map[string]string) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if u, ok := directObjectURL(r, authPairs); ok { + w.Header().Set("Referrer-Policy", "no-referrer") + w.Header().Set("Cache-Control", "max-age=0, no-cache, no-store, must-revalidate") + w.Header().Set("Location", u) + w.WriteHeader(http.StatusFound) + return + } + if u, ok := directUploadURL(r, authPairs); ok { + w.Header().Set("Referrer-Policy", "no-referrer") + w.Header().Set("Location", u) + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(http.StatusTemporaryRedirect) + return + } + next.ServeHTTP(w, r) + }) +} + +func directObjectURL(r *http.Request, authPairs map[string]string) (string, bool) { + if r.Method != http.MethodGet { + return "", false + } + if hasNonObjectQuery(r) || !s3RequestAuthorized(r, authPairs) { + return "", false + } + bucketName, objectName, ok := parseObjectPath(r.URL.Path) + if !ok { + return "", false + } + bucket, err := getBucketByName(bucketName) + if err != nil { + return "", false + } + reqPath := path.Join(bucket.Path, objectName) + meta, _ := op.GetNearestMeta(reqPath) + ctx := context.WithValue(r.Context(), conf.MetaKey, meta) + storage, err := fs.GetStorage(reqPath, &fs.GetStoragesArgs{}) + if err != nil || common.ShouldProxy(storage, path.Base(reqPath)) { + return "", false + } + link, file, err := fs.Link(ctx, reqPath, model.LinkArgs{ + IP: utils.ClientIP(r), + Header: r.Header, + Redirect: true, + }) + if err != nil { + return "", false + } + defer link.Close() + if file == nil || file.IsDir() || link.URL == "" || link.RangeReader != nil { + return "", false + } + return link.URL, true +} + +func directUploadURL(r *http.Request, authPairs map[string]string) (string, bool) { + if r.Method != http.MethodPut || r.ContentLength < 0 { + return "", false + } + if hasNonObjectQuery(r) || !s3RequestAuthorized(r, authPairs) { + return "", false + } + if r.Header.Get("X-Amz-Copy-Source") != "" || + r.Header.Get("X-Amz-Content-Sha256") == "STREAMING-AWS4-HMAC-SHA256-PAYLOAD" { + return "", false + } + bucketName, objectName, ok := parseObjectPath(r.URL.Path) + if !ok || strings.HasSuffix(objectName, "/") { + return "", false + } + bucket, err := getBucketByName(bucketName) + if err != nil { + return "", false + } + reqPath := path.Join(bucket.Path, objectName) + storage, dstDirActualPath, err := op.GetStorageAndActualPath(path.Dir(reqPath)) + if err != nil || storage.Config().NoUpload { + return "", false + } + info, err := op.GetDirectUploadInfo(r.Context(), "HttpDirect", storage, dstDirActualPath, path.Base(reqPath), r.ContentLength, true) + if err != nil { + return "", false + } + httpInfo, ok := asHTTPDirectUploadInfo(info) + if !ok { + return "", false + } + method := httpInfo.Method + if method == "" { + method = http.MethodPut + } + if !strings.EqualFold(method, http.MethodPut) || httpInfo.UploadURL == "" || + httpInfo.ChunkSize > 0 || len(httpInfo.Headers) > 0 { + return "", false + } + return httpInfo.UploadURL, true +} + +func asHTTPDirectUploadInfo(info any) (*model.HttpDirectUploadInfo, bool) { + switch v := info.(type) { + case *model.HttpDirectUploadInfo: + return v, v != nil + case model.HttpDirectUploadInfo: + return &v, true + default: + return nil, false + } +} + +func parseObjectPath(rawPath string) (bucket, object string, ok bool) { + parts := strings.SplitN(strings.TrimPrefix(rawPath, "/"), "/", 2) + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return "", "", false + } + return parts[0], parts[1], true +} + +// hasNonObjectQuery reports whether the request carries a query parameter that +// makes gofakes3 route it to a sub-resource handler instead of plain object +// download (see gofakes3 routing.go). Only those keys force server-side +// handling; every other query parameter (response-content-disposition, +// response-content-type, etc.) is irrelevant to route selection and must not +// block a direct redirect. +func hasNonObjectQuery(r *http.Request) bool { + query := r.URL.Query() + for _, key := range []string{"uploadId", "uploads", "versioning", "versions", "location"} { + if _, ok := query[key]; ok { + return true + } + } + if versionID := query.Get("versionId"); versionID != "" && versionID != "null" { + return true + } + return false +} + +func s3RequestAuthorized(r *http.Request, authPairs map[string]string) bool { + if len(authPairs) == 0 { + return true + } + result := signature.V4SignVerify(r) + if result == signature.ErrUnsupportAlgorithm { + result = signature.V2SignVerify(r) + } + return result == signature.ErrNone +} diff --git a/server/s3/redirect_test.go b/server/s3/redirect_test.go new file mode 100644 index 000000000..9584c136a --- /dev/null +++ b/server/s3/redirect_test.go @@ -0,0 +1,87 @@ +package s3 + +import ( + "net/http/httptest" + "testing" + + "github.com/OpenListTeam/OpenList/v4/internal/model" +) + +func TestParseObjectPath(t *testing.T) { + tests := []struct { + name string + path string + wantBucket string + wantObject string + wantOK bool + }{ + {name: "object", path: "/bucket/path/to/file.txt", wantBucket: "bucket", wantObject: "path/to/file.txt", wantOK: true}, + {name: "missing object", path: "/bucket", wantOK: false}, + {name: "empty bucket", path: "//file.txt", wantOK: false}, + {name: "empty object", path: "/bucket/", wantOK: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + bucket, object, ok := parseObjectPath(tt.path) + if ok != tt.wantOK || bucket != tt.wantBucket || object != tt.wantObject { + t.Fatalf("parseObjectPath(%q) = (%q, %q, %v), want (%q, %q, %v)", + tt.path, bucket, object, ok, tt.wantBucket, tt.wantObject, tt.wantOK) + } + }) + } +} + +func TestHasNonObjectQuery(t *testing.T) { + tests := []struct { + name string + raw string + want bool + }{ + {name: "no query", raw: "/bucket/object", want: false}, + {name: "aws auth query", raw: "/bucket/object?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Signature=abc", want: false}, + {name: "legacy auth query", raw: "/bucket/object?AWSAccessKeyId=ak&Signature=sig&Expires=1", want: false}, + {name: "sdk operation marker", raw: "/bucket/object?x-id=GetObject", want: false}, + {name: "response content disposition", raw: "/bucket/object?response-content-disposition=attachment%3Bfilename%3Dx", want: false}, + {name: "response content type", raw: "/bucket/object?response-content-type=text%2Fplain", want: false}, + // list-type is not a routing key in gofakes3: a path with an object + // segment is dispatched to getObject regardless, so it must not block + // a direct redirect. + {name: "list query", raw: "/bucket/object?list-type=2", want: false}, + {name: "multipart uploads", raw: "/bucket/object?uploads", want: true}, + {name: "multipart upload id", raw: "/bucket/object?uploadId=123", want: true}, + {name: "versioning", raw: "/bucket/object?versioning", want: true}, + {name: "versions", raw: "/bucket/object?versions", want: true}, + {name: "bucket location", raw: "/bucket/object?location", want: true}, + {name: "version id", raw: "/bucket/object?versionId=abc", want: true}, + {name: "version id null", raw: "/bucket/object?versionId=null", want: false}, + {name: "version id empty", raw: "/bucket/object?versionId=", want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest("GET", tt.raw, nil) + if got := hasNonObjectQuery(req); got != tt.want { + t.Fatalf("hasNonObjectQuery(%q) = %v, want %v", tt.raw, got, tt.want) + } + }) + } +} + +func TestAsHTTPDirectUploadInfo(t *testing.T) { + info := model.HttpDirectUploadInfo{UploadURL: "https://example.com/upload"} + got, ok := asHTTPDirectUploadInfo(info) + if !ok || got == nil || got.UploadURL != info.UploadURL { + t.Fatalf("asHTTPDirectUploadInfo(value) = (%v, %v), want upload info", got, ok) + } + + got, ok = asHTTPDirectUploadInfo(&info) + if !ok || got == nil || got.UploadURL != info.UploadURL { + t.Fatalf("asHTTPDirectUploadInfo(pointer) = (%v, %v), want upload info", got, ok) + } + + got, ok = asHTTPDirectUploadInfo(struct{}{}) + if ok || got != nil { + t.Fatalf("asHTTPDirectUploadInfo(unsupported) = (%v, %v), want nil false", got, ok) + } +} diff --git a/server/s3/server.go b/server/s3/server.go index 360eb8752..d33b63ddd 100644 --- a/server/s3/server.go +++ b/server/s3/server.go @@ -13,15 +13,16 @@ import ( // Make a new S3 Server to serve the remote func NewServer(ctx context.Context) (h http.Handler, err error) { var newLogger logger + authPairs := authlistResolver() faker := gofakes3.New( newBackend(), // gofakes3.WithHostBucket(!opt.pathBucketMode), gofakes3.WithLogger(newLogger), gofakes3.WithRequestID(rand.Uint64()), gofakes3.WithoutVersioning(), - gofakes3.WithV4Auth(authlistResolver()), + gofakes3.WithV4Auth(authPairs), gofakes3.WithIntegrityCheck(true), // Check Content-MD5 if supplied ) - return faker.Server(), nil + return redirectHandler(faker.Server(), authPairs), nil } diff --git a/server/static/static.go b/server/static/static.go index 29f97ff74..38eee2dfa 100644 --- a/server/static/static.go +++ b/server/static/static.go @@ -98,9 +98,9 @@ func initIndex(siteConfig SiteConfig) { manifestPath = siteConfig.BasePath + "/manifest.json" } replaceMap := map[string]string{ - "cdn: undefined": fmt.Sprintf("cdn: '%s'", siteConfig.Cdn), - "base_path: undefined": fmt.Sprintf("base_path: '%s'", siteConfig.BasePath), - `href="/manifest.json"`: fmt.Sprintf(`href="%s"`, manifestPath), + "cdn: undefined": fmt.Sprintf("cdn: '%s'", siteConfig.Cdn), + "base_path: undefined": fmt.Sprintf("base_path: '%s'", siteConfig.BasePath), + `href="/manifest.json"`: fmt.Sprintf(`href="%s"`, manifestPath), } conf.RawIndexHtml = replaceStrings(conf.RawIndexHtml, replaceMap) UpdateIndex() @@ -134,10 +134,10 @@ func UpdateIndex() { func ManifestJSON(c *gin.Context) { // Get site configuration to ensure consistent base path handling siteConfig := getSiteConfig() - + // Get site title from settings siteTitle := setting.GetStr(conf.SiteTitle) - + // Get logo from settings, use the first line (light theme logo) logoSetting := setting.GetStr(conf.Logo) logoUrl := strings.Split(logoSetting, "\n")[0] @@ -167,7 +167,7 @@ func ManifestJSON(c *gin.Context) { c.Header("Content-Type", "application/json") c.Header("Cache-Control", "public, max-age=3600") // cache for 1 hour - + if err := json.NewEncoder(c.Writer).Encode(manifest); err != nil { utils.Log.Errorf("Failed to encode manifest.json: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate manifest"}) @@ -181,7 +181,7 @@ func Static(r *gin.RouterGroup, noRoute func(handlers ...gin.HandlerFunc)) { initStatic() initIndex(siteConfig) folders := []string{"assets", "images", "streamer", "static"} - + if conf.Conf.Cdn == "" { utils.Log.Debug("Setting up static file serving...") r.Use(func(c *gin.Context) {