├── 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 |
--------------------------------------------------------------------------------
/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 |
14 |
15 | 
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 |
--------------------------------------------------------------------------------