├── web ├── .npmrc ├── .prettierignore ├── .eslintignore ├── public │ ├── favicon.ico │ └── vercel.svg ├── utils │ ├── theme.ts │ ├── aws.ts │ ├── http.ts │ └── shared.ts ├── postcss.config.js ├── .eslintrc ├── config │ └── index.ts ├── next-env.d.ts ├── .prettierrc ├── tailwind.config.js ├── components │ ├── empty.tsx │ ├── error-boundary.tsx │ ├── header.tsx │ ├── delete-modal.tsx │ ├── share-modal.tsx │ ├── object-listitem.tsx │ └── object-list.tsx ├── tsconfig.json ├── next.config.js ├── styles │ └── globals.css ├── pages │ ├── _app.tsx │ └── index.tsx ├── hooks │ ├── buckets.ts │ ├── options.ts │ └── objects.ts ├── api │ ├── types.ts │ └── index.ts ├── package.json └── README.md ├── api ├── types.go ├── impl.go └── handlers.go ├── docs └── images │ ├── delete.png │ ├── grid.png │ ├── list.png │ ├── share.png │ └── usage.png ├── utils ├── parser.go ├── functions.go └── cmd.go ├── .gitattributes ├── .gitignore ├── .github └── workflows │ ├── lint.yml │ └── build.yml ├── Makefile ├── go.mod ├── README.md ├── service ├── types.go └── impl.go ├── main.go └── go.sum /web/.npmrc: -------------------------------------------------------------------------------- 1 | save-exact = true 2 | -------------------------------------------------------------------------------- /web/.prettierignore: -------------------------------------------------------------------------------- 1 | .next 2 | build 3 | -------------------------------------------------------------------------------- /api/types.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | type ApiError struct { 4 | Err string `json:"err"` 5 | } 6 | -------------------------------------------------------------------------------- /docs/images/delete.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karanpratapsingh/s3-explorer/HEAD/docs/images/delete.png -------------------------------------------------------------------------------- /docs/images/grid.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karanpratapsingh/s3-explorer/HEAD/docs/images/grid.png -------------------------------------------------------------------------------- /docs/images/list.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karanpratapsingh/s3-explorer/HEAD/docs/images/list.png -------------------------------------------------------------------------------- /docs/images/share.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karanpratapsingh/s3-explorer/HEAD/docs/images/share.png -------------------------------------------------------------------------------- /docs/images/usage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karanpratapsingh/s3-explorer/HEAD/docs/images/usage.png -------------------------------------------------------------------------------- /web/.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | **/dist/ 3 | **/out/ 4 | **/coverage/ 5 | **/build/ 6 | **/.webpack/ 7 | -------------------------------------------------------------------------------- /web/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karanpratapsingh/s3-explorer/HEAD/web/public/favicon.ico -------------------------------------------------------------------------------- /web/utils/theme.ts: -------------------------------------------------------------------------------- 1 | export const Colors = { 2 | primary: '#000000', 3 | secondary: '#9E9E9E', 4 | }; 5 | -------------------------------------------------------------------------------- /web/utils/aws.ts: -------------------------------------------------------------------------------- 1 | export const defaultParams = { 2 | Bucket: '', 3 | Prefix: '', 4 | Delimiter: '/', 5 | }; 6 | -------------------------------------------------------------------------------- /web/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /web/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals", 3 | "rules": { 4 | "@next/next/no-img-element": "off" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /utils/parser.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import "strings" 4 | 5 | func Trim(value, prefix, suffix string) string { 6 | return strings.TrimSuffix(strings.TrimPrefix(value, prefix), suffix) 7 | } 8 | -------------------------------------------------------------------------------- /web/config/index.ts: -------------------------------------------------------------------------------- 1 | const isDevelopment = process.env.NODE_ENV === 'development'; 2 | 3 | const config = { 4 | name: 'S3 Explorer', 5 | apiURL: isDevelopment ? '' : 'http://localhost:8080', 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /web/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | web/components/*.tsx linguist-detectable=false 2 | *.js linguist-detectable=false 3 | *.json linguist-detectable=false 4 | *.css linguist-detectable=false 5 | *go.sum linguist-language=go 6 | *go.mod linguist-language=go 7 | -------------------------------------------------------------------------------- /utils/functions.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | func Concat[T any](slices ...[]T) []T { 4 | result := make([]T, 0) 5 | 6 | for _, slice := range slices { 7 | result = append(result, slice...) 8 | } 9 | 10 | return result 11 | } 12 | -------------------------------------------------------------------------------- /web/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "printWidth": 80, 4 | "arrowParens": "avoid", 5 | "trailingComma": "all", 6 | "semi": true, 7 | "useTabs": false, 8 | "singleQuote": true, 9 | "jsxSingleQuote": true, 10 | "bracketSpacing": true 11 | } 12 | -------------------------------------------------------------------------------- /web/tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | content: [ 3 | './pages/**/*.{js,ts,jsx,tsx}', 4 | './components/**/*.{js,ts,jsx,tsx}', 5 | ], 6 | theme: { 7 | extend: { 8 | colors: { 9 | primary: '#000000', 10 | secondary: '#9E9E9E', 11 | }, 12 | borderColor: { 13 | light: '#EAEAEA', 14 | dark: '#252525', 15 | }, 16 | }, 17 | }, 18 | plugins: [], 19 | }; 20 | -------------------------------------------------------------------------------- /web/components/empty.tsx: -------------------------------------------------------------------------------- 1 | import { Spacer } from '@geist-ui/core'; 2 | 3 | interface EmptyProps { 4 | text?: string; 5 | icon?: React.ReactNode; 6 | } 7 | 8 | export default function Empty(props: EmptyProps): React.ReactElement { 9 | const { text, icon } = props; 10 | 11 | return ( 12 |
13 | {icon} 14 | 15 | {text} 16 |
17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /web/utils/http.ts: -------------------------------------------------------------------------------- 1 | import config from '../config'; 2 | 3 | type Method = 'GET' | 'POST'; 4 | 5 | export async function http( 6 | path: string, 7 | method: Method = 'GET', 8 | body?: B, 9 | ): Promise { 10 | const options = { 11 | method, 12 | body: JSON.stringify(body), 13 | headers: { 14 | 'Content-Type': 'application/json', 15 | }, 16 | }; 17 | 18 | const url = `${config.apiURL}${path}`; 19 | 20 | return fetch(url, options).then(res => res.json()); 21 | } 22 | -------------------------------------------------------------------------------- /utils/cmd.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "os" 5 | "os/exec" 6 | "runtime" 7 | ) 8 | 9 | func Open(url string) error { 10 | if os.Getenv("ENVIRONMENT") == "development" { 11 | return nil 12 | } 13 | 14 | var cmd string 15 | var args []string 16 | 17 | switch runtime.GOOS { 18 | case "windows": 19 | cmd = "cmd" 20 | args = []string{"/c", "start"} 21 | case "darwin": 22 | cmd = "open" 23 | default: // "linux", "freebsd", "openbsd", "netbsd" 24 | cmd = "xdg-open" 25 | } 26 | args = append(args, url) 27 | 28 | return exec.Command(cmd, args...).Start() 29 | } 30 | -------------------------------------------------------------------------------- /web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true 17 | }, 18 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], 19 | "exclude": ["node_modules"] 20 | } 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | **/node_modules/** 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | **/.next/** 13 | **/out/** 14 | 15 | # production 16 | **/build/** 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | .pnpm-debug.log* 27 | 28 | # local env files 29 | .env.local 30 | .env.development.local 31 | .env.test.local 32 | .env.production.local 33 | 34 | # vercel 35 | .vercel 36 | 37 | # typescript 38 | *.tsbuildinfo 39 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: 4 | workflow_dispatch: 5 | pull_request: 6 | push: 7 | branches: [main] 8 | 9 | jobs: 10 | lint: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v2 14 | 15 | - name: Setup Go 16 | uses: actions/setup-go@v2 17 | with: 18 | go-version: 1.19 19 | 20 | - name: Setup Node.js 21 | uses: actions/setup-node@v1 22 | with: 23 | node-version: 16.14.2 24 | 25 | - name: Prepare 26 | run: make prepare 27 | 28 | - name: Lint Web 29 | run: cd web && npm run lint 30 | 31 | -------------------------------------------------------------------------------- /web/next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const withBundleAnalyzer = require('@next/bundle-analyzer')({ 3 | enabled: process.env.ANALYZE === 'true', 4 | }) 5 | 6 | const nextConfig = withBundleAnalyzer({ 7 | reactStrictMode: true, 8 | async rewrites() { 9 | return [ 10 | { 11 | source: '/api/:path*', 12 | destination: 'http://localhost:8080/api/:path*', // Proxy to Backend 13 | }, 14 | ]; 15 | }, 16 | async exportPathMap(defaultPathMap, { dev, dir, outDir, distDir, buildId }) { 17 | return { 18 | '/': { page: '/' }, 19 | }; 20 | }, 21 | trailingSlash: true, 22 | }); 23 | 24 | module.exports = nextConfig; 25 | -------------------------------------------------------------------------------- /web/styles/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | html, 6 | body, 7 | #__next { 8 | padding: 0; 9 | margin: 0; 10 | display: flex; 11 | height: 100vh; 12 | width: 100vw; 13 | } 14 | 15 | .breadcrumbs-item { 16 | @apply text-sm; 17 | @apply font-light; 18 | } 19 | 20 | .breadcrumbs-item { 21 | @apply font-light; 22 | @apply text-secondary; 23 | } 24 | 25 | .db-icon { 26 | @apply text-lg; 27 | } 28 | 29 | .fade-in { 30 | animation: fade-in ease 500ms; 31 | animation-iteration-count: 1; 32 | animation-fill-mode: forwards; 33 | } 34 | 35 | @keyframes fade-in { 36 | 0% { 37 | opacity: 0; 38 | } 39 | 100% { 40 | opacity: 1; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Set a region if not provided 2 | region := $(if $(region),$(region),us-east-1) 3 | 4 | os := $(if $(os),$(os),darwin) 5 | arch := $(if $(arch),$(arch),arm64) 6 | 7 | prepare: 8 | cd web && npm install 9 | go mod tidy 10 | 11 | run: 12 | cd web && npm run build 13 | npx concurrently "make run-web" "make run-server" 14 | 15 | run-web: 16 | cd web && npm run dev 17 | 18 | run-server: 19 | ENVIRONMENT=development go run main.go --region $(region) 20 | 21 | build-web: clean 22 | cd web && npm run build 23 | 24 | build: clean 25 | cd web && npm run build 26 | GOOS=$(os) GOARCH=$(arch) go build -o build/s3explorer_$(os)_$(arch) 27 | 28 | package: 29 | GOOS=$(os) GOARCH=$(arch) go build -o build/s3explorer_$(os)_$(arch) 30 | 31 | .PHONY: clean 32 | clean: 33 | rm -rf web/.next web/build build 34 | -------------------------------------------------------------------------------- /web/components/error-boundary.tsx: -------------------------------------------------------------------------------- 1 | import AlertTriangleIcon from '@geist-ui/icons/alertTriangle'; 2 | import React from 'react'; 3 | import Empty from './empty'; 4 | 5 | interface ErrorBoundaryState { 6 | error: Error | null; 7 | hasError: boolean; 8 | } 9 | 10 | export default class ErrorBoundary extends React.Component { 11 | state: ErrorBoundaryState = { 12 | error: null, 13 | hasError: false, 14 | }; 15 | 16 | static getDerivedStateFromError(error: Error): ErrorBoundaryState { 17 | return { 18 | error, 19 | hasError: true, 20 | }; 21 | } 22 | 23 | render(): React.ReactNode { 24 | const { error, hasError } = this.state; 25 | const { children } = this.props; 26 | 27 | if (error && hasError) { 28 | return ( 29 | } /> 30 | ); 31 | } 32 | 33 | return children; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /web/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import { CssBaseline, GeistProvider } from '@geist-ui/core'; 2 | import { AppProps } from 'next/app'; 3 | import Head from 'next/head'; 4 | import React from 'react'; 5 | import { QueryClient, QueryClientProvider } from 'react-query'; 6 | import ErrorBoundary from '../components/error-boundary'; 7 | import config from '../config'; 8 | import '../styles/globals.css'; 9 | 10 | const client = new QueryClient(); 11 | 12 | function App({ Component, pageProps }: AppProps): React.ReactElement { 13 | return ( 14 | 15 | 16 | 17 | 18 | {config.name} 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | ); 27 | } 28 | 29 | export default App; 30 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: [main] 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | 14 | - name: Setup Go 15 | uses: actions/setup-go@v2 16 | with: 17 | go-version: 1.19 18 | 19 | - name: Setup Node.js 20 | uses: actions/setup-node@v1 21 | with: 22 | node-version: 16.14.2 23 | 24 | - name: Prepare 25 | run: make prepare 26 | 27 | - name: Build 28 | run: | 29 | make build-web 30 | make package os=darwin arch=amd64 31 | make package os=darwin arch=arm64 32 | make package os=linux arch=amd64 33 | make package os=linux arch=arm64 34 | make package os=windows arch=amd64 35 | 36 | - name: Upload 37 | uses: actions/upload-artifact@v3 38 | with: 39 | name: binaries 40 | path: build/* 41 | -------------------------------------------------------------------------------- /web/hooks/buckets.ts: -------------------------------------------------------------------------------- 1 | import isEmpty from 'lodash/isEmpty'; 2 | import { useQuery } from 'react-query'; 3 | import { 4 | BucketsResponse, 5 | listBuckets, 6 | navigateBucket, 7 | NavigateResponse, 8 | } from '../api'; 9 | import { ApiQueryResult } from './options'; 10 | 11 | export function useListBuckets(): ApiQueryResult { 12 | const { 13 | data, 14 | isLoading: loading, 15 | error, 16 | } = useQuery('buckets-list', listBuckets); 17 | 18 | return { data, loading, error }; 19 | } 20 | 21 | export function useNavigateBucket( 22 | bucket: string, 23 | prefix: string, 24 | ): ApiQueryResult { 25 | const { 26 | data, 27 | isLoading: loading, 28 | error, 29 | } = useQuery( 30 | ['navigate-bucket', bucket, prefix], 31 | () => navigateBucket({ bucket, prefix }), 32 | { 33 | enabled: !isEmpty(bucket), 34 | }, 35 | ); 36 | 37 | return { data, loading, error }; 38 | } 39 | -------------------------------------------------------------------------------- /web/hooks/options.ts: -------------------------------------------------------------------------------- 1 | import { useToasts } from '@geist-ui/core'; 2 | import { useEffect } from 'react'; 3 | import { UseMutateFunction } from 'react-query'; 4 | import { ApiError } from '../api'; 5 | 6 | export const options = { 7 | refetchInterval: 5 * 1000, 8 | }; 9 | 10 | export interface ApiResult { 11 | data: T | undefined; 12 | loading: boolean; 13 | error: E | null; 14 | } 15 | 16 | export interface ApiQueryResult extends ApiResult { 17 | refetch?: VoidFunction; 18 | } 19 | 20 | export interface ApiMutationResult extends ApiResult { 21 | mutate: UseMutateFunction; 22 | } 23 | 24 | export function useNotifyError({ 25 | data, 26 | loading, 27 | error, 28 | }: ApiResult) { 29 | const { setToast } = useToasts(); 30 | 31 | useEffect(() => { 32 | const error = (data as ApiError)?.err; 33 | 34 | if (error) { 35 | setToast({ type: 'error', text: error }); 36 | } 37 | // eslint-disable-next-line react-hooks/exhaustive-deps 38 | }, [data, loading, error]); 39 | } 40 | -------------------------------------------------------------------------------- /web/public/vercel.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | -------------------------------------------------------------------------------- /web/api/types.ts: -------------------------------------------------------------------------------- 1 | export type ApiError = { 2 | err: string; 3 | }; 4 | 5 | export type Bucket = { 6 | name: string; 7 | creationDate: Date; 8 | }; 9 | 10 | export type BucketsResponse = { 11 | buckets: Bucket[]; 12 | total: number; 13 | }; 14 | 15 | export type NavigateRequest = { 16 | bucket: string; 17 | prefix: string; 18 | }; 19 | 20 | export enum S3ObjectType { 21 | FILE = 'FILE', 22 | FOLDER = 'FOLDER', 23 | } 24 | 25 | export type S3Object = { 26 | name: string; 27 | key: string; 28 | size: number | null; 29 | type: S3ObjectType; 30 | }; 31 | 32 | export type NavigateResponse = { 33 | objects: S3Object[]; 34 | }; 35 | 36 | export enum PresignTimeUnit { 37 | Hour = 'h', 38 | Minute = 'm', 39 | Second = 's', 40 | } 41 | 42 | export type PresignRequest = { 43 | bucket: string; 44 | key: string; 45 | duration: `${string}${PresignTimeUnit}`; 46 | }; 47 | 48 | export type PresignResponse = { 49 | url: string; 50 | }; 51 | 52 | export type DeleteRequest = { 53 | bucket: string; 54 | key: string; 55 | }; 56 | 57 | export type DeleteResponse = { 58 | success: boolean; 59 | }; 60 | -------------------------------------------------------------------------------- /web/hooks/objects.ts: -------------------------------------------------------------------------------- 1 | import { useMutation } from 'react-query'; 2 | import { 3 | deleteObject, 4 | DeleteRequest, 5 | DeleteResponse, 6 | PresignRequest, 7 | PresignResponse, 8 | presignUrl 9 | } from '../api'; 10 | import { ApiMutationResult } from './options'; 11 | 12 | export function usePresignUrl( 13 | key: string, 14 | ): ApiMutationResult { 15 | const { 16 | mutate, 17 | data, 18 | isLoading: loading, 19 | error, 20 | } = useMutation( 21 | ['presign-object', key], 22 | (request: PresignRequest) => presignUrl(request), 23 | ); 24 | 25 | return { mutate, data, loading, error }; 26 | } 27 | 28 | export function useDeleteObject( 29 | key: string, 30 | ): ApiMutationResult { 31 | const { 32 | mutate, 33 | data, 34 | isLoading: loading, 35 | error, 36 | } = useMutation( 37 | ['delete-object', key], 38 | (request: DeleteRequest) => deleteObject(request), 39 | ); 40 | 41 | return { mutate, data, loading, error }; 42 | } 43 | -------------------------------------------------------------------------------- /web/api/index.ts: -------------------------------------------------------------------------------- 1 | import { http } from '../utils/http'; 2 | import type { 3 | BucketsResponse, 4 | DeleteRequest, 5 | DeleteResponse, 6 | NavigateRequest, 7 | NavigateResponse, 8 | PresignRequest, 9 | PresignResponse, 10 | } from './types'; 11 | 12 | export async function listBuckets(): Promise { 13 | return http('/api/buckets/list'); 14 | } 15 | 16 | export async function navigateBucket( 17 | request: NavigateRequest, 18 | ): Promise { 19 | return http( 20 | '/api/buckets/navigate', 21 | 'POST', 22 | request, 23 | ); 24 | } 25 | 26 | export async function presignUrl( 27 | request: PresignRequest, 28 | ): Promise { 29 | return http( 30 | '/api/objects/presign', 31 | 'POST', 32 | request, 33 | ); 34 | } 35 | 36 | export async function deleteObject( 37 | request: DeleteRequest, 38 | ): Promise { 39 | return http( 40 | '/api/objects/delete', 41 | 'POST', 42 | request, 43 | ); 44 | } 45 | 46 | export * from './types'; 47 | -------------------------------------------------------------------------------- /web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "s3-explorer", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build && next export -o build", 8 | "start": "next start", 9 | "analyze": "ANALYZE=true next build", 10 | "lint": "next lint", 11 | "lint:fix": "prettier --write ." 12 | }, 13 | "dependencies": { 14 | "@geist-ui/core": "2.3.7", 15 | "@geist-ui/icons": "1.0.1", 16 | "clsx": "1.1.1", 17 | "lodash": "4.17.21", 18 | "next": "12.1.0", 19 | "query-string": "7.1.1", 20 | "react": "17.0.2", 21 | "react-dom": "17.0.2", 22 | "react-query": "3.34.16" 23 | }, 24 | "devDependencies": { 25 | "@next/bundle-analyzer": "12.1.0", 26 | "@types/lodash": "4.14.180", 27 | "@types/node": "17.0.21", 28 | "@types/react": "17.0.41", 29 | "autoprefixer": "10.4.4", 30 | "cross-env": "7.0.3", 31 | "eslint": "8.11.0", 32 | "eslint-config-next": "12.1.0", 33 | "postcss": "8.4.12", 34 | "prettier": "2.6.0", 35 | "tailwindcss": "3.0.23", 36 | "typescript": "4.6.2" 37 | }, 38 | "volta": { 39 | "node": "16.14.2", 40 | "npm": "8.5.0" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module s3explorer 2 | 3 | go 1.19 4 | 5 | require ( 6 | github.com/aws/aws-sdk-go-v2 v1.15.0 7 | github.com/aws/aws-sdk-go-v2/config v1.15.0 8 | github.com/aws/aws-sdk-go-v2/service/s3 v1.26.0 9 | github.com/gorilla/mux v1.8.0 10 | github.com/rs/zerolog v1.26.1 11 | ) 12 | 13 | require ( 14 | github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.0 // indirect 15 | github.com/aws/aws-sdk-go-v2/credentials v1.10.0 // indirect 16 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.0 // indirect 17 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.6 // indirect 18 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.0 // indirect 19 | github.com/aws/aws-sdk-go-v2/internal/ini v1.3.7 // indirect 20 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.0 // indirect 21 | github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.0 // indirect 22 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.0 // indirect 23 | github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.13.0 // indirect 24 | github.com/aws/aws-sdk-go-v2/service/sso v1.11.0 // indirect 25 | github.com/aws/aws-sdk-go-v2/service/sts v1.16.0 // indirect 26 | github.com/aws/smithy-go v1.11.1 // indirect 27 | ) 28 | -------------------------------------------------------------------------------- /web/utils/shared.ts: -------------------------------------------------------------------------------- 1 | import toLower from 'lodash/toLower'; 2 | import { S3Object } from '../api'; 3 | import { defaultParams } from './aws'; 4 | 5 | export function formatBytes(bytes: number, decimals: number = 2): string { 6 | if (bytes === 0) return '0 Bytes'; 7 | 8 | const k = 1024; 9 | const dm = decimals < 0 ? 0 : decimals; 10 | const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; 11 | 12 | const i = Math.floor(Math.log(bytes) / Math.log(k)); 13 | 14 | return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]; 15 | } 16 | 17 | export function getPreviousKey(key: string): string { 18 | const updatedPaths = key.split(defaultParams.Delimiter); 19 | 20 | updatedPaths.splice(-2, 1); 21 | 22 | if (updatedPaths.length === 1 && updatedPaths[0] === '') { 23 | updatedPaths.pop(); 24 | updatedPaths.push(defaultParams.Prefix); 25 | } 26 | 27 | return updatedPaths.join(defaultParams.Delimiter); 28 | } 29 | 30 | export function createBreadcrumbs(key: string): string[] { 31 | return key.split(defaultParams.Delimiter); 32 | } 33 | 34 | export function filterObjects(search: string, objects: S3Object[]): S3Object[] { 35 | return [...objects].filter( 36 | (object: S3Object) => toLower(object.name).search(toLower(search)) !== -1, 37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # S3 Explorer 2 | 3 | This is something I built for personal use with [Go](https://github.com/karanpratapsingh/go-course) and [Next.js](https://nextjs.org) to quickly navigate S3 buckets. The following features are supported: 4 | 5 | - Quick navigation across multiple buckets 6 | - AWS region and profile support 7 | - Generate pre-signed URLs 8 | - Grid and List view with search 9 | - Delete objects 10 | 11 | ### Demo 12 | 13 | list share grid delete 14 | 15 | ![usage](./docs/images/usage.png) 16 | 17 | ### Usage 18 | 19 | For using this application, you can download the binaries for different platforms from the [Build](https://github.com/karanpratapsingh/s3-explorer/actions/workflows/build.yml) action runs. 20 | 21 | ``` 22 | $ s3explorer --region us-east-1 --profile personal 23 | 1:41PM TRC AWS.Config region=us-east-1 24 | 1:41PM INF Starting application... port=8080 25 | ``` 26 | 27 | _Note: `--profile` flag is optional._ 28 | 29 | ### Development 30 | 31 | Prepare and run application development mode. 32 | 33 | ``` 34 | $ make prepare 35 | $ make run 36 | ``` 37 | 38 | ### Build 39 | 40 | Build the application. 41 | 42 | ``` 43 | $ make build 44 | ``` 45 | -------------------------------------------------------------------------------- /web/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import { useToasts } from '@geist-ui/core'; 2 | import dynamic from 'next/dynamic'; 3 | import React, { useState } from 'react'; 4 | import { defaultParams } from '../utils/aws'; 5 | import { getPreviousKey } from '../utils/shared'; 6 | 7 | const Header = dynamic(() => import('../components/header')); 8 | const ObjectList = dynamic(() => import('../components/object-list')); 9 | 10 | export default function Home(): React.ReactElement { 11 | const { setToast } = useToasts(); 12 | 13 | const [bucket, setBucket] = useState(defaultParams.Bucket); 14 | const [currentKey, setCurrentKey] = useState(defaultParams.Prefix); 15 | 16 | function onSelect(bucket: string | string[]): void { 17 | if (Array.isArray(bucket)) { 18 | return; 19 | } 20 | 21 | setBucket(bucket); 22 | setCurrentKey(defaultParams.Prefix); 23 | } 24 | 25 | function onNext(key: string): void { 26 | setCurrentKey(key); 27 | } 28 | 29 | function onBack(): void { 30 | if (currentKey.length) { 31 | const prevKey = getPreviousKey(currentKey); 32 | setCurrentKey(prevKey); 33 | } else { 34 | setToast({ 35 | type: 'warning', 36 | text: 'Already at the root. Please select a different bucket.', 37 | }); 38 | } 39 | } 40 | 41 | return ( 42 |
43 |
44 | 50 |
51 | ); 52 | } 53 | -------------------------------------------------------------------------------- /service/types.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "context" 5 | "time" 6 | ) 7 | 8 | type Service interface { 9 | Buckets(ctx context.Context) (BucketsResponse, error) 10 | Navigate(ctx context.Context, request NavigateRequest) (NavigateResponse, error) 11 | Presign(ctx context.Context, request PresignRequest) (PresignResponse, error) 12 | Delete(ctx context.Context, request DeleteRequest) (DeleteResponse, error) 13 | } 14 | 15 | type Bucket struct { 16 | Name string `json:"name"` 17 | CreationDate time.Time `json:"creationDate"` 18 | } 19 | 20 | type BucketsResponse struct { 21 | Buckets []Bucket `json:"buckets"` 22 | Total int `json:"total"` 23 | } 24 | 25 | type NavigateRequest struct { 26 | Bucket string `json:"bucket"` 27 | Prefix string `json:"prefix"` 28 | } 29 | 30 | type S3ObjectType = string 31 | 32 | var ( 33 | S3ObjectTypeFile S3ObjectType = "FILE" 34 | S3ObjectTypeFolder S3ObjectType = "FOLDER" 35 | ) 36 | 37 | type S3Object struct { 38 | Name string `json:"name"` 39 | Key string `json:"key"` 40 | Size *int64 `json:"size"` 41 | Type S3ObjectType `json:"type"` 42 | } 43 | 44 | type NavigateResponse struct { 45 | Objects []S3Object `json:"objects"` 46 | } 47 | 48 | type PresignRequest struct { 49 | Bucket string `json:"bucket"` 50 | Key string `json:"key"` 51 | Duration string `json:"duration"` 52 | } 53 | 54 | type PresignResponse struct { 55 | Url string `json:"url"` 56 | } 57 | 58 | type DeleteRequest struct { 59 | Bucket string `json:"bucket"` 60 | Key string `json:"key"` 61 | } 62 | 63 | type DeleteResponse struct { 64 | Success bool `json:"success"` 65 | } 66 | -------------------------------------------------------------------------------- /api/impl.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io/fs" 7 | "net/http" 8 | "s3explorer/service" 9 | "s3explorer/utils" 10 | 11 | "github.com/gorilla/mux" 12 | "github.com/rs/zerolog/log" 13 | ) 14 | 15 | type apiImpl struct { 16 | router *mux.Router 17 | assets fs.FS 18 | svc service.Service 19 | } 20 | 21 | func New(router *mux.Router, assets fs.FS, svc service.Service) apiImpl { 22 | return apiImpl{router, assets, svc} 23 | } 24 | 25 | func (api apiImpl) routes() { 26 | api.router.HandleFunc("/api/buckets/list", api.ListBuckets).Methods(http.MethodGet) 27 | api.router.HandleFunc("/api/buckets/navigate", api.NavigateBucket).Methods(http.MethodPost) 28 | api.router.HandleFunc("/api/objects/presign", api.PresignObject).Methods(http.MethodPost) 29 | api.router.HandleFunc("/api/objects/delete", api.DeleteObject).Methods(http.MethodPost) 30 | api.router.PathPrefix("/").Handler(http.FileServer(http.FS(api.assets))) 31 | } 32 | 33 | func (api apiImpl) Start(port int) { 34 | api.routes() 35 | 36 | log.Info().Int("port", port).Msg("Starting application...") 37 | addr := fmt.Sprintf(":%d", port) 38 | 39 | go utils.Open("http://localhost" + addr) 40 | panic(http.ListenAndServe(addr, api.router)) 41 | } 42 | 43 | func (api apiImpl) response(writer http.ResponseWriter, data any) { 44 | writer.Header().Add("Content-Type", "application/json") 45 | json.NewEncoder(writer).Encode(data) 46 | } 47 | 48 | func (api apiImpl) error(writer http.ResponseWriter, err error, code int) { 49 | writer.Header().Add("Content-Type", "application/json") 50 | writer.WriteHeader(code) 51 | json.NewEncoder(writer).Encode(ApiError{err.Error()}) 52 | } 53 | -------------------------------------------------------------------------------- /web/components/header.tsx: -------------------------------------------------------------------------------- 1 | import { Description, Loading, Select, Spacer, Text } from '@geist-ui/core'; 2 | import defaultTo from 'lodash/defaultTo'; 3 | import React from 'react'; 4 | import { Bucket, BucketsResponse } from '../api'; 5 | import config from '../config'; 6 | import { useListBuckets } from '../hooks/buckets'; 7 | import { useNotifyError } from '../hooks/options'; 8 | 9 | interface TitleProps { 10 | value: string; 11 | onSelect?: (value: string | string[]) => void; 12 | } 13 | 14 | export default function Header(props: TitleProps): React.ReactElement { 15 | const { value, onSelect } = props; 16 | 17 | const { data, loading, error } = useListBuckets(); 18 | 19 | useNotifyError({ data, loading, error }); 20 | 21 | const defaultValue = defaultTo(value, undefined); 22 | const buckets = defaultTo(data?.buckets, []); 23 | 24 | const title: React.ReactNode = {config.name}; 25 | 26 | function renderBucket({ name }: Bucket): React.ReactNode { 27 | return ( 28 | 29 | {name} 30 | 31 | ); 32 | } 33 | 34 | const placeholder: React.ReactNode = ( 35 |
36 | select a bucket 37 | 38 | {loading && } 39 |
40 | ); 41 | 42 | const content: React.ReactNode = ( 43 | 46 | ); 47 | 48 | return ; 49 | } 50 | -------------------------------------------------------------------------------- /web/README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | ``` 12 | 13 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 14 | 15 | You can start editing the page by modifying `pages/index.tsx`. The page auto-updates as you edit the file. 16 | 17 | [API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.ts`. 18 | 19 | The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages. 20 | 21 | ## Learn More 22 | 23 | To learn more about Next.js, take a look at the following resources: 24 | 25 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 26 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 27 | 28 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! 29 | 30 | ## Deploy on Vercel 31 | 32 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 33 | 34 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. 35 | -------------------------------------------------------------------------------- /api/handlers.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | "s3explorer/service" 7 | ) 8 | 9 | func (api apiImpl) ListBuckets(w http.ResponseWriter, r *http.Request) { 10 | data, err := api.svc.Buckets(r.Context()) 11 | 12 | if err != nil { 13 | api.error(w, err, http.StatusInternalServerError) 14 | return 15 | } 16 | 17 | api.response(w, data) 18 | } 19 | 20 | func (api apiImpl) NavigateBucket(w http.ResponseWriter, r *http.Request) { 21 | var body service.NavigateRequest 22 | 23 | if err := json.NewDecoder(r.Body).Decode(&body); err != nil { 24 | api.error(w, err, http.StatusBadRequest) 25 | return 26 | } 27 | 28 | data, err := api.svc.Navigate(r.Context(), body) 29 | 30 | if err != nil { 31 | api.error(w, err, http.StatusInternalServerError) 32 | return 33 | } 34 | 35 | api.response(w, data) 36 | } 37 | 38 | func (api apiImpl) PresignObject(w http.ResponseWriter, r *http.Request) { 39 | var body service.PresignRequest 40 | 41 | if err := json.NewDecoder(r.Body).Decode(&body); err != nil { 42 | http.Error(w, err.Error(), http.StatusBadRequest) 43 | return 44 | } 45 | 46 | data, err := api.svc.Presign(r.Context(), body) 47 | 48 | if err != nil { 49 | api.error(w, err, http.StatusInternalServerError) 50 | return 51 | } 52 | 53 | api.response(w, data) 54 | } 55 | 56 | func (api apiImpl) DeleteObject(w http.ResponseWriter, r *http.Request) { 57 | var body service.DeleteRequest 58 | 59 | if err := json.NewDecoder(r.Body).Decode(&body); err != nil { 60 | http.Error(w, err.Error(), http.StatusBadRequest) 61 | return 62 | } 63 | 64 | data, err := api.svc.Delete(r.Context(), body) 65 | 66 | if err != nil { 67 | api.error(w, err, http.StatusInternalServerError) 68 | return 69 | } 70 | 71 | api.response(w, data) 72 | } 73 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "embed" 6 | "flag" 7 | "io/fs" 8 | "os" 9 | "s3explorer/api" 10 | "s3explorer/service" 11 | 12 | "github.com/aws/aws-sdk-go-v2/aws" 13 | "github.com/aws/aws-sdk-go-v2/config" 14 | "github.com/aws/aws-sdk-go-v2/service/s3" 15 | "github.com/gorilla/mux" 16 | "github.com/rs/zerolog" 17 | "github.com/rs/zerolog/log" 18 | ) 19 | 20 | const path = "web/build" 21 | 22 | //go:embed web/build 23 | //go:embed web/build/_next 24 | //go:embed web/build/_next/static/css/*.css 25 | //go:embed web/build/_next/static/chunks/pages/*.js 26 | //go:embed web/build/_next/static/*/*.js 27 | var nextFS embed.FS 28 | 29 | func init() { 30 | log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr}) 31 | } 32 | 33 | func main() { 34 | port := 8080 35 | region := flag.String("region", "", "AWS region") 36 | profile := flag.String("profile", "", "AWS profile") 37 | 38 | flag.Parse() 39 | 40 | build, err := fs.Sub(nextFS, path) 41 | 42 | if err != nil { 43 | log.Panic().Err(err).Msg("Static.Error") 44 | } 45 | 46 | cfg, err := prepareConfig(region, profile) 47 | 48 | if err != nil { 49 | log.Panic().Err(err).Msg("AWS.Config.Error") 50 | } 51 | 52 | log.Trace().Str("region", *region).Msg("AWS.Config") 53 | 54 | router := mux.NewRouter() 55 | client := s3.NewFromConfig(cfg) 56 | 57 | svc := service.New(client) 58 | a := api.New(router, build, svc) 59 | a.Start(port) 60 | } 61 | 62 | func prepareConfig(region, profile *string) (aws.Config, error) { 63 | var options []func(*config.LoadOptions) error 64 | 65 | if *region == "" { // Region is required 66 | flag.Usage() 67 | panic("AWS Region is required") 68 | } 69 | 70 | if *profile != "" { 71 | options = append(options, config.WithSharedConfigProfile(*profile)) 72 | } 73 | 74 | options = append(options, config.WithDefaultRegion(*region)) 75 | 76 | return config.LoadDefaultConfig(context.TODO(), options...) 77 | } 78 | -------------------------------------------------------------------------------- /web/components/delete-modal.tsx: -------------------------------------------------------------------------------- 1 | import { Modal, useToasts } from '@geist-ui/core'; 2 | import { ModalHooksBindings } from '@geist-ui/core/esm/use-modal'; 3 | import isEmpty from 'lodash/isEmpty'; 4 | import React from 'react'; 5 | import { DeleteRequest, DeleteResponse } from '../api'; 6 | import { useDeleteObject } from '../hooks/objects'; 7 | import { useNotifyError } from '../hooks/options'; 8 | 9 | interface DeleteModalProps { 10 | bucket: string; 11 | objectKey: string; 12 | bindings: ModalHooksBindings; 13 | onClose: VoidFunction; 14 | } 15 | 16 | export default function DeleteModal( 17 | props: DeleteModalProps, 18 | ): React.ReactElement { 19 | const { bucket, objectKey, bindings, onClose } = props; 20 | 21 | const { setToast } = useToasts(); 22 | const { mutate, data, loading, error } = useDeleteObject(objectKey); 23 | 24 | useNotifyError({ data, loading, error }); 25 | 26 | function deleteObject(): void { 27 | if (isEmpty(objectKey)) { 28 | setToast({ type: 'error', text: 'object key is empty' }); 29 | return; 30 | } 31 | 32 | if (isEmpty(bucket)) { 33 | setToast({ type: 'error', text: 'bucket is null' }); 34 | return; 35 | } 36 | 37 | const params: DeleteRequest = { 38 | bucket, 39 | key: objectKey, 40 | }; 41 | 42 | mutate(params, { 43 | onSuccess: () => { 44 | onClose(); 45 | }, 46 | }); 47 | } 48 | 49 | return ( 50 | 51 | Delete 52 | 53 |

