mirror of
https://github.com/OpenListTeam/OpenList.git
synced 2026-10-10 13:03:09 +08:00
Compare commits
561 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b2870dda3c | |||
| 4c39bbe9c2 | |||
| e1d88b071f | |||
| ea10624fb6 | |||
| 54ae9d7451 | |||
| c16701b94b | |||
| 893457cd50 | |||
| 90acfa18e4 | |||
| 1462d63a48 | |||
| cadbf87246 | |||
| 9de3f69b8f | |||
| e73a80c78c | |||
| 6c6009109f | |||
| b51d1c8284 | |||
| f286862c61 | |||
| 40f4f6546f | |||
| 3a31b438a9 | |||
| 56064d1981 | |||
| d894a3983b | |||
| 084c008102 | |||
| 4580c4db33 | |||
| 5447ecb072 | |||
| f18b4acc76 | |||
| d6109a7940 | |||
| 0463da4034 | |||
| 2d51c9ab4b | |||
| d9d8aa24e6 | |||
| 55530ff171 | |||
| 6247cf7be2 | |||
| eee910babb | |||
| 6b55a82ffe | |||
| 93dac1655f | |||
| 6ad44605c0 | |||
| d90d84906e | |||
| c3d3da9286 | |||
| 2bdf16d596 | |||
| f8ebaec4f1 | |||
| fc23f4e781 | |||
| 3ea9984aea | |||
| cc11f354f1 | |||
| 8869874b76 | |||
| 523af855ba | |||
| bba3516693 | |||
| f244adf60e | |||
| ce87b45d2d | |||
| 4031b31837 | |||
| 9b34ca5bf1 | |||
| 666b1a039d | |||
| 8f9a09d359 | |||
| 394bb8f80e | |||
| 819fdbd518 | |||
| d220b8b005 | |||
| d12bd4c71e | |||
| 4c611ef8de | |||
| eea8fee7f1 | |||
| c06656958c | |||
| 0d277b8550 | |||
| e0e4de5e82 | |||
| 39fcaf488b | |||
| 31ae0f5bc9 | |||
| 3943180898 | |||
| b1cc7c6a93 | |||
| f6557f3d62 | |||
| 9dc6cb29d9 | |||
| b86e4cb8dc | |||
| fd7fb04ed1 | |||
| 80795b8ec3 | |||
| c2b3666818 | |||
| edbf42898b | |||
| ab51cd6eae | |||
| 4619aa2bc5 | |||
| 376cada3d4 | |||
| 1a6cabf45a | |||
| 0b1e9d0943 | |||
| 18823a23d5 | |||
| 150a90f2ac | |||
| e80ee006ad | |||
| 89077c35a7 | |||
| cc87e88f03 | |||
| 7e9082f90e | |||
| 6d4ad58678 | |||
| d8ad28f5b7 | |||
| 2e27852952 | |||
| 194f941f15 | |||
| ec1f968de1 | |||
| c97173286c | |||
| cca773f07b | |||
| a4ae2acb65 | |||
| f68f3e9f4a | |||
| 6f80df2827 | |||
| 940b69fe38 | |||
| f2f7ee4107 | |||
| cf931d4760 | |||
| be708292eb | |||
| 78f2f0b25d | |||
| 6b92cbd21f | |||
| 84ecda35aa | |||
| 2deb16b26e | |||
| 8495470e63 | |||
| fe56ca483d | |||
| e83835dd80 | |||
| 791a27e2ac | |||
| 28cea1fb2b | |||
| 0a9e71ae67 | |||
| a21bc8bd0f | |||
| bacd087096 | |||
| e1ded83d5f | |||
| ff3cb8b931 | |||
| 7aea393a6d | |||
| 36168c86a0 | |||
| 24332e371f | |||
| 6b702afc0f | |||
| 78db0f7aff | |||
| e469693114 | |||
| 5961289b83 | |||
| 0527dc41af | |||
| b1d298d192 | |||
| 651b5cea1c | |||
| 5aeaf7a8c8 | |||
| 651da18da4 | |||
| 5a5d8d6e0e | |||
| 061c1e2e1e | |||
| caed356200 | |||
| 74af297471 | |||
| 7c54b0e3e4 | |||
| a632356902 | |||
| 82ea8d0d01 | |||
| eb48671202 | |||
| f3e74fbdf1 | |||
| e670322ee3 | |||
| 8930e1945e | |||
| daaba0d7ea | |||
| a81f54488f | |||
| 59bd343140 | |||
| 757f9d383d | |||
| 7ded6dfe2c | |||
| a283f2b52a | |||
| bb3bc31609 | |||
| 744021d80d | |||
| 94eac2e91e | |||
| a92d59d35c | |||
| fcae009bd3 | |||
| f63a5e15ee | |||
| d85980d3d9 | |||
| c8306256ff | |||
| 5273a77854 | |||
| cc3d8a06a7 | |||
| 925a2422d6 | |||
| 33015eee9f | |||
| 1be74776f6 | |||
| 31810f556c | |||
| 30922b6d52 | |||
| 759adfca21 | |||
| bfbc441fa6 | |||
| 866793f485 | |||
| 40b39000ab | |||
| 6eedff851c | |||
| aea77c5967 | |||
| 009d834e71 | |||
| e9ea991584 | |||
| d4aa8b6a15 | |||
| 9e55a0111b | |||
| f869afc71f | |||
| e960035b65 | |||
| a0f001c168 | |||
| c8e7f35761 | |||
| 6ed7db1ee7 | |||
| 5a12c8d2ba | |||
| b86d10fa2f | |||
| aa534a955b | |||
| d403f75da2 | |||
| 3abdf7990b | |||
| 3b017c2b5c | |||
| a42e367660 | |||
| f45473357e | |||
| dcfefce85d | |||
| d349765617 | |||
| ace7482bc2 | |||
| eadf03a4f8 | |||
| da84a800ab | |||
| b05a51a062 | |||
| ffe3f3cfa3 | |||
| 7e55f11e4a | |||
| e7bccc636a | |||
| b7f9e8ef0a | |||
| d7c332ca13 | |||
| 64d39114ee | |||
| 88d256de73 | |||
| 5a92dc2834 | |||
| 726bbf4bd2 | |||
| bd6cf5bc1e | |||
| 089315118d | |||
| 9737006a8d | |||
| 054db9f5eb | |||
| 9b587ee165 | |||
| 9282e8ef09 | |||
| ac80fe19bf | |||
| cd6643a642 | |||
| ba6f96ca4e | |||
| 9cc5dd969b | |||
| 98c32d3be4 | |||
| 4c77b9c7bf | |||
| 3b1760e959 | |||
| b28208bd76 | |||
| ffbbc1ccdf | |||
| 1d071c0fd8 | |||
| 948e87159d | |||
| c3df8da577 | |||
| 7cfb25558f | |||
| 31b41f99f8 | |||
| 9eae62581a | |||
| daad21ef91 | |||
| 219564b56c | |||
| ea19bed247 | |||
| b6db83ed5e | |||
| 0726d16632 | |||
| 7feec2b7d9 | |||
| c7c0cfaeb7 | |||
| d3a6b06566 | |||
| e87e028a49 | |||
| bfda719e64 | |||
| 7e37c40516 | |||
| e28406fa2c | |||
| 9033b5502d | |||
| 07506b003e | |||
| 201a206ed9 | |||
| 2d2d9ae213 | |||
| 376638978a | |||
| 29ec90e8e8 | |||
| ece1518eac | |||
| a5ba6a0e9d | |||
| 331f575c17 | |||
| b31ae9c9f6 | |||
| 8d39d636be | |||
| da26e72bee | |||
| e11b8a82e7 | |||
| 12c9bdbd56 | |||
| 9e49adc353 | |||
| 9fdba3a730 | |||
| 7bea29c18e | |||
| 29447a41a3 | |||
| d85f084acb | |||
| d598ef7569 | |||
| e41b683efb | |||
| 9a2ba1dabe | |||
| f3428e65bc | |||
| 5eaef96078 | |||
| b5626b275b | |||
| 795a18b565 | |||
| 82ae2d5890 | |||
| db0e2ec103 | |||
| e0ee737080 | |||
| 0673a74302 | |||
| a8d1c0ddbf | |||
| 8431c1b1e3 | |||
| a121f861dc | |||
| 6861cb4c3b | |||
| 5d9fc8359d | |||
| f542187654 | |||
| e3c664f81d | |||
| 7b78fed106 | |||
| d685bbfa9a | |||
| 27732ccc88 | |||
| 29fcf5904a | |||
| f0e53d18a8 | |||
| a2573fb285 | |||
| 031b719bb0 | |||
| d8417e050c | |||
| 02aec71383 | |||
| fca993a830 | |||
| a79d8347bd | |||
| f057846e01 | |||
| 27fdd03ec3 | |||
| ea4b5e3251 | |||
| 378e37b082 | |||
| 5c82f55200 | |||
| b553dc35b6 | |||
| e6db0bd6ed | |||
| 85c69d853f | |||
| c6bd437242 | |||
| e192149480 | |||
| 0d99e16b59 | |||
| 744dbd5e26 | |||
| 7088fa1f69 | |||
| 642acf8bca | |||
| 747993e2ca | |||
| 1f9a7e3a91 | |||
| 874234449b | |||
| 5fe267089a | |||
| 2442e302ad | |||
| 0612271732 | |||
| c261ce78fb | |||
| 7398e7d45e | |||
| 6e2d499ca9 | |||
| 4680ece2d9 | |||
| 8a4f3769d8 | |||
| cc5172e70b | |||
| a32ae97860 | |||
| d6dd62dfe5 | |||
| 216f071e64 | |||
| f47df5f9b2 | |||
| ff3c4b885c | |||
| f86c7c844c | |||
| 5db2172ed6 | |||
| c4c121befc | |||
| b4542753ba | |||
| 2a99c97d52 | |||
| 0a407c3d8b | |||
| b2596fdc24 | |||
| 2dbe1b00d3 | |||
| 1fc9c83df1 | |||
| d31e1a333d | |||
| e1bba7072b | |||
| 94c7d68413 | |||
| 9ed77a5875 | |||
| 7d6d3b8f55 | |||
| 5480d61f70 | |||
| 96cd714385 | |||
| e29d92f92e | |||
| c5f57bbcc5 | |||
| 9835afc645 | |||
| 1f373eac8d | |||
| ede96a314c | |||
| 72206ac9f6 | |||
| 62dedb2a2e | |||
| 7189c5b461 | |||
| 1a445f9d3f | |||
| aa22884079 | |||
| 316d4caf37 | |||
| 60a489eb68 | |||
| b22e211044 | |||
| ca401b9af9 | |||
| addce8b691 | |||
| 42fc841dc1 | |||
| 4c0916b64b | |||
| 3989d35abd | |||
| 72e2ae1f14 | |||
| 3e37f575d8 | |||
| c0d480366d | |||
| 9de7561154 | |||
| 0866b9075f | |||
| 055696f576 | |||
| 854415160c | |||
| 8f4f7d1291 | |||
| ee2c77acd8 | |||
| fc90ec1b53 | |||
| 7d78944d14 | |||
| f2e0fe8589 | |||
| 39dcf9bd19 | |||
| 25f38df4ca | |||
| a1f1f98f94 | |||
| affc499913 | |||
| c7574b545c | |||
| 9e852ba12d | |||
| 174eae802a | |||
| b9f058fcc9 | |||
| 6de15b6310 | |||
| 2844797684 | |||
| 9f4e439478 | |||
| 9d09ee133d | |||
| d88f0e8f3c | |||
| 0857478516 | |||
| 66d9809057 | |||
| db8a7e8caf | |||
| 8f18e34da0 | |||
| 525f26dc23 | |||
| a0fcfa3ed2 | |||
| 15f276537c | |||
| 623a12050e | |||
| ae2d2d1021 | |||
| a109152a13 | |||
| febbcd6027 | |||
| 549e60136b | |||
| 14d2b8290a | |||
| cdc069d8e7 | |||
| fb5094f688 | |||
| 670e0bdc45 | |||
| 89235012af | |||
| 2bfbad2874 | |||
| 4ba7696032 | |||
| 66645516e5 | |||
| eb2ff2d2ca | |||
| 4153245f2c | |||
| 6fe9af7819 | |||
| 2edc446ced | |||
| c3c7983f7b | |||
| 22deb4df30 | |||
| da0c734aa3 | |||
| 189cebe4c9 | |||
| 9d3da44a99 | |||
| 8f17d35ed5 | |||
| 89759b6e3b | |||
| a2fc38be8d | |||
| e0414e7110 | |||
| b486af0031 | |||
| ea09ce4b8f | |||
| d465da43e3 | |||
| 84ed487950 | |||
| 3c07144211 | |||
| 3936e736e6 | |||
| 68433d4f5b | |||
| cc16cb35bf | |||
| d3bc6321f4 | |||
| cbbb5ad231 | |||
| c1d03c5bcc | |||
| 61a8ed515f | |||
| bbb7c06504 | |||
| 8bbdb272d4 | |||
| c15ae94307 | |||
| f1a5048558 | |||
| 1fe26bff9a | |||
| 433dcd156b | |||
| e97f0a289e | |||
| 89f35170b3 | |||
| 8188fb2d7d | |||
| 87cf95f50b | |||
| 8ab26cb823 | |||
| 5880c8e1af | |||
| 14bf4ecb4c | |||
| 04a5e58781 | |||
| bbd4389345 | |||
| f350ccdf95 | |||
| 4f2de9395e | |||
| b0dbbebfb0 | |||
| 0c27b4bd47 | |||
| 736cd9e5f2 | |||
| c7a603c926 | |||
| a28d6d5693 | |||
| e59d2233e2 | |||
| 01914a06ef | |||
| 6499374d1c | |||
| b054919d5c | |||
| 048ee9c2e5 | |||
| 23394548ca | |||
| b04677b806 | |||
| e4c902dd93 | |||
| 5d8bd258c0 | |||
| 08c5283c8c | |||
| 10a14f10cd | |||
| f86ebc52a0 | |||
| 016ed90efa | |||
| d76407b201 | |||
| 5de6b660f2 | |||
| 71ada3b656 | |||
| dc42f0e226 | |||
| 74bf9f6467 | |||
| d0c22a1ecb | |||
| 57fceabcf4 | |||
| 8c244a984d | |||
| df479ba806 | |||
| 5ae8e96237 | |||
| aa0ced47b0 | |||
| ab747d9052 | |||
| 93c06213d4 | |||
| b9b8eed285 | |||
| 317d190b77 | |||
| 52d7d819ad | |||
| 0483e0f868 | |||
| 08dae4f55f | |||
| 9ac0484bc0 | |||
| 8cf15183a0 | |||
| c8f2aaaa55 | |||
| 1208bd0a83 | |||
| 6b096bcad4 | |||
| 58dbf088f9 | |||
| 05ff7908f2 | |||
| a703b736c9 | |||
| e458f2ab53 | |||
| a5a22e7085 | |||
| 9469c95b14 | |||
| cf912dcf7a | |||
| ccd4af26e5 | |||
| 1682e873d6 | |||
| 54ae7e6d9b | |||
| 991da7d87f | |||
| a498091aef | |||
| 976c82bb2b | |||
| 5b41a3bdff | |||
| 19d1a3b785 | |||
| 3c7b0c4999 | |||
| d6867b4ab6 | |||
| 11cf561307 | |||
| 239b58f63e | |||
| 7da06655cb | |||
| e0b3a611ba | |||
| be1ad08a83 | |||
| 4e9c30f49d | |||
| 0ee31a3f36 | |||
| 23bddf991e | |||
| da8d6607cf | |||
| 6134574dac | |||
| b273232f87 | |||
| 358e4d851e | |||
| e8a1ed638a | |||
| 4106e2a996 | |||
| c2271df64e | |||
| d4b8570eb8 | |||
| bd297e8ccc | |||
| 923d282c8a | |||
| 4d8c4d7089 | |||
| e93ab76036 | |||
| a9f02ecdac | |||
| 93849a3b5b | |||
| c2e0d0c9ce | |||
| 4a713363ee | |||
| 3da8ccb7a7 | |||
| 676b8cff0b | |||
| 57cf28fc90 | |||
| 8cf90e074d | |||
| 74c2ed8306 | |||
| 5f03edd683 | |||
| 8b65c918d4 | |||
| b5f0e3e5ee | |||
| 179894ff37 | |||
| e2fc89c637 | |||
| cacf67b181 | |||
| afb043e1d6 | |||
| d9debb81ad | |||
| 4c069fddd6 | |||
| b450a2104d | |||
| 7d0de17daf | |||
| bba4fb2203 | |||
| a20c2020f8 | |||
| a92b5eb929 | |||
| 6817494a41 | |||
| 5a0d8ee1b8 | |||
| 012e51c551 | |||
| 59ec1dbc9b | |||
| 6bb28d13f9 | |||
| 811a862288 | |||
| 74d32fd4d7 | |||
| cedb3d488d | |||
| 86324d2d6b | |||
| 648079ae24 | |||
| e8d45398d6 | |||
| 0c461991f9 | |||
| 2a4c546a8b | |||
| 750d4eb3f6 | |||
| cc01b410a4 | |||
| e5fbe72581 | |||
| 283f3723d1 | |||
| ad8c7b37a1 | |||
| a84ffb96e9 | |||
| 19c6b6f930 | |||
| eed3c0533c | |||
| c72ba9828a | |||
| 4965a1b909 | |||
| 1bba550469 | |||
| d678322b18 | |||
| efd8897bdf | |||
| 7c7cec0993 | |||
| 3838ef0663 | |||
| 9e610af114 | |||
| 0177177238 | |||
| a77e515c9b | |||
| 4af16ab009 | |||
| da35423198 | |||
| 9612d61e60 | |||
| 92f396df10 | |||
| 9557834342 | |||
| 288ba2fcda |
@@ -13,7 +13,7 @@ body:
|
||||
attributes:
|
||||
label: 请确认以下事项
|
||||
description: |
|
||||
您必须勾选以下内容,否则您的问题可能会被直接关闭。
|
||||
您必须阅读、检查、确认、同意以下内容,否则您的问题一定会被直接关闭。
|
||||
或者您可以去[讨论区](https://github.com/OpenListTeam/OpenList/discussions)。
|
||||
options:
|
||||
- label: |
|
||||
@@ -25,16 +25,17 @@ body:
|
||||
- label: |
|
||||
我确认我的描述清晰,语法礼貌,能帮助开发者快速定位问题,并符合社区规则。
|
||||
- label: |
|
||||
我已确认阅读了[OpenList文档](https://docs.oplist.org)。
|
||||
我已确认阅读了[OpenList文档](https://doc.oplist.org)。
|
||||
- label: |
|
||||
我已确认没有重复的问题或讨论。
|
||||
- label: |
|
||||
我已确认是`OpenList`的问题,而不是其他原因(例如 [网络](https://docs.oplist.org/zh/faq/howto.html#tls-handshake-timeout-read-connection-reset-by-peer-dns-lookup-failed-connect-connection-refused-client-timeout-exceeded-while-awaiting-headers-no-such-host) ,`依赖`或`操作`)。
|
||||
我已确认是`OpenList`的问题,而不是其他原因(例如 [网络](https://doc.oplist.org/faq/howto#tls-handshake-timeout-read-connection-reset-by-peer-dns-lookup-failed-connect-connection-refused-client-timeout-exceeded-while-awaiting-headers-no-such-host-1) ,`依赖`或`操作`)。
|
||||
- label: |
|
||||
我认为此问题必须由`OpenList`处理,而非第三方。
|
||||
- label: |
|
||||
我已确认这个问题在最新版本中没有被修复。
|
||||
|
||||
- label: |
|
||||
我没有阅读这个清单,只是闭眼选中了所有的复选框,请关闭这个 Issue 。
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
@@ -59,6 +60,14 @@ body:
|
||||
label: 问题描述(必填)
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: 日志(必填)
|
||||
description: |
|
||||
请复制粘贴错误日志,或者截图。(可隐藏隐私字段) [查看方法](https://doc.oplist.org/faq/howto#%E5%A6%82%E4%BD%95%E5%BF%AB%E9%80%9F%E5%AE%9A%E4%BD%8Dbug)
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: config
|
||||
attributes:
|
||||
@@ -67,15 +76,23 @@ body:
|
||||
请提供您的`OpenList`应用的配置文件,并截图相关存储配置。(可隐藏隐私字段)
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: 日志(可选)
|
||||
description: |
|
||||
请复制粘贴错误日志,或者截图。(可隐藏隐私字段)
|
||||
- type: textarea
|
||||
id: reproduction
|
||||
attributes:
|
||||
label: 复现链接(可选)
|
||||
description: |
|
||||
请提供能复现此问题的链接。
|
||||
- type: checkboxes
|
||||
id: aigenerated
|
||||
attributes:
|
||||
label: AI生成内容
|
||||
description: 必须且只能勾选一项,请勿删除或修改声明文字。
|
||||
options:
|
||||
- label: 我使用了AI工具生成此内容
|
||||
- label: 我没有使用AI工具生成此内容
|
||||
- type: input
|
||||
id: ai-model
|
||||
attributes:
|
||||
label: AI模型是
|
||||
description: 如果使用了AI工具,请填写模型名称;未使用则留空。
|
||||
placeholder: xxx
|
||||
|
||||
@@ -13,7 +13,7 @@ body:
|
||||
attributes:
|
||||
label: Please confirm the following
|
||||
description: |
|
||||
You must check all the following, otherwise your issue may be closed directly.
|
||||
You must read, check, confirm, and agree to all the following, otherwise your issue will definitely be closed directly.
|
||||
Or you can go to the [discussions](https://github.com/OpenListTeam/OpenList/discussions).
|
||||
options:
|
||||
- label: |
|
||||
@@ -25,16 +25,17 @@ body:
|
||||
- label: |
|
||||
I confirm my description is clear, polite, helps developers quickly locate the issue, and complies with community rules.
|
||||
- label: |
|
||||
I have read the [OpenList documentation](https://docs.oplist.org).
|
||||
I have read the [OpenList documentation](https://doc.oplist.org).
|
||||
- label: |
|
||||
I confirm there are no duplicate issues or discussions.
|
||||
- label: |
|
||||
I confirm this is an `OpenList` issue, not caused by other reasons (such as [network](https://docs.oplist.org/faq/howto.html#tls-handshake-timeout-read-connection-reset-by-peer-dns-lookup-failed-connect-connection-refused-client-timeout-exceeded-while-awaiting-headers-no-such-host), dependencies, or operation).
|
||||
I confirm this is an `OpenList` issue, not caused by other reasons (such as [network](https://doc.oplist.org/faq/howto#tls-handshake-timeout-read-connection-reset-by-peer-dns-lookup-failed-connect-connection-refused-client-timeout-exceeded-while-awaiting-headers-no-such-host-1), dependencies, or operation).
|
||||
- label: |
|
||||
I believe this issue must be handled by `OpenList` and not by a third party.
|
||||
- label: |
|
||||
I confirm this issue is not fixed in the latest version.
|
||||
|
||||
- label: |
|
||||
I have not read these checkboxes and therefore I just ticked them all, Please close this issue.
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
@@ -59,6 +60,14 @@ body:
|
||||
label: Bug Description (required)
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Logs (required)
|
||||
description: |
|
||||
Please copy and paste any relevant log output or screenshots. (You may mask sensitive fields) [Guide](https://doc.oplist.org/faq/howto#how-to-quickly-locate-bugs)
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: config
|
||||
attributes:
|
||||
@@ -67,15 +76,23 @@ body:
|
||||
Please provide your `OpenList` application's configuration file and a screenshot of the relevant storage configuration. (You may mask sensitive fields)
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Logs (optional)
|
||||
description: |
|
||||
Please copy and paste any relevant log output or screenshots. (You may mask sensitive fields)
|
||||
- type: textarea
|
||||
id: reproduction
|
||||
attributes:
|
||||
label: Reproduction Link (optional)
|
||||
description: |
|
||||
Please provide a link to a repo or page that can reproduce this issue.
|
||||
- type: checkboxes
|
||||
id: aigenerated
|
||||
attributes:
|
||||
label: AI Generated Content
|
||||
description: Select exactly one option. Do not delete or modify the disclosure text.
|
||||
options:
|
||||
- label: I used AI tools to generate this content
|
||||
- label: I did not use AI tools to generate this content
|
||||
- type: input
|
||||
id: ai-model
|
||||
attributes:
|
||||
label: AI model used
|
||||
description: If you used AI tools, enter the model name; otherwise leave this blank.
|
||||
placeholder: xxx
|
||||
|
||||
@@ -7,7 +7,7 @@ body:
|
||||
attributes:
|
||||
label: 请确认以下事项
|
||||
description: |
|
||||
您必须勾选以下内容,否则您的问题可能会被直接关闭。
|
||||
您必须阅读、检查、确认、同意以下内容,否则您的问题可能会被直接关闭。
|
||||
或者您可以去[讨论区](https://github.com/OpenListTeam/OpenList/discussions)。
|
||||
options:
|
||||
- label: |
|
||||
@@ -19,7 +19,7 @@ body:
|
||||
- label: |
|
||||
我确认我的描述清晰,语法礼貌,能帮助开发者快速定位问题,并符合社区规则。
|
||||
- label: |
|
||||
我已确认阅读了[OpenList文档](https://docs.oplist.org)。
|
||||
我已确认阅读了[OpenList文档](https://doc.oplist.org)。
|
||||
- label: |
|
||||
我已确认没有重复的问题或讨论。
|
||||
- label: |
|
||||
@@ -28,6 +28,8 @@ body:
|
||||
我已确认此功能尚未被实现。
|
||||
- label: |
|
||||
我已确认此功能是合理的,且有普遍需求,并非我个人需要。
|
||||
- label: |
|
||||
我没有阅读这个清单,只是闭眼选中了所有的复选框,请关闭这个 Issue 。
|
||||
- type: textarea
|
||||
id: feature-description
|
||||
attributes:
|
||||
@@ -46,3 +48,17 @@ body:
|
||||
label: 附加信息
|
||||
description: |
|
||||
相关的任何其他上下文或截图,或者你觉得有帮助的信息
|
||||
- type: checkboxes
|
||||
id: aigenerated
|
||||
attributes:
|
||||
label: AI生成内容
|
||||
description: 必须且只能勾选一项,请勿删除或修改声明文字。
|
||||
options:
|
||||
- label: 我使用了AI工具生成此内容
|
||||
- label: 我没有使用AI工具生成此内容
|
||||
- type: input
|
||||
id: ai-model
|
||||
attributes:
|
||||
label: AI模型是
|
||||
description: 如果使用了AI工具,请填写模型名称;未使用则留空。
|
||||
placeholder: xxx
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
name: "Feature Request"
|
||||
description: Feature Request / Enhancement
|
||||
title: "[Feature] Please change the title to your feature name"
|
||||
title: "[Feature] Please modify the title to your feature name"
|
||||
labels: [enhancement]
|
||||
body:
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Please confirm the following
|
||||
description: |
|
||||
You must check all the following, otherwise your request may be closed directly.
|
||||
You must read, check, confirm, and agree to all the following, otherwise your request may be closed directly.
|
||||
Or you can go to the [discussions](https://github.com/OpenListTeam/OpenList/discussions).
|
||||
options:
|
||||
- label: |
|
||||
@@ -19,7 +19,7 @@ body:
|
||||
- label: |
|
||||
I confirm my description is clear, polite, helps developers quickly locate the issue, and complies with community rules.
|
||||
- label: |
|
||||
I have read the [OpenList documentation](https://docs.oplist.org).
|
||||
I have read the [OpenList documentation](https://doc.oplist.org).
|
||||
- label: |
|
||||
I confirm there are no duplicate issues or discussions.
|
||||
- label: |
|
||||
@@ -28,6 +28,8 @@ body:
|
||||
I confirm this feature has not been implemented yet.
|
||||
- label: |
|
||||
I confirm this feature is reasonable and has general demand, not just my personal need.
|
||||
- label: |
|
||||
I have not read these checkboxes and therefore I just ticked them all, Please close this issue.
|
||||
- type: textarea
|
||||
id: feature-description
|
||||
attributes:
|
||||
@@ -46,3 +48,17 @@ body:
|
||||
label: Additional Information
|
||||
description: |
|
||||
Any other context or screenshots related to this feature request, or information you find helpful.
|
||||
- type: checkboxes
|
||||
id: aigenerated
|
||||
attributes:
|
||||
label: AI Generated Content
|
||||
description: Select exactly one option. Do not delete or modify the disclosure text.
|
||||
options:
|
||||
- label: I used AI tools to generate this content
|
||||
- label: I did not use AI tools to generate this content
|
||||
- type: input
|
||||
id: ai-model
|
||||
attributes:
|
||||
label: AI model used
|
||||
description: If you used AI tools, enter the model name; otherwise leave this blank.
|
||||
placeholder: xxx
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
<!--
|
||||
PR title / PR 标题:
|
||||
- Use Conventional Commits: `type(scope): summary`
|
||||
- Allowed types: `feat`, `docs`, `fix`, `style`, `refactor`, `chore`
|
||||
- Scope is required by the current PR title check.
|
||||
- For breaking changes, add `!`: `feat(driver)!: change auth flow`
|
||||
-->
|
||||
|
||||
## Summary / 摘要
|
||||
|
||||
<!--
|
||||
Briefly describe what changed and why.
|
||||
简要说明改了什么,以及为什么需要改。
|
||||
-->
|
||||
|
||||
<!--
|
||||
- List user-visible behavior changes.
|
||||
- List important implementation changes.
|
||||
- Mention config, storage, API, or compatibility changes if any.
|
||||
|
||||
- 列出用户可感知的行为变化。
|
||||
- 列出重要实现变化。
|
||||
- 如涉及配置、存储、API 或兼容性变化,请明确说明。
|
||||
-->
|
||||
|
||||
- [ ] This PR has breaking changes.
|
||||
/ 此 PR 包含破坏性变更。
|
||||
- [ ] This PR changes public API, config, storage format, or migration behavior.
|
||||
/ 此 PR 修改了公开 API、配置、存储格式或迁移行为。
|
||||
- [ ] This PR requires corresponding changes in related repositories.
|
||||
/ 此 PR 需要关联仓库同步修改。
|
||||
|
||||
Related repository PRs / 关联仓库 PR:
|
||||
|
||||
- OpenList-Frontend:
|
||||
- OpenList-Docs:
|
||||
|
||||
## Related Issues / 关联 Issue
|
||||
|
||||
<!--
|
||||
Use `Closes #123`, `Fixes #123`, or `Relates to #123`.
|
||||
Remove this section if not applicable.
|
||||
使用 `Closes #123`、`Fixes #123` 或 `Relates to #123`。
|
||||
不适用时请删除本节。
|
||||
-->
|
||||
|
||||
## Testing / 测试
|
||||
|
||||
<!--
|
||||
Describe commands, platforms, and manual checks.
|
||||
If not tested, explain why.
|
||||
|
||||
说明执行过的命令、测试平台和手动验证。
|
||||
如果未测试,请说明原因。
|
||||
-->
|
||||
|
||||
- [ ] `go test ./...`
|
||||
- [ ] Manual test / 手动测试:
|
||||
|
||||
## Checklist / 检查清单
|
||||
|
||||
- [ ] I have read [CONTRIBUTING](https://github.com/OpenListTeam/OpenList/blob/main/CONTRIBUTING.md).
|
||||
/ 我已阅读 [CONTRIBUTING](https://github.com/OpenListTeam/OpenList/blob/main/CONTRIBUTING.md)。
|
||||
- [ ] I confirm this contribution follows the repository license, contribution policy, and code of conduct.
|
||||
/ 我确认此贡献符合仓库许可证、贡献规范和行为准则。
|
||||
- [ ] I have formatted the changed code with `gofmt`, `go fmt`, or `prettier` where applicable.
|
||||
/ 我已按适用情况使用 `gofmt`、`go fmt` 或 `prettier` 格式化变更代码。
|
||||
- [ ] I have requested review from relevant maintainers or code owners where applicable.
|
||||
/ 我已在适用情况下请求相关维护者或代码所有者审查。
|
||||
|
||||
## AI Disclosure / AI 使用声明
|
||||
|
||||
<!--
|
||||
Please disclose any substantial AI assistance used in this PR.
|
||||
Minor AI assistance, such as typo fixes, autocomplete, formatting suggestions,
|
||||
or wording polish, does not need to be disclosed.
|
||||
Remove this section if not applicable.
|
||||
|
||||
请披露此 PR 中使用的重要 AI 辅助内容。
|
||||
轻微 AI 辅助,例如拼写修正、自动补全、格式建议或文字润色,无需披露。
|
||||
如不适用,请删除本节。
|
||||
|
||||
Deliberate non-disclosure may be treated as a trust and compliance issue.
|
||||
|
||||
故意隐瞒 AI 使用情况可能被视为信任与合规问题。
|
||||
-->
|
||||
|
||||
- [ ] This PR includes AI-assisted content.
|
||||
/ 此 PR 包含 AI 辅助内容。
|
||||
|
||||
Tools used / 使用工具:
|
||||
|
||||
- [ ] ChatGPT
|
||||
- [ ] Codex
|
||||
- [ ] GitHub Copilot
|
||||
- [ ] Claude
|
||||
- [ ] Gemini
|
||||
- [ ] Other (please specify) / 其他(请注明):
|
||||
|
||||
Usage scope / 使用范围:
|
||||
|
||||
- [ ] Code generation / 代码生成
|
||||
- [ ] Refactoring / 重构
|
||||
- [ ] Documentation / 文档
|
||||
- [ ] Tests / 测试
|
||||
- [ ] Translation / 翻译
|
||||
- [ ] Review assistance / 审查辅助
|
||||
|
||||
- [ ] I have reviewed and validated all AI-assisted content included in this PR.
|
||||
/ 我已审核并验证此 PR 中的所有 AI 辅助内容。
|
||||
- [ ] I have ensured that all AI-assisted commits include `Co-Authored-By` attribution.
|
||||
/ 我已确保所有 AI 辅助提交都包含 `Co-Authored-By` 归属信息。
|
||||
- [ ] I can reproduce all AI-assisted content included in this PR without any AI tools.
|
||||
/ 我可以在没有任何 AI 工具的情况下重现此 PR 中包含的所有 AI 辅助内容。
|
||||
@@ -14,21 +14,17 @@ permissions:
|
||||
|
||||
jobs:
|
||||
changelog:
|
||||
strategy:
|
||||
matrix:
|
||||
platform: [ubuntu-latest]
|
||||
go-version: ["1.21"]
|
||||
name: Beta Release Changelog
|
||||
runs-on: ${{ matrix.platform }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Create or update ref
|
||||
id: create-or-update-ref
|
||||
uses: ovsds/create-or-update-ref-action@v1
|
||||
uses: ovsds/create-or-update-ref-action@1157b2991820188d5a8e6f22656775d1d171f762 # v1
|
||||
with:
|
||||
ref: tags/beta
|
||||
sha: ${{ github.sha }}
|
||||
@@ -44,15 +40,18 @@ jobs:
|
||||
npx changelogithub --output CHANGELOG.md
|
||||
|
||||
- name: Upload assets to beta release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
body_path: CHANGELOG.md
|
||||
files: CHANGELOG.md
|
||||
prerelease: true
|
||||
tag_name: beta
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
if ! gh release view beta; then
|
||||
gh release create beta --prerelease --notes-file CHANGELOG.md
|
||||
else
|
||||
gh release edit beta --prerelease --notes-file CHANGELOG.md
|
||||
fi
|
||||
gh release upload beta "CHANGELOG.md" --clobber
|
||||
|
||||
- name: Upload assets to github artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: beta changelog
|
||||
path: ${{ github.workspace }}/CHANGELOG.md
|
||||
@@ -65,51 +64,102 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- target: "!(*musl*|*windows-arm64*|*android*|*freebsd*)" # xgo
|
||||
- target: "!(*musl*|*windows-arm64*|*windows7-*|*android*|*freebsd*)" # xgo and loongarch (exclude mips64le)
|
||||
hash: "md5"
|
||||
- target: "linux-!(arm*)-musl*" #musl-not-arm
|
||||
flags: ""
|
||||
goflags: ""
|
||||
- target: "linux-(mips|mips64|mipsle|mips64le|loong64)-musl*" # musl-compat-family
|
||||
hash: "md5-linux-musl-mips"
|
||||
flags: "-ldflags=-linkmode external -extldflags '-static -fpic'"
|
||||
goflags: ""
|
||||
musl_static: "true"
|
||||
- target: "linux-!(arm*|mips|mips64|mipsle|mips64le|loong64)-musl*" # musl-not-arm (exclude compat-family)
|
||||
hash: "md5-linux-musl"
|
||||
flags: "-ldflags=-linkmode external -extldflags '-static -fpic'"
|
||||
goflags: ""
|
||||
musl_static: "true"
|
||||
- target: "linux-arm*-musl*" #musl-arm
|
||||
hash: "md5-linux-musl-arm"
|
||||
flags: "-ldflags=-linkmode external -extldflags '-static -fpic'"
|
||||
goflags: ""
|
||||
musl_static: "true"
|
||||
- target: "windows-arm64" #win-arm64
|
||||
hash: "md5-windows-arm64"
|
||||
flags: ""
|
||||
goflags: ""
|
||||
musl_static: "false"
|
||||
- target: "windows7-*" #win7
|
||||
hash: "md5-windows7"
|
||||
flags: ""
|
||||
goflags: "-tags=sqlite_cgo_compat"
|
||||
musl_static: "false"
|
||||
- target: "android-*" #android
|
||||
hash: "md5-android"
|
||||
flags: ""
|
||||
goflags: ""
|
||||
musl_static: "false"
|
||||
- target: "freebsd-*" #freebsd
|
||||
hash: "md5-freebsd"
|
||||
flags: ""
|
||||
goflags: ""
|
||||
musl_static: "false"
|
||||
|
||||
name: Beta Release
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
|
||||
with:
|
||||
go-version: "1.22"
|
||||
go-version: "1.27.1"
|
||||
|
||||
- name: Setup web
|
||||
run: bash build.sh dev web
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
FRONTEND_REPO: ${{ vars.FRONTEND_REPO }}
|
||||
|
||||
- name: Build
|
||||
uses: OpenListTeam/cgo-actions@v1.1.2
|
||||
uses: OpenListTeam/cgo-actions@d760a8ec1a6be1f8ec181229e11cb2671797f9e0 # v1.3.0
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
flags: ${{ matrix.flags || '-ldflags=' }}
|
||||
static-link-for-musl: true
|
||||
musl-target-format: $os-$musl-$arch
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
out-dir: build
|
||||
output: openlist-$target$ext
|
||||
musl-base-url: "https://github.com/OpenListTeam/musl-compilers/releases/latest/download/"
|
||||
x-flags: |
|
||||
github.com/OpenListTeam/OpenList/v4/internal/conf.BuiltAt=$built_at
|
||||
github.com/OpenListTeam/OpenList/v4/internal/conf.GitAuthor=OpenList
|
||||
github.com/OpenListTeam/OpenList/v4/internal/conf.GitAuthor=The OpenList Projects Contributors <noreply@oplist.org>
|
||||
github.com/OpenListTeam/OpenList/v4/internal/conf.GitCommit=$git_commit
|
||||
github.com/OpenListTeam/OpenList/v4/internal/conf.Version=$tag
|
||||
github.com/OpenListTeam/OpenList/v4/internal/conf.WebVersion=dev
|
||||
github.com/OpenListTeam/OpenList/v4/internal/conf.WebVersion=rolling
|
||||
env:
|
||||
GOFLAGS: ${{ matrix.goflags }}
|
||||
|
||||
- name: Verify musl binaries are static
|
||||
if: matrix.musl_static == 'true'
|
||||
run: |
|
||||
set -e
|
||||
shopt -s nullglob
|
||||
files=(build/openlist-*-musl-*)
|
||||
if [ ${#files[@]} -eq 0 ]; then
|
||||
echo "No musl binaries found"
|
||||
exit 1
|
||||
fi
|
||||
for f in "${files[@]}"; do
|
||||
if readelf -l "$f" | grep -q "Requesting program interpreter"; then
|
||||
echo "Dynamic binary detected: $f"
|
||||
readelf -l "$f" | grep "Requesting program interpreter" || true
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Compress
|
||||
run: |
|
||||
@@ -117,13 +167,13 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# See above
|
||||
- name: Upload assets to beta release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: build/compress/*
|
||||
prerelease: true
|
||||
tag_name: beta
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
for file in build/compress/*; do
|
||||
gh release upload beta "$file" --clobber
|
||||
done
|
||||
|
||||
- name: Clean illegal characters from matrix.target
|
||||
id: clean_target_name
|
||||
@@ -135,7 +185,7 @@ jobs:
|
||||
echo "cleaned_target=$CLEANED_TARGET" >> $GITHUB_ENV
|
||||
|
||||
- name: Upload assets to github artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: beta builds for ${{ env.cleaned_target }}
|
||||
path: ${{ github.workspace }}/build/compress/*
|
||||
|
||||
+26
-14
@@ -1,8 +1,6 @@
|
||||
name: Test Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main"]
|
||||
pull_request:
|
||||
branches: ["main"]
|
||||
workflow_dispatch:
|
||||
@@ -15,7 +13,6 @@ jobs:
|
||||
build:
|
||||
strategy:
|
||||
matrix:
|
||||
platform: [ubuntu-latest]
|
||||
target:
|
||||
- darwin-amd64
|
||||
- darwin-arm64
|
||||
@@ -24,40 +21,55 @@ jobs:
|
||||
- linux-amd64-musl
|
||||
- windows-arm64
|
||||
- android-arm64
|
||||
name: Build
|
||||
runs-on: ${{ matrix.platform }}
|
||||
name: Build ${{ matrix.target }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
|
||||
- uses: benjlevesque/short-sha@v3.0
|
||||
- uses: benjlevesque/short-sha@dbe07338b37c456ce06d23409b35a56a7815eef4 # v4.0
|
||||
id: short-sha
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
|
||||
with:
|
||||
go-version: "1.22"
|
||||
go-version: "1.27.1"
|
||||
|
||||
- name: Setup web
|
||||
run: bash build.sh dev web
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
FRONTEND_REPO: ${{ vars.FRONTEND_REPO }}
|
||||
|
||||
- name: Build
|
||||
uses: OpenListTeam/cgo-actions@v1.1.2
|
||||
uses: OpenListTeam/cgo-actions@d760a8ec1a6be1f8ec181229e11cb2671797f9e0 # v1.3.0
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
flags: ${{ contains(matrix.target, '-musl') && '-ldflags=-linkmode external -extldflags ''-static -fpic''' || '-ldflags=' }}
|
||||
static-link-for-musl: true
|
||||
musl-target-format: $os-$musl-$arch
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
out-dir: build
|
||||
x-flags: |
|
||||
github.com/OpenListTeam/OpenList/v4/internal/conf.BuiltAt=$built_at
|
||||
github.com/OpenListTeam/OpenList/v4/internal/conf.GitAuthor=OpenList
|
||||
github.com/OpenListTeam/OpenList/v4/internal/conf.GitAuthor=The OpenList Projects Contributors <noreply@oplist.org>
|
||||
github.com/OpenListTeam/OpenList/v4/internal/conf.GitCommit=$git_commit
|
||||
github.com/OpenListTeam/OpenList/v4/internal/conf.Version=$tag
|
||||
github.com/OpenListTeam/OpenList/v4/internal/conf.WebVersion=dev
|
||||
github.com/OpenListTeam/OpenList/v4/internal/conf.WebVersion=rolling
|
||||
output: openlist$ext
|
||||
|
||||
- name: Verify musl binary is static
|
||||
if: contains(matrix.target, '-musl')
|
||||
run: |
|
||||
set -e
|
||||
if readelf -l build/openlist | grep -q "Requesting program interpreter"; then
|
||||
echo "Dynamic binary detected: build/openlist"
|
||||
readelf -l build/openlist | grep "Requesting program interpreter" || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: openlist_${{ env.SHA }}_${{ matrix.target }}
|
||||
name: openlist_${{ steps.short-sha.outputs.sha }}_${{ matrix.target }}
|
||||
path: build/*
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Automatic changelog
|
||||
name: Release Automatic changelog
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
name: Container Registry Cleanup
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
package:
|
||||
description: 'The name of the package to clean up'
|
||||
required: true
|
||||
default: 'openlist-git'
|
||||
owner:
|
||||
description: 'The owner of the package (user or organization)'
|
||||
required: false
|
||||
default: 'openlistteam'
|
||||
older_than:
|
||||
description: 'Delete untagged images older than this many days'
|
||||
required: false
|
||||
default: '-1'
|
||||
untagged_timestamp_tolerance:
|
||||
description: 'Tolerance in milliseconds for untagged images close to tagged ones'
|
||||
required: false
|
||||
# We do this because multi-arch docker containers will be pushed as separate untagged images
|
||||
# to the container registry and GitHub cannot recognize them as part of a tagged image.
|
||||
# Setting this option large enough prevents accidental deletion of one of these images.
|
||||
# Here we set it to 10 seconds
|
||||
default: '10000'
|
||||
token:
|
||||
description: 'GitHub token with permissions to delete packages (leave empty to use default GITHUB_TOKEN)'
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
jobs:
|
||||
cleanup:
|
||||
runs-on: ubuntu-slim
|
||||
|
||||
steps:
|
||||
- name: Check inputs and tools
|
||||
id: setup
|
||||
shell: bash
|
||||
run: |
|
||||
PACKAGE="${{ github.event.inputs.package || 'openlist-git' }}"
|
||||
OLDER="${{ github.event.inputs.older_than || '-1' }}"
|
||||
TOLERANCE_VAL="${{ github.event.inputs.untagged_timestamp_tolerance || '10000' }}"
|
||||
TOKEN_INPUT="${{ github.event.inputs.token || '' }}"
|
||||
OWNER="${{ github.event.inputs.owner || 'openlistteam' }}"
|
||||
|
||||
echo "package_name=$PACKAGE" >> "$GITHUB_OUTPUT"
|
||||
echo "older_than=$OLDER" >> "$GITHUB_OUTPUT"
|
||||
echo "tolerance=$TOLERANCE_VAL" >> "$GITHUB_OUTPUT"
|
||||
echo "owner=$OWNER" >> "$GITHUB_OUTPUT"
|
||||
|
||||
if [[ -z "$TOKEN_INPUT" ]]; then
|
||||
echo "::notice:: No token provided, using GITHUB_TOKEN instead."
|
||||
echo "token=${{ secrets.GITHUB_TOKEN }}" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "::add-mask::$TOKEN_INPUT"
|
||||
echo "token=$TOKEN_INPUT" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
if [[ -z "$PACKAGE" || -z "$OWNER" || ( -z "$TOKEN_INPUT" && -z "${{ secrets.GITHUB_TOKEN }}" ) ]]; then
|
||||
echo "::error:: Missing required inputs (package, token, or owner context)."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
command -v curl >/dev/null 2>&1 || { echo "::error::curl is required but not installed."; exit 1; }
|
||||
command -v jq >/dev/null 2>&1 || { echo "::error::jq is required but not installed."; exit 1; }
|
||||
|
||||
- name: Determine owner type (user or organization)
|
||||
id: owner_type
|
||||
shell: bash
|
||||
run: |
|
||||
OWNER="${{ steps.setup.outputs.owner }}"
|
||||
TOKEN="${{ steps.setup.outputs.token }}"
|
||||
|
||||
TYPE=$(curl -s -H "Authorization: token $TOKEN" \
|
||||
-H "Accept: application/vnd.github.v3+json" \
|
||||
"https://api.github.com/users/${OWNER}" | jq -r '.type')
|
||||
|
||||
if [[ "$TYPE" == "Organization" ]]; then
|
||||
OWNER_PATH="orgs"
|
||||
else
|
||||
OWNER_PATH="users"
|
||||
fi
|
||||
|
||||
echo "owner_path=$OWNER_PATH" >> "$GITHUB_OUTPUT"
|
||||
echo "Detected owner type: $TYPE (using /${OWNER_PATH}/)"
|
||||
|
||||
- name: Access all versions from GitHub
|
||||
id: fetch
|
||||
shell: bash
|
||||
run: |
|
||||
PACKAGE_NAME="${{ steps.setup.outputs.package_name }}"
|
||||
OWNER="${{ steps.setup.outputs.owner }}"
|
||||
TOKEN="${{ steps.setup.outputs.token }}"
|
||||
OWNER_PATH="${{ steps.owner_type.outputs.owner_path }}"
|
||||
|
||||
BASE_URL="https://api.github.com/${OWNER_PATH}/${OWNER}/packages/container/${PACKAGE_NAME}/versions"
|
||||
URL="${BASE_URL}?per_page=100"
|
||||
TEMP_FILE="/tmp/all_versions.ndjson"
|
||||
|
||||
> "$TEMP_FILE"
|
||||
PAGE=1
|
||||
while [[ -n "$URL" && $PAGE -le 10 ]]; do
|
||||
echo "Fetching: $URL"
|
||||
|
||||
curl -s -H "Authorization: token $TOKEN" \
|
||||
-H "Accept: application/vnd.github.v3+json" \
|
||||
-D headers.txt \
|
||||
"$URL" > page.json
|
||||
|
||||
jq -c '.[]' page.json >> "$TEMP_FILE"
|
||||
|
||||
URL=$(grep -i '^link:' headers.txt | grep -o '<[^>]*>;\s*rel="next"' | sed 's/<\(.*\)>.*/\1/' || true)
|
||||
PAGE=$((PAGE + 1))
|
||||
done
|
||||
rm -f headers.txt page.json
|
||||
|
||||
echo "temp_file=$TEMP_FILE" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Filter untagged versions
|
||||
id: filter
|
||||
shell: bash
|
||||
run: |
|
||||
OLDER_THAN="${{ steps.setup.outputs.older_than }}"
|
||||
TOLERANCE="${{ steps.setup.outputs.tolerance }}"
|
||||
TEMP_FILE="${{ steps.fetch.outputs.temp_file }}"
|
||||
|
||||
TO_DELETE=$(jq -c -s \
|
||||
--arg older_than "$OLDER_THAN" \
|
||||
--arg tolerance "$TOLERANCE" \
|
||||
'
|
||||
def abs: if . < 0 then -. else . end;
|
||||
def to_ms: sub("\\.[0-9]+Z$"; "Z") | strptime("%Y-%m-%dT%H:%M:%SZ") | mktime * 1000;
|
||||
|
||||
. as $all |
|
||||
($older_than | tonumber) as $older_than_val |
|
||||
($tolerance | tonumber) as $tolerance_val |
|
||||
now as $now |
|
||||
(if $older_than_val > 0 then ($now - ($older_than_val * 86400)) * 1000 else 0 end) as $cutoff_ms |
|
||||
|
||||
[ $all[] | select((.metadata.container.tags // []) | length > 0) | .created_at | to_ms ] as $tagged_times |
|
||||
|
||||
$all[] |
|
||||
select((.metadata.container.tags // []) | length == 0) |
|
||||
. as $item |
|
||||
(.created_at | to_ms) as $created_ms |
|
||||
|
||||
(if $older_than_val > 0 then $created_ms < $cutoff_ms else true end) as $pass_age |
|
||||
|
||||
(if $tolerance_val <= 0 then true
|
||||
else [ $tagged_times[] | select(($created_ms - .) | abs < $tolerance_val) ] | length == 0
|
||||
end) as $pass_tolerance |
|
||||
|
||||
select($pass_age and $pass_tolerance) |
|
||||
{id, name}
|
||||
' "$TEMP_FILE")
|
||||
|
||||
rm -f "$TEMP_FILE"
|
||||
|
||||
if [[ -z "$TO_DELETE" ]]; then
|
||||
DELETE_COUNT=0
|
||||
else
|
||||
DELETE_COUNT=$(echo "$TO_DELETE" | wc -l | tr -d ' ')
|
||||
fi
|
||||
|
||||
echo "Found $DELETE_COUNT untagged images no longer necessary"
|
||||
|
||||
{
|
||||
echo 'to_delete<<EOF'
|
||||
echo "$TO_DELETE"
|
||||
echo 'EOF'
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
echo "delete_count=$DELETE_COUNT" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Delete untagged versions
|
||||
id: delete
|
||||
shell: bash
|
||||
run: |
|
||||
PACKAGE_NAME="${{ steps.setup.outputs.package_name }}"
|
||||
OWNER="${{ steps.setup.outputs.owner }}"
|
||||
TOKEN="${{ steps.setup.outputs.token }}"
|
||||
OWNER_PATH="${{ steps.owner_type.outputs.owner_path }}"
|
||||
DELETE_COUNT="${{ steps.filter.outputs.delete_count }}"
|
||||
TO_DELETE='${{ steps.filter.outputs.to_delete }}'
|
||||
|
||||
if [[ "$DELETE_COUNT" -gt 0 && -n "$TO_DELETE" ]]; then
|
||||
while IFS= read -r item; do
|
||||
[[ -z "$item" ]] && continue
|
||||
|
||||
# $item 是完整的单行 JSON,如 {"id":123,"name":"sha256:..."}
|
||||
ID=$(echo "$item" | jq -r '.id')
|
||||
NAME=$(echo "$item" | jq -r '.name')
|
||||
|
||||
DELETE_URL="https://api.github.com/${OWNER_PATH}/${OWNER}/packages/container/${PACKAGE_NAME}/versions/${ID}"
|
||||
|
||||
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X DELETE \
|
||||
-H "Authorization: token $TOKEN" \
|
||||
-H "Accept: application/vnd.github.v3+json" \
|
||||
"$DELETE_URL")
|
||||
|
||||
if [[ "$HTTP_CODE" == "204" ]]; then
|
||||
echo "Deleted untagged container image '${NAME}'"
|
||||
else
|
||||
echo "::warning:: Failed to delete ${NAME} (ID: ${ID}). HTTP Status: ${HTTP_CODE}"
|
||||
fi
|
||||
done <<< "$TO_DELETE"
|
||||
else
|
||||
echo "No images to delete."
|
||||
fi
|
||||
|
||||
echo "Cleanup completed."
|
||||
@@ -0,0 +1,75 @@
|
||||
name: Issue Auto Reply
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
auto-reply:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check issue for unchecked tasks and reply
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
||||
with:
|
||||
script: |
|
||||
if (context.payload.issue.title.startsWith('[Announcements]')) return;
|
||||
const titleNotEdited = /(请修改标题|Please modify the title)/i.test(context.payload.issue.title);
|
||||
const issueBody = context.payload.issue.body || "";
|
||||
const aiSection = issueBody.match(/^### (AI生成内容|AI Generated Content)\r?\n([\s\S]*?)(?=^### |$(?![\s\S]))/m);
|
||||
const aiOptionsPattern = aiSection?.[1] === 'AI生成内容'
|
||||
? /^\s*- \[([ xX])\] 我使用了AI工具生成此内容\r?\n- \[([ xX])\] 我没有使用AI工具生成此内容\s*$/
|
||||
: /^\s*- \[([ xX])\] I used AI tools to generate this content\r?\n- \[([ xX])\] I did not use AI tools to generate this content\s*$/;
|
||||
const aiOptions = (aiSection?.[2] || '').match(aiOptionsPattern);
|
||||
const validAiDisclosure = aiOptions !== null && (aiOptions[1] !== ' ') !== (aiOptions[2] !== ' ');
|
||||
const aiModelSection = issueBody.match(/^### (AI模型是|AI model used)\r?\n([\s\S]*?)(?=^### |$(?![\s\S]))/m);
|
||||
const aiModel = (aiModelSection?.[2] || '').trim();
|
||||
const missingAiModel = validAiDisclosure && aiOptions[1] !== ' ' && (aiModel === '' || aiModel === '_No response_');
|
||||
const confirmNotRead = /- \[[xX]\] (?:我没有阅读这个清单|I have not read these checkboxes)/.test(issueBody);
|
||||
const closeIssue = titleNotEdited || confirmNotRead || !validAiDisclosure || missingAiModel;
|
||||
let comment;
|
||||
if (titleNotEdited) {
|
||||
comment = `⚠️ 请修改标题以更好地描述您的问题或需求,并删除示例提示。当前 Issue 将被自动关闭并锁定。如需继续提交,请创建新的 Issue。
|
||||
⚠️ Please modify the title to better describe your issue or request, and remove the example prompt. This issue will be automatically closed and locked. If you wish to proceed, please create a new issue.
|
||||
`;
|
||||
} else if (confirmNotRead || !validAiDisclosure || missingAiModel) {
|
||||
comment = `⚠️ 你的 Issue 不符合提交规则。请先阅读相关规范后再重新提交。当前 Issue 将被自动关闭并锁定。如需继续提交,请确认已了解规则后创建新的 Issue。
|
||||
⚠️ Your issue does not comply with the submission rules. Please read the guidelines before submitting again. This issue will be automatically closed and locked. If you wish to proceed, please confirm that you have reviewed the rules before creating a new issue.
|
||||
`;
|
||||
} else if (/- \[ \] (?!我没有阅读这个清单|I have not read these checkboxes)/.test(issueBody.replace(aiSection[0], ''))) {
|
||||
comment = `感谢您联系OpenList。我们会尽快回复您。
|
||||
Thanks for contacting OpenList. We will reply to you as soon as possible.
|
||||
|
||||
由于您提出的 Issue 中包含部分未确认的项目,为了更好地管理项目,在人工审核后可能会直接关闭此问题。
|
||||
如果您能确认并补充相关未确认项目的信息,欢迎随时重新提交。我们会及时关注并处理。感谢您的理解与支持!
|
||||
Since your issue contains some unchecked tasks, it may be closed after manual review.
|
||||
If you can confirm and provide information for the unchecked tasks, feel free to resubmit.
|
||||
We will pay attention and handle it in a timely manner.
|
||||
|
||||
感谢您的理解与支持!
|
||||
Thank you for your understanding and support!
|
||||
`;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
...context.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: comment
|
||||
});
|
||||
if (closeIssue) {
|
||||
await github.rest.issues.update({
|
||||
...context.repo,
|
||||
issue_number: context.issue.number,
|
||||
state: 'closed',
|
||||
state_reason: 'not_planned',
|
||||
labels: ['invalid']
|
||||
});
|
||||
await github.rest.issues.lock({
|
||||
...context.repo,
|
||||
issue_number: context.issue.number
|
||||
});
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
name: Issue or PR Auto Reply
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
pull_request:
|
||||
types: [opened]
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
auto-reply:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'issues'
|
||||
steps:
|
||||
- name: Check issue for unchecked tasks and reply
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const issueBody = context.payload.issue.body || "";
|
||||
const unchecked = /- \[ \] /.test(issueBody);
|
||||
let comment = "感谢您联系OpenList。我们会尽快回复您。\n";
|
||||
comment += "Thanks for contacting OpenList. We will reply to you as soon as possible.\n\n";
|
||||
if (unchecked) {
|
||||
comment += "由于您提出的 Issue 中包含部分未确认的项目,为了更好地管理项目,在人工审核后可能会直接关闭此问题。\n";
|
||||
comment += "如果您能确认并补充相关未确认项目的信息,欢迎随时重新提交。我们会及时关注并处理。感谢您的理解与支持!\n";
|
||||
comment += "Since your issue contains some unchecked tasks, it may be closed after manual review.\n";
|
||||
comment += "If you can confirm and provide information for the unchecked tasks, feel free to resubmit.\n";
|
||||
comment += "We will pay attention and handle it in a timely manner.\n\n";
|
||||
comment += "感谢您的理解与支持!\n";
|
||||
comment += "Thank you for your understanding and support!\n";
|
||||
}
|
||||
await github.rest.issues.createComment({
|
||||
...context.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: comment
|
||||
});
|
||||
|
||||
pr-title-check:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'pull_request'
|
||||
steps:
|
||||
- name: Check PR title for required prefix and comment
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const title = context.payload.pull_request.title || "";
|
||||
const ok = /^(feat|docs|fix|style|refactor|chore)\(.+?\): /i.test(title);
|
||||
if (!ok) {
|
||||
let comment = "⚠️ PR 标题需以 `feat(): `, `docs(): `, `fix(): `, `style(): `, `refactor(): `, `chore(): ` 其中之一开头,例如:`feat(component): 新增功能`。\n";
|
||||
comment += "⚠️ The PR title must start with `feat(): `, `docs(): `, `fix(): `, `style(): `, or `refactor(): `, `chore(): `. For example: `feat(component): add new feature`.\n\n";
|
||||
comment += "如果跨多个组件,请使用主要组件作为前缀,并在标题中枚举、描述中说明。\n";
|
||||
comment += "If it spans multiple components, use the main component as the prefix and enumerate in the title, describe in the body.\n\n";
|
||||
await github.rest.issues.createComment({
|
||||
...context.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: comment
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
name: PR Title Check
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened]
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
pr-title-check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check PR title for required prefix and comment
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
||||
with:
|
||||
script: |
|
||||
const title = context.payload.pull_request.title;
|
||||
if (/^(feat|docs|fix|style|refactor|chore)\(.+?\)!?: /i.test(title)) return;
|
||||
const comment = `⚠️ PR 标题需以 \`feat(): \`, \`docs(): \`, \`fix(): \`, \`style(): \`, \`refactor(): \`, \`chore(): \` 其中之一开头,例如:\`feat(component): 新增功能\`。
|
||||
⚠️ The PR title must start with \`feat(): \`, \`docs(): \`, \`fix(): \`, \`style(): \`, or \`refactor(): \`, \`chore(): \`. For example: \`feat(component): add new feature\`.
|
||||
|
||||
如果跨多个组件,请使用主要组件作为前缀,并在标题中枚举、描述中说明。
|
||||
If it spans multiple components, use the main component as the prefix and enumerate in the title, describe in the body.
|
||||
|
||||
如果是破坏性变更,请在类型后添加 \`!\`,例如 \`feat(component)!: 破坏性变更\`。
|
||||
For breaking changes, add \`!\` after the type, e.g., \`feat(component)!: breaking change\`.
|
||||
|
||||
`;
|
||||
await github.rest.issues.createComment({
|
||||
...context.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: comment
|
||||
});
|
||||
@@ -8,24 +8,34 @@ permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
# Set release to prerelease first
|
||||
prerelease:
|
||||
name: Set Prerelease
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Prerelease
|
||||
uses: irongut/EditRelease@ccf529ad26dddf9996e7dd0f24ca5da4ea507cc2 # v1.2.0
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
id: ${{ github.event.release.id }}
|
||||
prerelease: true
|
||||
|
||||
# Main release job for all platforms
|
||||
release:
|
||||
needs: prerelease
|
||||
strategy:
|
||||
matrix:
|
||||
platform: [ ubuntu-latest ]
|
||||
go-version: [ '1.21' ]
|
||||
name: Release
|
||||
runs-on: ${{ matrix.platform }}
|
||||
build-type: [ 'standard', 'lite' ]
|
||||
target-platform: [ '', 'android', 'freebsd', 'linux_musl', 'linux_musl_arm' ]
|
||||
name: Release ${{ matrix.target-platform && format('{0} ', matrix.target-platform) || '' }}${{ matrix.build-type == 'lite' && 'Lite' || '' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
- name: Free Disk Space (Ubuntu)
|
||||
uses: jlumbroso/free-disk-space@main
|
||||
if: matrix.target-platform == ''
|
||||
uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be # main
|
||||
with:
|
||||
# this might remove tools that are actually needed,
|
||||
# if set to "true" but frees about 6 GB
|
||||
tool-cache: false
|
||||
|
||||
# all of these default to true, but feel free to set to
|
||||
# "false" if necessary for your workflow
|
||||
android: true
|
||||
dotnet: true
|
||||
haskell: true
|
||||
@@ -33,24 +43,19 @@ jobs:
|
||||
docker-images: true
|
||||
swap-storage: true
|
||||
|
||||
- name: Prerelease
|
||||
uses: irongut/EditRelease@v1.2.0
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
id: ${{ github.event.release.id }}
|
||||
prerelease: true
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
|
||||
with:
|
||||
go-version: ${{ matrix.go-version }}
|
||||
go-version: '1.27.1'
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-depth: 1
|
||||
fetch-tags: true
|
||||
|
||||
- name: Install dependencies
|
||||
if: matrix.target-platform == ''
|
||||
run: |
|
||||
sudo snap install zig --classic --beta
|
||||
docker pull crazymax/xgo:latest
|
||||
@@ -59,74 +64,15 @@ jobs:
|
||||
|
||||
- name: Build
|
||||
run: |
|
||||
bash build.sh release
|
||||
bash build.sh release ${{ matrix.build-type == 'lite' && 'lite' || '' }} ${{ matrix.target-platform }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
FRONTEND_REPO: ${{ vars.FRONTEND_REPO }}
|
||||
|
||||
- name: Upload assets
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: build/compress/*
|
||||
prerelease: false
|
||||
|
||||
release-lite:
|
||||
strategy:
|
||||
matrix:
|
||||
platform: [ ubuntu-latest ]
|
||||
go-version: [ '1.21' ]
|
||||
name: Release Lite
|
||||
runs-on: ${{ matrix.platform }}
|
||||
steps:
|
||||
|
||||
- name: Free Disk Space (Ubuntu)
|
||||
uses: jlumbroso/free-disk-space@main
|
||||
with:
|
||||
# this might remove tools that are actually needed,
|
||||
# if set to "true" but frees about 6 GB
|
||||
tool-cache: false
|
||||
|
||||
# all of these default to true, but feel free to set to
|
||||
# "false" if necessary for your workflow
|
||||
android: true
|
||||
dotnet: true
|
||||
haskell: true
|
||||
large-packages: true
|
||||
docker-images: true
|
||||
swap-storage: true
|
||||
|
||||
- name: Prerelease
|
||||
uses: irongut/EditRelease@v1.2.0
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
id: ${{ github.event.release.id }}
|
||||
prerelease: true
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ matrix.go-version }}
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
sudo snap install zig --classic --beta
|
||||
docker pull crazymax/xgo:latest
|
||||
go install github.com/crazy-max/xgo@latest
|
||||
sudo apt install upx
|
||||
|
||||
- name: Build
|
||||
run: |
|
||||
bash build.sh release lite
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Upload assets
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: build/compress/*
|
||||
prerelease: false
|
||||
|
||||
run: |
|
||||
for file in build/compress/*; do
|
||||
gh release upload "${{ github.event.release.tag_name }}" "$file" --clobber
|
||||
done
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
name: Release builds (Android)
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [ published ]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
release_android:
|
||||
strategy:
|
||||
matrix:
|
||||
platform: [ ubuntu-latest ]
|
||||
go-version: [ '1.21' ]
|
||||
name: Release
|
||||
runs-on: ${{ matrix.platform }}
|
||||
steps:
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ matrix.go-version }}
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Build
|
||||
run: |
|
||||
bash build.sh release android
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Upload assets
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: build/compress/*
|
||||
|
||||
release_android_lite:
|
||||
strategy:
|
||||
matrix:
|
||||
platform: [ ubuntu-latest ]
|
||||
go-version: [ '1.21' ]
|
||||
name: Release
|
||||
runs-on: ${{ matrix.platform }}
|
||||
steps:
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ matrix.go-version }}
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Build
|
||||
run: |
|
||||
bash build.sh release lite android
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Upload assets
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: build/compress/*
|
||||
@@ -31,11 +31,8 @@ env:
|
||||
REGISTRY: ghcr.io
|
||||
ARTIFACT_NAME: 'binaries_docker_release'
|
||||
ARTIFACT_NAME_LITE: 'binaries_docker_release_lite'
|
||||
RELEASE_PLATFORMS: 'linux/amd64,linux/arm64,linux/arm/v7,linux/386,linux/arm/v6,linux/s390x,linux/ppc64le,linux/riscv64'
|
||||
RELEASE_PLATFORMS: 'linux/amd64,linux/arm64,linux/arm/v7,linux/386,linux/arm/v6,linux/ppc64le,linux/riscv64,linux/loong64' ### Temporarily disable Docker builds for linux/s390x architectures for unknown reasons.
|
||||
IMAGE_PUSH: ${{ github.event_name == 'push' || github.event_name == 'workflow_dispatch' }}
|
||||
IMAGE_IS_PROD: ${{ github.ref_type == 'tag' || github.event.inputs.as_latest == 'true' }}
|
||||
IMAGE_TAGS_BETA: |
|
||||
type=raw,value=beta,enable={{is_default_branch}}
|
||||
|
||||
permissions:
|
||||
packages: write
|
||||
@@ -46,15 +43,15 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
|
||||
with:
|
||||
go-version: 'stable'
|
||||
go-version: '1.27'
|
||||
|
||||
- name: Cache Musl
|
||||
id: cache-musl
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: build/musl-libs
|
||||
key: docker-musl-libs-v2
|
||||
@@ -65,20 +62,14 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build go binary (beta)
|
||||
if: env.IMAGE_IS_PROD != 'true'
|
||||
run: bash build.sh beta docker-multiplatform
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build go binary (release)
|
||||
if: env.IMAGE_IS_PROD == 'true'
|
||||
run: bash build.sh release docker-multiplatform
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
FRONTEND_REPO: ${{ vars.FRONTEND_REPO }}
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: ${{ env.ARTIFACT_NAME }}
|
||||
overwrite: true
|
||||
@@ -88,19 +79,19 @@ jobs:
|
||||
!build/musl-libs/**
|
||||
|
||||
build_binary_lite:
|
||||
name: Build Binaries for Docker Release
|
||||
name: Build Binaries for Docker Release (Lite)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
|
||||
with:
|
||||
go-version: 'stable'
|
||||
go-version: '1.27.1'
|
||||
|
||||
- name: Cache Musl
|
||||
id: cache-musl
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: build/musl-libs
|
||||
key: docker-musl-libs-v2
|
||||
@@ -111,20 +102,14 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build go binary (beta)
|
||||
if: env.IMAGE_IS_PROD != 'true'
|
||||
run: bash build.sh beta lite docker-multiplatform
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build go binary (release)
|
||||
if: env.IMAGE_IS_PROD == 'true'
|
||||
run: bash build.sh release lite docker-multiplatform
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
FRONTEND_REPO: ${{ vars.FRONTEND_REPO }}
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: ${{ env.ARTIFACT_NAME_LITE }}
|
||||
overwrite: true
|
||||
@@ -142,36 +127,40 @@ jobs:
|
||||
image: ["latest", "ffmpeg", "aria2", "aio"]
|
||||
include:
|
||||
- image: "latest"
|
||||
base_image_tag: "base"
|
||||
build_arg: ""
|
||||
tag_favor: ""
|
||||
- image: "ffmpeg"
|
||||
base_image_tag: "ffmpeg"
|
||||
build_arg: INSTALL_FFMPEG=true
|
||||
tag_favor: "suffix=-ffmpeg,onlatest=true"
|
||||
- image: "aria2"
|
||||
base_image_tag: "aria2"
|
||||
build_arg: INSTALL_ARIA2=true
|
||||
tag_favor: "suffix=-aria2,onlatest=true"
|
||||
- image: "aio"
|
||||
base_image_tag: "aio"
|
||||
build_arg: |
|
||||
INSTALL_FFMPEG=true
|
||||
INSTALL_ARIA2=true
|
||||
tag_favor: "suffix=-aio,onlatest=true"
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- uses: actions/download-artifact@v4
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
name: ${{ env.ARTIFACT_NAME }}
|
||||
path: 'build/'
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@1f40c72289eff860ee54a304f1438e3cff362e0a # v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
if: env.IMAGE_PUSH == 'true'
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
@@ -179,79 +168,83 @@ jobs:
|
||||
|
||||
- name: Login to DockerHub Container Registry
|
||||
if: env.IMAGE_PUSH == 'true'
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
|
||||
with:
|
||||
username: ${{ env.DOCKERHUB_ORG_NAME }}
|
||||
username: ${{ vars.DOCKERHUB_ORG_NAME_BACKUP || env.DOCKERHUB_ORG_NAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6
|
||||
with:
|
||||
images: |
|
||||
${{ env.REGISTRY }}/${{ env.GHCR_ORG_NAME }}/${{ env.IMAGE_NAME }}
|
||||
${{ env.DOCKERHUB_ORG_NAME }}/${{ env.IMAGE_NAME_DOCKERHUB }}
|
||||
tags: >
|
||||
${{ env.IMAGE_IS_PROD == 'true' && (
|
||||
github.event_name == 'workflow_dispatch'
|
||||
${{ github.event_name == 'workflow_dispatch'
|
||||
&& format('type=raw,value={0}', github.event.inputs.manual_tag)
|
||||
|| format('type=raw,value={0}', github.ref_name)
|
||||
) || env.IMAGE_TAGS_BETA }}
|
||||
|| format('type=raw,value={0}', github.ref_name) }}
|
||||
flavor: |
|
||||
latest=${{ env.IMAGE_IS_PROD }}
|
||||
latest=${{ github.event_name == 'push' || github.event.inputs.as_latest == 'true' }}
|
||||
${{ matrix.tag_favor }}
|
||||
|
||||
- name: Build and push
|
||||
id: docker_build
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile.ci
|
||||
push: ${{ env.IMAGE_PUSH == 'true' }}
|
||||
build-args: ${{ matrix.build_arg }}
|
||||
build-args: |
|
||||
BASE_IMAGE_TAG=${{ matrix.base_image_tag }}
|
||||
${{ matrix.build_arg }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
platforms: ${{ env.RELEASE_PLATFORMS }}
|
||||
|
||||
release_docker_lite:
|
||||
needs: build_binary_lite
|
||||
name: Release Docker image
|
||||
name: Release Docker image (Lite)
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
image: ["latest", "ffmpeg", "aria2", "aio"]
|
||||
include:
|
||||
- image: "latest"
|
||||
base_image_tag: "base"
|
||||
build_arg: ""
|
||||
tag_favor: "suffix=-lite,onlatest=true"
|
||||
- image: "ffmpeg"
|
||||
base_image_tag: "ffmpeg"
|
||||
build_arg: INSTALL_FFMPEG=true
|
||||
tag_favor: "suffix=-lite-ffmpeg,onlatest=true"
|
||||
- image: "aria2"
|
||||
base_image_tag: "aria2"
|
||||
build_arg: INSTALL_ARIA2=true
|
||||
tag_favor: "suffix=-lite-aria2,onlatest=true"
|
||||
- image: "aio"
|
||||
base_image_tag: "aio"
|
||||
build_arg: |
|
||||
INSTALL_FFMPEG=true
|
||||
INSTALL_ARIA2=true
|
||||
tag_favor: "suffix=-lite-aio,onlatest=true"
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- uses: actions/download-artifact@v4
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
name: ${{ env.ARTIFACT_NAME_LITE }}
|
||||
path: 'build/'
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@1f40c72289eff860ee54a304f1438e3cff362e0a # v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
if: env.IMAGE_PUSH == 'true'
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
@@ -259,36 +252,36 @@ jobs:
|
||||
|
||||
- name: Login to DockerHub Container Registry
|
||||
if: env.IMAGE_PUSH == 'true'
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
|
||||
with:
|
||||
username: ${{ env.DOCKERHUB_ORG_NAME }}
|
||||
username: ${{ vars.DOCKERHUB_ORG_NAME_BACKUP || env.DOCKERHUB_ORG_NAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6
|
||||
with:
|
||||
images: |
|
||||
${{ env.REGISTRY }}/${{ env.GHCR_ORG_NAME }}/${{ env.IMAGE_NAME }}
|
||||
${{ env.DOCKERHUB_ORG_NAME }}/${{ env.IMAGE_NAME_DOCKERHUB }}
|
||||
tags: >
|
||||
${{ env.IMAGE_IS_PROD == 'true' && (
|
||||
github.event_name == 'workflow_dispatch'
|
||||
${{ github.event_name == 'workflow_dispatch'
|
||||
&& format('type=raw,value={0}', github.event.inputs.manual_tag)
|
||||
|| format('type=raw,value={0}', github.ref_name)
|
||||
) || env.IMAGE_TAGS_BETA }}
|
||||
|| format('type=raw,value={0}', github.ref_name) }}
|
||||
flavor: |
|
||||
latest=${{ env.IMAGE_IS_PROD }}
|
||||
latest=${{ github.event_name == 'push' || github.event.inputs.as_latest == 'true' }}
|
||||
${{ matrix.tag_favor }}
|
||||
|
||||
- name: Build and push
|
||||
id: docker_build
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile.ci
|
||||
push: ${{ env.IMAGE_PUSH == 'true' }}
|
||||
build-args: ${{ matrix.build_arg }}
|
||||
build-args: |
|
||||
BASE_IMAGE_TAG=${{ matrix.base_image_tag }}
|
||||
${{ matrix.build_arg }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
platforms: ${{ env.RELEASE_PLATFORMS }}
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
name: Release builds (Freebsd)
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [ published ]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
release_freebsd:
|
||||
strategy:
|
||||
matrix:
|
||||
platform: [ ubuntu-latest ]
|
||||
go-version: [ '1.21' ]
|
||||
name: Release
|
||||
runs-on: ${{ matrix.platform }}
|
||||
steps:
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ matrix.go-version }}
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Build
|
||||
run: |
|
||||
bash build.sh release freebsd
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Upload assets
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: build/compress/*
|
||||
|
||||
release_freebsd_lite:
|
||||
strategy:
|
||||
matrix:
|
||||
platform: [ ubuntu-latest ]
|
||||
go-version: [ '1.21' ]
|
||||
name: Release
|
||||
runs-on: ${{ matrix.platform }}
|
||||
steps:
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ matrix.go-version }}
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Build
|
||||
run: |
|
||||
bash build.sh release lite freebsd
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Upload assets
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: build/compress/*
|
||||
@@ -1,69 +0,0 @@
|
||||
name: Release builds (linux_musl)
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [ published ]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
release_linux_musl:
|
||||
strategy:
|
||||
matrix:
|
||||
platform: [ ubuntu-latest ]
|
||||
go-version: [ '1.21' ]
|
||||
name: Release
|
||||
runs-on: ${{ matrix.platform }}
|
||||
steps:
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ matrix.go-version }}
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Build
|
||||
run: |
|
||||
bash build.sh release linux_musl
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Upload assets
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: build/compress/*
|
||||
|
||||
release_linux_musl_lite:
|
||||
strategy:
|
||||
matrix:
|
||||
platform: [ ubuntu-latest ]
|
||||
go-version: [ '1.21' ]
|
||||
name: Release
|
||||
runs-on: ${{ matrix.platform }}
|
||||
steps:
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ matrix.go-version }}
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Build
|
||||
run: |
|
||||
bash build.sh release lite linux_musl
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Upload assets
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: build/compress/*
|
||||
@@ -1,70 +0,0 @@
|
||||
name: Release builds (linux_musl_arm)
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [ published ]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
release_linux_musl_arm:
|
||||
strategy:
|
||||
matrix:
|
||||
platform: [ ubuntu-latest ]
|
||||
go-version: [ '1.21' ]
|
||||
name: Release
|
||||
runs-on: ${{ matrix.platform }}
|
||||
steps:
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ matrix.go-version }}
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Build
|
||||
run: |
|
||||
bash build.sh release linux_musl_arm
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Upload assets
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: build/compress/*
|
||||
|
||||
release_linux_musl_arm_lite:
|
||||
strategy:
|
||||
matrix:
|
||||
platform: [ ubuntu-latest ]
|
||||
go-version: [ '1.21' ]
|
||||
name: Release
|
||||
runs-on: ${{ matrix.platform }}
|
||||
steps:
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ matrix.go-version }}
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Build
|
||||
run: |
|
||||
bash build.sh release lite linux_musl_arm
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Upload assets
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: build/compress/*
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
name: Sync to Gitee
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
runs-on: ubuntu-latest
|
||||
name: Sync GitHub to Gitee
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup SSH
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.GITEE_SSH_PRIVATE_KEY }}" > ~/.ssh/id_rsa
|
||||
chmod 600 ~/.ssh/id_rsa
|
||||
ssh-keyscan gitee.com >> ~/.ssh/known_hosts
|
||||
|
||||
- name: Create single commit and push
|
||||
run: |
|
||||
git config user.name "GitHub Actions"
|
||||
git config user.email "actions@github.com"
|
||||
|
||||
# Create a new branch
|
||||
git checkout --orphan new-main
|
||||
git add .
|
||||
git commit -m "Sync from GitHub: $(date)"
|
||||
|
||||
# Add Gitee remote and force push
|
||||
git remote add gitee ${{ vars.GITEE_REPO_URL }}
|
||||
git push --force gitee new-main:main
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Docker Beta Release
|
||||
name: Beta Release (Docker)
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
@@ -20,8 +20,7 @@ env:
|
||||
IMAGE_NAME_DOCKERHUB: openlist
|
||||
REGISTRY: ghcr.io
|
||||
ARTIFACT_NAME: 'binaries_docker_release'
|
||||
ARTIFACT_NAME_LITE: 'binaries_docker_release_lite'
|
||||
RELEASE_PLATFORMS: 'linux/amd64,linux/arm64,linux/arm/v7,linux/386,linux/arm/v6,linux/s390x,linux/ppc64le,linux/riscv64'
|
||||
RELEASE_PLATFORMS: 'linux/amd64,linux/arm64,linux/arm/v7,linux/386,linux/arm/v6,linux/ppc64le,linux/riscv64,linux/loong64' ### Temporarily disable Docker builds for linux/s390x architectures for unknown reasons.
|
||||
IMAGE_PUSH: ${{ github.event_name == 'push' }}
|
||||
IMAGE_TAGS_BETA: |
|
||||
type=ref,event=pr
|
||||
@@ -29,19 +28,19 @@ env:
|
||||
|
||||
jobs:
|
||||
build_binary:
|
||||
name: Build Binaries for Docker Release
|
||||
name: Build Binaries for Docker Release (Beta)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
|
||||
with:
|
||||
go-version: 'stable'
|
||||
go-version: '1.27.1'
|
||||
|
||||
- name: Cache Musl
|
||||
id: cache-musl
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: build/musl-libs
|
||||
key: docker-musl-libs-v2
|
||||
@@ -56,9 +55,10 @@ jobs:
|
||||
run: bash build.sh beta docker-multiplatform
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
FRONTEND_REPO: ${{ vars.FRONTEND_REPO }}
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: ${{ env.ARTIFACT_NAME }}
|
||||
overwrite: true
|
||||
@@ -69,7 +69,7 @@ jobs:
|
||||
|
||||
release_docker:
|
||||
needs: build_binary
|
||||
name: Release Docker image
|
||||
name: Release Docker image (Beta)
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
packages: write
|
||||
@@ -78,36 +78,40 @@ jobs:
|
||||
image: ["latest", "ffmpeg", "aria2", "aio"]
|
||||
include:
|
||||
- image: "latest"
|
||||
base_image_tag: "base"
|
||||
build_arg: ""
|
||||
tag_favor: ""
|
||||
- image: "ffmpeg"
|
||||
base_image_tag: "ffmpeg"
|
||||
build_arg: INSTALL_FFMPEG=true
|
||||
tag_favor: "suffix=-ffmpeg,onlatest=true"
|
||||
- image: "aria2"
|
||||
base_image_tag: "aria2"
|
||||
build_arg: INSTALL_ARIA2=true
|
||||
tag_favor: "suffix=-aria2,onlatest=true"
|
||||
- image: "aio"
|
||||
base_image_tag: "aio"
|
||||
build_arg: |
|
||||
INSTALL_FFMPEG=true
|
||||
INSTALL_ARIA2=true
|
||||
tag_favor: "suffix=-aio,onlatest=true"
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- uses: actions/download-artifact@v4
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
name: ${{ env.ARTIFACT_NAME }}
|
||||
path: 'build/'
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@1f40c72289eff860ee54a304f1438e3cff362e0a # v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
if: env.IMAGE_PUSH == 'true'
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
@@ -115,14 +119,14 @@ jobs:
|
||||
|
||||
- name: Login to DockerHub Container Registry
|
||||
if: env.IMAGE_PUSH == 'true'
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
|
||||
with:
|
||||
username: ${{ env.DOCKERHUB_ORG_NAME }}
|
||||
username: ${{ vars.DOCKERHUB_ORG_NAME_BACKUP || env.DOCKERHUB_ORG_NAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6
|
||||
with:
|
||||
images: |
|
||||
${{ env.REGISTRY }}/${{ env.GHCR_ORG_NAME }}/${{ env.IMAGE_NAME }}
|
||||
@@ -133,12 +137,14 @@ jobs:
|
||||
|
||||
- name: Build and push
|
||||
id: docker_build
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile.ci
|
||||
push: ${{ env.IMAGE_PUSH == 'true' }}
|
||||
build-args: ${{ matrix.build_arg }}
|
||||
build-args: |
|
||||
BASE_IMAGE_TAG=${{ matrix.base_image_tag }}
|
||||
${{ matrix.build_arg }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
platforms: ${{ env.RELEASE_PLATFORMS }}
|
||||
|
||||
@@ -16,20 +16,33 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Trigger Makefile hash update
|
||||
uses: peter-evans/repository-dispatch@v3
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
||||
with:
|
||||
token: ${{ secrets.EXTERNAL_REPO_TOKEN_LUCI_APP_OPENLIST }}
|
||||
repository: ${{ vars.HOOK_REPO || 'OpenListTeam/luci-app-openlist' }}
|
||||
event-type: update-hashes
|
||||
client-payload: |
|
||||
{
|
||||
"source_repository": "${{ github.repository }}",
|
||||
"release_tag": "${{ inputs.tag || github.ref_name }}",
|
||||
"release_name": "${{ inputs.tag || github.ref_name }}",
|
||||
"release_url": "${{ github.server_url }}/${{ github.repository }}/releases/tag/${{ inputs.tag || github.ref_name }}",
|
||||
"triggered_by": "${{ github.actor }}",
|
||||
"trigger_reason": "${{ github.event_name }}"
|
||||
}
|
||||
github-token: ${{ secrets.EXTERNAL_REPO_TOKEN_LUCI_APP_OPENLIST }}
|
||||
script: |
|
||||
const repository = process.env.REPOSITORY;
|
||||
const [owner, repo] = repository.split('/');
|
||||
|
||||
await github.rest.repos.createDispatchEvent({
|
||||
owner: owner,
|
||||
repo: repo,
|
||||
event_type: 'update-hashes',
|
||||
client_payload: {
|
||||
source_repository: process.env.SOURCE_REPOSITORY,
|
||||
release_tag: process.env.RELEASE_TAG,
|
||||
release_name: process.env.RELEASE_TAG,
|
||||
release_url: process.env.RELEASE_URL,
|
||||
triggered_by: process.env.ACTOR,
|
||||
trigger_reason: process.env.EVENT_NAME
|
||||
}
|
||||
});
|
||||
env:
|
||||
REPOSITORY: ${{ vars.HOOK_REPO || 'OpenListTeam/OpenList-OpenWRT' }}
|
||||
SOURCE_REPOSITORY: ${{ github.repository }}
|
||||
RELEASE_TAG: ${{ inputs.tag || github.ref_name }}
|
||||
RELEASE_URL: ${{ github.server_url }}/${{ github.repository }}/releases/tag/${{ inputs.tag || github.ref_name }}
|
||||
ACTOR: ${{ github.actor }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
|
||||
- name: Log trigger information
|
||||
run: |
|
||||
|
||||
+4
-2
@@ -22,6 +22,7 @@ output/
|
||||
# vendor/
|
||||
/bin/*
|
||||
*.json
|
||||
!renovate.json
|
||||
/build
|
||||
/data/
|
||||
/tmp/
|
||||
@@ -29,6 +30,7 @@ output/
|
||||
/lang/
|
||||
/daemon/
|
||||
/public/dist/*
|
||||
/!public/dist/README.md
|
||||
!/public/dist/README.md
|
||||
|
||||
.VSCodeCounter
|
||||
.VSCodeCounter
|
||||
*.syso
|
||||
@@ -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 <copilot@github.com>`
|
||||
- `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.
|
||||
+4
-4
@@ -106,7 +106,7 @@ Violating these terms may lead to a permanent ban.
|
||||
### 4. Permanent Ban
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of community
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within
|
||||
@@ -116,7 +116,7 @@ the community.
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||
version 2.0, available at
|
||||
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
|
||||
<https://www.contributor-covenant.org/version/2/0/code_of_conduct.html>.
|
||||
|
||||
Community Impact Guidelines were inspired by [Mozilla's code of conduct
|
||||
enforcement ladder](https://github.com/mozilla/diversity).
|
||||
@@ -124,5 +124,5 @@ enforcement ladder](https://github.com/mozilla/diversity).
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
|
||||
For answers to common questions about this code of conduct, see the FAQ at
|
||||
https://www.contributor-covenant.org/faq. Translations are available at
|
||||
https://www.contributor-covenant.org/translations.
|
||||
<https://www.contributor-covenant.org/faq>. Translations are available at
|
||||
<https://www.contributor-covenant.org/translations>.
|
||||
|
||||
+75
-72
@@ -2,106 +2,109 @@
|
||||
|
||||
## Setup your machine
|
||||
|
||||
`OpenList` is written in [Go](https://golang.org/) and [React](https://reactjs.org/).
|
||||
`OpenList` is written in [Go](https://golang.org/) and [SolidJS](https://www.solidjs.com/).
|
||||
|
||||
Prerequisites:
|
||||
|
||||
- [git](https://git-scm.com)
|
||||
- [Go 1.20+](https://golang.org/doc/install)
|
||||
- [Go](https://golang.org/doc/install) version declared in [`go.mod`](./go.mod)
|
||||
- [gcc](https://gcc.gnu.org/)
|
||||
- [nodejs](https://nodejs.org/)
|
||||
|
||||
Clone `OpenList` and `OpenList-Frontend` anywhere:
|
||||
## Cloning a fork
|
||||
|
||||
Fork and clone `OpenList` and `OpenList-Frontend` anywhere:
|
||||
|
||||
```shell
|
||||
$ git clone https://github.com/OpenListTeam/OpenList.git
|
||||
$ git clone --recurse-submodules https://github.com/OpenListTeam/OpenList-Frontend.git
|
||||
git clone https://github.com/<your-username>/OpenList.git
|
||||
git clone --recurse-submodules https://github.com/<your-username>/OpenList-Frontend.git
|
||||
```
|
||||
|
||||
## Creating a branch
|
||||
|
||||
Create a new branch from the `main` branch, with an appropriate name.
|
||||
|
||||
```shell
|
||||
git checkout -b <branch-name>
|
||||
```
|
||||
You should switch to the `main` branch for development.
|
||||
|
||||
## Preview your change
|
||||
|
||||
### backend
|
||||
|
||||
```shell
|
||||
$ go run main.go
|
||||
go run main.go
|
||||
```
|
||||
|
||||
### frontend
|
||||
|
||||
```shell
|
||||
$ pnpm dev
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
## Add a new driver
|
||||
|
||||
Copy `drivers/template` folder and rename it, and follow the comments in it.
|
||||
|
||||
## Community and policies
|
||||
|
||||
By contributing, you agree to follow the repository's code of conduct and license terms.
|
||||
|
||||
- Code of conduct: [CODE_OF_CONDUCT.md](./CODE_OF_CONDUCT.md)
|
||||
- License: [LICENSE](./LICENSE)
|
||||
- Security issues: please report privately according to [SECURITY.md](./SECURITY.md)
|
||||
|
||||
If your contribution includes substantial AI-assisted content, disclose the tools used and the scope of assistance in the pull request.
|
||||
|
||||
## Create a commit
|
||||
|
||||
Commit messages should be well formatted, and to make that "standardized".
|
||||
|
||||
### Commit Message Format
|
||||
Each commit message consists of a **header**, a **body** and a **footer**. The header has a special
|
||||
format that includes a **type**, a **scope** and a **subject**:
|
||||
Submit your pull request. For PR titles, follow [Conventional Commits](https://www.conventionalcommits.org).
|
||||
|
||||
```
|
||||
<type>(<scope>): <subject>
|
||||
<BLANK LINE>
|
||||
<body>
|
||||
<BLANK LINE>
|
||||
<footer>
|
||||
```
|
||||
<https://github.com/OpenListTeam/OpenList/issues/376>
|
||||
|
||||
The **header** is mandatory and the **scope** of the header is optional.
|
||||
|
||||
Any line of the commit message cannot be longer than 100 characters! This allows the message to be easier
|
||||
to read on GitHub as well as in various git tools.
|
||||
|
||||
### Revert
|
||||
If the commit reverts a previous commit, it should begin with `revert: `, followed by the header
|
||||
of the reverted commit.
|
||||
In the body it should say: `This reverts commit <hash>.`, where the hash is the SHA of the commit
|
||||
being reverted.
|
||||
|
||||
### Type
|
||||
Must be one of the following:
|
||||
|
||||
* **feat**: A new feature
|
||||
* **fix**: A bug fix
|
||||
* **docs**: Documentation only changes
|
||||
* **style**: Changes that do not affect the meaning of the code (white-space, formatting, missing
|
||||
semi-colons, etc)
|
||||
* **refactor**: A code change that neither fixes a bug nor adds a feature
|
||||
* **perf**: A code change that improves performance
|
||||
* **test**: Adding missing or correcting existing tests
|
||||
* **build**: Affects project builds or dependency modifications
|
||||
* **revert**: Restore the previous commit
|
||||
* **ci**: Continuous integration of related file modifications
|
||||
* **chore**: Changes to the build process or auxiliary tools and libraries such as documentation
|
||||
generation
|
||||
* **release**: Release a new version
|
||||
|
||||
### Scope
|
||||
The scope could be anything specifying place of the commit change. For example `$location`,
|
||||
`$browser`, `$compile`, `$rootScope`, `ngHref`, `ngClick`, `ngView`, etc...
|
||||
|
||||
You can use `*` when the change affects more than a single scope.
|
||||
|
||||
### Subject
|
||||
The subject contains succinct description of the change:
|
||||
|
||||
* use the imperative, present tense: "change" not "changed" nor "changes"
|
||||
* don't capitalize first letter
|
||||
* no dot (.) at the end
|
||||
|
||||
### Body
|
||||
Just as in the **subject**, use the imperative, present tense: "change" not "changed" nor "changes".
|
||||
The body should include the motivation for the change and contrast this with previous behavior.
|
||||
|
||||
### Footer
|
||||
The footer should contain any information about **Breaking Changes** and is also the place to
|
||||
[reference GitHub issues that this commit closes](https://help.github.com/articles/closing-issues-via-commit-messages/).
|
||||
|
||||
**Breaking Changes** should start with the word `BREAKING CHANGE:` with a space or two newlines.
|
||||
The rest of the commit message is then used for this.
|
||||
It's suggested to sign your commits. See: [How to sign commits](https://docs.github.com/en/authentication/managing-commit-signature-verification/signing-commits)
|
||||
|
||||
## Submit a pull request
|
||||
|
||||
Push your branch to your `openlist` fork and open a pull request against the
|
||||
`main` branch.
|
||||
Please make sure your code has been formatted with `go fmt` or [prettier](https://prettier.io/) before submitting.
|
||||
|
||||
Push your branch to your `openlist` fork and open a pull request against the `main` branch.
|
||||
|
||||
## Merge your pull request
|
||||
|
||||
Your pull request will be merged after review. Please wait for the maintainer to merge your pull request after review.
|
||||
|
||||
At least 1 approving review is required by reviewers with write access. You can also request a review from maintainers.
|
||||
|
||||
## Delete your branch
|
||||
|
||||
(Optional) After your pull request is merged, you can delete your branch.
|
||||
|
||||
## AI Disclosure
|
||||
|
||||
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.
|
||||
- Usage scope, such as code generation, refactoring, documentation, tests, translation, or review assistance.
|
||||
- Confirmation that you have reviewed and validated all AI-assisted content before submission.
|
||||
- Confirmation that the submitted content complies with this repository's license and contribution policies.
|
||||
|
||||
Minor AI assistance, such as typo fixes, autocomplete, formatting suggestions, or wording polish, does not need to be disclosed.
|
||||
|
||||
---
|
||||
|
||||
Thank you for your contribution! Let's make OpenList better together!
|
||||
|
||||
+17
-23
@@ -1,4 +1,7 @@
|
||||
FROM docker.io/library/alpine:edge AS builder
|
||||
### Default image is base. You can add other support by modifying BASE_IMAGE_TAG. The following parameters are supported: base (default), aria2, ffmpeg, aio
|
||||
ARG BASE_IMAGE_TAG=base
|
||||
|
||||
FROM alpine:edge AS builder
|
||||
LABEL stage=go-builder
|
||||
WORKDIR /app/
|
||||
RUN apk add --no-cache bash curl jq gcc git go musl-dev
|
||||
@@ -7,36 +10,27 @@ RUN go mod download
|
||||
COPY ./ ./
|
||||
RUN bash build.sh release docker
|
||||
|
||||
FROM alpine:edge
|
||||
|
||||
FROM openlistteam/openlist-base-image:${BASE_IMAGE_TAG}
|
||||
LABEL MAINTAINER="OpenList"
|
||||
ARG INSTALL_FFMPEG=false
|
||||
ARG INSTALL_ARIA2=false
|
||||
LABEL MAINTAINER="OpenList"
|
||||
ARG USER=openlist
|
||||
ARG UID=1001
|
||||
ARG GID=1001
|
||||
|
||||
WORKDIR /opt/openlist/
|
||||
|
||||
RUN apk update && \
|
||||
apk upgrade --no-cache && \
|
||||
apk add --no-cache bash ca-certificates su-exec tzdata; \
|
||||
[ "$INSTALL_FFMPEG" = "true" ] && apk add --no-cache ffmpeg; \
|
||||
[ "$INSTALL_ARIA2" = "true" ] && apk add --no-cache curl aria2 && \
|
||||
mkdir -p /opt/aria2/.aria2 && \
|
||||
wget https://github.com/P3TERX/aria2.conf/archive/refs/heads/master.tar.gz -O /tmp/aria-conf.tar.gz && \
|
||||
tar -zxvf /tmp/aria-conf.tar.gz -C /opt/aria2/.aria2 --strip-components=1 && rm -f /tmp/aria-conf.tar.gz && \
|
||||
sed -i 's|rpc-secret|#rpc-secret|g' /opt/aria2/.aria2/aria2.conf && \
|
||||
sed -i 's|/root/.aria2|/opt/aria2/.aria2|g' /opt/aria2/.aria2/aria2.conf && \
|
||||
sed -i 's|/root/.aria2|/opt/aria2/.aria2|g' /opt/aria2/.aria2/script.conf && \
|
||||
sed -i 's|/root|/opt/aria2|g' /opt/aria2/.aria2/aria2.conf && \
|
||||
sed -i 's|/root|/opt/aria2|g' /opt/aria2/.aria2/script.conf && \
|
||||
touch /opt/aria2/.aria2/aria2.session && \
|
||||
/opt/aria2/.aria2/tracker.sh ; \
|
||||
rm -rf /var/cache/apk/*
|
||||
RUN addgroup -g ${GID} ${USER} && \
|
||||
adduser -D -u ${UID} -G ${USER} ${USER} && \
|
||||
mkdir -p /opt/openlist/data
|
||||
|
||||
COPY --chmod=755 --from=builder /app/bin/openlist ./
|
||||
COPY --chmod=755 entrypoint.sh /entrypoint.sh
|
||||
COPY --from=builder --chmod=755 --chown=${UID}:${GID} /app/bin/openlist ./
|
||||
COPY --chmod=755 --chown=${UID}:${GID} entrypoint.sh /entrypoint.sh
|
||||
|
||||
USER ${USER}
|
||||
RUN /entrypoint.sh version
|
||||
|
||||
ENV PUID=0 PGID=0 UMASK=022 RUN_ARIA2=${INSTALL_ARIA2}
|
||||
ENV UMASK=022 RUN_ARIA2=${INSTALL_ARIA2}
|
||||
VOLUME /opt/openlist/data/
|
||||
EXPOSE 5244 5245
|
||||
CMD [ "/entrypoint.sh" ]
|
||||
|
||||
+14
-22
@@ -1,34 +1,26 @@
|
||||
FROM docker.io/library/alpine:edge
|
||||
|
||||
ARG BASE_IMAGE_TAG=base
|
||||
FROM ghcr.io/openlistteam/openlist-base-image:${BASE_IMAGE_TAG}
|
||||
LABEL MAINTAINER="OpenList"
|
||||
ARG TARGETPLATFORM
|
||||
ARG INSTALL_FFMPEG=false
|
||||
ARG INSTALL_ARIA2=false
|
||||
LABEL MAINTAINER="OpenList"
|
||||
ARG USER=openlist
|
||||
ARG UID=1001
|
||||
ARG GID=1001
|
||||
|
||||
WORKDIR /opt/openlist/
|
||||
|
||||
RUN apk update && \
|
||||
apk upgrade --no-cache && \
|
||||
apk add --no-cache bash ca-certificates su-exec tzdata; \
|
||||
[ "$INSTALL_FFMPEG" = "true" ] && apk add --no-cache ffmpeg; \
|
||||
[ "$INSTALL_ARIA2" = "true" ] && apk add --no-cache curl aria2 && \
|
||||
mkdir -p /opt/aria2/.aria2 && \
|
||||
wget https://github.com/P3TERX/aria2.conf/archive/refs/heads/master.tar.gz -O /tmp/aria-conf.tar.gz && \
|
||||
tar -zxvf /tmp/aria-conf.tar.gz -C /opt/aria2/.aria2 --strip-components=1 && rm -f /tmp/aria-conf.tar.gz && \
|
||||
sed -i 's|rpc-secret|#rpc-secret|g' /opt/aria2/.aria2/aria2.conf && \
|
||||
sed -i 's|/root/.aria2|/opt/aria2/.aria2|g' /opt/aria2/.aria2/aria2.conf && \
|
||||
sed -i 's|/root/.aria2|/opt/aria2/.aria2|g' /opt/aria2/.aria2/script.conf && \
|
||||
sed -i 's|/root|/opt/aria2|g' /opt/aria2/.aria2/aria2.conf && \
|
||||
sed -i 's|/root|/opt/aria2|g' /opt/aria2/.aria2/script.conf && \
|
||||
touch /opt/aria2/.aria2/aria2.session && \
|
||||
/opt/aria2/.aria2/tracker.sh ; \
|
||||
rm -rf /var/cache/apk/*
|
||||
RUN addgroup -g ${GID} ${USER} && \
|
||||
adduser -D -u ${UID} -G ${USER} ${USER} && \
|
||||
mkdir -p /opt/openlist/data
|
||||
|
||||
COPY --chmod=755 /build/${TARGETPLATFORM}/openlist ./
|
||||
COPY --chmod=755 entrypoint.sh /entrypoint.sh
|
||||
COPY --chmod=755 --chown=${UID}:${GID} /build/${TARGETPLATFORM}/openlist ./
|
||||
COPY --chmod=755 --chown=${UID}:${GID} entrypoint.sh /entrypoint.sh
|
||||
|
||||
USER ${USER}
|
||||
RUN /entrypoint.sh version
|
||||
|
||||
ENV PUID=0 PGID=0 UMASK=022 RUN_ARIA2=${INSTALL_ARIA2}
|
||||
ENV UMASK=022 RUN_ARIA2=${INSTALL_ARIA2}
|
||||
VOLUME /opt/openlist/data/
|
||||
EXPOSE 5244 5245
|
||||
CMD [ "/entrypoint.sh" ]
|
||||
@@ -1,5 +1,5 @@
|
||||
<div align="center">
|
||||
<img style="width: 128px; height: 128px;" src="https://raw.githubusercontent.com/OpenListTeam/Logo/main/logo.svg" alt="logo" />
|
||||
<img src="https://raw.githubusercontent.com/OpenListTeam/Logo/main/logo.svg" width="128" height="128" alt="logo" />
|
||||
|
||||
<p><em>OpenList is a resilient, long-term governance, community-driven fork of AList — built to defend open source against trust-based attacks.</em></p>
|
||||
|
||||
@@ -14,12 +14,40 @@
|
||||
|
||||
---
|
||||
|
||||
- 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)
|
||||
- [LICENSE](./LICENSE)
|
||||
|
||||
## Disclaimer
|
||||
|
||||
OpenList is an open-source project independently maintained by the OpenList Team, following the AGPL-3.0 license and committed to maintaining complete code openness and modification transparency.
|
||||
|
||||
We have noticed the emergence of some third-party projects in the community with names similar to this project, such as OpenListApp/OpenListApp, as well as some paid proprietary software using the same or similar naming. To avoid user confusion, we hereby declare:
|
||||
|
||||
- OpenList has no official association with any third-party derivative projects.
|
||||
|
||||
- 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, and the use of existing features does not involve any costs.
|
||||
|
||||
We respect the community's rights to free use and derivative development, but we also strongly urge downstream projects:
|
||||
|
||||
- Should not use the "OpenList" name for impersonation promotion or commercial gain;
|
||||
|
||||
- Must not distribute OpenList-based code in a closed-source manner or violate AGPL license terms.
|
||||
|
||||
To better maintain healthy ecosystem development, we recommend:
|
||||
|
||||
- Clearly indicate the project source and choose appropriate open-source licenses in accordance with the open-source spirit;
|
||||
|
||||
- If involving commercial use, please avoid using "OpenList" or any confusing naming as the project name;
|
||||
|
||||
- If you need to use materials located under OpenListTeam/Logo, you may modify and use them under compliance with the agreement.
|
||||
|
||||
Thank you for your support and understanding of the OpenList project.
|
||||
|
||||
## Features
|
||||
|
||||
- [x] Multiple storages
|
||||
@@ -36,8 +64,10 @@
|
||||
- [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] [139yun](https://yun.139.com) (Personal, Family, Group)
|
||||
- [x] [ProtonDrive](https://proton.me/drive)
|
||||
- [x] [139yun](https://yun.139.com) (Personal, Family, Group, Share)
|
||||
- [x] [YandexDisk](https://disk.yandex.com)
|
||||
- [x] [BaiduNetdisk](http://pan.baidu.com)
|
||||
- [x] [Terabox](https://www.terabox.com/main)
|
||||
@@ -46,7 +76,6 @@
|
||||
- [x] [Thunder](https://pan.xunlei.com)
|
||||
- [x] [Lanzou](https://www.lanzou.com)
|
||||
- [x] [ILanzou](https://www.ilanzou.com)
|
||||
- [x] [Aliyundrive share](https://www.alipan.com)
|
||||
- [x] [Google photo](https://photos.google.com)
|
||||
- [x] [Mega.nz](https://mega.nz)
|
||||
- [x] [Baidu photo](https://photo.baidu.com)
|
||||
@@ -57,6 +86,16 @@
|
||||
- [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
|
||||
@@ -78,17 +117,32 @@
|
||||
|
||||
## Document
|
||||
|
||||
- 📘 [Docs & Install Guide](https://docs.oplist.org)
|
||||
- 📚 [Backup Docs Site](https://docs.openlist.team)
|
||||
- 📘 [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
|
||||
|
||||
N/A (to be rebuilt)
|
||||
- 🌎 [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 raising general questions, ***Issues* is for bug reports and feature requests only.**
|
||||
|
||||
## Sponsor
|
||||
|
||||
[](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.
|
||||
@@ -118,3 +172,4 @@ We sincerely thank the author [Xhofe](https://github.com/Xhofe) of the original
|
||||
Thanks goes to these wonderful people:
|
||||
|
||||
[](https://github.com/OpenListTeam/OpenList/graphs/contributors)
|
||||
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
<div align="center">
|
||||
<img src="https://raw.githubusercontent.com/OpenListTeam/Logo/main/logo.svg" width="128" height="128" alt="logo" />
|
||||
|
||||
<p><em>OpenList هو تفرّع من AList يتميز بالمرونة والحوكمة طويلة الأمد ويقوده المجتمع، وقد بُني للدفاع عن البرمجيات مفتوحة المصدر ضد الهجمات القائمة على الثقة.</em></p>
|
||||
|
||||
<img src="https://goreportcard.com/badge/github.com/OpenListTeam/OpenList/v3" alt="latest version" />
|
||||
<a href="https://github.com/OpenListTeam/OpenList/blob/main/LICENSE"><img src="https://img.shields.io/github/license/OpenListTeam/OpenList" alt="License" /></a>
|
||||
<a href="https://github.com/OpenListTeam/OpenList/actions?query=workflow%3ABuild"><img src="https://img.shields.io/github/actions/workflow/status/OpenListTeam/OpenList/build.yml?branch=main" alt="Build status" /></a>
|
||||
<a href="https://github.com/OpenListTeam/OpenList/releases"><img src="https://img.shields.io/github/release/OpenListTeam/OpenList" alt="latest version" /></a>
|
||||
|
||||
<a href="https://github.com/OpenListTeam/OpenList/discussions"><img src="https://img.shields.io/github/discussions/OpenListTeam/OpenList?color=%23ED8936" alt="discussions" /></a>
|
||||
<a href="https://github.com/OpenListTeam/OpenList/releases"><img src="https://img.shields.io/github/downloads/OpenListTeam/OpenList/total?color=%239F7AEA&logo=github" alt="Downloads" /></a>
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
- [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, Share)
|
||||
- [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.
|
||||
|
||||
## الراعي
|
||||
|
||||
[](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.
|
||||
|
||||
[](https://github.com/OpenListTeam/OpenList/graphs/contributors)
|
||||
@@ -1,5 +1,5 @@
|
||||
<div align="center">
|
||||
<img style="width: 128px; height: 128px;" src="https://raw.githubusercontent.com/OpenListTeam/Logo/main/logo.svg" alt="logo" />
|
||||
<img src="https://raw.githubusercontent.com/OpenListTeam/Logo/main/logo.svg" width="128" height="128" alt="logo" />
|
||||
|
||||
<p><em>OpenList 是一个有韧性、长期治理、社区驱动的 AList 分支,旨在防御基于信任的开源攻击。</em></p>
|
||||
|
||||
@@ -14,11 +14,39 @@
|
||||
|
||||
---
|
||||
|
||||
- [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)
|
||||
|
||||
## 免责声明
|
||||
|
||||
OpenList 是一个由 OpenList 团队独立维护的开源项目,遵循 AGPL-3.0 许可证,致力于保持完整的代码开放性和修改透明性。
|
||||
|
||||
我们注意到社区中出现了一些与本项目名称相似的第三方项目,如 OpenListApp/OpenListApp,以及部分采用相同或近似命名的收费专有软件。为避免用户误解,现声明如下:
|
||||
|
||||
- OpenList 与任何第三方衍生项目无官方关联。
|
||||
|
||||
- 本项目的全部软件、代码与服务由 OpenList 团队维护,可在 GitHub 免费获取。
|
||||
|
||||
- 项目文档与 API 服务均主要依托于 Cloudflare 提供的公益资源,目前无任何收费计划或商业部署,现有功能使用不涉及任何支出。
|
||||
|
||||
我们尊重社区的自由使用与衍生开发权利,但也强烈呼吁下游项目:
|
||||
|
||||
- 不应以“OpenList”名义进行冒名宣传或获取商业利益;
|
||||
|
||||
- 不得将基于 OpenList 的代码进行闭源分发或违反 AGPL 许可证条款。
|
||||
|
||||
为了更好地维护生态健康发展,我们建议:
|
||||
|
||||
- 明确注明项目来源,并以符合开源精神的方式选择适当的开源许可证;
|
||||
|
||||
- 如涉及商业用途,请避免使用“OpenList”或任何会产生混淆的方式作为项目名称;
|
||||
|
||||
- 若需使用本项目位于 OpenListTeam/Logo 下的素材,可在遵守协议的前提下进行修改后使用。
|
||||
|
||||
感谢您对 OpenList 项目的支持与理解。
|
||||
|
||||
## 功能
|
||||
|
||||
@@ -36,8 +64,10 @@
|
||||
- [x] [又拍云对象存储](https://www.upyun.com/products/file-storage)
|
||||
- [x] [WebDAV](https://en.wikipedia.org/wiki/WebDAV)
|
||||
- [x] Teambition([中国](https://www.teambition.com), [国际](https://us.teambition.com))
|
||||
- [x] [MediaFire](https://www.mediafire.com)
|
||||
- [x] [分秒帧](https://www.mediatrack.cn)
|
||||
- [x] [和彩云](https://yun.139.com)(个人、家庭、群组)
|
||||
- [x] [ProtonDrive](https://proton.me/drive)
|
||||
- [x] [和彩云](https://yun.139.com)(个人、家庭、群组、分享)
|
||||
- [x] [YandexDisk](https://disk.yandex.com)
|
||||
- [x] [百度网盘](http://pan.baidu.com)
|
||||
- [x] [Terabox](https://www.terabox.com/main)
|
||||
@@ -46,7 +76,6 @@
|
||||
- [x] [迅雷网盘](https://pan.xunlei.com)
|
||||
- [x] [蓝奏云](https://www.lanzou.com)
|
||||
- [x] [蓝奏云优享版](https://www.ilanzou.com)
|
||||
- [x] [阿里云盘分享](https://www.alipan.com)
|
||||
- [x] [Google 相册](https://photos.google.com)
|
||||
- [x] [Mega.nz](https://mega.nz)
|
||||
- [x] [百度相册](https://photo.baidu.com)
|
||||
@@ -57,6 +86,16 @@
|
||||
- [x] [飞机盘](https://www.feijipan.com)
|
||||
- [x] [多吉云](https://www.dogecloud.com/product/oss)
|
||||
- [x] [Azure Blob Storage](https://azure.microsoft.com/products/storage/blobs)
|
||||
- [x] [超星](https://www.chaoxing.com)
|
||||
- [x] [CNB](https://cnb.cool/)
|
||||
- [x] [Degoo](https://degoo.com)
|
||||
- [x] [豆包](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] [微云](https://www.weiyun.com)
|
||||
- [x] [钉钉文档](https://alidocs.dingtalk.com/)
|
||||
- [x] 部署方便,开箱即用
|
||||
- [x] 文件预览(PDF、markdown、代码、纯文本等)
|
||||
- [x] 画廊模式下的图片预览
|
||||
@@ -78,17 +117,32 @@
|
||||
|
||||
## 文档
|
||||
|
||||
- 📘 [文档与安装指南](https://docs.oplist.org)
|
||||
- 📚 [备用文档站点](https://docs.openlist.team)
|
||||
- 📘 [文档](https://doc.oplist.org)
|
||||
- 🌏 [中国镜像](https://doc.oplist.org.cn)
|
||||
- ⚖️ [使用条款](https://doc.oplist.org/terms)
|
||||
- 🔒 [隐私政策](https://doc.oplist.org/privacy)
|
||||
|
||||
## 演示
|
||||
## Demo
|
||||
|
||||
N/A(待重建)
|
||||
- 🌎 [全球 Demo](https://demo.oplist.org)
|
||||
- 🇨🇳 [中国 Demo](https://demo.oplist.org.cn)
|
||||
|
||||
## 讨论
|
||||
|
||||
如有一般性问题请前往 [*Discussions*](https://github.com/OpenListTeam/OpenList/discussions) 讨论区,***Issues* 仅用于错误报告和功能请求。**
|
||||
|
||||
## 赞助者
|
||||
|
||||
[](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) 许可证的开源软件。
|
||||
@@ -103,7 +157,7 @@ N/A(待重建)
|
||||
- 本软件按“原样”提供,不附带任何明示或暗示的担保,包括但不限于适销性或特定用途的适用性。
|
||||
- 维护者不对因使用或无法使用本软件而导致的任何直接或间接损失负责。
|
||||
- 您需自行承担使用本软件的所有风险,包括但不限于账号被封、下载限速等。
|
||||
- 本项目遵循 [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) 文件。
|
||||
|
||||
## 联系我们
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
<div align="center">
|
||||
<img src="https://raw.githubusercontent.com/OpenListTeam/Logo/main/logo.svg" width="128" height="128" alt="logo" />
|
||||
|
||||
<p><em>OpenList ist ein widerstandsfähiger, langfristig verwalteter und Community-getriebener Fork von AList, entwickelt zum Schutz von Open Source vor vertrauensbasierten Angriffen.</em></p>
|
||||
|
||||
<img src="https://goreportcard.com/badge/github.com/OpenListTeam/OpenList/v3" alt="latest version" />
|
||||
<a href="https://github.com/OpenListTeam/OpenList/blob/main/LICENSE"><img src="https://img.shields.io/github/license/OpenListTeam/OpenList" alt="License" /></a>
|
||||
<a href="https://github.com/OpenListTeam/OpenList/actions?query=workflow%3ABuild"><img src="https://img.shields.io/github/actions/workflow/status/OpenListTeam/OpenList/build.yml?branch=main" alt="Build status" /></a>
|
||||
<a href="https://github.com/OpenListTeam/OpenList/releases"><img src="https://img.shields.io/github/release/OpenListTeam/OpenList" alt="latest version" /></a>
|
||||
|
||||
<a href="https://github.com/OpenListTeam/OpenList/discussions"><img src="https://img.shields.io/github/discussions/OpenListTeam/OpenList?color=%23ED8936" alt="discussions" /></a>
|
||||
<a href="https://github.com/OpenListTeam/OpenList/releases"><img src="https://img.shields.io/github/downloads/OpenListTeam/OpenList/total?color=%239F7AEA&logo=github" alt="Downloads" /></a>
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
- [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, Share)
|
||||
- [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
|
||||
|
||||
[](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.
|
||||
|
||||
[](https://github.com/OpenListTeam/OpenList/graphs/contributors)
|
||||
@@ -0,0 +1,136 @@
|
||||
<div align="center">
|
||||
<img src="https://raw.githubusercontent.com/OpenListTeam/Logo/main/logo.svg" width="128" height="128" alt="logo" />
|
||||
|
||||
<p><em>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.</em></p>
|
||||
|
||||
<img src="https://goreportcard.com/badge/github.com/OpenListTeam/OpenList/v3" alt="latest version" />
|
||||
<a href="https://github.com/OpenListTeam/OpenList/blob/main/LICENSE"><img src="https://img.shields.io/github/license/OpenListTeam/OpenList" alt="License" /></a>
|
||||
<a href="https://github.com/OpenListTeam/OpenList/actions?query=workflow%3ABuild"><img src="https://img.shields.io/github/actions/workflow/status/OpenListTeam/OpenList/build.yml?branch=main" alt="Build status" /></a>
|
||||
<a href="https://github.com/OpenListTeam/OpenList/releases"><img src="https://img.shields.io/github/release/OpenListTeam/OpenList" alt="latest version" /></a>
|
||||
|
||||
<a href="https://github.com/OpenListTeam/OpenList/discussions"><img src="https://img.shields.io/github/discussions/OpenListTeam/OpenList?color=%23ED8936" alt="discussions" /></a>
|
||||
<a href="https://github.com/OpenListTeam/OpenList/releases"><img src="https://img.shields.io/github/downloads/OpenListTeam/OpenList/total?color=%239F7AEA&logo=github" alt="Downloads" /></a>
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
- [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, Share)
|
||||
- [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
|
||||
|
||||
[](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.
|
||||
|
||||
[](https://github.com/OpenListTeam/OpenList/graphs/contributors)
|
||||
@@ -0,0 +1,136 @@
|
||||
<div align="center">
|
||||
<img src="https://raw.githubusercontent.com/OpenListTeam/Logo/main/logo.svg" width="128" height="128" alt="logo" />
|
||||
|
||||
<p><em>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.</em></p>
|
||||
|
||||
<img src="https://goreportcard.com/badge/github.com/OpenListTeam/OpenList/v3" alt="latest version" />
|
||||
<a href="https://github.com/OpenListTeam/OpenList/blob/main/LICENSE"><img src="https://img.shields.io/github/license/OpenListTeam/OpenList" alt="License" /></a>
|
||||
<a href="https://github.com/OpenListTeam/OpenList/actions?query=workflow%3ABuild"><img src="https://img.shields.io/github/actions/workflow/status/OpenListTeam/OpenList/build.yml?branch=main" alt="Build status" /></a>
|
||||
<a href="https://github.com/OpenListTeam/OpenList/releases"><img src="https://img.shields.io/github/release/OpenListTeam/OpenList" alt="latest version" /></a>
|
||||
|
||||
<a href="https://github.com/OpenListTeam/OpenList/discussions"><img src="https://img.shields.io/github/discussions/OpenListTeam/OpenList?color=%23ED8936" alt="discussions" /></a>
|
||||
<a href="https://github.com/OpenListTeam/OpenList/releases"><img src="https://img.shields.io/github/downloads/OpenListTeam/OpenList/total?color=%239F7AEA&logo=github" alt="Downloads" /></a>
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
- [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, Share)
|
||||
- [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
|
||||
|
||||
[](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.
|
||||
|
||||
[](https://github.com/OpenListTeam/OpenList/graphs/contributors)
|
||||
@@ -1,5 +1,5 @@
|
||||
<div align="center">
|
||||
<img style="width: 128px; height: 128px;" src="https://raw.githubusercontent.com/OpenListTeam/Logo/main/logo.svg" alt="logo" />
|
||||
<img src="https://raw.githubusercontent.com/OpenListTeam/Logo/main/logo.svg" width="128" height="128" alt="logo" />
|
||||
|
||||
<p><em>OpenList は、信頼ベースの攻撃からオープンソースを守るために構築された、レジリエントで長期ガバナンス、コミュニティ主導の AList フォークです。</em></p>
|
||||
|
||||
@@ -14,11 +14,39 @@
|
||||
|
||||
---
|
||||
|
||||
- [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)
|
||||
|
||||
## 免責事項
|
||||
|
||||
OpenListは、OpenListチームが独立して維持するオープンソースプロジェクトであり、AGPL-3.0ライセンスに従い、完全なコードの開放性と変更の透明性を維持することに専念しています。
|
||||
|
||||
コミュニティ内で、OpenListApp/OpenListAppなど、本プロジェクトと類似した名称を持つサードパーティプロジェクトや、同一または類似した命名を採用する有料専有ソフトウェアが出現していることを確認しています。ユーザーの誤解を避けるため、以下のように宣言いたします:
|
||||
|
||||
- OpenListは、いかなるサードパーティ派生プロジェクトとも公式な関連性はありません。
|
||||
|
||||
- 本プロジェクトのすべてのソフトウェア、コード、サービスはOpenListチームによって維持され、GitHubで無料で取得できます。
|
||||
|
||||
- プロジェクトドキュメントとAPIサービスは主にCloudflareが提供する公益リソースに依存しており、現在有料プランや商業展開はなく、既存機能の使用に費用は発生しません。
|
||||
|
||||
私たちはコミュニティの自由な使用と派生開発の権利を尊重しますが、下流プロジェクトに強く呼びかけます:
|
||||
|
||||
- 「OpenList」の名前で偽装宣伝や商業利益を得るべきではありません;
|
||||
|
||||
- OpenListベースのコードをクローズドソースで配布したり、AGPLライセンス条項に違反してはいけません。
|
||||
|
||||
エコシステムの健全な発展をより良く維持するため、以下を推奨します:
|
||||
|
||||
- プロジェクトの出典を明確に示し、オープンソース精神に合致する適切なオープンソースライセンスを選択する;
|
||||
|
||||
- 商業用途が関わる場合は、「OpenList」や混乱を招く可能性のある名前をプロジェクト名として使用することを避ける;
|
||||
|
||||
- OpenListTeam/Logo下の素材を使用する必要がある場合は、協定を遵守した上で修正して使用できます。
|
||||
|
||||
OpenListプロジェクトへのご支援とご理解をありがとうございます。
|
||||
|
||||
## 特徴
|
||||
|
||||
@@ -37,7 +65,8 @@
|
||||
- [x] [WebDAV](https://en.wikipedia.org/wiki/WebDAV)
|
||||
- [x] Teambition([中国](https://www.teambition.com), [国際](https://us.teambition.com))
|
||||
- [x] [Mediatrack](https://www.mediatrack.cn)
|
||||
- [x] [139yun](https://yun.139.com)(個人、家族、グループ)
|
||||
- [x] [ProtonDrive](https://proton.me/drive)
|
||||
- [x] [139yun](https://yun.139.com)(個人、家族、グループ、共有)
|
||||
- [x] [YandexDisk](https://disk.yandex.com)
|
||||
- [x] [BaiduNetdisk](http://pan.baidu.com)
|
||||
- [x] [Terabox](https://www.terabox.com/main)
|
||||
@@ -46,7 +75,6 @@
|
||||
- [x] [Thunder](https://pan.xunlei.com)
|
||||
- [x] [Lanzou](https://www.lanzou.com)
|
||||
- [x] [ILanzou](https://www.ilanzou.com)
|
||||
- [x] [Aliyundrive share](https://www.alipan.com)
|
||||
- [x] [Google photo](https://photos.google.com)
|
||||
- [x] [Mega.nz](https://mega.nz)
|
||||
- [x] [Baidu photo](https://photo.baidu.com)
|
||||
@@ -57,6 +85,17 @@
|
||||
- [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 ドキュメント](https://alidocs.dingtalk.com/)
|
||||
- [x] [MediaFire](https://www.mediafire.com)
|
||||
- [x] 簡単にデプロイでき、すぐに使える
|
||||
- [x] ファイルプレビュー(PDF、markdown、コード、テキストなど)
|
||||
- [x] ギャラリーモードでの画像プレビュー
|
||||
@@ -78,17 +117,32 @@
|
||||
|
||||
## ドキュメント
|
||||
|
||||
- 📘 [ドキュメント・インストールガイド](https://docs.oplist.org)
|
||||
- 📚 [バックアップドキュメントサイト](https://docs.openlist.team)
|
||||
- 📘 [ドキュメント](https://doc.oplist.org)
|
||||
- 🌏 [中国ミラー](https://doc.oplist.org.cn)
|
||||
- ⚖️ [利用規約](https://doc.oplist.org/terms)
|
||||
- 🔒 [プライバシーポリシー](https://doc.oplist.org/privacy)
|
||||
|
||||
## デモ
|
||||
## Demo
|
||||
|
||||
N/A(再構築中)
|
||||
- 🌎 [グローバル Demo](https://demo.oplist.org)
|
||||
- 🇨🇳 [中国 Demo](https://demo.oplist.org.cn)
|
||||
|
||||
## ディスカッション
|
||||
|
||||
一般的な質問は [*Discussions*](https://github.com/OpenListTeam/OpenList/discussions) をご利用ください。***Issues* はバグ報告と機能リクエスト専用です。**
|
||||
|
||||
## スポンサー
|
||||
|
||||
[](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) ライセンスの下で公開されているオープンソースソフトウェアです。
|
||||
@@ -103,7 +157,7 @@ N/A(再構築中)
|
||||
- 本ソフトウェアは「現状有姿」で提供されており、商品性や特定目的への適合性を含むいかなる保証もありません。
|
||||
- 本ソフトウェアの使用または使用不能によるいかなる直接的・間接的損害についても、メンテナは責任を負いません。
|
||||
- 本ソフトウェアの利用に伴うすべてのリスク(アカウントの凍結やダウンロード速度制限などを含む)は、利用者自身が負うものとします。
|
||||
- 本プロジェクトは [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) ファイルをご覧ください。
|
||||
|
||||
## お問い合わせ
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
<div align="center">
|
||||
<img src="https://raw.githubusercontent.com/OpenListTeam/Logo/main/logo.svg" width="128" height="128" alt="logo" />
|
||||
|
||||
<p><em>OpenList는 신뢰 기반 공격으로부터 오픈 소스를 보호하기 위해 만들어진, 탄력적이고 장기 거버넌스를 지향하는 커뮤니티 주도 AList 포크입니다.</em></p>
|
||||
|
||||
<img src="https://goreportcard.com/badge/github.com/OpenListTeam/OpenList/v3" alt="latest version" />
|
||||
<a href="https://github.com/OpenListTeam/OpenList/blob/main/LICENSE"><img src="https://img.shields.io/github/license/OpenListTeam/OpenList" alt="License" /></a>
|
||||
<a href="https://github.com/OpenListTeam/OpenList/actions?query=workflow%3ABuild"><img src="https://img.shields.io/github/actions/workflow/status/OpenListTeam/OpenList/build.yml?branch=main" alt="Build status" /></a>
|
||||
<a href="https://github.com/OpenListTeam/OpenList/releases"><img src="https://img.shields.io/github/release/OpenListTeam/OpenList" alt="latest version" /></a>
|
||||
|
||||
<a href="https://github.com/OpenListTeam/OpenList/discussions"><img src="https://img.shields.io/github/discussions/OpenListTeam/OpenList?color=%23ED8936" alt="discussions" /></a>
|
||||
<a href="https://github.com/OpenListTeam/OpenList/releases"><img src="https://img.shields.io/github/downloads/OpenListTeam/OpenList/total?color=%239F7AEA&logo=github" alt="Downloads" /></a>
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
- [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, Share)
|
||||
- [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.
|
||||
|
||||
## 후원
|
||||
|
||||
[](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.
|
||||
|
||||
[](https://github.com/OpenListTeam/OpenList/graphs/contributors)
|
||||
@@ -1,5 +1,5 @@
|
||||
<div align="center">
|
||||
<img style="width: 128px; height: 128px;" src="https://raw.githubusercontent.com/OpenListTeam/Logo/main/logo.svg" alt="logo" />
|
||||
<img src="https://raw.githubusercontent.com/OpenListTeam/Logo/main/logo.svg" width="128" height="128" alt="logo" />
|
||||
|
||||
<p><em>OpenList is een veerkrachtige, langetermijn, door de gemeenschap geleide fork van AList — gebouwd om open source te beschermen tegen op vertrouwen gebaseerde aanvallen.</em></p>
|
||||
|
||||
@@ -14,11 +14,39 @@
|
||||
|
||||
---
|
||||
|
||||
- [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
|
||||
|
||||
OpenList is een open-source project dat onafhankelijk wordt onderhouden door het OpenList Team, volgend op de AGPL-3.0 licentie en toegewijd aan het behouden van volledige code openheid en transparantie van wijzigingen.
|
||||
|
||||
We hebben gemerkt dat er in de gemeenschap enkele derde partij projecten zijn verschenen met namen vergelijkbaar met dit project, zoals OpenListApp/OpenListApp, evenals enkele betaalde eigendomssoftware die dezelfde of soortgelijke naamgeving gebruikt. Om verwarring bij gebruikers te voorkomen, verklaren we hierbij:
|
||||
|
||||
- OpenList heeft geen officiële associatie met enige derde partij afgeleide projecten.
|
||||
|
||||
- Alle software, code en diensten van dit project worden onderhouden door het OpenList Team en zijn gratis beschikbaar op GitHub.
|
||||
|
||||
- Projectdocumentatie en API diensten zijn voornamelijk afhankelijk van liefdadigheidsbronnen verstrekt door Cloudflare. Er zijn momenteel geen betaalplannen of commerciële implementaties, en het gebruik van bestaande functies brengt geen kosten met zich mee.
|
||||
|
||||
We respecteren de rechten van de gemeenschap voor vrij gebruik en afgeleide ontwikkeling, maar we roepen downstream projecten ook ten zeerste op:
|
||||
|
||||
- Mogen niet de "OpenList" naam gebruiken voor namaakpromotie of commercieel gewin;
|
||||
|
||||
- Mogen OpenList-gebaseerde code niet distribueren op een closed-source manier of AGPL licentievoorwaarden schenden.
|
||||
|
||||
Om een gezonde ecosysteemontwikkeling beter te onderhouden, bevelen we aan:
|
||||
|
||||
- Duidelijk de projectbron aangeven en passende open-source licenties kiezen in overeenstemming met de open-source geest;
|
||||
|
||||
- Bij commercieel gebruik, vermijd het gebruik van "OpenList" of enige verwarrende naamgeving als projectnaam;
|
||||
|
||||
- Als u materialen onder OpenListTeam/Logo moet gebruiken, kunt u deze wijzigen en gebruiken onder naleving van de overeenkomst.
|
||||
|
||||
Dank u voor uw ondersteuning en begrip
|
||||
|
||||
## Functies
|
||||
|
||||
@@ -36,8 +64,10 @@
|
||||
- [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), [Internationaal](https://us.teambition.com))
|
||||
- [x] [MediaFire](https://www.mediafire.com)
|
||||
- [x] [Mediatrack](https://www.mediatrack.cn)
|
||||
- [x] [139yun](https://yun.139.com) (Persoonlijk, Familie, Groep)
|
||||
- [x] [ProtonDrive](https://proton.me/drive)
|
||||
- [x] [139yun](https://yun.139.com) (Persoonlijk, Familie, Groep, Delen)
|
||||
- [x] [YandexDisk](https://disk.yandex.com)
|
||||
- [x] [BaiduNetdisk](http://pan.baidu.com)
|
||||
- [x] [Terabox](https://www.terabox.com/main)
|
||||
@@ -46,7 +76,6 @@
|
||||
- [x] [Thunder](https://pan.xunlei.com)
|
||||
- [x] [Lanzou](https://www.lanzou.com)
|
||||
- [x] [ILanzou](https://www.ilanzou.com)
|
||||
- [x] [Aliyundrive share](https://www.alipan.com)
|
||||
- [x] [Google photo](https://photos.google.com)
|
||||
- [x] [Mega.nz](https://mega.nz)
|
||||
- [x] [Baidu photo](https://photo.baidu.com)
|
||||
@@ -57,6 +86,16 @@
|
||||
- [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-documenten](https://alidocs.dingtalk.com/)
|
||||
- [x] Eenvoudig te implementeren en direct te gebruiken
|
||||
- [x] Bestandsvoorbeeld (PDF, markdown, code, platte tekst, ...)
|
||||
- [x] Afbeeldingsvoorbeeld in galerijweergave
|
||||
@@ -78,17 +117,32 @@
|
||||
|
||||
## Documentatie
|
||||
|
||||
- 📘 [Documentatie & Installatiegids](https://docs.oplist.org)
|
||||
- 📚 [Back-up documentatiesite](https://docs.openlist.team)
|
||||
- 📘 [Documentatie](https://doc.oplist.org)
|
||||
- 🌏 [CN Mirror](https://doc.oplist.org.cn)
|
||||
- ⚖️ [Gebruiksvoorwaarden](https://doc.oplist.org/terms)
|
||||
- 🔒 [Privacybeleid](https://doc.oplist.org/privacy)
|
||||
|
||||
## Demo
|
||||
|
||||
N.v.t. (wordt opnieuw opgebouwd)
|
||||
- 🌎 [Global Demo](https://demo.oplist.org)
|
||||
- 🇨🇳 [CN Demo](https://demo.oplist.org.cn)
|
||||
|
||||
## Discussie
|
||||
|
||||
Stel algemene vragen in [*Discussions*](https://github.com/OpenListTeam/OpenList/discussions), ***Issues* zijn alleen voor bugmeldingen en feature requests.**
|
||||
|
||||
## Sponsoren
|
||||
|
||||
[](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.
|
||||
@@ -103,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
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
<div align="center">
|
||||
<img src="https://raw.githubusercontent.com/OpenListTeam/Logo/main/logo.svg" width="128" height="128" alt="logo" />
|
||||
|
||||
<p><em>OpenList — устойчивый, управляемый сообществом форк AList с долгосрочным управлением, созданный для защиты open source от атак, основанных на доверии.</em></p>
|
||||
|
||||
<img src="https://goreportcard.com/badge/github.com/OpenListTeam/OpenList/v3" alt="latest version" />
|
||||
<a href="https://github.com/OpenListTeam/OpenList/blob/main/LICENSE"><img src="https://img.shields.io/github/license/OpenListTeam/OpenList" alt="License" /></a>
|
||||
<a href="https://github.com/OpenListTeam/OpenList/actions?query=workflow%3ABuild"><img src="https://img.shields.io/github/actions/workflow/status/OpenListTeam/OpenList/build.yml?branch=main" alt="Build status" /></a>
|
||||
<a href="https://github.com/OpenListTeam/OpenList/releases"><img src="https://img.shields.io/github/release/OpenListTeam/OpenList" alt="latest version" /></a>
|
||||
|
||||
<a href="https://github.com/OpenListTeam/OpenList/discussions"><img src="https://img.shields.io/github/discussions/OpenListTeam/OpenList?color=%23ED8936" alt="discussions" /></a>
|
||||
<a href="https://github.com/OpenListTeam/OpenList/releases"><img src="https://img.shields.io/github/downloads/OpenListTeam/OpenList/total?color=%239F7AEA&logo=github" alt="Downloads" /></a>
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
- [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, Share)
|
||||
- [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.
|
||||
|
||||
## Спонсор
|
||||
|
||||
[](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.
|
||||
|
||||
[](https://github.com/OpenListTeam/OpenList/graphs/contributors)
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
Only the latest stable release receives security patches. We strongly recommend always keeping OpenList up to date.
|
||||
|
||||
| Version | Supported |
|
||||
| -------------------- | ------------------ |
|
||||
| Latest stable (v4.x) | :white_check_mark: |
|
||||
| Older versions | :x: |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
**Please do NOT report security vulnerabilities through public GitHub Issues.**
|
||||
|
||||
If you discover a security vulnerability in OpenList, please report it responsibly by using one of the following channels:
|
||||
|
||||
- **GitHub Private Security Advisory** (preferred): [Submit here](https://github.com/OpenListTeam/OpenList/security/advisories/new)
|
||||
- **Telegram**: Contact a maintainer privately via [@OpenListTeam](https://t.me/OpenListTeam)
|
||||
|
||||
When reporting, please include as much of the following as possible:
|
||||
|
||||
- A description of the vulnerability and its potential impact
|
||||
- The affected version(s)
|
||||
- Step-by-step instructions to reproduce the issue
|
||||
- Any proof-of-concept code or screenshots (if applicable)
|
||||
- Suggested mitigation or fix (optional but appreciated)
|
||||
|
||||
## Security Best Practices for Users
|
||||
|
||||
To keep your OpenList instance secure:
|
||||
|
||||
- Always update to the latest release.
|
||||
- Use a strong, unique admin password and change it after first login.
|
||||
- Enable HTTPS (TLS) for your deployment — do **not** expose OpenList over plain HTTP on the public internet.
|
||||
- Limit exposed ports using a reverse proxy (e.g., Nginx, Caddy).
|
||||
- Set up access controls and avoid enabling guest access unless necessary.
|
||||
- Regularly review mounted storage permissions and revoke unused API tokens.
|
||||
- When using Docker, avoid running the container as root if possible.
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
We sincerely thank all security researchers and community members who responsibly disclose vulnerabilities and help make OpenList safer for everyone.
|
||||
|
||||
---
|
||||
|
||||
# 安全政策
|
||||
|
||||
## 支持的版本
|
||||
|
||||
我们仅对最新稳定版本提供安全补丁。强烈建议始终保持 OpenList 为最新版本。
|
||||
|
||||
| 版本 | 是否支持 |
|
||||
| ------------------ | ------------------ |
|
||||
| 最新稳定版(v4.x) | :white_check_mark: |
|
||||
| 旧版本 | :x: |
|
||||
|
||||
## 报告漏洞
|
||||
|
||||
**请勿通过公开的 GitHub Issues 报告安全漏洞。**
|
||||
|
||||
如果您在 OpenList 中发现安全漏洞,请通过以下渠道之一负责任地进行报告:
|
||||
|
||||
- **GitHub 私密安全公告**(推荐):[点击提交](https://github.com/OpenListTeam/OpenList/security/advisories/new)
|
||||
- **Telegram**:通过 [@OpenListTeam](https://t.me/OpenListTeam) 私信联系维护者
|
||||
|
||||
报告时,请尽量提供以下信息:
|
||||
|
||||
- 漏洞描述及其潜在影响
|
||||
- 受影响的版本
|
||||
- 复现问题的详细步骤
|
||||
- 概念验证代码或截图(如有)
|
||||
- 建议的缓解措施或修复方案(可选,但非常欢迎)
|
||||
|
||||
## 用户安全最佳实践
|
||||
|
||||
为保障您的 OpenList 实例安全:
|
||||
|
||||
- 始终更新至最新版本。
|
||||
- 使用强且唯一的管理员密码,并在首次登录后立即修改。
|
||||
- 为您的部署启用 HTTPS(TLS)—— **请勿**在公网上以明文 HTTP 方式暴露 OpenList。
|
||||
- 使用反向代理(如 Nginx、Caddy)限制对外暴露的端口。
|
||||
- 配置访问控制,非必要情况下不要开启访客访问。
|
||||
- 定期检查已挂载存储的权限,并撤销未使用的 API 令牌。
|
||||
- 使用 Docker 部署时,尽可能避免以 root 用户运行容器。
|
||||
|
||||
## 致谢
|
||||
|
||||
我们衷心感谢所有负责任地披露漏洞、帮助 OpenList 变得更加安全的安全研究人员和社区成员。
|
||||
@@ -1,9 +1,12 @@
|
||||
set -e
|
||||
appName="openlist"
|
||||
builtAt="$(date +'%F %T %z')"
|
||||
gitAuthor="The OpenList Projects Contributors <noreply@openlist.team>"
|
||||
gitAuthor="The OpenList Projects Contributors <noreply@oplist.org>"
|
||||
gitCommit=$(git log --pretty=format:"%h" -1)
|
||||
|
||||
# Set frontend repository, default to OpenListTeam/OpenList-Frontend
|
||||
frontendRepo="${FRONTEND_REPO:-OpenListTeam/OpenList-Frontend}"
|
||||
|
||||
githubAuthArgs=""
|
||||
if [ -n "$GITHUB_TOKEN" ]; then
|
||||
githubAuthArgs="--header \"Authorization: Bearer $GITHUB_TOKEN\""
|
||||
@@ -17,15 +20,15 @@ fi
|
||||
|
||||
if [ "$1" = "dev" ]; then
|
||||
version="dev"
|
||||
webVersion="dev"
|
||||
webVersion="rolling"
|
||||
elif [ "$1" = "beta" ]; then
|
||||
version="beta"
|
||||
webVersion="dev"
|
||||
webVersion="rolling"
|
||||
else
|
||||
git tag -d beta || true
|
||||
# Always true if there's no tag
|
||||
version=$(git describe --abbrev=0 --tags 2>/dev/null || echo "v0.0.0")
|
||||
webVersion=$(eval "curl -fsSL --max-time 2 $githubAuthArgs \"https://api.github.com/repos/OpenListTeam/OpenList-Frontend/releases/latest\"" | grep "tag_name" | head -n 1 | awk -F ":" '{print $2}' | sed 's/\"//g;s/,//g;s/ //g')
|
||||
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
|
||||
|
||||
echo "backend version: $version"
|
||||
@@ -45,30 +48,69 @@ ldflags="\
|
||||
-X 'github.com/OpenListTeam/OpenList/v4/internal/conf.WebVersion=$webVersion' \
|
||||
"
|
||||
|
||||
FetchWebDev() {
|
||||
pre_release_tag=$(eval "curl -fsSL --max-time 2 $githubAuthArgs https://api.github.com/repos/OpenListTeam/OpenList-Frontend/releases" | jq -r 'map(select(.prerelease)) | first | .tag_name')
|
||||
if [ -z "$pre_release_tag" ] || [ "$pre_release_tag" == "null" ]; then
|
||||
# fall back to latest release
|
||||
pre_release_json=$(eval "curl -fsSL --max-time 2 $githubAuthArgs -H \"Accept: application/vnd.github.v3+json\" \"https://api.github.com/repos/OpenListTeam/OpenList-Frontend/releases/latest\"")
|
||||
else
|
||||
pre_release_json=$(eval "curl -fsSL --max-time 2 $githubAuthArgs -H \"Accept: application/vnd.github.v3+json\" \"https://api.github.com/repos/OpenListTeam/OpenList-Frontend/releases/tags/$pre_release_tag\"")
|
||||
# Keep sqlite driver tag selection centralized to avoid target drift.
|
||||
GetBuildTagsForTarget() {
|
||||
local target="$1"
|
||||
case "$target" in
|
||||
linux-loong64|linux-mips|linux-mips64|linux-mips64le|linux-mipsle|linux-musl-loong64|linux-musl-mips|linux-musl-mips64|linux-musl-mips64le|linux-musl-mipsle|windows-386|windows7-386|windows7-amd64)
|
||||
echo "jsoniter,sqlite_cgo_compat"
|
||||
;;
|
||||
*)
|
||||
echo "jsoniter"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Keep musl static link flags centralized for all musl build paths.
|
||||
GetMuslStaticLdflags() {
|
||||
echo "-linkmode external -extldflags '-static -fpic' $ldflags"
|
||||
}
|
||||
|
||||
# Fail fast if a musl build artifact is not fully static.
|
||||
AssertStaticBinary() {
|
||||
local binary="$1"
|
||||
if [ ! -f "$binary" ]; then
|
||||
echo "Error: binary not found: $binary"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if command -v readelf >/dev/null 2>&1; then
|
||||
if readelf -l "$binary" 2>/dev/null | grep -q "Requesting program interpreter"; then
|
||||
echo "Error: binary is not fully static: $binary"
|
||||
readelf -l "$binary" | grep "Requesting program interpreter" || true
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
|
||||
if command -v file >/dev/null 2>&1; then
|
||||
if file "$binary" | grep -qi "dynamically linked"; then
|
||||
echo "Error: binary is dynamically linked: $binary"
|
||||
file "$binary"
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "Warning: readelf/file not found, skip static verification for $binary"
|
||||
return 0
|
||||
}
|
||||
|
||||
FetchWebRolling() {
|
||||
pre_release_json=$(eval "curl -fsSL --max-time 2 $githubAuthArgs -H \"Accept: application/vnd.github.v3+json\" \"https://api.github.com/repos/$frontendRepo/releases/tags/edge\"")
|
||||
pre_release_assets=$(echo "$pre_release_json" | jq -r '.assets[].browser_download_url')
|
||||
|
||||
if [ "$useLite" = true ]; then
|
||||
pre_release_tar_url=$(echo "$pre_release_assets" | grep "openlist-frontend-dist-lite" | grep "\.tar\.gz$")
|
||||
else
|
||||
pre_release_tar_url=$(echo "$pre_release_assets" | grep "openlist-frontend-dist" | grep -v "lite" | grep "\.tar\.gz$")
|
||||
fi
|
||||
|
||||
curl -fsSL "$pre_release_tar_url" -o web-dist-dev.tar.gz
|
||||
# There is no lite for rolling
|
||||
pre_release_tar_url=$(echo "$pre_release_assets" | grep "openlist-frontend-dist" | grep -v "lite" | grep "\.tar\.gz$")
|
||||
|
||||
curl -fsSL "$pre_release_tar_url" -o dist.tar.gz
|
||||
rm -rf public/dist && mkdir -p public/dist
|
||||
tar -zxvf web-dist-dev.tar.gz -C public/dist
|
||||
rm -rf web-dist-dev.tar.gz
|
||||
tar -zxvf dist.tar.gz -C public/dist
|
||||
rm -rf dist.tar.gz
|
||||
}
|
||||
|
||||
FetchWebRelease() {
|
||||
release_json=$(eval "curl -fsSL --max-time 2 $githubAuthArgs -H \"Accept: application/vnd.github.v3+json\" \"https://api.github.com/repos/OpenListTeam/OpenList-Frontend/releases/latest\"")
|
||||
release_json=$(eval "curl -fsSL --max-time 2 $githubAuthArgs -H \"Accept: application/vnd.github.v3+json\" \"https://api.github.com/repos/$frontendRepo/releases/latest\"")
|
||||
release_assets=$(echo "$release_json" | jq -r '.assets[].browser_download_url')
|
||||
|
||||
if [ "$useLite" = true ]; then
|
||||
@@ -95,10 +137,49 @@ BuildWinArm64() {
|
||||
go build -o "$1" -ldflags="$ldflags" -tags=jsoniter .
|
||||
}
|
||||
|
||||
BuildWin7() {
|
||||
# Setup Win7 Go compiler (patched version that supports Windows 7)
|
||||
go_version=$(go version | grep -o 'go[0-9]\+\.[0-9]\+\.[0-9]\+' | sed 's/go//')
|
||||
echo "Detected Go version: $go_version"
|
||||
|
||||
curl -fsSL --retry 3 -o go-win7.zip -H "Authorization: Bearer $GITHUB_TOKEN" \
|
||||
"https://github.com/XTLS/go-win7/releases/download/patched-${go_version}/go-for-win7-linux-amd64.zip"
|
||||
|
||||
rm -rf go-win7
|
||||
unzip go-win7.zip -d go-win7
|
||||
rm go-win7.zip
|
||||
|
||||
# Set permissions for all wrapper files
|
||||
chmod +x ./wrapper/zcc-win7
|
||||
chmod +x ./wrapper/zcxx-win7
|
||||
chmod +x ./wrapper/zcc-win7-386
|
||||
chmod +x ./wrapper/zcxx-win7-386
|
||||
|
||||
# Build for both 386 and amd64 architectures
|
||||
for arch in "386" "amd64"; do
|
||||
echo "building for windows7-${arch}"
|
||||
build_tags=$(GetBuildTagsForTarget "windows7-${arch}")
|
||||
export GOOS=windows
|
||||
export GOARCH=${arch}
|
||||
export CGO_ENABLED=1
|
||||
|
||||
# Use architecture-specific wrapper files
|
||||
if [ "$arch" = "386" ]; then
|
||||
export CC=$(pwd)/wrapper/zcc-win7-386
|
||||
export CXX=$(pwd)/wrapper/zcxx-win7-386
|
||||
else
|
||||
export CC=$(pwd)/wrapper/zcc-win7
|
||||
export CXX=$(pwd)/wrapper/zcxx-win7
|
||||
fi
|
||||
|
||||
# Use the patched Go compiler for Win7 compatibility
|
||||
$(pwd)/go-win7/bin/go build -o "${1}-${arch}.exe" -ldflags="$ldflags" -tags="$build_tags" .
|
||||
done
|
||||
}
|
||||
|
||||
BuildDev() {
|
||||
rm -rf .git/
|
||||
mkdir -p "dist"
|
||||
muslflags="--extldflags '-static -fpic' $ldflags"
|
||||
muslflags="$(GetMuslStaticLdflags)"
|
||||
BASE="https://github.com/OpenListTeam/musl-compilers/releases/latest/download/"
|
||||
FILES=(x86_64-linux-musl-cross aarch64-linux-musl-cross)
|
||||
for i in "${FILES[@]}"; do
|
||||
@@ -116,13 +197,14 @@ BuildDev() {
|
||||
export GOARCH=${os_arch##*-}
|
||||
export CC=${cgo_cc}
|
||||
export CGO_ENABLED=1
|
||||
go build -o ./dist/$appName-$os_arch -ldflags="$muslflags" -tags=jsoniter .
|
||||
CGO_LDFLAGS="-static" go build -o ./dist/$appName-$os_arch -ldflags="$muslflags" -tags=jsoniter .
|
||||
AssertStaticBinary "./dist/$appName-$os_arch"
|
||||
done
|
||||
xgo -targets=windows/amd64,darwin/amd64,darwin/arm64 -out "$appName" -ldflags="$ldflags" -tags=jsoniter .
|
||||
mv "$appName"-* dist
|
||||
cd dist
|
||||
cp ./"$appName"-windows-amd64.exe ./"$appName"-windows-amd64-upx.exe
|
||||
upx -9 ./"$appName"-windows-amd64-upx.exe
|
||||
# cp ./"$appName"-windows-amd64.exe ./"$appName"-windows-amd64-upx.exe
|
||||
# upx -9 ./"$appName"-windows-amd64-upx.exe
|
||||
find . -type f -print0 | xargs -0 md5sum >md5.txt
|
||||
cat md5.txt
|
||||
}
|
||||
@@ -134,7 +216,7 @@ BuildDocker() {
|
||||
PrepareBuildDockerMusl() {
|
||||
mkdir -p build/musl-libs
|
||||
BASE="https://github.com/OpenListTeam/musl-compilers/releases/latest/download/"
|
||||
FILES=(x86_64-linux-musl-cross aarch64-linux-musl-cross i486-linux-musl-cross s390x-linux-musl-cross armv6-linux-musleabihf-cross armv7l-linux-musleabihf-cross riscv64-linux-musl-cross powerpc64le-linux-musl-cross)
|
||||
FILES=(x86_64-linux-musl-cross aarch64-linux-musl-cross i486-linux-musl-cross armv6-linux-musleabihf-cross armv7l-linux-musleabihf-cross riscv64-linux-musl-cross powerpc64le-linux-musl-cross loongarch64-linux-musl-cross) ## Disable s390x-linux-musl-cross builds
|
||||
for i in "${FILES[@]}"; do
|
||||
url="${BASE}${i}.tgz"
|
||||
lib_tgz="build/${i}.tgz"
|
||||
@@ -150,21 +232,23 @@ BuildDockerMultiplatform() {
|
||||
# run PrepareBuildDockerMusl before build
|
||||
export PATH=$PATH:$PWD/build/musl-libs/bin
|
||||
|
||||
docker_lflags="--extldflags '-static -fpic' $ldflags"
|
||||
docker_lflags="$(GetMuslStaticLdflags)"
|
||||
export CGO_ENABLED=1
|
||||
|
||||
OS_ARCHES=(linux-amd64 linux-arm64 linux-386 linux-s390x linux-riscv64 linux-ppc64le)
|
||||
CGO_ARGS=(x86_64-linux-musl-gcc aarch64-linux-musl-gcc i486-linux-musl-gcc s390x-linux-musl-gcc riscv64-linux-musl-gcc powerpc64le-linux-musl-gcc)
|
||||
OS_ARCHES=(linux-amd64 linux-arm64 linux-386 linux-riscv64 linux-ppc64le linux-loong64) ## Disable linux-s390x builds
|
||||
CGO_ARGS=(x86_64-linux-musl-gcc aarch64-linux-musl-gcc i486-linux-musl-gcc riscv64-linux-musl-gcc powerpc64le-linux-musl-gcc loongarch64-linux-musl-gcc) ## Disable s390x-linux-musl-gcc builds
|
||||
for i in "${!OS_ARCHES[@]}"; do
|
||||
os_arch=${OS_ARCHES[$i]}
|
||||
cgo_cc=${CGO_ARGS[$i]}
|
||||
os=${os_arch%%-*}
|
||||
arch=${os_arch##*-}
|
||||
build_tags=$(GetBuildTagsForTarget "$os_arch")
|
||||
export GOOS=$os
|
||||
export GOARCH=$arch
|
||||
export CC=${cgo_cc}
|
||||
echo "building for $os_arch"
|
||||
go build -o build/$os/$arch/"$appName" -ldflags="$docker_lflags" -tags=jsoniter .
|
||||
CGO_LDFLAGS="-static" go build -o build/$os/$arch/"$appName" -ldflags="$docker_lflags" -tags="$build_tags" .
|
||||
AssertStaticBinary "build/$os/$arch/$appName"
|
||||
done
|
||||
|
||||
DOCKER_ARM_ARCHES=(linux-arm/v6 linux-arm/v7)
|
||||
@@ -178,27 +262,188 @@ BuildDockerMultiplatform() {
|
||||
export GOARM=${GO_ARM[$i]}
|
||||
export CC=${cgo_cc}
|
||||
echo "building for $docker_arch"
|
||||
go build -o build/${docker_arch%%-*}/${docker_arch##*-}/"$appName" -ldflags="$docker_lflags" -tags=jsoniter .
|
||||
CGO_LDFLAGS="-static" go build -o build/${docker_arch%%-*}/${docker_arch##*-}/"$appName" -ldflags="$docker_lflags" -tags=jsoniter .
|
||||
AssertStaticBinary "build/${docker_arch%%-*}/${docker_arch##*-}/$appName"
|
||||
done
|
||||
}
|
||||
|
||||
BuildRelease() {
|
||||
rm -rf .git/
|
||||
mkdir -p "build"
|
||||
BuildWinArm64 ./build/"$appName"-windows-arm64.exe
|
||||
BuildWin7 ./build/"$appName"-windows7
|
||||
xgo -out "$appName" -ldflags="$ldflags" -tags=jsoniter .
|
||||
# why? Because some target platforms seem to have issues with upx compression
|
||||
upx -9 ./"$appName"-linux-amd64
|
||||
cp ./"$appName"-windows-amd64.exe ./"$appName"-windows-amd64-upx.exe
|
||||
upx -9 ./"$appName"-windows-amd64-upx.exe
|
||||
# upx -9 ./"$appName"-linux-amd64
|
||||
# cp ./"$appName"-windows-amd64.exe ./"$appName"-windows-amd64-upx.exe
|
||||
# upx -9 ./"$appName"-windows-amd64-upx.exe
|
||||
mv "$appName"-* build
|
||||
|
||||
# Build LoongArch with glibc (both old world abi1.0 and new world abi2.0)
|
||||
# Separate from musl builds to avoid cache conflicts
|
||||
BuildLoongGLIBC ./build/$appName-linux-loong64-abi1.0 abi1.0
|
||||
BuildLoongGLIBC ./build/$appName-linux-loong64 abi2.0
|
||||
}
|
||||
|
||||
BuildLoongGLIBC() {
|
||||
local target_abi="$2"
|
||||
local output_file="$1"
|
||||
local oldWorldGoVersion="1.26.4"
|
||||
local loong_tags
|
||||
loong_tags=$(GetBuildTagsForTarget "linux-loong64")
|
||||
|
||||
if [ "$target_abi" = "abi1.0" ]; then
|
||||
echo building for linux-loong64-abi1.0
|
||||
else
|
||||
echo building for linux-loong64-abi2.0
|
||||
target_abi="abi2.0" # Default to abi2.0 if not specified
|
||||
fi
|
||||
|
||||
# Note: No longer need global cache cleanup since ABI1.0 uses isolated cache directory
|
||||
echo "Using optimized cache strategy: ABI1.0 has isolated cache, ABI2.0 uses standard cache"
|
||||
|
||||
if [ "$target_abi" = "abi1.0" ]; then
|
||||
# Setup abi1.0 toolchain and patched Go compiler similar to cgo-action implementation
|
||||
echo "Setting up Loongson old-world ABI1.0 toolchain and patched Go compiler..."
|
||||
|
||||
# Download and setup patched Go compiler for old-world
|
||||
if ! curl -fsSL --retry 3 -H "Authorization: Bearer $GITHUB_TOKEN" \
|
||||
"https://github.com/loong64-abi1-0/golang/releases/download/go${oldWorldGoVersion}/go${oldWorldGoVersion}.linux-amd64.tar.gz" \
|
||||
-o go-loong64-abi1.0.tar.gz; then
|
||||
echo "Error: Failed to download patched Go compiler for old-world ABI1.0"
|
||||
if [ -n "$GITHUB_TOKEN" ]; then
|
||||
echo "Error output from curl:"
|
||||
curl -fsSL --retry 3 -H "Authorization: Bearer $GITHUB_TOKEN" \
|
||||
"https://github.com/loong64-abi1-0/golang/releases/download/go${oldWorldGoVersion}/go${oldWorldGoVersion}.linux-amd64.tar.gz" \
|
||||
-o go-loong64-abi1.0.tar.gz || true
|
||||
fi
|
||||
return 1
|
||||
fi
|
||||
|
||||
rm -rf go-loong64-abi1.0
|
||||
mkdir go-loong64-abi1.0
|
||||
if ! tar -xzf go-loong64-abi1.0.tar.gz -C go-loong64-abi1.0 --strip-components=1; then
|
||||
echo "Error: Failed to extract patched Go compiler"
|
||||
return 1
|
||||
fi
|
||||
rm go-loong64-abi1.0.tar.gz
|
||||
|
||||
# Download and setup GCC toolchain for old-world
|
||||
if ! curl -fsSL --retry 3 -H "Authorization: Bearer $GITHUB_TOKEN" \
|
||||
"https://github.com/loong64/loong64-abi1.0-toolchains/releases/download/20250722/loongson-gnu-toolchain-8.3.novec-x86_64-loongarch64-linux-gnu-rc1.1.tar.xz" \
|
||||
-o gcc8-loong64-abi1.0.tar.xz; then
|
||||
echo "Error: Failed to download GCC toolchain for old-world ABI1.0"
|
||||
if [ -n "$GITHUB_TOKEN" ]; then
|
||||
echo "Error output from curl:"
|
||||
curl -fsSL --retry 3 -H "Authorization: Bearer $GITHUB_TOKEN" \
|
||||
"https://github.com/loong64/loong64-abi1.0-toolchains/releases/download/20250722/loongson-gnu-toolchain-8.3.novec-x86_64-loongarch64-linux-gnu-rc1.1.tar.xz" \
|
||||
-o gcc8-loong64-abi1.0.tar.xz || true
|
||||
fi
|
||||
return 1
|
||||
fi
|
||||
|
||||
rm -rf gcc8-loong64-abi1.0
|
||||
mkdir gcc8-loong64-abi1.0
|
||||
if ! tar -Jxf gcc8-loong64-abi1.0.tar.xz -C gcc8-loong64-abi1.0 --strip-components=1; then
|
||||
echo "Error: Failed to extract GCC toolchain"
|
||||
return 1
|
||||
fi
|
||||
rm gcc8-loong64-abi1.0.tar.xz
|
||||
|
||||
# Setup separate cache directory for ABI1.0 to avoid cache pollution
|
||||
abi1_cache_dir="$(pwd)/go-loong64-abi1.0-cache"
|
||||
mkdir -p "$abi1_cache_dir"
|
||||
echo "Using separate cache directory for ABI1.0: $abi1_cache_dir"
|
||||
|
||||
# Use patched Go compiler for old-world build (critical for ABI1.0 compatibility)
|
||||
echo "Building with patched Go compiler for old-world ABI1.0..."
|
||||
echo "Using isolated cache directory: $abi1_cache_dir"
|
||||
|
||||
# Use env command to set environment variables locally without affecting global environment
|
||||
if ! env GOOS=linux GOARCH=loong64 \
|
||||
CC="$(pwd)/gcc8-loong64-abi1.0/bin/loongarch64-linux-gnu-gcc" \
|
||||
CXX="$(pwd)/gcc8-loong64-abi1.0/bin/loongarch64-linux-gnu-g++" \
|
||||
CGO_ENABLED=1 \
|
||||
GOCACHE="$abi1_cache_dir" \
|
||||
$(pwd)/go-loong64-abi1.0/bin/go build -a -o "$output_file" -ldflags="$ldflags" -tags="$loong_tags" .; then
|
||||
echo "Error: Build failed with patched Go compiler"
|
||||
echo "Attempting retry with cache cleanup..."
|
||||
env GOCACHE="$abi1_cache_dir" $(pwd)/go-loong64-abi1.0/bin/go clean -cache
|
||||
if ! env GOOS=linux GOARCH=loong64 \
|
||||
CC="$(pwd)/gcc8-loong64-abi1.0/bin/loongarch64-linux-gnu-gcc" \
|
||||
CXX="$(pwd)/gcc8-loong64-abi1.0/bin/loongarch64-linux-gnu-g++" \
|
||||
CGO_ENABLED=1 \
|
||||
GOCACHE="$abi1_cache_dir" \
|
||||
$(pwd)/go-loong64-abi1.0/bin/go build -a -o "$output_file" -ldflags="$ldflags" -tags="$loong_tags" .; then
|
||||
echo "Error: Build failed again after cache cleanup"
|
||||
echo "Build environment details:"
|
||||
echo "GOOS=linux"
|
||||
echo "GOARCH=loong64"
|
||||
echo "CC=$(pwd)/gcc8-loong64-abi1.0/bin/loongarch64-linux-gnu-gcc"
|
||||
echo "CXX=$(pwd)/gcc8-loong64-abi1.0/bin/loongarch64-linux-gnu-g++"
|
||||
echo "CGO_ENABLED=1"
|
||||
echo "GOCACHE=$abi1_cache_dir"
|
||||
echo "Go version: $($(pwd)/go-loong64-abi1.0/bin/go version)"
|
||||
echo "GCC version: $($(pwd)/gcc8-loong64-abi1.0/bin/loongarch64-linux-gnu-gcc --version | head -1)"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
else
|
||||
# Setup abi2.0 toolchain for new world glibc build
|
||||
echo "Setting up new-world ABI2.0 toolchain..."
|
||||
if ! curl -fsSL --retry 3 -H "Authorization: Bearer $GITHUB_TOKEN" \
|
||||
"https://github.com/loong64/cross-tools/releases/download/20250507/x86_64-cross-tools-loongarch64-unknown-linux-gnu-legacy.tar.xz" \
|
||||
-o gcc12-loong64-abi2.0.tar.xz; then
|
||||
echo "Error: Failed to download GCC toolchain for new-world ABI2.0"
|
||||
if [ -n "$GITHUB_TOKEN" ]; then
|
||||
echo "Error output from curl:"
|
||||
curl -fsSL --retry 3 -H "Authorization: Bearer $GITHUB_TOKEN" \
|
||||
"https://github.com/loong64/cross-tools/releases/download/20250507/x86_64-cross-tools-loongarch64-unknown-linux-gnu-legacy.tar.xz" \
|
||||
-o gcc12-loong64-abi2.0.tar.xz || true
|
||||
fi
|
||||
return 1
|
||||
fi
|
||||
|
||||
rm -rf gcc12-loong64-abi2.0
|
||||
mkdir gcc12-loong64-abi2.0
|
||||
if ! tar -Jxf gcc12-loong64-abi2.0.tar.xz -C gcc12-loong64-abi2.0 --strip-components=1; then
|
||||
echo "Error: Failed to extract GCC toolchain"
|
||||
return 1
|
||||
fi
|
||||
rm gcc12-loong64-abi2.0.tar.xz
|
||||
|
||||
export GOOS=linux
|
||||
export GOARCH=loong64
|
||||
export CC=$(pwd)/gcc12-loong64-abi2.0/bin/loongarch64-unknown-linux-gnu-gcc
|
||||
export CXX=$(pwd)/gcc12-loong64-abi2.0/bin/loongarch64-unknown-linux-gnu-g++
|
||||
export CGO_ENABLED=1
|
||||
|
||||
# Use standard Go compiler for new-world build
|
||||
echo "Building with standard Go compiler for new-world ABI2.0..."
|
||||
if ! go build -a -o "$output_file" -ldflags="$ldflags" -tags="$loong_tags" .; then
|
||||
echo "Error: Build failed with standard Go compiler"
|
||||
echo "Attempting retry with cache cleanup..."
|
||||
go clean -cache
|
||||
if ! go build -a -o "$output_file" -ldflags="$ldflags" -tags="$loong_tags" .; then
|
||||
echo "Error: Build failed again after cache cleanup"
|
||||
echo "Build environment details:"
|
||||
echo "GOOS=$GOOS"
|
||||
echo "GOARCH=$GOARCH"
|
||||
echo "CC=$CC"
|
||||
echo "CXX=$CXX"
|
||||
echo "CGO_ENABLED=$CGO_ENABLED"
|
||||
echo "Go version: $(go version)"
|
||||
echo "GCC version: $($CC --version | head -1)"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
BuildReleaseLinuxMusl() {
|
||||
rm -rf .git/
|
||||
mkdir -p "build"
|
||||
muslflags="--extldflags '-static -fpic' $ldflags"
|
||||
muslflags="$(GetMuslStaticLdflags)"
|
||||
BASE="https://github.com/OpenListTeam/musl-compilers/releases/latest/download/"
|
||||
# Keep mips-family targets enabled; sqlite driver selection is handled by Go build tags.
|
||||
FILES=(x86_64-linux-musl-cross aarch64-linux-musl-cross mips-linux-musl-cross mips64-linux-musl-cross mips64el-linux-musl-cross mipsel-linux-musl-cross powerpc64le-linux-musl-cross s390x-linux-musl-cross loongarch64-linux-musl-cross)
|
||||
for i in "${FILES[@]}"; do
|
||||
url="${BASE}${i}.tgz"
|
||||
@@ -211,19 +456,20 @@ BuildReleaseLinuxMusl() {
|
||||
for i in "${!OS_ARCHES[@]}"; do
|
||||
os_arch=${OS_ARCHES[$i]}
|
||||
cgo_cc=${CGO_ARGS[$i]}
|
||||
build_tags=$(GetBuildTagsForTarget "$os_arch")
|
||||
echo building for ${os_arch}
|
||||
export GOOS=${os_arch%%-*}
|
||||
export GOARCH=${os_arch##*-}
|
||||
export CC=${cgo_cc}
|
||||
export CGO_ENABLED=1
|
||||
go build -o ./build/$appName-$os_arch -ldflags="$muslflags" -tags=jsoniter .
|
||||
CGO_LDFLAGS="-static" go build -o ./build/$appName-$os_arch -ldflags="$muslflags" -tags="$build_tags" .
|
||||
AssertStaticBinary "./build/$appName-$os_arch"
|
||||
done
|
||||
}
|
||||
|
||||
BuildReleaseLinuxMuslArm() {
|
||||
rm -rf .git/
|
||||
mkdir -p "build"
|
||||
muslflags="--extldflags '-static -fpic' $ldflags"
|
||||
muslflags="$(GetMuslStaticLdflags)"
|
||||
BASE="https://github.com/OpenListTeam/musl-compilers/releases/latest/download/"
|
||||
FILES=(arm-linux-musleabi-cross arm-linux-musleabihf-cross armel-linux-musleabi-cross armel-linux-musleabihf-cross armv5l-linux-musleabi-cross armv5l-linux-musleabihf-cross armv6-linux-musleabi-cross armv6-linux-musleabihf-cross armv7l-linux-musleabihf-cross armv7m-linux-musleabi-cross armv7r-linux-musleabihf-cross)
|
||||
for i in "${FILES[@]}"; do
|
||||
@@ -245,12 +491,13 @@ BuildReleaseLinuxMuslArm() {
|
||||
export CC=${cgo_cc}
|
||||
export CGO_ENABLED=1
|
||||
export GOARM=${arm}
|
||||
go build -o ./build/$appName-$os_arch -ldflags="$muslflags" -tags=jsoniter .
|
||||
CGO_LDFLAGS="-static" go build -o ./build/$appName-$os_arch -ldflags="$muslflags" -tags=jsoniter .
|
||||
AssertStaticBinary "./build/$appName-$os_arch"
|
||||
done
|
||||
}
|
||||
|
||||
|
||||
BuildReleaseAndroid() {
|
||||
rm -rf .git/
|
||||
mkdir -p "build"
|
||||
wget https://dl.google.com/android/repository/android-ndk-r26b-linux.zip
|
||||
unzip android-ndk-r26b-linux.zip
|
||||
@@ -271,21 +518,21 @@ BuildReleaseAndroid() {
|
||||
}
|
||||
|
||||
BuildReleaseFreeBSD() {
|
||||
rm -rf .git/
|
||||
mkdir -p "build/freebsd"
|
||||
|
||||
# Get latest FreeBSD 14.x release version from GitHub
|
||||
freebsd_version=$(eval "curl -fsSL --max-time 2 $githubAuthArgs \"https://api.github.com/repos/freebsd/freebsd-src/tags\"" | \
|
||||
jq -r '.[].name' | \
|
||||
grep '^release/14\.' | \
|
||||
grep -v -- '-p[0-9]*$' | \
|
||||
sort -V | \
|
||||
tail -1 | \
|
||||
sed 's/release\///' | \
|
||||
sed 's/\.0$//')
|
||||
|
||||
if [ -z "$freebsd_version" ]; then
|
||||
echo "Failed to get FreeBSD version, falling back to 14.3"
|
||||
freebsd_version="14.3"
|
||||
echo "Failed to get FreeBSD version, falling back to 14.4"
|
||||
freebsd_version="14.4"
|
||||
fi
|
||||
|
||||
echo "Using FreeBSD version: $freebsd_version"
|
||||
@@ -343,7 +590,7 @@ MakeRelease() {
|
||||
tar -czvf compress/"$i$liteSuffix".tar.gz "$appName"
|
||||
rm -f "$appName"
|
||||
done
|
||||
for i in $(find . -type f -name "$appName-windows-*"); do
|
||||
for i in $(find . -type f \( -name "$appName-windows-*" -o -name "$appName-windows7-*" \)); do
|
||||
cp "$i" "$appName".exe
|
||||
zip compress/$(echo $i | sed 's/\.[^.]*$//')$liteSuffix.zip "$appName".exe
|
||||
rm -f "$appName".exe
|
||||
@@ -390,7 +637,7 @@ for arg in "$@"; do
|
||||
done
|
||||
|
||||
if [ "$buildType" = "dev" ]; then
|
||||
FetchWebDev
|
||||
FetchWebRolling
|
||||
if [ "$dockerType" = "docker" ]; then
|
||||
BuildDocker
|
||||
elif [ "$dockerType" = "docker-multiplatform" ]; then
|
||||
@@ -402,7 +649,7 @@ if [ "$buildType" = "dev" ]; then
|
||||
fi
|
||||
elif [ "$buildType" = "release" -o "$buildType" = "beta" ]; then
|
||||
if [ "$buildType" = "beta" ]; then
|
||||
FetchWebDev
|
||||
FetchWebRolling
|
||||
else
|
||||
FetchWebRelease
|
||||
fi
|
||||
@@ -483,4 +730,5 @@ else
|
||||
echo -e " $0 release"
|
||||
echo -e " $0 release lite"
|
||||
echo -e " $0 release docker lite"
|
||||
echo -e " $0 release linux_musl"
|
||||
fi
|
||||
|
||||
+24
-17
@@ -4,6 +4,9 @@ Copyright © 2022 NAME HERE <EMAIL ADDRESS>
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/bootstrap"
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/conf"
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/op"
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/setting"
|
||||
@@ -18,16 +21,17 @@ var AdminCmd = &cobra.Command{
|
||||
Aliases: []string{"password"},
|
||||
Short: "Show admin user's info and some operations about admin user's password",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
Init()
|
||||
defer Release()
|
||||
bootstrap.Init()
|
||||
defer bootstrap.Release()
|
||||
admin, err := op.GetAdmin()
|
||||
if err != nil {
|
||||
utils.Log.Errorf("failed get admin user: %+v", err)
|
||||
} else {
|
||||
utils.Log.Infof("Admin user's username: %s", admin.Username)
|
||||
utils.Log.Infof("The password can only be output at the first startup, and then stored as a hash value, which cannot be reversed")
|
||||
utils.Log.Infof("You can reset the password with a random string by running [openlist admin random]")
|
||||
utils.Log.Infof("You can also set a new password by running [openlist admin set NEW_PASSWORD]")
|
||||
utils.Log.Infof("get admin user from CLI")
|
||||
fmt.Println("Admin user's username:", admin.Username)
|
||||
fmt.Println("The password can only be output at the first startup, and then stored as a hash value, which cannot be reversed")
|
||||
fmt.Println("You can reset the password with a random string by running [openlist admin random]")
|
||||
fmt.Println("You can also set a new password by running [openlist admin set NEW_PASSWORD]")
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -36,6 +40,7 @@ var RandomPasswordCmd = &cobra.Command{
|
||||
Use: "random",
|
||||
Short: "Reset admin user's password to a random string",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
utils.Log.Infof("reset admin user's password to a random string from CLI")
|
||||
newPwd := random.String(8)
|
||||
setAdminPassword(newPwd)
|
||||
},
|
||||
@@ -44,12 +49,12 @@ var RandomPasswordCmd = &cobra.Command{
|
||||
var SetPasswordCmd = &cobra.Command{
|
||||
Use: "set",
|
||||
Short: "Set admin user's password",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if len(args) == 0 {
|
||||
utils.Log.Errorf("Please enter the new password")
|
||||
return
|
||||
return fmt.Errorf("Please enter the new password")
|
||||
}
|
||||
setAdminPassword(args[0])
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
@@ -57,16 +62,17 @@ var ShowTokenCmd = &cobra.Command{
|
||||
Use: "token",
|
||||
Short: "Show admin token",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
Init()
|
||||
defer Release()
|
||||
bootstrap.Init()
|
||||
defer bootstrap.Release()
|
||||
token := setting.GetStr(conf.Token)
|
||||
utils.Log.Infof("Admin token: %s", token)
|
||||
utils.Log.Infof("show admin token from CLI")
|
||||
fmt.Println("Admin token:", token)
|
||||
},
|
||||
}
|
||||
|
||||
func setAdminPassword(pwd string) {
|
||||
Init()
|
||||
defer Release()
|
||||
bootstrap.Init()
|
||||
defer bootstrap.Release()
|
||||
admin, err := op.GetAdmin()
|
||||
if err != nil {
|
||||
utils.Log.Errorf("failed get admin user: %+v", err)
|
||||
@@ -77,9 +83,10 @@ func setAdminPassword(pwd string) {
|
||||
utils.Log.Errorf("failed update admin user: %+v", err)
|
||||
return
|
||||
}
|
||||
utils.Log.Infof("admin user has been updated:")
|
||||
utils.Log.Infof("username: %s", admin.Username)
|
||||
utils.Log.Infof("password: %s", pwd)
|
||||
utils.Log.Infof("admin user has been update from CLI")
|
||||
fmt.Println("admin user has been updated:")
|
||||
fmt.Println("username:", admin.Username)
|
||||
fmt.Println("password:", pwd)
|
||||
DelAdminCacheOnline()
|
||||
}
|
||||
|
||||
|
||||
+7
-3
@@ -4,6 +4,9 @@ Copyright © 2022 NAME HERE <EMAIL ADDRESS>
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/bootstrap"
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/op"
|
||||
"github.com/OpenListTeam/OpenList/v4/pkg/utils"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -14,8 +17,8 @@ var Cancel2FACmd = &cobra.Command{
|
||||
Use: "cancel2fa",
|
||||
Short: "Delete 2FA of admin user",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
Init()
|
||||
defer Release()
|
||||
bootstrap.Init()
|
||||
defer bootstrap.Release()
|
||||
admin, err := op.GetAdmin()
|
||||
if err != nil {
|
||||
utils.Log.Errorf("failed to get admin user: %+v", err)
|
||||
@@ -24,7 +27,8 @@ var Cancel2FACmd = &cobra.Command{
|
||||
if err != nil {
|
||||
utils.Log.Errorf("failed to cancel 2FA: %+v", err)
|
||||
} else {
|
||||
utils.Log.Info("2FA canceled")
|
||||
utils.Log.Infof("2FA is canceled from CLI")
|
||||
fmt.Println("2FA canceled")
|
||||
DelAdminCacheOnline()
|
||||
}
|
||||
}
|
||||
|
||||
+2
-10
@@ -6,24 +6,16 @@ import (
|
||||
"strconv"
|
||||
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/bootstrap"
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/bootstrap/data"
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/db"
|
||||
"github.com/OpenListTeam/OpenList/v4/pkg/utils"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
func Init() {
|
||||
bootstrap.InitConfig()
|
||||
bootstrap.Log()
|
||||
bootstrap.InitDB()
|
||||
data.InitData()
|
||||
bootstrap.InitStreamLimit()
|
||||
bootstrap.InitIndex()
|
||||
bootstrap.InitUpgradePatch()
|
||||
bootstrap.Init()
|
||||
}
|
||||
|
||||
func Release() {
|
||||
db.Close()
|
||||
bootstrap.Release()
|
||||
}
|
||||
|
||||
var pid = -1
|
||||
|
||||
+3
-5
@@ -1,19 +1,17 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
rcCrypt "github.com/rclone/rclone/backend/crypt"
|
||||
"github.com/rclone/rclone/fs/config/configmap"
|
||||
"github.com/rclone/rclone/fs/config/obscure"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// encryption and decryption command format for Crypt driver
|
||||
@@ -25,7 +23,7 @@ type options struct {
|
||||
|
||||
pwd string //de/encrypt password
|
||||
salt string
|
||||
filenameEncryption string //reference drivers\crypt\meta.go Addtion
|
||||
filenameEncryption string //reference drivers\crypt\meta.go Addition
|
||||
dirnameEncryption string
|
||||
filenameEncode string
|
||||
suffix string
|
||||
|
||||
@@ -2,6 +2,7 @@ package flags
|
||||
|
||||
var (
|
||||
DataDir string
|
||||
ConfigPath string
|
||||
Debug bool
|
||||
NoPrefix bool
|
||||
Dev bool
|
||||
|
||||
+20
-3
@@ -8,7 +8,6 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
_ "github.com/OpenListTeam/OpenList/v4/drivers"
|
||||
@@ -69,15 +68,33 @@ func writeFile(name string, data interface{}) {
|
||||
log.Errorf("failed to unmarshal json: %+v", err)
|
||||
return
|
||||
}
|
||||
if reflect.DeepEqual(oldData, newData) {
|
||||
if mergeJson(newData, oldData) {
|
||||
log.Infof("%s.json no changed, skip", name)
|
||||
} else {
|
||||
log.Infof("%s.json changed, update file", name)
|
||||
//log.Infof("old: %+v\nnew:%+v", oldData, data)
|
||||
utils.WriteJsonToFile(fmt.Sprintf("lang/%s.json", name), newData, true)
|
||||
utils.WriteJsonToFile(fmt.Sprintf("lang/%s.json", name), oldData, true)
|
||||
}
|
||||
}
|
||||
|
||||
func mergeJson(source, target map[string]interface{}) bool {
|
||||
equal := true
|
||||
for k, v := range source {
|
||||
tgtV, tgtOk := target[k]
|
||||
if !tgtOk {
|
||||
equal = false
|
||||
target[k] = v
|
||||
} else {
|
||||
srcMap, srcIsMap := v.(map[string]interface{})
|
||||
tgtMap, tgtIsMap := tgtV.(map[string]interface{})
|
||||
if srcIsMap && tgtIsMap {
|
||||
equal = mergeJson(srcMap, tgtMap) && equal
|
||||
}
|
||||
}
|
||||
}
|
||||
return equal
|
||||
}
|
||||
|
||||
func generateDriversJson() {
|
||||
drivers := make(Drivers)
|
||||
drivers["drivers"] = make(KV[interface{}])
|
||||
|
||||
+3
-2
@@ -16,7 +16,7 @@ var RootCmd = &cobra.Command{
|
||||
Short: "A file list program that supports multiple storage.",
|
||||
Long: `A file list program that supports multiple storage,
|
||||
built with love by OpenListTeam.
|
||||
Complete documentation is available at https://docs.openlist.team/`,
|
||||
Complete documentation is available at https://doc.oplist.org/`,
|
||||
}
|
||||
|
||||
func Execute() {
|
||||
@@ -27,7 +27,8 @@ func Execute() {
|
||||
}
|
||||
|
||||
func init() {
|
||||
RootCmd.PersistentFlags().StringVar(&flags.DataDir, "data", "data", "data folder")
|
||||
RootCmd.PersistentFlags().StringVar(&flags.DataDir, "data", "data", "data directory (relative paths are resolved against the current working directory)")
|
||||
RootCmd.PersistentFlags().StringVar(&flags.ConfigPath, "config", "", "path to config.json (relative to current working directory; defaults to [data directory]/config.json, where [data directory] is set by --data)")
|
||||
RootCmd.PersistentFlags().BoolVar(&flags.Debug, "debug", false, "start with debug mode")
|
||||
RootCmd.PersistentFlags().BoolVar(&flags.NoPrefix, "no-prefix", false, "disable env prefix")
|
||||
RootCmd.PersistentFlags().BoolVar(&flags.Dev, "dev", false, "start with dev mode")
|
||||
|
||||
+4
-194
@@ -1,31 +1,13 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/OpenListTeam/OpenList/v4/cmd/flags"
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/bootstrap"
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/conf"
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/fs"
|
||||
"github.com/OpenListTeam/OpenList/v4/pkg/utils"
|
||||
"github.com/OpenListTeam/OpenList/v4/server"
|
||||
"github.com/OpenListTeam/sftpd-openlist"
|
||||
ftpserver "github.com/fclairamb/ftpserverlib"
|
||||
"github.com/gin-gonic/gin"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/net/http2"
|
||||
"golang.org/x/net/http2/h2c"
|
||||
)
|
||||
|
||||
// ServerCmd represents the server command
|
||||
@@ -35,128 +17,9 @@ var ServerCmd = &cobra.Command{
|
||||
Long: `Start the server at the specified address
|
||||
the address is defined in config file`,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
Init()
|
||||
if conf.Conf.DelayedStart != 0 {
|
||||
utils.Log.Infof("delayed start for %d seconds", conf.Conf.DelayedStart)
|
||||
time.Sleep(time.Duration(conf.Conf.DelayedStart) * time.Second)
|
||||
}
|
||||
bootstrap.InitOfflineDownloadTools()
|
||||
bootstrap.LoadStorages()
|
||||
bootstrap.InitTaskManager()
|
||||
if !flags.Debug && !flags.Dev {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
}
|
||||
r := gin.New()
|
||||
r.Use(gin.LoggerWithWriter(log.StandardLogger().Out), gin.RecoveryWithWriter(log.StandardLogger().Out))
|
||||
server.Init(r)
|
||||
var httpHandler http.Handler = r
|
||||
if conf.Conf.Scheme.EnableH2c {
|
||||
httpHandler = h2c.NewHandler(r, &http2.Server{})
|
||||
}
|
||||
var httpSrv, httpsSrv, unixSrv *http.Server
|
||||
if conf.Conf.Scheme.HttpPort != -1 {
|
||||
httpBase := fmt.Sprintf("%s:%d", conf.Conf.Scheme.Address, conf.Conf.Scheme.HttpPort)
|
||||
utils.Log.Infof("start HTTP server @ %s", httpBase)
|
||||
httpSrv = &http.Server{Addr: httpBase, Handler: httpHandler}
|
||||
go func() {
|
||||
err := httpSrv.ListenAndServe()
|
||||
if err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
utils.Log.Fatalf("failed to start http: %s", err.Error())
|
||||
}
|
||||
}()
|
||||
}
|
||||
if conf.Conf.Scheme.HttpsPort != -1 {
|
||||
httpsBase := fmt.Sprintf("%s:%d", conf.Conf.Scheme.Address, conf.Conf.Scheme.HttpsPort)
|
||||
utils.Log.Infof("start HTTPS server @ %s", httpsBase)
|
||||
httpsSrv = &http.Server{Addr: httpsBase, Handler: r}
|
||||
go func() {
|
||||
err := httpsSrv.ListenAndServeTLS(conf.Conf.Scheme.CertFile, conf.Conf.Scheme.KeyFile)
|
||||
if err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
utils.Log.Fatalf("failed to start https: %s", err.Error())
|
||||
}
|
||||
}()
|
||||
}
|
||||
if conf.Conf.Scheme.UnixFile != "" {
|
||||
utils.Log.Infof("start unix server @ %s", conf.Conf.Scheme.UnixFile)
|
||||
unixSrv = &http.Server{Handler: httpHandler}
|
||||
go func() {
|
||||
listener, err := net.Listen("unix", conf.Conf.Scheme.UnixFile)
|
||||
if err != nil {
|
||||
utils.Log.Fatalf("failed to listen unix: %+v", err)
|
||||
}
|
||||
// set socket file permission
|
||||
mode, err := strconv.ParseUint(conf.Conf.Scheme.UnixFilePerm, 8, 32)
|
||||
if err != nil {
|
||||
utils.Log.Errorf("failed to parse socket file permission: %+v", err)
|
||||
} else {
|
||||
err = os.Chmod(conf.Conf.Scheme.UnixFile, os.FileMode(mode))
|
||||
if err != nil {
|
||||
utils.Log.Errorf("failed to chmod socket file: %+v", err)
|
||||
}
|
||||
}
|
||||
err = unixSrv.Serve(listener)
|
||||
if err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
utils.Log.Fatalf("failed to start unix: %s", err.Error())
|
||||
}
|
||||
}()
|
||||
}
|
||||
if conf.Conf.S3.Port != -1 && conf.Conf.S3.Enable {
|
||||
s3r := gin.New()
|
||||
s3r.Use(gin.LoggerWithWriter(log.StandardLogger().Out), gin.RecoveryWithWriter(log.StandardLogger().Out))
|
||||
server.InitS3(s3r)
|
||||
s3Base := fmt.Sprintf("%s:%d", conf.Conf.Scheme.Address, conf.Conf.S3.Port)
|
||||
utils.Log.Infof("start S3 server @ %s", s3Base)
|
||||
go func() {
|
||||
var err error
|
||||
if conf.Conf.S3.SSL {
|
||||
httpsSrv = &http.Server{Addr: s3Base, Handler: s3r}
|
||||
err = httpsSrv.ListenAndServeTLS(conf.Conf.Scheme.CertFile, conf.Conf.Scheme.KeyFile)
|
||||
}
|
||||
if !conf.Conf.S3.SSL {
|
||||
httpSrv = &http.Server{Addr: s3Base, Handler: s3r}
|
||||
err = httpSrv.ListenAndServe()
|
||||
}
|
||||
if err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
utils.Log.Fatalf("failed to start s3 server: %s", err.Error())
|
||||
}
|
||||
}()
|
||||
}
|
||||
var ftpDriver *server.FtpMainDriver
|
||||
var ftpServer *ftpserver.FtpServer
|
||||
if conf.Conf.FTP.Listen != "" && conf.Conf.FTP.Enable {
|
||||
var err error
|
||||
ftpDriver, err = server.NewMainDriver()
|
||||
if err != nil {
|
||||
utils.Log.Fatalf("failed to start ftp driver: %s", err.Error())
|
||||
} else {
|
||||
utils.Log.Infof("start ftp server on %s", conf.Conf.FTP.Listen)
|
||||
go func() {
|
||||
ftpServer = ftpserver.NewFtpServer(ftpDriver)
|
||||
err = ftpServer.ListenAndServe()
|
||||
if err != nil {
|
||||
utils.Log.Fatalf("problem ftp server listening: %s", err.Error())
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
var sftpDriver *server.SftpDriver
|
||||
var sftpServer *sftpd.SftpServer
|
||||
if conf.Conf.SFTP.Listen != "" && conf.Conf.SFTP.Enable {
|
||||
var err error
|
||||
sftpDriver, err = server.NewSftpDriver()
|
||||
if err != nil {
|
||||
utils.Log.Fatalf("failed to start sftp driver: %s", err.Error())
|
||||
} else {
|
||||
utils.Log.Infof("start sftp server on %s", conf.Conf.SFTP.Listen)
|
||||
go func() {
|
||||
sftpServer = sftpd.NewSftpServer(sftpDriver)
|
||||
err = sftpServer.RunServer()
|
||||
if err != nil {
|
||||
utils.Log.Fatalf("problem sftp server listening: %s", err.Error())
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
bootstrap.Init()
|
||||
defer bootstrap.Release()
|
||||
bootstrap.Start()
|
||||
// Wait for interrupt signal to gracefully shutdown the server with
|
||||
// a timeout of 1 second.
|
||||
quit := make(chan os.Signal, 1)
|
||||
@@ -165,60 +28,7 @@ the address is defined in config file`,
|
||||
// kill -9 is syscall. SIGKILL but can"t be catch, so don't need add it
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-quit
|
||||
utils.Log.Println("Shutdown server...")
|
||||
fs.ArchiveContentUploadTaskManager.RemoveAll()
|
||||
Release()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
|
||||
defer cancel()
|
||||
var wg sync.WaitGroup
|
||||
if conf.Conf.Scheme.HttpPort != -1 {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if err := httpSrv.Shutdown(ctx); err != nil {
|
||||
utils.Log.Fatal("HTTP server shutdown err: ", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
if conf.Conf.Scheme.HttpsPort != -1 {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if err := httpsSrv.Shutdown(ctx); err != nil {
|
||||
utils.Log.Fatal("HTTPS server shutdown err: ", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
if conf.Conf.Scheme.UnixFile != "" {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if err := unixSrv.Shutdown(ctx); err != nil {
|
||||
utils.Log.Fatal("Unix server shutdown err: ", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
if conf.Conf.FTP.Listen != "" && conf.Conf.FTP.Enable && ftpServer != nil && ftpDriver != nil {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
ftpDriver.Stop()
|
||||
if err := ftpServer.Stop(); err != nil {
|
||||
utils.Log.Fatal("FTP server shutdown err: ", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
if conf.Conf.SFTP.Listen != "" && conf.Conf.SFTP.Enable && sftpServer != nil && sftpDriver != nil {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if err := sftpServer.Close(); err != nil {
|
||||
utils.Log.Fatal("SFTP server shutdown err: ", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
utils.Log.Println("Server exit")
|
||||
bootstrap.Shutdown(1 * time.Second)
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
+17
-7
@@ -8,6 +8,7 @@ import (
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -31,14 +32,23 @@ func start() {
|
||||
return
|
||||
}
|
||||
}
|
||||
args := os.Args
|
||||
args[1] = "server"
|
||||
args = append(args, "--force-bin-dir")
|
||||
cmd := &exec.Cmd{
|
||||
Path: args[0],
|
||||
Args: args,
|
||||
Env: os.Environ(),
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
log.Fatal("failed to resolve executable path: ", err)
|
||||
}
|
||||
childArgs := append([]string{"server"}, os.Args[2:]...)
|
||||
hasForceBinDir := false
|
||||
for _, arg := range childArgs {
|
||||
if arg == "--force-bin-dir" || strings.HasPrefix(arg, "--force-bin-dir=") {
|
||||
hasForceBinDir = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasForceBinDir {
|
||||
childArgs = append(childArgs, "--force-bin-dir")
|
||||
}
|
||||
cmd := exec.Command(exe, childArgs...)
|
||||
cmd.Env = os.Environ()
|
||||
stdout, err := os.OpenFile(filepath.Join(filepath.Dir(pidFile), "start.log"), os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0666)
|
||||
if err != nil {
|
||||
log.Fatal(os.Getpid(), ": failed to open start log file:", err)
|
||||
|
||||
+59
-21
@@ -4,9 +4,11 @@ Copyright © 2023 NAME HERE <EMAIL ADDRESS>
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/bootstrap"
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/db"
|
||||
"github.com/OpenListTeam/OpenList/v4/pkg/utils"
|
||||
"github.com/charmbracelet/bubbles/table"
|
||||
@@ -22,28 +24,61 @@ var storageCmd = &cobra.Command{
|
||||
}
|
||||
|
||||
var disableStorageCmd = &cobra.Command{
|
||||
Use: "disable",
|
||||
Short: "Disable a storage",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
Use: "disable [mount path]",
|
||||
Short: "Disable a storage by mount path",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if len(args) < 1 {
|
||||
utils.Log.Errorf("mount path is required")
|
||||
return
|
||||
return fmt.Errorf("mount path is required")
|
||||
}
|
||||
mountPath := args[0]
|
||||
Init()
|
||||
defer Release()
|
||||
bootstrap.Init()
|
||||
defer bootstrap.Release()
|
||||
storage, err := db.GetStorageByMountPath(mountPath)
|
||||
if err != nil {
|
||||
utils.Log.Errorf("failed to query storage: %+v", err)
|
||||
} else {
|
||||
storage.Disabled = true
|
||||
err = db.UpdateStorage(storage)
|
||||
if err != nil {
|
||||
utils.Log.Errorf("failed to update storage: %+v", err)
|
||||
} else {
|
||||
utils.Log.Infof("Storage with mount path [%s] have been disabled", mountPath)
|
||||
return fmt.Errorf("failed to query storage: %+v", err)
|
||||
}
|
||||
storage.Disabled = true
|
||||
err = db.UpdateStorage(storage)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update storage: %+v", err)
|
||||
}
|
||||
utils.Log.Infof("Storage with mount path [%s] has been disabled from CLI", mountPath)
|
||||
fmt.Printf("Storage with mount path [%s] has been disabled\n", mountPath)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var deleteStorageCmd = &cobra.Command{
|
||||
Use: "delete [id]",
|
||||
Short: "Delete a storage by id",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if len(args) < 1 {
|
||||
return fmt.Errorf("id is required")
|
||||
}
|
||||
id, err := strconv.Atoi(args[0])
|
||||
if err != nil {
|
||||
return fmt.Errorf("id must be a number")
|
||||
}
|
||||
|
||||
if force, _ := cmd.Flags().GetBool("force"); force {
|
||||
fmt.Printf("Are you sure you want to delete storage with id [%d]? [y/N]: ", id)
|
||||
var confirm string
|
||||
fmt.Scanln(&confirm)
|
||||
if confirm != "y" && confirm != "Y" {
|
||||
fmt.Println("Delete operation cancelled.")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
bootstrap.Init()
|
||||
defer bootstrap.Release()
|
||||
err = db.DeleteStorageById(uint(id))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete storage by id: %+v", err)
|
||||
}
|
||||
utils.Log.Infof("Storage with id [%d] have been deleted from CLI", id)
|
||||
fmt.Printf("Storage with id [%d] have been deleted\n", id)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
@@ -88,14 +123,14 @@ var storageTableHeight int
|
||||
var listStorageCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List all storages",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
Init()
|
||||
defer Release()
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
bootstrap.Init()
|
||||
defer bootstrap.Release()
|
||||
storages, _, err := db.GetStorages(1, -1)
|
||||
if err != nil {
|
||||
utils.Log.Errorf("failed to query storages: %+v", err)
|
||||
return fmt.Errorf("failed to query storages: %+v", err)
|
||||
} else {
|
||||
utils.Log.Infof("Found %d storages", len(storages))
|
||||
fmt.Printf("Found %d storages\n", len(storages))
|
||||
columns := []table.Column{
|
||||
{Title: "ID", Width: 4},
|
||||
{Title: "Driver", Width: 16},
|
||||
@@ -138,10 +173,11 @@ var listStorageCmd = &cobra.Command{
|
||||
|
||||
m := model{t}
|
||||
if _, err := tea.NewProgram(m).Run(); err != nil {
|
||||
utils.Log.Errorf("failed to run program: %+v", err)
|
||||
fmt.Printf("failed to run program: %+v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
@@ -151,6 +187,8 @@ func init() {
|
||||
storageCmd.AddCommand(disableStorageCmd)
|
||||
storageCmd.AddCommand(listStorageCmd)
|
||||
storageCmd.PersistentFlags().IntVarP(&storageTableHeight, "height", "H", 10, "Table height")
|
||||
storageCmd.AddCommand(deleteStorageCmd)
|
||||
deleteStorageCmd.Flags().BoolP("force", "f", false, "Force delete without confirmation")
|
||||
// Here you will define your flags and configuration settings.
|
||||
|
||||
// Cobra supports Persistent Flags which will work for this command
|
||||
|
||||
+3
-3
@@ -9,7 +9,7 @@ import (
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/op"
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/setting"
|
||||
"github.com/OpenListTeam/OpenList/v4/pkg/utils"
|
||||
"resty.dev/v3"
|
||||
"github.com/go-resty/resty/v2"
|
||||
)
|
||||
|
||||
func DelAdminCacheOnline() {
|
||||
@@ -42,8 +42,8 @@ func DelUserCacheOnline(username string) {
|
||||
utils.Log.Warnf("[del_user_cache_online] failed: %+v", res.String())
|
||||
return
|
||||
}
|
||||
code := utils.Json.Get(res.Bytes(), "code").ToInt()
|
||||
msg := utils.Json.Get(res.Bytes(), "message").ToString()
|
||||
code := utils.Json.Get(res.Body(), "code").ToInt()
|
||||
msg := utils.Json.Get(res.Body(), "message").ToString()
|
||||
if code != 200 {
|
||||
utils.Log.Errorf("[del_user_cache_online] error: %s", msg)
|
||||
return
|
||||
|
||||
+2
-3
@@ -6,10 +6,9 @@ services:
|
||||
ports:
|
||||
- '5244:5244'
|
||||
- '5245:5245'
|
||||
user: '0:0'
|
||||
environment:
|
||||
- PUID=0
|
||||
- PGID=0
|
||||
- UMASK=022
|
||||
- TZ=UTC
|
||||
- TZ=Asia/Shanghai
|
||||
container_name: openlist
|
||||
image: 'openlistteam/openlist:latest'
|
||||
|
||||
+33
-16
@@ -1,43 +1,60 @@
|
||||
package _115
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/OpenListTeam/OpenList/v4/drivers/base"
|
||||
driver115 "github.com/j2rong4cn/115driver/pkg/driver"
|
||||
"github.com/OpenListTeam/OpenList/v4/pkg/utils"
|
||||
driver115 "github.com/SheltonZhu/115driver/pkg/driver"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
var (
|
||||
md5Salt = "Qclm8MGWUv59TnrR0XPg"
|
||||
appVer = "27.0.5.7"
|
||||
appVer = "35.6.0.3"
|
||||
)
|
||||
|
||||
func (d *Pan115) getAppVersion() ([]driver115.AppVersion, error) {
|
||||
result := driver115.VersionResp{}
|
||||
resp, err := base.RestyClient.R().Get(driver115.ApiGetVersion)
|
||||
|
||||
err = driver115.CheckErr(err, &result, resp)
|
||||
func (d *Pan115) getAppVersion() (string, error) {
|
||||
result := VersionResp{}
|
||||
res, err := base.RestyClient.R().Get(driver115.ApiGetVersion)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return "", err
|
||||
}
|
||||
|
||||
return result.Data.GetAppVersions(), nil
|
||||
err = utils.Json.Unmarshal(res.Body(), &result)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(result.Error) > 0 {
|
||||
return "", errors.New(result.Error)
|
||||
}
|
||||
return result.Data.Win.Version, nil
|
||||
}
|
||||
|
||||
func (d *Pan115) getAppVer() string {
|
||||
// todo add some cache?
|
||||
vers, err := d.getAppVersion()
|
||||
ver, err := d.getAppVersion()
|
||||
if err != nil {
|
||||
log.Warnf("[115] get app version failed: %v", err)
|
||||
return appVer
|
||||
}
|
||||
for _, ver := range vers {
|
||||
if ver.AppName == "win" {
|
||||
return ver.Version
|
||||
}
|
||||
if len(ver) > 0 {
|
||||
return ver
|
||||
}
|
||||
return appVer
|
||||
}
|
||||
|
||||
func (d *Pan115) initAppVer() {
|
||||
appVer = d.getAppVer()
|
||||
log.Debugf("use app version: %v", appVer)
|
||||
}
|
||||
|
||||
type VersionResp struct {
|
||||
Error string `json:"error,omitempty"`
|
||||
Data Versions `json:"data"`
|
||||
}
|
||||
|
||||
type Versions struct {
|
||||
Win Version `json:"win"`
|
||||
}
|
||||
|
||||
type Version struct {
|
||||
Version string `json:"version_code"`
|
||||
}
|
||||
|
||||
+17
-5
@@ -10,7 +10,7 @@ import (
|
||||
streamPkg "github.com/OpenListTeam/OpenList/v4/internal/stream"
|
||||
"github.com/OpenListTeam/OpenList/v4/pkg/http_range"
|
||||
"github.com/OpenListTeam/OpenList/v4/pkg/utils"
|
||||
driver115 "github.com/j2rong4cn/115driver/pkg/driver"
|
||||
driver115 "github.com/SheltonZhu/115driver/pkg/driver"
|
||||
"github.com/pkg/errors"
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
@@ -68,8 +68,7 @@ func (d *Pan115) Link(ctx context.Context, file model.Obj, args model.LinkArgs)
|
||||
return nil, err
|
||||
}
|
||||
userAgent := args.Header.Get("User-Agent")
|
||||
downloadInfo, err := d.
|
||||
DownloadWithUA(file.(*FileObj).PickCode, userAgent)
|
||||
downloadInfo, err := d.client.DownloadWithUA(file.(*FileObj).PickCode, userAgent)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -93,7 +92,7 @@ func (d *Pan115) MakeDir(ctx context.Context, parentDir model.Obj, dirName strin
|
||||
req := d.client.NewRequest().
|
||||
SetFormData(form).
|
||||
SetResult(&result).
|
||||
SetForceResponseContentType("application/json;charset=UTF-8")
|
||||
ForceContentType("application/json;charset=UTF-8")
|
||||
|
||||
resp, err := req.Post(driver115.ApiDirAdd)
|
||||
|
||||
@@ -186,7 +185,7 @@ func (d *Pan115) Put(ctx context.Context, dstDir model.Obj, stream model.FileStr
|
||||
preHash = strings.ToUpper(preHash)
|
||||
fullHash := stream.GetHash().GetHash(utils.SHA1)
|
||||
if len(fullHash) != utils.SHA1.Width {
|
||||
_, fullHash, err = streamPkg.CacheFullInTempFileAndHash(stream, utils.SHA1)
|
||||
_, fullHash, err = streamPkg.CacheFullAndHash(stream, &up, utils.SHA1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -245,4 +244,17 @@ func (d *Pan115) DeleteOfflineTasks(ctx context.Context, hashes []string, delete
|
||||
return d.client.DeleteOfflineTasks(hashes, deleteFiles)
|
||||
}
|
||||
|
||||
func (d *Pan115) GetDetails(ctx context.Context) (*model.StorageDetails, error) {
|
||||
info, err := d.client.GetInfo()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &model.StorageDetails{
|
||||
DiskUsage: model.DiskUsage{
|
||||
TotalSpace: info.SpaceInfo.AllTotal.Size,
|
||||
UsedSpace: info.SpaceInfo.AllUse.Size,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
var _ driver.Driver = (*Pan115)(nil)
|
||||
|
||||
+3
-5
@@ -15,11 +15,9 @@ type Addition struct {
|
||||
}
|
||||
|
||||
var config = driver.Config{
|
||||
Name: "115 Cloud",
|
||||
DefaultRoot: "0",
|
||||
// OnlyProxy: true,
|
||||
// OnlyLocal: true,
|
||||
// NoOverwriteUpload: true,
|
||||
Name: "115 Cloud",
|
||||
DefaultRoot: "0",
|
||||
LinkCacheMode: driver.LinkCacheUA,
|
||||
}
|
||||
|
||||
func init() {
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/model"
|
||||
"github.com/OpenListTeam/OpenList/v4/pkg/utils"
|
||||
"github.com/j2rong4cn/115driver/pkg/driver"
|
||||
"github.com/SheltonZhu/115driver/pkg/driver"
|
||||
)
|
||||
|
||||
var _ model.Obj = (*FileObj)(nil)
|
||||
@@ -22,6 +22,10 @@ func (f *FileObj) GetHash() utils.HashInfo {
|
||||
return utils.NewHashInfo(utils.SHA1, f.Sha1)
|
||||
}
|
||||
|
||||
func (f *FileObj) Thumb() string {
|
||||
return f.ThumbURL
|
||||
}
|
||||
|
||||
type UploadResult struct {
|
||||
driver.BasicResp
|
||||
Data struct {
|
||||
|
||||
+14
-67
@@ -9,7 +9,6 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -20,13 +19,12 @@ import (
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/conf"
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/driver"
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/model"
|
||||
netutil "github.com/OpenListTeam/OpenList/v4/internal/net"
|
||||
"github.com/OpenListTeam/OpenList/v4/pkg/http_range"
|
||||
"github.com/OpenListTeam/OpenList/v4/pkg/utils"
|
||||
cipher "github.com/SheltonZhu/115driver/pkg/crypto/ec115"
|
||||
driver115 "github.com/SheltonZhu/115driver/pkg/driver"
|
||||
"github.com/aliyun/aliyun-oss-go-sdk/oss"
|
||||
|
||||
cipher "github.com/j2rong4cn/115driver/pkg/crypto/ec115"
|
||||
crypto "github.com/j2rong4cn/115driver/pkg/crypto/m115"
|
||||
driver115 "github.com/j2rong4cn/115driver/pkg/driver"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
@@ -88,7 +86,7 @@ func (d *Pan115) getNewFileByPickCode(pickCode string) (*FileObj, error) {
|
||||
result := driver115.GetFileInfoResponse{}
|
||||
req := d.client.NewRequest().
|
||||
SetQueryParam("pick_code", pickCode).
|
||||
SetForceResponseContentType("application/json;charset=UTF-8").
|
||||
ForceContentType("application/json;charset=UTF-8").
|
||||
SetResult(&result)
|
||||
resp, err := req.Get(driver115.ApiFileInfo)
|
||||
if err := driver115.CheckErr(err, &result, resp); err != nil {
|
||||
@@ -108,60 +106,6 @@ func (d *Pan115) getUA() string {
|
||||
return fmt.Sprintf("Mozilla/5.0 115Browser/%s", appVer)
|
||||
}
|
||||
|
||||
func (d *Pan115) DownloadWithUA(pickCode, ua string) (*driver115.DownloadInfo, error) {
|
||||
key := crypto.GenerateKey()
|
||||
result := driver115.DownloadResp{}
|
||||
params, err := utils.Json.Marshal(map[string]string{"pick_code": pickCode})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
data := crypto.Encode(params, key)
|
||||
|
||||
bodyReader := strings.NewReader(url.Values{"data": []string{data}}.Encode())
|
||||
reqUrl := fmt.Sprintf("%s?t=%s", driver115.AndroidApiDownloadGetUrl, driver115.Now().String())
|
||||
req, _ := http.NewRequest(http.MethodPost, reqUrl, bodyReader)
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Cookie", d.Cookie)
|
||||
req.Header.Set("User-Agent", ua)
|
||||
|
||||
resp, err := d.client.Client.Client().Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := utils.Json.Unmarshal(body, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err = result.Err(string(body)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
b, err := crypto.Decode(string(result.EncodedData), key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
downloadInfo := struct {
|
||||
Url string `json:"url"`
|
||||
}{}
|
||||
if err := utils.Json.Unmarshal(b, &downloadInfo); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
info := &driver115.DownloadInfo{}
|
||||
info.PickCode = pickCode
|
||||
info.Header = resp.Request.Header
|
||||
info.Url.Url = downloadInfo.Url
|
||||
return info, nil
|
||||
}
|
||||
|
||||
func (c *Pan115) GenerateToken(fileID, preID, timeStamp, fileSize, signKey, signVal string) string {
|
||||
userID := strconv.FormatInt(c.client.UserID, 10)
|
||||
userIDMd5 := md5.Sum([]byte(userID))
|
||||
@@ -227,7 +171,7 @@ func (d *Pan115) rapidUpload(fileSize int64, fileName, dirID, preID, fileID stri
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data := resp.Body
|
||||
data := resp.RawBody()
|
||||
defer data.Close()
|
||||
if bodyBytes, err = io.ReadAll(data); err != nil {
|
||||
return nil, err
|
||||
@@ -279,7 +223,7 @@ func (c *Pan115) UploadByOSS(ctx context.Context, params *driver115.UploadOSSPar
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ossClient, err := oss.New(driver115.OSSEndpoint, ossToken.AccessKeyID, ossToken.AccessKeySecret)
|
||||
ossClient, err := netutil.NewOSSClient(driver115.OSSEndpoint, ossToken.AccessKeyID, ossToken.AccessKeySecret)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -309,7 +253,8 @@ func (c *Pan115) UploadByOSS(ctx context.Context, params *driver115.UploadOSSPar
|
||||
|
||||
// UploadByMultipart upload by mutipart blocks
|
||||
func (d *Pan115) UploadByMultipart(ctx context.Context, params *driver115.UploadOSSParams, fileSize int64, s model.FileStreamer,
|
||||
dirID string, up driver.UpdateProgress, opts ...driver115.UploadMultipartOption) (*UploadResult, error) {
|
||||
dirID string, up driver.UpdateProgress, opts ...driver115.UploadMultipartOption,
|
||||
) (*UploadResult, error) {
|
||||
var (
|
||||
chunks []oss.FileChunk
|
||||
parts []oss.UploadPart
|
||||
@@ -321,7 +266,7 @@ func (d *Pan115) UploadByMultipart(ctx context.Context, params *driver115.Upload
|
||||
err error
|
||||
)
|
||||
|
||||
tmpF, err := s.CacheFullInTempFile()
|
||||
tmpF, err := s.CacheFullAndWriter(&up, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -339,7 +284,7 @@ func (d *Pan115) UploadByMultipart(ctx context.Context, params *driver115.Upload
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if ossClient, err = oss.New(driver115.OSSEndpoint, ossToken.AccessKeyID, ossToken.AccessKeySecret, oss.EnableMD5(true), oss.EnableCRC(true)); err != nil {
|
||||
if ossClient, err = netutil.NewOSSClient(driver115.OSSEndpoint, ossToken.AccessKeyID, ossToken.AccessKeySecret, oss.EnableMD5(true), oss.EnableCRC(true)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -392,9 +337,11 @@ func (d *Pan115) UploadByMultipart(ctx context.Context, params *driver115.Upload
|
||||
for chunk := range chunksCh {
|
||||
var part oss.UploadPart // 出现错误就继续尝试,共尝试3次
|
||||
for retry := 0; retry < 3; retry++ {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
if utils.IsCanceled(ctx) {
|
||||
err = ctx.Err()
|
||||
break
|
||||
}
|
||||
select {
|
||||
case <-ticker.C:
|
||||
if ossToken, err = d.client.GetOSSToken(); err != nil { // 到时重新获取ossToken
|
||||
errCh <- errors.Wrap(err, "刷新token时出现错误")
|
||||
|
||||
+150
-20
@@ -2,29 +2,34 @@ package _115_open
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
stdpath "path"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
sdk "github.com/OpenListTeam/115-sdk-go"
|
||||
"github.com/OpenListTeam/OpenList/v4/cmd/flags"
|
||||
"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"
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/stream"
|
||||
"github.com/OpenListTeam/OpenList/v4/pkg/http_range"
|
||||
"github.com/OpenListTeam/OpenList/v4/pkg/utils"
|
||||
sdk "github.com/OpenListTeam/115-sdk-go"
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
type Open115 struct {
|
||||
model.Storage
|
||||
Addition
|
||||
client *sdk.Client
|
||||
limiter *rate.Limiter
|
||||
client *sdk.Client
|
||||
limiter *rate.Limiter
|
||||
parentPath string
|
||||
}
|
||||
|
||||
func (d *Open115) Config() driver.Config {
|
||||
@@ -53,6 +58,34 @@ func (d *Open115) Init(ctx context.Context) error {
|
||||
if d.Addition.LimitRate > 0 {
|
||||
d.limiter = rate.NewLimiter(rate.Limit(d.Addition.LimitRate), 1)
|
||||
}
|
||||
if d.PageSize <= 0 {
|
||||
d.PageSize = 200
|
||||
} else if d.PageSize > 1150 {
|
||||
d.PageSize = 1150
|
||||
}
|
||||
|
||||
// add parent path
|
||||
d.parentPath = "/"
|
||||
if d.GetRootId() != d.Config().DefaultRoot {
|
||||
folderInfo, err := d.client.GetFolderInfo(ctx, d.GetRootId())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if folderInfo.FileID != d.Config().DefaultRoot {
|
||||
d.parentPath = stdpath.Join(d.parentPath, folderInfo.FileName)
|
||||
}
|
||||
|
||||
parentPaths := folderInfo.Paths
|
||||
slices.Reverse(parentPaths)
|
||||
for _, parentPathInfo := range parentPaths {
|
||||
if parentPathInfo.FileID == d.Config().DefaultRoot {
|
||||
d.parentPath = stdpath.Join("/", d.parentPath)
|
||||
} else {
|
||||
d.parentPath = stdpath.Join("/", parentPathInfo.FileName, d.parentPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -69,7 +102,7 @@ func (d *Open115) Drop(ctx context.Context) error {
|
||||
|
||||
func (d *Open115) List(ctx context.Context, dir model.Obj, args model.ListArgs) ([]model.Obj, error) {
|
||||
var res []model.Obj
|
||||
pageSize := int64(200)
|
||||
pageSize := int64(d.PageSize)
|
||||
offset := int64(0)
|
||||
for {
|
||||
if err := d.WaitLimit(ctx); err != nil {
|
||||
@@ -131,6 +164,69 @@ func (d *Open115) Link(ctx context.Context, file model.Obj, args model.LinkArgs)
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (d *Open115) Get(ctx context.Context, path string) (model.Obj, error) {
|
||||
if err := d.WaitLimit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
path = stdpath.Join(d.parentPath, path)
|
||||
resp, err := d.client.GetFolderInfoByPath(ctx, path)
|
||||
if err != nil {
|
||||
if errors.Is(err, sdk.ErrObjectNotFound) {
|
||||
return d.getFromParent(ctx, path, "")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
obj := &Obj{
|
||||
Fid: resp.FileID,
|
||||
Fn: resp.FileName,
|
||||
Fc: resp.FileCategory,
|
||||
Sha1: resp.Sha1,
|
||||
Pc: resp.PickCode,
|
||||
FS: resp.SizeByte,
|
||||
Upt: parseTime(resp.UTime),
|
||||
UpPt: parseTime(resp.PTime),
|
||||
}
|
||||
if !obj.IsDir() && obj.ModTime().Unix() <= 0 {
|
||||
return d.getFromParent(ctx, path, obj.GetID())
|
||||
}
|
||||
return obj, nil
|
||||
}
|
||||
|
||||
func (d *Open115) getFromParent(ctx context.Context, path, id string) (model.Obj, error) {
|
||||
path = stdpath.Clean(path)
|
||||
parent, name := stdpath.Split(path)
|
||||
parent = stdpath.Clean(parent)
|
||||
parentID := d.GetRootId()
|
||||
if stdpath.Clean(parent) != stdpath.Clean(d.parentPath) {
|
||||
if err := d.WaitLimit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
parentInfo, err := d.client.GetFolderInfoByPath(ctx, parent)
|
||||
if err != nil {
|
||||
if !errors.Is(err, sdk.ErrObjectNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
parentObj, err := d.getFromParent(ctx, parent, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
parentID = parentObj.GetID()
|
||||
} else {
|
||||
parentID = parentInfo.FileID
|
||||
}
|
||||
}
|
||||
files, err := d.List(ctx, &Obj{Fid: parentID, Fc: "0"}, model.ListArgs{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, file := range files {
|
||||
if (id != "" && file.GetID() == id) || (id == "" && file.GetName() == name) {
|
||||
return file, nil
|
||||
}
|
||||
}
|
||||
return nil, errs.ObjectNotFound
|
||||
}
|
||||
|
||||
func (d *Open115) MakeDir(ctx context.Context, parentDir model.Obj, dirName string) (model.Obj, error) {
|
||||
if err := d.WaitLimit(ctx); err != nil {
|
||||
return nil, err
|
||||
@@ -150,18 +246,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) {
|
||||
@@ -169,8 +262,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
|
||||
@@ -178,23 +271,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 {
|
||||
@@ -222,7 +313,7 @@ func (d *Open115) Put(ctx context.Context, dstDir model.Obj, file model.FileStre
|
||||
}
|
||||
sha1 := file.GetHash().GetHash(utils.SHA1)
|
||||
if len(sha1) != utils.SHA1.Width {
|
||||
_, sha1, err = stream.CacheFullInTempFileAndHash(file, utils.SHA1)
|
||||
_, sha1, err = stream.CacheFullAndHash(file, &up, utils.SHA1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -252,6 +343,7 @@ func (d *Open115) Put(ctx context.Context, dstDir model.Obj, file model.FileStre
|
||||
return err
|
||||
}
|
||||
if resp.Status == 2 {
|
||||
up(100)
|
||||
return nil
|
||||
}
|
||||
// 2. two way verify
|
||||
@@ -286,6 +378,7 @@ func (d *Open115) Put(ctx context.Context, dstDir model.Obj, file model.FileStre
|
||||
return err
|
||||
}
|
||||
if resp.Status == 2 {
|
||||
up(100)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -302,6 +395,43 @@ func (d *Open115) Put(ctx context.Context, dstDir model.Obj, file model.FileStre
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Open115) OfflineDownload(ctx context.Context, uris []string, dstDir model.Obj) ([]string, error) {
|
||||
return d.client.AddOfflineTaskURIs(ctx, uris, dstDir.GetID())
|
||||
}
|
||||
|
||||
func (d *Open115) DeleteOfflineTask(ctx context.Context, infoHash string, deleteFiles bool) error {
|
||||
return d.client.DeleteOfflineTask(ctx, infoHash, deleteFiles)
|
||||
}
|
||||
|
||||
func (d *Open115) OfflineList(ctx context.Context) (*sdk.OfflineTaskListResp, error) {
|
||||
resp, err := d.client.OfflineTaskList(ctx, 1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (d *Open115) GetDetails(ctx context.Context) (*model.StorageDetails, error) {
|
||||
userInfo, err := d.client.UserInfo(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
total, err := ParseInt64(userInfo.RtSpaceInfo.AllTotal.Size)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
used, err := ParseInt64(userInfo.RtSpaceInfo.AllUse.Size)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &model.StorageDetails{
|
||||
DiskUsage: model.DiskUsage{
|
||||
TotalSpace: total,
|
||||
UsedSpace: used,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// func (d *Open115) GetArchiveMeta(ctx context.Context, obj model.Obj, args model.ArchiveArgs) (model.ArchiveMeta, error) {
|
||||
// // TODO get archive file meta-info, return errs.NotImplement to use an internal archive tool, optional
|
||||
// return nil, errs.NotImplement
|
||||
|
||||
@@ -11,23 +11,16 @@ type Addition struct {
|
||||
// define other
|
||||
OrderBy string `json:"order_by" type:"select" options:"file_name,file_size,user_utime,file_type"`
|
||||
OrderDirection string `json:"order_direction" type:"select" options:"asc,desc"`
|
||||
LimitRate float64 `json:"limit_rate,string" type:"float" default:"1" help:"limit all api request rate ([limit]r/1s)"`
|
||||
LimitRate float64 `json:"limit_rate" type:"float" default:"1" help:"limit all api request rate ([limit]r/1s)"`
|
||||
PageSize int64 `json:"page_size" type:"number" default:"200" help:"list api per page size of 115open driver"`
|
||||
AccessToken string `json:"access_token" required:"true"`
|
||||
RefreshToken string `json:"refresh_token" required:"true"`
|
||||
}
|
||||
|
||||
var config = driver.Config{
|
||||
Name: "115 Open",
|
||||
LocalSort: false,
|
||||
OnlyLocal: false,
|
||||
OnlyProxy: false,
|
||||
NoCache: false,
|
||||
NoUpload: false,
|
||||
NeedMs: false,
|
||||
DefaultRoot: "0",
|
||||
CheckStatus: false,
|
||||
Alert: "",
|
||||
NoOverwriteUpload: false,
|
||||
Name: "115 Open",
|
||||
DefaultRoot: "0",
|
||||
LinkCacheMode: driver.LinkCacheUA,
|
||||
}
|
||||
|
||||
func init() {
|
||||
|
||||
@@ -3,9 +3,9 @@ package _115_open
|
||||
import (
|
||||
"time"
|
||||
|
||||
sdk "github.com/OpenListTeam/115-sdk-go"
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/model"
|
||||
"github.com/OpenListTeam/OpenList/v4/pkg/utils"
|
||||
sdk "github.com/OpenListTeam/115-sdk-go"
|
||||
)
|
||||
|
||||
type Obj sdk.GetFilesResp_File
|
||||
|
||||
+80
-25
@@ -3,15 +3,18 @@ package _115_open
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
sdk "github.com/OpenListTeam/115-sdk-go"
|
||||
"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/OpenListTeam/OpenList/v4/pkg/utils"
|
||||
"github.com/aliyun/aliyun-oss-go-sdk/oss"
|
||||
"github.com/avast/retry-go"
|
||||
sdk "github.com/OpenListTeam/115-sdk-go"
|
||||
)
|
||||
|
||||
func calPartSize(fileSize int64) int64 {
|
||||
@@ -35,7 +38,7 @@ func calPartSize(fileSize int64) int64 {
|
||||
}
|
||||
|
||||
func (d *Open115) singleUpload(ctx context.Context, tempF model.File, tokenResp *sdk.UploadGetTokenResp, initResp *sdk.UploadInitResp) error {
|
||||
ossClient, err := oss.New(tokenResp.Endpoint, tokenResp.AccessKeyId, tokenResp.AccessKeySecret, oss.SecurityToken(tokenResp.SecurityToken))
|
||||
ossClient, err := netutil.NewOSSClient(tokenResp.Endpoint, tokenResp.AccessKeyId, tokenResp.AccessKeySecret, oss.SecurityToken(tokenResp.SecurityToken))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -68,11 +71,21 @@ func (d *Open115) singleUpload(ctx context.Context, tempF model.File, tokenResp
|
||||
// } `json:"data"`
|
||||
// }
|
||||
|
||||
func (d *Open115) multpartUpload(ctx context.Context, stream model.FileStreamer, up driver.UpdateProgress, tokenResp *sdk.UploadGetTokenResp, initResp *sdk.UploadInitResp) error {
|
||||
fileSize := stream.GetSize()
|
||||
chunkSize := calPartSize(fileSize)
|
||||
// retryExpiredToken retries only the rejected OSS operation, preserving the upload ID.
|
||||
func retryExpiredToken(refresh func() error, operation func() error) error {
|
||||
err := operation()
|
||||
var serviceErr oss.ServiceError
|
||||
if !errors.As(err, &serviceErr) || serviceErr.Code != "SecurityTokenExpired" {
|
||||
return err
|
||||
}
|
||||
if err := refresh(); err != nil {
|
||||
return err
|
||||
}
|
||||
return operation()
|
||||
}
|
||||
|
||||
ossClient, err := oss.New(tokenResp.Endpoint, tokenResp.AccessKeyId, tokenResp.AccessKeySecret, oss.SecurityToken(tokenResp.SecurityToken))
|
||||
func (d *Open115) multpartUpload(ctx context.Context, stream model.FileStreamer, up driver.UpdateProgress, tokenResp *sdk.UploadGetTokenResp, initResp *sdk.UploadInitResp) error {
|
||||
ossClient, err := netutil.NewOSSClient(tokenResp.Endpoint, tokenResp.AccessKeyId, tokenResp.AccessKeySecret, oss.SecurityToken(tokenResp.SecurityToken))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -81,7 +94,39 @@ func (d *Open115) multpartUpload(ctx context.Context, stream model.FileStreamer,
|
||||
return err
|
||||
}
|
||||
|
||||
imur, err := bucket.InitiateMultipartUpload(initResp.Object, oss.Sequential())
|
||||
refresh := func() error {
|
||||
if err := d.WaitLimit(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
token, err := d.client.UploadGetToken(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
client, err := netutil.NewOSSClient(token.Endpoint, token.AccessKeyId, token.AccessKeySecret, oss.SecurityToken(token.SecurityToken))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
newBucket, err := client.Bucket(initResp.Bucket)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
bucket = newBucket
|
||||
return nil
|
||||
}
|
||||
|
||||
var imur oss.InitiateMultipartUploadResult
|
||||
err = retryExpiredToken(refresh, func() error {
|
||||
var err error
|
||||
imur, err = bucket.InitiateMultipartUpload(initResp.Object, oss.Sequential(), oss.WithContext(ctx))
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fileSize := stream.GetSize()
|
||||
chunkSize := calPartSize(fileSize)
|
||||
ss, err := streamPkg.NewStreamSectionReader(stream, int(chunkSize), &up)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -98,20 +143,28 @@ func (d *Open115) multpartUpload(ctx context.Context, stream model.FileStreamer,
|
||||
if i == partNum {
|
||||
partSize = fileSize - (i-1)*chunkSize
|
||||
}
|
||||
rd := utils.NewMultiReadable(io.LimitReader(stream, partSize))
|
||||
rd, err := ss.GetSectionReader(offset, partSize)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = retry.Do(func() error {
|
||||
_ = rd.Reset()
|
||||
rateLimitedRd := driver.NewLimitedUploadStream(ctx, rd)
|
||||
part, err := bucket.UploadPart(imur, rateLimitedRd, partSize, int(i))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
parts[i-1] = part
|
||||
return nil
|
||||
return retryExpiredToken(refresh, func() error {
|
||||
if _, err := rd.Seek(0, io.SeekStart); err != nil {
|
||||
return err
|
||||
}
|
||||
part, err := bucket.UploadPart(imur, driver.NewLimitedUploadStream(ctx, rd), partSize, int(i), oss.WithContext(ctx))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
parts[i-1] = part
|
||||
return nil
|
||||
})
|
||||
},
|
||||
retry.Context(ctx),
|
||||
retry.Attempts(3),
|
||||
retry.DelayType(retry.BackOffDelay),
|
||||
retry.Delay(time.Second))
|
||||
ss.FreeSectionReader(rd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -121,17 +174,19 @@ func (d *Open115) multpartUpload(ctx context.Context, stream model.FileStreamer,
|
||||
} else {
|
||||
offset += partSize
|
||||
}
|
||||
up(float64(offset) / float64(fileSize))
|
||||
up(float64(offset) * 100 / float64(fileSize))
|
||||
}
|
||||
|
||||
// callbackRespBytes := make([]byte, 1024)
|
||||
_, err = bucket.CompleteMultipartUpload(
|
||||
imur,
|
||||
parts,
|
||||
oss.Callback(base64.StdEncoding.EncodeToString([]byte(initResp.Callback.Value.Callback))),
|
||||
oss.CallbackVar(base64.StdEncoding.EncodeToString([]byte(initResp.Callback.Value.CallbackVar))),
|
||||
// oss.CallbackResult(&callbackRespBytes),
|
||||
)
|
||||
err = retryExpiredToken(refresh, func() error {
|
||||
_, err := bucket.CompleteMultipartUpload(
|
||||
imur,
|
||||
parts,
|
||||
oss.Callback(base64.StdEncoding.EncodeToString([]byte(initResp.Callback.Value.Callback))),
|
||||
oss.CallbackVar(base64.StdEncoding.EncodeToString([]byte(initResp.Callback.Value.CallbackVar))),
|
||||
oss.WithContext(ctx),
|
||||
)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1,3 +1,23 @@
|
||||
package _115_open
|
||||
|
||||
// do others that not defined in Driver interface
|
||||
import (
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
func ParseInt64(v json.Number) (int64, error) {
|
||||
i, err := v.Int64()
|
||||
if err == nil {
|
||||
return i, nil
|
||||
}
|
||||
f, e1 := v.Float64()
|
||||
if e1 == nil {
|
||||
return int64(f), nil
|
||||
}
|
||||
return int64(0), err
|
||||
}
|
||||
|
||||
func parseTime(value string) int64 {
|
||||
timestamp, _ := strconv.ParseInt(value, 10, 64)
|
||||
return timestamp
|
||||
}
|
||||
|
||||
@@ -2,12 +2,14 @@ package _115_share
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"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/utils"
|
||||
driver115 "github.com/j2rong4cn/115driver/pkg/driver"
|
||||
driver115 "github.com/SheltonZhu/115driver/pkg/driver"
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
@@ -49,9 +51,16 @@ func (d *Pan115Share) List(ctx context.Context, dir model.Obj, args model.ListAr
|
||||
if err := d.WaitLimit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var ua string
|
||||
// TODO: will use user agent from header
|
||||
// if args.Header != nil {
|
||||
// ua = args.Header.Get("User-Agent")
|
||||
// }
|
||||
if ua == "" {
|
||||
ua = base.UserAgentNT
|
||||
}
|
||||
files := make([]driver115.ShareFile, 0)
|
||||
fileResp, err := d.client.GetShareSnap(d.ShareCode, d.ReceiveCode, dir.GetID(), driver115.QueryLimit(int(d.PageSize)))
|
||||
fileResp, err := d.client.GetShareSnapWithUA(ua, d.ShareCode, d.ReceiveCode, dir.GetID(), driver115.QueryLimit(int(d.PageSize)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -77,12 +86,20 @@ func (d *Pan115Share) Link(ctx context.Context, file model.Obj, args model.LinkA
|
||||
if err := d.WaitLimit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
downloadInfo, err := d.client.DownloadByShareCode(d.ShareCode, d.ReceiveCode, file.GetID())
|
||||
var ua string
|
||||
if args.Header != nil {
|
||||
ua = args.Header.Get("User-Agent")
|
||||
}
|
||||
downloadInfo, err := d.client.DownloadByShareCodeWithUA(ua, d.ShareCode, d.ReceiveCode, file.GetID())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &model.Link{URL: downloadInfo.URL.URL}, nil
|
||||
header := http.Header{}
|
||||
header.Set("User-Agent", ua)
|
||||
return &model.Link{
|
||||
URL: downloadInfo.URL.URL,
|
||||
Header: header,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (d *Pan115Share) MakeDir(ctx context.Context, parentDir model.Obj, dirName string) error {
|
||||
|
||||
@@ -19,12 +19,7 @@ type Addition struct {
|
||||
var config = driver.Config{
|
||||
Name: "115 Share",
|
||||
DefaultRoot: "0",
|
||||
// OnlyProxy: true,
|
||||
// OnlyLocal: true,
|
||||
CheckStatus: false,
|
||||
Alert: "",
|
||||
NoOverwriteUpload: true,
|
||||
NoUpload: true,
|
||||
NoUpload: true,
|
||||
}
|
||||
|
||||
func init() {
|
||||
|
||||
@@ -5,9 +5,10 @@ import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/OpenListTeam/OpenList/v4/drivers/base"
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/model"
|
||||
"github.com/OpenListTeam/OpenList/v4/pkg/utils"
|
||||
driver115 "github.com/j2rong4cn/115driver/pkg/driver"
|
||||
driver115 "github.com/SheltonZhu/115driver/pkg/driver"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
@@ -20,6 +21,7 @@ type FileObj struct {
|
||||
FileName string
|
||||
isDir bool
|
||||
FileID string
|
||||
ThumbURL string
|
||||
}
|
||||
|
||||
func (f *FileObj) CreateTime() time.Time {
|
||||
@@ -54,6 +56,10 @@ func (f *FileObj) GetPath() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (f *FileObj) Thumb() string {
|
||||
return f.ThumbURL
|
||||
}
|
||||
|
||||
func transFunc(sf driver115.ShareFile) (model.Obj, error) {
|
||||
timeInt, err := strconv.ParseInt(sf.UpdateTime, 10, 64)
|
||||
if err != nil {
|
||||
@@ -74,15 +80,14 @@ func transFunc(sf driver115.ShareFile) (model.Obj, error) {
|
||||
FileName: string(sf.FileName),
|
||||
isDir: isDir,
|
||||
FileID: fileID,
|
||||
ThumbURL: sf.ThumbURL,
|
||||
}, nil
|
||||
}
|
||||
|
||||
var UserAgent = driver115.UA115Browser
|
||||
|
||||
func (d *Pan115Share) login() error {
|
||||
var err error
|
||||
opts := []driver115.Option{
|
||||
driver115.UA(UserAgent),
|
||||
driver115.UA(base.UserAgentNT),
|
||||
}
|
||||
d.client = driver115.New(opts...)
|
||||
if _, err := d.client.GetShareSnap(d.ShareCode, d.ReceiveCode, ""); err != nil {
|
||||
|
||||
+28
-20
@@ -22,8 +22,8 @@ import (
|
||||
"github.com/aws/aws-sdk-go/aws/credentials"
|
||||
"github.com/aws/aws-sdk-go/aws/session"
|
||||
"github.com/aws/aws-sdk-go/service/s3/s3manager"
|
||||
"github.com/go-resty/resty/v2"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"resty.dev/v3"
|
||||
)
|
||||
|
||||
type Pan123 struct {
|
||||
@@ -41,7 +41,9 @@ func (d *Pan123) GetAddition() driver.Additional {
|
||||
}
|
||||
|
||||
func (d *Pan123) Init(ctx context.Context) error {
|
||||
_, err := d.Request(UserInfo, http.MethodGet, nil, nil)
|
||||
_, err := d.Request(UserInfo, http.MethodGet, func(req *resty.Request) {
|
||||
req.SetHeader("platform", "web")
|
||||
}, nil)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -64,14 +66,6 @@ func (d *Pan123) List(ctx context.Context, dir model.Obj, args model.ListArgs) (
|
||||
|
||||
func (d *Pan123) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (*model.Link, error) {
|
||||
if f, ok := file.(File); ok {
|
||||
//var resp DownResp
|
||||
var headers map[string]string
|
||||
if !utils.IsLocalIPAddr(args.IP) {
|
||||
headers = map[string]string{
|
||||
//"X-Real-IP": "1.1.1.1",
|
||||
"X-Forwarded-For": args.IP,
|
||||
}
|
||||
}
|
||||
data := base.Json{
|
||||
"driveId": 0,
|
||||
"etag": f.Etag,
|
||||
@@ -82,28 +76,29 @@ func (d *Pan123) Link(ctx context.Context, file model.Obj, args model.LinkArgs)
|
||||
"type": f.Type,
|
||||
}
|
||||
resp, err := d.Request(DownloadInfo, http.MethodPost, func(req *resty.Request) {
|
||||
|
||||
req.SetBody(data).SetHeaders(headers)
|
||||
req.SetBody(data)
|
||||
}, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
downloadUrl := utils.Json.Get(resp, "data", "DownloadUrl").ToString()
|
||||
u, err := url.Parse(downloadUrl)
|
||||
ou, err := url.Parse(downloadUrl)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nu := u.Query().Get("params")
|
||||
u_ := ou.String()
|
||||
nu := ou.Query().Get("params")
|
||||
if nu != "" {
|
||||
du, _ := base64.StdEncoding.DecodeString(nu)
|
||||
u, err = url.Parse(string(du))
|
||||
u, err := url.Parse(string(du))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u_ = u.String()
|
||||
}
|
||||
u_ := u.String()
|
||||
|
||||
log.Debug("download url: ", u_)
|
||||
res, err := base.NoRedirectClient.R().SetHeader("Referer", "https://www.123pan.com/").Get(u_)
|
||||
res, err := base.NoRedirectClient.R().SetHeader("Referer", "https://yun.123pan.com/").Get(u_)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -115,10 +110,10 @@ func (d *Pan123) Link(ctx context.Context, file model.Obj, args model.LinkArgs)
|
||||
if res.StatusCode() == 302 {
|
||||
link.URL = res.Header().Get("location")
|
||||
} else if res.StatusCode() < 300 {
|
||||
link.URL = utils.Json.Get(res.Bytes(), "data", "redirect_url").ToString()
|
||||
link.URL = utils.Json.Get(res.Body(), "data", "redirect_url").ToString()
|
||||
}
|
||||
link.Header = http.Header{
|
||||
"Referer": []string{"https://www.123pan.com/"},
|
||||
"Referer": []string{fmt.Sprintf("%s://%s/", ou.Scheme, ou.Host)},
|
||||
}
|
||||
return &link, nil
|
||||
} else {
|
||||
@@ -188,7 +183,7 @@ func (d *Pan123) Put(ctx context.Context, dstDir model.Obj, file model.FileStrea
|
||||
etag := file.GetHash().GetHash(utils.MD5)
|
||||
var err error
|
||||
if len(etag) < utils.MD5.Width {
|
||||
_, etag, err = stream.CacheFullInTempFileAndHash(file, utils.MD5)
|
||||
_, etag, err = stream.CacheFullAndHash(file, &up, utils.MD5)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -260,4 +255,17 @@ func (d *Pan123) APIRateLimit(ctx context.Context, api string) error {
|
||||
return limiter.Wait(ctx)
|
||||
}
|
||||
|
||||
func (d *Pan123) GetDetails(ctx context.Context) (*model.StorageDetails, error) {
|
||||
userInfo, err := d.getUserInfo(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &model.StorageDetails{
|
||||
DiskUsage: model.DiskUsage{
|
||||
TotalSpace: userInfo.Data.SpacePermanent + userInfo.Data.SpaceTemp,
|
||||
UsedSpace: userInfo.Data.SpaceUsed,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
var _ driver.Driver = (*Pan123)(nil)
|
||||
|
||||
+11
-2
@@ -11,17 +11,26 @@ type Addition struct {
|
||||
driver.RootID
|
||||
//OrderBy string `json:"order_by" type:"select" options:"file_id,file_name,size,update_at" default:"file_name"`
|
||||
//OrderDirection string `json:"order_direction" type:"select" options:"asc,desc" default:"asc"`
|
||||
AccessToken string
|
||||
AccessToken string
|
||||
UploadThread int `json:"UploadThread" type:"number" default:"3" help:"the threads of upload"`
|
||||
Platform string `json:"platform" type:"string" default:"web" help:"the platform header value, sent with API requests"`
|
||||
}
|
||||
|
||||
var config = driver.Config{
|
||||
Name: "123Pan",
|
||||
DefaultRoot: "0",
|
||||
LocalSort: true,
|
||||
PreferProxy: true,
|
||||
}
|
||||
|
||||
func init() {
|
||||
op.RegisterDriver(func() driver.Driver {
|
||||
return &Pan123{}
|
||||
// 新增默认选项 要在RegisterDriver初始化设置 才会对正在使用的用户生效
|
||||
return &Pan123{
|
||||
Addition: Addition{
|
||||
UploadThread: 3,
|
||||
Platform: "web",
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
+57
-1
@@ -28,7 +28,7 @@ func (f File) CreateTime() time.Time {
|
||||
}
|
||||
|
||||
func (f File) GetHash() utils.HashInfo {
|
||||
return utils.HashInfo{}
|
||||
return utils.NewHashInfo(utils.MD5, f.Etag)
|
||||
}
|
||||
|
||||
func (f File) GetPath() string {
|
||||
@@ -122,3 +122,59 @@ type S3PreSignedURLs struct {
|
||||
PreSignedUrls map[string]string `json:"presignedUrls"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
type UserInfoResp struct {
|
||||
Data struct {
|
||||
Uid int64 `json:"UID"`
|
||||
Nickname string `json:"Nickname"`
|
||||
SpaceUsed int64 `json:"SpaceUsed"`
|
||||
SpacePermanent int64 `json:"SpacePermanent"`
|
||||
SpaceTemp int64 `json:"SpaceTemp"`
|
||||
FileCount int `json:"FileCount"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
type offlineResolveResp struct {
|
||||
Data struct {
|
||||
List []struct {
|
||||
Result int `json:"result"`
|
||||
ID int64 `json:"id"`
|
||||
ErrCode int `json:"err_code"`
|
||||
ErrMsg string `json:"err_msg"`
|
||||
Files []struct {
|
||||
ID int64 `json:"id"`
|
||||
} `json:"files"`
|
||||
} `json:"list"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
type offlineSubmitResp struct {
|
||||
Data struct {
|
||||
TaskList []struct {
|
||||
TaskID int64 `json:"task_id"`
|
||||
Result int `json:"result"`
|
||||
} `json:"task_list"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
type offlineTaskListResp struct {
|
||||
Data struct {
|
||||
HasRun bool `json:"has_run"`
|
||||
List []offlineTask `json:"list"`
|
||||
Total int `json:"total"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
type offlineTask struct {
|
||||
TaskID int64 `json:"task_id"`
|
||||
Name string `json:"name"`
|
||||
Status int `json:"status"`
|
||||
Size int64 `json:"size"`
|
||||
ThirdTask string `json:"third_task_id"`
|
||||
Downloaded int64 `json:"downloaded"`
|
||||
Progress float64 `json:"progress"`
|
||||
UploadIDR int64 `json:"upload_idr"`
|
||||
UploadName string `json:"upload_name"`
|
||||
Type string `json:"type"`
|
||||
Speed int64 `json:"speed"`
|
||||
}
|
||||
|
||||
+87
-62
@@ -6,12 +6,17 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"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/singleflight"
|
||||
"github.com/OpenListTeam/OpenList/v4/pkg/utils"
|
||||
"resty.dev/v3"
|
||||
"github.com/avast/retry-go"
|
||||
"github.com/go-resty/resty/v2"
|
||||
)
|
||||
|
||||
func (d *Pan123) getS3PreSignedUrls(ctx context.Context, upReq *UploadResp, start, end int) (*S3PreSignedURLs, error) {
|
||||
@@ -69,18 +74,21 @@ func (d *Pan123) completeS3(ctx context.Context, upReq *UploadResp, file model.F
|
||||
}
|
||||
|
||||
func (d *Pan123) newUpload(ctx context.Context, upReq *UploadResp, file model.FileStreamer, up driver.UpdateProgress) error {
|
||||
tmpF, err := file.CacheFullInTempFile()
|
||||
// fetch s3 pre signed urls
|
||||
size := file.GetSize()
|
||||
chunkSize := int64(16 * utils.MB)
|
||||
chunkCount := 1
|
||||
if size > chunkSize {
|
||||
chunkCount = int((size + chunkSize - 1) / chunkSize)
|
||||
}
|
||||
|
||||
ss, err := stream.NewStreamSectionReader(file, int(chunkSize), &up)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// fetch s3 pre signed urls
|
||||
size := file.GetSize()
|
||||
chunkSize := min(size, 16*utils.MB)
|
||||
chunkCount := int(size / chunkSize)
|
||||
|
||||
lastChunkSize := size % chunkSize
|
||||
if lastChunkSize > 0 {
|
||||
chunkCount++
|
||||
} else {
|
||||
if lastChunkSize == 0 {
|
||||
lastChunkSize = chunkSize
|
||||
}
|
||||
// only 1 batch is allowed
|
||||
@@ -90,73 +98,90 @@ func (d *Pan123) newUpload(ctx context.Context, upReq *UploadResp, file model.Fi
|
||||
batchSize = 10
|
||||
getS3UploadUrl = d.getS3PreSignedUrls
|
||||
}
|
||||
|
||||
thread := min(int(chunkCount), d.UploadThread)
|
||||
threadG, uploadCtx := errgroup.NewOrderedGroupWithContext(ctx, thread,
|
||||
retry.Attempts(3),
|
||||
retry.Delay(time.Second),
|
||||
retry.DelayType(retry.BackOffDelay))
|
||||
for i := 1; i <= chunkCount; i += batchSize {
|
||||
if utils.IsCanceled(ctx) {
|
||||
return ctx.Err()
|
||||
if utils.IsCanceled(uploadCtx) {
|
||||
break
|
||||
}
|
||||
start := i
|
||||
end := min(i+batchSize, chunkCount+1)
|
||||
s3PreSignedUrls, err := getS3UploadUrl(ctx, upReq, start, end)
|
||||
s3PreSignedUrls, err := getS3UploadUrl(uploadCtx, upReq, start, end)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// upload each chunk
|
||||
for j := start; j < end; j++ {
|
||||
if utils.IsCanceled(ctx) {
|
||||
return ctx.Err()
|
||||
for cur := start; cur < end; cur++ {
|
||||
if utils.IsCanceled(uploadCtx) {
|
||||
break
|
||||
}
|
||||
offset := int64(cur-1) * chunkSize
|
||||
curSize := chunkSize
|
||||
if j == chunkCount {
|
||||
if cur == chunkCount {
|
||||
curSize = lastChunkSize
|
||||
}
|
||||
err = d.uploadS3Chunk(ctx, upReq, s3PreSignedUrls, j, end, io.NewSectionReader(tmpF, chunkSize*int64(j-1), curSize), curSize, false, getS3UploadUrl)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
up(float64(j) * 100 / float64(chunkCount))
|
||||
var reader io.ReadSeeker
|
||||
threadG.GoWithLifecycle(errgroup.Lifecycle{
|
||||
Before: func(ctx context.Context) (err error) {
|
||||
reader, err = ss.GetSectionReader(offset, curSize)
|
||||
return
|
||||
},
|
||||
Do: func(ctx context.Context) (err error) {
|
||||
reader.Seek(0, io.SeekStart)
|
||||
uploadUrl := s3PreSignedUrls.Data.PreSignedUrls[strconv.Itoa(cur)]
|
||||
if uploadUrl == "" {
|
||||
return fmt.Errorf("upload url is empty, s3PreSignedUrls: %+v", s3PreSignedUrls)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPut, uploadUrl, driver.NewLimitedUploadStream(ctx, reader))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.ContentLength = curSize
|
||||
//req.Header.Set("Content-Length", strconv.FormatInt(curSize, 10))
|
||||
res, err := base.HttpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode == http.StatusForbidden {
|
||||
_, err, _ = singleflight.AnyGroup.Do(fmt.Sprintf("Pan123.newUpload_%p", threadG), func() (any, error) {
|
||||
newS3PreSignedUrls, err := getS3UploadUrl(ctx, upReq, cur, end)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s3PreSignedUrls.Data.PreSignedUrls = newS3PreSignedUrls.Data.PreSignedUrls
|
||||
return nil, nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return fmt.Errorf("upload s3 chunk %d failed, status code: %d", cur, res.StatusCode)
|
||||
}
|
||||
if res.StatusCode != http.StatusOK {
|
||||
body, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return fmt.Errorf("upload s3 chunk %d failed, status code: %d, body: %s", cur, res.StatusCode, body)
|
||||
}
|
||||
progress := 100 * float64(threadG.Success()+1) / float64(chunkCount+1)
|
||||
up(progress)
|
||||
return nil
|
||||
},
|
||||
After: func(err error) {
|
||||
ss.FreeSectionReader(reader)
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
if err := threadG.Wait(); err != nil {
|
||||
return err
|
||||
}
|
||||
defer up(100)
|
||||
// complete s3 upload
|
||||
return d.completeS3(ctx, upReq, file, chunkCount > 1)
|
||||
}
|
||||
|
||||
func (d *Pan123) uploadS3Chunk(ctx context.Context, upReq *UploadResp, s3PreSignedUrls *S3PreSignedURLs, cur, end int, reader *io.SectionReader, curSize int64, retry bool, getS3UploadUrl func(ctx context.Context, upReq *UploadResp, start int, end int) (*S3PreSignedURLs, error)) error {
|
||||
uploadUrl := s3PreSignedUrls.Data.PreSignedUrls[strconv.Itoa(cur)]
|
||||
if uploadUrl == "" {
|
||||
return fmt.Errorf("upload url is empty, s3PreSignedUrls: %+v", s3PreSignedUrls)
|
||||
}
|
||||
req, err := http.NewRequest("PUT", uploadUrl, driver.NewLimitedUploadStream(ctx, reader))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
req.ContentLength = curSize
|
||||
//req.Header.Set("Content-Length", strconv.FormatInt(curSize, 10))
|
||||
res, err := base.HttpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode == http.StatusForbidden {
|
||||
if retry {
|
||||
return fmt.Errorf("upload s3 chunk %d failed, status code: %d", cur, res.StatusCode)
|
||||
}
|
||||
// refresh s3 pre signed urls
|
||||
newS3PreSignedUrls, err := getS3UploadUrl(ctx, upReq, cur, end)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s3PreSignedUrls.Data.PreSignedUrls = newS3PreSignedUrls.Data.PreSignedUrls
|
||||
// retry
|
||||
reader.Seek(0, io.SeekStart)
|
||||
return d.uploadS3Chunk(ctx, upReq, s3PreSignedUrls, cur, end, reader, curSize, true, getS3UploadUrl)
|
||||
}
|
||||
if res.StatusCode != http.StatusOK {
|
||||
body, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return fmt.Errorf("upload s3 chunk %d failed, status code: %d, body: %s", cur, res.StatusCode, body)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
+142
-14
@@ -14,18 +14,19 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/OpenListTeam/OpenList/v4/drivers/base"
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/model"
|
||||
"github.com/OpenListTeam/OpenList/v4/pkg/utils"
|
||||
"github.com/go-resty/resty/v2"
|
||||
jsoniter "github.com/json-iterator/go"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"resty.dev/v3"
|
||||
)
|
||||
|
||||
// do others that not defined in Driver interface
|
||||
|
||||
const (
|
||||
Api = "https://www.123pan.com/api"
|
||||
AApi = "https://www.123pan.com/a/api"
|
||||
BApi = "https://www.123pan.com/b/api"
|
||||
Api = "https://yun.123pan.com/api"
|
||||
AApi = "https://yun.123pan.com/a/api"
|
||||
BApi = "https://yun.123pan.com/b/api"
|
||||
LoginApi = "https://login.123pan.com/api"
|
||||
MainApi = BApi
|
||||
SignIn = LoginApi + "/user/sign_in"
|
||||
@@ -43,9 +44,16 @@ const (
|
||||
S3Auth = MainApi + "/file/s3_upload_object/auth"
|
||||
UploadCompleteV2 = MainApi + "/file/upload_complete/v2"
|
||||
S3Complete = MainApi + "/file/s3_complete_multipart_upload"
|
||||
//AuthKeySalt = "8-8D$sL8gPjom7bk#cY"
|
||||
|
||||
OfflineResolve = MainApi + "/v2/offline_download/task/resolve"
|
||||
OfflineSubmit = MainApi + "/v2/offline_download/task/submit"
|
||||
OfflineTaskList = MainApi + "/offline_download/task/list"
|
||||
OfflineTaskDelete = MainApi + "/offline_download/task/delete"
|
||||
// AuthKeySalt = "8-8D$sL8gPjom7bk#cY"
|
||||
)
|
||||
|
||||
var ErrOfflineTaskNotFound = errors.New("offline task not found")
|
||||
|
||||
func signPath(path string, os string, version string) (k string, v string) {
|
||||
table := []byte{'a', 'd', 'e', 'f', 'g', 'h', 'l', 'm', 'y', 'i', 'j', 'n', 'o', 'p', 'k', 'q', 'r', 's', 't', 'u', 'b', 'c', 'v', 'w', 's', 'z'}
|
||||
random := fmt.Sprintf("%.f", math.Round(1e7*rand.Float64()))
|
||||
@@ -161,8 +169,8 @@ func (d *Pan123) login() error {
|
||||
}
|
||||
res, err := base.RestyClient.R().
|
||||
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": "Dart/2.19(dart:io)-openlist",
|
||||
"platform": "web",
|
||||
"app-version": "3",
|
||||
@@ -172,10 +180,10 @@ func (d *Pan123) login() error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if utils.Json.Get(res.Bytes(), "code").ToInt() != 200 {
|
||||
err = fmt.Errorf(utils.Json.Get(res.Bytes(), "message").ToString())
|
||||
if utils.Json.Get(res.Body(), "code").ToInt() != 200 {
|
||||
err = fmt.Errorf(utils.Json.Get(res.Body(), "message").ToString())
|
||||
} else {
|
||||
d.AccessToken = utils.Json.Get(res.Bytes(), "data", "token").ToString()
|
||||
d.AccessToken = utils.Json.Get(res.Body(), "data", "token").ToString()
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -199,11 +207,11 @@ 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": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) openlist-client",
|
||||
"platform": "web",
|
||||
"platform": d.Platform,
|
||||
"app-version": "3",
|
||||
//"user-agent": base.UserAgent,
|
||||
})
|
||||
@@ -222,7 +230,7 @@ do:
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body := res.Bytes()
|
||||
body := res.Body()
|
||||
code := utils.Json.Get(body, "code").ToInt()
|
||||
if code != 0 {
|
||||
if !isRetry && code == 401 {
|
||||
@@ -238,6 +246,115 @@ do:
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func (d *Pan123) OfflineDownload(ctx context.Context, uri string, dstDir model.Obj) (int64, error) {
|
||||
var resolveResp offlineResolveResp
|
||||
_, err := d.Request(OfflineResolve, http.MethodPost, func(req *resty.Request) {
|
||||
req.SetContext(ctx).SetBody(base.Json{
|
||||
"urls": uri,
|
||||
})
|
||||
}, &resolveResp)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(resolveResp.Data.List) == 0 {
|
||||
return 0, fmt.Errorf("offline resolve failed: empty response")
|
||||
}
|
||||
if resolveResp.Data.List[0].Result != 0 {
|
||||
msg := resolveResp.Data.List[0].ErrMsg
|
||||
if msg == "" {
|
||||
msg = "offline resolve failed"
|
||||
}
|
||||
return 0, fmt.Errorf("%s", msg)
|
||||
}
|
||||
resourceID := resolveResp.Data.List[0].ID
|
||||
if resourceID == 0 {
|
||||
return 0, fmt.Errorf("offline resolve failed: empty resource id")
|
||||
}
|
||||
selectFileIDs := make([]int64, 0, len(resolveResp.Data.List[0].Files))
|
||||
for _, f := range resolveResp.Data.List[0].Files {
|
||||
if f.ID > 0 {
|
||||
selectFileIDs = append(selectFileIDs, f.ID)
|
||||
}
|
||||
}
|
||||
if len(selectFileIDs) == 0 {
|
||||
return 0, fmt.Errorf("offline resolve failed: empty file list")
|
||||
}
|
||||
uploadDir, err := strconv.ParseInt(dstDir.GetID(), 10, 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid destination dir id: %s", dstDir.GetID())
|
||||
}
|
||||
|
||||
var submitResp offlineSubmitResp
|
||||
_, err = d.Request(OfflineSubmit, http.MethodPost, func(req *resty.Request) {
|
||||
req.SetContext(ctx).SetBody(base.Json{
|
||||
"resource_list": []base.Json{
|
||||
{
|
||||
"resource_id": resourceID,
|
||||
"select_file_id": selectFileIDs,
|
||||
},
|
||||
},
|
||||
"upload_dir": uploadDir,
|
||||
})
|
||||
}, &submitResp)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(submitResp.Data.TaskList) == 0 {
|
||||
return 0, fmt.Errorf("offline submit failed: empty task list")
|
||||
}
|
||||
if submitResp.Data.TaskList[0].Result != 0 {
|
||||
return 0, fmt.Errorf("offline submit failed")
|
||||
}
|
||||
if submitResp.Data.TaskList[0].TaskID == 0 {
|
||||
return 0, fmt.Errorf("offline submit failed: empty task id")
|
||||
}
|
||||
return submitResp.Data.TaskList[0].TaskID, nil
|
||||
}
|
||||
|
||||
func (d *Pan123) GetOfflineTask(ctx context.Context, taskID int64) (*offlineTask, error) {
|
||||
if taskID == 0 {
|
||||
return nil, fmt.Errorf("invalid task id")
|
||||
}
|
||||
page := 1
|
||||
pageSize := 100
|
||||
statusArr := []int{0, 1, 2, 3}
|
||||
for {
|
||||
var listResp offlineTaskListResp
|
||||
_, err := d.Request(OfflineTaskList, http.MethodPost, func(req *resty.Request) {
|
||||
req.SetContext(ctx).SetBody(base.Json{
|
||||
"current_page": page,
|
||||
"page_size": pageSize,
|
||||
"status_arr": statusArr,
|
||||
})
|
||||
}, &listResp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range listResp.Data.List {
|
||||
if listResp.Data.List[i].TaskID == taskID {
|
||||
return &listResp.Data.List[i], nil
|
||||
}
|
||||
}
|
||||
if len(listResp.Data.List) == 0 || page*pageSize >= listResp.Data.Total {
|
||||
break
|
||||
}
|
||||
page++
|
||||
}
|
||||
return nil, ErrOfflineTaskNotFound
|
||||
}
|
||||
|
||||
func (d *Pan123) DeleteOfflineTasks(ctx context.Context, taskIDs []int64) error {
|
||||
if len(taskIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
_, err := d.Request(OfflineTaskDelete, http.MethodPost, func(req *resty.Request) {
|
||||
req.SetContext(ctx).SetBody(base.Json{
|
||||
"task_ids": taskIDs,
|
||||
})
|
||||
}, nil)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *Pan123) getFiles(ctx context.Context, parentId string, name string) ([]File, error) {
|
||||
page := 1
|
||||
total := 0
|
||||
@@ -282,3 +399,14 @@ func (d *Pan123) getFiles(ctx context.Context, parentId string, name string) ([]
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (d *Pan123) getUserInfo(ctx context.Context) (*UserInfoResp, error) {
|
||||
var resp UserInfoResp
|
||||
_, err := d.Request(UserInfo, http.MethodGet, func(req *resty.Request) {
|
||||
req.SetContext(ctx)
|
||||
}, &resp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
@@ -39,6 +39,10 @@ func (d *Pan123Link) Drop(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (Addition) GetRootPath() string {
|
||||
return "/"
|
||||
}
|
||||
|
||||
func (d *Pan123Link) Get(ctx context.Context, path string) (model.Obj, error) {
|
||||
node := GetNodeFromRootByPath(d.root, path)
|
||||
return nodeToObj(node, path)
|
||||
|
||||
+158
-11
@@ -2,7 +2,9 @@ package _123_open
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/driver"
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/errs"
|
||||
@@ -15,6 +17,8 @@ import (
|
||||
type Open123 struct {
|
||||
model.Storage
|
||||
Addition
|
||||
UID uint64
|
||||
tm *tokenManager
|
||||
}
|
||||
|
||||
func (d *Open123) Config() driver.Config {
|
||||
@@ -30,6 +34,24 @@ func (d *Open123) Init(ctx context.Context) error {
|
||||
d.UploadThread = 3
|
||||
}
|
||||
|
||||
if (d.UseOnlineAPI && d.RefreshToken != "" && len(d.APIAddress) > 0) || (d.ClientID != "" && d.ClientSecret != "") {
|
||||
// proactive refresh by renewapi or client credentials
|
||||
d.AccessToken = ""
|
||||
d.tm = &tokenManager{}
|
||||
} else {
|
||||
// 避免个人 token 刷新产生的多个登录,被动刷新
|
||||
// 默认过期时间90天,jwt exp 不可靠
|
||||
d.tm = &tokenManager{
|
||||
// accessToken: d.AccessToken,
|
||||
expiredAt: time.Now().Add(90 * 24 * time.Hour),
|
||||
}
|
||||
}
|
||||
|
||||
_, err := d.getAccessToken(false)
|
||||
if err != nil {
|
||||
return fmt.Errorf("init get access token error: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -67,13 +89,45 @@ func (d *Open123) List(ctx context.Context, dir model.Obj, args model.ListArgs)
|
||||
func (d *Open123) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (*model.Link, error) {
|
||||
fileId, _ := strconv.ParseInt(file.GetID(), 10, 64)
|
||||
|
||||
if d.DirectLink {
|
||||
res, err := d.getDirectLink(fileId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if d.DirectLinkPrivateKey == "" {
|
||||
duration := 365 * 24 * time.Hour // 缓存1年
|
||||
return &model.Link{
|
||||
URL: res.Data.URL,
|
||||
Expiration: &duration,
|
||||
}, nil
|
||||
}
|
||||
|
||||
uid, err := d.getUID(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
duration := time.Duration(d.DirectLinkValidDuration) * time.Minute
|
||||
|
||||
newURL, err := d.SignURL(res.Data.URL, d.DirectLinkPrivateKey,
|
||||
uid, duration)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &model.Link{
|
||||
URL: newURL,
|
||||
Expiration: &duration,
|
||||
}, nil
|
||||
}
|
||||
|
||||
res, err := d.getDownloadInfo(fileId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
link := model.Link{URL: res.Data.DownloadUrl}
|
||||
return &link, nil
|
||||
return &model.Link{URL: res.Data.DownloadUrl}, nil
|
||||
}
|
||||
|
||||
func (d *Open123) MakeDir(ctx context.Context, parentDir model.Obj, dirName string) error {
|
||||
@@ -95,6 +149,22 @@ func (d *Open123) Rename(ctx context.Context, srcObj model.Obj, newName string)
|
||||
}
|
||||
|
||||
func (d *Open123) Copy(ctx context.Context, srcObj, dstDir model.Obj) error {
|
||||
// 尝试使用上传+MD5秒传功能实现复制
|
||||
// 1. 创建文件
|
||||
// parentFileID 父目录id,上传到根目录时填写 0
|
||||
parentFileId, err := strconv.ParseInt(dstDir.GetID(), 10, 64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse parentFileID error: %v", err)
|
||||
}
|
||||
etag := srcObj.(File).Etag
|
||||
createResp, err := d.create(parentFileId, srcObj.GetName(), etag, srcObj.GetSize(), 2, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 是否秒传
|
||||
if createResp.Data.Reuse {
|
||||
return nil
|
||||
}
|
||||
return errs.NotSupport
|
||||
}
|
||||
|
||||
@@ -104,26 +174,103 @@ func (d *Open123) Remove(ctx context.Context, obj model.Obj) error {
|
||||
return d.trash(fileId)
|
||||
}
|
||||
|
||||
func (d *Open123) Put(ctx context.Context, dstDir model.Obj, file model.FileStreamer, up driver.UpdateProgress) error {
|
||||
func (d *Open123) Put(ctx context.Context, dstDir model.Obj, file model.FileStreamer, up driver.UpdateProgress) (model.Obj, error) {
|
||||
// 1. 创建文件
|
||||
// parentFileID 父目录id,上传到根目录时填写 0
|
||||
parentFileId, err := strconv.ParseInt(dstDir.GetID(), 10, 64)
|
||||
etag := file.GetHash().GetHash(utils.MD5)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse parentFileID error: %v", err)
|
||||
}
|
||||
|
||||
// 尝试 SHA1 秒传
|
||||
sha1Hash := file.GetHash().GetHash(utils.SHA1)
|
||||
if len(sha1Hash) == utils.SHA1.Width {
|
||||
resp, err := d.sha1Reuse(parentFileId, file.GetName(), sha1Hash, file.GetSize(), 2)
|
||||
if err == nil && resp.Data.Reuse {
|
||||
return File{
|
||||
FileName: file.GetName(),
|
||||
Size: file.GetSize(),
|
||||
FileId: resp.Data.FileID,
|
||||
Type: 2,
|
||||
SHA1: sha1Hash,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// etag 文件md5
|
||||
etag := file.GetHash().GetHash(utils.MD5)
|
||||
if len(etag) < utils.MD5.Width {
|
||||
_, etag, err = stream.CacheFullInTempFileAndHash(file, utils.MD5)
|
||||
_, etag, err = stream.CacheFullAndHash(file, &up, utils.MD5)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
createResp, err := d.create(parentFileId, file.GetName(), etag, file.GetSize(), 2, false)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
// 是否秒传
|
||||
if createResp.Data.Reuse {
|
||||
return nil
|
||||
// 秒传成功才会返回正确的 FileID,否则为 0
|
||||
if createResp.Data.FileID != 0 {
|
||||
return File{
|
||||
FileName: file.GetName(),
|
||||
Size: file.GetSize(),
|
||||
FileId: createResp.Data.FileID,
|
||||
Type: 2,
|
||||
Etag: etag,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
up(10)
|
||||
|
||||
return d.Upload(ctx, file, createResp, up)
|
||||
// 2. 上传分片
|
||||
err = d.Upload(ctx, file, createResp, up)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 3. 上传完毕
|
||||
for range 60 {
|
||||
uploadCompleteResp, err := d.complete(createResp.Data.PreuploadID)
|
||||
// 返回错误代码未知,如:20103,文档也没有具体说
|
||||
if err == nil && uploadCompleteResp.Data.Completed && uploadCompleteResp.Data.FileID != 0 {
|
||||
up(100)
|
||||
return File{
|
||||
FileName: file.GetName(),
|
||||
Size: file.GetSize(),
|
||||
FileId: uploadCompleteResp.Data.FileID,
|
||||
Type: 2,
|
||||
Etag: etag,
|
||||
}, nil
|
||||
}
|
||||
// 若接口返回的completed为 false 时,则需间隔1秒继续轮询此接口,获取上传最终结果。
|
||||
time.Sleep(time.Second)
|
||||
}
|
||||
return nil, fmt.Errorf("upload complete timeout")
|
||||
}
|
||||
|
||||
var _ driver.Driver = (*Open123)(nil)
|
||||
func (d *Open123) GetDetails(ctx context.Context) (*model.StorageDetails, error) {
|
||||
userInfo, err := d.getUserInfo(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &model.StorageDetails{
|
||||
DiskUsage: model.DiskUsage{
|
||||
TotalSpace: userInfo.Data.SpacePermanent + userInfo.Data.SpaceTemp,
|
||||
UsedSpace: userInfo.Data.SpaceUsed,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (d *Open123) OfflineDownload(ctx context.Context, url string, dir model.Obj, callback string) (int, error) {
|
||||
return d.createOfflineDownloadTask(ctx, url, dir.GetID(), callback)
|
||||
}
|
||||
|
||||
func (d *Open123) OfflineDownloadProcess(ctx context.Context, taskID int) (float64, int, error) {
|
||||
return d.queryOfflineDownloadStatus(ctx, taskID)
|
||||
}
|
||||
|
||||
var (
|
||||
_ driver.Driver = (*Open123)(nil)
|
||||
_ driver.PutResult = (*Open123)(nil)
|
||||
)
|
||||
|
||||
@@ -6,16 +6,20 @@ import (
|
||||
)
|
||||
|
||||
type Addition struct {
|
||||
// refresh_token方式的AccessToken 【对个人开发者暂未开放】
|
||||
RefreshToken string `json:"RefreshToken" required:"false"`
|
||||
|
||||
// 通过 https://www.123pan.com/developer 申请
|
||||
ClientID string `json:"ClientID" required:"false"`
|
||||
ClientSecret string `json:"ClientSecret" required:"false"`
|
||||
|
||||
// 直接写入AccessToken
|
||||
// 直接写入AccessToken, AccessToken有过期时间,不建议直接填写
|
||||
AccessToken string `json:"AccessToken" required:"false"`
|
||||
|
||||
// refresh_token方式的AccessToken 【对个人开发者暂未开放】
|
||||
RefreshToken string `json:"RefreshToken" required:"false"`
|
||||
|
||||
// 使用在线API
|
||||
UseOnlineAPI bool `json:"use_online_api" default:"true"`
|
||||
APIAddress string `json:"api_url_address" default:"https://api.oplist.org/123cloud/renewapi"`
|
||||
|
||||
// 用户名+密码方式登录的AccessToken可以兼容
|
||||
//Username string `json:"username" required:"false"`
|
||||
//Password string `json:"password" required:"false"`
|
||||
@@ -23,6 +27,11 @@ type Addition struct {
|
||||
// 上传线程数
|
||||
UploadThread int `json:"UploadThread" type:"number" default:"3" help:"the threads of upload"`
|
||||
|
||||
// 使用直链
|
||||
DirectLink bool `json:"DirectLink" type:"bool" default:"false" required:"false" help:"use direct link when download file"`
|
||||
DirectLinkPrivateKey string `json:"DirectLinkPrivateKey" required:"false" help:"private key for direct link, if URL authentication is enabled"`
|
||||
DirectLinkValidDuration int64 `json:"DirectLinkValidDuration" type:"number" default:"30" required:"false" help:"minutes, if URL authentication is enabled"`
|
||||
|
||||
driver.RootID
|
||||
}
|
||||
|
||||
@@ -30,6 +39,7 @@ var config = driver.Config{
|
||||
Name: "123 Open",
|
||||
DefaultRoot: "0",
|
||||
LocalSort: true,
|
||||
PreferProxy: true,
|
||||
}
|
||||
|
||||
func init() {
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
package _123_open
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/OpenListTeam/OpenList/v4/drivers/base"
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/op"
|
||||
)
|
||||
|
||||
var (
|
||||
AccessToken = "https://open-api.123pan.com/api/v1/access_token"
|
||||
)
|
||||
|
||||
func expiresInToExpiredAt(expiresIn int64) (time.Time, error) {
|
||||
if expiresIn <= 0 {
|
||||
return time.Time{}, errors.New("invalid expires_in from official API")
|
||||
}
|
||||
return time.Now().UTC().Add(time.Duration(expiresIn) * time.Second), nil
|
||||
}
|
||||
|
||||
type tokenManager struct {
|
||||
// accessToken string
|
||||
expiredAt time.Time
|
||||
mu sync.Mutex
|
||||
blockRefresh bool
|
||||
}
|
||||
|
||||
func (d *Open123) getAccessToken(forceRefresh bool) (string, error) {
|
||||
tm := d.tm
|
||||
tm.mu.Lock()
|
||||
defer tm.mu.Unlock()
|
||||
if tm.blockRefresh {
|
||||
return "", errors.New("Authentication expired")
|
||||
}
|
||||
if !forceRefresh && d.AccessToken != "" && time.Now().Before(tm.expiredAt.Add(-5*time.Minute)) {
|
||||
return d.AccessToken, nil
|
||||
}
|
||||
if err := d.flushAccessToken(); err != nil {
|
||||
// token expired and failed to refresh, block further refresh attempts
|
||||
tm.blockRefresh = true
|
||||
return "", err
|
||||
}
|
||||
return d.AccessToken, nil
|
||||
}
|
||||
|
||||
func (d *Open123) flushAccessToken() error {
|
||||
// Official app renewapi response contains access_token, refresh_token and expires_in.
|
||||
if d.UseOnlineAPI && d.RefreshToken != "" && len(d.APIAddress) > 0 {
|
||||
var resp RefreshTokenResp
|
||||
_, err := base.RestyClient.R().
|
||||
SetResult(&resp).
|
||||
SetQueryParams(map[string]string{
|
||||
"refresh_ui": d.RefreshToken,
|
||||
"server_use": "true",
|
||||
"driver_txt": "123cloud_oa",
|
||||
}).
|
||||
Get(d.APIAddress)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if resp.AccessToken == "" || resp.RefreshToken == "" {
|
||||
errMessage := resp.ErrorDescription
|
||||
if errMessage == "" {
|
||||
errMessage = resp.Text
|
||||
}
|
||||
if errMessage == "" {
|
||||
errMessage = resp.Message
|
||||
}
|
||||
if errMessage == "" {
|
||||
errMessage = resp.Error
|
||||
}
|
||||
if errMessage != "" {
|
||||
return fmt.Errorf("failed to refresh token: %s", errMessage)
|
||||
}
|
||||
return fmt.Errorf("empty access_token or refresh_token returned from official API")
|
||||
}
|
||||
expiredAt, err := expiresInToExpiredAt(resp.ExpiresIn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
d.AccessToken = resp.AccessToken
|
||||
d.RefreshToken = resp.RefreshToken
|
||||
d.tm.expiredAt = expiredAt
|
||||
op.MustSaveDriverStorage(d)
|
||||
d.tm.blockRefresh = false
|
||||
return nil
|
||||
}
|
||||
|
||||
// Developer API response contains code/message/data(accessToken, expiredAt).
|
||||
if d.ClientID != "" && d.ClientSecret != "" {
|
||||
req := base.RestyClient.R()
|
||||
req.SetHeaders(map[string]string{
|
||||
"platform": "open_platform",
|
||||
"Content-Type": "application/json",
|
||||
})
|
||||
var resp AccessTokenResp
|
||||
req.SetBody(base.Json{
|
||||
"clientID": d.ClientID,
|
||||
"clientSecret": d.ClientSecret,
|
||||
})
|
||||
req.SetResult(&resp)
|
||||
_, err := req.Execute(http.MethodPost, AccessToken)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp.Code != 0 {
|
||||
return fmt.Errorf("get access token failed: %s", resp.Message)
|
||||
}
|
||||
if resp.Data.AccessToken == "" || resp.Data.ExpiredAt == "" {
|
||||
return errors.New("invalid token payload from developer API")
|
||||
}
|
||||
expiredAt, err := time.Parse(time.RFC3339, resp.Data.ExpiredAt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse expire time failed: %w", err)
|
||||
}
|
||||
d.AccessToken = resp.Data.AccessToken
|
||||
d.tm.expiredAt = expiredAt.UTC()
|
||||
op.MustSaveDriverStorage(d)
|
||||
d.tm.blockRefresh = false
|
||||
return nil
|
||||
}
|
||||
return errors.New("no valid authentication method available")
|
||||
}
|
||||
+62
-46
@@ -19,6 +19,7 @@ func (a *ApiInfo) Require() {
|
||||
a.token <- struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *ApiInfo) Release() {
|
||||
if a.qps > 0 {
|
||||
time.AfterFunc(time.Second, func() {
|
||||
@@ -26,13 +27,16 @@ func (a *ApiInfo) Release() {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (a *ApiInfo) SetQPS(qps int) {
|
||||
a.qps = qps
|
||||
a.token = make(chan struct{}, qps)
|
||||
}
|
||||
|
||||
func (a *ApiInfo) NowLen() int {
|
||||
return len(a.token)
|
||||
}
|
||||
|
||||
func InitApiInfo(url string, qps int) *ApiInfo {
|
||||
return &ApiInfo{
|
||||
url: url,
|
||||
@@ -54,9 +58,13 @@ type File struct {
|
||||
Category int `json:"category"`
|
||||
Status int `json:"status"`
|
||||
Trashed int `json:"trashed"`
|
||||
SHA1 string
|
||||
}
|
||||
|
||||
func (f File) GetHash() utils.HashInfo {
|
||||
if len(f.SHA1) == utils.SHA1.Width && len(f.Etag) != utils.MD5.Width {
|
||||
return utils.NewHashInfo(utils.SHA1, f.SHA1)
|
||||
}
|
||||
return utils.NewHashInfo(utils.MD5, f.Etag)
|
||||
}
|
||||
|
||||
@@ -73,7 +81,9 @@ func (f File) GetName() string {
|
||||
}
|
||||
|
||||
func (f File) CreateTime() time.Time {
|
||||
parsedTime, err := time.Parse("2006-01-02 15:04:05", f.CreateAt)
|
||||
// 返回的时间没有时区信息,默认 UTC+8
|
||||
loc := time.FixedZone("UTC+8", 8*60*60)
|
||||
parsedTime, err := time.ParseInLocation("2006-01-02 15:04:05", f.CreateAt, loc)
|
||||
if err != nil {
|
||||
return time.Now()
|
||||
}
|
||||
@@ -81,7 +91,9 @@ func (f File) CreateTime() time.Time {
|
||||
}
|
||||
|
||||
func (f File) ModTime() time.Time {
|
||||
parsedTime, err := time.Parse("2006-01-02 15:04:05", f.UpdateAt)
|
||||
// 返回的时间没有时区信息,默认 UTC+8
|
||||
loc := time.FixedZone("UTC+8", 8*60*60)
|
||||
parsedTime, err := time.ParseInLocation("2006-01-02 15:04:05", f.UpdateAt, loc)
|
||||
if err != nil {
|
||||
return time.Now()
|
||||
}
|
||||
@@ -113,29 +125,32 @@ type AccessTokenResp struct {
|
||||
}
|
||||
|
||||
type RefreshTokenResp struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
Scope string `json:"scope"`
|
||||
TokenType string `json:"token_type"`
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresIn int64 `json:"expires_in"`
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
ErrorDescription string `json:"error_description"`
|
||||
Error string `json:"error"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
type UserInfoResp struct {
|
||||
BaseResp
|
||||
Data struct {
|
||||
UID int64 `json:"uid"`
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"displayName"`
|
||||
HeadImage string `json:"headImage"`
|
||||
Passport string `json:"passport"`
|
||||
Mail string `json:"mail"`
|
||||
SpaceUsed int64 `json:"spaceUsed"`
|
||||
SpacePermanent int64 `json:"spacePermanent"`
|
||||
SpaceTemp int64 `json:"spaceTemp"`
|
||||
SpaceTempExpr string `json:"spaceTempExpr"`
|
||||
Vip bool `json:"vip"`
|
||||
DirectTraffic int64 `json:"directTraffic"`
|
||||
IsHideUID bool `json:"isHideUID"`
|
||||
UID uint64 `json:"uid"`
|
||||
// Username string `json:"username"`
|
||||
// DisplayName string `json:"displayName"`
|
||||
// HeadImage string `json:"headImage"`
|
||||
// Passport string `json:"passport"`
|
||||
// Mail string `json:"mail"`
|
||||
SpaceUsed int64 `json:"spaceUsed"`
|
||||
SpacePermanent int64 `json:"spacePermanent"`
|
||||
SpaceTemp int64 `json:"spaceTemp"`
|
||||
// SpaceTempExpr int64 `json:"spaceTempExpr"`
|
||||
// Vip bool `json:"vip"`
|
||||
// DirectTraffic int64 `json:"directTraffic"`
|
||||
// IsHideUID bool `json:"isHideUID"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
@@ -154,52 +169,53 @@ type DownloadInfoResp struct {
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
type DirectLinkResp struct {
|
||||
BaseResp
|
||||
Data struct {
|
||||
URL string `json:"url"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
// 创建文件V2返回
|
||||
type UploadCreateResp struct {
|
||||
BaseResp
|
||||
Data struct {
|
||||
FileID int64 `json:"fileID"`
|
||||
PreuploadID string `json:"preuploadID"`
|
||||
Reuse bool `json:"reuse"`
|
||||
SliceSize int64 `json:"sliceSize"`
|
||||
FileID int64 `json:"fileID"`
|
||||
PreuploadID string `json:"preuploadID"`
|
||||
Reuse bool `json:"reuse"`
|
||||
SliceSize int64 `json:"sliceSize"`
|
||||
Servers []string `json:"servers"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
type UploadUrlResp struct {
|
||||
BaseResp
|
||||
Data struct {
|
||||
PresignedURL string `json:"presignedURL"`
|
||||
}
|
||||
}
|
||||
|
||||
// 上传完毕V2返回
|
||||
type UploadCompleteResp struct {
|
||||
BaseResp
|
||||
Data struct {
|
||||
Async bool `json:"async"`
|
||||
Completed bool `json:"completed"`
|
||||
FileID int64 `json:"fileID"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
type UploadAsyncResp struct {
|
||||
type SHA1ReuseResp struct {
|
||||
BaseResp
|
||||
Data struct {
|
||||
Completed bool `json:"completed"`
|
||||
FileID int64 `json:"fileID"`
|
||||
FileID int64 `json:"fileID"`
|
||||
Reuse bool `json:"reuse"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
type UploadResp struct {
|
||||
type OfflineDownloadResp struct {
|
||||
BaseResp
|
||||
Data struct {
|
||||
AccessKeyId string `json:"AccessKeyId"`
|
||||
Bucket string `json:"Bucket"`
|
||||
Key string `json:"Key"`
|
||||
SecretAccessKey string `json:"SecretAccessKey"`
|
||||
SessionToken string `json:"SessionToken"`
|
||||
FileId int64 `json:"FileId"`
|
||||
Reuse bool `json:"Reuse"`
|
||||
EndPoint string `json:"EndPoint"`
|
||||
StorageNode string `json:"StorageNode"`
|
||||
UploadId string `json:"UploadId"`
|
||||
TaskID int `json:"taskID"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
type OfflineDownloadProcessResp struct {
|
||||
BaseResp
|
||||
Data struct {
|
||||
Process float64 `json:"process"`
|
||||
Status int `json:"status"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
+144
-92
@@ -1,21 +1,28 @@
|
||||
package _123_open
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"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"
|
||||
"resty.dev/v3"
|
||||
"github.com/go-resty/resty/v2"
|
||||
)
|
||||
|
||||
// 创建文件 V2
|
||||
func (d *Open123) create(parentFileID int64, filename string, etag string, size int64, duplicate int, containDir bool) (*UploadCreateResp, error) {
|
||||
var resp UploadCreateResp
|
||||
_, err := d.Request(UploadCreate, http.MethodPost, func(req *resty.Request) {
|
||||
@@ -34,21 +41,136 @@ func (d *Open123) create(parentFileID int64, filename string, etag string, size
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
func (d *Open123) url(preuploadID string, sliceNo int64) (string, error) {
|
||||
// get upload url
|
||||
var resp UploadUrlResp
|
||||
_, err := d.Request(UploadUrl, http.MethodPost, func(req *resty.Request) {
|
||||
req.SetBody(base.Json{
|
||||
"preuploadId": preuploadID,
|
||||
"sliceNo": sliceNo,
|
||||
})
|
||||
}, &resp)
|
||||
// 上传分片 V2
|
||||
func (d *Open123) Upload(ctx context.Context, file model.FileStreamer, createResp *UploadCreateResp, up driver.UpdateProgress) error {
|
||||
uploadDomain := createResp.Data.Servers[0]
|
||||
size := file.GetSize()
|
||||
chunkSize := createResp.Data.SliceSize
|
||||
|
||||
ss, err := stream.NewStreamSectionReader(file, int(chunkSize), &up)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return err
|
||||
}
|
||||
return resp.Data.PresignedURL, nil
|
||||
|
||||
uploadNums := (size + chunkSize - 1) / chunkSize
|
||||
thread := min(int(uploadNums), d.UploadThread)
|
||||
threadG, uploadCtx := errgroup.NewOrderedGroupWithContext(ctx, thread,
|
||||
retry.Attempts(3),
|
||||
retry.Delay(time.Second),
|
||||
retry.DelayType(retry.BackOffDelay))
|
||||
|
||||
for partIndex := range uploadNums {
|
||||
if utils.IsCanceled(uploadCtx) {
|
||||
break
|
||||
}
|
||||
partIndex := partIndex
|
||||
partNumber := partIndex + 1 // 分片号从1开始
|
||||
offset := partIndex * chunkSize
|
||||
size := min(chunkSize, size-offset)
|
||||
var reader io.ReadSeeker
|
||||
var rateLimitedRd io.Reader
|
||||
sliceMD5 := ""
|
||||
// 表单
|
||||
b := bytes.NewBuffer(make([]byte, 0, 2048))
|
||||
threadG.GoWithLifecycle(errgroup.Lifecycle{
|
||||
Before: func(ctx context.Context) (err error) {
|
||||
reader, err = ss.GetSectionReader(offset, size)
|
||||
return
|
||||
},
|
||||
Do: func(ctx context.Context) (err error) {
|
||||
reader.Seek(0, io.SeekStart)
|
||||
if sliceMD5 == "" {
|
||||
// 把耗时的计算放在这里,避免阻塞其他协程
|
||||
sliceMD5, err = utils.HashReader(utils.MD5, reader)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reader.Seek(0, io.SeekStart)
|
||||
}
|
||||
|
||||
b.Reset()
|
||||
w := multipart.NewWriter(b)
|
||||
// 添加表单字段
|
||||
err = w.WriteField("preuploadID", createResp.Data.PreuploadID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = w.WriteField("sliceNo", strconv.FormatInt(partNumber, 10))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = w.WriteField("sliceMD5", sliceMD5)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 写入文件内容
|
||||
_, err = w.CreateFormFile("slice", fmt.Sprintf("%s.part%d", file.GetName(), partNumber))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
headSize := b.Len()
|
||||
err = w.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
head := bytes.NewReader(b.Bytes()[:headSize])
|
||||
tail := bytes.NewReader(b.Bytes()[headSize:])
|
||||
rateLimitedRd = driver.NewLimitedUploadStream(ctx, io.MultiReader(head, reader, tail))
|
||||
token, err := d.getAccessToken(false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 创建请求并设置header
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, uploadDomain+"/upload/v2/file/slice", rateLimitedRd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 设置请求头
|
||||
req.Header.Add("Authorization", "Bearer "+token)
|
||||
req.Header.Add("Content-Type", w.FormDataContentType())
|
||||
req.Header.Add("Platform", "open_platform")
|
||||
|
||||
res, err := base.HttpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != 200 {
|
||||
return fmt.Errorf("slice %d upload failed, status code: %d", partNumber, res.StatusCode)
|
||||
}
|
||||
b.Reset()
|
||||
_, err = b.ReadFrom(res.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var resp BaseResp
|
||||
err = json.Unmarshal(b.Bytes(), &resp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp.Code != 0 {
|
||||
return fmt.Errorf("slice %d upload failed: %s", partNumber, resp.Message)
|
||||
}
|
||||
|
||||
progress := 100 * float64(threadG.Success()+1) / float64(uploadNums+1)
|
||||
up(progress)
|
||||
return nil
|
||||
},
|
||||
After: func(err error) {
|
||||
ss.FreeSectionReader(reader)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if err := threadG.Wait(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// 上传完毕
|
||||
func (d *Open123) complete(preuploadID string) (*UploadCompleteResp, error) {
|
||||
var resp UploadCompleteResp
|
||||
_, err := d.Request(UploadComplete, http.MethodPost, func(req *resty.Request) {
|
||||
@@ -62,11 +184,16 @@ func (d *Open123) complete(preuploadID string) (*UploadCompleteResp, error) {
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
func (d *Open123) async(preuploadID string) (*UploadAsyncResp, error) {
|
||||
var resp UploadAsyncResp
|
||||
_, err := d.Request(UploadAsync, http.MethodPost, func(req *resty.Request) {
|
||||
// SHA1 秒传
|
||||
func (d *Open123) sha1Reuse(parentFileID int64, filename string, sha1Hash string, size int64, duplicate int) (*SHA1ReuseResp, error) {
|
||||
var resp SHA1ReuseResp
|
||||
_, err := d.Request(UploadSHA1Reuse, http.MethodPost, func(req *resty.Request) {
|
||||
req.SetBody(base.Json{
|
||||
"preuploadID": preuploadID,
|
||||
"parentFileID": parentFileID,
|
||||
"filename": filename,
|
||||
"sha1": strings.ToLower(sha1Hash),
|
||||
"size": size,
|
||||
"duplicate": duplicate,
|
||||
})
|
||||
}, &resp)
|
||||
if err != nil {
|
||||
@@ -74,78 +201,3 @@ func (d *Open123) async(preuploadID string) (*UploadAsyncResp, error) {
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
func (d *Open123) Upload(ctx context.Context, file model.FileStreamer, createResp *UploadCreateResp, up driver.UpdateProgress) error {
|
||||
size := file.GetSize()
|
||||
chunkSize := createResp.Data.SliceSize
|
||||
uploadNums := (size + chunkSize - 1) / chunkSize
|
||||
threadG, uploadCtx := errgroup.NewGroupWithContext(ctx, d.UploadThread,
|
||||
retry.Attempts(3),
|
||||
retry.Delay(time.Second),
|
||||
retry.DelayType(retry.BackOffDelay))
|
||||
|
||||
for partIndex := int64(0); partIndex < uploadNums; partIndex++ {
|
||||
if utils.IsCanceled(uploadCtx) {
|
||||
return ctx.Err()
|
||||
}
|
||||
partIndex := partIndex
|
||||
partNumber := partIndex + 1 // 分片号从1开始
|
||||
offset := partIndex * chunkSize
|
||||
size := min(chunkSize, size-offset)
|
||||
limitedReader, err := file.RangeRead(http_range.Range{
|
||||
Start: offset,
|
||||
Length: size})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
limitedReader = driver.NewLimitedUploadStream(ctx, limitedReader)
|
||||
|
||||
threadG.Go(func(ctx context.Context) error {
|
||||
uploadPartUrl, err := d.url(createResp.Data.PreuploadID, partNumber)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "PUT", uploadPartUrl, limitedReader)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
req.ContentLength = size
|
||||
|
||||
res, err := base.HttpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_ = res.Body.Close()
|
||||
|
||||
progress := 10.0 + 85.0*float64(threadG.Success())/float64(uploadNums)
|
||||
up(progress)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
if err := threadG.Wait(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
uploadCompleteResp, err := d.complete(createResp.Data.PreuploadID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if uploadCompleteResp.Data.Async == false || uploadCompleteResp.Data.Completed {
|
||||
return nil
|
||||
}
|
||||
|
||||
for {
|
||||
uploadAsyncResp, err := d.async(createResp.Data.PreuploadID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if uploadAsyncResp.Data.Completed {
|
||||
break
|
||||
}
|
||||
}
|
||||
up(100)
|
||||
return nil
|
||||
}
|
||||
|
||||
+117
-56
@@ -1,42 +1,51 @@
|
||||
package _123_open
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/OpenListTeam/OpenList/v4/drivers/base"
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/op"
|
||||
"github.com/go-resty/resty/v2"
|
||||
"github.com/google/uuid"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"resty.dev/v3"
|
||||
)
|
||||
|
||||
var ( //不同情况下获取的AccessTokenQPS限制不同 如下模块化易于拓展
|
||||
var ( // 不同情况下获取的AccessTokenQPS限制不同 如下模块化易于拓展
|
||||
Api = "https://open-api.123pan.com"
|
||||
|
||||
AccessToken = InitApiInfo(Api+"/api/v1/access_token", 1)
|
||||
RefreshToken = InitApiInfo(Api+"/api/v1/oauth2/access_token", 1)
|
||||
UserInfo = InitApiInfo(Api+"/api/v1/user/info", 1)
|
||||
FileList = InitApiInfo(Api+"/api/v2/file/list", 4)
|
||||
DownloadInfo = InitApiInfo(Api+"/api/v1/file/download_info", 0)
|
||||
Mkdir = InitApiInfo(Api+"/upload/v1/file/mkdir", 2)
|
||||
Move = InitApiInfo(Api+"/api/v1/file/move", 1)
|
||||
Rename = InitApiInfo(Api+"/api/v1/file/name", 1)
|
||||
Trash = InitApiInfo(Api+"/api/v1/file/trash", 2)
|
||||
UploadCreate = InitApiInfo(Api+"/upload/v1/file/create", 2)
|
||||
UploadUrl = InitApiInfo(Api+"/upload/v1/file/get_upload_url", 0)
|
||||
UploadComplete = InitApiInfo(Api+"/upload/v1/file/upload_complete", 0)
|
||||
UploadAsync = InitApiInfo(Api+"/upload/v1/file/upload_async_result", 1)
|
||||
UserInfo = InitApiInfo(Api+"/api/v1/user/info", 1)
|
||||
FileList = InitApiInfo(Api+"/api/v2/file/list", 3)
|
||||
DownloadInfo = InitApiInfo(Api+"/api/v1/file/download_info", 5)
|
||||
DirectLink = InitApiInfo(Api+"/api/v1/direct-link/url", 5)
|
||||
Mkdir = InitApiInfo(Api+"/upload/v1/file/mkdir", 2)
|
||||
Move = InitApiInfo(Api+"/api/v1/file/move", 1)
|
||||
Rename = InitApiInfo(Api+"/api/v1/file/name", 1)
|
||||
Trash = InitApiInfo(Api+"/api/v1/file/trash", 2)
|
||||
UploadCreate = InitApiInfo(Api+"/upload/v2/file/create", 2)
|
||||
UploadComplete = InitApiInfo(Api+"/upload/v2/file/upload_complete", 0)
|
||||
UploadSHA1Reuse = InitApiInfo(Api+"/upload/v2/file/sha1_reuse", 2)
|
||||
|
||||
OfflineDownload = InitApiInfo(Api+"/api/v1/offline/download", 1)
|
||||
OfflineDownloadProcess = InitApiInfo(Api+"/api/v1/offline/download/process", 5)
|
||||
)
|
||||
|
||||
func (d *Open123) Request(apiInfo *ApiInfo, method string, callback base.ReqCallback, resp interface{}) ([]byte, error) {
|
||||
retryToken := true
|
||||
for {
|
||||
token, err := d.getAccessToken(false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req := base.RestyClient.R()
|
||||
req.SetHeaders(map[string]string{
|
||||
"authorization": "Bearer " + d.AccessToken,
|
||||
"authorization": "Bearer " + token,
|
||||
"platform": "open_platform",
|
||||
"Content-Type": "application/json",
|
||||
})
|
||||
@@ -56,7 +65,7 @@ func (d *Open123) Request(apiInfo *ApiInfo, method string, callback base.ReqCall
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body := res.Bytes()
|
||||
body := res.Body()
|
||||
|
||||
// 解析为通用响应
|
||||
var baseResp BaseResp
|
||||
@@ -66,9 +75,9 @@ func (d *Open123) Request(apiInfo *ApiInfo, method string, callback base.ReqCall
|
||||
|
||||
if baseResp.Code == 0 {
|
||||
return body, nil
|
||||
} else if baseResp.Code == 401 && retryToken {
|
||||
retryToken = false
|
||||
if err := d.flushAccessToken(); err != nil {
|
||||
} else if baseResp.Code == 401 {
|
||||
// 强制刷新Token, 有小概率会 race condition 导致多次刷新Token,但不影响正确运行
|
||||
if _, err := d.getAccessToken(true); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else if baseResp.Code == 429 {
|
||||
@@ -78,52 +87,59 @@ func (d *Open123) Request(apiInfo *ApiInfo, method string, callback base.ReqCall
|
||||
return nil, errors.New(baseResp.Message)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func (d *Open123) flushAccessToken() error {
|
||||
if d.Addition.ClientID != "" {
|
||||
if d.Addition.ClientSecret != "" {
|
||||
var resp AccessTokenResp
|
||||
_, err := d.Request(AccessToken, http.MethodPost, func(req *resty.Request) {
|
||||
req.SetBody(base.Json{
|
||||
"clientID": d.ClientID,
|
||||
"clientSecret": d.ClientSecret,
|
||||
})
|
||||
}, &resp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
d.AccessToken = resp.Data.AccessToken
|
||||
op.MustSaveDriverStorage(d)
|
||||
} else if d.Addition.RefreshToken != "" {
|
||||
var resp RefreshTokenResp
|
||||
_, err := d.Request(RefreshToken, http.MethodPost, func(req *resty.Request) {
|
||||
req.SetQueryParam("client_id", d.ClientID)
|
||||
req.SetQueryParam("grant_type", "refresh_token")
|
||||
req.SetQueryParam("refresh_token", d.Addition.RefreshToken)
|
||||
}, &resp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
d.AccessToken = resp.AccessToken
|
||||
d.RefreshToken = resp.RefreshToken
|
||||
op.MustSaveDriverStorage(d)
|
||||
}
|
||||
func (d *Open123) SignURL(originURL, privateKey string, uid uint64, validDuration time.Duration) (newURL string, err error) {
|
||||
// 生成Unix时间戳
|
||||
ts := time.Now().Add(validDuration).Unix()
|
||||
|
||||
// 生成随机数(建议使用UUID,不能包含中划线(-))
|
||||
rand := strings.ReplaceAll(uuid.New().String(), "-", "")
|
||||
|
||||
// 解析URL
|
||||
objURL, err := url.Parse(originURL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return nil
|
||||
|
||||
// 待签名字符串,格式:path-timestamp-rand-uid-privateKey
|
||||
unsignedStr := fmt.Sprintf("%s-%d-%s-%d-%s", objURL.Path, ts, rand, uid, privateKey)
|
||||
md5Hash := md5.Sum([]byte(unsignedStr))
|
||||
// 生成鉴权参数,格式:timestamp-rand-uid-md5hash
|
||||
authKey := fmt.Sprintf("%d-%s-%d-%x", ts, rand, uid, md5Hash)
|
||||
|
||||
// 添加鉴权参数到URL查询参数
|
||||
v := objURL.Query()
|
||||
v.Add("auth_key", authKey)
|
||||
objURL.RawQuery = v.Encode()
|
||||
|
||||
return objURL.String(), nil
|
||||
}
|
||||
|
||||
func (d *Open123) getUserInfo() (*UserInfoResp, error) {
|
||||
func (d *Open123) getUserInfo(ctx context.Context) (*UserInfoResp, error) {
|
||||
var resp UserInfoResp
|
||||
|
||||
if _, err := d.Request(UserInfo, http.MethodGet, nil, &resp); err != nil {
|
||||
if _, err := d.Request(UserInfo, http.MethodGet, func(req *resty.Request) {
|
||||
req.SetContext(ctx)
|
||||
}, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
func (d *Open123) getUID(ctx context.Context) (uint64, error) {
|
||||
if d.UID != 0 {
|
||||
return d.UID, nil
|
||||
}
|
||||
resp, err := d.getUserInfo(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
d.UID = resp.Data.UID
|
||||
return resp.Data.UID, nil
|
||||
}
|
||||
|
||||
func (d *Open123) getFiles(parentFileId int64, limit int, lastFileId int64) (*FileListResp, error) {
|
||||
var resp FileListResp
|
||||
|
||||
@@ -138,7 +154,6 @@ func (d *Open123) getFiles(parentFileId int64, limit int, lastFileId int64) (*Fi
|
||||
"searchData": "",
|
||||
})
|
||||
}, &resp)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -161,6 +176,21 @@ func (d *Open123) getDownloadInfo(fileId int64) (*DownloadInfoResp, error) {
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
func (d *Open123) getDirectLink(fileId int64) (*DirectLinkResp, error) {
|
||||
var resp DirectLinkResp
|
||||
|
||||
_, err := d.Request(DirectLink, http.MethodGet, func(req *resty.Request) {
|
||||
req.SetQueryParams(map[string]string{
|
||||
"fileID": strconv.FormatInt(fileId, 10),
|
||||
})
|
||||
}, &resp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
func (d *Open123) mkdir(parentID int64, name string) error {
|
||||
_, err := d.Request(Mkdir, http.MethodPost, func(req *resty.Request) {
|
||||
req.SetBody(base.Json{
|
||||
@@ -215,3 +245,34 @@ func (d *Open123) trash(fileId int64) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Open123) createOfflineDownloadTask(ctx context.Context, url string, dirID, callback string) (taskID int, err error) {
|
||||
body := base.Json{
|
||||
"url": url,
|
||||
"dirID": dirID,
|
||||
}
|
||||
if len(callback) > 0 {
|
||||
body["callBackUrl"] = callback
|
||||
}
|
||||
var resp OfflineDownloadResp
|
||||
_, err = d.Request(OfflineDownload, http.MethodPost, func(req *resty.Request) {
|
||||
req.SetBody(body)
|
||||
}, &resp)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return resp.Data.TaskID, nil
|
||||
}
|
||||
|
||||
func (d *Open123) queryOfflineDownloadStatus(ctx context.Context, taskID int) (process float64, status int, err error) {
|
||||
var resp OfflineDownloadProcessResp
|
||||
_, err = d.Request(OfflineDownloadProcess, http.MethodGet, func(req *resty.Request) {
|
||||
req.SetQueryParams(map[string]string{
|
||||
"taskID": strconv.Itoa(taskID),
|
||||
})
|
||||
}, &resp)
|
||||
if err != nil {
|
||||
return .0, 0, err
|
||||
}
|
||||
return resp.Data.Process, resp.Data.Status, nil
|
||||
}
|
||||
|
||||
+11
-17
@@ -17,8 +17,8 @@ import (
|
||||
"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"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"resty.dev/v3"
|
||||
)
|
||||
|
||||
type Pan123Share struct {
|
||||
@@ -70,14 +70,6 @@ func (d *Pan123Share) List(ctx context.Context, dir model.Obj, args model.ListAr
|
||||
func (d *Pan123Share) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (*model.Link, error) {
|
||||
// TODO return link of file, required
|
||||
if f, ok := file.(File); ok {
|
||||
//var resp DownResp
|
||||
var headers map[string]string
|
||||
if !utils.IsLocalIPAddr(args.IP) {
|
||||
headers = map[string]string{
|
||||
//"X-Real-IP": "1.1.1.1",
|
||||
"X-Forwarded-For": args.IP,
|
||||
}
|
||||
}
|
||||
data := base.Json{
|
||||
"shareKey": d.ShareKey,
|
||||
"SharePwd": d.SharePwd,
|
||||
@@ -87,27 +79,29 @@ func (d *Pan123Share) Link(ctx context.Context, file model.Obj, args model.LinkA
|
||||
"size": f.Size,
|
||||
}
|
||||
resp, err := d.request(DownloadInfo, http.MethodPost, func(req *resty.Request) {
|
||||
req.SetBody(data).SetHeaders(headers)
|
||||
req.SetBody(data)
|
||||
}, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
downloadUrl := utils.Json.Get(resp, "data", "DownloadURL").ToString()
|
||||
u, err := url.Parse(downloadUrl)
|
||||
ou, err := url.Parse(downloadUrl)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nu := u.Query().Get("params")
|
||||
u_ := ou.String()
|
||||
nu := ou.Query().Get("params")
|
||||
if nu != "" {
|
||||
du, _ := base64.StdEncoding.DecodeString(nu)
|
||||
u, err = url.Parse(string(du))
|
||||
u, err := url.Parse(string(du))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u_ = u.String()
|
||||
}
|
||||
u_ := u.String()
|
||||
|
||||
log.Debug("download url: ", u_)
|
||||
res, err := base.NoRedirectClient.R().SetHeader("Referer", "https://www.123pan.com/").Get(u_)
|
||||
res, err := base.NoRedirectClient.R().SetHeader("Referer", "https://yun.123pan.com/").Get(u_)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -119,10 +113,10 @@ func (d *Pan123Share) Link(ctx context.Context, file model.Obj, args model.LinkA
|
||||
if res.StatusCode() == 302 {
|
||||
link.URL = res.Header().Get("location")
|
||||
} else if res.StatusCode() < 300 {
|
||||
link.URL = utils.Json.Get(res.Bytes(), "data", "redirect_url").ToString()
|
||||
link.URL = utils.Json.Get(res.Body(), "data", "redirect_url").ToString()
|
||||
}
|
||||
link.Header = http.Header{
|
||||
"Referer": []string{"https://www.123pan.com/"},
|
||||
"Referer": []string{fmt.Sprintf("%s://%s/", ou.Scheme, ou.Host)},
|
||||
}
|
||||
return &link, nil
|
||||
}
|
||||
|
||||
@@ -15,17 +15,11 @@ type Addition struct {
|
||||
}
|
||||
|
||||
var config = driver.Config{
|
||||
Name: "123PanShare",
|
||||
LocalSort: true,
|
||||
OnlyLocal: false,
|
||||
OnlyProxy: false,
|
||||
NoCache: false,
|
||||
NoUpload: true,
|
||||
NeedMs: false,
|
||||
DefaultRoot: "0",
|
||||
CheckStatus: false,
|
||||
Alert: "",
|
||||
NoOverwriteUpload: false,
|
||||
Name: "123PanShare",
|
||||
LocalSort: true,
|
||||
NoUpload: true,
|
||||
DefaultRoot: "0",
|
||||
PreferProxy: true,
|
||||
}
|
||||
|
||||
func init() {
|
||||
|
||||
@@ -24,7 +24,7 @@ type File struct {
|
||||
}
|
||||
|
||||
func (f File) GetHash() utils.HashInfo {
|
||||
return utils.HashInfo{}
|
||||
return utils.NewHashInfo(utils.MD5, f.Etag)
|
||||
}
|
||||
|
||||
func (f File) GetPath() string {
|
||||
|
||||
@@ -15,14 +15,14 @@ import (
|
||||
|
||||
"github.com/OpenListTeam/OpenList/v4/drivers/base"
|
||||
"github.com/OpenListTeam/OpenList/v4/pkg/utils"
|
||||
"github.com/go-resty/resty/v2"
|
||||
jsoniter "github.com/json-iterator/go"
|
||||
"resty.dev/v3"
|
||||
)
|
||||
|
||||
const (
|
||||
Api = "https://www.123pan.com/api"
|
||||
AApi = "https://www.123pan.com/a/api"
|
||||
BApi = "https://www.123pan.com/b/api"
|
||||
Api = "https://yun.123pan.com/api"
|
||||
AApi = "https://yun.123pan.com/a/api"
|
||||
BApi = "https://yun.123pan.com/b/api"
|
||||
MainApi = BApi
|
||||
FileList = MainApi + "/share/get"
|
||||
DownloadInfo = MainApi + "/share/download/info"
|
||||
@@ -58,8 +58,8 @@ func (d *Pan123Share) request(url string, method string, callback base.ReqCallba
|
||||
}
|
||||
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": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) openlist-client",
|
||||
"platform": "web",
|
||||
@@ -76,7 +76,7 @@ func (d *Pan123Share) request(url string, method string, callback base.ReqCallba
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body := res.Bytes()
|
||||
body := res.Body()
|
||||
code := utils.Json.Get(body, "code").ToInt()
|
||||
if code != 0 {
|
||||
return nil, errors.New(jsoniter.Get(body, "message").ToString())
|
||||
|
||||
+385
-152
@@ -14,10 +14,12 @@ import (
|
||||
"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"
|
||||
"github.com/OpenListTeam/OpenList/v4/pkg/cron"
|
||||
"github.com/OpenListTeam/OpenList/v4/pkg/utils"
|
||||
"github.com/OpenListTeam/OpenList/v4/pkg/utils/random"
|
||||
"github.com/avast/retry-go"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
@@ -28,6 +30,9 @@ type Yun139 struct {
|
||||
Account string
|
||||
ref *Yun139
|
||||
PersonalCloudHost string
|
||||
FamilyCloudHost string
|
||||
GroupCloudHost string
|
||||
ProviderRoot string
|
||||
}
|
||||
|
||||
func (d *Yun139) Config() driver.Config {
|
||||
@@ -40,43 +45,62 @@ func (d *Yun139) GetAddition() driver.Additional {
|
||||
|
||||
func (d *Yun139) Init(ctx context.Context) error {
|
||||
if d.ref == nil {
|
||||
if len(d.Authorization) == 0 {
|
||||
return fmt.Errorf("authorization is empty")
|
||||
}
|
||||
err := d.refreshToken()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Query Route Policy
|
||||
var resp QueryRoutePolicyResp
|
||||
_, err = d.requestRoute(base.Json{
|
||||
"userInfo": base.Json{
|
||||
"userType": 1,
|
||||
"accountType": 1,
|
||||
"accountName": d.Account},
|
||||
"modAddrType": 1,
|
||||
}, &resp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, policyItem := range resp.Data.RoutePolicyList {
|
||||
if policyItem.ModName == "personal" {
|
||||
d.PersonalCloudHost = policyItem.HttpsUrl
|
||||
break
|
||||
// Share links may be browsed without account credentials. All other
|
||||
// storage types need an authorization or a supported login fallback.
|
||||
if !d.isShare() {
|
||||
if err := d.validateAndInitCredentials(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if len(d.PersonalCloudHost) == 0 {
|
||||
return fmt.Errorf("PersonalCloudHost is empty")
|
||||
}
|
||||
|
||||
d.cron = cron.NewCron(time.Hour * 12)
|
||||
d.cron.Do(func() {
|
||||
if d.Authorization != "" {
|
||||
err := d.refreshToken()
|
||||
if err != nil {
|
||||
log.Errorf("%+v", err)
|
||||
return err
|
||||
}
|
||||
})
|
||||
|
||||
// Query Route Policy
|
||||
var resp QueryRoutePolicyResp
|
||||
_, err = d.requestRoute(base.Json{
|
||||
"userInfo": base.Json{
|
||||
"userType": 1,
|
||||
"accountType": 1,
|
||||
"accountName": d.Account,
|
||||
},
|
||||
"modAddrType": 1,
|
||||
}, &resp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, policyItem := range resp.Data.RoutePolicyList {
|
||||
switch policyItem.ModName {
|
||||
case "personal":
|
||||
d.PersonalCloudHost = policyItem.HttpsUrl
|
||||
case "group":
|
||||
d.GroupCloudHost = policyItem.HttpsUrl
|
||||
case "family":
|
||||
d.FamilyCloudHost = policyItem.HttpsUrl
|
||||
}
|
||||
}
|
||||
if len(d.PersonalCloudHost) == 0 {
|
||||
return fmt.Errorf("PersonalCloudHost is empty")
|
||||
}
|
||||
if d.isGroup() || d.isFamily() {
|
||||
if len(d.GroupCloudHost) == 0 {
|
||||
return fmt.Errorf("GroupCloudHost is empty")
|
||||
}
|
||||
if len(d.FamilyCloudHost) == 0 {
|
||||
return fmt.Errorf("FamilyCloudHost is empty")
|
||||
}
|
||||
}
|
||||
|
||||
d.cron = cron.NewCron(time.Hour * 12)
|
||||
d.cron.Do(func() {
|
||||
err := d.refreshToken()
|
||||
if err != nil {
|
||||
log.Errorf("%+v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
switch d.Addition.Type {
|
||||
case MetaPersonalNew:
|
||||
@@ -91,7 +115,32 @@ func (d *Yun139) Init(ctx context.Context) error {
|
||||
if len(d.Addition.RootFolderID) == 0 {
|
||||
d.RootFolderID = d.CloudID
|
||||
}
|
||||
_, err := d.groupGetFiles(d.RootFolderID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
case MetaShare:
|
||||
if len(d.Addition.RootFolderID) == 0 {
|
||||
d.RootFolderID = "root"
|
||||
}
|
||||
if len(d.shareEntries()) == 0 {
|
||||
return fmt.Errorf("link_id is empty")
|
||||
}
|
||||
case MetaFamily:
|
||||
// Attempt to obtain data.path as the root via a query and persist it.
|
||||
root, err := d.getFamilyRootPath(d.CloudID)
|
||||
if err != nil || root == "" {
|
||||
return fmt.Errorf("failed to get family root path: %w", err)
|
||||
}
|
||||
d.ProviderRoot = root
|
||||
if len(d.Addition.RootFolderID) == 0 {
|
||||
d.RootFolderID = root
|
||||
op.MustSaveDriverStorage(d)
|
||||
}
|
||||
_, err = d.familyGetFiles(d.RootFolderID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
return errs.NotImplement
|
||||
}
|
||||
@@ -107,6 +156,19 @@ func (d *Yun139) InitReference(storage driver.Driver) error {
|
||||
return errs.NotSupport
|
||||
}
|
||||
|
||||
func (d *Yun139) Get(ctx context.Context, path string) (model.Obj, error) {
|
||||
if !d.isShare() {
|
||||
return nil, errs.NotImplement
|
||||
}
|
||||
if path == "/" {
|
||||
return &model.Object{ID: "root", Name: "root", IsFolder: true, Path: "/"}, nil
|
||||
}
|
||||
if obj, err := d.shareGetObj(path); err == nil {
|
||||
return obj, nil
|
||||
}
|
||||
return nil, errs.ObjectNotFound
|
||||
}
|
||||
|
||||
func (d *Yun139) Drop(ctx context.Context) error {
|
||||
if d.cron != nil {
|
||||
d.cron.Stop()
|
||||
@@ -125,12 +187,23 @@ func (d *Yun139) List(ctx context.Context, dir model.Obj, args model.ListArgs) (
|
||||
return d.familyGetFiles(dir.GetID())
|
||||
case MetaGroup:
|
||||
return d.groupGetFiles(dir.GetID())
|
||||
case MetaShare:
|
||||
if dir.GetID() == "root" {
|
||||
return d.shareGetMergedFiles(d.shareRootEntries())
|
||||
}
|
||||
if refs, ok := decodeShareRefs(dir.GetID()); ok {
|
||||
return d.shareGetMergedFiles(refs)
|
||||
}
|
||||
return d.shareGetFilesWithRef(shareRef{LinkID: d.LinkID, NodeID: dir.GetID()}, dir.GetID())
|
||||
default:
|
||||
return nil, errs.NotImplement
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Yun139) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (*model.Link, error) {
|
||||
if file.IsDir() {
|
||||
return nil, errs.NotFile
|
||||
}
|
||||
var url string
|
||||
var err error
|
||||
switch d.Addition.Type {
|
||||
@@ -142,6 +215,16 @@ func (d *Yun139) Link(ctx context.Context, file model.Obj, args model.LinkArgs)
|
||||
url, err = d.familyGetLink(file.GetID(), file.GetPath())
|
||||
case MetaGroup:
|
||||
url, err = d.groupGetLink(file.GetID(), file.GetPath())
|
||||
case MetaShare:
|
||||
if refs, ok := decodeShareRefs(file.GetID()); ok && len(refs) > 0 {
|
||||
return d.shareGetLinkWithRef(refs[0], refs[0].NodeID, args.Type)
|
||||
}
|
||||
fallbackRef := shareRef{LinkID: d.LinkID, NodeID: "root"}
|
||||
if entries := d.shareEntries(); len(entries) > 0 {
|
||||
fallbackRef.LinkID = entries[0].LinkID
|
||||
fallbackRef.Password = entries[0].Password
|
||||
}
|
||||
return d.shareGetLinkWithRef(fallbackRef, file.GetID(), args.Type)
|
||||
default:
|
||||
return nil, errs.NotImplement
|
||||
}
|
||||
@@ -152,6 +235,9 @@ func (d *Yun139) Link(ctx context.Context, file model.Obj, args model.LinkArgs)
|
||||
}
|
||||
|
||||
func (d *Yun139) MakeDir(ctx context.Context, parentDir model.Obj, dirName string) error {
|
||||
if d.isShare() {
|
||||
return errs.NotImplement
|
||||
}
|
||||
var err error
|
||||
switch d.Addition.Type {
|
||||
case MetaPersonalNew:
|
||||
@@ -185,7 +271,7 @@ func (d *Yun139) MakeDir(ctx context.Context, parentDir model.Obj, dirName strin
|
||||
"accountType": 1,
|
||||
},
|
||||
"docLibName": dirName,
|
||||
"path": path.Join(parentDir.GetPath(), parentDir.GetID()),
|
||||
"path": d.dirPath(parentDir),
|
||||
}
|
||||
pathname := "/orchestration/familyCloud-rebuild/cloudCatalog/v1.0/createCloudDoc"
|
||||
_, err = d.post(pathname, data, nil)
|
||||
@@ -198,7 +284,7 @@ func (d *Yun139) MakeDir(ctx context.Context, parentDir model.Obj, dirName strin
|
||||
},
|
||||
"groupID": d.CloudID,
|
||||
"parentFileId": parentDir.GetID(),
|
||||
"path": path.Join(parentDir.GetPath(), parentDir.GetID()),
|
||||
"path": d.dirPath(parentDir),
|
||||
}
|
||||
pathname := "/orchestration/group-rebuild/catalog/v1.0/createGroupCatalog"
|
||||
_, err = d.post(pathname, data, nil)
|
||||
@@ -209,6 +295,9 @@ func (d *Yun139) MakeDir(ctx context.Context, parentDir model.Obj, dirName strin
|
||||
}
|
||||
|
||||
func (d *Yun139) Move(ctx context.Context, srcObj, dstDir model.Obj) (model.Obj, error) {
|
||||
if d.isShare() {
|
||||
return nil, errs.NotImplement
|
||||
}
|
||||
switch d.Addition.Type {
|
||||
case MetaPersonalNew:
|
||||
data := base.Json{
|
||||
@@ -278,12 +367,51 @@ func (d *Yun139) Move(ctx context.Context, srcObj, dstDir model.Obj) (model.Obj,
|
||||
return nil, err
|
||||
}
|
||||
return srcObj, nil
|
||||
case MetaFamily:
|
||||
pathname := "/isbo/openApi/createBatchOprTask"
|
||||
var contentList []string
|
||||
var catalogList []string
|
||||
if srcObj.IsDir() {
|
||||
catalogList = append(catalogList, d.dirPath(srcObj))
|
||||
} else {
|
||||
contentList = append(contentList, d.dirPath(srcObj))
|
||||
}
|
||||
|
||||
body := base.Json{
|
||||
"catalogList": catalogList,
|
||||
"accountInfo": base.Json{
|
||||
"accountName": d.getAccount(),
|
||||
"accountType": "1",
|
||||
},
|
||||
"contentList": contentList,
|
||||
"destCatalogID": dstDir.GetID(),
|
||||
"destGroupID": d.CloudID,
|
||||
"destPath": d.dirPath(dstDir),
|
||||
"destType": 0,
|
||||
"srcGroupID": d.CloudID,
|
||||
"srcType": 0,
|
||||
"taskType": 3,
|
||||
}
|
||||
|
||||
var resp CreateBatchOprTaskResp
|
||||
_, err := d.isboPost(pathname, body, &resp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
log.Debugf("[139] Move MetaFamily CreateBatchOprTaskResp.Result.ResultCode: %s", resp.Result.ResultCode)
|
||||
if resp.Result.ResultCode != "0" {
|
||||
return nil, fmt.Errorf("failed to move in family cloud: %s", resp.Result.ResultDesc)
|
||||
}
|
||||
return srcObj, nil
|
||||
default:
|
||||
return nil, errs.NotImplement
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Yun139) Rename(ctx context.Context, srcObj model.Obj, newName string) error {
|
||||
if d.isShare() {
|
||||
return errs.NotImplement
|
||||
}
|
||||
var err error
|
||||
switch d.Addition.Type {
|
||||
case MetaPersonalNew:
|
||||
@@ -352,19 +480,27 @@ func (d *Yun139) Rename(ctx context.Context, srcObj model.Obj, newName string) e
|
||||
var data base.Json
|
||||
var pathname string
|
||||
if srcObj.IsDir() {
|
||||
// 网页接口不支持重命名家庭云文件夹
|
||||
// data = base.Json{
|
||||
// "catalogType": 3,
|
||||
// "catalogID": srcObj.GetID(),
|
||||
// "catalogName": newName,
|
||||
// "commonAccountInfo": base.Json{
|
||||
// "account": d.getAccount(),
|
||||
// "accountType": 1,
|
||||
// },
|
||||
// "path": srcObj.GetPath(),
|
||||
// }
|
||||
// pathname = "/orchestration/familyCloud-rebuild/photoContent/v1.0/modifyCatalogInfo"
|
||||
return errs.NotImplement
|
||||
pathname = "/modifyCloudDocV2"
|
||||
data = base.Json{
|
||||
"catalogType": 3,
|
||||
"cloudID": d.CloudID,
|
||||
"commonAccountInfo": base.Json{
|
||||
"account": d.getAccount(),
|
||||
"accountType": "1",
|
||||
},
|
||||
"docLibName": newName,
|
||||
"docLibraryID": srcObj.GetID(),
|
||||
"path": d.dirPath(srcObj),
|
||||
}
|
||||
var resp ModifyCloudDocV2Resp
|
||||
_, err = d.andAlbumRequest(pathname, data, &resp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp.Result.ResultCode != "0" {
|
||||
return fmt.Errorf("failed to rename family folder: %s", resp.Result.ResultDesc)
|
||||
}
|
||||
return nil
|
||||
} else {
|
||||
data = base.Json{
|
||||
"contentID": srcObj.GetID(),
|
||||
@@ -385,6 +521,9 @@ func (d *Yun139) Rename(ctx context.Context, srcObj model.Obj, newName string) e
|
||||
}
|
||||
|
||||
func (d *Yun139) Copy(ctx context.Context, srcObj, dstDir model.Obj) error {
|
||||
if d.isShare() {
|
||||
return errs.NotImplement
|
||||
}
|
||||
var err error
|
||||
switch d.Addition.Type {
|
||||
case MetaPersonalNew:
|
||||
@@ -420,6 +559,33 @@ func (d *Yun139) Copy(ctx context.Context, srcObj, dstDir model.Obj) error {
|
||||
}
|
||||
pathname := "/orchestration/personalCloud/batchOprTask/v1.0/createBatchOprTask"
|
||||
_, err = d.post(pathname, data, nil)
|
||||
case MetaGroup:
|
||||
err = d.handleMetaGroupCopy(ctx, srcObj, dstDir)
|
||||
case MetaFamily:
|
||||
pathname := "/copyContentCatalog"
|
||||
var sourceContentIDs []string
|
||||
var sourceCatalogIDs []string
|
||||
if srcObj.IsDir() {
|
||||
sourceCatalogIDs = append(sourceCatalogIDs, srcObj.GetID())
|
||||
} else {
|
||||
sourceContentIDs = append(sourceContentIDs, srcObj.GetID())
|
||||
}
|
||||
|
||||
body := base.Json{
|
||||
"commonAccountInfo": base.Json{
|
||||
"accountType": "1",
|
||||
"accountUserId": d.ref.UserDomainID,
|
||||
},
|
||||
"destCatalogID": dstDir.GetID(),
|
||||
"destCloudID": d.CloudID,
|
||||
"sourceCatalogIDs": sourceCatalogIDs,
|
||||
"sourceCloudID": d.CloudID,
|
||||
"sourceContentIDs": sourceContentIDs,
|
||||
}
|
||||
|
||||
var resp base.Json // Assuming a generic JSON response for success/failure
|
||||
_, err = d.andAlbumRequest(pathname, body, &resp)
|
||||
// For now, we assume no error means success.
|
||||
default:
|
||||
err = errs.NotImplement
|
||||
}
|
||||
@@ -427,6 +593,9 @@ func (d *Yun139) Copy(ctx context.Context, srcObj, dstDir model.Obj) error {
|
||||
}
|
||||
|
||||
func (d *Yun139) Remove(ctx context.Context, obj model.Obj) error {
|
||||
if d.isShare() {
|
||||
return errs.NotImplement
|
||||
}
|
||||
switch d.Addition.Type {
|
||||
case MetaPersonalNew:
|
||||
data := base.Json{
|
||||
@@ -517,35 +686,49 @@ func (d *Yun139) getPartSize(size int64) int64 {
|
||||
}
|
||||
|
||||
func (d *Yun139) Put(ctx context.Context, dstDir model.Obj, stream model.FileStreamer, up driver.UpdateProgress) error {
|
||||
switch d.Addition.Type {
|
||||
case MetaPersonalNew:
|
||||
if d.isShare() {
|
||||
return errs.NotImplement
|
||||
}
|
||||
// PersonalNew 以及 Group/Family 在非旧流模式时走新上传路径
|
||||
if d.Addition.Type == MetaPersonalNew ||
|
||||
((d.isGroup() || d.isFamily()) && !d.UseOldStreamUpload) {
|
||||
var createPath, getUploadUrlPath, completePath string
|
||||
if d.isGroup() || d.isFamily() {
|
||||
// 家庭云和共享群共用同一套新上传 API
|
||||
createPath = "/dynamic/file/create"
|
||||
getUploadUrlPath = "/dynamic/file/getUploadUrl"
|
||||
completePath = "/dynamic/file/complete"
|
||||
} else {
|
||||
// MetaPersonalNew
|
||||
createPath = "/file/create"
|
||||
getUploadUrlPath = "/file/getUploadUrl"
|
||||
completePath = "/file/complete"
|
||||
}
|
||||
|
||||
var err error
|
||||
fullHash := stream.GetHash().GetHash(utils.SHA256)
|
||||
if len(fullHash) != utils.SHA256.Width {
|
||||
_, fullHash, err = streamPkg.CacheFullInTempFileAndHash(stream, utils.SHA256)
|
||||
_, fullHash, err = streamPkg.CacheFullAndHash(stream, &up, utils.SHA256)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
size := stream.GetSize()
|
||||
var partSize = d.getPartSize(size)
|
||||
part := size / partSize
|
||||
if size%partSize > 0 {
|
||||
part++
|
||||
} else if part == 0 {
|
||||
part = 1
|
||||
partSize := d.getPartSize(size)
|
||||
part := int64(1)
|
||||
if size > partSize {
|
||||
part = (size + partSize - 1) / partSize
|
||||
}
|
||||
|
||||
// 生成所有 partInfos
|
||||
partInfos := make([]PartInfo, 0, part)
|
||||
for i := int64(0); i < part; i++ {
|
||||
if utils.IsCanceled(ctx) {
|
||||
return ctx.Err()
|
||||
}
|
||||
start := i * partSize
|
||||
byteSize := size - start
|
||||
if byteSize > partSize {
|
||||
byteSize = partSize
|
||||
}
|
||||
byteSize := min(size-start, partSize)
|
||||
partNumber := i + 1
|
||||
partInfo := PartInfo{
|
||||
PartNumber: partNumber,
|
||||
@@ -576,9 +759,22 @@ func (d *Yun139) Put(ctx context.Context, dstDir model.Obj, stream model.FileStr
|
||||
"type": "file",
|
||||
"fileRenameMode": "auto_rename",
|
||||
}
|
||||
pathname := "/file/create"
|
||||
// 家庭云和共享群需要额外的参数
|
||||
if d.isGroup() || d.isFamily() {
|
||||
if d.CloudID == "" {
|
||||
return fmt.Errorf("cloud_id is required for group/family upload")
|
||||
}
|
||||
data["groupId"] = d.CloudID
|
||||
if d.isGroup() {
|
||||
data["groupType"] = 2
|
||||
} else if d.isFamily() {
|
||||
data["groupType"] = 1
|
||||
}
|
||||
data["catalogType"] = 3
|
||||
data["seqNo"] = random.String(32)
|
||||
}
|
||||
var resp PersonalUploadResp
|
||||
_, err = d.personalPost(pathname, data, &resp)
|
||||
_, err = d.newPost(createPath, data, &resp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -593,17 +789,29 @@ func (d *Yun139) Put(ctx context.Context, dstDir model.Obj, stream model.FileStr
|
||||
// resp.Data.RapidUpload: true 支持快传,但此处直接检测是否返回分片的上传地址
|
||||
// 快传的情况下同样需要手动处理冲突
|
||||
if resp.Data.PartInfos != nil {
|
||||
// 读取前100个分片的上传地址
|
||||
uploadPartInfos := resp.Data.PartInfos
|
||||
// Progress
|
||||
p := driver.NewProgress(size, up)
|
||||
|
||||
// 获取后续分片的上传地址
|
||||
for i := 101; i < len(partInfos); i += 100 {
|
||||
end := i + 100
|
||||
if end > len(partInfos) {
|
||||
end = len(partInfos)
|
||||
}
|
||||
rateLimited := driver.NewLimitedUploadStream(ctx, stream)
|
||||
ss, err := streamPkg.NewStreamSectionReader(&streamPkg.FileStream{
|
||||
Ctx: ctx,
|
||||
Reader: rateLimited,
|
||||
Obj: &model.Object{Size: size},
|
||||
}, int(partSize), &up)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 先上传前100个分片
|
||||
err = d.uploadPersonalParts(ctx, partInfos, resp.Data.PartInfos, ss, p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 如果还有剩余分片,分批获取上传地址并上传
|
||||
for i := 100; i < len(partInfos); i += 100 {
|
||||
end := min(i+100, len(partInfos))
|
||||
batchPartInfos := partInfos[i:end]
|
||||
|
||||
moredata := base.Json{
|
||||
"fileId": resp.Data.FileId,
|
||||
"uploadId": resp.Data.UploadId,
|
||||
@@ -613,58 +821,29 @@ func (d *Yun139) Put(ctx context.Context, dstDir model.Obj, stream model.FileStr
|
||||
"accountType": 1,
|
||||
},
|
||||
}
|
||||
pathname := "/file/getUploadUrl"
|
||||
var moreresp PersonalUploadUrlResp
|
||||
_, err = d.personalPost(pathname, moredata, &moreresp)
|
||||
_, err = d.newPost(getUploadUrlPath, moredata, &moreresp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
uploadPartInfos = append(uploadPartInfos, moreresp.Data.PartInfos...)
|
||||
}
|
||||
|
||||
// Progress
|
||||
p := driver.NewProgress(size, up)
|
||||
|
||||
rateLimited := driver.NewLimitedUploadStream(ctx, stream)
|
||||
// 上传所有分片
|
||||
for _, uploadPartInfo := range uploadPartInfos {
|
||||
index := uploadPartInfo.PartNumber - 1
|
||||
partSize := partInfos[index].PartSize
|
||||
log.Debugf("[139] uploading part %+v/%+v", index, len(uploadPartInfos))
|
||||
limitReader := io.LimitReader(rateLimited, partSize)
|
||||
|
||||
// Update Progress
|
||||
r := io.TeeReader(limitReader, p)
|
||||
|
||||
req, err := http.NewRequest("PUT", uploadPartInfo.UploadUrl, r)
|
||||
err = d.uploadPersonalParts(ctx, partInfos, moreresp.Data.PartInfos, ss, p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
req.Header.Set("Content-Type", "application/octet-stream")
|
||||
req.Header.Set("Content-Length", fmt.Sprint(partSize))
|
||||
req.Header.Set("Origin", "https://yun.139.com")
|
||||
req.Header.Set("Referer", "https://yun.139.com/")
|
||||
req.ContentLength = partSize
|
||||
|
||||
res, err := base.HttpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_ = res.Body.Close()
|
||||
log.Debugf("[139] uploaded: %+v", res)
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("unexpected status code: %d", res.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// 全部分片上传完毕后,complete
|
||||
data = base.Json{
|
||||
"contentHash": fullHash,
|
||||
"contentHashAlgorithm": "SHA256",
|
||||
"fileId": resp.Data.FileId,
|
||||
"uploadId": resp.Data.UploadId,
|
||||
}
|
||||
_, err = d.personalPost("/file/complete", data, nil)
|
||||
// 家庭云和共享群需要额外的参数
|
||||
if d.isGroup() || d.isFamily() {
|
||||
data["groupId"] = d.CloudID
|
||||
}
|
||||
_, err = d.newPost(completePath, data, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -709,9 +888,11 @@ func (d *Yun139) Put(ctx context.Context, dstDir model.Obj, stream model.FileStr
|
||||
}
|
||||
}
|
||||
return nil
|
||||
case MetaPersonal:
|
||||
fallthrough
|
||||
case MetaFamily:
|
||||
}
|
||||
|
||||
// 旧上传路径
|
||||
switch d.Addition.Type {
|
||||
case MetaPersonal, MetaGroup, MetaFamily:
|
||||
// 处理冲突
|
||||
// 获取文件列表
|
||||
files, err := d.List(ctx, dstDir, model.ListArgs{})
|
||||
@@ -758,13 +939,18 @@ func (d *Yun139) Put(ctx context.Context, dstDir model.Obj, stream model.FileStr
|
||||
},
|
||||
}
|
||||
pathname := "/orchestration/personalCloud/uploadAndDownload/v1.0/pcUploadFileRequest"
|
||||
if d.isFamily() {
|
||||
if d.isFamily() || d.isGroup() {
|
||||
uploadPath := d.dirPath(dstDir)
|
||||
// 共享群的根目录上传路径为 0
|
||||
if d.isGroup() && dstDir.GetID() == d.RootFolderID {
|
||||
uploadPath = "0"
|
||||
}
|
||||
data = d.newJson(base.Json{
|
||||
"fileCount": 1,
|
||||
"manualRename": 2,
|
||||
"operation": 0,
|
||||
"path": path.Join(dstDir.GetPath(), dstDir.GetID()),
|
||||
"seqNo": random.String(32), //序列号不能为空
|
||||
"path": uploadPath,
|
||||
"seqNo": random.String(32), // 序列号不能为空
|
||||
"totalSize": reportSize,
|
||||
"uploadContentList": []base.Json{{
|
||||
"contentName": stream.GetName(),
|
||||
@@ -775,6 +961,7 @@ func (d *Yun139) Put(ctx context.Context, dstDir model.Obj, stream model.FileStr
|
||||
pathname = "/orchestration/familyCloud-rebuild/content/v1.0/getFileUploadURL"
|
||||
}
|
||||
var resp UploadResp
|
||||
log.Debugf("[139] upload request body: %+v", data)
|
||||
_, err = d.post(pathname, data, &resp)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -784,60 +971,86 @@ func (d *Yun139) Put(ctx context.Context, dstDir model.Obj, stream model.FileStr
|
||||
}
|
||||
|
||||
size := stream.GetSize()
|
||||
partSize := d.getPartSize(size)
|
||||
|
||||
// Progress
|
||||
p := driver.NewProgress(size, up)
|
||||
var partSize = d.getPartSize(size)
|
||||
part := size / partSize
|
||||
if size%partSize > 0 {
|
||||
part++
|
||||
} else if part == 0 {
|
||||
part = 1
|
||||
}
|
||||
rateLimited := driver.NewLimitedUploadStream(ctx, stream)
|
||||
|
||||
// StreamSectionReader for per-chunk buffering and retry
|
||||
ss, err := streamPkg.NewStreamSectionReader(&streamPkg.FileStream{
|
||||
Ctx: ctx,
|
||||
Reader: rateLimited,
|
||||
Obj: &model.Object{Size: size},
|
||||
}, int(partSize), &up)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
part := int64(1)
|
||||
if size > partSize {
|
||||
part = (size + partSize - 1) / partSize
|
||||
}
|
||||
for i := int64(0); i < part; i++ {
|
||||
if utils.IsCanceled(ctx) {
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
start := i * partSize
|
||||
byteSize := min(size-start, partSize)
|
||||
|
||||
limitReader := io.LimitReader(rateLimited, byteSize)
|
||||
// Update Progress
|
||||
r := io.TeeReader(limitReader, p)
|
||||
req, err := http.NewRequest("POST", resp.Data.UploadResult.RedirectionURL, r)
|
||||
if err != nil {
|
||||
return err
|
||||
rd, getErr := ss.GetSectionReader(start, byteSize)
|
||||
if getErr != nil {
|
||||
return getErr
|
||||
}
|
||||
|
||||
req = req.WithContext(ctx)
|
||||
req.Header.Set("Content-Type", "text/plain;name="+unicode(stream.GetName()))
|
||||
req.Header.Set("contentSize", strconv.FormatInt(size, 10))
|
||||
req.Header.Set("range", fmt.Sprintf("bytes=%d-%d", start, start+byteSize-1))
|
||||
req.Header.Set("uploadtaskID", resp.Data.UploadResult.UploadTaskID)
|
||||
req.Header.Set("rangeType", "0")
|
||||
req.ContentLength = byteSize
|
||||
err = retry.Do(
|
||||
func() error {
|
||||
if _, err := rd.Seek(0, io.SeekStart); err != nil {
|
||||
return err
|
||||
}
|
||||
req, reqErr := http.NewRequestWithContext(ctx, http.MethodPost, resp.Data.UploadResult.RedirectionURL,
|
||||
io.TeeReader(rd, p))
|
||||
if reqErr != nil {
|
||||
return reqErr
|
||||
}
|
||||
req.Header.Set("Content-Type", "text/plain;name="+unicode(stream.GetName()))
|
||||
req.Header.Set("contentSize", strconv.FormatInt(size, 10))
|
||||
req.Header.Set("range", fmt.Sprintf("bytes=%d-%d", start, start+byteSize-1))
|
||||
req.Header.Set("uploadtaskID", resp.Data.UploadResult.UploadTaskID)
|
||||
req.Header.Set("rangeType", "0")
|
||||
req.ContentLength = byteSize
|
||||
|
||||
res, err := base.HttpClient.Do(req)
|
||||
res, doErr := base.HttpClient.Do(req)
|
||||
if doErr != nil {
|
||||
return doErr
|
||||
}
|
||||
defer res.Body.Close()
|
||||
bodyBytes, readErr := io.ReadAll(res.Body)
|
||||
if readErr != nil {
|
||||
return fmt.Errorf("error reading response body: %v", readErr)
|
||||
}
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("unexpected status code: %d, body: %s", res.StatusCode, string(bodyBytes))
|
||||
}
|
||||
var result InterLayerUploadResult
|
||||
xmlErr := xml.Unmarshal(bodyBytes, &result)
|
||||
if xmlErr != nil {
|
||||
return fmt.Errorf("error parsing XML: %v", xmlErr)
|
||||
}
|
||||
if result.ResultCode != 0 {
|
||||
return fmt.Errorf("upload failed with result code: %d, message: %s", result.ResultCode, result.Msg)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
retry.Context(ctx),
|
||||
retry.Attempts(3),
|
||||
retry.DelayType(retry.BackOffDelay),
|
||||
retry.Delay(time.Second),
|
||||
)
|
||||
ss.FreeSectionReader(rd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if res.StatusCode != http.StatusOK {
|
||||
res.Body.Close()
|
||||
return fmt.Errorf("unexpected status code: %d", res.StatusCode)
|
||||
}
|
||||
bodyBytes, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error reading response body: %v", err)
|
||||
}
|
||||
var result InterLayerUploadResult
|
||||
err = xml.Unmarshal(bodyBytes, &result)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error parsing XML: %v", err)
|
||||
}
|
||||
if result.ResultCode != 0 {
|
||||
return fmt.Errorf("upload failed with result code: %d, message: %s", result.ResultCode, result.Msg)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
@@ -870,4 +1083,24 @@ func (d *Yun139) Other(ctx context.Context, args model.OtherArgs) (interface{},
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Yun139) GetDetails(ctx context.Context) (*model.StorageDetails, error) {
|
||||
if d.UserDomainID == "" {
|
||||
return nil, errs.NotImplement
|
||||
}
|
||||
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,
|
||||
UsedSpace: used,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
var _ driver.Driver = (*Yun139)(nil)
|
||||
|
||||
+9
-2
@@ -7,13 +7,20 @@ import (
|
||||
|
||||
type Addition struct {
|
||||
//Account string `json:"account" required:"true"`
|
||||
Authorization string `json:"authorization" type:"text" required:"true"`
|
||||
Authorization string `json:"authorization" type:"text" help:"Authorization can be used alone. If empty, use username + password; mail_cookies is optional and will be established/updated automatically. Existing mail_cookies can also be used alone for fast login."`
|
||||
Username string `json:"username" help:"Use together with password when Authorization is empty. mail_cookies may be left empty on the first login."`
|
||||
Password string `json:"password" secret:"true" help:"Use together with username when Authorization is empty. mail_cookies may be left empty on the first login."`
|
||||
SmsCode string `json:"sms_code" secret:"true" help:"Fill this only after OpenList reports that a 139 Mail SMS verification code was sent, then save the storage again."`
|
||||
MailCookies string `json:"mail_cookies" type:"text" help:"Optional cookies from mail.10086.cn. Leave empty for a first username/password login; cookies created or updated by password/SMS login are persisted and reused as device context. Existing cookies may also be used alone for fast login."`
|
||||
driver.RootID
|
||||
Type string `json:"type" type:"select" options:"personal_new,family,group,personal" default:"personal_new"`
|
||||
Type string `json:"type" type:"select" options:"personal_new,family,group,personal,share" default:"personal_new"`
|
||||
LinkID string `json:"link_id" type:"text" help:"Multiple shares are separated by commas or new lines. Use link_id#password for password-protected shares."`
|
||||
CloudID string `json:"cloud_id"`
|
||||
UserDomainID string `json:"user_domain_id" help:"ud_id in Cookie, fill in to show disk usage"`
|
||||
CustomUploadPartSize int64 `json:"custom_upload_part_size" type:"number" default:"0" help:"0 for auto"`
|
||||
ReportRealSize bool `json:"report_real_size" type:"bool" default:"true" help:"Enable to report the real file size during upload"`
|
||||
UseLargeThumbnail bool `json:"use_large_thumbnail" type:"bool" default:"false" help:"Enable to use large thumbnail for images"`
|
||||
UseOldStreamUpload bool `json:"use_old_stream_upload" type:"bool" default:"false" help:"Enable to use old stream upload method (not support rapid upload)"`
|
||||
}
|
||||
|
||||
var config = driver.Config{
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package _139
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestShareEntriesAndRefEncoding(t *testing.T) {
|
||||
d := &Yun139{Addition: Addition{
|
||||
Type: MetaShare,
|
||||
LinkID: "share-a,share-b,share-c#pass",
|
||||
}}
|
||||
|
||||
entries := d.shareEntries()
|
||||
if len(entries) != 3 {
|
||||
t.Fatalf("expected 3 share entries, got %d", len(entries))
|
||||
}
|
||||
if entries[2].LinkID != "share-c" || entries[2].Password != "pass" {
|
||||
t.Fatalf("unexpected password share entry: %+v", entries[2])
|
||||
}
|
||||
|
||||
refs := []shareRef{
|
||||
{LinkID: entries[0].LinkID, Password: entries[0].Password, NodeID: "root-a"},
|
||||
{LinkID: entries[2].LinkID, Password: entries[2].Password, NodeID: "root-b"},
|
||||
}
|
||||
encoded := encodeShareRefs(refs)
|
||||
decoded, ok := decodeShareRefs(encoded)
|
||||
if !ok || len(decoded) != len(refs) {
|
||||
t.Fatalf("failed to decode merged share refs: %q", encoded)
|
||||
}
|
||||
for i := range refs {
|
||||
if decoded[i] != refs[i] {
|
||||
t.Fatalf("unexpected decoded ref at %d: %+v", i, decoded[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
+126
-1
@@ -9,6 +9,7 @@ const (
|
||||
MetaFamily string = "family"
|
||||
MetaGroup string = "group"
|
||||
MetaPersonalNew string = "personal_new"
|
||||
MetaShare string = "share"
|
||||
)
|
||||
|
||||
type BaseResp struct {
|
||||
@@ -285,9 +286,61 @@ type PersonalUploadUrlResp struct {
|
||||
}
|
||||
}
|
||||
|
||||
type QueryRoutePolicyResp struct {
|
||||
type ShareCatalog struct {
|
||||
CaID string `json:"caId"`
|
||||
CaName string `json:"caName"`
|
||||
UdTime string `json:"udTime"`
|
||||
}
|
||||
|
||||
type ShareContent struct {
|
||||
CoID string `json:"coId"`
|
||||
CoName string `json:"coName"`
|
||||
CoSize int64 `json:"coSize"`
|
||||
CoType int `json:"coType"`
|
||||
UdTime string `json:"udTime"`
|
||||
CoPath string `json:"coPath"`
|
||||
PresentURL string `json:"presentURL"`
|
||||
DownloadURL string `json:"downloadURL"`
|
||||
}
|
||||
|
||||
type ShareListResp struct {
|
||||
BaseResp
|
||||
Data struct {
|
||||
LKName string `json:"lkName"`
|
||||
Passwd string `json:"password"`
|
||||
CaLst []ShareCatalog `json:"caLst"`
|
||||
CoLst []ShareContent `json:"coLst"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
type ShareContentInfo struct {
|
||||
PresentURL string `json:"presentURL"`
|
||||
DownloadURL string `json:"cdnDownLoadUrl"`
|
||||
}
|
||||
|
||||
type ShareDownloadResp struct {
|
||||
BaseResp
|
||||
Data struct {
|
||||
DownloadURL string `json:"downloadURL"`
|
||||
RedrURL string `json:"redrUrl"`
|
||||
ExtInfo struct {
|
||||
CDNDownloadURL string `json:"cdnDownloadUrl"`
|
||||
} `json:"extInfo"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
type ShareContentInfoResp struct {
|
||||
BaseResp
|
||||
Data struct {
|
||||
ContentInfo ShareContentInfo `json:"contentInfo"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
type QueryRoutePolicyResp struct {
|
||||
Success bool `json:"success"`
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data struct {
|
||||
RoutePolicyList []struct {
|
||||
SiteID string `json:"siteID"`
|
||||
SiteCode string `json:"siteCode"`
|
||||
@@ -310,3 +363,75 @@ type RefreshTokenResp struct {
|
||||
AccessToken string `xml:"accessToken"`
|
||||
Desc string `xml:"desc"`
|
||||
}
|
||||
|
||||
type DiskQuotaDetail struct {
|
||||
BaseResp
|
||||
Data struct {
|
||||
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"`
|
||||
}
|
||||
|
||||
type AndAlbumUploadResp struct {
|
||||
Result struct {
|
||||
ResultCode string `json:"resultCode"`
|
||||
ResultDesc string `json:"resultDesc"`
|
||||
} `json:"result"`
|
||||
UploadResult struct {
|
||||
UploadTaskID string `json:"uploadTaskID"`
|
||||
RedirectionURL string `json:"redirectionUrl"`
|
||||
NewContentIDList []struct {
|
||||
ContentID string `json:"contentID"`
|
||||
ContentName string `json:"contentName"`
|
||||
} `json:"newContentIDList"`
|
||||
} `json:"uploadResult"`
|
||||
}
|
||||
|
||||
type ModifyCloudDocV2Req struct {
|
||||
CatalogType int `json:"catalogType"`
|
||||
CloudID string `json:"cloudID"`
|
||||
CommonAccountInfo struct {
|
||||
Account string `json:"account"`
|
||||
AccountType string `json:"accountType"`
|
||||
} `json:"commonAccountInfo"`
|
||||
DocLibName string `json:"docLibName"`
|
||||
DocLibraryID string `json:"docLibraryID"`
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
type ModifyCloudDocV2Resp struct {
|
||||
Result struct {
|
||||
ResultCode string `json:"resultCode"`
|
||||
ResultDesc string `json:"resultDesc"`
|
||||
} `json:"result"`
|
||||
}
|
||||
|
||||
type CreateBatchOprTaskReq struct {
|
||||
CatalogList []string `json:"catalogList"`
|
||||
CommonAccountInfo struct {
|
||||
Account string `json:"account"`
|
||||
AccountType string `json:"accountType"`
|
||||
} `json:"commonAccountInfo"`
|
||||
ContentList []string `json:"contentList"`
|
||||
DestCatalogID string `json:"destCatalogID"`
|
||||
DestGroupID string `json:"destGroupID"`
|
||||
DestPath string `json:"destPath"`
|
||||
DestType int `json:"destType"`
|
||||
SourceCatalogType int `json:"sourceCatalogType"`
|
||||
SourceCloudID string `json:"sourceCloudID"`
|
||||
SourceType int `json:"sourceType"`
|
||||
TaskType int `json:"taskType"`
|
||||
}
|
||||
|
||||
type CreateBatchOprTaskResp struct {
|
||||
Result struct {
|
||||
ResultCode string `json:"resultCode"`
|
||||
ResultDesc string `json:"resultDesc"`
|
||||
} `json:"result"`
|
||||
TaskID string `json:"taskID"`
|
||||
}
|
||||
|
||||
+1519
-54
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,474 @@
|
||||
package _139
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/OpenListTeam/OpenList/v4/drivers/base"
|
||||
"github.com/go-resty/resty/v2"
|
||||
)
|
||||
|
||||
func useRetryingTestClient(t *testing.T) {
|
||||
t.Helper()
|
||||
oldClient := base.RestyClient
|
||||
base.RestyClient = resty.New().SetRetryCount(3)
|
||||
t.Cleanup(func() {
|
||||
base.RestyClient = oldClient
|
||||
})
|
||||
}
|
||||
|
||||
func TestMergeMailCookiesPreservesExistingOrderAndAppendsNewNames(t *testing.T) {
|
||||
got := mergeMailCookieHeader("z=zv; behaviorid=b; Os_SSo_Sid=old", []*http.Cookie{
|
||||
{Name: "RMKEY", Value: "rm"},
|
||||
{Name: "Os_SSo_Sid", Value: "sid"},
|
||||
{Name: "a", Value: "av"},
|
||||
})
|
||||
want := "z=zv;behaviorid=b;Os_SSo_Sid=sid;RMKEY=rm;a=av"
|
||||
if got != want {
|
||||
t.Fatalf("mergeMailCookieHeader() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractFastLoginCookies(t *testing.T) {
|
||||
sid, rmkey := extractFastLoginCookies("RMKEY=rm; Os_SSo_Sid=sid")
|
||||
if sid != "sid" || rmkey != "rm" {
|
||||
t.Fatalf("extractFastLoginCookies() = %q, %q; want sid, rm", sid, rmkey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialState(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
d Yun139
|
||||
want credentialState
|
||||
err bool
|
||||
}{
|
||||
{
|
||||
name: "authorization",
|
||||
d: Yun139{Addition: Addition{Authorization: " auth "}},
|
||||
want: credentialStateAuthorization,
|
||||
},
|
||||
{
|
||||
name: "full login",
|
||||
d: Yun139{Addition: Addition{
|
||||
MailCookies: "RMKEY=rm; Os_SSo_Sid=sid",
|
||||
Username: "user",
|
||||
Password: "password",
|
||||
}},
|
||||
want: credentialStateFullLogin,
|
||||
},
|
||||
{
|
||||
name: "full login without initial cookies",
|
||||
d: Yun139{Addition: Addition{
|
||||
Username: "user",
|
||||
Password: "password",
|
||||
}},
|
||||
want: credentialStateFullLogin,
|
||||
},
|
||||
{
|
||||
name: "cookies only",
|
||||
d: Yun139{Addition: Addition{MailCookies: "RMKEY=rm; Os_SSo_Sid=sid"}},
|
||||
want: credentialStateCookiesOnly,
|
||||
},
|
||||
{
|
||||
name: "partial password login",
|
||||
d: Yun139{Addition: Addition{Username: "user"}},
|
||||
err: true,
|
||||
},
|
||||
{
|
||||
name: "missing credentials",
|
||||
d: Yun139{},
|
||||
err: true,
|
||||
},
|
||||
{
|
||||
name: "invalid cookie",
|
||||
d: Yun139{Addition: Addition{MailCookies: "invalid-cookie"}},
|
||||
err: true,
|
||||
},
|
||||
{
|
||||
name: "authorization with basic prefix",
|
||||
d: Yun139{Addition: Addition{Authorization: "Basic abc"}},
|
||||
err: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := tt.d.credentialState()
|
||||
if tt.err {
|
||||
if err == nil {
|
||||
t.Fatal("credentialState() expected error")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("credentialState() unexpected error: %v", err)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Fatalf("credentialState() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationLoginObtainsAuthorization(t *testing.T) {
|
||||
if os.Getenv("OPENLIST_139_INTEGRATION") != "1" {
|
||||
t.Skip("set OPENLIST_139_INTEGRATION=1 to run live 139Yun login checks")
|
||||
}
|
||||
base.RestyClient = resty.New().
|
||||
SetHeader("user-agent", base.UserAgent).
|
||||
SetRetryCount(3).
|
||||
SetRetryResetReaders(true).
|
||||
SetTimeout(30 * time.Second)
|
||||
|
||||
username := os.Getenv("OPENLIST_139_USERNAME")
|
||||
password := os.Getenv("OPENLIST_139_PASSWORD")
|
||||
mailCookies := os.Getenv("OPENLIST_139_MAIL_COOKIES")
|
||||
authorization := strings.TrimSpace(os.Getenv("OPENLIST_139_AUTHORIZATION"))
|
||||
|
||||
if authorization != "" {
|
||||
t.Run("authorization", func(t *testing.T) {
|
||||
d := Yun139{Addition: Addition{Authorization: authorization}}
|
||||
state, err := d.credentialState()
|
||||
if err != nil {
|
||||
t.Fatalf("credentialState() unexpected error: %v", err)
|
||||
}
|
||||
if state != credentialStateAuthorization {
|
||||
t.Fatalf("credentialState() = %v, want authorization", state)
|
||||
}
|
||||
if d.Authorization == "" || strings.HasPrefix(strings.ToLower(d.Authorization), "basic ") {
|
||||
t.Fatal("authorization should be present without Basic prefix")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if mailCookies == "" {
|
||||
t.Fatal("OPENLIST_139_MAIL_COOKIES is required")
|
||||
}
|
||||
|
||||
runFastLogin := func(mailCookies string) error {
|
||||
d := Yun139{Addition: Addition{MailCookies: mailCookies}}
|
||||
state, err := d.credentialState()
|
||||
if err != nil {
|
||||
return fmt.Errorf("credentialState() error: %w", err)
|
||||
}
|
||||
if state != credentialStateCookiesOnly {
|
||||
return fmt.Errorf("credentialState() = %v, want cookies only", state)
|
||||
}
|
||||
sid, rmkey := extractFastLoginCookies(d.MailCookies)
|
||||
if sid == "" || rmkey == "" {
|
||||
return fmt.Errorf("mail cookies are missing Os_SSo_Sid or RMKEY")
|
||||
}
|
||||
token, err := d.step2_get_single_token(sid)
|
||||
if err != nil {
|
||||
return fmt.Errorf("step2_get_single_token() error: %w", err)
|
||||
}
|
||||
auth, err := d.step3_third_party_login(token)
|
||||
if err != nil {
|
||||
return fmt.Errorf("step3_third_party_login() error: %w", err)
|
||||
}
|
||||
if auth == "" {
|
||||
return fmt.Errorf("authorization is empty after fast login")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if username == "" || password == "" {
|
||||
t.Fatal("OPENLIST_139_USERNAME and OPENLIST_139_PASSWORD are required for password fallback")
|
||||
}
|
||||
|
||||
var refreshedMailCookies string
|
||||
var generatedAuthorization string
|
||||
t.Run("password login fallback", func(t *testing.T) {
|
||||
d := Yun139{Addition: Addition{
|
||||
MailCookies: mailCookies,
|
||||
Username: username,
|
||||
Password: password,
|
||||
}}
|
||||
state, err := d.credentialState()
|
||||
if err != nil {
|
||||
t.Fatalf("credentialState() unexpected error: %v", err)
|
||||
}
|
||||
if state != credentialStateFullLogin {
|
||||
t.Fatalf("credentialState() = %v, want full login", state)
|
||||
}
|
||||
passId, err := d.step1_password_login()
|
||||
if err != nil {
|
||||
t.Fatalf("step1_password_login() error: %v", err)
|
||||
}
|
||||
token, err := d.step2_get_single_token(passId)
|
||||
if err != nil {
|
||||
t.Fatalf("step2_get_single_token() error: %v", err)
|
||||
}
|
||||
auth, err := d.step3_third_party_login(token)
|
||||
if err != nil {
|
||||
t.Fatalf("step3_third_party_login() error: %v", err)
|
||||
}
|
||||
d.Authorization = auth
|
||||
if auth == "" || d.Authorization == "" {
|
||||
t.Fatal("authorization is empty after password login")
|
||||
}
|
||||
generatedAuthorization = auth
|
||||
refreshedMailCookies = d.MailCookies
|
||||
})
|
||||
|
||||
t.Run("authorization generated by password login", func(t *testing.T) {
|
||||
if generatedAuthorization == "" {
|
||||
t.Fatal("password login did not generate authorization")
|
||||
}
|
||||
d := Yun139{Addition: Addition{Authorization: generatedAuthorization}}
|
||||
state, err := d.credentialState()
|
||||
if err != nil {
|
||||
t.Fatalf("credentialState() unexpected error: %v", err)
|
||||
}
|
||||
if state != credentialStateAuthorization {
|
||||
t.Fatalf("credentialState() = %v, want authorization", state)
|
||||
}
|
||||
if strings.HasPrefix(strings.ToLower(d.Authorization), "basic ") {
|
||||
t.Fatal("authorization should not include Basic prefix")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("mail cookies fast login from input", func(t *testing.T) {
|
||||
sid, rmkey := extractFastLoginCookies(mailCookies)
|
||||
if sid == "" || rmkey == "" {
|
||||
t.Skip("input mail cookies are missing Os_SSo_Sid or RMKEY")
|
||||
}
|
||||
if err := runFastLogin(mailCookies); err != nil {
|
||||
t.Skipf("input mail cookies are stale or unusable: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("mail cookies fast login after password login", func(t *testing.T) {
|
||||
if refreshedMailCookies == "" {
|
||||
t.Fatal("password login did not refresh mail cookies")
|
||||
}
|
||||
if err := runFastLogin(refreshedMailCookies); err != nil {
|
||||
t.Fatalf("refreshed mail cookies fast login failed: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestInvalidAuthorizationDoesNotUseCookieFastLogin(t *testing.T) {
|
||||
d := Yun139{Addition: Addition{
|
||||
Authorization: "not-base64",
|
||||
MailCookies: "Os_SSo_Sid=sid; RMKEY=rmkey",
|
||||
}}
|
||||
|
||||
err := d.refreshToken()
|
||||
if err == nil || !strings.Contains(err.Error(), "password login failed") {
|
||||
t.Fatalf("refreshToken() error = %v, want password login fallback error", err)
|
||||
}
|
||||
if d.Authorization != "not-base64" {
|
||||
t.Fatalf("Authorization = %q, want original invalid value retained", d.Authorization)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSMSSceneForRisk(t *testing.T) {
|
||||
tests := map[string]int{
|
||||
"S025": 1,
|
||||
"S035": 1,
|
||||
"PML401010062": 2,
|
||||
"MW0016": 4,
|
||||
}
|
||||
for riskCode, want := range tests {
|
||||
got, ok := smsSceneForRisk(riskCode)
|
||||
if !ok || got != want {
|
||||
t.Fatalf("smsSceneForRisk(%q) = %d, %t; want %d, true", riskCode, got, ok, want)
|
||||
}
|
||||
}
|
||||
if _, ok := smsSceneForRisk("PICTURE_ONLY"); ok {
|
||||
t.Fatal("smsSceneForRisk() accepted unsupported risk code")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendSMSVerificationCode(t *testing.T) {
|
||||
useRetryingTestClient(t)
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read request body: %v", err)
|
||||
}
|
||||
if r.URL.Query().Get("func") != "login:sendSmsCodeByScene" {
|
||||
t.Errorf("func = %q", r.URL.Query().Get("func"))
|
||||
}
|
||||
if !strings.Contains(string(body), `<string name="scene">1</string>`) {
|
||||
t.Errorf("request body = %s", body)
|
||||
}
|
||||
if !strings.Contains(r.Header.Get("Cookie"), "device=fingerprint") {
|
||||
t.Errorf("Cookie = %q", r.Header.Get("Cookie"))
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{Name: "challenge", Value: "sms"})
|
||||
_, _ = io.WriteString(w, `{"code":"S_OK"}`)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
oldURL := mailSMSURL
|
||||
mailSMSURL = server.URL
|
||||
defer func() { mailSMSURL = oldURL }()
|
||||
|
||||
d := Yun139{Addition: Addition{Username: "18800000000", MailCookies: "device=fingerprint"}}
|
||||
if err := d.sendSMSVerificationCode("S025"); err != nil {
|
||||
t.Fatalf("sendSMSVerificationCode() error = %v", err)
|
||||
}
|
||||
if !strings.Contains(d.MailCookies, "challenge=sms") {
|
||||
t.Fatalf("MailCookies = %q", d.MailCookies)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendSMSVerificationCodeStopsAtPictureChallenge(t *testing.T) {
|
||||
useRetryingTestClient(t)
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = io.WriteString(w, `{"code":"PML401010021"}`)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
oldURL := mailSMSURL
|
||||
mailSMSURL = server.URL
|
||||
defer func() { mailSMSURL = oldURL }()
|
||||
|
||||
d := Yun139{Addition: Addition{Username: "18800000000"}}
|
||||
err := d.sendSMSVerificationCode("S025")
|
||||
if err == nil || !strings.Contains(err.Error(), "requires picture verification") {
|
||||
t.Fatalf("sendSMSVerificationCode() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifySMSCode(t *testing.T) {
|
||||
useRetryingTestClient(t)
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read request body: %v", err)
|
||||
}
|
||||
if r.URL.Query().Get("func") != "/login/inlogin.action" {
|
||||
t.Errorf("func = %q", r.URL.Query().Get("func"))
|
||||
}
|
||||
wantHash := sha1Hash("fetion.com.cn:123456")
|
||||
if !strings.Contains(string(body), `<string name="loginPassword">`+wantHash+`</string>`) {
|
||||
t.Errorf("request body does not contain SMS code hash: %s", body)
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{Name: "RMKEY", Value: "new-rmkey"})
|
||||
_, _ = io.WriteString(w, `{"code":"S_OK","var":{"loginSuccessUrl":"https://mail.10086.cn/?sid=sms-sid"}}`)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
oldURL := mailSMSURL
|
||||
mailSMSURL = server.URL
|
||||
defer func() { mailSMSURL = oldURL }()
|
||||
|
||||
d := Yun139{Addition: Addition{
|
||||
Username: "18800000000",
|
||||
SmsCode: "123456",
|
||||
MailCookies: "challenge=sms",
|
||||
}}
|
||||
sid, err := d.verifySMSCode("S025")
|
||||
if err != nil {
|
||||
t.Fatalf("verifySMSCode() error = %v", err)
|
||||
}
|
||||
if sid != "sms-sid" {
|
||||
t.Fatalf("sid = %q", sid)
|
||||
}
|
||||
if d.SmsCode != "" {
|
||||
t.Fatalf("SmsCode = %q, want cleared", d.SmsCode)
|
||||
}
|
||||
if !strings.Contains(d.MailCookies, "RMKEY=new-rmkey") {
|
||||
t.Fatalf("MailCookies = %q", d.MailCookies)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasswordLoginWithoutInitialCookiesStopsAtRedirectAndPersistsCookies(t *testing.T) {
|
||||
useRetryingTestClient(t)
|
||||
|
||||
var loginRequests, redirectRequests int
|
||||
var server *httptest.Server
|
||||
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/Login/Login.ashx":
|
||||
loginRequests++
|
||||
http.SetCookie(w, &http.Cookie{Name: "RMKEY", Value: "new-rm"})
|
||||
http.SetCookie(w, &http.Cookie{Name: "Os_SSo_Sid", Value: "new-sid"})
|
||||
w.Header().Set("Location", server.URL+"/appmail?sid=single-sid")
|
||||
w.WriteHeader(http.StatusFound)
|
||||
case "/appmail":
|
||||
redirectRequests++
|
||||
w.WriteHeader(http.StatusOK)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
oldURL := mailPasswordURL
|
||||
mailPasswordURL = server.URL + "/Login/Login.ashx"
|
||||
defer func() { mailPasswordURL = oldURL }()
|
||||
|
||||
d := Yun139{Addition: Addition{Username: "18800000000", Password: "password"}}
|
||||
sid, err := d.step1_password_login()
|
||||
if err != nil || sid != "single-sid" {
|
||||
t.Fatalf("sid=%q err=%v", sid, err)
|
||||
}
|
||||
if loginRequests != 1 || redirectRequests != 0 {
|
||||
t.Fatalf("login/redirect requests=%d/%d, want 1/0", loginRequests, redirectRequests)
|
||||
}
|
||||
if !strings.Contains(d.MailCookies, "RMKEY=new-rm") || !strings.Contains(d.MailCookies, "Os_SSo_Sid=new-sid") {
|
||||
t.Fatalf("response cookies were not persisted: %q", d.MailCookies)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasswordLoginReusesRawRefreshedCookies(t *testing.T) {
|
||||
useRetryingTestClient(t)
|
||||
|
||||
var n int
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
n++
|
||||
cookie := r.Header.Get("Cookie")
|
||||
if !strings.Contains(cookie, "JSESSIONID=stale") || !strings.Contains(cookie, "behaviorid=device") {
|
||||
t.Errorf("login %d lost raw cookie context: %q", n, cookie)
|
||||
}
|
||||
if n == 1 {
|
||||
http.SetCookie(w, &http.Cookie{Name: "RMKEY", Value: "rm-1"})
|
||||
http.SetCookie(w, &http.Cookie{Name: "Os_SSo_Sid", Value: "sid-1"})
|
||||
w.Header().Set("Location", "https://mail.10086.cn/?sid=first")
|
||||
} else {
|
||||
if !strings.Contains(cookie, "RMKEY=rm-1") || !strings.Contains(cookie, "Os_SSo_Sid=sid-1") {
|
||||
t.Errorf("second login did not reuse first refreshed cookies: %q", cookie)
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{Name: "RMKEY", Value: "rm-2"})
|
||||
http.SetCookie(w, &http.Cookie{Name: "Os_SSo_Sid", Value: "sid-2"})
|
||||
w.Header().Set("Location", "https://mail.10086.cn/?sid=second")
|
||||
}
|
||||
w.WriteHeader(http.StatusFound)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
oldURL := mailPasswordURL
|
||||
mailPasswordURL = server.URL
|
||||
defer func() { mailPasswordURL = oldURL }()
|
||||
|
||||
d := Yun139{Addition: Addition{Username: "18800000000", Password: "password", MailCookies: "Os_SSo_Sid=old; RMKEY=old; JSESSIONID=stale; behaviorid=device"}}
|
||||
if sid, err := d.step1_password_login(); err != nil || sid != "first" {
|
||||
t.Fatalf("first login sid=%q err=%v", sid, err)
|
||||
}
|
||||
refreshed := d.MailCookies
|
||||
if !strings.Contains(refreshed, "RMKEY=rm-1") || !strings.Contains(refreshed, "Os_SSo_Sid=sid-1") {
|
||||
t.Fatalf("first login did not persist refreshed cookies: %q", refreshed)
|
||||
}
|
||||
d.Authorization = ""
|
||||
d.MailCookies = refreshed
|
||||
if sid, err := d.step1_password_login(); err != nil || sid != "second" {
|
||||
t.Fatalf("second login sid=%q err=%v", sid, err)
|
||||
}
|
||||
if n != 2 {
|
||||
t.Fatalf("login count=%d, want 2", n)
|
||||
}
|
||||
}
|
||||
+15
-2
@@ -9,8 +9,8 @@ import (
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/driver"
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/model"
|
||||
"github.com/OpenListTeam/OpenList/v4/pkg/utils"
|
||||
"github.com/go-resty/resty/v2"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"resty.dev/v3"
|
||||
)
|
||||
|
||||
type Cloud189 struct {
|
||||
@@ -52,7 +52,7 @@ func (d *Cloud189) Link(ctx context.Context, file model.Obj, args model.LinkArgs
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
client := resty.NewWithClient(d.client.Client()).SetRedirectPolicy(
|
||||
client := resty.NewWithClient(d.client.GetClient()).SetRedirectPolicy(
|
||||
resty.RedirectPolicyFunc(func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
}))
|
||||
@@ -194,4 +194,17 @@ func (d *Cloud189) Put(ctx context.Context, dstDir model.Obj, stream model.FileS
|
||||
return d.newUpload(ctx, dstDir, stream, up)
|
||||
}
|
||||
|
||||
func (d *Cloud189) GetDetails(ctx context.Context) (*model.StorageDetails, error) {
|
||||
capacityInfo, err := d.getCapacityInfo(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &model.StorageDetails{
|
||||
DiskUsage: model.DiskUsage{
|
||||
TotalSpace: capacityInfo.CloudCapacityInfo.TotalSize,
|
||||
UsedSpace: capacityInfo.CloudCapacityInfo.UsedSize,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
var _ driver.Driver = (*Cloud189)(nil)
|
||||
|
||||
@@ -83,7 +83,7 @@ func (d *Cloud189) newLogin() error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = utils.Json.Unmarshal(res.Bytes(), &encryptConf)
|
||||
err = utils.Json.Unmarshal(res.Body(), &encryptConf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -118,9 +118,9 @@ func (d *Cloud189) newLogin() error {
|
||||
return err
|
||||
}
|
||||
log.Debugf("189 login resp body: %s", res.String())
|
||||
loginResult := utils.Json.Get(res.Bytes(), "result").ToInt()
|
||||
loginResult := utils.Json.Get(res.Body(), "result").ToInt()
|
||||
if loginResult != 0 {
|
||||
return errors.New(utils.Json.Get(res.Bytes(), "msg").ToString())
|
||||
return errors.New(utils.Json.Get(res.Body(), "msg").ToString())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
package _189
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha1"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/model"
|
||||
"github.com/OpenListTeam/OpenList/v4/pkg/torrent"
|
||||
"github.com/OpenListTeam/OpenList/v4/pkg/utils"
|
||||
)
|
||||
|
||||
// GenerateTorrent 根据上传过程中收集的哈希信息生成包含 CAS 扩展的 torrent 文件
|
||||
func GenerateTorrent(fileName string, fileSize int64, fileMD5 string, sliceMD5s []string, sliceSize int64, pieceHashes []byte) ([]byte, error) {
|
||||
// 计算 sliceMD5
|
||||
sliceMD5 := fileMD5
|
||||
if len(sliceMD5s) > 1 {
|
||||
joined := strings.Join(sliceMD5s, "\n")
|
||||
sliceMD5 = strings.ToUpper(torrent.GetMD5Str(joined))
|
||||
}
|
||||
|
||||
t := torrent.NewTorrent(fileName, fileSize, fileMD5)
|
||||
t.Info.PieceLength = sliceSize
|
||||
t.SetPieces(pieceHashes)
|
||||
t.SetCASInfo(&torrent.CASInfo{
|
||||
FileMD5: fileMD5,
|
||||
SliceMD5: sliceMD5,
|
||||
SliceMD5s: sliceMD5s,
|
||||
SliceSize: sliceSize,
|
||||
Cloud: "189",
|
||||
})
|
||||
|
||||
return t.Encode()
|
||||
}
|
||||
|
||||
// RapidUploadFromTorrent 从 torrent 文件中提取 CAS 信息进行秒传
|
||||
func (d *Cloud189) RapidUploadFromTorrent(ctx context.Context, dstDir model.Obj, torrentData []byte) error {
|
||||
// 解析 torrent
|
||||
t, err := torrent.Decode(torrentData)
|
||||
if err != nil {
|
||||
return fmt.Errorf("解析 torrent 失败: %w", err)
|
||||
}
|
||||
|
||||
// 检查是否包含 CAS 扩展信息
|
||||
if !t.HasCASInfo() {
|
||||
return fmt.Errorf("torrent 不包含 CAS 扩展信息,无法秒传")
|
||||
}
|
||||
|
||||
cas := t.CAS
|
||||
fileName := t.Info.Name
|
||||
fileSize := t.GetTotalSize()
|
||||
|
||||
// 获取 sessionKey
|
||||
sessionKey, err := d.getSessionKey()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
d.sessionKey = sessionKey
|
||||
|
||||
// 初始化上传
|
||||
res, err := d.uploadRequest("/person/initMultiUpload", map[string]string{
|
||||
"parentFolderId": dstDir.GetID(),
|
||||
"fileName": encode(fileName),
|
||||
"fileSize": fmt.Sprint(fileSize),
|
||||
"sliceSize": fmt.Sprint(cas.SliceSize),
|
||||
"lazyCheck": "1",
|
||||
}, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("初始化上传失败: %w", err)
|
||||
}
|
||||
|
||||
uploadFileId := utils.Json.Get(res, "data", "uploadFileId").ToString()
|
||||
|
||||
// 提交上传(使用 CAS 信息秒传)
|
||||
_, err = d.uploadRequest("/person/commitMultiUploadFile", map[string]string{
|
||||
"uploadFileId": uploadFileId,
|
||||
"fileMd5": cas.FileMD5,
|
||||
"sliceMd5": cas.SliceMD5,
|
||||
"lazyCheck": "1",
|
||||
"opertype": "3",
|
||||
}, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("秒传提交失败: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ComputeTorrentFromReader 从 io.Reader 计算并生成 torrent 文件
|
||||
func ComputeTorrentFromReader(reader io.Reader, fileName string, fileSize int64, sliceSize int64) ([]byte, error) {
|
||||
if sliceSize <= 0 {
|
||||
sliceSize = torrent.DefaultPieceSize
|
||||
}
|
||||
|
||||
hw := torrent.NewHashWriter(sliceSize, sliceSize)
|
||||
|
||||
buf := make([]byte, 32*1024)
|
||||
for {
|
||||
n, err := reader.Read(buf)
|
||||
if n > 0 {
|
||||
hw.Write(buf[:n])
|
||||
}
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
hw.Finish()
|
||||
|
||||
fileMD5 := hw.GetFileMD5()
|
||||
sliceMD5s := hw.GetSliceMD5s()
|
||||
pieceHashes := hw.GetPieceHashes()
|
||||
|
||||
return GenerateTorrent(fileName, fileSize, fileMD5, sliceMD5s, sliceSize, pieceHashes)
|
||||
}
|
||||
|
||||
// ComputePieceSHA1 计算单个分片的 SHA-1 哈希
|
||||
func ComputePieceSHA1(data []byte) []byte {
|
||||
h := sha1.Sum(data)
|
||||
return h[:]
|
||||
}
|
||||
|
||||
// ExtractCASFromTorrent 从 torrent 数据中提取 CAS 信息
|
||||
func ExtractCASFromTorrent(torrentData []byte) (*torrent.CASInfo, string, int64, error) {
|
||||
t, err := torrent.Decode(torrentData)
|
||||
if err != nil {
|
||||
return nil, "", 0, fmt.Errorf("解析 torrent 失败: %w", err)
|
||||
}
|
||||
|
||||
if !t.HasCASInfo() {
|
||||
return nil, "", 0, fmt.Errorf("torrent 不包含 CAS 扩展信息")
|
||||
}
|
||||
|
||||
return t.CAS, t.Info.Name, t.GetTotalSize(), nil
|
||||
}
|
||||
|
||||
// GetInfoHashHex 获取 torrent 的 info_hash(十六进制字符串)
|
||||
func GetInfoHashHex(torrentData []byte) (string, error) {
|
||||
t, err := torrent.Decode(torrentData)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(t.InfoHash), nil
|
||||
}
|
||||
@@ -66,3 +66,21 @@ type DownResp struct {
|
||||
ResMessage string `json:"res_message"`
|
||||
FileDownloadUrl string `json:"downloadUrl"`
|
||||
}
|
||||
|
||||
type CapacityResp struct {
|
||||
ResCode int `json:"res_code"`
|
||||
ResMessage string `json:"res_message"`
|
||||
Account string `json:"account"`
|
||||
CloudCapacityInfo struct {
|
||||
FreeSize int64 `json:"freeSize"`
|
||||
MailUsedSize int64 `json:"mail189UsedSize"`
|
||||
TotalSize int64 `json:"totalSize"`
|
||||
UsedSize int64 `json:"usedSize"`
|
||||
} `json:"cloudCapacityInfo"`
|
||||
FamilyCapacityInfo struct {
|
||||
FreeSize int64 `json:"freeSize"`
|
||||
TotalSize int64 `json:"totalSize"`
|
||||
UsedSize int64 `json:"usedSize"`
|
||||
} `json:"familyCapacityInfo"`
|
||||
TotalSize uint64 `json:"totalSize"`
|
||||
}
|
||||
|
||||
+201
-43
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/md5"
|
||||
sha1Pkg "crypto/sha1"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
@@ -18,11 +19,13 @@ import (
|
||||
"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/op"
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/stream"
|
||||
"github.com/OpenListTeam/OpenList/v4/pkg/utils"
|
||||
myrand "github.com/OpenListTeam/OpenList/v4/pkg/utils/random"
|
||||
"github.com/go-resty/resty/v2"
|
||||
jsoniter "github.com/json-iterator/go"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"resty.dev/v3"
|
||||
)
|
||||
|
||||
// do others that not defined in Driver interface
|
||||
@@ -81,7 +84,7 @@ import (
|
||||
// // Enter the verification code manually
|
||||
// //err = message.GetMessenger().WaitSend(message.Message{
|
||||
// // Type: "image",
|
||||
// // Content: "data:image/png;base64," + base64.StdEncoding.EncodeToString(imgRes.Body),
|
||||
// // Content: "data:image/png;base64," + base64.StdEncoding.EncodeToString(imgRes.Body()),
|
||||
// //}, 10)
|
||||
// //if err != nil {
|
||||
// // return err
|
||||
@@ -89,15 +92,15 @@ import (
|
||||
// //vCodeRS, err = message.GetMessenger().WaitReceive(30)
|
||||
// // use ocr api
|
||||
// vRes, err := base.RestyClient.R().SetMultipartField(
|
||||
// "image", "validateCode.png", "image/png", bytes.NewReader(imgRes.Body)).
|
||||
// "image", "validateCode.png", "image/png", bytes.NewReader(imgRes.Body())).
|
||||
// Post(setting.GetStr(conf.OcrApi))
|
||||
// if err != nil {
|
||||
// return err
|
||||
// }
|
||||
// if jsoniter.Get(vRes.Bytes(), "status").ToInt() != 200 {
|
||||
// return errors.New("ocr error:" + jsoniter.Get(vRes.Bytes(), "msg").ToString())
|
||||
// if jsoniter.Get(vRes.Body(), "status").ToInt() != 200 {
|
||||
// return errors.New("ocr error:" + jsoniter.Get(vRes.Body(), "msg").ToString())
|
||||
// }
|
||||
// vCodeRS = jsoniter.Get(vRes.Bytes(), "result").ToString()
|
||||
// vCodeRS = jsoniter.Get(vRes.Body(), "result").ToString()
|
||||
// log.Debugln("code: ", vCodeRS)
|
||||
// }
|
||||
// userRsa := RsaEncode([]byte(d.Username), jRsakey, true)
|
||||
@@ -107,7 +110,7 @@ import (
|
||||
// res, err = d.client.R().
|
||||
// SetHeaders(map[string]string{
|
||||
// "lt": lt,
|
||||
// "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36",
|
||||
// "User-Agent": base.UserAgentNT,
|
||||
// "Referer": "https://open.e.189.cn/",
|
||||
// "accept": "application/json;charset=UTF-8",
|
||||
// }).SetFormData(map[string]string{
|
||||
@@ -128,7 +131,7 @@ import (
|
||||
// if err != nil {
|
||||
// return err
|
||||
// }
|
||||
// err = utils.Json.Unmarshal(res.Bytes(), &loginResp)
|
||||
// err = utils.Json.Unmarshal(res.Body(), &loginResp)
|
||||
// if err != nil {
|
||||
// log.Error(err.Error())
|
||||
// return err
|
||||
@@ -157,7 +160,7 @@ func (d *Cloud189) request(url string, method string, callback base.ReqCallback,
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
//log.Debug(res.String())
|
||||
// log.Debug(res.String())
|
||||
if e.ErrorCode != "" {
|
||||
if e.ErrorCode == "InvalidSessionKey" {
|
||||
err = d.newLogin()
|
||||
@@ -167,10 +170,10 @@ func (d *Cloud189) request(url string, method string, callback base.ReqCallback,
|
||||
return d.request(url, method, callback, resp)
|
||||
}
|
||||
}
|
||||
if jsoniter.Get(res.Bytes(), "res_code").ToInt() != 0 {
|
||||
err = errors.New(jsoniter.Get(res.Bytes(), "res_message").ToString())
|
||||
if jsoniter.Get(res.Body(), "res_code").ToInt() != 0 {
|
||||
err = errors.New(jsoniter.Get(res.Body(), "res_message").ToString())
|
||||
}
|
||||
return res.Bytes(), err
|
||||
return res.Body(), err
|
||||
}
|
||||
|
||||
func (d *Cloud189) getFiles(fileId string) ([]model.Obj, error) {
|
||||
@@ -186,8 +189,8 @@ func (d *Cloud189) getFiles(fileId string) ([]model.Obj, error) {
|
||||
"mediaType": "0",
|
||||
"folderId": fileId,
|
||||
"iconOption": "5",
|
||||
"orderBy": "lastOpTime", //account.OrderBy
|
||||
"descending": "true", //account.OrderDirection
|
||||
"orderBy": "lastOpTime", // account.OrderBy
|
||||
"descending": "true", // account.OrderDirection
|
||||
})
|
||||
}, &resp)
|
||||
if err != nil {
|
||||
@@ -232,7 +235,7 @@ func (d *Cloud189) oldUpload(dstDir model.Obj, file model.FileStreamer) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if utils.Json.Get(res.Bytes(), "MD5").ToString() != "" {
|
||||
if utils.Json.Get(res.Body(), "MD5").ToString() != "" {
|
||||
return nil
|
||||
}
|
||||
log.Debugf(res.String())
|
||||
@@ -297,7 +300,7 @@ func (d *Cloud189) uploadRequest(uri string, form map[string]string, resp interf
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data = res.Bytes()
|
||||
data = res.Body()
|
||||
if utils.Json.Get(data, "code").ToString() != "SUCCESS" {
|
||||
return nil, errors.New(uri + "---" + jsoniter.Get(data, "msg").ToString())
|
||||
}
|
||||
@@ -311,48 +314,107 @@ func (d *Cloud189) newUpload(ctx context.Context, dstDir model.Obj, file model.F
|
||||
}
|
||||
d.sessionKey = sessionKey
|
||||
const DEFAULT int64 = 10485760
|
||||
var count = int64(math.Ceil(float64(file.GetSize()) / float64(DEFAULT)))
|
||||
fileSize := file.GetSize()
|
||||
count := int64(math.Ceil(float64(fileSize) / float64(DEFAULT)))
|
||||
|
||||
res, err := d.uploadRequest("/person/initMultiUpload", map[string]string{
|
||||
// 先计算文件完整MD5和分片MD5,用于秒传判断
|
||||
fileMd5Hex := file.GetHash().GetHash(utils.MD5)
|
||||
sliceMd5Hex := ""
|
||||
md5s := make([]string, 0)
|
||||
|
||||
if len(fileMd5Hex) < utils.MD5.Width {
|
||||
// 没有MD5,先缓存流并同时计算文件MD5和分片MD5
|
||||
fileMd5Hash := md5.New()
|
||||
sliceMd5Hash := md5.New()
|
||||
var finish int64
|
||||
cache, err := file.CacheFullAndWriter(nil, io.MultiWriter(fileMd5Hash, &sliceHashWriter{
|
||||
hash: sliceMd5Hash,
|
||||
md5s: &md5s,
|
||||
sliceSize: DEFAULT,
|
||||
finish: &finish,
|
||||
fileSize: fileSize,
|
||||
up: up,
|
||||
ctx: ctx,
|
||||
}))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 处理最后一个分片的MD5
|
||||
if finish%DEFAULT != 0 || finish == 0 {
|
||||
md5s = append(md5s, strings.ToUpper(hex.EncodeToString(sliceMd5Hash.Sum(nil))))
|
||||
}
|
||||
fileMd5Hex = hex.EncodeToString(fileMd5Hash.Sum(nil))
|
||||
|
||||
// seek回起始位置,供后续上传使用
|
||||
if _, err := cache.Seek(0, io.SeekStart); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// 计算sliceMd5
|
||||
if fileSize > DEFAULT && len(md5s) > 0 {
|
||||
sliceMd5Hex = utils.GetMD5EncodeStr(strings.Join(md5s, "\n"))
|
||||
} else {
|
||||
sliceMd5Hex = fileMd5Hex
|
||||
}
|
||||
|
||||
// 带fileMd5调用initMultiUpload,支持秒传
|
||||
initParams := map[string]string{
|
||||
"parentFolderId": dstDir.GetID(),
|
||||
"fileName": encode(file.GetName()),
|
||||
"fileSize": strconv.FormatInt(file.GetSize(), 10),
|
||||
"fileSize": strconv.FormatInt(fileSize, 10),
|
||||
"sliceSize": strconv.FormatInt(DEFAULT, 10),
|
||||
"lazyCheck": "1",
|
||||
}, nil)
|
||||
"fileMd5": fileMd5Hex,
|
||||
"sliceMd5": sliceMd5Hex,
|
||||
}
|
||||
|
||||
res, err := d.uploadRequest("/person/initMultiUpload", initParams, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
uploadFileId := jsoniter.Get(res, "data", "uploadFileId").ToString()
|
||||
//_, err = d.uploadRequest("/person/getUploadedPartsInfo", map[string]string{
|
||||
// "uploadFileId": uploadFileId,
|
||||
//}, nil)
|
||||
fileDataExists := jsoniter.Get(res, "data", "fileDataExists").ToInt()
|
||||
|
||||
// 秒传成功,直接提交
|
||||
if fileDataExists == 1 {
|
||||
_, err = d.uploadRequest("/person/commitMultiUploadFile", map[string]string{
|
||||
"uploadFileId": uploadFileId,
|
||||
"fileMd5": fileMd5Hex,
|
||||
"sliceMd5": sliceMd5Hex,
|
||||
"lazyCheck": "1",
|
||||
"opertype": "3",
|
||||
}, nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// 非秒传,需要上传分片
|
||||
var finish int64 = 0
|
||||
var i int64
|
||||
var byteSize int64
|
||||
md5s := make([]string, 0)
|
||||
md5Sum := md5.New()
|
||||
|
||||
// 额外计算 SHA-1 piece hash 用于生成 torrent
|
||||
pieceSHA1Hashes := make([]byte, 0, int(count)*20)
|
||||
|
||||
for i = 1; i <= count; i++ {
|
||||
if utils.IsCanceled(ctx) {
|
||||
return ctx.Err()
|
||||
}
|
||||
byteSize = file.GetSize() - finish
|
||||
byteSize = fileSize - finish
|
||||
if DEFAULT < byteSize {
|
||||
byteSize = DEFAULT
|
||||
}
|
||||
//log.Debugf("%d,%d", byteSize, finish)
|
||||
byteData := make([]byte, byteSize)
|
||||
n, err := io.ReadFull(file, byteData)
|
||||
//log.Debug(err, n)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
finish += int64(n)
|
||||
md5Bytes := getMd5(byteData)
|
||||
md5Hex := hex.EncodeToString(md5Bytes)
|
||||
md5Base64 := base64.StdEncoding.EncodeToString(md5Bytes)
|
||||
md5s = append(md5s, strings.ToUpper(md5Hex))
|
||||
md5Sum.Write(byteData)
|
||||
|
||||
// 计算 SHA-1 piece hash
|
||||
sha1Hash := sha1Pkg.Sum(byteData)
|
||||
pieceSHA1Hashes = append(pieceSHA1Hashes, sha1Hash[:]...)
|
||||
var resp UploadUrlsResp
|
||||
res, err = d.uploadRequest("/person/getMultiUploadUrls", map[string]string{
|
||||
"partInfo": fmt.Sprintf("%s-%s", strconv.FormatInt(i, 10), md5Base64),
|
||||
@@ -365,11 +427,10 @@ func (d *Cloud189) newUpload(ctx context.Context, dstDir model.Obj, file model.F
|
||||
log.Debugf("uploadData: %+v", uploadData)
|
||||
requestURL := uploadData.RequestURL
|
||||
uploadHeaders := strings.Split(decodeURIComponent(uploadData.RequestHeader), "&")
|
||||
req, err := http.NewRequest(http.MethodPut, requestURL, driver.NewLimitedUploadStream(ctx, bytes.NewReader(byteData)))
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPut, requestURL, driver.NewLimitedUploadStream(ctx, bytes.NewReader(byteData)))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
for _, v := range uploadHeaders {
|
||||
i := strings.Index(v, "=")
|
||||
req.Header.Set(v[0:i], v[i+1:])
|
||||
@@ -380,19 +441,116 @@ func (d *Cloud189) newUpload(ctx context.Context, dstDir model.Obj, file model.F
|
||||
}
|
||||
log.Debugf("%+v %+v", r, r.Request.Header)
|
||||
_ = r.Body.Close()
|
||||
up(float64(i) * 100 / float64(count))
|
||||
}
|
||||
fileMd5 := hex.EncodeToString(md5Sum.Sum(nil))
|
||||
sliceMd5 := fileMd5
|
||||
if file.GetSize() > DEFAULT {
|
||||
sliceMd5 = utils.GetMD5EncodeStr(strings.Join(md5s, "\n"))
|
||||
up(50 + float64(i)*50/float64(count))
|
||||
}
|
||||
res, err = d.uploadRequest("/person/commitMultiUploadFile", map[string]string{
|
||||
"uploadFileId": uploadFileId,
|
||||
"fileMd5": fileMd5,
|
||||
"sliceMd5": sliceMd5,
|
||||
"fileMd5": fileMd5Hex,
|
||||
"sliceMd5": sliceMd5Hex,
|
||||
"lazyCheck": "1",
|
||||
"opertype": "3",
|
||||
}, nil)
|
||||
return err
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 生成 torrent 文件(异步,不影响上传结果)
|
||||
capturedDstDir := dstDir
|
||||
capturedFileName := file.GetName()
|
||||
capturedFileSize := fileSize
|
||||
capturedFileMd5Hex := fileMd5Hex
|
||||
capturedMd5s := md5s
|
||||
go func() {
|
||||
fileMD5Upper := strings.ToUpper(capturedFileMd5Hex)
|
||||
torrentData, err := GenerateTorrent(capturedFileName, capturedFileSize, fileMD5Upper, capturedMd5s, DEFAULT, pieceSHA1Hashes)
|
||||
if err != nil {
|
||||
log.Warnf("生成 torrent 失败: %v", err)
|
||||
return
|
||||
}
|
||||
infoHash, _ := GetInfoHashHex(torrentData)
|
||||
torrentName := capturedFileName + ".cas.torrent"
|
||||
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 := d.oldUpload(capturedDstDir, torrentFileStream)
|
||||
if uploadErr != nil {
|
||||
log.Warnf("上传 torrent 文件失败: %v", uploadErr)
|
||||
} else {
|
||||
log.Infof("torrent 文件已上传: %s", torrentName)
|
||||
op.Cache.DeleteDirectory(d, capturedDstDir.GetPath())
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Cloud189) getCapacityInfo(ctx context.Context) (*CapacityResp, error) {
|
||||
var resp CapacityResp
|
||||
_, err := d.request("https://cloud.189.cn/api/portal/getUserSizeInfo.action", http.MethodGet, func(req *resty.Request) {
|
||||
req.SetContext(ctx)
|
||||
}, &resp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// sliceHashWriter 在写入过程中按分片大小自动切分并计算每个分片的MD5,
|
||||
// 同时支持进度回调和取消检查。
|
||||
type sliceHashWriter struct {
|
||||
hash io.Writer // 当前分片的MD5 hash
|
||||
md5s *[]string // 收集每个分片的MD5十六进制字符串
|
||||
sliceSize int64 // 分片大小
|
||||
finish *int64 // 已写入的总字节数
|
||||
fileSize int64 // 文件总大小
|
||||
up driver.UpdateProgress
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
func (w *sliceHashWriter) Write(p []byte) (int, error) {
|
||||
if utils.IsCanceled(w.ctx) {
|
||||
return 0, w.ctx.Err()
|
||||
}
|
||||
total := len(p)
|
||||
written := 0
|
||||
for written < total {
|
||||
// 当前分片还能写入的字节数
|
||||
sliceRemain := w.sliceSize - (*w.finish % w.sliceSize)
|
||||
toWrite := int64(total - written)
|
||||
if toWrite > sliceRemain {
|
||||
toWrite = sliceRemain
|
||||
}
|
||||
n, err := w.hash.Write(p[written : written+int(toWrite)])
|
||||
if err != nil {
|
||||
return written, err
|
||||
}
|
||||
written += n
|
||||
*w.finish += int64(n)
|
||||
|
||||
// 当前分片写满,记录MD5并重置
|
||||
if *w.finish%w.sliceSize == 0 {
|
||||
if h, ok := w.hash.(interface{ Sum([]byte) []byte }); ok {
|
||||
*w.md5s = append(*w.md5s, strings.ToUpper(hex.EncodeToString(h.Sum(nil))))
|
||||
}
|
||||
if resetter, ok := w.hash.(interface{ Reset() }); ok {
|
||||
resetter.Reset()
|
||||
}
|
||||
}
|
||||
}
|
||||
// 报告进度(缓存阶段占50%)
|
||||
if w.fileSize > 0 && w.up != nil {
|
||||
w.up(float64(*w.finish) / float64(w.fileSize) * 50)
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
+40
-13
@@ -1,7 +1,6 @@
|
||||
package _189_tv
|
||||
|
||||
import (
|
||||
"container/ring"
|
||||
"context"
|
||||
"net/http"
|
||||
"strconv"
|
||||
@@ -12,18 +11,20 @@ import (
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/driver"
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/errs"
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/model"
|
||||
"resty.dev/v3"
|
||||
"github.com/OpenListTeam/OpenList/v4/pkg/cron"
|
||||
"github.com/go-resty/resty/v2"
|
||||
)
|
||||
|
||||
type Cloud189TV struct {
|
||||
model.Storage
|
||||
Addition
|
||||
client *resty.Client
|
||||
tokenInfo *AppSessionResp
|
||||
uploadThread int
|
||||
familyTransferFolder *ring.Ring
|
||||
cleanFamilyTransferFile func()
|
||||
storageConfig driver.Config
|
||||
client *resty.Client
|
||||
tokenInfo *AppSessionResp
|
||||
uploadThread int
|
||||
storageConfig driver.Config
|
||||
|
||||
TempUuid string
|
||||
cron *cron.Cron // 新增 cron 字段
|
||||
}
|
||||
|
||||
func (y *Cloud189TV) Config() driver.Config {
|
||||
@@ -68,7 +69,7 @@ func (y *Cloud189TV) Init(ctx context.Context) (err error) {
|
||||
// 避免重复登陆
|
||||
if !y.isLogin() || y.Addition.AccessToken == "" {
|
||||
if err = y.login(); err != nil {
|
||||
return
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,10 +80,17 @@ func (y *Cloud189TV) Init(ctx context.Context) (err error) {
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
y.cron = cron.NewCron(time.Minute * 5)
|
||||
y.cron.Do(y.keepAlive)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (y *Cloud189TV) Drop(ctx context.Context) error {
|
||||
if y.cron != nil {
|
||||
y.cron.Stop()
|
||||
y.cron = nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -126,7 +134,7 @@ func (y *Cloud189TV) Link(ctx context.Context, file model.Obj, args model.LinkAr
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
defer res.RawBody().Close()
|
||||
if res.StatusCode() == 302 {
|
||||
downloadUrl.URL = res.Header().Get("location")
|
||||
}
|
||||
@@ -236,7 +244,6 @@ func (y *Cloud189TV) Copy(ctx context.Context, srcObj, dstDir model.Obj) error {
|
||||
FileName: srcObj.GetName(),
|
||||
IsFolder: BoolToNumber(srcObj.IsDir()),
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -270,5 +277,25 @@ func (y *Cloud189TV) Put(ctx context.Context, dstDir model.Obj, stream model.Fil
|
||||
}
|
||||
|
||||
return y.OldUpload(ctx, dstDir, stream, up, isFamily, overwrite)
|
||||
|
||||
}
|
||||
|
||||
func (y *Cloud189TV) GetDetails(ctx context.Context) (*model.StorageDetails, error) {
|
||||
capacityInfo, err := y.getCapacityInfo(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var total, used int64
|
||||
if y.isFamily() {
|
||||
total = capacityInfo.FamilyCapacityInfo.TotalSize
|
||||
used = capacityInfo.FamilyCapacityInfo.UsedSize
|
||||
} else {
|
||||
total = capacityInfo.CloudCapacityInfo.TotalSize
|
||||
used = capacityInfo.CloudCapacityInfo.UsedSize
|
||||
}
|
||||
return &model.StorageDetails{
|
||||
DiskUsage: model.DiskUsage{
|
||||
TotalSpace: total,
|
||||
UsedSpace: used,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
+21
-3
@@ -5,12 +5,15 @@ import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha1"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/OpenListTeam/OpenList/v4/pkg/utils"
|
||||
)
|
||||
|
||||
func clientSuffix() map[string]string {
|
||||
@@ -57,7 +60,13 @@ func timestamp() int64 {
|
||||
|
||||
type Time time.Time
|
||||
|
||||
func (t *Time) UnmarshalJSON(b []byte) error { return t.Unmarshal(b) }
|
||||
func (t *Time) UnmarshalJSON(b []byte) error {
|
||||
var s string
|
||||
if err := json.Unmarshal(b, &s); err != nil {
|
||||
return err
|
||||
}
|
||||
return t.Unmarshal([]byte(s))
|
||||
}
|
||||
func (t *Time) UnmarshalXML(e *xml.Decoder, ee xml.StartElement) error {
|
||||
b, err := e.Token()
|
||||
if err != nil {
|
||||
@@ -72,10 +81,19 @@ func (t *Time) UnmarshalXML(e *xml.Decoder, ee xml.StartElement) error {
|
||||
}
|
||||
func (t *Time) Unmarshal(b []byte) error {
|
||||
bs := strings.Trim(string(b), "\"")
|
||||
// 189 时间串里 AM/PM 前可能使用 U+202F 窄不换行空格或 U+00A0 不换行空格,统一替换为普通空格
|
||||
bs = strings.ReplaceAll(bs, "\u202f", " ")
|
||||
bs = strings.ReplaceAll(bs, "\u00a0", " ")
|
||||
var v time.Time
|
||||
var err error
|
||||
for _, f := range []string{"2006-01-02 15:04:05 -07", "Jan 2, 2006 15:04:05 PM -07"} {
|
||||
v, err = time.ParseInLocation(f, bs+" +08", time.Local)
|
||||
// 189 返回的时间可能自带时区(如 "Aug 11, 2026, 10:37:18 PM +08"),也可能不带,分别尝试
|
||||
for _, s := range []string{bs, bs + " +08"} {
|
||||
for _, f := range []string{"2006-01-02 15:04:05 -07", "Jan 2, 2006 3:04:05 PM -07", "Jan 2, 2006, 3:04:05 PM -07"} {
|
||||
v, err = time.ParseInLocation(f, s, utils.CNLoc)
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package _189_tv
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestTimeUnmarshal(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want time.Time
|
||||
}{
|
||||
{"numeric date", `"2026-08-11 10:37:18"`, time.Date(2026, 8, 11, 10, 37, 18, 0, time.FixedZone("", 8*3600))},
|
||||
{"legacy month date", `"Aug 11, 2026 10:37:18 PM"`, time.Date(2026, 8, 11, 22, 37, 18, 0, time.FixedZone("", 8*3600))},
|
||||
{"new format with tz", `"Aug 11, 2026, 10:37:18 PM +08"`, time.Date(2026, 8, 11, 22, 37, 18, 0, time.FixedZone("", 8*3600))},
|
||||
{"new format no tz", `"Aug 11, 2026, 10:37:18 PM"`, time.Date(2026, 8, 11, 22, 37, 18, 0, time.FixedZone("", 8*3600))},
|
||||
{"narrow no-break space (U+202F)", "\"Aug 12, 2026, 12:35:41\u202fAM +08\"", time.Date(2026, 8, 12, 0, 35, 41, 0, time.FixedZone("", 8*3600))},
|
||||
{"no-break space (U+00A0)", "\"Aug 12, 2026, 12:35:41\u00a0AM +08\"", time.Date(2026, 8, 12, 0, 35, 41, 0, time.FixedZone("", 8*3600))},
|
||||
{"JSON escaped narrow space", `"Sep 4, 2026, 10:59:33\u202fPM +08"`, time.Date(2026, 9, 4, 22, 59, 33, 0, time.FixedZone("", 8*3600))},
|
||||
{"JSON escaped space without timezone", `"Sep 4, 2026, 12:59:33\u00a0AM"`, time.Date(2026, 9, 4, 0, 59, 33, 0, time.FixedZone("", 8*3600))},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var tm Time
|
||||
if err := json.Unmarshal([]byte(tt.input), &tm); err != nil {
|
||||
t.Fatalf("Unmarshal(%s) error: %v", tt.input, err)
|
||||
}
|
||||
if !tt.want.Equal(time.Time(tm)) {
|
||||
t.Fatalf("Unmarshal(%s) = %v, want %v", tt.input, time.Time(tm), tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimeUnmarshalRejectsInvalid(t *testing.T) {
|
||||
var tm Time
|
||||
if err := tm.Unmarshal([]byte("Aug 12, 2026, 25:32:44 AM")); err == nil {
|
||||
t.Fatal("Unmarshal accepted an invalid time")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimeUnmarshalJSONRejectsInvalid(t *testing.T) {
|
||||
for _, input := range []string{`"invalid"`, `123`, `null`, `"Sep 4, 2026, 10:59:33\uZZZZPM +08"`} {
|
||||
t.Run(input, func(t *testing.T) {
|
||||
var tm Time
|
||||
if err := tm.UnmarshalJSON([]byte(input)); err == nil {
|
||||
t.Fatalf("UnmarshalJSON(%s) accepted invalid input", input)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimeUnmarshalXML(t *testing.T) {
|
||||
var tm Time
|
||||
if err := xml.Unmarshal([]byte(`<time>Sep 4, 2026, 10:59:33 PM +08</time>`), &tm); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := time.Date(2026, 9, 4, 22, 59, 33, 0, time.FixedZone("", 8*3600))
|
||||
if !want.Equal(time.Time(tm)) {
|
||||
t.Fatalf("UnmarshalXML = %v, want %v", time.Time(tm), want)
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
type Addition struct {
|
||||
driver.RootID
|
||||
AccessToken string `json:"access_token"`
|
||||
TempUuid string
|
||||
OrderBy string `json:"order_by" type:"select" options:"filename,filesize,lastOpTime" default:"filename"`
|
||||
OrderDirection string `json:"order_direction" type:"select" options:"asc,desc" default:"asc"`
|
||||
Type string `json:"type" type:"select" options:"personal,family" default:"personal"`
|
||||
|
||||
@@ -316,3 +316,21 @@ type BatchTaskConflictTaskInfoResp struct {
|
||||
TaskInfos []BatchTaskInfo
|
||||
TaskType int `json:"taskType"`
|
||||
}
|
||||
|
||||
type CapacityResp struct {
|
||||
ResCode int `json:"res_code"`
|
||||
ResMessage string `json:"res_message"`
|
||||
Account string `json:"account"`
|
||||
CloudCapacityInfo struct {
|
||||
FreeSize int64 `json:"freeSize"`
|
||||
MailUsedSize int64 `json:"mail189UsedSize"`
|
||||
TotalSize int64 `json:"totalSize"`
|
||||
UsedSize int64 `json:"usedSize"`
|
||||
} `json:"cloudCapacityInfo"`
|
||||
FamilyCapacityInfo struct {
|
||||
FreeSize int64 `json:"freeSize"`
|
||||
TotalSize int64 `json:"totalSize"`
|
||||
UsedSize int64 `json:"usedSize"`
|
||||
} `json:"familyCapacityInfo"`
|
||||
TotalSize uint64 `json:"totalSize"`
|
||||
}
|
||||
|
||||
+110
-21
@@ -17,12 +17,13 @@ import (
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/driver"
|
||||
"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/utils"
|
||||
|
||||
"github.com/go-resty/resty/v2"
|
||||
"github.com/google/uuid"
|
||||
jsoniter "github.com/json-iterator/go"
|
||||
"github.com/pkg/errors"
|
||||
"resty.dev/v3"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -65,6 +66,13 @@ func (y *Cloud189TV) AppKeySignatureHeader(url, method string) map[string]string
|
||||
}
|
||||
|
||||
func (y *Cloud189TV) request(url, method string, callback base.ReqCallback, params map[string]string, resp interface{}, isFamily ...bool) ([]byte, error) {
|
||||
return y.requestWithRetry(url, method, callback, params, resp, 0, isFamily...)
|
||||
}
|
||||
|
||||
func (y *Cloud189TV) requestWithRetry(url, method string, callback base.ReqCallback, params map[string]string, resp interface{}, retryCount int, isFamily ...bool) ([]byte, error) {
|
||||
if y.tokenInfo == nil {
|
||||
return nil, fmt.Errorf("login failed")
|
||||
}
|
||||
req := y.client.R().SetQueryParams(clientSuffix())
|
||||
|
||||
if params != nil {
|
||||
@@ -90,14 +98,29 @@ func (y *Cloud189TV) request(url, method string, callback base.ReqCallback, para
|
||||
|
||||
if strings.Contains(res.String(), "userSessionBO is null") ||
|
||||
strings.Contains(res.String(), "InvalidSessionKey") {
|
||||
return nil, errors.New("session expired")
|
||||
// 限制重试次数,避免无限递归
|
||||
if retryCount >= 3 {
|
||||
y.Addition.AccessToken = ""
|
||||
op.MustSaveDriverStorage(y)
|
||||
return nil, errors.New("session expired after retry")
|
||||
}
|
||||
|
||||
// 尝试刷新会话
|
||||
if err := y.refreshSession(); err != nil {
|
||||
// 如果刷新失败,说明AccessToken也已过期,需要重新登录
|
||||
y.Addition.AccessToken = ""
|
||||
op.MustSaveDriverStorage(y)
|
||||
return nil, errors.New("session expired")
|
||||
}
|
||||
// 如果刷新成功,则重试原始请求(增加重试计数)
|
||||
return y.requestWithRetry(url, method, callback, params, resp, retryCount+1, isFamily...)
|
||||
}
|
||||
|
||||
// 处理错误
|
||||
if erron.HasError() {
|
||||
return nil, &erron
|
||||
}
|
||||
return res.Bytes(), nil
|
||||
return res.Body(), nil
|
||||
}
|
||||
|
||||
func (y *Cloud189TV) get(url string, callback base.ReqCallback, resp interface{}, isFamily ...bool) ([]byte, error) {
|
||||
@@ -130,6 +153,7 @@ func (y *Cloud189TV) put(ctx context.Context, url string, headers map[string]str
|
||||
}
|
||||
}
|
||||
|
||||
// 请求完成后http.Client会Close Request.Body
|
||||
resp, err := base.HttpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -152,6 +176,7 @@ func (y *Cloud189TV) put(ctx context.Context, url string, headers map[string]str
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func (y *Cloud189TV) getFiles(ctx context.Context, fileId string, isFamily bool) ([]model.Obj, error) {
|
||||
fullUrl := ApiUrl
|
||||
if isFamily {
|
||||
@@ -159,6 +184,7 @@ func (y *Cloud189TV) getFiles(ctx context.Context, fileId string, isFamily bool)
|
||||
}
|
||||
fullUrl += "/listFiles.action"
|
||||
|
||||
pageSize := 130 // 每一页返回的文件数量
|
||||
res := make([]model.Obj, 0, 130)
|
||||
for pageNum := 1; ; pageNum++ {
|
||||
var resp Cloud189FilesResp
|
||||
@@ -170,7 +196,7 @@ func (y *Cloud189TV) getFiles(ctx context.Context, fileId string, isFamily bool)
|
||||
"mediaAttr": "0",
|
||||
"iconOption": "5",
|
||||
"pageNum": fmt.Sprint(pageNum),
|
||||
"pageSize": "130",
|
||||
"pageSize": fmt.Sprint(pageSize),
|
||||
})
|
||||
if isFamily {
|
||||
r.SetQueryParams(map[string]string{
|
||||
@@ -193,13 +219,22 @@ func (y *Cloud189TV) getFiles(ctx context.Context, fileId string, isFamily bool)
|
||||
if resp.FileListAO.Count == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
FolderCount := len(resp.FileListAO.FolderList) // 当前文件夹总数
|
||||
FileCount := len(resp.FileListAO.FileList) // 当前文件总数
|
||||
PageCount := FolderCount + FileCount // 当前页数总数
|
||||
|
||||
for i := 0; i < len(resp.FileListAO.FolderList); i++ {
|
||||
for i := 0; i < FolderCount; i++ {
|
||||
res = append(res, &resp.FileListAO.FolderList[i])
|
||||
}
|
||||
for i := 0; i < len(resp.FileListAO.FileList); i++ {
|
||||
for i := 0; i < FileCount; i++ {
|
||||
res = append(res, &resp.FileListAO.FileList[i])
|
||||
}
|
||||
|
||||
// 文件数量小于设定数量时跳出
|
||||
if PageCount < pageSize {
|
||||
break
|
||||
}
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
@@ -209,7 +244,7 @@ func (y *Cloud189TV) login() (err error) {
|
||||
var erron RespErr
|
||||
var tokenInfo AppSessionResp
|
||||
if y.Addition.AccessToken == "" {
|
||||
if y.Addition.TempUuid == "" {
|
||||
if y.TempUuid == "" {
|
||||
// 获取登录参数
|
||||
var uuidInfo UuidInfoResp
|
||||
req.SetResult(&uuidInfo).SetError(&erron)
|
||||
@@ -217,9 +252,8 @@ func (y *Cloud189TV) login() (err error) {
|
||||
req.SetHeaders(y.AppKeySignatureHeader(ApiUrl+"/family/manage/getQrCodeUUID.action",
|
||||
http.MethodGet))
|
||||
_, err = req.Execute(http.MethodGet, ApiUrl+"/family/manage/getQrCodeUUID.action")
|
||||
|
||||
if err != nil {
|
||||
return
|
||||
return err
|
||||
}
|
||||
if erron.HasError() {
|
||||
return &erron
|
||||
@@ -228,7 +262,7 @@ func (y *Cloud189TV) login() (err error) {
|
||||
if uuidInfo.Uuid == "" {
|
||||
return errors.New("uuidInfo is empty")
|
||||
}
|
||||
y.Addition.TempUuid = uuidInfo.Uuid
|
||||
y.TempUuid = uuidInfo.Uuid
|
||||
op.MustSaveDriverStorage(y)
|
||||
|
||||
// 展示二维码
|
||||
@@ -256,10 +290,10 @@ func (y *Cloud189TV) login() (err error) {
|
||||
// Signature
|
||||
req.SetHeaders(y.AppKeySignatureHeader(ApiUrl+"/family/manage/qrcodeLoginResult.action",
|
||||
http.MethodGet))
|
||||
req.SetQueryParam("uuid", y.Addition.TempUuid)
|
||||
req.SetQueryParam("uuid", y.TempUuid)
|
||||
_, err = req.Execute(http.MethodGet, ApiUrl+"/family/manage/qrcodeLoginResult.action")
|
||||
if err != nil {
|
||||
return
|
||||
return err
|
||||
}
|
||||
if erron.HasError() {
|
||||
return &erron
|
||||
@@ -268,7 +302,6 @@ func (y *Cloud189TV) login() (err error) {
|
||||
return errors.New("E189AccessToken is empty")
|
||||
}
|
||||
y.Addition.AccessToken = accessTokenResp.E189AccessToken
|
||||
y.Addition.TempUuid = ""
|
||||
}
|
||||
}
|
||||
// 获取SessionKey 和 SessionSecret
|
||||
@@ -280,7 +313,7 @@ func (y *Cloud189TV) login() (err error) {
|
||||
reqb.SetQueryParam("e189AccessToken", y.Addition.AccessToken)
|
||||
_, err = reqb.Execute(http.MethodGet, ApiUrl+"/family/manage/loginFamilyMerge.action")
|
||||
if err != nil {
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
if erron.HasError() {
|
||||
@@ -289,7 +322,45 @@ func (y *Cloud189TV) login() (err error) {
|
||||
|
||||
y.tokenInfo = &tokenInfo
|
||||
op.MustSaveDriverStorage(y)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
// refreshSession 尝试使用现有的 AccessToken 刷新会话
|
||||
func (y *Cloud189TV) refreshSession() (err error) {
|
||||
var erron RespErr
|
||||
var tokenInfo AppSessionResp
|
||||
reqb := y.client.R().SetQueryParams(clientSuffix())
|
||||
reqb.SetResult(&tokenInfo).SetError(&erron)
|
||||
// Signature
|
||||
reqb.SetHeaders(y.AppKeySignatureHeader(ApiUrl+"/family/manage/loginFamilyMerge.action",
|
||||
http.MethodGet))
|
||||
reqb.SetQueryParam("e189AccessToken", y.Addition.AccessToken)
|
||||
_, err = reqb.Execute(http.MethodGet, ApiUrl+"/family/manage/loginFamilyMerge.action")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if erron.HasError() {
|
||||
return &erron
|
||||
}
|
||||
|
||||
y.tokenInfo = &tokenInfo
|
||||
return nil
|
||||
}
|
||||
|
||||
func (y *Cloud189TV) keepAlive() {
|
||||
_, err := y.get(ApiUrl+"/keepUserSession.action", func(r *resty.Request) {
|
||||
r.SetQueryParams(clientSuffix())
|
||||
}, nil)
|
||||
if err != nil {
|
||||
utils.Log.Warnf("189tv: Failed to keep user session alive: %v", err)
|
||||
// 如果keepAlive失败,尝试刷新session
|
||||
if refreshErr := y.refreshSession(); refreshErr != nil {
|
||||
utils.Log.Errorf("189tv: Failed to refresh session after keepAlive error: %v", refreshErr)
|
||||
}
|
||||
} else {
|
||||
utils.Log.Debugf("189tv: User session kept alive successfully.")
|
||||
}
|
||||
}
|
||||
|
||||
func (y *Cloud189TV) RapidUpload(ctx context.Context, dstDir model.Obj, stream model.FileStreamer, isFamily bool, overwrite bool) (model.Obj, error) {
|
||||
@@ -312,11 +383,14 @@ func (y *Cloud189TV) RapidUpload(ctx context.Context, dstDir model.Obj, stream m
|
||||
|
||||
// 旧版本上传,家庭云不支持覆盖
|
||||
func (y *Cloud189TV) OldUpload(ctx context.Context, dstDir model.Obj, file model.FileStreamer, up driver.UpdateProgress, isFamily bool, overwrite bool) (model.Obj, error) {
|
||||
tempFile, err := file.CacheFullInTempFile()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
fileMd5 := file.GetHash().GetHash(utils.MD5)
|
||||
tempFile := file.GetFile()
|
||||
var err error
|
||||
if len(fileMd5) != utils.MD5.Width {
|
||||
tempFile, fileMd5, err = stream.CacheFullAndHash(file, &up, utils.MD5)
|
||||
} else if tempFile == nil {
|
||||
tempFile, err = file.CacheFullAndWriter(&up, nil)
|
||||
}
|
||||
fileMd5, err := utils.HashFile(utils.MD5, tempFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -329,6 +403,10 @@ func (y *Cloud189TV) OldUpload(ctx context.Context, dstDir model.Obj, file model
|
||||
|
||||
// 网盘中不存在该文件,开始上传
|
||||
status := GetUploadFileStatusResp{CreateUploadFileResp: *uploadInfo}
|
||||
// driver.RateLimitReader会尝试Close底层的reader
|
||||
// 但这里的tempFile是一个*os.File,Close后就没法继续读了
|
||||
// 所以这里用io.NopCloser包一层
|
||||
rateLimitedRd := driver.NewLimitedUploadStream(ctx, io.NopCloser(tempFile))
|
||||
for status.GetSize() < file.GetSize() && status.FileDataExists != 1 {
|
||||
if utils.IsCanceled(ctx) {
|
||||
return nil, ctx.Err()
|
||||
@@ -346,7 +424,7 @@ func (y *Cloud189TV) OldUpload(ctx context.Context, dstDir model.Obj, file model
|
||||
header["Edrive-UploadFileId"] = fmt.Sprint(status.UploadFileId)
|
||||
}
|
||||
|
||||
_, err := y.put(ctx, status.FileUploadUrl, header, true, io.NopCloser(tempFile), isFamily)
|
||||
_, err := y.put(ctx, status.FileUploadUrl, header, true, rateLimitedRd, isFamily)
|
||||
if err, ok := err.(*RespErr); ok && err.Code != "InputStreamReadError" {
|
||||
return nil, err
|
||||
}
|
||||
@@ -409,7 +487,6 @@ func (y *Cloud189TV) OldUploadCreate(ctx context.Context, parentID string, fileM
|
||||
})
|
||||
}
|
||||
}, &uploadInfo, isFamily)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -563,3 +640,15 @@ func (y *Cloud189TV) WaitBatchTask(aType string, taskID string, t time.Duration)
|
||||
time.Sleep(t)
|
||||
}
|
||||
}
|
||||
|
||||
func (y *Cloud189TV) getCapacityInfo(ctx context.Context) (*CapacityResp, error) {
|
||||
fullUrl := ApiUrl + "/portal/getUserSizeInfo.action"
|
||||
var resp CapacityResp
|
||||
_, err := y.get(fullUrl, func(req *resty.Request) {
|
||||
req.SetContext(ctx)
|
||||
}, &resp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
+101
-25
@@ -12,21 +12,22 @@ import (
|
||||
"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/utils"
|
||||
"github.com/go-resty/resty/v2"
|
||||
"github.com/google/uuid"
|
||||
"resty.dev/v3"
|
||||
)
|
||||
|
||||
type Cloud189PC struct {
|
||||
model.Storage
|
||||
Addition
|
||||
|
||||
identity string
|
||||
|
||||
client *resty.Client
|
||||
|
||||
loginParam *LoginParam
|
||||
tokenInfo *AppSessionResp
|
||||
loginParam *LoginParam
|
||||
qrcodeParam *QRLoginParam
|
||||
|
||||
tokenInfo *AppSessionResp
|
||||
|
||||
uploadThread int
|
||||
|
||||
@@ -35,6 +36,7 @@ type Cloud189PC struct {
|
||||
|
||||
storageConfig driver.Config
|
||||
ref *Cloud189PC
|
||||
cron *cron.Cron
|
||||
}
|
||||
|
||||
func (y *Cloud189PC) Config() driver.Config {
|
||||
@@ -84,14 +86,27 @@ func (y *Cloud189PC) Init(ctx context.Context) (err error) {
|
||||
})
|
||||
}
|
||||
|
||||
// 避免重复登陆
|
||||
identity := utils.GetMD5EncodeStr(y.Username + y.Password)
|
||||
if !y.isLogin() || y.identity != identity {
|
||||
y.identity = identity
|
||||
// 先尝试用Token刷新,之后尝试登陆
|
||||
if y.Addition.AccessToken != "" {
|
||||
y.tokenInfo = &AppSessionResp{AccessToken: y.Addition.AccessToken, RefreshToken: y.Addition.RefreshToken}
|
||||
if err = y.refreshSession(); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if y.Addition.RefreshToken != "" {
|
||||
y.tokenInfo = &AppSessionResp{RefreshToken: y.Addition.RefreshToken}
|
||||
if err = y.refreshToken(); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err = y.login(); err != nil {
|
||||
return
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化并启动 cron 任务
|
||||
y.cron = cron.NewCron(time.Duration(time.Minute * 5))
|
||||
// 每5分钟执行一次 keepAlive
|
||||
y.cron.Do(y.keepAlive)
|
||||
}
|
||||
|
||||
// 处理家庭云ID
|
||||
@@ -114,7 +129,7 @@ func (y *Cloud189PC) Init(ctx context.Context) (err error) {
|
||||
utils.Log.Errorf("cleanFamilyTransferFolderError:%s", err)
|
||||
}
|
||||
})
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *Cloud189PC) InitReference(storage driver.Driver) error {
|
||||
@@ -128,6 +143,10 @@ func (d *Cloud189PC) InitReference(storage driver.Driver) error {
|
||||
|
||||
func (y *Cloud189PC) Drop(ctx context.Context) error {
|
||||
y.ref = nil
|
||||
if y.cron != nil {
|
||||
y.cron.Stop()
|
||||
y.cron = nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -171,7 +190,7 @@ func (y *Cloud189PC) Link(ctx context.Context, file model.Obj, args model.LinkAr
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
defer res.RawBody().Close()
|
||||
if res.StatusCode() == 302 {
|
||||
downloadUrl.URL = res.Header().Get("location")
|
||||
}
|
||||
@@ -243,6 +262,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
|
||||
}
|
||||
|
||||
@@ -257,29 +286,41 @@ func (y *Cloud189PC) Rename(ctx context.Context, srcObj model.Obj, newName strin
|
||||
queryParam["familyId"] = y.FamilyID
|
||||
}
|
||||
|
||||
var newObj model.Obj
|
||||
switch f := srcObj.(type) {
|
||||
switch srcObj.(type) {
|
||||
case *Cloud189File:
|
||||
fullUrl += "/renameFile.action"
|
||||
queryParam["fileId"] = srcObj.GetID()
|
||||
queryParam["destFileName"] = newName
|
||||
newObj = &Cloud189File{Icon: f.Icon} // 复用预览
|
||||
case *Cloud189Folder:
|
||||
fullUrl += "/renameFolder.action"
|
||||
queryParam["folderId"] = srcObj.GetID()
|
||||
queryParam["destFolderName"] = newName
|
||||
newObj = &Cloud189Folder{}
|
||||
default:
|
||||
return nil, errs.NotSupport
|
||||
}
|
||||
|
||||
var resp RenameResp
|
||||
_, err := y.request(fullUrl, method, func(req *resty.Request) {
|
||||
req.SetContext(ctx).SetQueryParams(queryParam)
|
||||
}, nil, newObj, isFamily)
|
||||
}, nil, &resp, isFamily)
|
||||
if err != nil {
|
||||
if code, ok := resp.ResCode.(string); ok && code == "FileAlreadyExists" {
|
||||
return nil, errs.ObjectAlreadyExists
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return newObj, nil
|
||||
|
||||
// 跟随重命名 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
|
||||
case *Cloud189Folder:
|
||||
return resp.toFolder(), nil
|
||||
}
|
||||
return nil, errs.NotSupport
|
||||
}
|
||||
|
||||
func (y *Cloud189PC) Copy(ctx context.Context, srcObj, dstDir model.Obj) error {
|
||||
@@ -291,11 +332,23 @@ func (y *Cloud189PC) Copy(ctx context.Context, srcObj, dstDir model.Obj) error {
|
||||
FileName: srcObj.GetName(),
|
||||
IsFolder: BoolToNumber(srcObj.IsDir()),
|
||||
})
|
||||
|
||||
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 {
|
||||
@@ -319,6 +372,7 @@ func (y *Cloud189PC) Put(ctx context.Context, dstDir model.Obj, stream model.Fil
|
||||
|
||||
// 响应时间长,按需启用
|
||||
if y.Addition.RapidUpload && !stream.IsForceStreamUpload() {
|
||||
// 尝试妙传
|
||||
if newObj, err := y.RapidUpload(ctx, dstDir, stream, isFamily, overwrite); err == nil {
|
||||
return newObj, nil
|
||||
}
|
||||
@@ -327,10 +381,11 @@ func (y *Cloud189PC) Put(ctx context.Context, dstDir model.Obj, stream model.Fil
|
||||
uploadMethod := y.UploadMethod
|
||||
if stream.IsForceStreamUpload() {
|
||||
uploadMethod = "stream"
|
||||
}
|
||||
|
||||
// 旧版上传家庭云也有限制
|
||||
if uploadMethod == "old" {
|
||||
} else if y.Addition.RapidUpload && stream.GetFile() != nil {
|
||||
// 文件流支持随机读取,走FastUpload计算MD5并尝试秒传
|
||||
uploadMethod = "rapid"
|
||||
} else if uploadMethod == "old" {
|
||||
// 旧版上传家庭云也有限制
|
||||
return y.OldUpload(ctx, dstDir, stream, up, isFamily, overwrite)
|
||||
}
|
||||
|
||||
@@ -397,3 +452,24 @@ func (y *Cloud189PC) Put(ctx context.Context, dstDir model.Obj, stream model.Fil
|
||||
return y.StreamUpload(ctx, dstDir, stream, up, isFamily, overwrite)
|
||||
}
|
||||
}
|
||||
|
||||
func (y *Cloud189PC) GetDetails(ctx context.Context) (*model.StorageDetails, error) {
|
||||
capacityInfo, err := y.getCapacityInfo(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var total, used int64
|
||||
if y.isFamily() {
|
||||
total = capacityInfo.FamilyCapacityInfo.TotalSize
|
||||
used = capacityInfo.FamilyCapacityInfo.UsedSize
|
||||
} else {
|
||||
total = capacityInfo.CloudCapacityInfo.TotalSize
|
||||
used = capacityInfo.CloudCapacityInfo.UsedSize
|
||||
}
|
||||
return &model.StorageDetails{
|
||||
DiskUsage: model.DiskUsage{
|
||||
TotalSpace: total,
|
||||
UsedSpace: used,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
+34
-3
@@ -9,6 +9,7 @@ import (
|
||||
"crypto/sha1"
|
||||
"crypto/x509"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
@@ -19,6 +20,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/model"
|
||||
"github.com/OpenListTeam/OpenList/v4/pkg/utils"
|
||||
"github.com/OpenListTeam/OpenList/v4/pkg/utils/random"
|
||||
)
|
||||
|
||||
@@ -80,6 +82,20 @@ func timestamp() int64 {
|
||||
return time.Now().UTC().UnixNano() / 1e6
|
||||
}
|
||||
|
||||
// formatDate formats a time.Time object into the "YYYY-MM-DDHH:mm:ssSSS" format.
|
||||
func formatDate(t time.Time) string {
|
||||
// The layout string "2006-01-0215:04:05.000" corresponds to:
|
||||
// 2006 -> Year (YYYY)
|
||||
// 01 -> Month (MM)
|
||||
// 02 -> Day (DD)
|
||||
// 15 -> Hour (HH)
|
||||
// 04 -> Minute (mm)
|
||||
// 05 -> Second (ss)
|
||||
// 000 -> Millisecond (SSS) with leading zeros
|
||||
// Note the lack of a separator between the date and hour, matching the desired output.
|
||||
return t.Format("2006-01-0215:04:05.000")
|
||||
}
|
||||
|
||||
func MustParseTime(str string) *time.Time {
|
||||
lastOpTime, _ := time.ParseInLocation("2006-01-02 15:04:05 -07", str+" +08", time.Local)
|
||||
return &lastOpTime
|
||||
@@ -87,7 +103,13 @@ func MustParseTime(str string) *time.Time {
|
||||
|
||||
type Time time.Time
|
||||
|
||||
func (t *Time) UnmarshalJSON(b []byte) error { return t.Unmarshal(b) }
|
||||
func (t *Time) UnmarshalJSON(b []byte) error {
|
||||
var s string
|
||||
if err := json.Unmarshal(b, &s); err != nil {
|
||||
return err
|
||||
}
|
||||
return t.Unmarshal([]byte(s))
|
||||
}
|
||||
func (t *Time) UnmarshalXML(e *xml.Decoder, ee xml.StartElement) error {
|
||||
b, err := e.Token()
|
||||
if err != nil {
|
||||
@@ -102,10 +124,19 @@ func (t *Time) UnmarshalXML(e *xml.Decoder, ee xml.StartElement) error {
|
||||
}
|
||||
func (t *Time) Unmarshal(b []byte) error {
|
||||
bs := strings.Trim(string(b), "\"")
|
||||
// 189 时间串里 AM/PM 前可能使用 U+202F 窄不换行空格或 U+00A0 不换行空格,统一替换为普通空格
|
||||
bs = strings.ReplaceAll(bs, "\u202f", " ")
|
||||
bs = strings.ReplaceAll(bs, "\u00a0", " ")
|
||||
var v time.Time
|
||||
var err error
|
||||
for _, f := range []string{"2006-01-02 15:04:05 -07", "Jan 2, 2006 15:04:05 PM -07"} {
|
||||
v, err = time.ParseInLocation(f, bs+" +08", time.Local)
|
||||
// 189 返回的时间可能自带时区(如 "Aug 11, 2026, 10:37:18 PM +08"),也可能不带,分别尝试
|
||||
for _, s := range []string{bs, bs + " +08"} {
|
||||
for _, f := range []string{"2006-01-02 15:04:05 -07", "Jan 2, 2006 3:04:05 PM -07", "Jan 2, 2006, 3:04:05 PM -07"} {
|
||||
v, err = time.ParseInLocation(f, s, utils.CNLoc)
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package _189pc
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestTimeUnmarshal(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want time.Time
|
||||
}{
|
||||
{"numeric date", `"2026-08-11 10:37:18"`, time.Date(2026, 8, 11, 10, 37, 18, 0, time.FixedZone("", 8*3600))},
|
||||
{"legacy month date", `"Aug 11, 2026 10:37:18 PM"`, time.Date(2026, 8, 11, 22, 37, 18, 0, time.FixedZone("", 8*3600))},
|
||||
{"new format with tz", `"Aug 11, 2026, 10:37:18 PM +08"`, time.Date(2026, 8, 11, 22, 37, 18, 0, time.FixedZone("", 8*3600))},
|
||||
{"new format no tz", `"Aug 11, 2026, 10:37:18 PM"`, time.Date(2026, 8, 11, 22, 37, 18, 0, time.FixedZone("", 8*3600))},
|
||||
{"narrow no-break space (U+202F)", "\"Aug 12, 2026, 12:35:41\u202fAM +08\"", time.Date(2026, 8, 12, 0, 35, 41, 0, time.FixedZone("", 8*3600))},
|
||||
{"no-break space (U+00A0)", "\"Aug 12, 2026, 12:35:41\u00a0AM +08\"", time.Date(2026, 8, 12, 0, 35, 41, 0, time.FixedZone("", 8*3600))},
|
||||
{"JSON escaped narrow space", `"Sep 4, 2026, 10:59:33\u202fPM +08"`, time.Date(2026, 9, 4, 22, 59, 33, 0, time.FixedZone("", 8*3600))},
|
||||
{"JSON escaped space without timezone", `"Sep 4, 2026, 12:59:33\u00a0AM"`, time.Date(2026, 9, 4, 0, 59, 33, 0, time.FixedZone("", 8*3600))},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var tm Time
|
||||
if err := json.Unmarshal([]byte(tt.input), &tm); err != nil {
|
||||
t.Fatalf("Unmarshal(%s) error: %v", tt.input, err)
|
||||
}
|
||||
if !tt.want.Equal(time.Time(tm)) {
|
||||
t.Fatalf("Unmarshal(%s) = %v, want %v", tt.input, time.Time(tm), tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimeUnmarshalRejectsInvalid(t *testing.T) {
|
||||
var tm Time
|
||||
if err := tm.Unmarshal([]byte("Aug 12, 2026, 25:32:44 AM")); err == nil {
|
||||
t.Fatal("Unmarshal accepted an invalid time")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimeUnmarshalJSONRejectsInvalid(t *testing.T) {
|
||||
for _, input := range []string{`"invalid"`, `123`, `null`, `"Sep 4, 2026, 10:59:33\uZZZZPM +08"`} {
|
||||
t.Run(input, func(t *testing.T) {
|
||||
var tm Time
|
||||
if err := tm.UnmarshalJSON([]byte(input)); err == nil {
|
||||
t.Fatalf("UnmarshalJSON(%s) accepted invalid input", input)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimeUnmarshalXML(t *testing.T) {
|
||||
var tm Time
|
||||
if err := xml.Unmarshal([]byte(`<time>Sep 4, 2026, 10:59:33 PM +08</time>`), &tm); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := time.Date(2026, 9, 4, 22, 59, 33, 0, time.FixedZone("", 8*3600))
|
||||
if !want.Equal(time.Time(tm)) {
|
||||
t.Fatalf("UnmarshalXML = %v, want %v", time.Time(tm), want)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user