feat(core): rebrand to @lossless.zone/objectstorage
- Rename from @lossless.zone/s3container to @lossless.zone/objectstorage - Replace @push.rocks/smarts3 with @push.rocks/smartstorage - Change env var prefix from S3_ to OBJST_ - Rename S3Container class to ObjectStorageContainer - Update web component prefix from s3c- to objst- - Update UI labels, CLI flags, documentation, and Docker config
6
.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules
|
||||||
|
.pnpm-store
|
||||||
|
.nogit
|
||||||
|
.git
|
||||||
|
*.md
|
||||||
|
test
|
||||||
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules/
|
||||||
|
.nogit/
|
||||||
|
dist/
|
||||||
|
dist_*/
|
||||||
|
coverage/
|
||||||
|
.playwright-mcp/
|
||||||
54
Dockerfile
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
# gitzone dockerfile_service
|
||||||
|
## STAGE 1 // BUILD frontend bundle
|
||||||
|
FROM --platform=linux/amd64 node:22-alpine AS build
|
||||||
|
|
||||||
|
# Install pnpm
|
||||||
|
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Use verdaccio registry (hosts private packages and proxies public ones)
|
||||||
|
RUN npm config set registry https://verdaccio.lossless.digital/
|
||||||
|
|
||||||
|
# Install dependencies first (better caching)
|
||||||
|
COPY package.json pnpm-lock.yaml ./
|
||||||
|
RUN pnpm install
|
||||||
|
|
||||||
|
# Copy source and build
|
||||||
|
COPY npmextra.json ./
|
||||||
|
COPY html/ ./html/
|
||||||
|
COPY ts_web/ ./ts_web/
|
||||||
|
COPY ts_interfaces/ ./ts_interfaces/
|
||||||
|
COPY ts_bundled/ ./ts_bundled/
|
||||||
|
RUN pnpm run build
|
||||||
|
|
||||||
|
## STAGE 2 // production runtime with Deno
|
||||||
|
FROM alpine:edge AS final
|
||||||
|
|
||||||
|
# Install Deno and minimal runtime dependencies
|
||||||
|
RUN apk add --no-cache \
|
||||||
|
deno \
|
||||||
|
ca-certificates \
|
||||||
|
tini \
|
||||||
|
gcompat \
|
||||||
|
libstdc++
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy only what Deno needs at runtime
|
||||||
|
COPY deno.json ./
|
||||||
|
COPY mod.ts ./
|
||||||
|
COPY ts/ ./ts/
|
||||||
|
COPY ts_interfaces/ ./ts_interfaces/
|
||||||
|
COPY --from=build /app/ts_bundled/bundle.ts ./ts_bundled/bundle.ts
|
||||||
|
|
||||||
|
# Pre-cache Deno dependencies
|
||||||
|
RUN deno cache mod.ts
|
||||||
|
|
||||||
|
# Create storage directory
|
||||||
|
RUN mkdir -p /data
|
||||||
|
|
||||||
|
EXPOSE 9000 3000
|
||||||
|
VOLUME ["/data"]
|
||||||
|
ENTRYPOINT ["/sbin/tini", "--"]
|
||||||
|
CMD ["deno", "run", "--allow-all", "mod.ts", "server"]
|
||||||
64
changelog.md
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
## 2026-03-14 - 1.5.0 - feat(core)
|
||||||
|
rebrand from s3container to objectstorage
|
||||||
|
|
||||||
|
- Rename project from @lossless.zone/s3container to @lossless.zone/objectstorage
|
||||||
|
- Replace @push.rocks/smarts3 dependency with @push.rocks/smartstorage
|
||||||
|
- Change all environment variable prefixes from S3_ to OBJST_
|
||||||
|
- Rename S3Container class to ObjectStorageContainer
|
||||||
|
- Update all web component prefixes from s3c- to objst-
|
||||||
|
- Update UI labels, CLI flags, documentation, and Docker configuration
|
||||||
|
|
||||||
|
## 2026-03-14 - 1.4.0 - feat(docs)
|
||||||
|
expand management UI documentation with screenshots and feature walkthroughs
|
||||||
|
|
||||||
|
- Reorganizes the Management UI section into dedicated Overview, Buckets, Browser, Policies, Access Keys, and Dark Theme sections
|
||||||
|
- Adds screenshots for core UI views and workflows such as inline code editing, PDF viewing, attaching policies, and adding access keys
|
||||||
|
- Clarifies browser capabilities including syntax-highlighted code preview and save-back-to-S3 editing
|
||||||
|
|
||||||
|
## 2026-03-14 - 1.3.0 - feat(web-router)
|
||||||
|
add URL-based view routing and synchronize navigation state across the web UI
|
||||||
|
|
||||||
|
- introduce a dedicated app router that maps valid views to browser paths and handles initial route resolution
|
||||||
|
- update app shell and bucket navigation to use router-based view changes instead of directly mutating UI state
|
||||||
|
- refresh policy and bucket management modals by updating modal content through the created modal instance
|
||||||
|
|
||||||
|
## 2026-03-14 - 1.2.0 - feat(readme)
|
||||||
|
add comprehensive project documentation for setup, configuration, UI, and S3 usage
|
||||||
|
|
||||||
|
- Adds an initial README covering Docker and Deno quick start flows
|
||||||
|
- Documents environment variables, CLI flags, management UI views, and named policy behavior
|
||||||
|
- Includes S3 client usage examples, Docker deployment guidance, architecture overview, and development instructions
|
||||||
|
|
||||||
|
## 2026-03-14 - 1.1.3 - fix(package)
|
||||||
|
update package metadata
|
||||||
|
|
||||||
|
- Adjusts package.json with a single-line metadata change.
|
||||||
|
|
||||||
|
## 2026-03-14 - 1.1.2 - fix(package)
|
||||||
|
update package metadata
|
||||||
|
|
||||||
|
- Adjusts package.json with a single-line metadata change.
|
||||||
|
|
||||||
|
## 2026-03-14 - 1.1.1 - fix(package)
|
||||||
|
update package metadata
|
||||||
|
|
||||||
|
- Adjusts package.json with a single-line metadata change.
|
||||||
|
|
||||||
|
## 2026-03-14 - 1.1.0 - feat(policies)
|
||||||
|
add named policy management with bucket attachments in the ops API and web UI
|
||||||
|
|
||||||
|
- introduces a PolicyManager that persists named policies and bucket attachments, then recomputes and applies merged bucket policies automatically
|
||||||
|
- adds typed ops server endpoints and shared request/data interfaces for creating, updating, deleting, listing, and attaching policies
|
||||||
|
- adds a dedicated Policies view in the web UI and replaces raw bucket policy JSON editing with attach/detach management for named policies
|
||||||
|
- cleans up policy attachments when buckets are deleted
|
||||||
|
|
||||||
|
## 2026-03-14 - 1.0.0 - initial release
|
||||||
|
Initial release of s3container, a MinIO replacement built on smarts3 with a dees-catalog-based management UI.
|
||||||
|
|
||||||
|
- Added an S3-compatible storage server powered by `@push.rocks/smarts3`
|
||||||
|
- Added a management UI with `dees-s3-browser` for object browsing
|
||||||
|
- Added bucket management with policy support
|
||||||
|
- Added credential management for access key / secret key pairs
|
||||||
|
- Added Docker containerization with a multi-stage build using Node.js and Deno
|
||||||
46
deno.json
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
{
|
||||||
|
"name": "@lossless.zone/objectstorage",
|
||||||
|
"version": "1.4.0",
|
||||||
|
"exports": "./mod.ts",
|
||||||
|
"nodeModulesDir": "auto",
|
||||||
|
"tasks": {
|
||||||
|
"test": "deno test --allow-all test/",
|
||||||
|
"dev": "pnpm run watch"
|
||||||
|
},
|
||||||
|
"imports": {
|
||||||
|
"@push.rocks/smartstorage": "npm:@push.rocks/smartstorage@^6.0.1",
|
||||||
|
"@push.rocks/smartbucket": "npm:@push.rocks/smartbucket@^4.3.0",
|
||||||
|
"@aws-sdk/client-s3": "npm:@aws-sdk/client-s3@^3.937.0",
|
||||||
|
"@api.global/typedrequest-interfaces": "npm:@api.global/typedrequest-interfaces@^3.0.19",
|
||||||
|
"@api.global/typedrequest": "npm:@api.global/typedrequest@^3.2.6",
|
||||||
|
"@api.global/typedserver": "npm:@api.global/typedserver@^8.3.1",
|
||||||
|
"@push.rocks/smartguard": "npm:@push.rocks/smartguard@^3.1.0",
|
||||||
|
"@push.rocks/smartjwt": "npm:@push.rocks/smartjwt@^2.2.1"
|
||||||
|
},
|
||||||
|
"compilerOptions": {
|
||||||
|
"lib": [
|
||||||
|
"deno.window",
|
||||||
|
"deno.ns"
|
||||||
|
],
|
||||||
|
"strict": true,
|
||||||
|
"noImplicitAny": true,
|
||||||
|
"strictNullChecks": true,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false
|
||||||
|
},
|
||||||
|
"fmt": {
|
||||||
|
"useTabs": false,
|
||||||
|
"lineWidth": 100,
|
||||||
|
"indentWidth": 2,
|
||||||
|
"semiColons": true,
|
||||||
|
"singleQuote": true,
|
||||||
|
"proseWrap": "preserve"
|
||||||
|
},
|
||||||
|
"lint": {
|
||||||
|
"rules": {
|
||||||
|
"tags": [
|
||||||
|
"recommended"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
docs/01-overview.png
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
docs/02-buckets.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
docs/03-browser.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
docs/04-policies.png
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
docs/05-access-keys.png
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
docs/06-code-editing.png
Normal file
|
After Width: | Height: | Size: 60 KiB |
BIN
docs/06-config.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
docs/07-pdf-viewer.png
Normal file
|
After Width: | Height: | Size: 63 KiB |
BIN
docs/08-attach-policy.png
Normal file
|
After Width: | Height: | Size: 103 KiB |
BIN
docs/09-add-access-key.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
docs/10-dark-theme.png
Normal file
|
After Width: | Height: | Size: 60 KiB |
BIN
docs/11-context-menu.png
Normal file
|
After Width: | Height: | Size: 48 KiB |
33
html/index.html
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta
|
||||||
|
name="viewport"
|
||||||
|
content="user-scalable=0, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width, height=device-height"
|
||||||
|
/>
|
||||||
|
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
|
||||||
|
<meta name="theme-color" content="#000000" />
|
||||||
|
<title>ObjectStorage</title>
|
||||||
|
<link rel="preconnect" href="https://assetbroker.lossless.one/" crossorigin>
|
||||||
|
<link rel="stylesheet" href="https://assetbroker.lossless.one/fonts/fonts.css">
|
||||||
|
<style>
|
||||||
|
html {
|
||||||
|
-ms-text-size-adjust: 100%;
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
position: relative;
|
||||||
|
background: #000;
|
||||||
|
margin: 0px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<noscript>
|
||||||
|
<p style="color: #fff; text-align: center; margin-top: 100px;">
|
||||||
|
JavaScript is required to run the ObjectStorage dashboard.
|
||||||
|
</p>
|
||||||
|
</noscript>
|
||||||
|
</body>
|
||||||
|
<script defer type="module" src="/bundle.js"></script>
|
||||||
|
</html>
|
||||||
11
mod.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
#!/usr/bin/env -S deno run --allow-all
|
||||||
|
import { runCli } from './ts/index.ts';
|
||||||
|
|
||||||
|
if (import.meta.main) {
|
||||||
|
try {
|
||||||
|
await runCli();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
Deno.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
44
npmextra.json
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
{
|
||||||
|
"@git.zone/tsbundle": {
|
||||||
|
"bundles": [
|
||||||
|
{
|
||||||
|
"from": "./ts_web/index.ts",
|
||||||
|
"to": "./ts_bundled/bundle.ts",
|
||||||
|
"outputMode": "base64ts",
|
||||||
|
"bundler": "esbuild",
|
||||||
|
"production": true,
|
||||||
|
"includeFiles": [{"from": "./html/index.html", "to": "index.html"}]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"@git.zone/tsdocker": {
|
||||||
|
"registries": ["code.foss.global"],
|
||||||
|
"registryRepoMap": {
|
||||||
|
"code.foss.global": "lossless.zone/objectstorage"
|
||||||
|
},
|
||||||
|
"platforms": ["linux/amd64", "linux/arm64"]
|
||||||
|
},
|
||||||
|
"@git.zone/tswatch": {
|
||||||
|
"bundles": [
|
||||||
|
{
|
||||||
|
"from": "./ts_web/index.ts",
|
||||||
|
"to": "./ts_bundled/bundle.ts",
|
||||||
|
"outputMode": "base64ts",
|
||||||
|
"bundler": "esbuild",
|
||||||
|
"production": true,
|
||||||
|
"watchPatterns": ["./ts_web/**/*", "./html/**/*"],
|
||||||
|
"includeFiles": [{"from": "./html/index.html", "to": "index.html"}]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"watchers": [
|
||||||
|
{
|
||||||
|
"name": "backend",
|
||||||
|
"watch": ["./ts/**/*", "./ts_interfaces/**/*", "./ts_bundled/**/*"],
|
||||||
|
"command": "deno run --allow-all mod.ts server --ephemeral",
|
||||||
|
"restart": true,
|
||||||
|
"debounce": 500,
|
||||||
|
"runOnStart": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
31
package.json
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"name": "@lossless.zone/objectstorage",
|
||||||
|
"version": "1.4.0",
|
||||||
|
"description": "object storage server with management UI powered by smartstorage",
|
||||||
|
"main": "mod.ts",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"watch": "tswatch",
|
||||||
|
"build": "tsbundle",
|
||||||
|
"bundle": "tsbundle",
|
||||||
|
"build:docker": "tsdocker build --verbose",
|
||||||
|
"start:docker": "docker stop objectstorage 2>/dev/null; docker rm objectstorage 2>/dev/null; docker build --load -t objectstorage:latest . && docker run --rm --name objectstorage -p 9000:9000 -p 3000:3000 -v objectstorage-data:/data objectstorage:latest"
|
||||||
|
},
|
||||||
|
"author": "Lossless GmbH",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@api.global/typedrequest-interfaces": "^3.0.19",
|
||||||
|
"@design.estate/dees-catalog": "^3.48.0",
|
||||||
|
"@design.estate/dees-element": "^2.2.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@git.zone/tsbundle": "^2.9.0",
|
||||||
|
"@git.zone/tsdocker": "^2.0.0",
|
||||||
|
"@git.zone/tswatch": "^3.2.0"
|
||||||
|
},
|
||||||
|
"pnpm": {
|
||||||
|
"onlyBuiltDependencies": [
|
||||||
|
"esbuild"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
5638
pnpm-lock.yaml
generated
Normal file
349
readme.md
Normal file
@@ -0,0 +1,349 @@
|
|||||||
|
# @lossless.zone/objectstorage
|
||||||
|
|
||||||
|
> S3-compatible object storage server with a slick management UI — powered by [`smartstorage`](https://code.foss.global/push.rocks/smartstorage).
|
||||||
|
|
||||||
|
**objectstorage** gives you a fully featured, self-hosted S3-compatible storage server with a beautiful web-based management interface — all in a single Docker image. No Java, no bloat, no fuss.
|
||||||
|
|
||||||
|
Built on Deno for the backend and `@design.estate/dees-catalog` for a polished UI, it speaks the S3 protocol out of the box while adding powerful management features on top.
|
||||||
|
|
||||||
|
## Issue Reporting and Security
|
||||||
|
|
||||||
|
For reporting bugs, issues, or security vulnerabilities, please visit [community.foss.global/](https://community.foss.global/). This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Full S3 API compatibility** — Works with any S3 client, SDK, or tool (AWS CLI, boto3, etc.)
|
||||||
|
- **Management UI** — Web dashboard for buckets, objects, policies, credentials, and server config
|
||||||
|
- **Finder-style object browser** — Column view with file preview, drag-and-drop upload, move/rename, context menus
|
||||||
|
- **Named policy management** — Create reusable IAM-style policies, attach them to multiple buckets
|
||||||
|
- **Credential management** — Add/remove access keys through the UI with live-reload
|
||||||
|
- **Single Docker image** — Multi-arch (`amd64` + `arm64`), tiny Alpine-based image
|
||||||
|
- **Fast** — Rust-powered storage engine via `smartstorage`, Deno runtime for management layer
|
||||||
|
- **Secure by default** — JWT-based admin auth, S3 SigV4 authentication, bucket policies
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Docker (recommended)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -d \
|
||||||
|
--name objectstorage \
|
||||||
|
-p 9000:9000 \
|
||||||
|
-p 3000:3000 \
|
||||||
|
-v objstdata:/data \
|
||||||
|
-e OBJST_ACCESS_KEY=myadminkey \
|
||||||
|
-e OBJST_SECRET_KEY=mysupersecret \
|
||||||
|
-e OBJST_ADMIN_PASSWORD=myuipassword \
|
||||||
|
code.foss.global/lossless.zone/objectstorage:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
Then open **http://localhost:3000** for the management UI and use **http://localhost:9000** as your S3 endpoint.
|
||||||
|
|
||||||
|
### Deno (development)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone and install frontend dependencies
|
||||||
|
git clone ssh://git@code.foss.global:29419/lossless.zone/objectstorage.git
|
||||||
|
cd objectstorage
|
||||||
|
pnpm install
|
||||||
|
pnpm run build
|
||||||
|
|
||||||
|
# Run in ephemeral mode (data stored in .nogit/objstdata)
|
||||||
|
deno run --allow-all mod.ts server --ephemeral
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
objectstorage is configured through environment variables, CLI flags, or programmatic config. **Environment variables take precedence** over CLI flags.
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
| Variable | Description | Default |
|
||||||
|
|---|---|---|
|
||||||
|
| `OBJST_PORT` | Storage API port | `9000` |
|
||||||
|
| `UI_PORT` | Management UI port | `3000` |
|
||||||
|
| `OBJST_STORAGE_DIR` | Data storage directory | `/data` |
|
||||||
|
| `OBJST_ACCESS_KEY` | Access key ID | `admin` |
|
||||||
|
| `OBJST_SECRET_KEY` | Secret access key | `admin` |
|
||||||
|
| `OBJST_ADMIN_PASSWORD` | Admin UI password | `admin` |
|
||||||
|
| `OBJST_REGION` | Storage region identifier | `us-east-1` |
|
||||||
|
|
||||||
|
### CLI Flags
|
||||||
|
|
||||||
|
```bash
|
||||||
|
deno run --allow-all mod.ts server [options]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--storage-port <port> Storage API port (default: 9000)
|
||||||
|
--ui-port <port> Management UI port (default: 3000)
|
||||||
|
--storage-dir <path> Storage directory (default: /data)
|
||||||
|
--ephemeral Use ./.nogit/objstdata for storage (dev mode)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Management UI
|
||||||
|
|
||||||
|
The web-based management UI is served on the UI port (default: `3000`). Log in with username `admin` and the configured admin password.
|
||||||
|
|
||||||
|
### Overview
|
||||||
|
|
||||||
|
Dashboard showing server status, uptime, storage usage, bucket count, and connection info.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### Buckets
|
||||||
|
|
||||||
|
Create/delete buckets. View object counts and sizes. Attach/detach named policies per bucket.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### Browser
|
||||||
|
|
||||||
|
Finder-style column browser for objects. Upload, download, preview, move, rename, and delete files and folders — with syntax-highlighted code preview.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
#### Inline Code Editing
|
||||||
|
|
||||||
|
Click Edit on any text file to open the built-in Monaco editor with syntax highlighting, language detection, and save-back-to-storage.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
#### PDF Viewer
|
||||||
|
|
||||||
|
PDF files render inline with a full-featured viewer — page navigation, zoom, fit-to-page, thumbnails, download, and print.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### Policies
|
||||||
|
|
||||||
|
Create reusable named policies with IAM-style S3 statements. Attach policies to multiple buckets at once.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
#### Attaching Policies to Buckets
|
||||||
|
|
||||||
|
From the Buckets view, click the policy icon on any bucket to see attached and available policies. Attach or detach with one click.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### Access Keys
|
||||||
|
|
||||||
|
Add and remove access credentials. Secret keys are masked. Changes take effect immediately — no server restart needed.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
#### Adding Access Keys
|
||||||
|
|
||||||
|
Click "Add Key" to create new access credentials. They're immediately available for API authentication.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### Dark Theme
|
||||||
|
|
||||||
|
Full dark theme support — automatically follows your system preference via `prefers-color-scheme`.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Named Policy System
|
||||||
|
|
||||||
|
objectstorage adds a **named policy** abstraction on top of standard S3 bucket policies. Instead of editing raw JSON per bucket, you define reusable policy templates and attach them to any number of buckets.
|
||||||
|
|
||||||
|
### How it works
|
||||||
|
|
||||||
|
1. **Create a named policy** in the Policies view — give it a name, description, and S3 policy statements
|
||||||
|
2. **Attach it to buckets** — from the Policies view or the Buckets view
|
||||||
|
3. **objectstorage merges** all attached policy statements into a single S3 policy document and applies it to the bucket automatically
|
||||||
|
|
||||||
|
### `${bucket}` placeholder
|
||||||
|
|
||||||
|
Use `${bucket}` in your policy's `Resource` ARN and it will be replaced with the actual bucket name when applied:
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"Sid": "PublicRead",
|
||||||
|
"Effect": "Allow",
|
||||||
|
"Principal": "*",
|
||||||
|
"Action": "s3:GetObject",
|
||||||
|
"Resource": "arn:aws:s3:::${bucket}/*"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
This lets one policy like "Public Read" work across many buckets without hardcoding names.
|
||||||
|
|
||||||
|
### Lifecycle
|
||||||
|
|
||||||
|
- **Updating a policy** automatically recomputes and re-applies the merged S3 policy on all attached buckets
|
||||||
|
- **Deleting a policy** detaches it from all buckets and recomputes each
|
||||||
|
- **Deleting a bucket** cleans up its policy attachments automatically
|
||||||
|
|
||||||
|
## S3 API Usage
|
||||||
|
|
||||||
|
Use any S3-compatible client to interact with the storage. Here's an example with the AWS CLI:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Configure AWS CLI
|
||||||
|
aws configure set aws_access_key_id admin
|
||||||
|
aws configure set aws_secret_access_key admin
|
||||||
|
aws configure set default.region us-east-1
|
||||||
|
|
||||||
|
# Create a bucket
|
||||||
|
aws --endpoint-url http://localhost:9000 s3 mb s3://my-bucket
|
||||||
|
|
||||||
|
# Upload a file
|
||||||
|
aws --endpoint-url http://localhost:9000 s3 cp myfile.txt s3://my-bucket/
|
||||||
|
|
||||||
|
# List objects
|
||||||
|
aws --endpoint-url http://localhost:9000 s3 ls s3://my-bucket/
|
||||||
|
|
||||||
|
# Download a file
|
||||||
|
aws --endpoint-url http://localhost:9000 s3 cp s3://my-bucket/myfile.txt ./downloaded.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
### Node.js / TypeScript (AWS SDK v3)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
|
||||||
|
|
||||||
|
const s3 = new S3Client({
|
||||||
|
endpoint: 'http://localhost:9000',
|
||||||
|
region: 'us-east-1',
|
||||||
|
credentials: {
|
||||||
|
accessKeyId: 'admin',
|
||||||
|
secretAccessKey: 'admin',
|
||||||
|
},
|
||||||
|
forcePathStyle: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await s3.send(new PutObjectCommand({
|
||||||
|
Bucket: 'my-bucket',
|
||||||
|
Key: 'hello.txt',
|
||||||
|
Body: 'Hello, S3!',
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
|
||||||
|
## Docker
|
||||||
|
|
||||||
|
### Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build for the native platform
|
||||||
|
pnpm run build:docker
|
||||||
|
|
||||||
|
# Or build and run directly
|
||||||
|
pnpm run start:docker
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker Compose
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
objectstorage:
|
||||||
|
image: code.foss.global/lossless.zone/objectstorage:latest
|
||||||
|
ports:
|
||||||
|
- "9000:9000" # S3 API
|
||||||
|
- "3000:3000" # Management UI
|
||||||
|
volumes:
|
||||||
|
- objstdata:/data
|
||||||
|
environment:
|
||||||
|
OBJST_ACCESS_KEY: myadminkey
|
||||||
|
OBJST_SECRET_KEY: mysupersecret
|
||||||
|
OBJST_ADMIN_PASSWORD: securepw123
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
objstdata:
|
||||||
|
```
|
||||||
|
|
||||||
|
### Image details
|
||||||
|
|
||||||
|
- **Base**: `alpine:edge` with Deno runtime
|
||||||
|
- **Architectures**: `linux/amd64`, `linux/arm64`
|
||||||
|
- **Size**: ~150MB compressed
|
||||||
|
- **Init system**: `tini` for proper signal handling
|
||||||
|
- **Exposed ports**: `9000` (S3), `3000` (UI)
|
||||||
|
- **Volume**: `/data` — all bucket data and config persisted here
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────┐
|
||||||
|
│ objectstorage │
|
||||||
|
│ │
|
||||||
|
│ ┌──────────────┐ ┌───────────────────────┐ │
|
||||||
|
│ │ Management │ │ Storage Engine │ │
|
||||||
|
│ │ UI (port │ │ (smartstorage/ │ │
|
||||||
|
│ │ 3000) │ │ ruststorage) │ │
|
||||||
|
│ │ │ │ (port 9000) │ │
|
||||||
|
│ │ dees-catalog │ │ • S3 API compat │ │
|
||||||
|
│ │ SPA bundle │ │ • SigV4 auth │ │
|
||||||
|
│ └──────┬───────┘ │ • Bucket policies │ │
|
||||||
|
│ │ │ • Rust binary engine │ │
|
||||||
|
│ ┌──────▼───────┐ └───────────────────────┘ │
|
||||||
|
│ │ OpsServer │ │
|
||||||
|
│ │ (TypedReq │──── AWS SDK S3 Client ────────│
|
||||||
|
│ │ handlers) │ (manages own storage) │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ • Admin auth │ │
|
||||||
|
│ │ • CRUD APIs │ │
|
||||||
|
│ │ • Policy mgr │ │
|
||||||
|
│ └──────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ┌──────────────────────────────────────────────┐│
|
||||||
|
│ │ /data (persistent volume) ││
|
||||||
|
│ │ buckets/ .policies/ .objectstorage/ ││
|
||||||
|
│ └──────────────────────────────────────────────┘│
|
||||||
|
└─────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tech Stack
|
||||||
|
|
||||||
|
| Layer | Technology |
|
||||||
|
|---|---|
|
||||||
|
| **Storage Engine** | `@push.rocks/smartstorage` (Rust binary via `ruststorage`) |
|
||||||
|
| **Runtime** | Deno |
|
||||||
|
| **Management API** | `@api.global/typedrequest` + `@api.global/typedserver` |
|
||||||
|
| **Auth** | JWT via `@push.rocks/smartjwt`, S3 SigV4 |
|
||||||
|
| **Frontend** | `@design.estate/dees-element` (LitElement) + `@design.estate/dees-catalog` |
|
||||||
|
| **Frontend Build** | esbuild via `@git.zone/tsbundle` |
|
||||||
|
| **Docker** | Multi-stage (Node.js build → Alpine + Deno runtime) |
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install dependencies
|
||||||
|
pnpm install
|
||||||
|
|
||||||
|
# Watch mode — auto-rebuilds frontend + restarts backend
|
||||||
|
pnpm run watch
|
||||||
|
|
||||||
|
# Build frontend bundle only
|
||||||
|
pnpm run build
|
||||||
|
|
||||||
|
# Type check backend
|
||||||
|
deno check mod.ts
|
||||||
|
|
||||||
|
# Run in development mode
|
||||||
|
deno run --allow-all mod.ts server --ephemeral
|
||||||
|
```
|
||||||
|
|
||||||
|
## License and Legal Information
|
||||||
|
|
||||||
|
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](./LICENSE) file.
|
||||||
|
|
||||||
|
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
### Trademarks
|
||||||
|
|
||||||
|
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.
|
||||||
|
|
||||||
|
Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.
|
||||||
|
|
||||||
|
### Company Information
|
||||||
|
|
||||||
|
Task Venture Capital GmbH
|
||||||
|
Registered at District Court Bremen HRB 35230 HB, Germany
|
||||||
|
|
||||||
|
For any legal inquiries or further information, please contact us via email at hello@task.vc.
|
||||||
|
|
||||||
|
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.
|
||||||
8
ts/00_commitinfo_data.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* autocreated commitinfo by @push.rocks/commitinfo
|
||||||
|
*/
|
||||||
|
export const commitinfo = {
|
||||||
|
name: '@lossless.zone/objectstorage',
|
||||||
|
version: '1.4.0',
|
||||||
|
description: 'object storage server with management UI powered by smartstorage'
|
||||||
|
}
|
||||||
376
ts/classes/objectstoragecontainer.ts
Normal file
@@ -0,0 +1,376 @@
|
|||||||
|
import * as plugins from '../plugins.ts';
|
||||||
|
import { type IObjectStorageConfig, defaultConfig } from '../types.ts';
|
||||||
|
import type * as interfaces from '../../ts_interfaces/index.ts';
|
||||||
|
import { OpsServer } from '../opsserver/index.ts';
|
||||||
|
import { PolicyManager } from './policymanager.ts';
|
||||||
|
|
||||||
|
export class ObjectStorageContainer {
|
||||||
|
public config: IObjectStorageConfig;
|
||||||
|
public smartstorageInstance!: plugins.smartstorage.SmartStorage;
|
||||||
|
public s3Client!: plugins.S3Client;
|
||||||
|
public opsServer: OpsServer;
|
||||||
|
public policyManager: PolicyManager;
|
||||||
|
public startedAt: number = 0;
|
||||||
|
|
||||||
|
constructor(configArg?: Partial<IObjectStorageConfig>) {
|
||||||
|
this.config = { ...defaultConfig, ...configArg };
|
||||||
|
|
||||||
|
// Read environment variables (override config)
|
||||||
|
const envPort = Deno.env.get('OBJST_PORT');
|
||||||
|
if (envPort) this.config.objstPort = parseInt(envPort, 10);
|
||||||
|
|
||||||
|
const envUiPort = Deno.env.get('UI_PORT');
|
||||||
|
if (envUiPort) this.config.uiPort = parseInt(envUiPort, 10);
|
||||||
|
|
||||||
|
const envStorageDir = Deno.env.get('OBJST_STORAGE_DIR');
|
||||||
|
if (envStorageDir) this.config.storageDirectory = envStorageDir;
|
||||||
|
|
||||||
|
const envAccessKey = Deno.env.get('OBJST_ACCESS_KEY');
|
||||||
|
const envSecretKey = Deno.env.get('OBJST_SECRET_KEY');
|
||||||
|
if (envAccessKey && envSecretKey) {
|
||||||
|
this.config.accessCredentials = [
|
||||||
|
{ accessKeyId: envAccessKey, secretAccessKey: envSecretKey },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const envAdminPassword = Deno.env.get('OBJST_ADMIN_PASSWORD');
|
||||||
|
if (envAdminPassword) this.config.adminPassword = envAdminPassword;
|
||||||
|
|
||||||
|
const envRegion = Deno.env.get('OBJST_REGION');
|
||||||
|
if (envRegion) this.config.region = envRegion;
|
||||||
|
|
||||||
|
this.opsServer = new OpsServer(this);
|
||||||
|
this.policyManager = new PolicyManager(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async start(): Promise<void> {
|
||||||
|
console.log(`Starting ObjectStorage...`);
|
||||||
|
console.log(` Storage port: ${this.config.objstPort}`);
|
||||||
|
console.log(` UI port: ${this.config.uiPort}`);
|
||||||
|
console.log(` Storage: ${this.config.storageDirectory}`);
|
||||||
|
console.log(` Region: ${this.config.region}`);
|
||||||
|
|
||||||
|
// Start smartstorage
|
||||||
|
this.smartstorageInstance = await plugins.smartstorage.SmartStorage.createAndStart({
|
||||||
|
server: {
|
||||||
|
port: this.config.objstPort,
|
||||||
|
address: '0.0.0.0',
|
||||||
|
region: this.config.region,
|
||||||
|
},
|
||||||
|
storage: {
|
||||||
|
directory: this.config.storageDirectory,
|
||||||
|
},
|
||||||
|
auth: {
|
||||||
|
enabled: true,
|
||||||
|
credentials: this.config.accessCredentials,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.startedAt = Date.now();
|
||||||
|
console.log(`Storage server started on port ${this.config.objstPort}`);
|
||||||
|
|
||||||
|
// Create S3 client for management operations
|
||||||
|
const descriptor = await this.smartstorageInstance.getStorageDescriptor();
|
||||||
|
this.s3Client = new plugins.S3Client({
|
||||||
|
endpoint: `http://${descriptor.endpoint}:${descriptor.port}`,
|
||||||
|
region: this.config.region,
|
||||||
|
credentials: {
|
||||||
|
accessKeyId: descriptor.accessKey,
|
||||||
|
secretAccessKey: descriptor.accessSecret,
|
||||||
|
},
|
||||||
|
forcePathStyle: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load named policies
|
||||||
|
await this.policyManager.load();
|
||||||
|
|
||||||
|
// Start UI server
|
||||||
|
await this.opsServer.start(this.config.uiPort);
|
||||||
|
console.log(`Management UI started on port ${this.config.uiPort}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async stop(): Promise<void> {
|
||||||
|
console.log('Stopping ObjectStorage...');
|
||||||
|
await this.opsServer.stop();
|
||||||
|
await this.smartstorageInstance.stop();
|
||||||
|
console.log('ObjectStorage stopped.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Management methods ──
|
||||||
|
|
||||||
|
public async listBuckets(): Promise<interfaces.data.IBucketInfo[]> {
|
||||||
|
const response = await this.s3Client.send(new plugins.ListBucketsCommand({}));
|
||||||
|
const buckets: interfaces.data.IBucketInfo[] = [];
|
||||||
|
|
||||||
|
for (const bucket of response.Buckets || []) {
|
||||||
|
const name = bucket.Name || '';
|
||||||
|
const creationDate = bucket.CreationDate?.getTime() || 0;
|
||||||
|
|
||||||
|
// Get object count and size for each bucket
|
||||||
|
let objectCount = 0;
|
||||||
|
let totalSizeBytes = 0;
|
||||||
|
let continuationToken: string | undefined;
|
||||||
|
|
||||||
|
do {
|
||||||
|
const listResp = await this.s3Client.send(
|
||||||
|
new plugins.ListObjectsV2Command({
|
||||||
|
Bucket: name,
|
||||||
|
ContinuationToken: continuationToken,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const obj of listResp.Contents || []) {
|
||||||
|
objectCount++;
|
||||||
|
totalSizeBytes += obj.Size || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
continuationToken = listResp.IsTruncated ? listResp.NextContinuationToken : undefined;
|
||||||
|
} while (continuationToken);
|
||||||
|
|
||||||
|
buckets.push({ name, creationDate, objectCount, totalSizeBytes });
|
||||||
|
}
|
||||||
|
|
||||||
|
return buckets;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async createBucket(bucketName: string): Promise<void> {
|
||||||
|
await this.smartstorageInstance.createBucket(bucketName);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async deleteBucket(bucketName: string): Promise<void> {
|
||||||
|
await this.s3Client.send(new plugins.DeleteBucketCommand({ Bucket: bucketName }));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async listObjects(
|
||||||
|
bucketName: string,
|
||||||
|
prefix?: string,
|
||||||
|
delimiter?: string,
|
||||||
|
maxKeys?: number,
|
||||||
|
): Promise<interfaces.data.IObjectListResult> {
|
||||||
|
const response = await this.s3Client.send(
|
||||||
|
new plugins.ListObjectsV2Command({
|
||||||
|
Bucket: bucketName,
|
||||||
|
Prefix: prefix || '',
|
||||||
|
Delimiter: delimiter || '/',
|
||||||
|
MaxKeys: maxKeys || 1000,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const objects: interfaces.data.IObjectInfo[] = (response.Contents || []).map((obj) => ({
|
||||||
|
key: obj.Key || '',
|
||||||
|
size: obj.Size || 0,
|
||||||
|
lastModified: obj.LastModified?.getTime() || 0,
|
||||||
|
etag: obj.ETag || '',
|
||||||
|
contentType: '',
|
||||||
|
}));
|
||||||
|
|
||||||
|
const commonPrefixes = (response.CommonPrefixes || []).map((p) => p.Prefix || '');
|
||||||
|
|
||||||
|
return {
|
||||||
|
objects,
|
||||||
|
commonPrefixes,
|
||||||
|
isTruncated: response.IsTruncated || false,
|
||||||
|
currentPrefix: prefix || '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async deleteObject(bucketName: string, key: string): Promise<void> {
|
||||||
|
await this.s3Client.send(
|
||||||
|
new plugins.DeleteObjectCommand({ Bucket: bucketName, Key: key }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getObject(bucketName: string, key: string): Promise<{
|
||||||
|
content: string;
|
||||||
|
contentType: string;
|
||||||
|
size: number;
|
||||||
|
lastModified: string;
|
||||||
|
}> {
|
||||||
|
const response = await this.s3Client.send(
|
||||||
|
new plugins.GetObjectCommand({ Bucket: bucketName, Key: key }),
|
||||||
|
);
|
||||||
|
const bodyBytes = await response.Body!.transformToByteArray();
|
||||||
|
// Convert to base64
|
||||||
|
let binary = '';
|
||||||
|
for (const byte of bodyBytes) {
|
||||||
|
binary += String.fromCharCode(byte);
|
||||||
|
}
|
||||||
|
const base64Content = btoa(binary);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: base64Content,
|
||||||
|
contentType: response.ContentType || 'application/octet-stream',
|
||||||
|
size: response.ContentLength || 0,
|
||||||
|
lastModified: response.LastModified?.toISOString() || new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async putObject(
|
||||||
|
bucketName: string,
|
||||||
|
key: string,
|
||||||
|
base64Content: string,
|
||||||
|
contentType: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const binaryString = atob(base64Content);
|
||||||
|
const bytes = new Uint8Array(binaryString.length);
|
||||||
|
for (let i = 0; i < binaryString.length; i++) {
|
||||||
|
bytes[i] = binaryString.charCodeAt(i);
|
||||||
|
}
|
||||||
|
await this.s3Client.send(
|
||||||
|
new plugins.PutObjectCommand({
|
||||||
|
Bucket: bucketName,
|
||||||
|
Key: key,
|
||||||
|
Body: bytes,
|
||||||
|
ContentType: contentType,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async deletePrefix(bucketName: string, prefix: string): Promise<void> {
|
||||||
|
let continuationToken: string | undefined;
|
||||||
|
do {
|
||||||
|
const listResp = await this.s3Client.send(
|
||||||
|
new plugins.ListObjectsV2Command({
|
||||||
|
Bucket: bucketName,
|
||||||
|
Prefix: prefix,
|
||||||
|
ContinuationToken: continuationToken,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
for (const obj of listResp.Contents || []) {
|
||||||
|
if (obj.Key) {
|
||||||
|
await this.s3Client.send(
|
||||||
|
new plugins.DeleteObjectCommand({ Bucket: bucketName, Key: obj.Key }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continuationToken = listResp.IsTruncated ? listResp.NextContinuationToken : undefined;
|
||||||
|
} while (continuationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getObjectUrl(bucketName: string, key: string): Promise<string> {
|
||||||
|
const descriptor = await this.smartstorageInstance.getStorageDescriptor();
|
||||||
|
return `http://${descriptor.endpoint}:${descriptor.port}/${bucketName}/${key}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async moveObject(
|
||||||
|
bucketName: string,
|
||||||
|
sourceKey: string,
|
||||||
|
destKey: string,
|
||||||
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
|
try {
|
||||||
|
await this.s3Client.send(
|
||||||
|
new plugins.CopyObjectCommand({
|
||||||
|
Bucket: bucketName,
|
||||||
|
CopySource: `${bucketName}/${sourceKey}`,
|
||||||
|
Key: destKey,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await this.s3Client.send(
|
||||||
|
new plugins.DeleteObjectCommand({ Bucket: bucketName, Key: sourceKey }),
|
||||||
|
);
|
||||||
|
return { success: true };
|
||||||
|
} catch (err: any) {
|
||||||
|
return { success: false, error: err.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async movePrefix(
|
||||||
|
bucketName: string,
|
||||||
|
sourcePrefix: string,
|
||||||
|
destPrefix: string,
|
||||||
|
): Promise<{ success: boolean; movedCount?: number; error?: string }> {
|
||||||
|
try {
|
||||||
|
let movedCount = 0;
|
||||||
|
let continuationToken: string | undefined;
|
||||||
|
do {
|
||||||
|
const listResp = await this.s3Client.send(
|
||||||
|
new plugins.ListObjectsV2Command({
|
||||||
|
Bucket: bucketName,
|
||||||
|
Prefix: sourcePrefix,
|
||||||
|
ContinuationToken: continuationToken,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
for (const obj of listResp.Contents || []) {
|
||||||
|
if (!obj.Key) continue;
|
||||||
|
const newKey = destPrefix + obj.Key.slice(sourcePrefix.length);
|
||||||
|
await this.s3Client.send(
|
||||||
|
new plugins.CopyObjectCommand({
|
||||||
|
Bucket: bucketName,
|
||||||
|
CopySource: `${bucketName}/${obj.Key}`,
|
||||||
|
Key: newKey,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await this.s3Client.send(
|
||||||
|
new plugins.DeleteObjectCommand({ Bucket: bucketName, Key: obj.Key }),
|
||||||
|
);
|
||||||
|
movedCount++;
|
||||||
|
}
|
||||||
|
continuationToken = listResp.IsTruncated ? listResp.NextContinuationToken : undefined;
|
||||||
|
} while (continuationToken);
|
||||||
|
return { success: true, movedCount };
|
||||||
|
} catch (err: any) {
|
||||||
|
return { success: false, error: err.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getServerStats(): Promise<interfaces.data.IServerStatus> {
|
||||||
|
const buckets = await this.listBuckets();
|
||||||
|
let totalObjectCount = 0;
|
||||||
|
let totalStorageBytes = 0;
|
||||||
|
for (const b of buckets) {
|
||||||
|
totalObjectCount += b.objectCount;
|
||||||
|
totalStorageBytes += b.totalSizeBytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
running: true,
|
||||||
|
objstPort: this.config.objstPort,
|
||||||
|
uiPort: this.config.uiPort,
|
||||||
|
uptime: Math.floor((Date.now() - this.startedAt) / 1000),
|
||||||
|
startedAt: this.startedAt,
|
||||||
|
bucketCount: buckets.length,
|
||||||
|
totalObjectCount,
|
||||||
|
totalStorageBytes,
|
||||||
|
storageDirectory: this.config.storageDirectory,
|
||||||
|
region: this.config.region,
|
||||||
|
authEnabled: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getBucketPolicy(bucketName: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const response = await this.s3Client.send(
|
||||||
|
new plugins.GetBucketPolicyCommand({ Bucket: bucketName }),
|
||||||
|
);
|
||||||
|
return response.Policy || null;
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err.name === 'NoSuchBucketPolicy' || err.Code === 'NoSuchBucketPolicy') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async putBucketPolicy(bucketName: string, policyJson: string): Promise<void> {
|
||||||
|
await this.s3Client.send(
|
||||||
|
new plugins.PutBucketPolicyCommand({ Bucket: bucketName, Policy: policyJson }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async deleteBucketPolicy(bucketName: string): Promise<void> {
|
||||||
|
await this.s3Client.send(
|
||||||
|
new plugins.DeleteBucketPolicyCommand({ Bucket: bucketName }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getConnectionInfo(): Promise<interfaces.data.IConnectionInfo> {
|
||||||
|
const descriptor = await this.smartstorageInstance.getStorageDescriptor();
|
||||||
|
return {
|
||||||
|
endpoint: descriptor.endpoint,
|
||||||
|
port: Number(descriptor.port ?? this.config.objstPort),
|
||||||
|
useSsl: descriptor.useSsl ?? false,
|
||||||
|
accessKey: descriptor.accessKey,
|
||||||
|
region: this.config.region,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
269
ts/classes/policymanager.ts
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
import type { ObjectStorageContainer } from './objectstoragecontainer.ts';
|
||||||
|
import type * as interfaces from '../../ts_interfaces/index.ts';
|
||||||
|
|
||||||
|
export class PolicyManager {
|
||||||
|
private objectStorageRef: ObjectStorageContainer;
|
||||||
|
private data: interfaces.data.IPoliciesData = {
|
||||||
|
namedPolicies: {},
|
||||||
|
bucketPolicyAttachments: {},
|
||||||
|
};
|
||||||
|
private filePath: string;
|
||||||
|
|
||||||
|
constructor(objectStorageRef: ObjectStorageContainer) {
|
||||||
|
this.objectStorageRef = objectStorageRef;
|
||||||
|
const storageDir = objectStorageRef.config.storageDirectory;
|
||||||
|
this.filePath = `${storageDir}/.objectstorage/policies.json`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Persistence ──
|
||||||
|
|
||||||
|
public async load(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const dirPath = this.filePath.substring(0, this.filePath.lastIndexOf('/'));
|
||||||
|
await Deno.mkdir(dirPath, { recursive: true });
|
||||||
|
|
||||||
|
const content = await Deno.readTextFile(this.filePath);
|
||||||
|
this.data = JSON.parse(content);
|
||||||
|
} catch {
|
||||||
|
// File doesn't exist yet — start with empty data
|
||||||
|
this.data = { namedPolicies: {}, bucketPolicyAttachments: {} };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async save(): Promise<void> {
|
||||||
|
const dirPath = this.filePath.substring(0, this.filePath.lastIndexOf('/'));
|
||||||
|
await Deno.mkdir(dirPath, { recursive: true });
|
||||||
|
await Deno.writeTextFile(this.filePath, JSON.stringify(this.data, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── CRUD ──
|
||||||
|
|
||||||
|
public listPolicies(): interfaces.data.INamedPolicy[] {
|
||||||
|
return Object.values(this.data.namedPolicies);
|
||||||
|
}
|
||||||
|
|
||||||
|
public getPolicy(policyId: string): interfaces.data.INamedPolicy | null {
|
||||||
|
return this.data.namedPolicies[policyId] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async createPolicy(
|
||||||
|
name: string,
|
||||||
|
description: string,
|
||||||
|
statements: interfaces.data.IObjstStatement[],
|
||||||
|
): Promise<interfaces.data.INamedPolicy> {
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
const now = Date.now();
|
||||||
|
const policy: interfaces.data.INamedPolicy = {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
statements,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
this.data.namedPolicies[id] = policy;
|
||||||
|
await this.save();
|
||||||
|
return policy;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async updatePolicy(
|
||||||
|
policyId: string,
|
||||||
|
name: string,
|
||||||
|
description: string,
|
||||||
|
statements: interfaces.data.IObjstStatement[],
|
||||||
|
): Promise<interfaces.data.INamedPolicy> {
|
||||||
|
const existing = this.data.namedPolicies[policyId];
|
||||||
|
if (!existing) {
|
||||||
|
throw new Error(`Policy not found: ${policyId}`);
|
||||||
|
}
|
||||||
|
existing.name = name;
|
||||||
|
existing.description = description;
|
||||||
|
existing.statements = statements;
|
||||||
|
existing.updatedAt = Date.now();
|
||||||
|
await this.save();
|
||||||
|
|
||||||
|
// Recompute policies for all buckets that have this policy attached
|
||||||
|
const affectedBuckets = this.getBucketsForPolicy(policyId);
|
||||||
|
for (const bucket of affectedBuckets) {
|
||||||
|
await this.recomputeAndApplyPolicy(bucket);
|
||||||
|
}
|
||||||
|
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async deletePolicy(policyId: string): Promise<void> {
|
||||||
|
if (!this.data.namedPolicies[policyId]) {
|
||||||
|
throw new Error(`Policy not found: ${policyId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find all affected buckets before deleting
|
||||||
|
const affectedBuckets = this.getBucketsForPolicy(policyId);
|
||||||
|
|
||||||
|
// Remove from namedPolicies
|
||||||
|
delete this.data.namedPolicies[policyId];
|
||||||
|
|
||||||
|
// Remove from all bucket attachments
|
||||||
|
for (const bucket of Object.keys(this.data.bucketPolicyAttachments)) {
|
||||||
|
this.data.bucketPolicyAttachments[bucket] = this.data.bucketPolicyAttachments[bucket].filter(
|
||||||
|
(id) => id !== policyId,
|
||||||
|
);
|
||||||
|
if (this.data.bucketPolicyAttachments[bucket].length === 0) {
|
||||||
|
delete this.data.bucketPolicyAttachments[bucket];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.save();
|
||||||
|
|
||||||
|
// Recompute policies for affected buckets
|
||||||
|
for (const bucket of affectedBuckets) {
|
||||||
|
await this.recomputeAndApplyPolicy(bucket);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Attachments ──
|
||||||
|
|
||||||
|
public getBucketAttachments(bucketName: string): {
|
||||||
|
attachedPolicies: interfaces.data.INamedPolicy[];
|
||||||
|
availablePolicies: interfaces.data.INamedPolicy[];
|
||||||
|
} {
|
||||||
|
const attachedIds = this.data.bucketPolicyAttachments[bucketName] || [];
|
||||||
|
const allPolicies = this.listPolicies();
|
||||||
|
const attachedPolicies = allPolicies.filter((p) => attachedIds.includes(p.id));
|
||||||
|
const availablePolicies = allPolicies.filter((p) => !attachedIds.includes(p.id));
|
||||||
|
return { attachedPolicies, availablePolicies };
|
||||||
|
}
|
||||||
|
|
||||||
|
public getBucketsForPolicy(policyId: string): string[] {
|
||||||
|
const buckets: string[] = [];
|
||||||
|
for (const [bucket, policyIds] of Object.entries(this.data.bucketPolicyAttachments)) {
|
||||||
|
if (policyIds.includes(policyId)) {
|
||||||
|
buckets.push(bucket);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return buckets;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async attachPolicyToBucket(policyId: string, bucketName: string): Promise<void> {
|
||||||
|
if (!this.data.namedPolicies[policyId]) {
|
||||||
|
throw new Error(`Policy not found: ${policyId}`);
|
||||||
|
}
|
||||||
|
if (!this.data.bucketPolicyAttachments[bucketName]) {
|
||||||
|
this.data.bucketPolicyAttachments[bucketName] = [];
|
||||||
|
}
|
||||||
|
if (!this.data.bucketPolicyAttachments[bucketName].includes(policyId)) {
|
||||||
|
this.data.bucketPolicyAttachments[bucketName].push(policyId);
|
||||||
|
}
|
||||||
|
await this.save();
|
||||||
|
await this.recomputeAndApplyPolicy(bucketName);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async detachPolicyFromBucket(policyId: string, bucketName: string): Promise<void> {
|
||||||
|
if (this.data.bucketPolicyAttachments[bucketName]) {
|
||||||
|
this.data.bucketPolicyAttachments[bucketName] = this.data.bucketPolicyAttachments[bucketName].filter(
|
||||||
|
(id) => id !== policyId,
|
||||||
|
);
|
||||||
|
if (this.data.bucketPolicyAttachments[bucketName].length === 0) {
|
||||||
|
delete this.data.bucketPolicyAttachments[bucketName];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await this.save();
|
||||||
|
await this.recomputeAndApplyPolicy(bucketName);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async setPolicyBuckets(policyId: string, bucketNames: string[]): Promise<void> {
|
||||||
|
if (!this.data.namedPolicies[policyId]) {
|
||||||
|
throw new Error(`Policy not found: ${policyId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const oldBuckets = this.getBucketsForPolicy(policyId);
|
||||||
|
const newBucketsSet = new Set(bucketNames);
|
||||||
|
const oldBucketsSet = new Set(oldBuckets);
|
||||||
|
|
||||||
|
// Remove from buckets no longer in the list
|
||||||
|
for (const bucket of oldBuckets) {
|
||||||
|
if (!newBucketsSet.has(bucket)) {
|
||||||
|
this.data.bucketPolicyAttachments[bucket] = (this.data.bucketPolicyAttachments[bucket] || []).filter(
|
||||||
|
(id) => id !== policyId,
|
||||||
|
);
|
||||||
|
if (this.data.bucketPolicyAttachments[bucket]?.length === 0) {
|
||||||
|
delete this.data.bucketPolicyAttachments[bucket];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to new buckets
|
||||||
|
for (const bucket of bucketNames) {
|
||||||
|
if (!oldBucketsSet.has(bucket)) {
|
||||||
|
if (!this.data.bucketPolicyAttachments[bucket]) {
|
||||||
|
this.data.bucketPolicyAttachments[bucket] = [];
|
||||||
|
}
|
||||||
|
if (!this.data.bucketPolicyAttachments[bucket].includes(policyId)) {
|
||||||
|
this.data.bucketPolicyAttachments[bucket].push(policyId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.save();
|
||||||
|
|
||||||
|
// Recompute all affected buckets (union of old and new)
|
||||||
|
const allAffected = new Set([...oldBuckets, ...bucketNames]);
|
||||||
|
for (const bucket of allAffected) {
|
||||||
|
await this.recomputeAndApplyPolicy(bucket);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Cleanup ──
|
||||||
|
|
||||||
|
public async onBucketDeleted(bucketName: string): Promise<void> {
|
||||||
|
if (this.data.bucketPolicyAttachments[bucketName]) {
|
||||||
|
delete this.data.bucketPolicyAttachments[bucketName];
|
||||||
|
await this.save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Merge & Apply ──
|
||||||
|
|
||||||
|
private async recomputeAndApplyPolicy(bucketName: string): Promise<void> {
|
||||||
|
const attachedIds = this.data.bucketPolicyAttachments[bucketName] || [];
|
||||||
|
|
||||||
|
if (attachedIds.length === 0) {
|
||||||
|
// No policies attached — remove any existing policy
|
||||||
|
try {
|
||||||
|
await this.objectStorageRef.deleteBucketPolicy(bucketName);
|
||||||
|
} catch {
|
||||||
|
// NoSuchBucketPolicy is fine
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gather all statements from attached policies
|
||||||
|
const allStatements: interfaces.data.IObjstStatement[] = [];
|
||||||
|
for (const policyId of attachedIds) {
|
||||||
|
const policy = this.data.namedPolicies[policyId];
|
||||||
|
if (!policy) continue;
|
||||||
|
for (const stmt of policy.statements) {
|
||||||
|
// Deep clone and replace ${bucket} placeholder
|
||||||
|
const cloned = JSON.parse(JSON.stringify(stmt)) as interfaces.data.IObjstStatement;
|
||||||
|
cloned.Resource = this.replaceBucketPlaceholder(cloned.Resource, bucketName);
|
||||||
|
allStatements.push(cloned);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const mergedPolicy = {
|
||||||
|
Version: '2012-10-17',
|
||||||
|
Statement: allStatements,
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.objectStorageRef.putBucketPolicy(bucketName, JSON.stringify(mergedPolicy));
|
||||||
|
}
|
||||||
|
|
||||||
|
private replaceBucketPlaceholder(
|
||||||
|
resource: string | string[],
|
||||||
|
bucketName: string,
|
||||||
|
): string | string[] {
|
||||||
|
if (typeof resource === 'string') {
|
||||||
|
return resource.replace(/\$\{bucket\}/g, bucketName);
|
||||||
|
}
|
||||||
|
return resource.map((r) => r.replace(/\$\{bucket\}/g, bucketName));
|
||||||
|
}
|
||||||
|
}
|
||||||
77
ts/cli.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { ObjectStorageContainer } from './classes/objectstoragecontainer.ts';
|
||||||
|
import type { IObjectStorageConfig } from './types.ts';
|
||||||
|
|
||||||
|
export async function runCli(): Promise<void> {
|
||||||
|
const args = Deno.args;
|
||||||
|
const command = args[0];
|
||||||
|
|
||||||
|
if (!command || command === 'help') {
|
||||||
|
printHelp();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command === 'server') {
|
||||||
|
const configOverrides: Partial<IObjectStorageConfig> = {};
|
||||||
|
|
||||||
|
// Parse CLI args
|
||||||
|
for (let i = 1; i < args.length; i++) {
|
||||||
|
switch (args[i]) {
|
||||||
|
case '--storage-port':
|
||||||
|
configOverrides.objstPort = parseInt(args[++i], 10);
|
||||||
|
break;
|
||||||
|
case '--ui-port':
|
||||||
|
configOverrides.uiPort = parseInt(args[++i], 10);
|
||||||
|
break;
|
||||||
|
case '--storage-dir':
|
||||||
|
configOverrides.storageDirectory = args[++i];
|
||||||
|
break;
|
||||||
|
case '--ephemeral':
|
||||||
|
// Use a temp directory for storage
|
||||||
|
configOverrides.storageDirectory = './.nogit/objstdata';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const container = new ObjectStorageContainer(configOverrides);
|
||||||
|
|
||||||
|
// Graceful shutdown
|
||||||
|
const shutdown = async () => {
|
||||||
|
console.log('\nShutting down...');
|
||||||
|
await container.stop();
|
||||||
|
Deno.exit(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
Deno.addSignalListener('SIGINT', shutdown);
|
||||||
|
Deno.addSignalListener('SIGTERM', shutdown);
|
||||||
|
|
||||||
|
await container.start();
|
||||||
|
} else {
|
||||||
|
console.error(`Unknown command: ${command}`);
|
||||||
|
printHelp();
|
||||||
|
Deno.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function printHelp(): void {
|
||||||
|
console.log(`
|
||||||
|
ObjectStorage - S3-compatible object storage server with management UI
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
objectstorage server [options]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--ephemeral Use local .nogit/objstdata for storage
|
||||||
|
--storage-port PORT Storage API port (default: 9000, env: OBJST_PORT)
|
||||||
|
--ui-port PORT Management UI port (default: 3000, env: UI_PORT)
|
||||||
|
--storage-dir DIR Storage directory (default: /data, env: OBJST_STORAGE_DIR)
|
||||||
|
|
||||||
|
Environment Variables:
|
||||||
|
OBJST_PORT Storage API port
|
||||||
|
UI_PORT Management UI port
|
||||||
|
OBJST_STORAGE_DIR Storage directory
|
||||||
|
OBJST_ACCESS_KEY Access key (default: admin)
|
||||||
|
OBJST_SECRET_KEY Secret key (default: admin)
|
||||||
|
OBJST_ADMIN_PASSWORD Admin UI password (default: admin)
|
||||||
|
OBJST_REGION Storage region (default: us-east-1)
|
||||||
|
`);
|
||||||
|
}
|
||||||
3
ts/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export { ObjectStorageContainer } from './classes/objectstoragecontainer.ts';
|
||||||
|
export { runCli } from './cli.ts';
|
||||||
|
export type { IObjectStorageConfig } from './types.ts';
|
||||||
63
ts/opsserver/classes.opsserver.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import * as plugins from '../plugins.ts';
|
||||||
|
import type { ObjectStorageContainer } from '../classes/objectstoragecontainer.ts';
|
||||||
|
import * as handlers from './handlers/index.ts';
|
||||||
|
import { files as bundledFiles } from '../../ts_bundled/bundle.ts';
|
||||||
|
|
||||||
|
export class OpsServer {
|
||||||
|
public objectStorageRef: ObjectStorageContainer;
|
||||||
|
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||||
|
public server!: plugins.typedserver.utilityservers.UtilityWebsiteServer;
|
||||||
|
|
||||||
|
// Handler instances
|
||||||
|
public adminHandler!: handlers.AdminHandler;
|
||||||
|
public statusHandler!: handlers.StatusHandler;
|
||||||
|
public bucketsHandler!: handlers.BucketsHandler;
|
||||||
|
public objectsHandler!: handlers.ObjectsHandler;
|
||||||
|
public configHandler!: handlers.ConfigHandler;
|
||||||
|
public credentialsHandler!: handlers.CredentialsHandler;
|
||||||
|
public policiesHandler!: handlers.PoliciesHandler;
|
||||||
|
|
||||||
|
constructor(objectStorageRef: ObjectStorageContainer) {
|
||||||
|
this.objectStorageRef = objectStorageRef;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async start(port = 3000) {
|
||||||
|
this.server = new plugins.typedserver.utilityservers.UtilityWebsiteServer({
|
||||||
|
domain: 'localhost',
|
||||||
|
feedMetadata: undefined,
|
||||||
|
bundledContent: bundledFiles,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Chain typedrouters: server -> opsServer -> individual handlers
|
||||||
|
this.server.typedrouter.addTypedRouter(this.typedrouter);
|
||||||
|
|
||||||
|
// Set up all handlers
|
||||||
|
await this.setupHandlers();
|
||||||
|
|
||||||
|
await this.server.start(port);
|
||||||
|
console.log(`OpsServer started on http://localhost:${port}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async setupHandlers(): Promise<void> {
|
||||||
|
// AdminHandler requires async initialization for JWT key generation
|
||||||
|
this.adminHandler = new handlers.AdminHandler(this);
|
||||||
|
await this.adminHandler.initialize();
|
||||||
|
|
||||||
|
// All other handlers self-register in their constructors
|
||||||
|
this.statusHandler = new handlers.StatusHandler(this);
|
||||||
|
this.bucketsHandler = new handlers.BucketsHandler(this);
|
||||||
|
this.objectsHandler = new handlers.ObjectsHandler(this);
|
||||||
|
this.configHandler = new handlers.ConfigHandler(this);
|
||||||
|
this.credentialsHandler = new handlers.CredentialsHandler(this);
|
||||||
|
this.policiesHandler = new handlers.PoliciesHandler(this);
|
||||||
|
|
||||||
|
console.log('OpsServer TypedRequest handlers initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
public async stop() {
|
||||||
|
if (this.server) {
|
||||||
|
await this.server.stop();
|
||||||
|
console.log('OpsServer stopped');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
131
ts/opsserver/handlers/admin.handler.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import * as plugins from '../../plugins.ts';
|
||||||
|
import type { OpsServer } from '../classes.opsserver.ts';
|
||||||
|
import * as interfaces from '../../../ts_interfaces/index.ts';
|
||||||
|
|
||||||
|
export interface IJwtData {
|
||||||
|
userId: string;
|
||||||
|
status: 'loggedIn' | 'loggedOut';
|
||||||
|
expiresAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AdminHandler {
|
||||||
|
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||||
|
public smartjwtInstance!: plugins.smartjwt.SmartJwt<IJwtData>;
|
||||||
|
|
||||||
|
constructor(private opsServerRef: OpsServer) {
|
||||||
|
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async initialize(): Promise<void> {
|
||||||
|
this.smartjwtInstance = new plugins.smartjwt.SmartJwt();
|
||||||
|
await this.smartjwtInstance.init();
|
||||||
|
await this.smartjwtInstance.createNewKeyPair();
|
||||||
|
this.registerHandlers();
|
||||||
|
}
|
||||||
|
|
||||||
|
private registerHandlers(): void {
|
||||||
|
// Login
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
|
||||||
|
'adminLoginWithUsernameAndPassword',
|
||||||
|
async (dataArg) => {
|
||||||
|
const adminPassword = this.opsServerRef.objectStorageRef.config.adminPassword;
|
||||||
|
if (dataArg.username !== 'admin' || dataArg.password !== adminPassword) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Invalid credentials');
|
||||||
|
}
|
||||||
|
|
||||||
|
const expiresAt = Date.now() + 24 * 3600 * 1000;
|
||||||
|
const userId = 'admin';
|
||||||
|
const jwt = await this.smartjwtInstance.createJWT({
|
||||||
|
userId,
|
||||||
|
status: 'loggedIn',
|
||||||
|
expiresAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Admin user logged in');
|
||||||
|
|
||||||
|
return {
|
||||||
|
identity: {
|
||||||
|
jwt,
|
||||||
|
userId,
|
||||||
|
username: 'admin',
|
||||||
|
expiresAt,
|
||||||
|
role: 'admin' as const,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Logout
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_AdminLogout>(
|
||||||
|
'adminLogout',
|
||||||
|
async (_dataArg) => {
|
||||||
|
return { ok: true };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify Identity
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_VerifyIdentity>(
|
||||||
|
'verifyIdentity',
|
||||||
|
async (dataArg) => {
|
||||||
|
if (!dataArg.identity?.jwt) {
|
||||||
|
return { valid: false };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const jwtData = await this.smartjwtInstance.verifyJWTAndGetData(dataArg.identity.jwt);
|
||||||
|
if (jwtData.expiresAt < Date.now()) return { valid: false };
|
||||||
|
if (jwtData.status !== 'loggedIn') return { valid: false };
|
||||||
|
return {
|
||||||
|
valid: true,
|
||||||
|
identity: {
|
||||||
|
jwt: dataArg.identity.jwt,
|
||||||
|
userId: jwtData.userId,
|
||||||
|
username: dataArg.identity.username,
|
||||||
|
expiresAt: jwtData.expiresAt,
|
||||||
|
role: dataArg.identity.role,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { valid: false };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guard for valid identity
|
||||||
|
public validIdentityGuard = new plugins.smartguard.Guard<{
|
||||||
|
identity: interfaces.data.IIdentity;
|
||||||
|
}>(
|
||||||
|
async (dataArg) => {
|
||||||
|
if (!dataArg.identity?.jwt) return false;
|
||||||
|
try {
|
||||||
|
const jwtData = await this.smartjwtInstance.verifyJWTAndGetData(dataArg.identity.jwt);
|
||||||
|
if (jwtData.expiresAt < Date.now()) return false;
|
||||||
|
if (jwtData.status !== 'loggedIn') return false;
|
||||||
|
if (dataArg.identity.expiresAt !== jwtData.expiresAt) return false;
|
||||||
|
if (dataArg.identity.userId !== jwtData.userId) return false;
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ failedHint: 'identity is not valid', name: 'validIdentityGuard' },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Guard for admin identity
|
||||||
|
public adminIdentityGuard = new plugins.smartguard.Guard<{
|
||||||
|
identity: interfaces.data.IIdentity;
|
||||||
|
}>(
|
||||||
|
async (dataArg) => {
|
||||||
|
const isValid = await this.validIdentityGuard.exec(dataArg);
|
||||||
|
if (!isValid) return false;
|
||||||
|
return dataArg.identity.role === 'admin';
|
||||||
|
},
|
||||||
|
{ failedHint: 'user is not admin', name: 'adminIdentityGuard' },
|
||||||
|
);
|
||||||
|
}
|
||||||
94
ts/opsserver/handlers/buckets.handler.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import * as plugins from '../../plugins.ts';
|
||||||
|
import type { OpsServer } from '../classes.opsserver.ts';
|
||||||
|
import * as interfaces from '../../../ts_interfaces/index.ts';
|
||||||
|
import { requireValidIdentity } from '../helpers/guards.ts';
|
||||||
|
|
||||||
|
export class BucketsHandler {
|
||||||
|
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||||
|
|
||||||
|
constructor(private opsServerRef: OpsServer) {
|
||||||
|
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||||
|
this.registerHandlers();
|
||||||
|
}
|
||||||
|
|
||||||
|
private registerHandlers(): void {
|
||||||
|
// List buckets
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ListBuckets>(
|
||||||
|
'listBuckets',
|
||||||
|
async (dataArg) => {
|
||||||
|
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||||
|
const buckets = await this.opsServerRef.objectStorageRef.listBuckets();
|
||||||
|
return { buckets };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create bucket
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateBucket>(
|
||||||
|
'createBucket',
|
||||||
|
async (dataArg) => {
|
||||||
|
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||||
|
await this.opsServerRef.objectStorageRef.createBucket(dataArg.bucketName);
|
||||||
|
return { ok: true };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Delete bucket
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteBucket>(
|
||||||
|
'deleteBucket',
|
||||||
|
async (dataArg) => {
|
||||||
|
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||||
|
await this.opsServerRef.objectStorageRef.deleteBucket(dataArg.bucketName);
|
||||||
|
await this.opsServerRef.objectStorageRef.policyManager.onBucketDeleted(dataArg.bucketName);
|
||||||
|
return { ok: true };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get bucket policy
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetBucketPolicy>(
|
||||||
|
'getBucketPolicy',
|
||||||
|
async (dataArg) => {
|
||||||
|
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||||
|
const policy = await this.opsServerRef.objectStorageRef.getBucketPolicy(dataArg.bucketName);
|
||||||
|
return { policy };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Put bucket policy
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_PutBucketPolicy>(
|
||||||
|
'putBucketPolicy',
|
||||||
|
async (dataArg) => {
|
||||||
|
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||||
|
// Validate JSON
|
||||||
|
try {
|
||||||
|
JSON.parse(dataArg.policy);
|
||||||
|
} catch {
|
||||||
|
throw new Error('Invalid JSON policy document');
|
||||||
|
}
|
||||||
|
await this.opsServerRef.objectStorageRef.putBucketPolicy(dataArg.bucketName, dataArg.policy);
|
||||||
|
return { ok: true };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Delete bucket policy
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteBucketPolicy>(
|
||||||
|
'deleteBucketPolicy',
|
||||||
|
async (dataArg) => {
|
||||||
|
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||||
|
await this.opsServerRef.objectStorageRef.deleteBucketPolicy(dataArg.bucketName);
|
||||||
|
return { ok: true };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
34
ts/opsserver/handlers/config.handler.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import * as plugins from '../../plugins.ts';
|
||||||
|
import type { OpsServer } from '../classes.opsserver.ts';
|
||||||
|
import * as interfaces from '../../../ts_interfaces/index.ts';
|
||||||
|
import { requireValidIdentity } from '../helpers/guards.ts';
|
||||||
|
|
||||||
|
export class ConfigHandler {
|
||||||
|
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||||
|
|
||||||
|
constructor(private opsServerRef: OpsServer) {
|
||||||
|
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||||
|
this.registerHandlers();
|
||||||
|
}
|
||||||
|
|
||||||
|
private registerHandlers(): void {
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetServerConfig>(
|
||||||
|
'getServerConfig',
|
||||||
|
async (dataArg) => {
|
||||||
|
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||||
|
const containerConfig = this.opsServerRef.objectStorageRef.config;
|
||||||
|
const config: interfaces.data.IServerConfig = {
|
||||||
|
objstPort: containerConfig.objstPort,
|
||||||
|
uiPort: containerConfig.uiPort,
|
||||||
|
region: containerConfig.region,
|
||||||
|
storageDirectory: containerConfig.storageDirectory,
|
||||||
|
authEnabled: true,
|
||||||
|
corsEnabled: false,
|
||||||
|
};
|
||||||
|
return { config };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
73
ts/opsserver/handlers/credentials.handler.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import * as plugins from '../../plugins.ts';
|
||||||
|
import type { OpsServer } from '../classes.opsserver.ts';
|
||||||
|
import * as interfaces from '../../../ts_interfaces/index.ts';
|
||||||
|
import { requireValidIdentity } from '../helpers/guards.ts';
|
||||||
|
|
||||||
|
export class CredentialsHandler {
|
||||||
|
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||||
|
|
||||||
|
constructor(private opsServerRef: OpsServer) {
|
||||||
|
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||||
|
this.registerHandlers();
|
||||||
|
}
|
||||||
|
|
||||||
|
private registerHandlers(): void {
|
||||||
|
// Get credentials (secrets masked)
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetCredentials>(
|
||||||
|
'getCredentials',
|
||||||
|
async (dataArg) => {
|
||||||
|
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||||
|
const credentials = this.opsServerRef.objectStorageRef.config.accessCredentials.map(
|
||||||
|
(cred) => ({
|
||||||
|
accessKeyId: cred.accessKeyId,
|
||||||
|
secretAccessKey: cred.secretAccessKey.slice(0, 4) + '****',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return { credentials };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add credential
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_AddCredential>(
|
||||||
|
'addCredential',
|
||||||
|
async (dataArg) => {
|
||||||
|
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||||
|
this.opsServerRef.objectStorageRef.config.accessCredentials.push({
|
||||||
|
accessKeyId: dataArg.accessKeyId,
|
||||||
|
secretAccessKey: dataArg.secretAccessKey,
|
||||||
|
});
|
||||||
|
// Update the smartstorage auth config
|
||||||
|
this.opsServerRef.objectStorageRef.smartstorageInstance.config.auth!.credentials =
|
||||||
|
this.opsServerRef.objectStorageRef.config.accessCredentials;
|
||||||
|
return { ok: true };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Remove credential
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RemoveCredential>(
|
||||||
|
'removeCredential',
|
||||||
|
async (dataArg) => {
|
||||||
|
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||||
|
const creds = this.opsServerRef.objectStorageRef.config.accessCredentials;
|
||||||
|
if (creds.length <= 1) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError(
|
||||||
|
'Cannot remove the last credential',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.opsServerRef.objectStorageRef.config.accessCredentials = creds.filter(
|
||||||
|
(c) => c.accessKeyId !== dataArg.accessKeyId,
|
||||||
|
);
|
||||||
|
// Update the smartstorage auth config
|
||||||
|
this.opsServerRef.objectStorageRef.smartstorageInstance.config.auth!.credentials =
|
||||||
|
this.opsServerRef.objectStorageRef.config.accessCredentials;
|
||||||
|
return { ok: true };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
7
ts/opsserver/handlers/index.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export { AdminHandler } from './admin.handler.ts';
|
||||||
|
export { StatusHandler } from './status.handler.ts';
|
||||||
|
export { BucketsHandler } from './buckets.handler.ts';
|
||||||
|
export { ObjectsHandler } from './objects.handler.ts';
|
||||||
|
export { ConfigHandler } from './config.handler.ts';
|
||||||
|
export { CredentialsHandler } from './credentials.handler.ts';
|
||||||
|
export { PoliciesHandler } from './policies.handler.ts';
|
||||||
126
ts/opsserver/handlers/objects.handler.ts
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import * as plugins from '../../plugins.ts';
|
||||||
|
import type { OpsServer } from '../classes.opsserver.ts';
|
||||||
|
import * as interfaces from '../../../ts_interfaces/index.ts';
|
||||||
|
import { requireValidIdentity } from '../helpers/guards.ts';
|
||||||
|
|
||||||
|
export class ObjectsHandler {
|
||||||
|
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||||
|
|
||||||
|
constructor(private opsServerRef: OpsServer) {
|
||||||
|
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||||
|
this.registerHandlers();
|
||||||
|
}
|
||||||
|
|
||||||
|
private registerHandlers(): void {
|
||||||
|
// List objects
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ListObjects>(
|
||||||
|
'listObjects',
|
||||||
|
async (dataArg) => {
|
||||||
|
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||||
|
const result = await this.opsServerRef.objectStorageRef.listObjects(
|
||||||
|
dataArg.bucketName,
|
||||||
|
dataArg.prefix,
|
||||||
|
dataArg.delimiter,
|
||||||
|
dataArg.maxKeys,
|
||||||
|
);
|
||||||
|
return { result };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Delete object
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteObject>(
|
||||||
|
'deleteObject',
|
||||||
|
async (dataArg) => {
|
||||||
|
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||||
|
await this.opsServerRef.objectStorageRef.deleteObject(dataArg.bucketName, dataArg.key);
|
||||||
|
return { ok: true };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get object
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetObject>(
|
||||||
|
'getObject',
|
||||||
|
async (dataArg) => {
|
||||||
|
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||||
|
return await this.opsServerRef.objectStorageRef.getObject(dataArg.bucketName, dataArg.key);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Put object
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_PutObject>(
|
||||||
|
'putObject',
|
||||||
|
async (dataArg) => {
|
||||||
|
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||||
|
await this.opsServerRef.objectStorageRef.putObject(
|
||||||
|
dataArg.bucketName,
|
||||||
|
dataArg.key,
|
||||||
|
dataArg.base64Content,
|
||||||
|
dataArg.contentType,
|
||||||
|
);
|
||||||
|
return { ok: true };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Delete prefix (folder)
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeletePrefix>(
|
||||||
|
'deletePrefix',
|
||||||
|
async (dataArg) => {
|
||||||
|
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||||
|
await this.opsServerRef.objectStorageRef.deletePrefix(dataArg.bucketName, dataArg.prefix);
|
||||||
|
return { ok: true };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get object URL
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetObjectUrl>(
|
||||||
|
'getObjectUrl',
|
||||||
|
async (dataArg) => {
|
||||||
|
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||||
|
const url = await this.opsServerRef.objectStorageRef.getObjectUrl(dataArg.bucketName, dataArg.key);
|
||||||
|
return { url };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Move object
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_MoveObject>(
|
||||||
|
'moveObject',
|
||||||
|
async (dataArg) => {
|
||||||
|
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||||
|
return await this.opsServerRef.objectStorageRef.moveObject(
|
||||||
|
dataArg.bucketName,
|
||||||
|
dataArg.sourceKey,
|
||||||
|
dataArg.destKey,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Move prefix (folder)
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_MovePrefix>(
|
||||||
|
'movePrefix',
|
||||||
|
async (dataArg) => {
|
||||||
|
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||||
|
return await this.opsServerRef.objectStorageRef.movePrefix(
|
||||||
|
dataArg.bucketName,
|
||||||
|
dataArg.sourcePrefix,
|
||||||
|
dataArg.destPrefix,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
128
ts/opsserver/handlers/policies.handler.ts
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import * as plugins from '../../plugins.ts';
|
||||||
|
import type { OpsServer } from '../classes.opsserver.ts';
|
||||||
|
import * as interfaces from '../../../ts_interfaces/index.ts';
|
||||||
|
import { requireValidIdentity } from '../helpers/guards.ts';
|
||||||
|
|
||||||
|
export class PoliciesHandler {
|
||||||
|
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||||
|
|
||||||
|
constructor(private opsServerRef: OpsServer) {
|
||||||
|
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||||
|
this.registerHandlers();
|
||||||
|
}
|
||||||
|
|
||||||
|
private registerHandlers(): void {
|
||||||
|
const pm = () => this.opsServerRef.objectStorageRef.policyManager;
|
||||||
|
|
||||||
|
// List named policies
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ListNamedPolicies>(
|
||||||
|
'listNamedPolicies',
|
||||||
|
async (dataArg) => {
|
||||||
|
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||||
|
return { policies: pm().listPolicies() };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create named policy
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateNamedPolicy>(
|
||||||
|
'createNamedPolicy',
|
||||||
|
async (dataArg) => {
|
||||||
|
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||||
|
const policy = await pm().createPolicy(dataArg.name, dataArg.description, dataArg.statements);
|
||||||
|
return { policy };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update named policy
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateNamedPolicy>(
|
||||||
|
'updateNamedPolicy',
|
||||||
|
async (dataArg) => {
|
||||||
|
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||||
|
const policy = await pm().updatePolicy(dataArg.policyId, dataArg.name, dataArg.description, dataArg.statements);
|
||||||
|
return { policy };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Delete named policy
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteNamedPolicy>(
|
||||||
|
'deleteNamedPolicy',
|
||||||
|
async (dataArg) => {
|
||||||
|
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||||
|
await pm().deletePolicy(dataArg.policyId);
|
||||||
|
return { ok: true };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get bucket named policies (attached + available)
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetBucketNamedPolicies>(
|
||||||
|
'getBucketNamedPolicies',
|
||||||
|
async (dataArg) => {
|
||||||
|
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||||
|
return pm().getBucketAttachments(dataArg.bucketName);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Attach policy to bucket
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_AttachPolicyToBucket>(
|
||||||
|
'attachPolicyToBucket',
|
||||||
|
async (dataArg) => {
|
||||||
|
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||||
|
await pm().attachPolicyToBucket(dataArg.policyId, dataArg.bucketName);
|
||||||
|
return { ok: true };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Detach policy from bucket
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DetachPolicyFromBucket>(
|
||||||
|
'detachPolicyFromBucket',
|
||||||
|
async (dataArg) => {
|
||||||
|
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||||
|
await pm().detachPolicyFromBucket(dataArg.policyId, dataArg.bucketName);
|
||||||
|
return { ok: true };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get policy buckets (attached + available)
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetPolicyBuckets>(
|
||||||
|
'getPolicyBuckets',
|
||||||
|
async (dataArg) => {
|
||||||
|
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||||
|
const attachedBuckets = pm().getBucketsForPolicy(dataArg.policyId);
|
||||||
|
const allBuckets = await this.opsServerRef.objectStorageRef.listBuckets();
|
||||||
|
const attachedSet = new Set(attachedBuckets);
|
||||||
|
const availableBuckets = allBuckets
|
||||||
|
.map((b) => b.name)
|
||||||
|
.filter((name) => !attachedSet.has(name));
|
||||||
|
return { attachedBuckets, availableBuckets };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Set policy buckets
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_SetPolicyBuckets>(
|
||||||
|
'setPolicyBuckets',
|
||||||
|
async (dataArg) => {
|
||||||
|
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||||
|
await pm().setPolicyBuckets(dataArg.policyId, dataArg.bucketNames);
|
||||||
|
return { ok: true };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
27
ts/opsserver/handlers/status.handler.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import * as plugins from '../../plugins.ts';
|
||||||
|
import type { OpsServer } from '../classes.opsserver.ts';
|
||||||
|
import * as interfaces from '../../../ts_interfaces/index.ts';
|
||||||
|
import { requireValidIdentity } from '../helpers/guards.ts';
|
||||||
|
|
||||||
|
export class StatusHandler {
|
||||||
|
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||||
|
|
||||||
|
constructor(private opsServerRef: OpsServer) {
|
||||||
|
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||||
|
this.registerHandlers();
|
||||||
|
}
|
||||||
|
|
||||||
|
private registerHandlers(): void {
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetServerStatus>(
|
||||||
|
'getServerStatus',
|
||||||
|
async (dataArg) => {
|
||||||
|
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||||
|
const status = await this.opsServerRef.objectStorageRef.getServerStats();
|
||||||
|
const connectionInfo = await this.opsServerRef.objectStorageRef.getConnectionInfo();
|
||||||
|
return { status, connectionInfo };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
29
ts/opsserver/helpers/guards.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import * as plugins from '../../plugins.ts';
|
||||||
|
import type { AdminHandler } from '../handlers/admin.handler.ts';
|
||||||
|
import * as interfaces from '../../../ts_interfaces/index.ts';
|
||||||
|
|
||||||
|
export async function requireValidIdentity<T extends { identity?: interfaces.data.IIdentity }>(
|
||||||
|
adminHandler: AdminHandler,
|
||||||
|
dataArg: T,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!dataArg.identity) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('No identity provided');
|
||||||
|
}
|
||||||
|
const passed = await adminHandler.validIdentityGuard.exec({ identity: dataArg.identity });
|
||||||
|
if (!passed) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Valid identity required');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requireAdminIdentity<T extends { identity?: interfaces.data.IIdentity }>(
|
||||||
|
adminHandler: AdminHandler,
|
||||||
|
dataArg: T,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!dataArg.identity) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('No identity provided');
|
||||||
|
}
|
||||||
|
const passed = await adminHandler.adminIdentityGuard.exec({ identity: dataArg.identity });
|
||||||
|
if (!passed) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Admin access required');
|
||||||
|
}
|
||||||
|
}
|
||||||
1
ts/opsserver/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { OpsServer } from './classes.opsserver.ts';
|
||||||
48
ts/plugins.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import * as smartstorage from '@push.rocks/smartstorage';
|
||||||
|
export { smartstorage };
|
||||||
|
|
||||||
|
import * as smartbucket from '@push.rocks/smartbucket';
|
||||||
|
export { smartbucket };
|
||||||
|
|
||||||
|
import {
|
||||||
|
S3Client,
|
||||||
|
ListBucketsCommand,
|
||||||
|
CreateBucketCommand,
|
||||||
|
DeleteBucketCommand,
|
||||||
|
ListObjectsV2Command,
|
||||||
|
HeadObjectCommand,
|
||||||
|
DeleteObjectCommand,
|
||||||
|
GetObjectCommand,
|
||||||
|
PutObjectCommand,
|
||||||
|
CopyObjectCommand,
|
||||||
|
GetBucketPolicyCommand,
|
||||||
|
PutBucketPolicyCommand,
|
||||||
|
DeleteBucketPolicyCommand,
|
||||||
|
} from '@aws-sdk/client-s3';
|
||||||
|
export {
|
||||||
|
S3Client,
|
||||||
|
ListBucketsCommand,
|
||||||
|
CreateBucketCommand,
|
||||||
|
DeleteBucketCommand,
|
||||||
|
ListObjectsV2Command,
|
||||||
|
HeadObjectCommand,
|
||||||
|
DeleteObjectCommand,
|
||||||
|
GetObjectCommand,
|
||||||
|
PutObjectCommand,
|
||||||
|
CopyObjectCommand,
|
||||||
|
GetBucketPolicyCommand,
|
||||||
|
PutBucketPolicyCommand,
|
||||||
|
DeleteBucketPolicyCommand,
|
||||||
|
};
|
||||||
|
|
||||||
|
import * as typedrequest from '@api.global/typedrequest';
|
||||||
|
export { typedrequest };
|
||||||
|
|
||||||
|
import * as typedserver from '@api.global/typedserver';
|
||||||
|
export { typedserver };
|
||||||
|
|
||||||
|
import * as smartguard from '@push.rocks/smartguard';
|
||||||
|
export { smartguard };
|
||||||
|
|
||||||
|
import * as smartjwt from '@push.rocks/smartjwt';
|
||||||
|
export { smartjwt };
|
||||||
17
ts/types.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
export interface IObjectStorageConfig {
|
||||||
|
objstPort: number;
|
||||||
|
uiPort: number;
|
||||||
|
storageDirectory: string;
|
||||||
|
accessCredentials: Array<{ accessKeyId: string; secretAccessKey: string }>;
|
||||||
|
adminPassword: string;
|
||||||
|
region: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const defaultConfig: IObjectStorageConfig = {
|
||||||
|
objstPort: 9000,
|
||||||
|
uiPort: 3000,
|
||||||
|
storageDirectory: '/data',
|
||||||
|
accessCredentials: [{ accessKeyId: 'admin', secretAccessKey: 'admin' }],
|
||||||
|
adminPassword: 'admin',
|
||||||
|
region: 'us-east-1',
|
||||||
|
};
|
||||||
11
ts_bundled/bundle.ts
Normal file
7
ts_interfaces/data/auth.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export interface IIdentity {
|
||||||
|
jwt: string;
|
||||||
|
userId: string;
|
||||||
|
username: string;
|
||||||
|
expiresAt: number;
|
||||||
|
role: 'admin';
|
||||||
|
}
|
||||||
6
ts_interfaces/data/bucket.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export interface IBucketInfo {
|
||||||
|
name: string;
|
||||||
|
creationDate: number;
|
||||||
|
objectCount: number;
|
||||||
|
totalSizeBytes: number;
|
||||||
|
}
|
||||||
5
ts_interfaces/data/index.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export * from './auth.ts';
|
||||||
|
export * from './bucket.ts';
|
||||||
|
export * from './object.ts';
|
||||||
|
export * from './policy.ts';
|
||||||
|
export * from './server.ts';
|
||||||
14
ts_interfaces/data/object.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
export interface IObjectInfo {
|
||||||
|
key: string;
|
||||||
|
size: number;
|
||||||
|
lastModified: number;
|
||||||
|
etag: string;
|
||||||
|
contentType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IObjectListResult {
|
||||||
|
objects: IObjectInfo[];
|
||||||
|
commonPrefixes: string[];
|
||||||
|
isTruncated: boolean;
|
||||||
|
currentPrefix: string;
|
||||||
|
}
|
||||||
22
ts_interfaces/data/policy.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
export interface IObjstStatement {
|
||||||
|
Sid?: string;
|
||||||
|
Effect: 'Allow' | 'Deny';
|
||||||
|
Principal: string | Record<string, string | string[]>;
|
||||||
|
Action: string | string[];
|
||||||
|
Resource: string | string[];
|
||||||
|
Condition?: Record<string, Record<string, string | string[]>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface INamedPolicy {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
statements: IObjstStatement[];
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IPoliciesData {
|
||||||
|
namedPolicies: Record<string, INamedPolicy>;
|
||||||
|
bucketPolicyAttachments: Record<string, string[]>;
|
||||||
|
}
|
||||||
35
ts_interfaces/data/server.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
export interface IServerStatus {
|
||||||
|
running: boolean;
|
||||||
|
objstPort: number;
|
||||||
|
uiPort: number;
|
||||||
|
uptime: number;
|
||||||
|
startedAt: number;
|
||||||
|
bucketCount: number;
|
||||||
|
totalObjectCount: number;
|
||||||
|
totalStorageBytes: number;
|
||||||
|
storageDirectory: string;
|
||||||
|
region: string;
|
||||||
|
authEnabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IServerConfig {
|
||||||
|
objstPort: number;
|
||||||
|
uiPort: number;
|
||||||
|
region: string;
|
||||||
|
storageDirectory: string;
|
||||||
|
authEnabled: boolean;
|
||||||
|
corsEnabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IObjstCredential {
|
||||||
|
accessKeyId: string;
|
||||||
|
secretAccessKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IConnectionInfo {
|
||||||
|
endpoint: string;
|
||||||
|
port: number;
|
||||||
|
useSsl: boolean;
|
||||||
|
accessKey: string;
|
||||||
|
region: string;
|
||||||
|
}
|
||||||
7
ts_interfaces/index.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export * from './plugins.ts';
|
||||||
|
|
||||||
|
import * as data from './data/index.ts';
|
||||||
|
export { data };
|
||||||
|
|
||||||
|
import * as requests from './requests/index.ts';
|
||||||
|
export { requests };
|
||||||
2
ts_interfaces/plugins.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
import * as typedrequestInterfaces from '@api.global/typedrequest-interfaces';
|
||||||
|
export { typedrequestInterfaces };
|
||||||
43
ts_interfaces/requests/admin.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import * as plugins from '../plugins.ts';
|
||||||
|
import * as data from '../data/index.ts';
|
||||||
|
|
||||||
|
export interface IReq_AdminLoginWithUsernameAndPassword extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_AdminLoginWithUsernameAndPassword
|
||||||
|
> {
|
||||||
|
method: 'adminLoginWithUsernameAndPassword';
|
||||||
|
request: {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
identity?: data.IIdentity;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IReq_AdminLogout extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_AdminLogout
|
||||||
|
> {
|
||||||
|
method: 'adminLogout';
|
||||||
|
request: {
|
||||||
|
identity: data.IIdentity;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
ok: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IReq_VerifyIdentity extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_VerifyIdentity
|
||||||
|
> {
|
||||||
|
method: 'verifyIdentity';
|
||||||
|
request: {
|
||||||
|
identity: data.IIdentity;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
valid: boolean;
|
||||||
|
identity?: data.IIdentity;
|
||||||
|
};
|
||||||
|
}
|
||||||
86
ts_interfaces/requests/buckets.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import * as plugins from '../plugins.ts';
|
||||||
|
import * as data from '../data/index.ts';
|
||||||
|
|
||||||
|
export interface IReq_ListBuckets extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_ListBuckets
|
||||||
|
> {
|
||||||
|
method: 'listBuckets';
|
||||||
|
request: {
|
||||||
|
identity: data.IIdentity;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
buckets: data.IBucketInfo[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IReq_CreateBucket extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_CreateBucket
|
||||||
|
> {
|
||||||
|
method: 'createBucket';
|
||||||
|
request: {
|
||||||
|
identity: data.IIdentity;
|
||||||
|
bucketName: string;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
ok: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IReq_DeleteBucket extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_DeleteBucket
|
||||||
|
> {
|
||||||
|
method: 'deleteBucket';
|
||||||
|
request: {
|
||||||
|
identity: data.IIdentity;
|
||||||
|
bucketName: string;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
ok: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IReq_GetBucketPolicy extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_GetBucketPolicy
|
||||||
|
> {
|
||||||
|
method: 'getBucketPolicy';
|
||||||
|
request: {
|
||||||
|
identity: data.IIdentity;
|
||||||
|
bucketName: string;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
policy: string | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IReq_PutBucketPolicy extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_PutBucketPolicy
|
||||||
|
> {
|
||||||
|
method: 'putBucketPolicy';
|
||||||
|
request: {
|
||||||
|
identity: data.IIdentity;
|
||||||
|
bucketName: string;
|
||||||
|
policy: string;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
ok: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IReq_DeleteBucketPolicy extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_DeleteBucketPolicy
|
||||||
|
> {
|
||||||
|
method: 'deleteBucketPolicy';
|
||||||
|
request: {
|
||||||
|
identity: data.IIdentity;
|
||||||
|
bucketName: string;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
ok: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
15
ts_interfaces/requests/config.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import * as plugins from '../plugins.ts';
|
||||||
|
import * as data from '../data/index.ts';
|
||||||
|
|
||||||
|
export interface IReq_GetServerConfig extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_GetServerConfig
|
||||||
|
> {
|
||||||
|
method: 'getServerConfig';
|
||||||
|
request: {
|
||||||
|
identity: data.IIdentity;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
config: data.IServerConfig;
|
||||||
|
};
|
||||||
|
}
|
||||||
44
ts_interfaces/requests/credentials.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import * as plugins from '../plugins.ts';
|
||||||
|
import * as data from '../data/index.ts';
|
||||||
|
|
||||||
|
export interface IReq_GetCredentials extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_GetCredentials
|
||||||
|
> {
|
||||||
|
method: 'getCredentials';
|
||||||
|
request: {
|
||||||
|
identity: data.IIdentity;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
credentials: data.IObjstCredential[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IReq_AddCredential extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_AddCredential
|
||||||
|
> {
|
||||||
|
method: 'addCredential';
|
||||||
|
request: {
|
||||||
|
identity: data.IIdentity;
|
||||||
|
accessKeyId: string;
|
||||||
|
secretAccessKey: string;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
ok: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IReq_RemoveCredential extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_RemoveCredential
|
||||||
|
> {
|
||||||
|
method: 'removeCredential';
|
||||||
|
request: {
|
||||||
|
identity: data.IIdentity;
|
||||||
|
accessKeyId: string;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
ok: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
7
ts_interfaces/requests/index.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export * from './admin.ts';
|
||||||
|
export * from './status.ts';
|
||||||
|
export * from './buckets.ts';
|
||||||
|
export * from './objects.ts';
|
||||||
|
export * from './config.ts';
|
||||||
|
export * from './credentials.ts';
|
||||||
|
export * from './policies.ts';
|
||||||
134
ts_interfaces/requests/objects.ts
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
import * as plugins from '../plugins.ts';
|
||||||
|
import * as data from '../data/index.ts';
|
||||||
|
|
||||||
|
export interface IReq_ListObjects extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_ListObjects
|
||||||
|
> {
|
||||||
|
method: 'listObjects';
|
||||||
|
request: {
|
||||||
|
identity: data.IIdentity;
|
||||||
|
bucketName: string;
|
||||||
|
prefix?: string;
|
||||||
|
delimiter?: string;
|
||||||
|
maxKeys?: number;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
result: data.IObjectListResult;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IReq_DeleteObject extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_DeleteObject
|
||||||
|
> {
|
||||||
|
method: 'deleteObject';
|
||||||
|
request: {
|
||||||
|
identity: data.IIdentity;
|
||||||
|
bucketName: string;
|
||||||
|
key: string;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
ok: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IReq_GetObject extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_GetObject
|
||||||
|
> {
|
||||||
|
method: 'getObject';
|
||||||
|
request: {
|
||||||
|
identity: data.IIdentity;
|
||||||
|
bucketName: string;
|
||||||
|
key: string;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
content: string;
|
||||||
|
contentType: string;
|
||||||
|
size: number;
|
||||||
|
lastModified: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IReq_PutObject extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_PutObject
|
||||||
|
> {
|
||||||
|
method: 'putObject';
|
||||||
|
request: {
|
||||||
|
identity: data.IIdentity;
|
||||||
|
bucketName: string;
|
||||||
|
key: string;
|
||||||
|
base64Content: string;
|
||||||
|
contentType: string;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
ok: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IReq_DeletePrefix extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_DeletePrefix
|
||||||
|
> {
|
||||||
|
method: 'deletePrefix';
|
||||||
|
request: {
|
||||||
|
identity: data.IIdentity;
|
||||||
|
bucketName: string;
|
||||||
|
prefix: string;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
ok: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IReq_GetObjectUrl extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_GetObjectUrl
|
||||||
|
> {
|
||||||
|
method: 'getObjectUrl';
|
||||||
|
request: {
|
||||||
|
identity: data.IIdentity;
|
||||||
|
bucketName: string;
|
||||||
|
key: string;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IReq_MoveObject extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_MoveObject
|
||||||
|
> {
|
||||||
|
method: 'moveObject';
|
||||||
|
request: {
|
||||||
|
identity: data.IIdentity;
|
||||||
|
bucketName: string;
|
||||||
|
sourceKey: string;
|
||||||
|
destKey: string;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IReq_MovePrefix extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_MovePrefix
|
||||||
|
> {
|
||||||
|
method: 'movePrefix';
|
||||||
|
request: {
|
||||||
|
identity: data.IIdentity;
|
||||||
|
bucketName: string;
|
||||||
|
sourcePrefix: string;
|
||||||
|
destPrefix: string;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
success: boolean;
|
||||||
|
movedCount?: number;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
137
ts_interfaces/requests/policies.ts
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import * as plugins from '../plugins.ts';
|
||||||
|
import * as data from '../data/index.ts';
|
||||||
|
|
||||||
|
export interface IReq_ListNamedPolicies extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_ListNamedPolicies
|
||||||
|
> {
|
||||||
|
method: 'listNamedPolicies';
|
||||||
|
request: {
|
||||||
|
identity: data.IIdentity;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
policies: data.INamedPolicy[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IReq_CreateNamedPolicy extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_CreateNamedPolicy
|
||||||
|
> {
|
||||||
|
method: 'createNamedPolicy';
|
||||||
|
request: {
|
||||||
|
identity: data.IIdentity;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
statements: data.IObjstStatement[];
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
policy: data.INamedPolicy;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IReq_UpdateNamedPolicy extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_UpdateNamedPolicy
|
||||||
|
> {
|
||||||
|
method: 'updateNamedPolicy';
|
||||||
|
request: {
|
||||||
|
identity: data.IIdentity;
|
||||||
|
policyId: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
statements: data.IObjstStatement[];
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
policy: data.INamedPolicy;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IReq_DeleteNamedPolicy extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_DeleteNamedPolicy
|
||||||
|
> {
|
||||||
|
method: 'deleteNamedPolicy';
|
||||||
|
request: {
|
||||||
|
identity: data.IIdentity;
|
||||||
|
policyId: string;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
ok: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IReq_GetBucketNamedPolicies extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_GetBucketNamedPolicies
|
||||||
|
> {
|
||||||
|
method: 'getBucketNamedPolicies';
|
||||||
|
request: {
|
||||||
|
identity: data.IIdentity;
|
||||||
|
bucketName: string;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
attachedPolicies: data.INamedPolicy[];
|
||||||
|
availablePolicies: data.INamedPolicy[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IReq_AttachPolicyToBucket extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_AttachPolicyToBucket
|
||||||
|
> {
|
||||||
|
method: 'attachPolicyToBucket';
|
||||||
|
request: {
|
||||||
|
identity: data.IIdentity;
|
||||||
|
policyId: string;
|
||||||
|
bucketName: string;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
ok: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IReq_DetachPolicyFromBucket extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_DetachPolicyFromBucket
|
||||||
|
> {
|
||||||
|
method: 'detachPolicyFromBucket';
|
||||||
|
request: {
|
||||||
|
identity: data.IIdentity;
|
||||||
|
policyId: string;
|
||||||
|
bucketName: string;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
ok: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IReq_GetPolicyBuckets extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_GetPolicyBuckets
|
||||||
|
> {
|
||||||
|
method: 'getPolicyBuckets';
|
||||||
|
request: {
|
||||||
|
identity: data.IIdentity;
|
||||||
|
policyId: string;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
attachedBuckets: string[];
|
||||||
|
availableBuckets: string[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IReq_SetPolicyBuckets extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_SetPolicyBuckets
|
||||||
|
> {
|
||||||
|
method: 'setPolicyBuckets';
|
||||||
|
request: {
|
||||||
|
identity: data.IIdentity;
|
||||||
|
policyId: string;
|
||||||
|
bucketNames: string[];
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
ok: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
16
ts_interfaces/requests/status.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import * as plugins from '../plugins.ts';
|
||||||
|
import * as data from '../data/index.ts';
|
||||||
|
|
||||||
|
export interface IReq_GetServerStatus extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_GetServerStatus
|
||||||
|
> {
|
||||||
|
method: 'getServerStatus';
|
||||||
|
request: {
|
||||||
|
identity: data.IIdentity;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
status: data.IServerStatus;
|
||||||
|
connectionInfo: data.IConnectionInfo;
|
||||||
|
};
|
||||||
|
}
|
||||||
8
ts_web/00_commitinfo_data.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* autocreated commitinfo by @push.rocks/commitinfo
|
||||||
|
*/
|
||||||
|
export const commitinfo = {
|
||||||
|
name: '@lossless.zone/objectstorage',
|
||||||
|
version: '1.4.0',
|
||||||
|
description: 'object storage server with management UI powered by smartstorage'
|
||||||
|
}
|
||||||
589
ts_web/appstate.ts
Normal file
@@ -0,0 +1,589 @@
|
|||||||
|
import * as plugins from './plugins.js';
|
||||||
|
import * as interfaces from '../ts_interfaces/index.js';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Smartstate instance
|
||||||
|
// ============================================================================
|
||||||
|
export const appState = new plugins.domtools.plugins.smartstate.Smartstate();
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// State Part Interfaces
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface ILoginState {
|
||||||
|
identity: interfaces.data.IIdentity | null;
|
||||||
|
isLoggedIn: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IServerState {
|
||||||
|
status: interfaces.data.IServerStatus | null;
|
||||||
|
connectionInfo: interfaces.data.IConnectionInfo | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IBucketsState {
|
||||||
|
buckets: interfaces.data.IBucketInfo[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IObjectsState {
|
||||||
|
result: interfaces.data.IObjectListResult | null;
|
||||||
|
currentBucket: string;
|
||||||
|
currentPrefix: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ICredentialsState {
|
||||||
|
credentials: interfaces.data.IObjstCredential[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IPoliciesState {
|
||||||
|
policies: interfaces.data.INamedPolicy[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IConfigState {
|
||||||
|
config: interfaces.data.IServerConfig | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IUiState {
|
||||||
|
activeView: string;
|
||||||
|
autoRefresh: boolean;
|
||||||
|
refreshInterval: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// State Parts
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const loginStatePart = await appState.getStatePart<ILoginState>(
|
||||||
|
'login',
|
||||||
|
{
|
||||||
|
identity: null,
|
||||||
|
isLoggedIn: false,
|
||||||
|
},
|
||||||
|
'persistent',
|
||||||
|
);
|
||||||
|
|
||||||
|
export const serverStatePart = await appState.getStatePart<IServerState>(
|
||||||
|
'server',
|
||||||
|
{
|
||||||
|
status: null,
|
||||||
|
connectionInfo: null,
|
||||||
|
},
|
||||||
|
'soft',
|
||||||
|
);
|
||||||
|
|
||||||
|
export const bucketsStatePart = await appState.getStatePart<IBucketsState>(
|
||||||
|
'buckets',
|
||||||
|
{
|
||||||
|
buckets: [],
|
||||||
|
},
|
||||||
|
'soft',
|
||||||
|
);
|
||||||
|
|
||||||
|
export const objectsStatePart = await appState.getStatePart<IObjectsState>(
|
||||||
|
'objects',
|
||||||
|
{
|
||||||
|
result: null,
|
||||||
|
currentBucket: '',
|
||||||
|
currentPrefix: '',
|
||||||
|
},
|
||||||
|
'soft',
|
||||||
|
);
|
||||||
|
|
||||||
|
export const credentialsStatePart = await appState.getStatePart<ICredentialsState>(
|
||||||
|
'credentials',
|
||||||
|
{
|
||||||
|
credentials: [],
|
||||||
|
},
|
||||||
|
'soft',
|
||||||
|
);
|
||||||
|
|
||||||
|
export const policiesStatePart = await appState.getStatePart<IPoliciesState>(
|
||||||
|
'policies',
|
||||||
|
{
|
||||||
|
policies: [],
|
||||||
|
},
|
||||||
|
'soft',
|
||||||
|
);
|
||||||
|
|
||||||
|
export const configStatePart = await appState.getStatePart<IConfigState>(
|
||||||
|
'config',
|
||||||
|
{
|
||||||
|
config: null,
|
||||||
|
},
|
||||||
|
'soft',
|
||||||
|
);
|
||||||
|
|
||||||
|
export const uiStatePart = await appState.getStatePart<IUiState>(
|
||||||
|
'ui',
|
||||||
|
{
|
||||||
|
activeView: 'overview',
|
||||||
|
autoRefresh: true,
|
||||||
|
refreshInterval: 30000,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Helpers
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface IActionContext {
|
||||||
|
identity: interfaces.data.IIdentity | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getActionContext = (): IActionContext => {
|
||||||
|
return { identity: loginStatePart.getState().identity };
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Login Actions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const loginAction = loginStatePart.createAction<{
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
}>(async (statePartArg, dataArg) => {
|
||||||
|
try {
|
||||||
|
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_AdminLoginWithUsernameAndPassword
|
||||||
|
>('/typedrequest', 'adminLoginWithUsernameAndPassword');
|
||||||
|
|
||||||
|
const response = await typedRequest.fire({
|
||||||
|
username: dataArg.username,
|
||||||
|
password: dataArg.password,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
identity: response.identity || null,
|
||||||
|
isLoggedIn: !!response.identity,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Login failed:', err);
|
||||||
|
return { identity: null, isLoggedIn: false };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const logoutAction = loginStatePart.createAction(async (statePartArg) => {
|
||||||
|
const context = getActionContext();
|
||||||
|
try {
|
||||||
|
if (context.identity) {
|
||||||
|
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_AdminLogout
|
||||||
|
>('/typedrequest', 'adminLogout');
|
||||||
|
await typedRequest.fire({ identity: context.identity });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Logout error:', err);
|
||||||
|
}
|
||||||
|
return { identity: null, isLoggedIn: false };
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Server Status Actions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const fetchServerStatusAction = serverStatePart.createAction(async (statePartArg) => {
|
||||||
|
const context = getActionContext();
|
||||||
|
try {
|
||||||
|
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_GetServerStatus
|
||||||
|
>('/typedrequest', 'getServerStatus');
|
||||||
|
const response = await typedRequest.fire({ identity: context.identity! });
|
||||||
|
return { status: response.status, connectionInfo: response.connectionInfo };
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch server status:', err);
|
||||||
|
return statePartArg.getState();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Buckets Actions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const fetchBucketsAction = bucketsStatePart.createAction(async (statePartArg) => {
|
||||||
|
const context = getActionContext();
|
||||||
|
try {
|
||||||
|
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_ListBuckets
|
||||||
|
>('/typedrequest', 'listBuckets');
|
||||||
|
const response = await typedRequest.fire({ identity: context.identity! });
|
||||||
|
return { buckets: response.buckets };
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch buckets:', err);
|
||||||
|
return statePartArg.getState();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const createBucketAction = bucketsStatePart.createAction<{ bucketName: string }>(
|
||||||
|
async (statePartArg, dataArg) => {
|
||||||
|
const context = getActionContext();
|
||||||
|
try {
|
||||||
|
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_CreateBucket
|
||||||
|
>('/typedrequest', 'createBucket');
|
||||||
|
await typedRequest.fire({ identity: context.identity!, bucketName: dataArg.bucketName });
|
||||||
|
// Re-fetch buckets list
|
||||||
|
const listReq = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_ListBuckets
|
||||||
|
>('/typedrequest', 'listBuckets');
|
||||||
|
const listResp = await listReq.fire({ identity: context.identity! });
|
||||||
|
return { buckets: listResp.buckets };
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to create bucket:', err);
|
||||||
|
return statePartArg.getState();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const deleteBucketAction = bucketsStatePart.createAction<{ bucketName: string }>(
|
||||||
|
async (statePartArg, dataArg) => {
|
||||||
|
const context = getActionContext();
|
||||||
|
try {
|
||||||
|
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_DeleteBucket
|
||||||
|
>('/typedrequest', 'deleteBucket');
|
||||||
|
await typedRequest.fire({ identity: context.identity!, bucketName: dataArg.bucketName });
|
||||||
|
const state = statePartArg.getState();
|
||||||
|
return {
|
||||||
|
buckets: state.buckets.filter((b) => b.name !== dataArg.bucketName),
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to delete bucket:', err);
|
||||||
|
return statePartArg.getState();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Named Policies Actions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const fetchPoliciesAction = policiesStatePart.createAction(async (statePartArg) => {
|
||||||
|
const context = getActionContext();
|
||||||
|
try {
|
||||||
|
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_ListNamedPolicies
|
||||||
|
>('/typedrequest', 'listNamedPolicies');
|
||||||
|
const response = await typedRequest.fire({ identity: context.identity! });
|
||||||
|
return { policies: response.policies };
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch policies:', err);
|
||||||
|
return statePartArg.getState();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const createPolicyAction = policiesStatePart.createAction<{
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
statements: interfaces.data.IObjstStatement[];
|
||||||
|
}>(async (statePartArg, dataArg) => {
|
||||||
|
const context = getActionContext();
|
||||||
|
try {
|
||||||
|
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_CreateNamedPolicy
|
||||||
|
>('/typedrequest', 'createNamedPolicy');
|
||||||
|
await typedRequest.fire({
|
||||||
|
identity: context.identity!,
|
||||||
|
name: dataArg.name,
|
||||||
|
description: dataArg.description,
|
||||||
|
statements: dataArg.statements,
|
||||||
|
});
|
||||||
|
// Re-fetch
|
||||||
|
const listReq = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_ListNamedPolicies
|
||||||
|
>('/typedrequest', 'listNamedPolicies');
|
||||||
|
const listResp = await listReq.fire({ identity: context.identity! });
|
||||||
|
return { policies: listResp.policies };
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to create policy:', err);
|
||||||
|
return statePartArg.getState();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const updatePolicyAction = policiesStatePart.createAction<{
|
||||||
|
policyId: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
statements: interfaces.data.IObjstStatement[];
|
||||||
|
}>(async (statePartArg, dataArg) => {
|
||||||
|
const context = getActionContext();
|
||||||
|
try {
|
||||||
|
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_UpdateNamedPolicy
|
||||||
|
>('/typedrequest', 'updateNamedPolicy');
|
||||||
|
await typedRequest.fire({
|
||||||
|
identity: context.identity!,
|
||||||
|
policyId: dataArg.policyId,
|
||||||
|
name: dataArg.name,
|
||||||
|
description: dataArg.description,
|
||||||
|
statements: dataArg.statements,
|
||||||
|
});
|
||||||
|
// Re-fetch
|
||||||
|
const listReq = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_ListNamedPolicies
|
||||||
|
>('/typedrequest', 'listNamedPolicies');
|
||||||
|
const listResp = await listReq.fire({ identity: context.identity! });
|
||||||
|
return { policies: listResp.policies };
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to update policy:', err);
|
||||||
|
return statePartArg.getState();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const deletePolicyAction = policiesStatePart.createAction<{ policyId: string }>(
|
||||||
|
async (statePartArg, dataArg) => {
|
||||||
|
const context = getActionContext();
|
||||||
|
try {
|
||||||
|
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_DeleteNamedPolicy
|
||||||
|
>('/typedrequest', 'deleteNamedPolicy');
|
||||||
|
await typedRequest.fire({ identity: context.identity!, policyId: dataArg.policyId });
|
||||||
|
const state = statePartArg.getState();
|
||||||
|
return { policies: state.policies.filter((p) => p.id !== dataArg.policyId) };
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to delete policy:', err);
|
||||||
|
return statePartArg.getState();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Standalone async functions for policy-bucket management
|
||||||
|
|
||||||
|
export const getBucketNamedPolicies = async (bucketName: string) => {
|
||||||
|
const context = getActionContext();
|
||||||
|
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_GetBucketNamedPolicies
|
||||||
|
>('/typedrequest', 'getBucketNamedPolicies');
|
||||||
|
return await typedRequest.fire({ identity: context.identity!, bucketName });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const attachPolicyToBucket = async (policyId: string, bucketName: string) => {
|
||||||
|
const context = getActionContext();
|
||||||
|
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_AttachPolicyToBucket
|
||||||
|
>('/typedrequest', 'attachPolicyToBucket');
|
||||||
|
return await typedRequest.fire({ identity: context.identity!, policyId, bucketName });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const detachPolicyFromBucket = async (policyId: string, bucketName: string) => {
|
||||||
|
const context = getActionContext();
|
||||||
|
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_DetachPolicyFromBucket
|
||||||
|
>('/typedrequest', 'detachPolicyFromBucket');
|
||||||
|
return await typedRequest.fire({ identity: context.identity!, policyId, bucketName });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getPolicyBuckets = async (policyId: string) => {
|
||||||
|
const context = getActionContext();
|
||||||
|
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_GetPolicyBuckets
|
||||||
|
>('/typedrequest', 'getPolicyBuckets');
|
||||||
|
return await typedRequest.fire({ identity: context.identity!, policyId });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setPolicyBuckets = async (policyId: string, bucketNames: string[]) => {
|
||||||
|
const context = getActionContext();
|
||||||
|
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_SetPolicyBuckets
|
||||||
|
>('/typedrequest', 'setPolicyBuckets');
|
||||||
|
return await typedRequest.fire({ identity: context.identity!, policyId, bucketNames });
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Objects Actions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const fetchObjectsAction = objectsStatePart.createAction<{
|
||||||
|
bucketName: string;
|
||||||
|
prefix?: string;
|
||||||
|
delimiter?: string;
|
||||||
|
}>(async (statePartArg, dataArg) => {
|
||||||
|
const context = getActionContext();
|
||||||
|
try {
|
||||||
|
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_ListObjects
|
||||||
|
>('/typedrequest', 'listObjects');
|
||||||
|
const response = await typedRequest.fire({
|
||||||
|
identity: context.identity!,
|
||||||
|
bucketName: dataArg.bucketName,
|
||||||
|
prefix: dataArg.prefix || '',
|
||||||
|
delimiter: dataArg.delimiter || '/',
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
result: response.result,
|
||||||
|
currentBucket: dataArg.bucketName,
|
||||||
|
currentPrefix: dataArg.prefix || '',
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch objects:', err);
|
||||||
|
return statePartArg.getState();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const deleteObjectAction = objectsStatePart.createAction<{
|
||||||
|
bucketName: string;
|
||||||
|
key: string;
|
||||||
|
}>(async (statePartArg, dataArg) => {
|
||||||
|
const context = getActionContext();
|
||||||
|
try {
|
||||||
|
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_DeleteObject
|
||||||
|
>('/typedrequest', 'deleteObject');
|
||||||
|
await typedRequest.fire({
|
||||||
|
identity: context.identity!,
|
||||||
|
bucketName: dataArg.bucketName,
|
||||||
|
key: dataArg.key,
|
||||||
|
});
|
||||||
|
// Re-fetch objects
|
||||||
|
const state = statePartArg.getState();
|
||||||
|
const listReq = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_ListObjects
|
||||||
|
>('/typedrequest', 'listObjects');
|
||||||
|
const listResp = await listReq.fire({
|
||||||
|
identity: context.identity!,
|
||||||
|
bucketName: state.currentBucket,
|
||||||
|
prefix: state.currentPrefix,
|
||||||
|
delimiter: '/',
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
result: listResp.result,
|
||||||
|
currentBucket: state.currentBucket,
|
||||||
|
currentPrefix: state.currentPrefix,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to delete object:', err);
|
||||||
|
return statePartArg.getState();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Credentials Actions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const fetchCredentialsAction = credentialsStatePart.createAction(async (statePartArg) => {
|
||||||
|
const context = getActionContext();
|
||||||
|
try {
|
||||||
|
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_GetCredentials
|
||||||
|
>('/typedrequest', 'getCredentials');
|
||||||
|
const response = await typedRequest.fire({ identity: context.identity! });
|
||||||
|
return { credentials: response.credentials };
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch credentials:', err);
|
||||||
|
return statePartArg.getState();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const addCredentialAction = credentialsStatePart.createAction<{
|
||||||
|
accessKeyId: string;
|
||||||
|
secretAccessKey: string;
|
||||||
|
}>(async (statePartArg, dataArg) => {
|
||||||
|
const context = getActionContext();
|
||||||
|
try {
|
||||||
|
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_AddCredential
|
||||||
|
>('/typedrequest', 'addCredential');
|
||||||
|
await typedRequest.fire({
|
||||||
|
identity: context.identity!,
|
||||||
|
accessKeyId: dataArg.accessKeyId,
|
||||||
|
secretAccessKey: dataArg.secretAccessKey,
|
||||||
|
});
|
||||||
|
// Re-fetch credentials
|
||||||
|
const listReq = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_GetCredentials
|
||||||
|
>('/typedrequest', 'getCredentials');
|
||||||
|
const listResp = await listReq.fire({ identity: context.identity! });
|
||||||
|
return { credentials: listResp.credentials };
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to add credential:', err);
|
||||||
|
return statePartArg.getState();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const removeCredentialAction = credentialsStatePart.createAction<{
|
||||||
|
accessKeyId: string;
|
||||||
|
}>(async (statePartArg, dataArg) => {
|
||||||
|
const context = getActionContext();
|
||||||
|
try {
|
||||||
|
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_RemoveCredential
|
||||||
|
>('/typedrequest', 'removeCredential');
|
||||||
|
await typedRequest.fire({
|
||||||
|
identity: context.identity!,
|
||||||
|
accessKeyId: dataArg.accessKeyId,
|
||||||
|
});
|
||||||
|
const state = statePartArg.getState();
|
||||||
|
return {
|
||||||
|
credentials: state.credentials.filter((c) => c.accessKeyId !== dataArg.accessKeyId),
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to remove credential:', err);
|
||||||
|
return statePartArg.getState();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Config Actions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const fetchConfigAction = configStatePart.createAction(async (statePartArg) => {
|
||||||
|
const context = getActionContext();
|
||||||
|
try {
|
||||||
|
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_GetServerConfig
|
||||||
|
>('/typedrequest', 'getServerConfig');
|
||||||
|
const response = await typedRequest.fire({ identity: context.identity! });
|
||||||
|
return { config: response.config };
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch config:', err);
|
||||||
|
return statePartArg.getState();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// UI Actions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const setActiveViewAction = uiStatePart.createAction<{ view: string }>(
|
||||||
|
async (statePartArg, dataArg) => {
|
||||||
|
return { ...statePartArg.getState(), activeView: dataArg.view };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Auto-refresh system
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
let refreshIntervalHandle: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
const dispatchCombinedRefreshAction = async () => {
|
||||||
|
const loginState = loginStatePart.getState();
|
||||||
|
if (!loginState.isLoggedIn) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await serverStatePart.dispatchAction(fetchServerStatusAction, null);
|
||||||
|
} catch (_err) {
|
||||||
|
// Silently fail on auto-refresh
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const startAutoRefresh = () => {
|
||||||
|
const uiState = uiStatePart.getState();
|
||||||
|
const loginState = loginStatePart.getState();
|
||||||
|
|
||||||
|
if (uiState.autoRefresh && loginState.isLoggedIn) {
|
||||||
|
if (refreshIntervalHandle) {
|
||||||
|
clearInterval(refreshIntervalHandle);
|
||||||
|
}
|
||||||
|
refreshIntervalHandle = setInterval(() => {
|
||||||
|
dispatchCombinedRefreshAction();
|
||||||
|
}, uiState.refreshInterval);
|
||||||
|
} else {
|
||||||
|
if (refreshIntervalHandle) {
|
||||||
|
clearInterval(refreshIntervalHandle);
|
||||||
|
refreshIntervalHandle = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
uiStatePart.select((s) => s).subscribe(() => startAutoRefresh());
|
||||||
|
loginStatePart.select((s) => s).subscribe(() => startAutoRefresh());
|
||||||
|
startAutoRefresh();
|
||||||
115
ts_web/dataprovider.ts
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import * as plugins from './plugins.js';
|
||||||
|
import * as appstate from './appstate.js';
|
||||||
|
import * as interfaces from '../ts_interfaces/index.js';
|
||||||
|
import type { IS3DataProvider } from '@design.estate/dees-catalog';
|
||||||
|
|
||||||
|
const getIdentity = (): interfaces.data.IIdentity => {
|
||||||
|
return appstate.loginStatePart.getState().identity!;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createDataProvider = (): IS3DataProvider => ({
|
||||||
|
async listObjects(bucket: string, prefix?: string, delimiter?: string) {
|
||||||
|
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_ListObjects
|
||||||
|
>('/typedrequest', 'listObjects');
|
||||||
|
const response = await typedRequest.fire({
|
||||||
|
identity: getIdentity(),
|
||||||
|
bucketName: bucket,
|
||||||
|
prefix: prefix || '',
|
||||||
|
delimiter: delimiter || '/',
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
objects: response.result.objects.map((obj) => ({
|
||||||
|
key: obj.key,
|
||||||
|
size: obj.size,
|
||||||
|
lastModified: new Date(obj.lastModified).toISOString(),
|
||||||
|
})),
|
||||||
|
prefixes: response.result.commonPrefixes,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async getObject(bucket: string, key: string) {
|
||||||
|
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_GetObject
|
||||||
|
>('/typedrequest', 'getObject');
|
||||||
|
return await typedRequest.fire({
|
||||||
|
identity: getIdentity(),
|
||||||
|
bucketName: bucket,
|
||||||
|
key,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async putObject(bucket: string, key: string, base64Content: string, contentType: string) {
|
||||||
|
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_PutObject
|
||||||
|
>('/typedrequest', 'putObject');
|
||||||
|
const response = await typedRequest.fire({
|
||||||
|
identity: getIdentity(),
|
||||||
|
bucketName: bucket,
|
||||||
|
key,
|
||||||
|
base64Content,
|
||||||
|
contentType,
|
||||||
|
});
|
||||||
|
return response.ok;
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteObject(bucket: string, key: string) {
|
||||||
|
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_DeleteObject
|
||||||
|
>('/typedrequest', 'deleteObject');
|
||||||
|
const response = await typedRequest.fire({
|
||||||
|
identity: getIdentity(),
|
||||||
|
bucketName: bucket,
|
||||||
|
key,
|
||||||
|
});
|
||||||
|
return response.ok;
|
||||||
|
},
|
||||||
|
|
||||||
|
async deletePrefix(bucket: string, prefix: string) {
|
||||||
|
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_DeletePrefix
|
||||||
|
>('/typedrequest', 'deletePrefix');
|
||||||
|
const response = await typedRequest.fire({
|
||||||
|
identity: getIdentity(),
|
||||||
|
bucketName: bucket,
|
||||||
|
prefix,
|
||||||
|
});
|
||||||
|
return response.ok;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getObjectUrl(bucket: string, key: string) {
|
||||||
|
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_GetObjectUrl
|
||||||
|
>('/typedrequest', 'getObjectUrl');
|
||||||
|
const response = await typedRequest.fire({
|
||||||
|
identity: getIdentity(),
|
||||||
|
bucketName: bucket,
|
||||||
|
key,
|
||||||
|
});
|
||||||
|
return response.url;
|
||||||
|
},
|
||||||
|
|
||||||
|
async moveObject(bucket: string, sourceKey: string, destKey: string) {
|
||||||
|
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_MoveObject
|
||||||
|
>('/typedrequest', 'moveObject');
|
||||||
|
return await typedRequest.fire({
|
||||||
|
identity: getIdentity(),
|
||||||
|
bucketName: bucket,
|
||||||
|
sourceKey,
|
||||||
|
destKey,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async movePrefix(bucket: string, sourcePrefix: string, destPrefix: string) {
|
||||||
|
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_MovePrefix
|
||||||
|
>('/typedrequest', 'movePrefix');
|
||||||
|
return await typedRequest.fire({
|
||||||
|
identity: getIdentity(),
|
||||||
|
bucketName: bucket,
|
||||||
|
sourcePrefix,
|
||||||
|
destPrefix,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
8
ts_web/elements/index.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export * from './shared/index.js';
|
||||||
|
export * from './objst-app-shell.js';
|
||||||
|
export * from './objst-view-overview.js';
|
||||||
|
export * from './objst-view-buckets.js';
|
||||||
|
export * from './objst-view-objects.js';
|
||||||
|
export * from './objst-view-policies.js';
|
||||||
|
export * from './objst-view-config.js';
|
||||||
|
export * from './objst-view-credentials.js';
|
||||||
200
ts_web/elements/objst-app-shell.ts
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import * as appstate from '../appstate.js';
|
||||||
|
import * as interfaces from '../../ts_interfaces/index.js';
|
||||||
|
import { appRouter } from '../router.js';
|
||||||
|
import {
|
||||||
|
DeesElement,
|
||||||
|
customElement,
|
||||||
|
html,
|
||||||
|
state,
|
||||||
|
css,
|
||||||
|
cssManager,
|
||||||
|
type TemplateResult,
|
||||||
|
} from '@design.estate/dees-element';
|
||||||
|
|
||||||
|
import type { ObjstViewOverview } from './objst-view-overview.js';
|
||||||
|
import type { ObjstViewBuckets } from './objst-view-buckets.js';
|
||||||
|
import type { ObjstViewObjects } from './objst-view-objects.js';
|
||||||
|
import type { ObjstViewPolicies } from './objst-view-policies.js';
|
||||||
|
import type { ObjstViewConfig } from './objst-view-config.js';
|
||||||
|
import type { ObjstViewCredentials } from './objst-view-credentials.js';
|
||||||
|
|
||||||
|
@customElement('objst-app-shell')
|
||||||
|
export class ObjstAppShell extends DeesElement {
|
||||||
|
@state()
|
||||||
|
accessor loginState: appstate.ILoginState = { identity: null, isLoggedIn: false };
|
||||||
|
|
||||||
|
@state()
|
||||||
|
accessor uiState: appstate.IUiState = {
|
||||||
|
activeView: 'overview',
|
||||||
|
autoRefresh: true,
|
||||||
|
refreshInterval: 30000,
|
||||||
|
};
|
||||||
|
|
||||||
|
private viewTabs = [
|
||||||
|
{ name: 'Overview', iconName: 'lucide:layoutDashboard', element: (async () => (await import('./objst-view-overview.js')).ObjstViewOverview)() },
|
||||||
|
{ name: 'Buckets', iconName: 'lucide:database', element: (async () => (await import('./objst-view-buckets.js')).ObjstViewBuckets)() },
|
||||||
|
{ name: 'Browser', iconName: 'lucide:folderOpen', element: (async () => (await import('./objst-view-objects.js')).ObjstViewObjects)() },
|
||||||
|
{ name: 'Policies', iconName: 'lucide:shield', element: (async () => (await import('./objst-view-policies.js')).ObjstViewPolicies)() },
|
||||||
|
{ name: 'Config', iconName: 'lucide:settings', element: (async () => (await import('./objst-view-config.js')).ObjstViewConfig)() },
|
||||||
|
{ name: 'Access Keys', iconName: 'lucide:key', element: (async () => (await import('./objst-view-credentials.js')).ObjstViewCredentials)() },
|
||||||
|
];
|
||||||
|
|
||||||
|
private resolvedViewTabs: Array<{ name: string; iconName: string; element: any }> = [];
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
document.title = 'ObjectStorage';
|
||||||
|
|
||||||
|
const loginSubscription = appstate.loginStatePart
|
||||||
|
.select((stateArg) => stateArg)
|
||||||
|
.subscribe((loginState) => {
|
||||||
|
this.loginState = loginState;
|
||||||
|
if (loginState.isLoggedIn) {
|
||||||
|
appstate.serverStatePart.dispatchAction(appstate.fetchServerStatusAction, null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.rxSubscriptions.push(loginSubscription);
|
||||||
|
|
||||||
|
const uiSubscription = appstate.uiStatePart
|
||||||
|
.select((stateArg) => stateArg)
|
||||||
|
.subscribe((uiState) => {
|
||||||
|
this.uiState = uiState;
|
||||||
|
this.syncAppdashView(uiState.activeView);
|
||||||
|
});
|
||||||
|
this.rxSubscriptions.push(uiSubscription);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static styles = [
|
||||||
|
cssManager.defaultStyles,
|
||||||
|
css`
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.maincontainer {
|
||||||
|
width: 100%;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
];
|
||||||
|
|
||||||
|
public render(): TemplateResult {
|
||||||
|
return html`
|
||||||
|
<div class="maincontainer">
|
||||||
|
<dees-simple-login name="ObjectStorage">
|
||||||
|
<dees-simple-appdash
|
||||||
|
name="ObjectStorage"
|
||||||
|
.viewTabs=${this.resolvedViewTabs}
|
||||||
|
>
|
||||||
|
</dees-simple-appdash>
|
||||||
|
</dees-simple-login>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async firstUpdated() {
|
||||||
|
// Resolve async view tab imports
|
||||||
|
this.resolvedViewTabs = await Promise.all(
|
||||||
|
this.viewTabs.map(async (tab) => ({
|
||||||
|
name: tab.name,
|
||||||
|
iconName: tab.iconName,
|
||||||
|
element: await tab.element,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
this.requestUpdate();
|
||||||
|
await this.updateComplete;
|
||||||
|
|
||||||
|
const simpleLogin = this.shadowRoot!.querySelector('dees-simple-login') as any;
|
||||||
|
if (simpleLogin) {
|
||||||
|
simpleLogin.addEventListener('login', (e: CustomEvent) => {
|
||||||
|
this.login(e.detail.data.username, e.detail.data.password);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const appDash = this.shadowRoot!.querySelector('dees-simple-appdash') as any;
|
||||||
|
if (appDash) {
|
||||||
|
appDash.addEventListener('view-select', (e: CustomEvent) => {
|
||||||
|
const viewName = e.detail.view.name.toLowerCase();
|
||||||
|
appRouter.navigateToView(viewName);
|
||||||
|
});
|
||||||
|
appDash.addEventListener('logout', async () => {
|
||||||
|
await appstate.loginStatePart.dispatchAction(appstate.logoutAction, null);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load the initial view on the appdash now that tabs are resolved
|
||||||
|
if (appDash && this.resolvedViewTabs.length > 0) {
|
||||||
|
const initialView = this.resolvedViewTabs.find(
|
||||||
|
(t) => t.name.toLowerCase() === this.uiState.activeView,
|
||||||
|
) || this.resolvedViewTabs[0];
|
||||||
|
await appDash.loadView(initialView);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for stored session (persistent login state)
|
||||||
|
const loginState = appstate.loginStatePart.getState();
|
||||||
|
if (loginState.identity?.jwt) {
|
||||||
|
if (loginState.identity.expiresAt > Date.now()) {
|
||||||
|
try {
|
||||||
|
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_GetServerStatus
|
||||||
|
>('/typedrequest', 'getServerStatus');
|
||||||
|
const response = await typedRequest.fire({ identity: loginState.identity });
|
||||||
|
appstate.serverStatePart.setState({
|
||||||
|
status: response.status,
|
||||||
|
connectionInfo: response.connectionInfo,
|
||||||
|
});
|
||||||
|
this.loginState = loginState;
|
||||||
|
if (simpleLogin) {
|
||||||
|
await simpleLogin.switchToSlottedContent();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Stored session invalid, returning to login:', err);
|
||||||
|
await appstate.loginStatePart.dispatchAction(appstate.logoutAction, null);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await appstate.loginStatePart.dispatchAction(appstate.logoutAction, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async login(username: string, password: string) {
|
||||||
|
const domtools = await this.domtoolsPromise;
|
||||||
|
const simpleLogin = this.shadowRoot!.querySelector('dees-simple-login') as any;
|
||||||
|
const form = simpleLogin?.shadowRoot?.querySelector('dees-form') as any;
|
||||||
|
|
||||||
|
if (form) {
|
||||||
|
form.setStatus('pending', 'Logging in...');
|
||||||
|
}
|
||||||
|
|
||||||
|
const newState = await appstate.loginStatePart.dispatchAction(appstate.loginAction, {
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (newState.identity) {
|
||||||
|
if (form) {
|
||||||
|
form.setStatus('success', 'Logged in!');
|
||||||
|
}
|
||||||
|
if (simpleLogin) {
|
||||||
|
await simpleLogin.switchToSlottedContent();
|
||||||
|
}
|
||||||
|
await appstate.serverStatePart.dispatchAction(appstate.fetchServerStatusAction, null);
|
||||||
|
} else {
|
||||||
|
if (form) {
|
||||||
|
form.setStatus('error', 'Login failed!');
|
||||||
|
await domtools.convenience.smartdelay.delayFor(2000);
|
||||||
|
form.reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private syncAppdashView(viewName: string): void {
|
||||||
|
const appDash = this.shadowRoot?.querySelector('dees-simple-appdash') as any;
|
||||||
|
if (!appDash || this.resolvedViewTabs.length === 0) return;
|
||||||
|
const targetTab = this.resolvedViewTabs.find((t) => t.name.toLowerCase() === viewName);
|
||||||
|
if (!targetTab) return;
|
||||||
|
appDash.loadView(targetTab);
|
||||||
|
}
|
||||||
|
}
|
||||||
271
ts_web/elements/objst-view-buckets.ts
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import * as appstate from '../appstate.js';
|
||||||
|
import * as shared from './shared/index.js';
|
||||||
|
import { appRouter } from '../router.js';
|
||||||
|
|
||||||
|
import {
|
||||||
|
DeesElement,
|
||||||
|
customElement,
|
||||||
|
html,
|
||||||
|
state,
|
||||||
|
css,
|
||||||
|
cssManager,
|
||||||
|
type TemplateResult,
|
||||||
|
} from '@design.estate/dees-element';
|
||||||
|
import { DeesModal } from '@design.estate/dees-catalog';
|
||||||
|
|
||||||
|
@customElement('objst-view-buckets')
|
||||||
|
export class ObjstViewBuckets extends DeesElement {
|
||||||
|
@state()
|
||||||
|
accessor bucketsState: appstate.IBucketsState = { buckets: [] };
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
const sub = appstate.bucketsStatePart
|
||||||
|
.select((s) => s)
|
||||||
|
.subscribe((bucketsState) => {
|
||||||
|
this.bucketsState = bucketsState;
|
||||||
|
});
|
||||||
|
this.rxSubscriptions.push(sub);
|
||||||
|
}
|
||||||
|
|
||||||
|
async connectedCallback() {
|
||||||
|
super.connectedCallback();
|
||||||
|
appstate.bucketsStatePart.dispatchAction(appstate.fetchBucketsAction, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static styles = [
|
||||||
|
cssManager.defaultStyles,
|
||||||
|
shared.viewHostCss,
|
||||||
|
];
|
||||||
|
|
||||||
|
public render(): TemplateResult {
|
||||||
|
return html`
|
||||||
|
<objst-sectionheading>Buckets</objst-sectionheading>
|
||||||
|
<dees-table
|
||||||
|
heading1="Buckets"
|
||||||
|
heading2="Manage your storage buckets"
|
||||||
|
.data=${this.bucketsState.buckets}
|
||||||
|
.dataName=${'bucket'}
|
||||||
|
.searchable=${true}
|
||||||
|
.displayFunction=${(item: any) => ({
|
||||||
|
name: item.name,
|
||||||
|
objects: String(item.objectCount),
|
||||||
|
size: this.formatBytes(item.totalSizeBytes),
|
||||||
|
created: new Date(item.creationDate).toLocaleDateString(),
|
||||||
|
})}
|
||||||
|
.dataActions=${[
|
||||||
|
{
|
||||||
|
name: 'Create Bucket',
|
||||||
|
iconName: 'lucide:plus',
|
||||||
|
type: ['header'] as any[],
|
||||||
|
actionFunc: async () => {
|
||||||
|
await this.showCreateBucketModal();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Browse',
|
||||||
|
iconName: 'lucide:folderOpen',
|
||||||
|
type: ['inRow'] as any[],
|
||||||
|
actionFunc: async (args: any) => {
|
||||||
|
await appstate.objectsStatePart.dispatchAction(appstate.fetchObjectsAction, {
|
||||||
|
bucketName: args.item.name,
|
||||||
|
prefix: '',
|
||||||
|
delimiter: '/',
|
||||||
|
});
|
||||||
|
appRouter.navigateToView('browser');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Policy',
|
||||||
|
iconName: 'lucide:shield',
|
||||||
|
type: ['inRow'] as any[],
|
||||||
|
actionFunc: async (args: any) => {
|
||||||
|
await this.showPolicyModal(args.item.name);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Delete',
|
||||||
|
iconName: 'lucide:trash2',
|
||||||
|
type: ['inRow', 'contextmenu'] as any[],
|
||||||
|
actionFunc: async (args: any) => {
|
||||||
|
await this.showDeleteBucketModal(args.item.name);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
></dees-table>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async showCreateBucketModal(): Promise<void> {
|
||||||
|
await DeesModal.createAndShow({
|
||||||
|
heading: 'Create Bucket',
|
||||||
|
content: html`
|
||||||
|
<dees-form>
|
||||||
|
<dees-input-text .key=${'bucketName'} .label=${'Bucket Name'} .required=${true}></dees-input-text>
|
||||||
|
</dees-form>
|
||||||
|
`,
|
||||||
|
menuOptions: [
|
||||||
|
{
|
||||||
|
name: 'Cancel',
|
||||||
|
action: async (modal: any) => { modal.destroy(); },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Create',
|
||||||
|
action: async (modal: any) => {
|
||||||
|
const form = modal.shadowRoot.querySelector('dees-form') as any;
|
||||||
|
const data = await form.collectFormData();
|
||||||
|
if (data.bucketName) {
|
||||||
|
await appstate.bucketsStatePart.dispatchAction(appstate.createBucketAction, {
|
||||||
|
bucketName: data.bucketName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
modal.destroy();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async showDeleteBucketModal(bucketName: string): Promise<void> {
|
||||||
|
await DeesModal.createAndShow({
|
||||||
|
heading: 'Delete Bucket',
|
||||||
|
content: html`<p>Are you sure you want to delete bucket <strong>${bucketName}</strong>? This action cannot be undone.</p>`,
|
||||||
|
menuOptions: [
|
||||||
|
{
|
||||||
|
name: 'Cancel',
|
||||||
|
action: async (modal: any) => { modal.destroy(); },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Delete',
|
||||||
|
action: async (modal: any) => {
|
||||||
|
await appstate.bucketsStatePart.dispatchAction(appstate.deleteBucketAction, {
|
||||||
|
bucketName,
|
||||||
|
});
|
||||||
|
modal.destroy();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async showPolicyModal(bucketName: string): Promise<void> {
|
||||||
|
const data = await appstate.getBucketNamedPolicies(bucketName);
|
||||||
|
let modalRef: any = null;
|
||||||
|
|
||||||
|
const renderContent = (
|
||||||
|
attached: typeof data.attachedPolicies,
|
||||||
|
available: typeof data.availablePolicies,
|
||||||
|
) => html`
|
||||||
|
<style>
|
||||||
|
.policy-lists { display: flex; flex-direction: column; gap: 16px; }
|
||||||
|
.policy-section h4 {
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: ${cssManager.bdTheme('#333', '#ccc')};
|
||||||
|
}
|
||||||
|
.policy-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: ${cssManager.bdTheme('#f5f5f5', '#1a1a2e')};
|
||||||
|
margin-bottom: 4px;
|
||||||
|
color: ${cssManager.bdTheme('#333', '#eee')};
|
||||||
|
}
|
||||||
|
.policy-item-info { display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
.policy-item-name { font-size: 14px; font-weight: 500; }
|
||||||
|
.policy-item-desc { font-size: 12px; color: ${cssManager.bdTheme('#666', '#999')}; }
|
||||||
|
.policy-item button {
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.btn-detach {
|
||||||
|
background: ${cssManager.bdTheme('#ffebee', '#3e1a1a')};
|
||||||
|
color: ${cssManager.bdTheme('#c62828', '#ef5350')};
|
||||||
|
}
|
||||||
|
.btn-detach:hover { opacity: 0.8; }
|
||||||
|
.btn-attach {
|
||||||
|
background: ${cssManager.bdTheme('#e3f2fd', '#1a2a3e')};
|
||||||
|
color: ${cssManager.bdTheme('#1565c0', '#64b5f6')};
|
||||||
|
}
|
||||||
|
.btn-attach:hover { opacity: 0.8; }
|
||||||
|
.empty-note {
|
||||||
|
color: ${cssManager.bdTheme('#999', '#666')};
|
||||||
|
font-size: 13px;
|
||||||
|
font-style: italic;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<div class="policy-lists">
|
||||||
|
<div class="policy-section">
|
||||||
|
<h4>Attached Policies (${attached.length})</h4>
|
||||||
|
${attached.length > 0
|
||||||
|
? attached.map((policy) => html`
|
||||||
|
<div class="policy-item">
|
||||||
|
<div class="policy-item-info">
|
||||||
|
<span class="policy-item-name">${policy.name}</span>
|
||||||
|
${policy.description ? html`<span class="policy-item-desc">${policy.description}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
<button class="btn-detach" @click=${async (e: Event) => {
|
||||||
|
(e.target as HTMLButtonElement).disabled = true;
|
||||||
|
await appstate.detachPolicyFromBucket(policy.id, bucketName);
|
||||||
|
const fresh = await appstate.getBucketNamedPolicies(bucketName);
|
||||||
|
if (modalRef) {
|
||||||
|
modalRef.content = renderContent(fresh.attachedPolicies, fresh.availablePolicies);
|
||||||
|
}
|
||||||
|
}}>Detach</button>
|
||||||
|
</div>
|
||||||
|
`)
|
||||||
|
: html`<p class="empty-note">No policies attached to this bucket.</p>`}
|
||||||
|
</div>
|
||||||
|
<div class="policy-section">
|
||||||
|
<h4>Available Policies (${available.length})</h4>
|
||||||
|
${available.length > 0
|
||||||
|
? available.map((policy) => html`
|
||||||
|
<div class="policy-item">
|
||||||
|
<div class="policy-item-info">
|
||||||
|
<span class="policy-item-name">${policy.name}</span>
|
||||||
|
${policy.description ? html`<span class="policy-item-desc">${policy.description}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
<button class="btn-attach" @click=${async (e: Event) => {
|
||||||
|
(e.target as HTMLButtonElement).disabled = true;
|
||||||
|
await appstate.attachPolicyToBucket(policy.id, bucketName);
|
||||||
|
const fresh = await appstate.getBucketNamedPolicies(bucketName);
|
||||||
|
if (modalRef) {
|
||||||
|
modalRef.content = renderContent(fresh.attachedPolicies, fresh.availablePolicies);
|
||||||
|
}
|
||||||
|
}}>Attach</button>
|
||||||
|
</div>
|
||||||
|
`)
|
||||||
|
: html`<p class="empty-note">${attached.length > 0
|
||||||
|
? 'All policies are already attached.'
|
||||||
|
: 'No policies defined yet. Create policies in the Policies view.'
|
||||||
|
}</p>`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
modalRef = await DeesModal.createAndShow({
|
||||||
|
heading: `Policies for: ${bucketName}`,
|
||||||
|
content: renderContent(data.attachedPolicies, data.availablePolicies),
|
||||||
|
menuOptions: [
|
||||||
|
{ name: 'Done', action: async (modal: any) => { modal.destroy(); } },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatBytes(bytes: number): string {
|
||||||
|
if (bytes === 0) return '0 B';
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||||
|
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
111
ts_web/elements/objst-view-config.ts
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import * as appstate from '../appstate.js';
|
||||||
|
import * as shared from './shared/index.js';
|
||||||
|
|
||||||
|
import {
|
||||||
|
DeesElement,
|
||||||
|
customElement,
|
||||||
|
html,
|
||||||
|
state,
|
||||||
|
css,
|
||||||
|
cssManager,
|
||||||
|
type TemplateResult,
|
||||||
|
} from '@design.estate/dees-element';
|
||||||
|
import { type IStatsTile } from '@design.estate/dees-catalog';
|
||||||
|
|
||||||
|
@customElement('objst-view-config')
|
||||||
|
export class ObjstViewConfig extends DeesElement {
|
||||||
|
@state()
|
||||||
|
accessor configState: appstate.IConfigState = { config: null };
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
const sub = appstate.configStatePart
|
||||||
|
.select((s) => s)
|
||||||
|
.subscribe((configState) => {
|
||||||
|
this.configState = configState;
|
||||||
|
});
|
||||||
|
this.rxSubscriptions.push(sub);
|
||||||
|
}
|
||||||
|
|
||||||
|
async connectedCallback() {
|
||||||
|
super.connectedCallback();
|
||||||
|
appstate.configStatePart.dispatchAction(appstate.fetchConfigAction, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static styles = [
|
||||||
|
cssManager.defaultStyles,
|
||||||
|
shared.viewHostCss,
|
||||||
|
];
|
||||||
|
|
||||||
|
public render(): TemplateResult {
|
||||||
|
const config = this.configState.config;
|
||||||
|
|
||||||
|
const tiles: IStatsTile[] = [
|
||||||
|
{
|
||||||
|
id: 'objstPort',
|
||||||
|
title: 'Storage API Port',
|
||||||
|
value: config?.objstPort ?? '--',
|
||||||
|
type: 'number',
|
||||||
|
icon: 'lucide:network',
|
||||||
|
color: '#2196f3',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'uiPort',
|
||||||
|
title: 'UI Port',
|
||||||
|
value: config?.uiPort ?? '--',
|
||||||
|
type: 'number',
|
||||||
|
icon: 'lucide:monitor',
|
||||||
|
color: '#00bcd4',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'region',
|
||||||
|
title: 'Region',
|
||||||
|
value: config?.region ?? '--',
|
||||||
|
type: 'text',
|
||||||
|
icon: 'lucide:globe',
|
||||||
|
color: '#607d8b',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'storageDir',
|
||||||
|
title: 'Storage Directory',
|
||||||
|
value: config?.storageDirectory ?? '--',
|
||||||
|
type: 'text',
|
||||||
|
icon: 'lucide:hardDrive',
|
||||||
|
color: '#9c27b0',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'auth',
|
||||||
|
title: 'Authentication',
|
||||||
|
value: config?.authEnabled ? 'Enabled' : 'Disabled',
|
||||||
|
type: 'text',
|
||||||
|
icon: 'lucide:shield',
|
||||||
|
color: config?.authEnabled ? '#4caf50' : '#f44336',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cors',
|
||||||
|
title: 'CORS',
|
||||||
|
value: config?.corsEnabled ? 'Enabled' : 'Disabled',
|
||||||
|
type: 'text',
|
||||||
|
icon: 'lucide:globe2',
|
||||||
|
color: config?.corsEnabled ? '#4caf50' : '#ff9800',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<objst-sectionheading>Configuration</objst-sectionheading>
|
||||||
|
<dees-statsgrid
|
||||||
|
.tiles=${tiles}
|
||||||
|
.gridActions=${[
|
||||||
|
{
|
||||||
|
name: 'Refresh',
|
||||||
|
iconName: 'lucide:refreshCw',
|
||||||
|
action: async () => {
|
||||||
|
await appstate.configStatePart.dispatchAction(appstate.fetchConfigAction, null);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
></dees-statsgrid>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
128
ts_web/elements/objst-view-credentials.ts
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import * as appstate from '../appstate.js';
|
||||||
|
import * as shared from './shared/index.js';
|
||||||
|
|
||||||
|
import {
|
||||||
|
DeesElement,
|
||||||
|
customElement,
|
||||||
|
html,
|
||||||
|
state,
|
||||||
|
css,
|
||||||
|
cssManager,
|
||||||
|
type TemplateResult,
|
||||||
|
} from '@design.estate/dees-element';
|
||||||
|
import { DeesModal } from '@design.estate/dees-catalog';
|
||||||
|
|
||||||
|
@customElement('objst-view-credentials')
|
||||||
|
export class ObjstViewCredentials extends DeesElement {
|
||||||
|
@state()
|
||||||
|
accessor credentialsState: appstate.ICredentialsState = { credentials: [] };
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
const sub = appstate.credentialsStatePart
|
||||||
|
.select((s) => s)
|
||||||
|
.subscribe((credentialsState) => {
|
||||||
|
this.credentialsState = credentialsState;
|
||||||
|
});
|
||||||
|
this.rxSubscriptions.push(sub);
|
||||||
|
}
|
||||||
|
|
||||||
|
async connectedCallback() {
|
||||||
|
super.connectedCallback();
|
||||||
|
appstate.credentialsStatePart.dispatchAction(appstate.fetchCredentialsAction, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static styles = [
|
||||||
|
cssManager.defaultStyles,
|
||||||
|
shared.viewHostCss,
|
||||||
|
];
|
||||||
|
|
||||||
|
public render(): TemplateResult {
|
||||||
|
return html`
|
||||||
|
<objst-sectionheading>Access Keys</objst-sectionheading>
|
||||||
|
<dees-table
|
||||||
|
heading1="Access Credentials"
|
||||||
|
heading2="Manage access keys for API authentication"
|
||||||
|
.data=${this.credentialsState.credentials}
|
||||||
|
.dataName=${'credential'}
|
||||||
|
.displayFunction=${(item: any) => ({
|
||||||
|
'Access Key ID': item.accessKeyId,
|
||||||
|
'Secret Access Key': item.secretAccessKey,
|
||||||
|
})}
|
||||||
|
.dataActions=${[
|
||||||
|
{
|
||||||
|
name: 'Add Key',
|
||||||
|
iconName: 'lucide:plus',
|
||||||
|
type: ['header'] as any[],
|
||||||
|
actionFunc: async () => {
|
||||||
|
await this.showAddKeyModal();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Remove',
|
||||||
|
iconName: 'lucide:trash2',
|
||||||
|
type: ['inRow', 'contextmenu'] as any[],
|
||||||
|
actionFunc: async (args: any) => {
|
||||||
|
await this.showRemoveKeyModal(args.item.accessKeyId);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
></dees-table>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async showAddKeyModal(): Promise<void> {
|
||||||
|
await DeesModal.createAndShow({
|
||||||
|
heading: 'Add Access Key',
|
||||||
|
content: html`
|
||||||
|
<dees-form>
|
||||||
|
<dees-input-text .key=${'accessKeyId'} .label=${'Access Key ID'} .required=${true}></dees-input-text>
|
||||||
|
<dees-input-text .key=${'secretAccessKey'} .label=${'Secret Access Key'} .required=${true}></dees-input-text>
|
||||||
|
</dees-form>
|
||||||
|
`,
|
||||||
|
menuOptions: [
|
||||||
|
{
|
||||||
|
name: 'Cancel',
|
||||||
|
action: async (modal: any) => { modal.destroy(); },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Add',
|
||||||
|
action: async (modal: any) => {
|
||||||
|
const form = modal.shadowRoot.querySelector('dees-form') as any;
|
||||||
|
const data = await form.collectFormData();
|
||||||
|
if (data.accessKeyId && data.secretAccessKey) {
|
||||||
|
await appstate.credentialsStatePart.dispatchAction(appstate.addCredentialAction, {
|
||||||
|
accessKeyId: data.accessKeyId,
|
||||||
|
secretAccessKey: data.secretAccessKey,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
modal.destroy();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async showRemoveKeyModal(accessKeyId: string): Promise<void> {
|
||||||
|
await DeesModal.createAndShow({
|
||||||
|
heading: 'Remove Access Key',
|
||||||
|
content: html`<p>Are you sure you want to remove access key <strong>${accessKeyId}</strong>?</p>`,
|
||||||
|
menuOptions: [
|
||||||
|
{
|
||||||
|
name: 'Cancel',
|
||||||
|
action: async (modal: any) => { modal.destroy(); },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Remove',
|
||||||
|
action: async (modal: any) => {
|
||||||
|
await appstate.credentialsStatePart.dispatchAction(appstate.removeCredentialAction, {
|
||||||
|
accessKeyId,
|
||||||
|
});
|
||||||
|
modal.destroy();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
149
ts_web/elements/objst-view-objects.ts
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import * as appstate from '../appstate.js';
|
||||||
|
import { createDataProvider } from '../dataprovider.js';
|
||||||
|
|
||||||
|
import {
|
||||||
|
DeesElement,
|
||||||
|
customElement,
|
||||||
|
html,
|
||||||
|
state,
|
||||||
|
css,
|
||||||
|
cssManager,
|
||||||
|
type TemplateResult,
|
||||||
|
} from '@design.estate/dees-element';
|
||||||
|
|
||||||
|
@customElement('objst-view-objects')
|
||||||
|
export class ObjstViewObjects extends DeesElement {
|
||||||
|
@state()
|
||||||
|
accessor bucketsState: appstate.IBucketsState = { buckets: [] };
|
||||||
|
|
||||||
|
@state()
|
||||||
|
accessor selectedBucket: string = '';
|
||||||
|
|
||||||
|
private dataProvider = createDataProvider();
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
const bucketsSub = appstate.bucketsStatePart
|
||||||
|
.select((s) => s)
|
||||||
|
.subscribe((bucketsState) => {
|
||||||
|
this.bucketsState = bucketsState;
|
||||||
|
});
|
||||||
|
this.rxSubscriptions.push(bucketsSub);
|
||||||
|
|
||||||
|
// Track current bucket from objects state
|
||||||
|
const objSub = appstate.objectsStatePart
|
||||||
|
.select((s) => s.currentBucket)
|
||||||
|
.subscribe((currentBucket) => {
|
||||||
|
if (currentBucket) {
|
||||||
|
this.selectedBucket = currentBucket;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.rxSubscriptions.push(objSub);
|
||||||
|
}
|
||||||
|
|
||||||
|
async connectedCallback() {
|
||||||
|
super.connectedCallback();
|
||||||
|
if (this.bucketsState.buckets.length === 0) {
|
||||||
|
appstate.bucketsStatePart.dispatchAction(appstate.fetchBucketsAction, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static styles = [
|
||||||
|
cssManager.defaultStyles,
|
||||||
|
css`
|
||||||
|
:host {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.bucket-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.bucket-bar label {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: ${cssManager.bdTheme('#333', '#ccc')};
|
||||||
|
}
|
||||||
|
.bucket-chip {
|
||||||
|
padding: 6px 16px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid ${cssManager.bdTheme('#ddd', '#444')};
|
||||||
|
background: ${cssManager.bdTheme('#f5f5f5', '#1a1a2e')};
|
||||||
|
color: ${cssManager.bdTheme('#333', '#ccc')};
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
.bucket-chip:hover {
|
||||||
|
border-color: ${cssManager.bdTheme('#2196f3', '#64b5f6')};
|
||||||
|
color: ${cssManager.bdTheme('#2196f3', '#64b5f6')};
|
||||||
|
}
|
||||||
|
.bucket-chip.active {
|
||||||
|
background: ${cssManager.bdTheme('#2196f3', '#1565c0')};
|
||||||
|
color: white;
|
||||||
|
border-color: ${cssManager.bdTheme('#2196f3', '#1565c0')};
|
||||||
|
}
|
||||||
|
.browser-container {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.noBucket {
|
||||||
|
text-align: center;
|
||||||
|
padding: 64px 0;
|
||||||
|
color: ${cssManager.bdTheme('#666', '#999')};
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
];
|
||||||
|
|
||||||
|
public render(): TemplateResult {
|
||||||
|
return html`
|
||||||
|
<div class="bucket-bar">
|
||||||
|
<label>Bucket:</label>
|
||||||
|
${this.bucketsState.buckets.map(
|
||||||
|
(bucket) => html`
|
||||||
|
<span
|
||||||
|
class="bucket-chip ${this.selectedBucket === bucket.name ? 'active' : ''}"
|
||||||
|
@click=${() => this.selectBucket(bucket.name)}
|
||||||
|
>${bucket.name}</span>
|
||||||
|
`,
|
||||||
|
)}
|
||||||
|
${this.bucketsState.buckets.length === 0
|
||||||
|
? html`<span style="color: #999; font-size: 13px;">No buckets found</span>`
|
||||||
|
: ''}
|
||||||
|
</div>
|
||||||
|
${this.selectedBucket
|
||||||
|
? html`
|
||||||
|
<div class="browser-container">
|
||||||
|
<dees-s3-browser
|
||||||
|
.dataProvider=${this.dataProvider}
|
||||||
|
.bucketName=${this.selectedBucket}
|
||||||
|
></dees-s3-browser>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
: html`
|
||||||
|
<div class="noBucket">
|
||||||
|
<p>Select a bucket above to browse objects.</p>
|
||||||
|
</div>
|
||||||
|
`}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private selectBucket(bucketName: string): void {
|
||||||
|
this.selectedBucket = bucketName;
|
||||||
|
// Update objects state for tracking
|
||||||
|
appstate.objectsStatePart.dispatchAction(appstate.fetchObjectsAction, {
|
||||||
|
bucketName,
|
||||||
|
prefix: '',
|
||||||
|
delimiter: '/',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
201
ts_web/elements/objst-view-overview.ts
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import * as appstate from '../appstate.js';
|
||||||
|
import * as shared from './shared/index.js';
|
||||||
|
|
||||||
|
import {
|
||||||
|
DeesElement,
|
||||||
|
customElement,
|
||||||
|
html,
|
||||||
|
state,
|
||||||
|
css,
|
||||||
|
cssManager,
|
||||||
|
type TemplateResult,
|
||||||
|
} from '@design.estate/dees-element';
|
||||||
|
import { type IStatsTile } from '@design.estate/dees-catalog';
|
||||||
|
|
||||||
|
@customElement('objst-view-overview')
|
||||||
|
export class ObjstViewOverview extends DeesElement {
|
||||||
|
@state()
|
||||||
|
accessor serverState: appstate.IServerState = { status: null, connectionInfo: null };
|
||||||
|
|
||||||
|
@state()
|
||||||
|
accessor bucketsState: appstate.IBucketsState = { buckets: [] };
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
const serverSub = appstate.serverStatePart
|
||||||
|
.select((s) => s)
|
||||||
|
.subscribe((serverState) => {
|
||||||
|
this.serverState = serverState;
|
||||||
|
});
|
||||||
|
this.rxSubscriptions.push(serverSub);
|
||||||
|
|
||||||
|
const bucketsSub = appstate.bucketsStatePart
|
||||||
|
.select((s) => s)
|
||||||
|
.subscribe((bucketsState) => {
|
||||||
|
this.bucketsState = bucketsState;
|
||||||
|
});
|
||||||
|
this.rxSubscriptions.push(bucketsSub);
|
||||||
|
}
|
||||||
|
|
||||||
|
async connectedCallback() {
|
||||||
|
super.connectedCallback();
|
||||||
|
appstate.serverStatePart.dispatchAction(appstate.fetchServerStatusAction, null);
|
||||||
|
appstate.bucketsStatePart.dispatchAction(appstate.fetchBucketsAction, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static styles = [
|
||||||
|
cssManager.defaultStyles,
|
||||||
|
shared.viewHostCss,
|
||||||
|
css`
|
||||||
|
.connectionInfo {
|
||||||
|
margin-top: 32px;
|
||||||
|
padding: 24px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: ${cssManager.bdTheme('#f5f5f5', '#1a1a2e')};
|
||||||
|
border: 1px solid ${cssManager.bdTheme('#e0e0e0', '#2a2a4a')};
|
||||||
|
}
|
||||||
|
.connectionInfo h2 {
|
||||||
|
margin: 0 0 16px 0;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: ${cssManager.bdTheme('#333', '#ccc')};
|
||||||
|
}
|
||||||
|
.connectionInfo .row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.connectionInfo .label {
|
||||||
|
width: 120px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: ${cssManager.bdTheme('#666', '#999')};
|
||||||
|
}
|
||||||
|
.connectionInfo .value {
|
||||||
|
font-family: monospace;
|
||||||
|
color: ${cssManager.bdTheme('#333', '#e0e0e0')};
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: ${cssManager.bdTheme('#e8e8e8', '#252540')};
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
];
|
||||||
|
|
||||||
|
public render(): TemplateResult {
|
||||||
|
const status = this.serverState.status;
|
||||||
|
const connInfo = this.serverState.connectionInfo;
|
||||||
|
|
||||||
|
const statsTiles: IStatsTile[] = [
|
||||||
|
{
|
||||||
|
id: 'status',
|
||||||
|
title: 'Server Status',
|
||||||
|
value: status?.running ? 'Online' : 'Offline',
|
||||||
|
type: 'text',
|
||||||
|
icon: 'lucide:server',
|
||||||
|
color: status?.running ? '#4caf50' : '#f44336',
|
||||||
|
description: status ? `Uptime: ${this.formatUptime(status.uptime)}` : 'Loading...',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'buckets',
|
||||||
|
title: 'Buckets',
|
||||||
|
value: status?.bucketCount ?? 0,
|
||||||
|
type: 'number',
|
||||||
|
icon: 'lucide:database',
|
||||||
|
color: '#2196f3',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'objects',
|
||||||
|
title: 'Total Objects',
|
||||||
|
value: status?.totalObjectCount ?? 0,
|
||||||
|
type: 'number',
|
||||||
|
icon: 'lucide:file',
|
||||||
|
color: '#ff9800',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'storage',
|
||||||
|
title: 'Storage Used',
|
||||||
|
value: status ? this.formatBytes(status.totalStorageBytes) : '0 B',
|
||||||
|
type: 'text',
|
||||||
|
icon: 'lucide:hardDrive',
|
||||||
|
color: '#9c27b0',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'storagePort',
|
||||||
|
title: 'Storage Port',
|
||||||
|
value: status?.objstPort ?? 9000,
|
||||||
|
type: 'number',
|
||||||
|
icon: 'lucide:network',
|
||||||
|
color: '#00bcd4',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'region',
|
||||||
|
title: 'Region',
|
||||||
|
value: status?.region ?? 'us-east-1',
|
||||||
|
type: 'text',
|
||||||
|
icon: 'lucide:globe',
|
||||||
|
color: '#607d8b',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<objst-sectionheading>Overview</objst-sectionheading>
|
||||||
|
<dees-statsgrid
|
||||||
|
.tiles=${statsTiles}
|
||||||
|
.gridActions=${[
|
||||||
|
{
|
||||||
|
name: 'Refresh',
|
||||||
|
iconName: 'lucide:refreshCw',
|
||||||
|
action: async () => {
|
||||||
|
await appstate.serverStatePart.dispatchAction(
|
||||||
|
appstate.fetchServerStatusAction,
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
await appstate.bucketsStatePart.dispatchAction(appstate.fetchBucketsAction, null);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
></dees-statsgrid>
|
||||||
|
|
||||||
|
${connInfo
|
||||||
|
? html`
|
||||||
|
<div class="connectionInfo">
|
||||||
|
<h2>Connection Info</h2>
|
||||||
|
<div class="row">
|
||||||
|
<span class="label">Endpoint</span>
|
||||||
|
<span class="value">${connInfo.endpoint}:${connInfo.port}</span>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<span class="label">Protocol</span>
|
||||||
|
<span class="value">${connInfo.useSsl ? 'HTTPS' : 'HTTP'}</span>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<span class="label">Access Key</span>
|
||||||
|
<span class="value">${connInfo.accessKey}</span>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<span class="label">Region</span>
|
||||||
|
<span class="value">${connInfo.region}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
: ''}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatUptime(seconds: number): string {
|
||||||
|
const days = Math.floor(seconds / 86400);
|
||||||
|
const hours = Math.floor((seconds % 86400) / 3600);
|
||||||
|
const minutes = Math.floor((seconds % 3600) / 60);
|
||||||
|
if (days > 0) return `${days}d ${hours}h ${minutes}m`;
|
||||||
|
if (hours > 0) return `${hours}h ${minutes}m`;
|
||||||
|
return `${minutes}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatBytes(bytes: number): string {
|
||||||
|
if (bytes === 0) return '0 B';
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||||
|
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
394
ts_web/elements/objst-view-policies.ts
Normal file
@@ -0,0 +1,394 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import * as appstate from '../appstate.js';
|
||||||
|
import * as shared from './shared/index.js';
|
||||||
|
import * as interfaces from '../../ts_interfaces/index.js';
|
||||||
|
|
||||||
|
import {
|
||||||
|
DeesElement,
|
||||||
|
customElement,
|
||||||
|
html,
|
||||||
|
state,
|
||||||
|
css,
|
||||||
|
cssManager,
|
||||||
|
type TemplateResult,
|
||||||
|
} from '@design.estate/dees-element';
|
||||||
|
import { DeesModal } from '@design.estate/dees-catalog';
|
||||||
|
|
||||||
|
@customElement('objst-view-policies')
|
||||||
|
export class ObjstViewPolicies extends DeesElement {
|
||||||
|
@state()
|
||||||
|
accessor policiesState: appstate.IPoliciesState = { policies: [] };
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
const sub = appstate.policiesStatePart
|
||||||
|
.select((s) => s)
|
||||||
|
.subscribe((policiesState) => {
|
||||||
|
this.policiesState = policiesState;
|
||||||
|
});
|
||||||
|
this.rxSubscriptions.push(sub);
|
||||||
|
}
|
||||||
|
|
||||||
|
async connectedCallback() {
|
||||||
|
super.connectedCallback();
|
||||||
|
appstate.policiesStatePart.dispatchAction(appstate.fetchPoliciesAction, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static styles = [
|
||||||
|
cssManager.defaultStyles,
|
||||||
|
shared.viewHostCss,
|
||||||
|
];
|
||||||
|
|
||||||
|
public render(): TemplateResult {
|
||||||
|
return html`
|
||||||
|
<objst-sectionheading>Policies</objst-sectionheading>
|
||||||
|
<dees-table
|
||||||
|
heading1="Named Policies"
|
||||||
|
heading2="Create reusable policies and attach them to buckets"
|
||||||
|
.data=${this.policiesState.policies}
|
||||||
|
.dataName=${'policy'}
|
||||||
|
.displayFunction=${(item: any) => ({
|
||||||
|
Name: item.name,
|
||||||
|
Description: item.description,
|
||||||
|
Statements: String(item.statements.length),
|
||||||
|
Updated: new Date(item.updatedAt).toLocaleDateString(),
|
||||||
|
})}
|
||||||
|
.dataActions=${[
|
||||||
|
{
|
||||||
|
name: 'Create Policy',
|
||||||
|
iconName: 'lucide:plus',
|
||||||
|
type: ['header'] as any[],
|
||||||
|
actionFunc: async () => {
|
||||||
|
await this.showCreatePolicyModal();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Edit',
|
||||||
|
iconName: 'lucide:pencil',
|
||||||
|
type: ['inRow'] as any[],
|
||||||
|
actionFunc: async (args: any) => {
|
||||||
|
await this.showEditPolicyModal(args.item);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Buckets',
|
||||||
|
iconName: 'lucide:database',
|
||||||
|
type: ['inRow'] as any[],
|
||||||
|
actionFunc: async (args: any) => {
|
||||||
|
await this.showPolicyBucketsModal(args.item);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Delete',
|
||||||
|
iconName: 'lucide:trash2',
|
||||||
|
type: ['inRow', 'contextmenu'] as any[],
|
||||||
|
actionFunc: async (args: any) => {
|
||||||
|
await this.showDeletePolicyModal(args.item);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
></dees-table>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getDefaultStatements(): interfaces.data.IObjstStatement[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
Sid: 'PublicRead',
|
||||||
|
Effect: 'Allow',
|
||||||
|
Principal: '*',
|
||||||
|
Action: 's3:GetObject',
|
||||||
|
Resource: 'arn:aws:s3:::${bucket}/*',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private async showCreatePolicyModal(): Promise<void> {
|
||||||
|
const defaultJson = JSON.stringify(this.getDefaultStatements(), null, 2);
|
||||||
|
await DeesModal.createAndShow({
|
||||||
|
heading: 'Create Policy',
|
||||||
|
content: html`
|
||||||
|
<style>
|
||||||
|
.policy-form { display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
.policy-form label { font-size: 13px; font-weight: 600; color: ${cssManager.bdTheme('#333', '#ccc')}; }
|
||||||
|
.policy-form input, .policy-form textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid ${cssManager.bdTheme('#ddd', '#444')};
|
||||||
|
border-radius: 6px;
|
||||||
|
background: ${cssManager.bdTheme('#fff', '#111')};
|
||||||
|
color: ${cssManager.bdTheme('#333', '#eee')};
|
||||||
|
font-size: 14px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.policy-form textarea {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
min-height: 200px;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
.policy-form input:focus, .policy-form textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: ${cssManager.bdTheme('#2196f3', '#64b5f6')};
|
||||||
|
}
|
||||||
|
.policy-error { color: #ef5350; font-size: 13px; min-height: 20px; }
|
||||||
|
.policy-hint { color: ${cssManager.bdTheme('#666', '#999')}; font-size: 12px; }
|
||||||
|
</style>
|
||||||
|
<div class="policy-form">
|
||||||
|
<div>
|
||||||
|
<label>Name</label>
|
||||||
|
<input type="text" class="policy-name" placeholder="e.g. Public Read Access" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Description</label>
|
||||||
|
<input type="text" class="policy-description" placeholder="e.g. Allow anonymous read access to objects" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Statements (JSON array)</label>
|
||||||
|
<p class="policy-hint">Use \${bucket} in Resource ARNs — it will be replaced with the actual bucket name when applied.</p>
|
||||||
|
<textarea class="policy-statements">${defaultJson}</textarea>
|
||||||
|
</div>
|
||||||
|
<div class="policy-error"></div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
menuOptions: [
|
||||||
|
{ name: 'Cancel', action: async (modal: any) => { modal.destroy(); } },
|
||||||
|
{
|
||||||
|
name: 'Create',
|
||||||
|
action: async (modal: any) => {
|
||||||
|
const root = modal.shadowRoot;
|
||||||
|
const name = (root.querySelector('.policy-name') as HTMLInputElement)?.value?.trim();
|
||||||
|
const description = (root.querySelector('.policy-description') as HTMLInputElement)?.value?.trim();
|
||||||
|
const statementsText = (root.querySelector('.policy-statements') as HTMLTextAreaElement)?.value?.trim();
|
||||||
|
const errorDiv = root.querySelector('.policy-error') as HTMLElement;
|
||||||
|
|
||||||
|
if (!name) { errorDiv.textContent = 'Name is required.'; return; }
|
||||||
|
if (!statementsText) { errorDiv.textContent = 'Statements are required.'; return; }
|
||||||
|
|
||||||
|
let statements: interfaces.data.IObjstStatement[];
|
||||||
|
try {
|
||||||
|
statements = JSON.parse(statementsText);
|
||||||
|
if (!Array.isArray(statements)) throw new Error('Must be an array');
|
||||||
|
} catch (err: any) {
|
||||||
|
errorDiv.textContent = `Invalid JSON: ${err.message}`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await appstate.policiesStatePart.dispatchAction(appstate.createPolicyAction, {
|
||||||
|
name,
|
||||||
|
description: description || '',
|
||||||
|
statements,
|
||||||
|
});
|
||||||
|
modal.destroy();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async showEditPolicyModal(policy: interfaces.data.INamedPolicy): Promise<void> {
|
||||||
|
const statementsJson = JSON.stringify(policy.statements, null, 2);
|
||||||
|
await DeesModal.createAndShow({
|
||||||
|
heading: `Edit Policy: ${policy.name}`,
|
||||||
|
content: html`
|
||||||
|
<style>
|
||||||
|
.policy-form { display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
.policy-form label { font-size: 13px; font-weight: 600; color: ${cssManager.bdTheme('#333', '#ccc')}; }
|
||||||
|
.policy-form input, .policy-form textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid ${cssManager.bdTheme('#ddd', '#444')};
|
||||||
|
border-radius: 6px;
|
||||||
|
background: ${cssManager.bdTheme('#fff', '#111')};
|
||||||
|
color: ${cssManager.bdTheme('#333', '#eee')};
|
||||||
|
font-size: 14px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.policy-form textarea {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
min-height: 200px;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
.policy-form input:focus, .policy-form textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: ${cssManager.bdTheme('#2196f3', '#64b5f6')};
|
||||||
|
}
|
||||||
|
.policy-error { color: #ef5350; font-size: 13px; min-height: 20px; }
|
||||||
|
.policy-hint { color: ${cssManager.bdTheme('#666', '#999')}; font-size: 12px; }
|
||||||
|
</style>
|
||||||
|
<div class="policy-form">
|
||||||
|
<div>
|
||||||
|
<label>Name</label>
|
||||||
|
<input type="text" class="policy-name" .value=${policy.name} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Description</label>
|
||||||
|
<input type="text" class="policy-description" .value=${policy.description} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Statements (JSON array)</label>
|
||||||
|
<p class="policy-hint">Use \${bucket} in Resource ARNs — it will be replaced with the actual bucket name when applied.</p>
|
||||||
|
<textarea class="policy-statements">${statementsJson}</textarea>
|
||||||
|
</div>
|
||||||
|
<div class="policy-error"></div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
menuOptions: [
|
||||||
|
{ name: 'Cancel', action: async (modal: any) => { modal.destroy(); } },
|
||||||
|
{
|
||||||
|
name: 'Save',
|
||||||
|
action: async (modal: any) => {
|
||||||
|
const root = modal.shadowRoot;
|
||||||
|
const name = (root.querySelector('.policy-name') as HTMLInputElement)?.value?.trim();
|
||||||
|
const description = (root.querySelector('.policy-description') as HTMLInputElement)?.value?.trim();
|
||||||
|
const statementsText = (root.querySelector('.policy-statements') as HTMLTextAreaElement)?.value?.trim();
|
||||||
|
const errorDiv = root.querySelector('.policy-error') as HTMLElement;
|
||||||
|
|
||||||
|
if (!name) { errorDiv.textContent = 'Name is required.'; return; }
|
||||||
|
if (!statementsText) { errorDiv.textContent = 'Statements are required.'; return; }
|
||||||
|
|
||||||
|
let statements: interfaces.data.IObjstStatement[];
|
||||||
|
try {
|
||||||
|
statements = JSON.parse(statementsText);
|
||||||
|
if (!Array.isArray(statements)) throw new Error('Must be an array');
|
||||||
|
} catch (err: any) {
|
||||||
|
errorDiv.textContent = `Invalid JSON: ${err.message}`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await appstate.policiesStatePart.dispatchAction(appstate.updatePolicyAction, {
|
||||||
|
policyId: policy.id,
|
||||||
|
name,
|
||||||
|
description: description || '',
|
||||||
|
statements,
|
||||||
|
});
|
||||||
|
modal.destroy();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async showDeletePolicyModal(policy: interfaces.data.INamedPolicy): Promise<void> {
|
||||||
|
await DeesModal.createAndShow({
|
||||||
|
heading: 'Delete Policy',
|
||||||
|
content: html`
|
||||||
|
<p>Are you sure you want to delete policy <strong>${policy.name}</strong>?</p>
|
||||||
|
<p style="color: ${cssManager.bdTheme('#666', '#999')}; font-size: 13px;">
|
||||||
|
This will also detach the policy from all buckets it is currently applied to.
|
||||||
|
</p>
|
||||||
|
`,
|
||||||
|
menuOptions: [
|
||||||
|
{ name: 'Cancel', action: async (modal: any) => { modal.destroy(); } },
|
||||||
|
{
|
||||||
|
name: 'Delete',
|
||||||
|
action: async (modal: any) => {
|
||||||
|
await appstate.policiesStatePart.dispatchAction(appstate.deletePolicyAction, {
|
||||||
|
policyId: policy.id,
|
||||||
|
});
|
||||||
|
modal.destroy();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async showPolicyBucketsModal(policy: interfaces.data.INamedPolicy): Promise<void> {
|
||||||
|
const data = await appstate.getPolicyBuckets(policy.id);
|
||||||
|
let modalRef: any = null;
|
||||||
|
|
||||||
|
const renderContent = (attached: string[], available: string[]) => html`
|
||||||
|
<style>
|
||||||
|
.bucket-lists { display: flex; flex-direction: column; gap: 16px; }
|
||||||
|
.bucket-section h4 {
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: ${cssManager.bdTheme('#333', '#ccc')};
|
||||||
|
}
|
||||||
|
.bucket-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: ${cssManager.bdTheme('#f5f5f5', '#1a1a2e')};
|
||||||
|
margin-bottom: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: ${cssManager.bdTheme('#333', '#eee')};
|
||||||
|
}
|
||||||
|
.bucket-item button {
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.btn-detach {
|
||||||
|
background: ${cssManager.bdTheme('#ffebee', '#3e1a1a')};
|
||||||
|
color: ${cssManager.bdTheme('#c62828', '#ef5350')};
|
||||||
|
}
|
||||||
|
.btn-detach:hover { opacity: 0.8; }
|
||||||
|
.btn-attach {
|
||||||
|
background: ${cssManager.bdTheme('#e3f2fd', '#1a2a3e')};
|
||||||
|
color: ${cssManager.bdTheme('#1565c0', '#64b5f6')};
|
||||||
|
}
|
||||||
|
.btn-attach:hover { opacity: 0.8; }
|
||||||
|
.empty-note {
|
||||||
|
color: ${cssManager.bdTheme('#999', '#666')};
|
||||||
|
font-size: 13px;
|
||||||
|
font-style: italic;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<div class="bucket-lists">
|
||||||
|
<div class="bucket-section">
|
||||||
|
<h4>Attached Buckets (${attached.length})</h4>
|
||||||
|
${attached.length > 0
|
||||||
|
? attached.map((bucket) => html`
|
||||||
|
<div class="bucket-item">
|
||||||
|
<span>${bucket}</span>
|
||||||
|
<button class="btn-detach" @click=${async (e: Event) => {
|
||||||
|
(e.target as HTMLButtonElement).disabled = true;
|
||||||
|
await appstate.detachPolicyFromBucket(policy.id, bucket);
|
||||||
|
const fresh = await appstate.getPolicyBuckets(policy.id);
|
||||||
|
if (modalRef) {
|
||||||
|
modalRef.content = renderContent(fresh.attachedBuckets, fresh.availableBuckets);
|
||||||
|
}
|
||||||
|
}}>Detach</button>
|
||||||
|
</div>
|
||||||
|
`)
|
||||||
|
: html`<p class="empty-note">No buckets attached to this policy.</p>`}
|
||||||
|
</div>
|
||||||
|
<div class="bucket-section">
|
||||||
|
<h4>Available Buckets (${available.length})</h4>
|
||||||
|
${available.length > 0
|
||||||
|
? available.map((bucket) => html`
|
||||||
|
<div class="bucket-item">
|
||||||
|
<span>${bucket}</span>
|
||||||
|
<button class="btn-attach" @click=${async (e: Event) => {
|
||||||
|
(e.target as HTMLButtonElement).disabled = true;
|
||||||
|
await appstate.attachPolicyToBucket(policy.id, bucket);
|
||||||
|
const fresh = await appstate.getPolicyBuckets(policy.id);
|
||||||
|
if (modalRef) {
|
||||||
|
modalRef.content = renderContent(fresh.attachedBuckets, fresh.availableBuckets);
|
||||||
|
}
|
||||||
|
}}>Attach</button>
|
||||||
|
</div>
|
||||||
|
`)
|
||||||
|
: html`<p class="empty-note">All buckets already have this policy.</p>`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
modalRef = await DeesModal.createAndShow({
|
||||||
|
heading: `Buckets for: ${policy.name}`,
|
||||||
|
content: renderContent(data.attachedBuckets, data.availableBuckets),
|
||||||
|
menuOptions: [
|
||||||
|
{ name: 'Done', action: async (modal: any) => { modal.destroy(); } },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
10
ts_web/elements/shared/css.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { css } from '@design.estate/dees-element';
|
||||||
|
|
||||||
|
export const viewHostCss = css`
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
margin: auto;
|
||||||
|
max-width: 1280px;
|
||||||
|
padding: 16px 16px;
|
||||||
|
}
|
||||||
|
`;
|
||||||
2
ts_web/elements/shared/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './css.js';
|
||||||
|
export * from './objst-sectionheading.js';
|
||||||
31
ts_web/elements/shared/objst-sectionheading.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import {
|
||||||
|
DeesElement,
|
||||||
|
customElement,
|
||||||
|
html,
|
||||||
|
css,
|
||||||
|
cssManager,
|
||||||
|
type TemplateResult,
|
||||||
|
} from '@design.estate/dees-element';
|
||||||
|
|
||||||
|
@customElement('objst-sectionheading')
|
||||||
|
export class ObjstSectionHeading extends DeesElement {
|
||||||
|
public static styles = [
|
||||||
|
cssManager.defaultStyles,
|
||||||
|
css`
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0;
|
||||||
|
color: ${cssManager.bdTheme('#1a1a2e', '#e0e0e0')};
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
];
|
||||||
|
|
||||||
|
public render(): TemplateResult {
|
||||||
|
return html`<h1><slot></slot></h1>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
7
ts_web/index.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import * as plugins from './plugins.js';
|
||||||
|
import { html } from '@design.estate/dees-element';
|
||||||
|
import { appRouter } from './router.js';
|
||||||
|
import './elements/index.js';
|
||||||
|
|
||||||
|
appRouter.init();
|
||||||
|
plugins.deesElement.render(html`<objst-app-shell></objst-app-shell>`, document.body);
|
||||||
4
ts_web/plugins.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import * as deesElement from '@design.estate/dees-element';
|
||||||
|
import * as deesCatalog from '@design.estate/dees-catalog';
|
||||||
|
export { deesElement, deesCatalog };
|
||||||
|
export const domtools = deesElement.domtools;
|
||||||
109
ts_web/router.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import * as plugins from './plugins.js';
|
||||||
|
import * as appstate from './appstate.js';
|
||||||
|
|
||||||
|
const SmartRouter = plugins.domtools.plugins.smartrouter.SmartRouter;
|
||||||
|
|
||||||
|
export const validViews = ['overview', 'buckets', 'browser', 'policies', 'config', 'access keys'] as const;
|
||||||
|
|
||||||
|
export type TValidView = typeof validViews[number];
|
||||||
|
|
||||||
|
class AppRouter {
|
||||||
|
private router: InstanceType<typeof SmartRouter>;
|
||||||
|
private initialized = false;
|
||||||
|
private suppressStateUpdate = false;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.router = new SmartRouter({ debug: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(): void {
|
||||||
|
if (this.initialized) return;
|
||||||
|
this.setupRoutes();
|
||||||
|
this.setupStateSync();
|
||||||
|
this.handleInitialRoute();
|
||||||
|
this.initialized = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private viewToPath(view: string): string {
|
||||||
|
return `/${view.replace(/\s+/g, '-')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private pathToView(path: string): string {
|
||||||
|
const segment = path.split('/').filter(Boolean)[0] || '';
|
||||||
|
return segment.replace(/-/g, ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupRoutes(): void {
|
||||||
|
for (const view of validViews) {
|
||||||
|
this.router.on(this.viewToPath(view), async () => {
|
||||||
|
this.updateViewState(view);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Root redirect
|
||||||
|
this.router.on('/', async () => {
|
||||||
|
this.navigateTo(this.viewToPath('overview'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupStateSync(): void {
|
||||||
|
appstate.uiStatePart.state.subscribe((uiState) => {
|
||||||
|
if (this.suppressStateUpdate) return;
|
||||||
|
|
||||||
|
const currentPath = window.location.pathname;
|
||||||
|
const expectedPath = this.viewToPath(uiState.activeView);
|
||||||
|
|
||||||
|
if (currentPath !== expectedPath) {
|
||||||
|
this.suppressStateUpdate = true;
|
||||||
|
this.router.pushUrl(expectedPath);
|
||||||
|
this.suppressStateUpdate = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleInitialRoute(): void {
|
||||||
|
const path = window.location.pathname;
|
||||||
|
|
||||||
|
if (!path || path === '/') {
|
||||||
|
this.router.pushUrl(this.viewToPath('overview'));
|
||||||
|
} else {
|
||||||
|
const view = this.pathToView(path);
|
||||||
|
if (validViews.includes(view as TValidView)) {
|
||||||
|
this.updateViewState(view as TValidView);
|
||||||
|
} else {
|
||||||
|
this.router.pushUrl(this.viewToPath('overview'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateViewState(view: string): void {
|
||||||
|
this.suppressStateUpdate = true;
|
||||||
|
const currentState = appstate.uiStatePart.getState();
|
||||||
|
if (currentState.activeView !== view) {
|
||||||
|
appstate.uiStatePart.setState({
|
||||||
|
...currentState,
|
||||||
|
activeView: view,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.suppressStateUpdate = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public navigateTo(path: string): void {
|
||||||
|
this.router.pushUrl(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
public navigateToView(view: string): void {
|
||||||
|
if (validViews.includes(view as TValidView)) {
|
||||||
|
this.navigateTo(this.viewToPath(view));
|
||||||
|
} else {
|
||||||
|
this.navigateTo(this.viewToPath('overview'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public destroy(): void {
|
||||||
|
this.router.destroy();
|
||||||
|
this.initialized = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const appRouter = new AppRouter();
|
||||||