feat: support tencent cos

This commit is contained in:
hstyi
2025-06-26 18:05:39 +08:00
committed by GitHub
parent e25bd485ac
commit 007318dae3
36 changed files with 853 additions and 450 deletions

View File

@@ -37,6 +37,7 @@ jobs:
# test build # test build
- run: | - run: |
./gradlew build -x test --no-daemon ./gradlew build -x test --no-daemon
./gradlew clean --no-daemon
# dist # dist
- run: | - run: |

View File

@@ -0,0 +1,69 @@
package app.termora.plugins.cos
import app.termora.AuthenticationType
import app.termora.Proxy
import app.termora.ProxyType
import com.qcloud.cos.COSClient
import com.qcloud.cos.ClientConfig
import com.qcloud.cos.auth.BasicCOSCredentials
import com.qcloud.cos.model.Bucket
import com.qcloud.cos.region.Region
import java.io.Closeable
import java.util.concurrent.atomic.AtomicBoolean
class COSClientHandler(
private val cred: BasicCOSCredentials,
private val proxy: Proxy,
val buckets: List<Bucket>
) : Closeable {
companion object {
fun createCOSClient(cred: BasicCOSCredentials, region: String, proxy: Proxy): COSClient {
val clientConfig = ClientConfig()
if (region.isNotBlank()) {
clientConfig.region = Region(region)
}
clientConfig.isPrintShutdownStackTrace = false
if (proxy.type == ProxyType.HTTP) {
clientConfig.httpProxyIp = proxy.host
clientConfig.httpProxyPort = proxy.port
if (proxy.authenticationType == AuthenticationType.Password) {
clientConfig.proxyPassword = proxy.password
clientConfig.proxyUsername = proxy.username
}
}
return COSClient(cred, clientConfig)
}
}
/**
* key: Region
* value: Client
*/
private val clients = mutableMapOf<String, COSClient>()
private val closed = AtomicBoolean(false)
fun getClientForBucket(bucket: String): COSClient {
if (closed.get()) throw IllegalStateException("Client already closed")
synchronized(this) {
val bucket = buckets.first { it.name == bucket }
if (clients.containsKey(bucket.location)) {
return clients.getValue(bucket.location)
}
clients[bucket.location] = createCOSClient(cred, bucket.location, proxy)
return clients.getValue(bucket.location)
}
}
override fun close() {
if (closed.compareAndSet(false, true)) {
synchronized(this) {
clients.forEach { it.value.shutdown() }
clients.clear()
}
}
}
}

View File

@@ -1,41 +0,0 @@
package app.termora.plugins.cos
import org.apache.commons.vfs2.Capability
import org.apache.commons.vfs2.FileName
import org.apache.commons.vfs2.FileSystem
import org.apache.commons.vfs2.FileSystemOptions
import org.apache.commons.vfs2.provider.AbstractOriginatingFileProvider
class COSFileProvider private constructor() : AbstractOriginatingFileProvider() {
companion object {
val instance by lazy { COSFileProvider() }
val capabilities = listOf(
Capability.CREATE,
Capability.DELETE,
Capability.RENAME,
Capability.GET_TYPE,
Capability.LIST_CHILDREN,
Capability.READ_CONTENT,
Capability.URI,
Capability.WRITE_CONTENT,
Capability.GET_LAST_MODIFIED,
Capability.SET_LAST_MODIFIED_FILE,
Capability.RANDOM_ACCESS_READ,
Capability.APPEND_CONTENT
)
}
override fun getCapabilities(): Collection<Capability> {
return COSFileProvider.capabilities
}
override fun doCreateFileSystem(
rootFileName: FileName,
fileSystemOptions: FileSystemOptions
): FileSystem? {
TODO("Not yet implemented")
}
}

View File

@@ -0,0 +1,16 @@
package app.termora.plugins.cos
import app.termora.transfer.s3.S3FileSystem
import org.apache.commons.io.IOUtils
/**
* key: region
*/
class COSFileSystem(private val clientHandler: COSClientHandler) :
S3FileSystem(COSFileSystemProvider(clientHandler)) {
override fun close() {
IOUtils.closeQuietly(clientHandler)
super.close()
}
}

View File

@@ -0,0 +1,142 @@
package app.termora.plugins.cos
import app.termora.transfer.s3.S3FileAttributes
import app.termora.transfer.s3.S3FileSystemProvider
import app.termora.transfer.s3.S3Path
import com.qcloud.cos.model.ListObjectsRequest
import com.qcloud.cos.model.ObjectMetadata
import com.qcloud.cos.model.PutObjectRequest
import org.apache.commons.io.IOUtils
import org.apache.commons.lang3.StringUtils
import java.io.InputStream
import java.io.OutputStream
import java.io.PipedInputStream
import java.io.PipedOutputStream
import java.nio.file.AccessMode
import java.nio.file.NoSuchFileException
import java.util.concurrent.atomic.AtomicReference
import kotlin.io.path.absolutePathString
class COSFileSystemProvider(private val clientHandler: COSClientHandler) : S3FileSystemProvider() {
override fun getScheme(): String? {
return "s3"
}
override fun getOutputStream(path: S3Path): OutputStream {
return createStreamer(path)
}
override fun getInputStream(path: S3Path): InputStream {
val client = clientHandler.getClientForBucket(path.bucketName)
return client.getObject(path.bucketName, path.objectName).objectContent
}
private fun createStreamer(path: S3Path): OutputStream {
val pis = PipedInputStream()
val pos = PipedOutputStream(pis)
val exception = AtomicReference<Throwable>()
val thread = Thread.ofVirtual().start {
try {
val client = clientHandler.getClientForBucket(path.bucketName)
client.putObject(PutObjectRequest(path.bucketName, path.objectName, pis, ObjectMetadata()))
} catch (e: Exception) {
exception.set(e)
} finally {
IOUtils.closeQuietly(pis)
}
}
return object : OutputStream() {
override fun write(b: Int) {
val exception = exception.get()
if (exception != null) throw exception
pos.write(b)
}
override fun close() {
pos.close()
if (thread.isAlive) thread.join()
}
}
}
override fun fetchChildren(path: S3Path): MutableList<S3Path> {
val paths = mutableListOf<S3Path>()
// root
if (path.isRoot) {
for (bucket in clientHandler.buckets) {
val p = path.resolve(bucket.name)
p.attributes = S3FileAttributes(
directory = true,
lastModifiedTime = bucket.creationDate.toInstant().toEpochMilli()
)
paths.add(p)
}
return paths
}
var nextMarker = StringUtils.EMPTY
val maxKeys = 100
val bucketName = path.bucketName
while (true) {
val request = ListObjectsRequest()
.withBucketName(bucketName)
.withMaxKeys(maxKeys)
.withDelimiter(path.fileSystem.separator)
if (path.objectName.isNotBlank()) request.withPrefix(path.objectName + path.fileSystem.separator)
if (nextMarker.isNotBlank()) request.withMarker(nextMarker)
val objectListing = clientHandler.getClientForBucket(bucketName).listObjects(request)
for (e in objectListing.commonPrefixes) {
val p = path.bucket.resolve(e)
p.attributes = p.attributes.copy(directory = true)
delete(p)
paths.add(p)
}
for (e in objectListing.objectSummaries) {
val p = path.bucket.resolve(e.key)
p.attributes = p.attributes.copy(
regularFile = true, size = e.size,
lastModifiedTime = e.lastModified.time
)
paths.add(p)
}
if (objectListing.isTruncated.not()) {
break
}
nextMarker = objectListing.nextMarker
}
paths.addAll(directories[path.absolutePathString()] ?: emptyList())
return paths
}
override fun delete(path: S3Path, isDirectory: Boolean) {
if (isDirectory.not())
clientHandler.getClientForBucket(path.bucketName).deleteObject(path.bucketName, path.objectName)
}
override fun checkAccess(path: S3Path, vararg modes: AccessMode) {
try {
val client = clientHandler.getClientForBucket(path.bucketName)
if (client.doesObjectExist(path.bucketName, path.objectName).not()) {
throw NoSuchFileException(path.objectName)
}
} catch (e: Exception) {
if (e is NoSuchFileException) throw e
throw NoSuchFileException(e.message)
}
}
}