54 | Are you sure you want to delete {objectKey}? 55 |

56 |
57 | 58 | Cancel 59 | 60 | 61 | Delete 62 | 63 |
64 | ); 65 | } 66 | -------------------------------------------------------------------------------- /service/impl.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "context" 5 | "s3explorer/utils" 6 | "time" 7 | 8 | "github.com/aws/aws-sdk-go-v2/service/s3" 9 | "github.com/rs/zerolog/log" 10 | ) 11 | 12 | type serviceImpl struct { 13 | client *s3.Client 14 | } 15 | 16 | func New(client *s3.Client) serviceImpl { 17 | return serviceImpl{client} 18 | } 19 | 20 | func (svc serviceImpl) Buckets(ctx context.Context) (BucketsResponse, error) { 21 | output, err := svc.client.ListBuckets(ctx, nil) 22 | 23 | if err != nil { 24 | log.Error().Err(err).Msg("Service.Buckets") 25 | return BucketsResponse{}, err 26 | } 27 | 28 | var buckets []Bucket 29 | total := len(output.Buckets) 30 | 31 | for _, bucket := range output.Buckets { 32 | buckets = append(buckets, Bucket{*bucket.Name, *bucket.CreationDate}) 33 | } 34 | 35 | return BucketsResponse{buckets, total}, nil 36 | } 37 | 38 | func (svc serviceImpl) Navigate(ctx context.Context, request NavigateRequest) (NavigateResponse, error) { 39 | var data []S3Object = make([]S3Object, 0) 40 | 41 | delimiter := "/" 42 | 43 | params := s3.ListObjectsV2Input{ 44 | Bucket: &request.Bucket, 45 | Prefix: &request.Prefix, 46 | Delimiter: &delimiter, 47 | } 48 | 49 | output, err := svc.client.ListObjectsV2(ctx, ¶ms) 50 | 51 | if err != nil { 52 | log.Error().Err(err).Msg("Service.Navigate.Error") 53 | return NavigateResponse{data}, err 54 | } 55 | 56 | var files = make([]S3Object, 0) 57 | var folders = make([]S3Object, 0) 58 | 59 | if output.Contents != nil { 60 | for _, content := range output.Contents { 61 | key := *content.Key 62 | name := utils.Trim(key, request.Prefix, delimiter) 63 | 64 | if *content.Key != request.Prefix { 65 | files = append(files, S3Object{name, key, &content.Size, S3ObjectTypeFile}) 66 | } 67 | } 68 | } 69 | 70 | if output.CommonPrefixes != nil { 71 | for _, common := range output.CommonPrefixes { 72 | key := *common.Prefix 73 | name := utils.Trim(key, request.Prefix, delimiter) 74 | 75 | folders = append(folders, S3Object{name, key, nil, S3ObjectTypeFolder}) 76 | } 77 | } 78 | 79 | data = utils.Concat(files, folders) 80 | 81 | log.Debug(). 82 | Str("bucket", request.Bucket). 83 | Str("prefix", request.Prefix). 84 | Int("total", len(data)). 85 | Msg("Service.Navigate") 86 | 87 | return NavigateResponse{data}, nil 88 | } 89 | 90 | func (svc serviceImpl) Presign(ctx context.Context, request PresignRequest) (PresignResponse, error) { 91 | expires, err := time.ParseDuration(request.Duration) 92 | 93 | if err != nil { 94 | log.Error().Err(err).Msg("Service.Parse.Error") 95 | return PresignResponse{}, err 96 | } 97 | 98 | params := s3.GetObjectInput{ 99 | Bucket: &request.Bucket, 100 | Key: &request.Key, 101 | } 102 | 103 | client := s3.NewPresignClient(svc.client, s3.WithPresignExpires(expires)) 104 | output, err := client.PresignGetObject(ctx, ¶ms) 105 | 106 | if err != nil { 107 | log.Error().Err(err).Msg("Service.Presign.Error") 108 | return PresignResponse{}, err 109 | } 110 | 111 | log.Info(). 112 | Str("bucket", request.Bucket). 113 | Str("key", request.Key). 114 | Str("duration", request.Duration). 115 | Msg("Service.Presign") 116 | 117 | return PresignResponse{output.URL}, nil 118 | } 119 | 120 | func (svc serviceImpl) Delete(ctx context.Context, request DeleteRequest) (DeleteResponse, error) { 121 | params := s3.DeleteObjectInput{ 122 | Bucket: &request.Bucket, 123 | Key: &request.Key, 124 | } 125 | 126 | if _, err := svc.client.DeleteObject(ctx, ¶ms); err != nil { 127 | log.Error().Err(err).Msg("Service.Delete.Error") 128 | return DeleteResponse{}, err 129 | } 130 | 131 | log.Warn(). 132 | Str("bucket", request.Bucket). 133 | Str("key", request.Key). 134 | Msg("Service.Delete") 135 | 136 | return DeleteResponse{true}, nil 137 | } 138 | -------------------------------------------------------------------------------- /web/components/share-modal.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Button, 3 | Grid, 4 | Input, 5 | Modal, 6 | Select, 7 | Snippet, 8 | useToasts 9 | } from '@geist-ui/core'; 10 | import { ModalHooksBindings } from '@geist-ui/core/esm/use-modal'; 11 | import ClockIcon from '@geist-ui/icons/clock'; 12 | import isEmpty from 'lodash/isEmpty'; 13 | import React, { ChangeEvent, useState } from 'react'; 14 | import { PresignRequest, PresignResponse, PresignTimeUnit } from '../api'; 15 | import { usePresignUrl } from '../hooks/objects'; 16 | import { useNotifyError } from '../hooks/options'; 17 | 18 | interface ShareModalProps { 19 | bucket: string; 20 | objectKey: string; 21 | bindings: ModalHooksBindings; 22 | onClose: VoidFunction; 23 | } 24 | 25 | export default function ShareModal(props: ShareModalProps): React.ReactElement { 26 | const { bucket, objectKey, bindings, onClose } = props; 27 | 28 | const { setToast } = useToasts(); 29 | const { mutate, data, loading, error } = usePresignUrl(objectKey); 30 | 31 | const [time, setTime] = useState('5'); 32 | const [unit, setUnit] = useState(PresignTimeUnit.Hour); 33 | 34 | const type = Number.parseInt(time) < 0 ? 'error' : 'default'; 35 | 36 | useNotifyError({ data, loading, error }); 37 | 38 | function generatePresignUrl(): void { 39 | if (isEmpty(objectKey)) { 40 | setToast({ type: 'error', text: 'object key is empty' }); 41 | return; 42 | } 43 | 44 | if (isEmpty(bucket)) { 45 | setToast({ type: 'error', text: 'bucket is null' }); 46 | return; 47 | } 48 | 49 | const params: PresignRequest = { 50 | bucket, 51 | key: objectKey, 52 | duration: `${time}${unit}`, 53 | }; 54 | 55 | mutate(params); 56 | } 57 | 58 | function onTime({ target }: ChangeEvent): void { 59 | setTime(target.value); 60 | } 61 | 62 | function renderTimeUnitOption([key, value]: [ 63 | string, 64 | string, 65 | ]): React.ReactNode { 66 | return ( 67 | 68 | {key} 69 | 70 | ); 71 | } 72 | 73 | function onUnit(unit: string | string[]): void { 74 | if (Array.isArray(unit)) { 75 | return; 76 | } 77 | 78 | setUnit(unit as PresignTimeUnit); 79 | } 80 | 81 | const timeUnitOptions = Object.entries(PresignTimeUnit); 82 | 83 | return ( 84 | 85 | Share 86 | Create a presigned url 87 | 88 | 89 | 90 | 98 | 99 | 100 | 111 | 112 | 113 | 116 | 117 | 118 | 119 | {data?.url && ( 120 | 121 | )} 122 | 123 | 124 | 125 | Done 126 | 127 | 128 | ); 129 | } 130 | -------------------------------------------------------------------------------- /web/components/object-listitem.tsx: -------------------------------------------------------------------------------- 1 | import { Card, Popover, Spacer, Text } from '@geist-ui/core'; 2 | import FileIcon from '@geist-ui/icons/file'; 3 | import FolderIcon from '@geist-ui/icons/folder'; 4 | import MoreVerticalIcon from '@geist-ui/icons/moreVertical'; 5 | import clsx from 'clsx'; 6 | import React from 'react'; 7 | import { S3Object, S3ObjectType } from '../api'; 8 | import { formatBytes } from '../utils/shared'; 9 | 10 | export enum LayoutType { 11 | List = 'List', 12 | Grid = 'Grid', 13 | } 14 | 15 | export enum ActionType { 16 | Share, 17 | Move, 18 | Delete, 19 | } 20 | 21 | interface ObjectListItemProps { 22 | layoutType: LayoutType; 23 | object: S3Object; 24 | onNext: (key: string) => void; 25 | onAction: (key: string, action: ActionType) => void; 26 | } 27 | 28 | export default function ObjectListItem( 29 | props: ObjectListItemProps, 30 | ): React.ReactElement { 31 | const { object, layoutType, onNext, onAction } = props; 32 | const { name, key, type, size } = object; 33 | 34 | const isFile = type === S3ObjectType.FILE; 35 | 36 | const onClick = () => !isFile && onNext(key); 37 | 38 | const popoverContent: React.ReactNode = () => ( 39 |
40 | 41 | onAction(key, ActionType.Share)} 45 | > 46 | Share 47 | 48 | 49 | 50 | onAction(key, ActionType.Delete)} 55 | > 56 | Delete 57 | 58 | 59 |
60 | ); 61 | 62 | let item = ( 63 |
70 |
71 | {getIcon(type, 20)} 72 | 73 | {name} 74 |
75 | 76 |
77 | {size && ( 78 | 79 | {formatBytes(size)} 80 | 81 | )} 82 | {isFile && ( 83 | <> 84 | 85 | 86 | 87 | 88 | 89 | )} 90 |
91 |
92 | ); 93 | 94 | if (layoutType === LayoutType.Grid) { 95 | item = ( 96 | 97 | 102 |
103 | {getIcon(type, 40)} 104 |
105 |

106 | {name} 107 |

108 | {size && ( 109 |

110 | {formatBytes(size)} 111 |

112 | )} 113 |
114 |
115 | ); 116 | } 117 | 118 | return item; 119 | } 120 | 121 | function getIcon(type: S3ObjectType, size: number): React.ReactNode { 122 | let icon: React.ReactNode = null; 123 | 124 | switch (type) { 125 | case S3ObjectType.FILE: 126 | icon = ; 127 | break; 128 | case S3ObjectType.FOLDER: 129 | icon = ; 130 | break; 131 | } 132 | 133 | return icon; 134 | } 135 | -------------------------------------------------------------------------------- /web/components/object-list.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Breadcrumbs, 3 | Grid, 4 | Input, 5 | Loading, 6 | Spacer, 7 | useModal, 8 | } from '@geist-ui/core'; 9 | import AlertCircleIcon from '@geist-ui/icons/alertCircle'; 10 | import ArchiveIcon from '@geist-ui/icons/archive'; 11 | import ChevronLeftIcon from '@geist-ui/icons/chevronLeft'; 12 | import DatabaseIcon from '@geist-ui/icons/database'; 13 | import GridIcon from '@geist-ui/icons/grid'; 14 | import InfoIcon from '@geist-ui/icons/info'; 15 | import ListIcon from '@geist-ui/icons/list'; 16 | import SearchIcon from '@geist-ui/icons/search'; 17 | import clsx from 'clsx'; 18 | import defaultTo from 'lodash/defaultTo'; 19 | import isEmpty from 'lodash/isEmpty'; 20 | import dynamic from 'next/dynamic'; 21 | import React, { useMemo, useState } from 'react'; 22 | import { NavigateResponse, S3Object } from '../api'; 23 | import { useNavigateBucket } from '../hooks/buckets'; 24 | import { useNotifyError } from '../hooks/options'; 25 | import { defaultParams } from '../utils/aws'; 26 | import { createBreadcrumbs, filterObjects } from '../utils/shared'; 27 | import { Colors } from '../utils/theme'; 28 | import Empty from './empty'; 29 | import ObjectListItem, { ActionType, LayoutType } from './object-listitem'; 30 | 31 | const ShareModal = dynamic(() => import('./share-modal')); 32 | const DeleteModal = dynamic(() => import('./delete-modal')); 33 | 34 | interface ObjectListProps { 35 | bucket: string; 36 | currentKey: string; 37 | onNext: (key: string) => void; 38 | onBack: VoidFunction; 39 | } 40 | 41 | export default function ObjectList(props: ObjectListProps): React.ReactElement { 42 | const { bucket, currentKey, onNext, onBack } = props; 43 | 44 | const { setVisible: setShareVisible, bindings: shareBindings } = useModal(); 45 | const { setVisible: setDeleteVisible, bindings: deleteBindings } = useModal(); 46 | 47 | const [layoutType, setLayoutType] = useState(LayoutType.List); 48 | const [search, setSearch] = useState(''); 49 | const [objectKey, setObjectKey] = useState(defaultParams.Prefix); 50 | 51 | const { data, loading, error } = useNavigateBucket(bucket, currentKey); 52 | 53 | useNotifyError({ data, loading, error }); 54 | 55 | const objects = defaultTo(data?.objects, []); 56 | 57 | function onSearch({ target }: React.ChangeEvent) { 58 | setSearch(target.value); 59 | } 60 | 61 | function onAction(key: string, action: ActionType): void { 62 | setObjectKey(key); 63 | 64 | switch (action) { 65 | case ActionType.Share: 66 | setShareVisible(true); 67 | break; 68 | case ActionType.Delete: 69 | setDeleteVisible(true); 70 | break; 71 | } 72 | } 73 | 74 | function onChangeType(type: LayoutType): void { 75 | setLayoutType(type); 76 | } 77 | 78 | function renderObject(object: S3Object): React.ReactNode { 79 | const { key } = object; 80 | 81 | const isListLayout = layoutType === LayoutType.List; 82 | 83 | return ( 84 | 85 | 92 | 93 | ); 94 | } 95 | 96 | const filteredObjects = useMemo( 97 | () => filterObjects(search, objects), 98 | [search, objects], 99 | ); 100 | 101 | const hasItems = !!filteredObjects.length && !isEmpty(bucket) && !loading; 102 | const noObjects = !filteredObjects.length && !isEmpty(bucket) && !loading; 103 | const isRoot = currentKey !== defaultParams.Prefix; 104 | 105 | function onNavigateNext(key: string): void { 106 | setSearch(''); 107 | onNext(key); 108 | } 109 | 110 | function onNavigateBack(): void { 111 | if (!loading) { 112 | setSearch(''); 113 | onBack(); 114 | } 115 | } 116 | 117 | const breadcrumbs = createBreadcrumbs(currentKey); 118 | 119 | function renderBreadcrumb(breadcrumb: string): React.ReactNode { 120 | return ( 121 | 122 | {breadcrumb} 123 | 124 | ); 125 | } 126 | 127 | let breadcrumbContent: React.ReactNode = ( 128 |
129 | 130 | 131 | Select a bucket 132 |
133 | ); 134 | 135 | if (bucket) { 136 | breadcrumbContent = ( 137 |
138 | {isRoot && ( 139 | 144 | )} 145 | 146 | 147 | 148 | {bucket} 149 | 150 | {React.Children.toArray(breadcrumbs.map(renderBreadcrumb))} 151 | 152 |
153 | ); 154 | } 155 | 156 | return ( 157 |
161 |
162 | {breadcrumbContent} 163 |
164 | } 168 | placeholder='Search...' 169 | onChange={onSearch} 170 | /> 171 |
172 | {layoutType === LayoutType.List && ( 173 | onChangeType(LayoutType.Grid)} 177 | /> 178 | )} 179 | {layoutType === LayoutType.Grid && ( 180 | onChangeType(LayoutType.List)} 184 | /> 185 | )} 186 |
187 |
188 |
189 | 190 | {loading && } />} 191 | 192 | {!bucket && ( 193 | } 196 | /> 197 | )} 198 | 199 | {noObjects && ( 200 | } /> 201 | )} 202 | 203 | {hasItems && ( 204 |
205 | 206 | {React.Children.toArray(filteredObjects.map(renderObject))} 207 | 208 |
209 | )} 210 | 211 | setShareVisible(false)} 216 | /> 217 | setDeleteVisible(false)} 222 | /> 223 |
224 | ); 225 | } 226 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/aws/aws-sdk-go-v2 v1.15.0 h1:f9kWLNfyCzCB43eupDAk3/XgJ2EpgktiySD6leqs0js= 2 | github.com/aws/aws-sdk-go-v2 v1.15.0/go.mod h1:lJYcuZZEHWNIb6ugJjbQY1fykdoobWbOS7kJYb4APoI= 3 | github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.0 h1:J/tiyHbl07LL4/1i0rFrW5pbLMvo7M6JrekBUNpLeT4= 4 | github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.0/go.mod h1:ohZjRmiToJ4NybwWTGOCbzlUQU8dxSHxYKzuX7k5l6Y= 5 | github.com/aws/aws-sdk-go-v2/config v1.15.0 h1:cibCYF2c2uq0lsbu0Ggbg8RuGeiHCmXwUlTMS77CiK4= 6 | github.com/aws/aws-sdk-go-v2/config v1.15.0/go.mod h1:NccaLq2Z9doMmeQXHQRrt2rm+2FbkrcPvfdbCaQn5hY= 7 | github.com/aws/aws-sdk-go-v2/credentials v1.10.0 h1:M/FFpf2w31F7xqJqJLgiM0mFpLOtBvwZggORr6QCpo8= 8 | github.com/aws/aws-sdk-go-v2/credentials v1.10.0/go.mod h1:HWJMr4ut5X+Lt/7epc7I6Llg5QIcoFHKAeIzw32t6EE= 9 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.0 h1:gUlb+I7NwDtqJUIRcFYDiheYa97PdVHG/5Iz+SwdoHE= 10 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.0/go.mod h1:prX26x9rmLwkEE1VVCelQOQgRN9sOVIssgowIJ270SE= 11 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.6 h1:xiGjGVQsem2cxoIX61uRGy+Jux2s9C/kKbTrWLdrU54= 12 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.6/go.mod h1:SSPEdf9spsFgJyhjrXvawfpyzrXHBCUe+2eQ1CjC1Ak= 13 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.0 h1:bt3zw79tm209glISdMRCIVRCwvSDXxgAxh5KWe2qHkY= 14 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.0/go.mod h1:viTrxhAuejD+LszDahzAE2x40YjYWhMqzHxv2ZiWaME= 15 | github.com/aws/aws-sdk-go-v2/internal/ini v1.3.7 h1:QOMEP8jnO8sm0SX/4G7dbaIq2eEP2wcWEsF0jzrXLJc= 16 | github.com/aws/aws-sdk-go-v2/internal/ini v1.3.7/go.mod h1:P5sjYYf2nc5dE6cZIzEMsVtq6XeLD7c4rM+kQJPrByA= 17 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.0 h1:uhb7moM7VjqIEpWzTpCvceLDSwrWpaleXm39OnVjuLE= 18 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.0/go.mod h1:pA2St3Pu2Ldy6fBPY45Azoh1WBG4oS7eIKOd4XN7Meg= 19 | github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.0 h1:IhiVUezzcKlszx6wXSDQYDjEn/bIO6Mc73uNQ1YfTmA= 20 | github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.0/go.mod h1:kLKc4lo+XKlMhENIpKbp7dCePpyUqUG1PqGIAXoxwNE= 21 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.0 h1:YQ3fTXACo7xeAqg0NiqcCmBOXJruUfh+4+O2qxF2EjQ= 22 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.0/go.mod h1:R31ot6BgESRCIoxwfKtIHzZMo/vsZn2un81g9BJ4nmo= 23 | github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.13.0 h1:i+7ve93k5G0S2xWBu60CKtmzU5RjBj9g7fcSypQNLR0= 24 | github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.13.0/go.mod h1:L8EoTDLnnN2zL7MQPhyfCbmiZqEs8Cw7+1d9RlLXT5s= 25 | github.com/aws/aws-sdk-go-v2/service/s3 v1.26.0 h1:6IdBZVY8zod9umkwWrtbH2opcM00eKEmIfZKGUg5ywI= 26 | github.com/aws/aws-sdk-go-v2/service/s3 v1.26.0/go.mod h1:WJzrjAFxq82Hl42oh8HuvwpugTgxmoiJBBX8SLwVs74= 27 | github.com/aws/aws-sdk-go-v2/service/sso v1.11.0 h1:gZLEXLH6NiU8Y52nRhK1jA+9oz7LZzBK242fi/ziXa4= 28 | github.com/aws/aws-sdk-go-v2/service/sso v1.11.0/go.mod h1:d1WcT0OjggjQCAdOkph8ijkr5sUwk1IH/VenOn7W1PU= 29 | github.com/aws/aws-sdk-go-v2/service/sts v1.16.0 h1:0+X/rJ2+DTBKWbUsn7WtF0JvNk/fRf928vkFsXkbbZs= 30 | github.com/aws/aws-sdk-go-v2/service/sts v1.16.0/go.mod h1:+8k4H2ASUZZXmjx/s3DFLo9tGBb44lkz3XcgfypJY7s= 31 | github.com/aws/smithy-go v1.11.1 h1:IQ+lPZVkSM3FRtyaDox41R8YS6iwPMYIreejOgPW49g= 32 | github.com/aws/smithy-go v1.11.1/go.mod h1:3xHYmszWVx2c0kIwQeEVf9uSm4fYZt67FBJnwub1bgM= 33 | github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= 34 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 35 | github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= 36 | github.com/google/go-cmp v0.5.7 h1:81/ik6ipDQS2aGcBfIN5dHDB36BwrStyeAQquSYCV4o= 37 | github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= 38 | github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= 39 | github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= 40 | github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= 41 | github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= 42 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 43 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 44 | github.com/rs/xid v1.3.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= 45 | github.com/rs/zerolog v1.26.1 h1:/ihwxqH+4z8UxyI70wM1z9yCvkWcfz/a3mj48k/Zngc= 46 | github.com/rs/zerolog v1.26.1/go.mod h1:/wSSJWX7lVrsOwlbyTRSOJvqRlc+WjWlfes+CiJ+tmc= 47 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 48 | github.com/yuin/goldmark v1.4.0/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 49 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 50 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 51 | golang.org/x/crypto v0.0.0-20211215165025-cf75a172585e/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= 52 | golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 53 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 54 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 55 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 56 | golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 57 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 58 | golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 59 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 60 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 61 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 62 | golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 63 | golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 64 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 65 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 66 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 67 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 68 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 69 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 70 | golang.org/x/tools v0.1.7/go.mod h1:LGqMHiF4EqQNHR1JncWGqT5BVaXmza+X+BDGol+dOxo= 71 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 72 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 73 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 74 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= 75 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 76 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 77 | gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 78 | --------------------------------------------------------------------------------