stashify 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- stash/__init__.py +5 -0
- stash/cli/__init__.py +5 -0
- stash/cli/commands/__init__.py +23 -0
- stash/cli/commands/get.py +160 -0
- stash/cli/commands/info.py +45 -0
- stash/cli/commands/init.py +34 -0
- stash/cli/commands/ls.py +41 -0
- stash/cli/commands/provider.py +124 -0
- stash/cli/commands/put.py +185 -0
- stash/cli/commands/rm.py +82 -0
- stash/cli/commands/status.py +47 -0
- stash/cli/commands/verify.py +100 -0
- stash/cli/main.py +43 -0
- stash/cli/output.py +121 -0
- stash/core/__init__.py +53 -0
- stash/core/chunking.py +102 -0
- stash/core/crypto.py +121 -0
- stash/core/exceptions.py +66 -0
- stash/core/http_status.py +19 -0
- stash/core/jobs.py +235 -0
- stash/core/manifest.py +171 -0
- stash/core/metadata.py +130 -0
- stash/core/storage.py +120 -0
- stash/providers/__init__.py +15 -0
- stash/providers/base.py +48 -0
- stash/providers/discord/__init__.py +12 -0
- stash/providers/discord/auth.py +29 -0
- stash/providers/discord/limits.py +34 -0
- stash/providers/discord/provider.py +250 -0
- stash/providers/telegram/__init__.py +11 -0
- stash/providers/telegram/auth.py +15 -0
- stash/providers/telegram/limits.py +21 -0
- stash/providers/telegram/provider.py +286 -0
- stashify-0.1.0.dist-info/METADATA +653 -0
- stashify-0.1.0.dist-info/RECORD +39 -0
- stashify-0.1.0.dist-info/WHEEL +5 -0
- stashify-0.1.0.dist-info/entry_points.txt +2 -0
- stashify-0.1.0.dist-info/licenses/LICENSE +21 -0
- stashify-0.1.0.dist-info/top_level.txt +1 -0
stash/__init__.py
ADDED
stash/cli/__init__.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""CLI commands package."""
|
|
2
|
+
|
|
3
|
+
from stash.cli.commands.get import get_commands
|
|
4
|
+
from stash.cli.commands.info import info_commands
|
|
5
|
+
from stash.cli.commands.init import init_commands
|
|
6
|
+
from stash.cli.commands.ls import ls_commands
|
|
7
|
+
from stash.cli.commands.provider import provider_commands
|
|
8
|
+
from stash.cli.commands.put import put_commands
|
|
9
|
+
from stash.cli.commands.rm import rm_commands
|
|
10
|
+
from stash.cli.commands.status import status_commands
|
|
11
|
+
from stash.cli.commands.verify import verify_commands
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"init_commands",
|
|
15
|
+
"provider_commands",
|
|
16
|
+
"put_commands",
|
|
17
|
+
"get_commands",
|
|
18
|
+
"ls_commands",
|
|
19
|
+
"info_commands",
|
|
20
|
+
"rm_commands",
|
|
21
|
+
"verify_commands",
|
|
22
|
+
"status_commands",
|
|
23
|
+
]
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""CLI command: get - Retrieve a file."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
|
|
8
|
+
from stash.cli.output import create_progress, format_size, print_error, print_info, print_success
|
|
9
|
+
from stash.core.crypto import CryptoEngine, EncryptionConfig
|
|
10
|
+
from stash.core.metadata import MetadataStore
|
|
11
|
+
from stash.providers import ProviderRegistry
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@click.command()
|
|
15
|
+
@click.argument("file_id_or_name")
|
|
16
|
+
@click.option("--output", "-o", type=click.Path(path_type=Path), help="Output path (default: current directory)")
|
|
17
|
+
@click.option("--password", prompt=True, hide_input=True, help="Encryption password")
|
|
18
|
+
@click.option("--overwrite", is_flag=True, help="Overwrite existing file")
|
|
19
|
+
@click.pass_context
|
|
20
|
+
def get_cmd(ctx: click.Context, file_id_or_name: str, output: Path | None, password: str, overwrite: bool) -> None:
|
|
21
|
+
"""Retrieve a file from Stash."""
|
|
22
|
+
asyncio.run(_get_async(file_id_or_name, ctx.obj["repo"], output, password, overwrite))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
async def _get_async(
|
|
26
|
+
file_id_or_name: str,
|
|
27
|
+
repo_path: Path,
|
|
28
|
+
output: Path | None,
|
|
29
|
+
password: str,
|
|
30
|
+
overwrite: bool,
|
|
31
|
+
) -> None:
|
|
32
|
+
repo = repo_path.resolve()
|
|
33
|
+
store = MetadataStore(repo)
|
|
34
|
+
|
|
35
|
+
file_id = _resolve_file_id(store, file_id_or_name)
|
|
36
|
+
if not file_id:
|
|
37
|
+
print_error(f"File not found: {file_id_or_name}")
|
|
38
|
+
return
|
|
39
|
+
|
|
40
|
+
manifest = store.load_manifest(file_id)
|
|
41
|
+
|
|
42
|
+
# Decrypt filename
|
|
43
|
+
crypto = CryptoEngine()
|
|
44
|
+
enc_config = EncryptionConfig(
|
|
45
|
+
algorithm=manifest.encryption.algorithm,
|
|
46
|
+
key_size=manifest.encryption.key_size,
|
|
47
|
+
nonce_size=manifest.encryption.nonce_size,
|
|
48
|
+
chunk_key_derivation=manifest.encryption.chunk_key_derivation,
|
|
49
|
+
)
|
|
50
|
+
file_key = None
|
|
51
|
+
try:
|
|
52
|
+
if manifest.encryption.file_key_wrapped:
|
|
53
|
+
from stash.core.crypto import FileKey
|
|
54
|
+
file_key = FileKey(
|
|
55
|
+
key=crypto.decrypt_file_key(
|
|
56
|
+
manifest.encryption.file_key_wrapped,
|
|
57
|
+
password,
|
|
58
|
+
enc_config
|
|
59
|
+
).key,
|
|
60
|
+
salt=manifest.encryption.file_key_salt,
|
|
61
|
+
config=enc_config
|
|
62
|
+
)
|
|
63
|
+
else:
|
|
64
|
+
print_error("File key not wrapped - cannot decrypt")
|
|
65
|
+
return
|
|
66
|
+
except Exception as e:
|
|
67
|
+
print_error(f"Failed to decrypt file key: {e}")
|
|
68
|
+
return
|
|
69
|
+
|
|
70
|
+
# Decrypt filename
|
|
71
|
+
from stash.core.crypto import EncryptedChunk
|
|
72
|
+
encrypted_name_bytes = bytes.fromhex(manifest.encrypted_name)
|
|
73
|
+
encrypted_name_chunk = EncryptedChunk(
|
|
74
|
+
ciphertext=encrypted_name_bytes,
|
|
75
|
+
nonce=manifest.encrypted_name_nonce,
|
|
76
|
+
chunk_index=-1,
|
|
77
|
+
)
|
|
78
|
+
try:
|
|
79
|
+
decrypted_name = crypto.decrypt_chunk(encrypted_name_chunk, file_key).decode()
|
|
80
|
+
except Exception as e:
|
|
81
|
+
print_error(f"Failed to decrypt filename: {e}")
|
|
82
|
+
return
|
|
83
|
+
|
|
84
|
+
if output is None:
|
|
85
|
+
output_path = Path.cwd() / decrypted_name
|
|
86
|
+
elif output.is_dir():
|
|
87
|
+
output_path = output / decrypted_name
|
|
88
|
+
else:
|
|
89
|
+
output_path = output
|
|
90
|
+
|
|
91
|
+
if output_path.exists() and not overwrite:
|
|
92
|
+
print_error(f"File exists: {output_path}. Use --overwrite to replace.")
|
|
93
|
+
return
|
|
94
|
+
|
|
95
|
+
providers_config = {}
|
|
96
|
+
for chunk in manifest.chunks:
|
|
97
|
+
if chunk.provider not in providers_config:
|
|
98
|
+
config = store.get_provider_config(chunk.provider)
|
|
99
|
+
if config:
|
|
100
|
+
providers_config[chunk.provider] = config
|
|
101
|
+
|
|
102
|
+
provider_instances = {}
|
|
103
|
+
for name, config in providers_config.items():
|
|
104
|
+
instance = await ProviderRegistry.create(config.type, config)
|
|
105
|
+
provider_instances[name] = instance
|
|
106
|
+
|
|
107
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
108
|
+
|
|
109
|
+
progress = create_progress()
|
|
110
|
+
task = progress.add_task("Downloading", total=manifest.chunk_count)
|
|
111
|
+
|
|
112
|
+
with progress, output_path.open("wb") as f:
|
|
113
|
+
for chunk_info in sorted(manifest.chunks, key=lambda c: c.index):
|
|
114
|
+
provider = provider_instances.get(chunk_info.provider)
|
|
115
|
+
if not provider:
|
|
116
|
+
print_error(f"Provider not available: {chunk_info.provider}")
|
|
117
|
+
return
|
|
118
|
+
|
|
119
|
+
remote_ref = type('RemoteRef', (), {
|
|
120
|
+
'provider': chunk_info.provider,
|
|
121
|
+
'remote_id': chunk_info.remote_id,
|
|
122
|
+
'metadata': chunk_info.metadata
|
|
123
|
+
})()
|
|
124
|
+
|
|
125
|
+
encrypted_data = await provider.download_chunk(remote_ref)
|
|
126
|
+
|
|
127
|
+
from stash.core.crypto import EncryptedChunk
|
|
128
|
+
encrypted_chunk = EncryptedChunk(
|
|
129
|
+
ciphertext=encrypted_data,
|
|
130
|
+
nonce=chunk_info.nonce,
|
|
131
|
+
chunk_index=chunk_info.index,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
try:
|
|
135
|
+
decrypted = crypto.decrypt_chunk(encrypted_chunk, file_key)
|
|
136
|
+
f.write(decrypted)
|
|
137
|
+
progress.advance(task)
|
|
138
|
+
except Exception as e:
|
|
139
|
+
print_error(f"Decryption failed for chunk {chunk_info.index}: {e}")
|
|
140
|
+
return
|
|
141
|
+
|
|
142
|
+
for instance in provider_instances.values():
|
|
143
|
+
await instance.close()
|
|
144
|
+
|
|
145
|
+
print_success(f"Retrieved '{decrypted_name}' to {output_path}")
|
|
146
|
+
print_info(f"Size: {format_size(manifest.original_size)}")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _resolve_file_id(store: MetadataStore, identifier: str) -> str | None:
|
|
150
|
+
"""Resolve file ID or name to file ID."""
|
|
151
|
+
if store.file_exists(identifier):
|
|
152
|
+
return identifier
|
|
153
|
+
for fid in store.list_files():
|
|
154
|
+
manifest = store.load_manifest(fid)
|
|
155
|
+
if manifest.original_name == identifier:
|
|
156
|
+
return fid
|
|
157
|
+
return None
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
get_commands = get_cmd
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""CLI command: info - Show file metadata."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
import click
|
|
5
|
+
|
|
6
|
+
from stash.cli.output import print_error, print_file_info
|
|
7
|
+
from stash.core.metadata import MetadataStore
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@click.command()
|
|
11
|
+
@click.argument("file_id_or_name")
|
|
12
|
+
@click.pass_context
|
|
13
|
+
def info_cmd(ctx: click.Context, file_id_or_name: str) -> None:
|
|
14
|
+
"""Show detailed file metadata."""
|
|
15
|
+
repo = ctx.obj["repo"].resolve()
|
|
16
|
+
store = MetadataStore(repo)
|
|
17
|
+
|
|
18
|
+
file_id = _resolve_file_id(store, file_id_or_name)
|
|
19
|
+
if not file_id:
|
|
20
|
+
print_error(f"File not found: {file_id_or_name}")
|
|
21
|
+
return
|
|
22
|
+
|
|
23
|
+
manifest = store.load_manifest(file_id)
|
|
24
|
+
|
|
25
|
+
providers = {}
|
|
26
|
+
for name in store.list_providers():
|
|
27
|
+
config = store.get_provider_config(name)
|
|
28
|
+
if config:
|
|
29
|
+
providers[name] = config
|
|
30
|
+
|
|
31
|
+
print_file_info(manifest, providers)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _resolve_file_id(store: MetadataStore, identifier: str) -> str | None:
|
|
35
|
+
"""Resolve file ID or name to file ID."""
|
|
36
|
+
if store.file_exists(identifier):
|
|
37
|
+
return identifier
|
|
38
|
+
for fid in store.list_files():
|
|
39
|
+
manifest = store.load_manifest(fid)
|
|
40
|
+
if manifest.original_name == identifier:
|
|
41
|
+
return fid
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
info_commands = info_cmd
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""CLI command: init - Initialize a new Stash repository."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
import click
|
|
5
|
+
|
|
6
|
+
from stash.cli.output import print_error, print_info, print_success
|
|
7
|
+
from stash.core.metadata import MetadataStore
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@click.command()
|
|
11
|
+
@click.option("--force", "-f", is_flag=True, help="Overwrite existing repository")
|
|
12
|
+
@click.pass_context
|
|
13
|
+
def init_cmd(ctx: click.Context, force: bool) -> None:
|
|
14
|
+
"""Initialize a new Stash repository."""
|
|
15
|
+
repo_path = ctx.obj["repo"]
|
|
16
|
+
metadata_dir = repo_path / ".stash" / "metadata"
|
|
17
|
+
|
|
18
|
+
if metadata_dir.exists() and not force:
|
|
19
|
+
print_error(f"Repository already exists at {repo_path}")
|
|
20
|
+
print_info("Use --force to overwrite")
|
|
21
|
+
return
|
|
22
|
+
|
|
23
|
+
store = MetadataStore(repo_path)
|
|
24
|
+
store.save_config({
|
|
25
|
+
"version": 1,
|
|
26
|
+
"created_at": __import__("time").time(),
|
|
27
|
+
"providers": {},
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
print_success(f"Initialized Stash repository at {repo_path}")
|
|
31
|
+
print_info("Add a provider with: stash provider add discord")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
init_commands = init_cmd
|
stash/cli/commands/ls.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""CLI command: ls - List stored files."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
import click
|
|
5
|
+
|
|
6
|
+
from stash.cli.output import format_size, format_timestamp, print_info, print_table
|
|
7
|
+
from stash.core.metadata import MetadataStore
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@click.command()
|
|
11
|
+
@click.option("--long", "-l", is_flag=True, help="Show detailed information")
|
|
12
|
+
@click.pass_context
|
|
13
|
+
def ls_cmd(ctx: click.Context, long: bool) -> None:
|
|
14
|
+
"""List stored files."""
|
|
15
|
+
repo = ctx.obj["repo"].resolve()
|
|
16
|
+
store = MetadataStore(repo)
|
|
17
|
+
|
|
18
|
+
files = store.list_files()
|
|
19
|
+
if not files:
|
|
20
|
+
print_info("No files stored")
|
|
21
|
+
return
|
|
22
|
+
|
|
23
|
+
if long:
|
|
24
|
+
rows = []
|
|
25
|
+
for fid in files:
|
|
26
|
+
manifest = store.load_manifest(fid)
|
|
27
|
+
rows.append([
|
|
28
|
+
fid[:16],
|
|
29
|
+
manifest.original_name,
|
|
30
|
+
format_size(manifest.original_size),
|
|
31
|
+
str(manifest.chunk_count),
|
|
32
|
+
format_timestamp(manifest.created_at),
|
|
33
|
+
])
|
|
34
|
+
print_table("Stored Files", ["File ID", "Name", "Size", "Chunks", "Created"], rows)
|
|
35
|
+
else:
|
|
36
|
+
for fid in files:
|
|
37
|
+
manifest = store.load_manifest(fid)
|
|
38
|
+
print_info(f"{fid[:16]} {manifest.original_name} ({format_size(manifest.original_size)})")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
ls_commands = ls_cmd
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""CLI commands for provider management."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
import click
|
|
5
|
+
|
|
6
|
+
from stash.cli.output import confirm, print_error, print_info, print_success
|
|
7
|
+
from stash.core.metadata import MetadataStore
|
|
8
|
+
from stash.core.storage import ProviderConfig
|
|
9
|
+
from stash.providers import ProviderRegistry
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@click.command()
|
|
13
|
+
@click.argument("name")
|
|
14
|
+
@click.option("--type", "provider_type", type=click.Choice(["discord", "telegram"]), help="Provider type")
|
|
15
|
+
@click.option("--token", prompt=True, hide_input=True, help="Bot token")
|
|
16
|
+
@click.option("--channel-id", help="Discord channel ID for storage")
|
|
17
|
+
@click.option("--chat-id", help="Telegram chat ID for storage")
|
|
18
|
+
@click.option("--is-bot/--is-user", default=True, help="Discord: token type (default: bot)")
|
|
19
|
+
@click.option("--max-concurrent", default=3, help="Max concurrent uploads")
|
|
20
|
+
@click.pass_context
|
|
21
|
+
def provider_add_cmd(
|
|
22
|
+
ctx: click.Context,
|
|
23
|
+
name: str,
|
|
24
|
+
provider_type: str | None,
|
|
25
|
+
token: str,
|
|
26
|
+
channel_id: str | None,
|
|
27
|
+
chat_id: str | None,
|
|
28
|
+
is_bot: bool,
|
|
29
|
+
max_concurrent: int,
|
|
30
|
+
) -> None:
|
|
31
|
+
"""Add a storage provider."""
|
|
32
|
+
repo_path = ctx.obj["repo"]
|
|
33
|
+
store = MetadataStore(repo_path)
|
|
34
|
+
|
|
35
|
+
if name in store.list_providers():
|
|
36
|
+
print_error(f"Provider '{name}' already exists")
|
|
37
|
+
return
|
|
38
|
+
|
|
39
|
+
if provider_type is None:
|
|
40
|
+
provider_type = name.lower()
|
|
41
|
+
|
|
42
|
+
if provider_type not in ProviderRegistry.list_providers():
|
|
43
|
+
print_error(f"Unknown provider type: {provider_type}")
|
|
44
|
+
print_info(f"Available types: {', '.join(ProviderRegistry.list_providers())}")
|
|
45
|
+
return
|
|
46
|
+
|
|
47
|
+
credentials: dict[str, str] = {"token": token}
|
|
48
|
+
if provider_type == "discord":
|
|
49
|
+
if not channel_id:
|
|
50
|
+
channel_id = click.prompt("Discord channel ID", type=str)
|
|
51
|
+
credentials["channel_id"] = channel_id
|
|
52
|
+
credentials["is_bot"] = str(is_bot).lower()
|
|
53
|
+
elif provider_type == "telegram":
|
|
54
|
+
if not chat_id:
|
|
55
|
+
chat_id = click.prompt("Telegram chat ID", type=str)
|
|
56
|
+
credentials["chat_id"] = chat_id
|
|
57
|
+
|
|
58
|
+
config = ProviderConfig(
|
|
59
|
+
name=name,
|
|
60
|
+
type=provider_type,
|
|
61
|
+
credentials=credentials,
|
|
62
|
+
settings={
|
|
63
|
+
"max_concurrent": str(max_concurrent),
|
|
64
|
+
},
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
store.set_provider_config(name, config)
|
|
68
|
+
print_success(f"Added provider '{name}' ({provider_type})")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@click.command(name="list")
|
|
72
|
+
@click.pass_context
|
|
73
|
+
def provider_list_cmd(ctx: click.Context) -> None:
|
|
74
|
+
"""List configured providers."""
|
|
75
|
+
repo_path = ctx.obj["repo"]
|
|
76
|
+
store = MetadataStore(repo_path)
|
|
77
|
+
|
|
78
|
+
providers = store.list_providers()
|
|
79
|
+
if not providers:
|
|
80
|
+
print_info("No providers configured")
|
|
81
|
+
print_info("Add one with: stash provider add --type discord <name>")
|
|
82
|
+
return
|
|
83
|
+
|
|
84
|
+
from stash.cli.output import print_table
|
|
85
|
+
rows = []
|
|
86
|
+
for name in providers:
|
|
87
|
+
config = store.get_provider_config(name)
|
|
88
|
+
ptype = config.type if config else "unknown"
|
|
89
|
+
if ptype == "discord":
|
|
90
|
+
channel = config.credentials.get("channel_id", "unknown") if config else "unknown"
|
|
91
|
+
elif ptype == "telegram":
|
|
92
|
+
channel = config.credentials.get("chat_id", "unknown") if config else "unknown"
|
|
93
|
+
else:
|
|
94
|
+
channel = "unknown"
|
|
95
|
+
rows.append([name, ptype, channel])
|
|
96
|
+
print_table("Configured Providers", ["Name", "Type", "Channel/Chat ID"], rows)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@click.command()
|
|
100
|
+
@click.argument("name")
|
|
101
|
+
@click.option("--force", "-f", is_flag=True, help="Force removal")
|
|
102
|
+
@click.pass_context
|
|
103
|
+
def provider_remove_cmd(ctx: click.Context, name: str, force: bool) -> None:
|
|
104
|
+
"""Remove a storage provider."""
|
|
105
|
+
repo_path = ctx.obj["repo"]
|
|
106
|
+
store = MetadataStore(repo_path)
|
|
107
|
+
|
|
108
|
+
if name not in store.list_providers():
|
|
109
|
+
print_error(f"Provider '{name}' not found")
|
|
110
|
+
return
|
|
111
|
+
|
|
112
|
+
if not force and not confirm(f"Remove provider '{name}'?"):
|
|
113
|
+
print_info("Cancelled")
|
|
114
|
+
return
|
|
115
|
+
|
|
116
|
+
store.remove_provider_config(name)
|
|
117
|
+
print_success(f"Removed provider '{name}'")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
provider_commands = click.Group("provider", commands={
|
|
121
|
+
"add": provider_add_cmd,
|
|
122
|
+
"list": provider_list_cmd,
|
|
123
|
+
"remove": provider_remove_cmd,
|
|
124
|
+
})
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""CLI command: put - Store a file."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
|
|
8
|
+
from stash.cli.output import confirm as confirm_prompt
|
|
9
|
+
from stash.cli.output import create_progress, format_size, print_error, print_info, print_success
|
|
10
|
+
from stash.core.chunking import ChunkConfig, Chunker
|
|
11
|
+
from stash.core.crypto import CryptoEngine
|
|
12
|
+
from stash.core.jobs import JobConfig
|
|
13
|
+
from stash.core.manifest import (
|
|
14
|
+
DistributionStrategy,
|
|
15
|
+
EncryptionInfo,
|
|
16
|
+
ManifestBuilder,
|
|
17
|
+
compute_checksum,
|
|
18
|
+
generate_file_id,
|
|
19
|
+
)
|
|
20
|
+
from stash.core.metadata import MetadataStore
|
|
21
|
+
from stash.providers import ProviderRegistry
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@click.command()
|
|
25
|
+
@click.argument("file_path", type=click.Path(exists=True, path_type=Path))
|
|
26
|
+
@click.option("--provider", help="Specific provider to use (default: first available)")
|
|
27
|
+
@click.option("--chunk-size", type=int, help="Chunk size in bytes (default: provider limit)")
|
|
28
|
+
@click.option("--strategy", type=click.Choice(["single", "split", "balanced", "replicated"]), default="single", help="Distribution strategy")
|
|
29
|
+
@click.option("--password", prompt=True, hide_input=True, help="Encryption password")
|
|
30
|
+
@click.option("--confirm/--no-confirm", default=True, help="Confirm before upload")
|
|
31
|
+
@click.pass_context
|
|
32
|
+
def put_cmd(ctx: click.Context, file_path: Path, provider: str | None, chunk_size: int | None, strategy: str, password: str, confirm: bool) -> None:
|
|
33
|
+
"""Store a file in Stash."""
|
|
34
|
+
asyncio.run(_put_async(file_path, ctx.obj["repo"], provider, chunk_size, strategy, password, confirm))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
async def _put_async(
|
|
38
|
+
file_path: Path,
|
|
39
|
+
repo_path: Path,
|
|
40
|
+
provider_name: str | None,
|
|
41
|
+
chunk_size: int | None,
|
|
42
|
+
strategy: str,
|
|
43
|
+
password: str,
|
|
44
|
+
do_confirm: bool,
|
|
45
|
+
) -> None:
|
|
46
|
+
repo = repo_path.resolve()
|
|
47
|
+
store = MetadataStore(repo)
|
|
48
|
+
|
|
49
|
+
providers = store.list_providers()
|
|
50
|
+
if not providers:
|
|
51
|
+
print_error("No providers configured. Run: stash provider add discord")
|
|
52
|
+
return
|
|
53
|
+
|
|
54
|
+
if provider_name:
|
|
55
|
+
if provider_name not in providers:
|
|
56
|
+
print_error(f"Provider '{provider_name}' not found")
|
|
57
|
+
return
|
|
58
|
+
provider_names = [provider_name]
|
|
59
|
+
else:
|
|
60
|
+
provider_names = providers
|
|
61
|
+
|
|
62
|
+
if not file_path.exists():
|
|
63
|
+
print_error(f"File not found: {file_path}")
|
|
64
|
+
return
|
|
65
|
+
|
|
66
|
+
file_size = file_path.stat().st_size
|
|
67
|
+
if file_size == 0:
|
|
68
|
+
print_error("Cannot store empty file")
|
|
69
|
+
return
|
|
70
|
+
|
|
71
|
+
if do_confirm and not confirm_prompt(f"Store '{file_path.name}' ({format_size(file_size)})?"):
|
|
72
|
+
print_info("Cancelled")
|
|
73
|
+
return
|
|
74
|
+
|
|
75
|
+
crypto = CryptoEngine()
|
|
76
|
+
file_key = crypto.generate_file_key()
|
|
77
|
+
wrapped_key = crypto.encrypt_file_key(file_key, password)
|
|
78
|
+
|
|
79
|
+
# Encrypt the filename
|
|
80
|
+
encrypted_name_chunk = crypto.encrypt_chunk(file_path.name.encode(), file_key, -1)
|
|
81
|
+
encrypted_name = encrypted_name_chunk.ciphertext.hex()
|
|
82
|
+
encrypted_name_nonce = encrypted_name_chunk.nonce
|
|
83
|
+
|
|
84
|
+
provider_configs = {}
|
|
85
|
+
for name in provider_names:
|
|
86
|
+
config = store.get_provider_config(name)
|
|
87
|
+
if not config:
|
|
88
|
+
print_error(f"Provider config not found: {name}")
|
|
89
|
+
return
|
|
90
|
+
provider_configs[name] = config
|
|
91
|
+
|
|
92
|
+
provider_instances = {}
|
|
93
|
+
for name, config in provider_configs.items():
|
|
94
|
+
instance = await ProviderRegistry.create(config.type, config)
|
|
95
|
+
provider_instances[name] = instance
|
|
96
|
+
|
|
97
|
+
limits = {name: p.get_limits() for name, p in provider_instances.items()}
|
|
98
|
+
max_chunk = min(l.max_chunk_size for l in limits.values())
|
|
99
|
+
effective_chunk_size = min(chunk_size or max_chunk, max_chunk)
|
|
100
|
+
|
|
101
|
+
chunker = Chunker(ChunkConfig(chunk_size=effective_chunk_size))
|
|
102
|
+
num_chunks = chunker.get_num_chunks(file_size)
|
|
103
|
+
|
|
104
|
+
dist_strategy = DistributionStrategy(strategy)
|
|
105
|
+
|
|
106
|
+
encryption_info = EncryptionInfo(
|
|
107
|
+
algorithm="AES-256-GCM",
|
|
108
|
+
key_size=32,
|
|
109
|
+
nonce_size=12,
|
|
110
|
+
chunk_key_derivation="HKDF-SHA256",
|
|
111
|
+
file_key_salt=file_key.salt,
|
|
112
|
+
file_key_wrapped=wrapped_key,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
builder = ManifestBuilder(
|
|
116
|
+
file_id=generate_file_id(),
|
|
117
|
+
original_name=file_path.name,
|
|
118
|
+
encrypted_name=encrypted_name,
|
|
119
|
+
encrypted_name_nonce=encrypted_name_nonce,
|
|
120
|
+
original_size=file_size,
|
|
121
|
+
chunk_size=effective_chunk_size,
|
|
122
|
+
encryption=encryption_info,
|
|
123
|
+
strategy=dist_strategy,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
print_info(f"Processing {num_chunks} chunks ({format_size(effective_chunk_size)} each)...")
|
|
127
|
+
|
|
128
|
+
JobConfig(max_workers=min(4, num_chunks))
|
|
129
|
+
|
|
130
|
+
semaphores = {name: asyncio.Semaphore(int(p.config.settings.get("max_concurrent", "3"))) for name, p in provider_instances.items()}
|
|
131
|
+
|
|
132
|
+
progress = create_progress()
|
|
133
|
+
task = progress.add_task("Uploading", total=num_chunks)
|
|
134
|
+
|
|
135
|
+
from stash.core.chunking import Chunk
|
|
136
|
+
|
|
137
|
+
async def upload_chunk(chunk: Chunk, provider_name: str) -> None:
|
|
138
|
+
async with semaphores[provider_name]:
|
|
139
|
+
# Use opaque identifier: file_id + chunk index (no filename)
|
|
140
|
+
remote_path = f"{builder.file_id}/chunk-{chunk.index:06d}"
|
|
141
|
+
remote_ref = await provider_instances[provider_name].upload_chunk(chunk, remote_path)
|
|
142
|
+
checksum = compute_checksum(chunk.data)
|
|
143
|
+
builder.add_chunk(
|
|
144
|
+
index=chunk.index,
|
|
145
|
+
size=chunk.size,
|
|
146
|
+
encrypted_size=len(remote_ref.metadata.get("size", "0")),
|
|
147
|
+
checksum=checksum,
|
|
148
|
+
provider=provider_name,
|
|
149
|
+
remote_id=remote_ref.remote_id,
|
|
150
|
+
nonce=encrypted.nonce,
|
|
151
|
+
metadata=remote_ref.metadata,
|
|
152
|
+
)
|
|
153
|
+
progress.advance(task)
|
|
154
|
+
|
|
155
|
+
with progress:
|
|
156
|
+
for chunk in chunker.chunk_file(file_path):
|
|
157
|
+
encrypted = crypto.encrypt_chunk(chunk.data, file_key, chunk.index)
|
|
158
|
+
encrypted_chunk = type(chunk)(
|
|
159
|
+
index=chunk.index,
|
|
160
|
+
data=encrypted.ciphertext,
|
|
161
|
+
offset=chunk.offset,
|
|
162
|
+
size=len(encrypted.ciphertext),
|
|
163
|
+
is_last=chunk.is_last,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
if dist_strategy == DistributionStrategy.SINGLE:
|
|
167
|
+
target = provider_names[0]
|
|
168
|
+
elif dist_strategy == DistributionStrategy.SPLIT:
|
|
169
|
+
target = provider_names[chunk.index % len(provider_names)]
|
|
170
|
+
else:
|
|
171
|
+
target = provider_names[0]
|
|
172
|
+
|
|
173
|
+
await upload_chunk(encrypted_chunk, target)
|
|
174
|
+
|
|
175
|
+
manifest = builder.build()
|
|
176
|
+
store.save_manifest(manifest)
|
|
177
|
+
|
|
178
|
+
for instance in provider_instances.values():
|
|
179
|
+
await instance.close()
|
|
180
|
+
|
|
181
|
+
print_success(f"Stored '{file_path.name}' as {manifest.file_id}")
|
|
182
|
+
print_info(f"Chunks: {manifest.chunk_count}, Size: {format_size(manifest.original_size)}")
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
put_commands = put_cmd
|