View File

@@ -0,0 +1,313 @@
package app.termora.plugins.cos
import app.termora.*
import app.termora.plugin.internal.BasicProxyOption
import com.formdev.flatlaf.FlatClientProperties
import com.formdev.flatlaf.ui.FlatTextBorder
import com.jgoodies.forms.builder.FormBuilder
import com.jgoodies.forms.layout.FormLayout
import org.apache.commons.lang3.StringUtils
import java.awt.BorderLayout
import java.awt.KeyboardFocusManager
import java.awt.event.ComponentAdapter
import java.awt.event.ComponentEvent
import javax.swing.*
class COSHostOptionsPane : OptionsPane() {
private val generalOption = GeneralOption()
private val proxyOption = BasicProxyOption(listOf(ProxyType.HTTP))
private val sftpOption = SFTPOption()
init {
addOption(generalOption)
addOption(proxyOption)
addOption(sftpOption)
}
fun getHost(): Host {
val name = generalOption.nameTextField.text
val protocol = COSProtocolProvider.PROTOCOL
val port = 0
var authentication = Authentication.Companion.No
var proxy = Proxy.Companion.No
val authenticationType = AuthenticationType.Password
authentication = authentication.copy(
type = authenticationType,
password = String(generalOption.passwordTextField.password)
)
if (proxyOption.proxyTypeComboBox.selectedItem != ProxyType.No) {
proxy = proxy.copy(
type = proxyOption.proxyTypeComboBox.selectedItem as ProxyType,
host = proxyOption.proxyHostTextField.text,
username = proxyOption.proxyUsernameTextField.text,
password = String(proxyOption.proxyPasswordTextField.password),
port = proxyOption.proxyPortTextField.value as Int,
authenticationType = proxyOption.proxyAuthenticationTypeComboBox.selectedItem as AuthenticationType,
)
}
val options = Options.Default.copy(
sftpDefaultDirectory = sftpOption.defaultDirectoryField.text,
extras = mutableMapOf(
"cos.delimiter" to generalOption.delimiterTextField.text,
)
)
return Host(
name = name,
protocol = protocol,
port = port,
username = generalOption.usernameTextField.text,
authentication = authentication,
proxy = proxy,
sort = System.currentTimeMillis(),
remark = generalOption.remarkTextArea.text,
options = options,
)
}
fun setHost(host: Host) {
generalOption.nameTextField.text = host.name
generalOption.usernameTextField.text = host.username
generalOption.remarkTextArea.text = host.remark
generalOption.passwordTextField.text = host.authentication.password
generalOption.delimiterTextField.text = host.options.extras["cos.delimiter"] ?: StringUtils.EMPTY
proxyOption.proxyTypeComboBox.selectedItem = host.proxy.type
proxyOption.proxyHostTextField.text = host.proxy.host
proxyOption.proxyPasswordTextField.text = host.proxy.password
proxyOption.proxyUsernameTextField.text = host.proxy.username
proxyOption.proxyPortTextField.value = host.proxy.port
proxyOption.proxyAuthenticationTypeComboBox.selectedItem = host.proxy.authenticationType
sftpOption.defaultDirectoryField.text = host.options.sftpDefaultDirectory
}
fun validateFields(): Boolean {
val host = getHost()
// general
if (validateField(generalOption.nameTextField)) {
return false
}
if (validateField(generalOption.usernameTextField)) {
return false
}
if (host.authentication.type == AuthenticationType.Password) {
if (validateField(generalOption.passwordTextField)) {
return false
}
}
// proxy
if (host.proxy.type != ProxyType.No) {
if (validateField(proxyOption.proxyHostTextField)
) {
return false
}
if (host.proxy.authenticationType != AuthenticationType.No) {
if (validateField(proxyOption.proxyUsernameTextField)
|| validateField(proxyOption.proxyPasswordTextField)
) {
return false
}
}
}
return true
}
/**
* 返回 true 表示有错误
*/
private fun validateField(textField: JTextField): Boolean {
if (textField.isEnabled && textField.text.isBlank()) {
setOutlineError(textField)
return true
}
return false
}
private fun setOutlineError(c: JComponent) {
selectOptionJComponent(c)
c.putClientProperty(FlatClientProperties.OUTLINE, FlatClientProperties.OUTLINE_ERROR)
c.requestFocusInWindow()
}
private inner class GeneralOption : JPanel(BorderLayout()), Option {
val nameTextField = OutlineTextField(128)
val usernameTextField = OutlineTextField(128)
val passwordTextField = OutlinePasswordField(255)
val remarkTextArea = FixedLengthTextArea(512)
// val regionComboBox = OutlineComboBox<String>()
val delimiterTextField = OutlineTextField(128)
init {
initView()
initEvents()
}
private fun initView() {
/*regionComboBox.addItem("ap-beijing-1")
regionComboBox.addItem("ap-beijing")
regionComboBox.addItem("ap-nanjing")
regionComboBox.addItem("ap-shanghai")
regionComboBox.addItem("ap-guangzhou")
regionComboBox.addItem("ap-chengdu")
regionComboBox.addItem("ap-chongqing")
regionComboBox.addItem("ap-shenzhen-fsi")
regionComboBox.addItem("ap-shanghai-fsi")
regionComboBox.addItem("ap-beijing-fsi")
regionComboBox.addItem("ap-hongkong")
regionComboBox.addItem("ap-singapore")
regionComboBox.addItem("ap-jakarta")
regionComboBox.addItem("ap-seoul")
regionComboBox.addItem("ap-bangkok")
regionComboBox.addItem("ap-tokyo")
regionComboBox.addItem("na-siliconvalley")
regionComboBox.addItem("na-ashburn")
regionComboBox.addItem("sa-saopaulo")
regionComboBox.addItem("eu-frankfurt")
regionComboBox.isEditable = true*/
delimiterTextField.text = "/"
delimiterTextField.isEditable = false
add(getCenterComponent(), BorderLayout.CENTER)
}
private fun initEvents() {
addComponentListener(object : ComponentAdapter() {
override fun componentResized(e: ComponentEvent) {
SwingUtilities.invokeLater { nameTextField.requestFocusInWindow() }
removeComponentListener(this)
}
})
}
override fun getIcon(isSelected: Boolean): Icon {
return Icons.settings
}
override fun getTitle(): String {
return I18n.getString("termora.new-host.general")
}
override fun getJComponent(): JComponent {
return this
}
private fun getCenterComponent(): JComponent {
val layout = FormLayout(
"left:pref, $FORM_MARGIN, default:grow, $FORM_MARGIN, pref, $FORM_MARGIN, default",
"pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref"
)
remarkTextArea.setFocusTraversalKeys(
KeyboardFocusManager.FORWARD_TRAVERSAL_KEYS,
KeyboardFocusManager.getCurrentKeyboardFocusManager()
.getDefaultFocusTraversalKeys(KeyboardFocusManager.FORWARD_TRAVERSAL_KEYS)
)
remarkTextArea.setFocusTraversalKeys(
KeyboardFocusManager.BACKWARD_TRAVERSAL_KEYS,
KeyboardFocusManager.getCurrentKeyboardFocusManager()
.getDefaultFocusTraversalKeys(KeyboardFocusManager.BACKWARD_TRAVERSAL_KEYS)
)
remarkTextArea.rows = 8
remarkTextArea.lineWrap = true
remarkTextArea.border = BorderFactory.createEmptyBorder(4, 4, 4, 4)
var rows = 1
val step = 2
val panel = FormBuilder.create().layout(layout)
.add("${I18n.getString("termora.new-host.general.name")}:").xy(1, rows)
.add(nameTextField).xyw(3, rows, 5).apply { rows += step }
.add("SecretId:").xy(1, rows)
.add(usernameTextField).xyw(3, rows, 5).apply { rows += step }
.add("SecretKey:").xy(1, rows)
.add(passwordTextField).xyw(3, rows, 5).apply { rows += step }
.add("Delimiter:").xy(1, rows)
.add(delimiterTextField).xyw(3, rows, 5).apply { rows += step }
.add("${I18n.getString("termora.new-host.general.remark")}:").xy(1, rows)
.add(JScrollPane(remarkTextArea).apply { border = FlatTextBorder() })
.xyw(3, rows, 5).apply { rows += step }
.build()
return panel
}
}
private inner class SFTPOption : JPanel(BorderLayout()), Option {
val defaultDirectoryField = OutlineTextField(255)
init {
initView()
initEvents()
}
private fun initView() {
add(getCenterComponent(), BorderLayout.CENTER)
}
private fun initEvents() {
}
override fun getIcon(isSelected: Boolean): Icon {
return Icons.folder
}
override fun getTitle(): String {
return I18n.getString("termora.transport.sftp")
}
override fun getJComponent(): JComponent {
return this
}
private fun getCenterComponent(): JComponent {
val layout = FormLayout(
"left:pref, $FORM_MARGIN, default:grow",
"pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref"
)
var rows = 1
val step = 2
val panel = FormBuilder.create().layout(layout)
.add("${I18n.getString("termora.settings.sftp.default-directory")}:").xy(1, rows)
.add(defaultDirectoryField).xy(3, rows).apply { rows += step }
.build()
return panel
}
}
}

View File

@@ -1,8 +1,5 @@
package app.termora.plugins.cos package app.termora.plugins.cos
import app.termora.DynamicIcon
import app.termora.I18n
import app.termora.Icons
import app.termora.plugin.Extension import app.termora.plugin.Extension
import app.termora.plugin.ExtensionSupport import app.termora.plugin.ExtensionSupport
import app.termora.plugin.PaidPlugin import app.termora.plugin.PaidPlugin
@@ -13,8 +10,8 @@ class COSPlugin : PaidPlugin {
private val support = ExtensionSupport() private val support = ExtensionSupport()
init { init {
support.addExtension(ProtocolProviderExtension::class.java) { COSProtocolProviderExtension.Companion.instance } support.addExtension(ProtocolProviderExtension::class.java) { COSProtocolProviderExtension.instance }
support.addExtension(ProtocolHostPanelExtension::class.java) { COSProtocolHostPanelExtension.Companion.instance } support.addExtension(ProtocolHostPanelExtension::class.java) { COSProtocolHostPanelExtension.instance }
} }
override fun getAuthor(): String { override fun getAuthor(): String {

View File

@@ -1,22 +1,36 @@
package app.termora.plugins.cos package app.termora.plugins.cos
import app.termora.Disposer
import app.termora.Host import app.termora.Host
import app.termora.protocol.ProtocolHostPanel import app.termora.protocol.ProtocolHostPanel
import org.apache.commons.lang3.StringUtils import java.awt.BorderLayout
class COSProtocolHostPanel : ProtocolHostPanel() { class COSProtocolHostPanel : ProtocolHostPanel() {
private val pane = COSHostOptionsPane()
init {
initView()
initEvents()
}
private fun initView() {
add(pane, BorderLayout.CENTER)
Disposer.register(this, pane)
}
private fun initEvents() {}
override fun getHost(): Host { override fun getHost(): Host {
return Host( return pane.getHost()
name = StringUtils.EMPTY,
protocol = COSProtocolProvider.Companion.PROTOCOL
)
} }
override fun setHost(host: Host) { override fun setHost(host: Host) {
pane.setHost(host)
} }
override fun validateFields(): Boolean { override fun validateFields(): Boolean {
return true return pane.validateFields()
} }
} }

View File

@@ -10,7 +10,7 @@ class COSProtocolHostPanelExtension private constructor() : ProtocolHostPanelExt
} }
override fun getProtocolProvider(): ProtocolProvider { override fun getProtocolProvider(): ProtocolProvider {
return COSProtocolProvider.Companion.instance return COSProtocolProvider.instance
} }
override fun createProtocolHostPanel(): ProtocolHostPanel { override fun createProtocolHostPanel(): ProtocolHostPanel {

View File

@@ -2,10 +2,14 @@ package app.termora.plugins.cos
import app.termora.DynamicIcon import app.termora.DynamicIcon
import app.termora.Icons import app.termora.Icons
import app.termora.protocol.FileObjectHandler import app.termora.protocol.PathHandler
import app.termora.protocol.FileObjectRequest import app.termora.protocol.PathHandlerRequest
import app.termora.protocol.TransferProtocolProvider import app.termora.protocol.TransferProtocolProvider
import org.apache.commons.vfs2.provider.FileProvider import com.qcloud.cos.ClientConfig
import com.qcloud.cos.auth.BasicCOSCredentials
import com.qcloud.cos.model.Bucket
import org.apache.commons.lang3.StringUtils
class COSProtocolProvider private constructor() : TransferProtocolProvider { class COSProtocolProvider private constructor() : TransferProtocolProvider {
@@ -22,12 +26,30 @@ class COSProtocolProvider private constructor() : TransferProtocolProvider {
return Icons.tencent return Icons.tencent
} }
override fun getFileProvider(): FileProvider { override fun createPathHandler(requester: PathHandlerRequest): PathHandler {
return COSFileProvider.instance val host = requester.host
val secretId = host.username
val secretKey = host.authentication.password
val cred = BasicCOSCredentials(secretId, secretKey)
val clientConfig = ClientConfig()
clientConfig.isPrintShutdownStackTrace = false
val cosClient = COSClientHandler.createCOSClient(cred, StringUtils.EMPTY, host.proxy)
val buckets: List<Bucket>
try {
buckets = cosClient.listBuckets()
if (buckets.isEmpty()) {
throw IllegalStateException("没有获取到桶信息")
}
} finally {
cosClient.shutdown()
}
val defaultPath = host.options.sftpDefaultDirectory
val fs = COSFileSystem(COSClientHandler(cred, host.proxy, buckets))
return PathHandler(fs, fs.getPath(defaultPath))
} }
override fun getRootFileObject(requester: FileObjectRequest): FileObjectHandler {
TODO("Not yet implemented")
}
} }

View File

@@ -9,6 +9,6 @@ class COSProtocolProviderExtension private constructor() : ProtocolProviderExten
} }
override fun getProtocolProvider(): ProtocolProvider { override fun getProtocolProvider(): ProtocolProvider {
return COSProtocolProvider.Companion.instance return COSProtocolProvider.instance
} }
} }

View File

@@ -3,7 +3,7 @@ plugins {
} }
project.version = "0.0.4" project.version = "0.0.5"
dependencies { dependencies {

View File

@@ -0,0 +1,13 @@
package app.termora.plugins.s3
import app.termora.transfer.s3.S3FileSystem
import io.minio.MinioClient
class MyS3FileSystem(private val minioClient: MinioClient) :
S3FileSystem(MyS3FileSystemProvider(minioClient)) {
override fun close() {
minioClient.close()
super.close()
}
}

View File

@@ -0,0 +1,157 @@
package app.termora.plugins.s3
import app.termora.transfer.s3.S3FileAttributes
import app.termora.transfer.s3.S3FileSystemProvider
import app.termora.transfer.s3.S3Path
import io.minio.*
import io.minio.errors.ErrorResponseException
import org.apache.commons.io.IOUtils
import org.apache.commons.lang3.StringUtils
import java.io.InputStream
import java.io.OutputStream
import java.io.PipedInputStream
import java.io.PipedOutputStream
import java.nio.file.AccessMode
import java.nio.file.NoSuchFileException
import java.util.concurrent.atomic.AtomicReference
import kotlin.io.path.absolutePathString
class MyS3FileSystemProvider(private val minioClient: MinioClient) : S3FileSystemProvider() {
override fun getScheme(): String? {
return "s3"
}
override fun getOutputStream(path: S3Path): OutputStream {
return createStreamer(path)
}
override fun getInputStream(path: S3Path): InputStream {
return minioClient.getObject(
GetObjectArgs.builder().bucket(path.bucketName)
.`object`(path.objectName).build()
)
}
private fun createStreamer(path: S3Path): OutputStream {
val pis = PipedInputStream()
val pos = PipedOutputStream(pis)
val exception = AtomicReference<Throwable>()
val thread = Thread.ofVirtual().start {
try {
minioClient.putObject(
PutObjectArgs.builder()
.bucket(path.bucketName)
.stream(pis, -1, 32 * 1024 * 1024)
.`object`(path.objectName).build()
)
} catch (e: Exception) {
exception.set(e)
} finally {
IOUtils.closeQuietly(pis)
}
}
return object : OutputStream() {
override fun write(b: Int) {
val exception = exception.get()
if (exception != null) throw exception
pos.write(b)
}
override fun close() {
pos.close()
if (thread.isAlive) thread.join()
}
}
}
override fun fetchChildren(path: S3Path): MutableList<S3Path> {
val paths = mutableListOf<S3Path>()
// root
if (path.isRoot) {
for (bucket in minioClient.listBuckets()) {
val p = path.resolve(bucket.name())
p.attributes = S3FileAttributes(
directory = true,
lastModifiedTime = bucket.creationDate().toInstant().toEpochMilli()
)
paths.add(p)
}
return paths
}
var startAfter = StringUtils.EMPTY
val maxKeys = 100
while (true) {
val builder = ListObjectsArgs.builder()
.bucket(path.bucketName)
.maxKeys(maxKeys)
.delimiter(path.fileSystem.separator)
if (path.objectName.isNotBlank()) builder.prefix(path.objectName + path.fileSystem.separator)
if (startAfter.isNotBlank()) builder.startAfter(startAfter)
val subPaths = mutableListOf<S3Path>()
for (e in minioClient.listObjects(builder.build())) {
val item = e.get()
val p = path.bucket.resolve(item.objectName())
var attributes = p.attributes.copy(
directory = item.isDir,
regularFile = item.isDir.not(),
size = item.size()
)
if (item.lastModified() != null) {
attributes = attributes.copy(lastModifiedTime = item.lastModified().toInstant().toEpochMilli())
}
p.attributes = attributes
// 如果是文件夹,那么就要删除内存中的
if (attributes.isDirectory) {
delete(p)
}
subPaths.add(p)
startAfter = item.objectName()
}
paths.addAll(subPaths)
if (subPaths.size < maxKeys)
break
}
paths.addAll(directories[path.absolutePathString()] ?: emptyList())
return paths
}
override fun delete(path: S3Path, isDirectory: Boolean) {
if (isDirectory.not())
minioClient.removeObject(
RemoveObjectArgs.builder().bucket(path.bucketName).`object`(path.objectName).build()
)
}
override fun checkAccess(path: S3Path, vararg modes: AccessMode) {
try {
minioClient.statObject(
StatObjectArgs.builder()
.`object`(path.objectName)
.bucket(path.bucketName).build()
)
} catch (e: ErrorResponseException) {
throw NoSuchFileException(e.errorResponse().message())
}
}
}

View File

@@ -70,7 +70,7 @@ class S3ProtocolProvider private constructor() : TransferProtocolProvider {
// val delimiter = host.options.extras["s3.delimiter"] ?: "/" // val delimiter = host.options.extras["s3.delimiter"] ?: "/"
val defaultPath = host.options.sftpDefaultDirectory val defaultPath = host.options.sftpDefaultDirectory
val minioClient = builder.build() val minioClient = builder.build()
val fs = S3FileSystem(minioClient) val fs = MyS3FileSystem(minioClient)
return PathHandler(fs, fs.getPath(defaultPath)) return PathHandler(fs, fs.getPath(defaultPath))
} }

View File

@@ -58,7 +58,7 @@ class S3FileSystemTest {
} }
} }
val fileSystem = S3FileSystem(minioClient) val fileSystem = MyS3FileSystem(minioClient)
val path = fileSystem.getPath("/") val path = fileSystem.getPath("/")
PathWalker.walkFileTree(path, object : PathVisitor { PathWalker.walkFileTree(path, object : PathVisitor {
override fun preVisitDirectory( override fun preVisitDirectory(

View File

@@ -5,7 +5,7 @@ rootProject.name = "termora"
include("plugins:s3") include("plugins:s3")
//include("plugins:oss") //include("plugins:oss")
//include("plugins:cos") include("plugins:cos")
//include("plugins:obs") //include("plugins:obs")
//include("plugins:ftp") //include("plugins:ftp")
include("plugins:bg") include("plugins:bg")

View File

@@ -10,7 +10,8 @@ import java.awt.Component
import java.awt.event.ItemEvent import java.awt.event.ItemEvent
import javax.swing.* import javax.swing.*
class BasicProxyOption : JPanel(BorderLayout()), Option { class BasicProxyOption(private val proxyTypes: List<ProxyType> = listOf(ProxyType.HTTP, ProxyType.SOCKS5)) :
JPanel(BorderLayout()), Option {
private val formMargin = "7dlu" private val formMargin = "7dlu"
val proxyTypeComboBox = FlatComboBox<ProxyType>() val proxyTypeComboBox = FlatComboBox<ProxyType>()
@@ -61,8 +62,9 @@ class BasicProxyOption : JPanel(BorderLayout()), Option {
} }
proxyTypeComboBox.addItem(ProxyType.No) proxyTypeComboBox.addItem(ProxyType.No)
proxyTypeComboBox.addItem(ProxyType.HTTP) for (type in proxyTypes) {
proxyTypeComboBox.addItem(ProxyType.SOCKS5) proxyTypeComboBox.addItem(type)
}
proxyAuthenticationTypeComboBox.addItem(AuthenticationType.No) proxyAuthenticationTypeComboBox.addItem(AuthenticationType.No)
proxyAuthenticationTypeComboBox.addItem(AuthenticationType.Password) proxyAuthenticationTypeComboBox.addItem(AuthenticationType.Password)

View File

@@ -1,9 +1,12 @@
package app.termora.protocol package app.termora.protocol
import app.termora.Disposable import app.termora.Disposable
import org.apache.commons.io.IOUtils
import java.nio.file.FileSystem import java.nio.file.FileSystem
import java.nio.file.Path import java.nio.file.Path
open class PathHandler(val fileSystem: FileSystem, val path: Path) : Disposable { open class PathHandler(val fileSystem: FileSystem, val path: Path) : Disposable {
override fun dispose() {
IOUtils.closeQuietly(fileSystem)
}
} }

View File

@@ -442,11 +442,6 @@ class TransferVisualWindow(tab: SSHTerminalTab, visualWindowManager: VisualWindo
override fun getWorkdir(): Path? { override fun getWorkdir(): Path? {
return transportPanel.workdir return transportPanel.workdir
} }
override fun getTableModel(): TransportTableModel? {
return transportPanel.getTableModel()
}
} }
} }
@@ -457,7 +452,6 @@ class TransferVisualWindow(tab: SSHTerminalTab, visualWindowManager: VisualWindo
transferManager, transferManager,
object : DefaultInternalTransferManager.WorkdirProvider { object : DefaultInternalTransferManager.WorkdirProvider {
override fun getWorkdir() = null override fun getWorkdir() = null
override fun getTableModel() = null
}, },
createWorkdirProvider(transportPanel) createWorkdirProvider(transportPanel)
) )

View File

@@ -31,6 +31,7 @@ import kotlin.collections.ArrayDeque
import kotlin.collections.List import kotlin.collections.List
import kotlin.collections.Set import kotlin.collections.Set
import kotlin.collections.isNotEmpty import kotlin.collections.isNotEmpty
import kotlin.io.path.exists
import kotlin.io.path.name import kotlin.io.path.name
import kotlin.io.path.pathString import kotlin.io.path.pathString
import kotlin.math.max import kotlin.math.max
@@ -49,7 +50,6 @@ class DefaultInternalTransferManager(
interface WorkdirProvider { interface WorkdirProvider {
fun getWorkdir(): Path? fun getWorkdir(): Path?
fun getTableModel(): TransportTableModel?
} }
@@ -85,19 +85,14 @@ class DefaultInternalTransferManager(
if (paths.isEmpty()) return CompletableFuture.completedFuture(Unit) if (paths.isEmpty()) return CompletableFuture.completedFuture(Unit)
val future = CompletableFuture<Unit>() val future = CompletableFuture<Unit>()
val tableModel = when (targetWorkdir.fileSystem) {
source.getWorkdir()?.fileSystem -> source.getTableModel()
target.getWorkdir()?.fileSystem -> target.getTableModel()
else -> null
}
coroutineScope.launch(Dispatchers.IO) { coroutineScope.launch(Dispatchers.IO) {
try { try {
val context = AskTransferContext(TransferAction.Overwrite, false) val context = AskTransferContext(TransferAction.Overwrite, false)
for (pair in paths) { for (pair in paths) {
if (mode == TransferMode.Transfer && tableModel != null && context.applyAll.not()) { if (mode == TransferMode.Transfer && context.applyAll.not()) {
val action = withContext(Dispatchers.Swing) { val action = withContext(Dispatchers.Swing) {
getTransferAction(context, tableModel, pair.second) getTransferAction(context, targetWorkdir.resolve(pair.first.name), pair.second)
} }
if (action == null) { if (action == null) {
break break
@@ -143,21 +138,19 @@ class DefaultInternalTransferManager(
private fun getTransferAction( private fun getTransferAction(
context: AskTransferContext, context: AskTransferContext,
model: TransportTableModel, path: Path,
source: TransportTableModel.Attributes source: TransportTableModel.Attributes
): TransferAction? { ): TransferAction? {
if (context.applyAll) return context.action if (context.applyAll) return context.action
for (i in 0 until model.rowCount) { if (path.exists()) {
val c = model.getAttributes(i) val transfer = askTransfer(source, source)
if (c.name != source.name) continue
val transfer = askTransfer(source, c)
context.action = transfer.action context.action = transfer.action
context.applyAll = transfer.applyAll context.applyAll = transfer.applyAll
if (transfer.option != JOptionPane.OK_OPTION) return null if (transfer.option != JOptionPane.OK_OPTION) return null
} }
return context.action return TransferAction.Overwrite
} }

View File

@@ -6,6 +6,7 @@ import java.io.InputStream
import java.io.OutputStream import java.io.OutputStream
import java.nio.file.Path import java.nio.file.Path
import java.nio.file.StandardOpenOption import java.nio.file.StandardOpenOption
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.io.path.inputStream import kotlin.io.path.inputStream
import kotlin.io.path.outputStream import kotlin.io.path.outputStream
@@ -18,8 +19,12 @@ class FileTransfer(
private lateinit var input: InputStream private lateinit var input: InputStream
private lateinit var output: OutputStream private lateinit var output: OutputStream
private val closed = AtomicBoolean(false)
override suspend fun transfer(bufferSize: Int): Long { override suspend fun transfer(bufferSize: Int): Long {
if (closed.get()) throw IllegalStateException("Transfer already closed")
if (::input.isInitialized.not()) { if (::input.isInitialized.not()) {
input = source().inputStream(StandardOpenOption.READ) input = source().inputStream(StandardOpenOption.READ)
} }
@@ -48,12 +53,14 @@ class FileTransfer(
} }
override fun close() { override fun close() {
if (::input.isInitialized) { if (closed.compareAndSet(false, true)) {
IOUtils.closeQuietly(input) if (::input.isInitialized) {
} IOUtils.closeQuietly(input)
}
if (::output.isInitialized) { if (::output.isInitialized) {
IOUtils.closeQuietly(output) IOUtils.closeQuietly(output)
}
} }
} }

View File

@@ -424,6 +424,11 @@ class TransferTableModel(private val coroutineScope: CoroutineScope) :
// 异步上报,因为数据量非常大,所以采用异步 // 异步上报,因为数据量非常大,所以采用异步
reporter.report(node, len, System.currentTimeMillis()) reporter.report(node, len, System.currentTimeMillis())
} }
// 因为可能是异步传输,只有关闭后才能确保数据已经到达云端
// 尤其是 S3 协议
if (transfer is Closeable) IOUtils.closeQuietly(transfer)
withContext(Dispatchers.Swing) { withContext(Dispatchers.Swing) {
if (continueTransfer(node)) { if (continueTransfer(node)) {
changeState(node, State.Done) changeState(node, State.Done)

View File

@@ -290,7 +290,7 @@ class TransportPanel(
// 传输完成之后刷新 // 传输完成之后刷新
transferManager.addTransferListener(object : TransferListener { transferManager.addTransferListener(object : TransferListener {
override fun onTransferChanged(transfer: Transfer, state: TransferTreeTableNode.State) { override fun onTransferChanged(transfer: Transfer, state: TransferTreeTableNode.State) {
if (state != TransferTreeTableNode.State.Done) return if (state != TransferTreeTableNode.State.Done && state != TransferTreeTableNode.State.Failed) return
if (transfer.target().fileSystem != _fileSystem) return if (transfer.target().fileSystem != _fileSystem) return
if (transfer.target() == workdir || transfer.target().parent == workdir) { if (transfer.target() == workdir || transfer.target().parent == workdir) {
reload(requestFocus = false) reload(requestFocus = false)

View File

@@ -95,9 +95,6 @@ class TransportViewer : JPanel(BorderLayout()), DataProvider, Disposable {
return source.getSelectedTransportPanel()?.workdir return source.getSelectedTransportPanel()?.workdir
} }
override fun getTableModel(): TransportTableModel? {
return source.getSelectedTransportPanel()?.getTableModel()
}
}, },
object : DefaultInternalTransferManager.WorkdirProvider { object : DefaultInternalTransferManager.WorkdirProvider {
@@ -105,9 +102,6 @@ class TransportViewer : JPanel(BorderLayout()), DataProvider, Disposable {
return target.getSelectedTransportPanel()?.workdir return target.getSelectedTransportPanel()?.workdir
} }
override fun getTableModel(): TransportTableModel? {
return target.getSelectedTransportPanel()?.getTableModel()
}
}) })
} }

View File

@@ -24,8 +24,8 @@ internal class SFTPPathHandler(
} }
override fun dispose() { override fun dispose() {
super.dispose()
session.removeCloseFutureListener(listener) session.removeCloseFutureListener(listener)
IOUtils.closeQuietly(fileSystem)
IOUtils.closeQuietly(session) IOUtils.closeQuietly(session)
IOUtils.closeQuietly(client) IOUtils.closeQuietly(client)
} }

View File

@@ -1,4 +1,4 @@
package app.termora.plugins.s3 package app.termora.transfer.s3
import java.nio.file.attribute.BasicFileAttributes import java.nio.file.attribute.BasicFileAttributes
import java.nio.file.attribute.FileTime import java.nio.file.attribute.FileTime

View File

@@ -1,14 +1,11 @@
package app.termora.plugins.s3 package app.termora.transfer.s3
import io.minio.MinioClient
import org.apache.sshd.common.file.util.BaseFileSystem import org.apache.sshd.common.file.util.BaseFileSystem
import java.nio.file.Path import java.nio.file.Path
import java.nio.file.attribute.UserPrincipalLookupService import java.nio.file.attribute.UserPrincipalLookupService
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
class S3FileSystem( open class S3FileSystem(provider: S3FileSystemProvider) : BaseFileSystem<S3Path>(provider) {
private val minioClient: MinioClient,
) : BaseFileSystem<S3Path>(S3FileSystemProvider(minioClient)) {
private val isOpen = AtomicBoolean(true) private val isOpen = AtomicBoolean(true)
@@ -20,16 +17,14 @@ class S3FileSystem(
return path return path
} }
override fun close() {
if (isOpen.compareAndSet(false, true)) {
minioClient.close()
}
}
override fun isOpen(): Boolean { override fun isOpen(): Boolean {
return isOpen.get() return isOpen.get()
} }
override fun close() {
isOpen.compareAndSet(false, true)
}
override fun getRootDirectories(): Iterable<Path> { override fun getRootDirectories(): Iterable<Path> {
return mutableSetOf<Path>(create(separator)) return mutableSetOf<Path>(create(separator))
} }

View File

@@ -1,32 +1,21 @@
package app.termora.plugins.s3 package app.termora.transfer.s3
import io.minio.* import java.io.InputStream
import io.minio.errors.ErrorResponseException
import org.apache.commons.io.IOUtils
import org.apache.commons.lang3.StringUtils
import java.io.OutputStream import java.io.OutputStream
import java.io.PipedInputStream
import java.io.PipedOutputStream
import java.net.URI import java.net.URI
import java.nio.channels.Channels
import java.nio.channels.SeekableByteChannel import java.nio.channels.SeekableByteChannel
import java.nio.file.* import java.nio.file.*
import java.nio.file.attribute.* import java.nio.file.attribute.*
import java.nio.file.spi.FileSystemProvider import java.nio.file.spi.FileSystemProvider
import java.util.concurrent.atomic.AtomicReference
import kotlin.io.path.absolutePathString import kotlin.io.path.absolutePathString
import kotlin.io.path.name import kotlin.io.path.name
class S3FileSystemProvider(private val minioClient: MinioClient) : FileSystemProvider() { abstract class S3FileSystemProvider() : FileSystemProvider() {
/** /**
* 因为 S3 协议不存在文件夹所以用户新建的文件夹先保存到内存中 * 因为 S3 协议不存在文件夹所以用户新建的文件夹先保存到内存中
*/ */
private val directories = mutableMapOf<String, MutableList<S3Path>>() protected val directories = mutableMapOf<String, MutableList<S3Path>>()
override fun getScheme(): String? {
return "s3"
}
override fun newFileSystem( override fun newFileSystem(
uri: URI, uri: URI,
@@ -49,51 +38,15 @@ class S3FileSystemProvider(private val minioClient: MinioClient) : FileSystemPro
vararg attrs: FileAttribute<*> vararg attrs: FileAttribute<*>
): SeekableByteChannel { ): SeekableByteChannel {
if (path !is S3Path) throw UnsupportedOperationException("path must be a S3Path") if (path !is S3Path) throw UnsupportedOperationException("path must be a S3Path")
if (options.contains(StandardOpenOption.WRITE)) { return if (options.contains(StandardOpenOption.WRITE)) {
return S3WriteSeekableByteChannel(Channels.newChannel(createStreamer(path))) S3WriteSeekableByteChannel(getOutputStream(path))
} else { } else {
val response = minioClient.getObject( S3ReadSeekableByteChannel(getInputStream(path), path.attributes.size())
GetObjectArgs.builder().bucket(path.bucketName)
.`object`(path.objectName).build()
)
return S3ReadSeekableByteChannel(Channels.newChannel(response), stat(path))
} }
} }
abstract fun getOutputStream(path: S3Path): OutputStream
private fun createStreamer(path: S3Path): OutputStream { abstract fun getInputStream(path: S3Path): InputStream
val pis = PipedInputStream()
val pos = PipedOutputStream(pis)
val exception = AtomicReference<Throwable>()
val thread = Thread.ofVirtual().start {
try {
minioClient.putObject(
PutObjectArgs.builder()
.bucket(path.bucketName)
.stream(pis, -1, 32 * 1024 * 1024)
.`object`(path.objectName).build()
)
} catch (e: Exception) {
exception.set(e)
} finally {
IOUtils.closeQuietly(pis)
}
}
return object : OutputStream() {
override fun write(b: Int) {
val exception = exception.get()
if (exception != null) throw exception
pos.write(b)
}
override fun close() {
pos.close()
if (thread.isAlive) thread.join()
}
}
}
override fun newDirectoryStream( override fun newDirectoryStream(
dir: Path, dir: Path,
@@ -101,7 +54,7 @@ class S3FileSystemProvider(private val minioClient: MinioClient) : FileSystemPro
): DirectoryStream<Path> { ): DirectoryStream<Path> {
return object : DirectoryStream<Path> { return object : DirectoryStream<Path> {
override fun iterator(): MutableIterator<Path> { override fun iterator(): MutableIterator<Path> {
return files(dir as S3Path).iterator() return fetchChildren(dir as S3Path).iterator()
} }
override fun close() { override fun close() {
@@ -110,70 +63,8 @@ class S3FileSystemProvider(private val minioClient: MinioClient) : FileSystemPro
} }
} }
private fun files(path: S3Path): MutableList<S3Path> { abstract fun fetchChildren(path: S3Path): MutableList<S3Path>
val paths = mutableListOf<S3Path>()
// root
if (path.isRoot) {
for (bucket in minioClient.listBuckets()) {
val p = path.resolve(bucket.name())
p.attributes = S3FileAttributes(
directory = true,
lastModifiedTime = bucket.creationDate().toInstant().toEpochMilli()
)
paths.add(p)
}
return paths
}
var startAfter = StringUtils.EMPTY
val maxKeys = 100
while (true) {
val builder = ListObjectsArgs.builder()
.bucket(path.bucketName)
.maxKeys(maxKeys)
.delimiter(path.fileSystem.separator)
if (path.objectName.isNotBlank()) builder.prefix(path.objectName + path.fileSystem.separator)
if (startAfter.isNotBlank()) builder.startAfter(startAfter)
val subPaths = mutableListOf<S3Path>()
for (e in minioClient.listObjects(builder.build())) {
val item = e.get()
val p = path.bucket.resolve(item.objectName())
var attributes = p.attributes.copy(
directory = item.isDir,
regularFile = item.isDir.not(),
size = item.size()
)
if (item.lastModified() != null) {
attributes = attributes.copy(lastModifiedTime = item.lastModified().toInstant().toEpochMilli())
}
p.attributes = attributes
// 如果是文件夹,那么就要删除内存中的
if (attributes.isDirectory) {
delete(p)
}
subPaths.add(p)
startAfter = item.objectName()
}
paths.addAll(subPaths)
if (subPaths.size < maxKeys)
break
}
paths.addAll(directories[path.absolutePathString()] ?: emptyList())
return paths
}
override fun createDirectory(dir: Path, vararg attrs: FileAttribute<*>) { override fun createDirectory(dir: Path, vararg attrs: FileAttribute<*>) {
synchronized(this) { synchronized(this) {
@@ -196,13 +87,20 @@ class S3FileSystemProvider(private val minioClient: MinioClient) : FileSystemPro
directories[parent.absolutePathString()]?.removeIf { it.name == path.name } directories[parent.absolutePathString()]?.removeIf { it.name == path.name }
} }
} }
return delete(path, true)
} else {
delete(path, false)
} }
minioClient.removeObject(
RemoveObjectArgs.builder().bucket(path.bucketName).`object`(path.objectName).build()
)
} }
override fun checkAccess(path: Path, vararg modes: AccessMode) {
if (path !is S3Path) throw UnsupportedOperationException("path must be a S3Path")
checkAccess(path, *modes)
}
abstract fun delete(path: S3Path, isDirectory: Boolean)
abstract fun checkAccess(path: S3Path, vararg modes: AccessMode)
override fun copy( override fun copy(
source: Path?, source: Path?,
target: Path?, target: Path?,
@@ -231,25 +129,6 @@ class S3FileSystemProvider(private val minioClient: MinioClient) : FileSystemPro
throw UnsupportedOperationException() throw UnsupportedOperationException()
} }
override fun checkAccess(path: Path, vararg modes: AccessMode) {
if (path !is S3Path) throw UnsupportedOperationException("path must be a S3Path")
try {
stat(path)
} catch (e: ErrorResponseException) {
throw NoSuchFileException(e.errorResponse().message())
}
}
private fun stat(path: S3Path): StatObjectResponse {
return minioClient.statObject(
StatObjectArgs.builder()
.`object`(path.objectName)
.bucket(path.bucketName).build()
)
}
override fun <V : FileAttributeView> getFileAttributeView( override fun <V : FileAttributeView> getFileAttributeView(
path: Path, path: Path,
type: Class<V>, type: Class<V>,

View File

@@ -1,4 +1,4 @@
package app.termora.plugins.s3 package app.termora.transfer.s3
import app.termora.transfer.WithPathAttributes import app.termora.transfer.WithPathAttributes
import org.apache.sshd.common.file.util.BasePath import org.apache.sshd.common.file.util.BasePath
@@ -6,7 +6,7 @@ import java.nio.file.LinkOption
import java.nio.file.Path import java.nio.file.Path
import kotlin.io.path.absolutePathString import kotlin.io.path.absolutePathString
class S3Path( open class S3Path(
fileSystem: S3FileSystem, fileSystem: S3FileSystem,
root: String?, root: String?,
names: List<String>, names: List<String>,
@@ -20,27 +20,27 @@ class S3Path(
/** /**
* 是否是 Bucket * 是否是 Bucket
*/ */
val isBucket get() = parent != null && parent?.parent == null open val isBucket get() = parent != null && parent?.parent == null
/** /**
* 是否是根 * 是否是根
*/ */
val isRoot get() = absolutePathString() == separator open val isRoot get() = absolutePathString() == separator
/** /**
* Bucket Name * Bucket Name
*/ */
val bucketName: String get() = names.first() open val bucketName: String get() = names.first()
/** /**
* 获取 Bucket * 获取 Bucket
*/ */
val bucket: S3Path get() = fileSystem.getPath(root, bucketName) open val bucket: S3Path get() = fileSystem.getPath(root, bucketName)
/** /**
* 获取所在 Bucket 的路径 * 获取所在 Bucket 的路径
*/ */
val objectName: String get() = names.subList(1, names.size).joinToString(separator) open val objectName: String get() = names.subList(1, names.size).joinToString(separator)
override fun getCustomType(): String? { override fun getCustomType(): String? {
if (isBucket) return "Bucket" if (isBucket) return "Bucket"

View File

@@ -1,16 +1,13 @@
package app.termora.plugins.s3 package app.termora.transfer.s3
import io.minio.StatObjectResponse
import org.apache.commons.io.IOUtils import org.apache.commons.io.IOUtils
import java.io.InputStream
import java.nio.ByteBuffer import java.nio.ByteBuffer
import java.nio.channels.ReadableByteChannel import java.nio.channels.Channels
import java.nio.channels.SeekableByteChannel import java.nio.channels.SeekableByteChannel
class S3ReadSeekableByteChannel( open class S3ReadSeekableByteChannel(input: InputStream, private val size: Long) : SeekableByteChannel {
private val channel: ReadableByteChannel, private val channel = Channels.newChannel(input)
private val stat: StatObjectResponse
) : SeekableByteChannel {
private var position: Long = 0 private var position: Long = 0
override fun read(dst: ByteBuffer): Int { override fun read(dst: ByteBuffer): Int {
@@ -34,7 +31,7 @@ class S3ReadSeekableByteChannel(
} }
override fun size(): Long { override fun size(): Long {
return stat.size() return size
} }
override fun truncate(size: Long): SeekableByteChannel { override fun truncate(size: Long): SeekableByteChannel {

View File

@@ -1,14 +1,13 @@
package app.termora.plugins.s3 package app.termora.transfer.s3
import org.apache.commons.io.IOUtils import org.apache.commons.io.IOUtils
import java.io.OutputStream
import java.nio.ByteBuffer import java.nio.ByteBuffer
import java.nio.channels.Channels
import java.nio.channels.SeekableByteChannel import java.nio.channels.SeekableByteChannel
import java.nio.channels.WritableByteChannel
class S3WriteSeekableByteChannel(
private val channel: WritableByteChannel,
) : SeekableByteChannel {
open class S3WriteSeekableByteChannel(output: OutputStream) : SeekableByteChannel {
private val channel = Channels.newChannel(output)
override fun read(dst: ByteBuffer): Int { override fun read(dst: ByteBuffer): Int {
throw UnsupportedOperationException("read not supported") throw UnsupportedOperationException("read not supported")

View File

@@ -1,39 +0,0 @@
package app.termora
import app.termora.database.DataEntity
import app.termora.database.DataType
import app.termora.database.OwnerType
import app.termora.database.SettingEntity
import org.jetbrains.exposed.v1.jdbc.Database
import org.jetbrains.exposed.v1.jdbc.SchemaUtils
import org.jetbrains.exposed.v1.jdbc.insert
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
import kotlin.test.Test
class ExposedTest {
@Test
fun test() {
val database = Database.connect("jdbc:h2:mem:test", driver = "org.h2.Driver", user = "sa")
transaction(database) {
SchemaUtils.create(DataEntity, SettingEntity)
println(DataEntity.insert {
it[ownerId] = "Test"
it[ownerType] = OwnerType.User.name
it[type] = DataType.KeywordHighlight.name
it[data] = "hello 中文".repeat(10000)
} get DataEntity.id)
println(SettingEntity.insert {
it[name] = "Test"
it[value] = "hello 中文".repeat(10000)
} get SettingEntity.id)
}
}
}

View File

@@ -10,7 +10,7 @@ class HostTest {
""" """
{ {
"name": "test", "name": "test",
"protocol": SSHProtocolProvider.PROTOCOL, "protocol": "SSH",
"test": "" "test": ""
} }
""".trimIndent() """.trimIndent()

View File

@@ -1,15 +0,0 @@
package app.termora.account
import kotlin.test.Test
class ServerManagerTest {
@Test
fun test() {
ServerManager.getInstance().login(
Server(
name = "test",
server = "http://127.0.0.1:8080"
), "admin", "admin"
)
}
}

View File

@@ -1,114 +0,0 @@
package app.termora.vfs2.sftp
import app.termora.SSHDTest
import app.termora.randomUUID
import org.apache.commons.vfs2.*
import org.apache.commons.vfs2.impl.DefaultFileSystemManager
import org.apache.commons.vfs2.provider.local.DefaultLocalFileProvider
import org.apache.sshd.sftp.client.SftpClientFactory
import java.io.File
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class MySftpFileProviderTest : SSHDTest() {
companion object {
init {
val fileSystemManager = DefaultFileSystemManager()
fileSystemManager.addProvider("sftp", MySftpFileProvider.instance)
fileSystemManager.addProvider("file", DefaultLocalFileProvider())
fileSystemManager.init()
VFS.setManager(fileSystemManager)
}
}
@Test
fun testSetExecutable() {
val file = newFileObject("/config/test.txt")
file.createFile()
file.refresh()
assertFalse(file.isExecutable)
file.setExecutable(true, false)
file.refresh()
assertTrue(file.isExecutable)
}
@Test
fun testCreateFile() {
val file = newFileObject("/config/test.txt")
assertFalse(file.exists())
file.createFile()
assertTrue(file.exists())
}
@Test
fun testWriteAndReadFile() {
val file = newFileObject("/config/test.txt")
file.createFile()
assertFalse(file.content.isOpen)
val os = file.content.outputStream
os.write("test".toByteArray())
os.flush()
assertTrue(file.content.isOpen)
os.close()
assertFalse(file.content.isOpen)
val input = file.content.inputStream
assertEquals("test", String(input.readAllBytes()))
assertTrue(file.content.isOpen)
input.close()
assertFalse(file.content.isOpen)
}
@Test
fun testCreateFolder() {
val file = newFileObject("/config/test")
assertFalse(file.exists())
file.createFolder()
assertTrue(file.exists())
}
@Test
fun testSftpClient() {
val session = newClientSession()
val client = SftpClientFactory.instance().createSftpClient(session)
assertTrue(client.isOpen)
session.close()
assertFalse(client.isOpen)
}
@Test
fun testCopy() {
val file = newFileObject("/config/sshd.pid")
val filepath = File("build", randomUUID())
val localFile = getVFS().resolveFile("file://${filepath.absolutePath}")
localFile.copyFrom(file, Selectors.SELECT_ALL)
assertEquals(
file.content.getString(Charsets.UTF_8),
localFile.content.getString(Charsets.UTF_8)
)
localFile.delete()
}
private fun getVFS(): FileSystemManager {
return VFS.getManager()
}
private fun newFileObject(path: String): FileObject {
val vfs = getVFS()
val fileSystemOptions = FileSystemOptions()
MySftpFileSystemConfigBuilder.getInstance()
.setSftpFileSystem(fileSystemOptions, SftpClientFactory.instance().createSftpFileSystem(newClientSession()))
return vfs.resolveFile("sftp://${path}", fileSystemOptions)
}
}