├── .eslintrc.json ├── public ├── cover.png ├── step1.jpg ├── step2.png ├── step3.png ├── step4.png ├── step5.png ├── step6.png ├── step7.png ├── step8.png ├── vercel.svg └── next.svg ├── src ├── app │ ├── favicon.ico │ ├── layout.tsx │ ├── globals.css │ ├── login │ │ └── page.tsx │ ├── page.tsx │ └── api │ │ └── v1 │ │ └── file │ │ └── route.ts ├── lib │ ├── utils.ts │ ├── response.ts │ ├── format.ts │ ├── redis.ts │ └── backblaze.ts ├── components │ ├── ui │ │ ├── skeleton.tsx │ │ ├── label.tsx │ │ ├── progress.tsx │ │ ├── input.tsx │ │ ├── sonner.tsx │ │ ├── button.tsx │ │ ├── tabs.tsx │ │ ├── card.tsx │ │ ├── table.tsx │ │ ├── dialog.tsx │ │ ├── alert-dialog.tsx │ │ └── dropdown-menu.tsx │ └── modules │ │ ├── content │ │ ├── grid │ │ │ └── index.tsx │ │ └── table │ │ │ └── index.tsx │ │ ├── imageItem │ │ └── index.tsx │ │ └── header │ │ └── index.tsx ├── actions.ts ├── hooks │ └── useCopy.ts ├── env.mjs └── middleware.ts ├── postcss.config.js ├── .prettierrc.mjs ├── .env.example ├── next.config.mjs ├── components.json ├── .gitignore ├── CHANGELOG.md ├── tsconfig.json ├── package.json ├── tailwind.config.ts └── README.md /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /public/cover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Peek-A-Booo/Backblaze-Cloudflare-OSS-Interface/HEAD/public/cover.png -------------------------------------------------------------------------------- /public/step1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Peek-A-Booo/Backblaze-Cloudflare-OSS-Interface/HEAD/public/step1.jpg -------------------------------------------------------------------------------- /public/step2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Peek-A-Booo/Backblaze-Cloudflare-OSS-Interface/HEAD/public/step2.png -------------------------------------------------------------------------------- /public/step3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Peek-A-Booo/Backblaze-Cloudflare-OSS-Interface/HEAD/public/step3.png -------------------------------------------------------------------------------- /public/step4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Peek-A-Booo/Backblaze-Cloudflare-OSS-Interface/HEAD/public/step4.png -------------------------------------------------------------------------------- /public/step5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Peek-A-Booo/Backblaze-Cloudflare-OSS-Interface/HEAD/public/step5.png -------------------------------------------------------------------------------- /public/step6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Peek-A-Booo/Backblaze-Cloudflare-OSS-Interface/HEAD/public/step6.png -------------------------------------------------------------------------------- /public/step7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Peek-A-Booo/Backblaze-Cloudflare-OSS-Interface/HEAD/public/step7.png -------------------------------------------------------------------------------- /public/step8.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Peek-A-Booo/Backblaze-Cloudflare-OSS-Interface/HEAD/public/step8.png -------------------------------------------------------------------------------- /src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Peek-A-Booo/Backblaze-Cloudflare-OSS-Interface/HEAD/src/app/favicon.ico -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /.prettierrc.mjs: -------------------------------------------------------------------------------- 1 | import { factory } from '@innei/prettier' 2 | 3 | export default factory({ importSort: true, tailwindcss: true }) 4 | -------------------------------------------------------------------------------- /src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { type ClassValue, clsx } from "clsx" 2 | import { twMerge } from "tailwind-merge" 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | APP_KEY_ID= 2 | APP_KEY= 3 | BUCKET_ID= 4 | NEXT_PUBLIC_HOSTNAME= 5 | 6 | # upstash redis config (optional) 7 | UPSTASH_REDIS_REST_URL= 8 | UPSTASH_REDIS_REST_TOKEN= 9 | 10 | # Access code (optional) 11 | ACCESS_CODE= -------------------------------------------------------------------------------- /src/lib/response.ts: -------------------------------------------------------------------------------- 1 | export const CORS_HEADERS = { 2 | "Access-Control-Allow-Origin": "*", 3 | "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS", 4 | "Access-Control-Allow-Headers": 5 | "Content-Type, Authorization, anthropic-version, X-Api-Key", 6 | }; 7 | -------------------------------------------------------------------------------- /src/lib/format.ts: -------------------------------------------------------------------------------- 1 | export function formatSize(size: number) { 2 | const units = ['B', 'KB', 'MB', 'GB', 'TB'] 3 | let unitIndex = 0 4 | while (size > 1024) { 5 | size /= 1024 6 | unitIndex++ 7 | } 8 | return `${size.toFixed(1)} ${units[unitIndex]}` 9 | } 10 | -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | import { env } from './src/env.mjs' 2 | 3 | /** @type {import('next').NextConfig} */ 4 | const nextConfig = { 5 | images: { 6 | remotePatterns: [ 7 | { 8 | hostname: env.NEXT_PUBLIC_HOSTNAME || '*', 9 | }, 10 | ], 11 | }, 12 | } 13 | 14 | export default nextConfig 15 | -------------------------------------------------------------------------------- /src/components/ui/skeleton.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils" 2 | 3 | function Skeleton({ 4 | className, 5 | ...props 6 | }: React.HTMLAttributes) { 7 | return ( 8 |
12 | ) 13 | } 14 | 15 | export { Skeleton } 16 | -------------------------------------------------------------------------------- /src/actions.ts: -------------------------------------------------------------------------------- 1 | 'use server' 2 | 3 | import { cookies } from 'next/headers' 4 | 5 | import { env } from '@/env.mjs' 6 | 7 | export async function setAccessCode(code: string) { 8 | if (!env.ACCESS_CODE || code !== env.ACCESS_CODE) { 9 | cookies().delete('Access-Code') 10 | return false 11 | } 12 | 13 | cookies().set('Access-Code', code) 14 | return true 15 | } 16 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "src/app/globals.css", 9 | "baseColor": "slate", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils" 16 | } 17 | } -------------------------------------------------------------------------------- /.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 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/hooks/useCopy.ts: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | import { useCopyToClipboard } from 'usehooks-ts' 3 | 4 | export function useCopy() { 5 | const [isCopied, setCopied] = useState(false) 6 | const [, copyToClipboard] = useCopyToClipboard() 7 | 8 | const copy = (text: string) => { 9 | if (isCopied) return 10 | copyToClipboard(text) 11 | .then(() => { 12 | setCopied(true) 13 | setTimeout(() => setCopied(false), 2000) 14 | }) 15 | .catch((error) => { 16 | console.error('Failed to copy!', error) 17 | }) 18 | } 19 | 20 | return [isCopied, copy] as const 21 | } 22 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## v0.1.2 4 | 5 | ### Add 6 | 7 | - Support display modes of grid and table 8 | - Add `ACCESS_CODE` to protect the access of the website 9 | 10 | ## v0.1.1 11 | 12 | ### Breaking 13 | 14 | - Replace env var `BUCKET_NAME` with `BUCKET_ID` 15 | 16 | ### Add 17 | 18 | - Integrate `@upstash/redis` to reduce [Transactions Class C calls](https://www.backblaze.com/cloud-storage/transaction-pricing). 19 | - Add edge runtime in `/api/v1` router 20 | 21 | ### Changed 22 | 23 | - Remove `backblaze-b2` 24 | - Refactor backblaze api based on [B2 NATIVE API](https://www.backblaze.com/apidocs/introduction-to-the-b2-native-api) 25 | -------------------------------------------------------------------------------- /src/env.mjs: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | import { createEnv } from '@t3-oss/env-nextjs' 4 | 5 | // https://env.t3.gg/docs/nextjs 6 | export const env = createEnv({ 7 | server: { 8 | APP_KEY_ID: z.string().min(1), 9 | APP_KEY: z.string().min(1), 10 | BUCKET_ID: z.string().min(1), 11 | UPSTASH_REDIS_REST_URL: z.string().optional(), 12 | UPSTASH_REDIS_REST_TOKEN: z.string().optional(), 13 | ACCESS_CODE: z.string().optional(), 14 | }, 15 | client: { 16 | NEXT_PUBLIC_HOSTNAME: z.string().min(1), 17 | }, 18 | experimental__runtimeEnv: { 19 | NEXT_PUBLIC_HOSTNAME: process.env.NEXT_PUBLIC_HOSTNAME, 20 | }, 21 | }) 22 | -------------------------------------------------------------------------------- /src/components/modules/content/grid/index.tsx: -------------------------------------------------------------------------------- 1 | import { useSWRConfig } from 'swr' 2 | 3 | import { ImageItem } from '@/components/modules/imageItem' 4 | 5 | export function GridData() { 6 | const { cache, mutate } = useSWRConfig() 7 | 8 | const lists: any[] = cache.get('/api/v1/file')?.data?.data || [] 9 | 10 | const onDelete = (fileId: string) => { 11 | mutate('/api/v1/file', { 12 | data: lists.filter((item) => item.fileId !== fileId), 13 | }) 14 | } 15 | 16 | return ( 17 |
18 | {lists.map((item) => ( 19 | 20 | ))} 21 |
22 | ) 23 | } 24 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["dom", "dom.iterable", "esnext"], 4 | "allowJs": true, 5 | "skipLibCheck": true, 6 | "strict": true, 7 | "noEmit": true, 8 | "esModuleInterop": true, 9 | "module": "esnext", 10 | "moduleResolution": "bundler", 11 | "resolveJsonModule": true, 12 | "isolatedModules": true, 13 | "jsx": "preserve", 14 | "incremental": true, 15 | "plugins": [ 16 | { 17 | "name": "next" 18 | } 19 | ], 20 | "paths": { 21 | "@/*": ["./src/*"] 22 | } 23 | }, 24 | "include": [ 25 | "next-env.d.ts", 26 | "**/*.ts", 27 | "**/*.tsx", 28 | ".next/types/**/*.ts", 29 | "src/env.mjs" 30 | ], 31 | "exclude": ["node_modules"] 32 | } 33 | -------------------------------------------------------------------------------- /src/components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as LabelPrimitive from "@radix-ui/react-label" 5 | import { cva, type VariantProps } from "class-variance-authority" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const labelVariants = cva( 10 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" 11 | ) 12 | 13 | const Label = React.forwardRef< 14 | React.ElementRef, 15 | React.ComponentPropsWithoutRef & 16 | VariantProps 17 | >(({ className, ...props }, ref) => ( 18 | 23 | )) 24 | Label.displayName = LabelPrimitive.Root.displayName 25 | 26 | export { Label } 27 | -------------------------------------------------------------------------------- /src/middleware.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server' 2 | import type { NextRequest } from 'next/server' 3 | 4 | import { env } from '@/env.mjs' 5 | 6 | export function middleware(request: NextRequest) { 7 | const pathname = request.url.split('/').at(-1) 8 | 9 | if (env.ACCESS_CODE) { 10 | const accessCode = request.cookies.get('Access-Code') 11 | 12 | if (!accessCode?.value || env.ACCESS_CODE !== accessCode.value) { 13 | if (pathname !== 'login') { 14 | return NextResponse.redirect(new URL('/login', request.url)) 15 | } 16 | } else if (pathname === 'login') { 17 | return NextResponse.redirect(new URL('/', request.url)) 18 | } 19 | } else if (pathname === 'login') { 20 | return NextResponse.redirect(new URL('/', request.url)) 21 | } 22 | } 23 | 24 | export const config = { 25 | matcher: ['/', '/login'], 26 | } 27 | -------------------------------------------------------------------------------- /src/components/ui/progress.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as ProgressPrimitive from "@radix-ui/react-progress" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Progress = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, value, ...props }, ref) => ( 12 | 20 | 24 | 25 | )) 26 | Progress.displayName = ProgressPrimitive.Root.displayName 27 | 28 | export { Progress } 29 | -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Inter } from 'next/font/google' 2 | import type { Metadata } from 'next' 3 | 4 | import 'react-photo-view/dist/react-photo-view.css' 5 | import './globals.css' 6 | 7 | import { Header } from '@/components/modules/header' 8 | import { Toaster } from '@/components/ui/sonner' 9 | 10 | const inter = Inter({ subsets: ['latin'] }) 11 | 12 | export const metadata: Metadata = { 13 | title: 'Backblaze Oss Interface', 14 | description: 'Generated by create next app', 15 | } 16 | 17 | export default function RootLayout({ 18 | children, 19 | }: Readonly<{ 20 | children: React.ReactNode 21 | }>) { 22 | return ( 23 | 24 | 25 |
26 |
27 | {children} 28 |
29 | 30 | 31 | 32 | ) 33 | } 34 | -------------------------------------------------------------------------------- /src/components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | export interface InputProps 6 | extends React.InputHTMLAttributes {} 7 | 8 | const Input = React.forwardRef( 9 | ({ className, type, ...props }, ref) => { 10 | return ( 11 | 20 | ) 21 | } 22 | ) 23 | Input.displayName = "Input" 24 | 25 | export { Input } 26 | -------------------------------------------------------------------------------- /src/lib/redis.ts: -------------------------------------------------------------------------------- 1 | import { env } from '@/env.mjs' 2 | import { Redis, SetCommandOptions } from '@upstash/redis' 3 | 4 | const redis = new Redis({ 5 | url: env.UPSTASH_REDIS_REST_URL || '', 6 | token: env.UPSTASH_REDIS_REST_TOKEN || '', 7 | }) 8 | 9 | export const getRedisValue = async (key: string): Promise => { 10 | if (env.UPSTASH_REDIS_REST_URL && env.UPSTASH_REDIS_REST_TOKEN) { 11 | const value = await redis.get(key) 12 | return (value as string) || '' 13 | } else { 14 | return '' 15 | } 16 | } 17 | 18 | export const setRedisValue = async ( 19 | key: string, 20 | value: string, 21 | options?: SetCommandOptions, 22 | ): Promise => { 23 | if (env.UPSTASH_REDIS_REST_URL && env.UPSTASH_REDIS_REST_TOKEN) { 24 | await redis.set(key, value, options) 25 | } 26 | } 27 | 28 | export const delRedisValue = async (key: string): Promise => { 29 | if (env.UPSTASH_REDIS_REST_URL && env.UPSTASH_REDIS_REST_TOKEN) { 30 | await redis.del(key) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/components/ui/sonner.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useTheme } from "next-themes" 4 | import { Toaster as Sonner } from "sonner" 5 | 6 | type ToasterProps = React.ComponentProps 7 | 8 | const Toaster = ({ ...props }: ToasterProps) => { 9 | const { theme = "system" } = useTheme() 10 | 11 | return ( 12 | 28 | ) 29 | } 30 | 31 | export { Toaster } 32 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "backblaze-cloudflare-oss-interface", 3 | "version": "0.1.2", 4 | "private": false, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@radix-ui/react-alert-dialog": "1.0.5", 13 | "@radix-ui/react-dialog": "1.0.5", 14 | "@radix-ui/react-dropdown-menu": "2.0.6", 15 | "@radix-ui/react-label": "2.0.2", 16 | "@radix-ui/react-progress": "1.0.3", 17 | "@radix-ui/react-slot": "1.0.2", 18 | "@radix-ui/react-tabs": "1.0.4", 19 | "@t3-oss/env-nextjs": "0.9.2", 20 | "@upstash/redis": "1.29.0", 21 | "class-variance-authority": "0.7.0", 22 | "clsx": "2.1.0", 23 | "crypto-js": "4.2.0", 24 | "date-fns": "3.6.0", 25 | "lucide-react": "0.363.0", 26 | "nanoid": "5.0.6", 27 | "next": "14.1.4", 28 | "next-themes": "0.3.0", 29 | "react": "18.2.0", 30 | "react-dom": "18.2.0", 31 | "react-dropzone": "14.2.3", 32 | "react-photo-view": "1.2.4", 33 | "sonner": "1.4.41", 34 | "swr": "2.2.5", 35 | "tailwind-merge": "2.2.2", 36 | "tailwindcss-animate": "1.0.7", 37 | "usehooks-ts": "3.0.2", 38 | "zod": "3.22.4", 39 | "zustand": "4.5.2" 40 | }, 41 | "devDependencies": { 42 | "@egoist/tailwindcss-icons": "1.7.4", 43 | "@iconify-json/mingcute": "1.1.17", 44 | "@innei/prettier": "0.13.0", 45 | "@types/crypto-js": "4.2.2", 46 | "@types/node": "20.11.30", 47 | "@types/react": "18.2.71", 48 | "@types/react-dom": "18.2.22", 49 | "autoprefixer": "10.4.19", 50 | "eslint": "8.57.0", 51 | "eslint-config-next": "14.1.4", 52 | "postcss": "8.4.38", 53 | "prettier": "3.2.5", 54 | "tailwindcss": "3.4.1", 55 | "typescript": "5.4.3" 56 | } 57 | } -------------------------------------------------------------------------------- /src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", 9 | { 10 | variants: { 11 | variant: { 12 | default: "bg-primary text-primary-foreground hover:bg-primary/90", 13 | destructive: 14 | "bg-destructive text-destructive-foreground hover:bg-destructive/90", 15 | outline: 16 | "border border-input bg-background hover:bg-accent hover:text-accent-foreground", 17 | secondary: 18 | "bg-secondary text-secondary-foreground hover:bg-secondary/80", 19 | ghost: "hover:bg-accent hover:text-accent-foreground", 20 | link: "text-primary underline-offset-4 hover:underline", 21 | }, 22 | size: { 23 | default: "h-10 px-4 py-2", 24 | sm: "h-9 rounded-md px-3", 25 | lg: "h-11 rounded-md px-8", 26 | icon: "h-10 w-10", 27 | }, 28 | }, 29 | defaultVariants: { 30 | variant: "default", 31 | size: "default", 32 | }, 33 | } 34 | ) 35 | 36 | export interface ButtonProps 37 | extends React.ButtonHTMLAttributes, 38 | VariantProps { 39 | asChild?: boolean 40 | } 41 | 42 | const Button = React.forwardRef( 43 | ({ className, variant, size, asChild = false, ...props }, ref) => { 44 | const Comp = asChild ? Slot : "button" 45 | return ( 46 | 51 | ) 52 | } 53 | ) 54 | Button.displayName = "Button" 55 | 56 | export { Button, buttonVariants } 57 | -------------------------------------------------------------------------------- /src/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | ::-webkit-scrollbar { 6 | height: 0.4rem; 7 | width: 0.4rem; 8 | } 9 | 10 | ::-webkit-scrollbar-thumb { 11 | background-color: rgba(217, 217, 227, 0.8); 12 | border-color: rgba(255, 255, 255, 1); 13 | border-radius: 9999px; 14 | border-width: 1px; 15 | } 16 | 17 | ::-webkit-scrollbar-track { 18 | background-color: transparent; 19 | border-radius: 9999px; 20 | } 21 | 22 | @layer base { 23 | :root { 24 | --background: 0 0% 100%; 25 | --foreground: 240 10% 3.9%; 26 | --card: 0 0% 100%; 27 | --card-foreground: 240 10% 3.9%; 28 | --popover: 0 0% 100%; 29 | --popover-foreground: 240 10% 3.9%; 30 | --primary: 240 5.9% 10%; 31 | --primary-foreground: 0 0% 98%; 32 | --secondary: 240 4.8% 95.9%; 33 | --secondary-foreground: 240 5.9% 10%; 34 | --muted: 240 4.8% 95.9%; 35 | --muted-foreground: 240 3.8% 46.1%; 36 | --accent: 240 4.8% 95.9%; 37 | --accent-foreground: 240 5.9% 10%; 38 | --destructive: 0 84.2% 60.2%; 39 | --destructive-foreground: 0 0% 98%; 40 | --border: 240 5.9% 90%; 41 | --input: 240 5.9% 90%; 42 | --ring: 240 5.9% 10%; 43 | --radius: 0.75rem; 44 | } 45 | 46 | .dark { 47 | --background: 240 10% 3.9%; 48 | --foreground: 0 0% 98%; 49 | --card: 240 10% 3.9%; 50 | --card-foreground: 0 0% 98%; 51 | --popover: 240 10% 3.9%; 52 | --popover-foreground: 0 0% 98%; 53 | --primary: 0 0% 98%; 54 | --primary-foreground: 240 5.9% 10%; 55 | --secondary: 240 3.7% 15.9%; 56 | --secondary-foreground: 0 0% 98%; 57 | --muted: 240 3.7% 15.9%; 58 | --muted-foreground: 240 5% 64.9%; 59 | --accent: 240 3.7% 15.9%; 60 | --accent-foreground: 0 0% 98%; 61 | --destructive: 0 62.8% 30.6%; 62 | --destructive-foreground: 0 0% 98%; 63 | --border: 240 3.7% 15.9%; 64 | --input: 240 3.7% 15.9%; 65 | --ring: 240 4.9% 83.9%; 66 | } 67 | } 68 | 69 | @layer base { 70 | * { 71 | @apply border-border; 72 | } 73 | body { 74 | @apply bg-background text-foreground; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/components/ui/tabs.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as TabsPrimitive from "@radix-ui/react-tabs" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Tabs = TabsPrimitive.Root 9 | 10 | const TabsList = React.forwardRef< 11 | React.ElementRef, 12 | React.ComponentPropsWithoutRef 13 | >(({ className, ...props }, ref) => ( 14 | 22 | )) 23 | TabsList.displayName = TabsPrimitive.List.displayName 24 | 25 | const TabsTrigger = React.forwardRef< 26 | React.ElementRef, 27 | React.ComponentPropsWithoutRef 28 | >(({ className, ...props }, ref) => ( 29 | 37 | )) 38 | TabsTrigger.displayName = TabsPrimitive.Trigger.displayName 39 | 40 | const TabsContent = React.forwardRef< 41 | React.ElementRef, 42 | React.ComponentPropsWithoutRef 43 | >(({ className, ...props }, ref) => ( 44 | 52 | )) 53 | TabsContent.displayName = TabsPrimitive.Content.displayName 54 | 55 | export { Tabs, TabsList, TabsTrigger, TabsContent } 56 | -------------------------------------------------------------------------------- /src/components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | const Card = React.forwardRef< 6 | HTMLDivElement, 7 | React.HTMLAttributes 8 | >(({ className, ...props }, ref) => ( 9 |
17 | )) 18 | Card.displayName = "Card" 19 | 20 | const CardHeader = React.forwardRef< 21 | HTMLDivElement, 22 | React.HTMLAttributes 23 | >(({ className, ...props }, ref) => ( 24 |
29 | )) 30 | CardHeader.displayName = "CardHeader" 31 | 32 | const CardTitle = React.forwardRef< 33 | HTMLParagraphElement, 34 | React.HTMLAttributes 35 | >(({ className, ...props }, ref) => ( 36 |

44 | )) 45 | CardTitle.displayName = "CardTitle" 46 | 47 | const CardDescription = React.forwardRef< 48 | HTMLParagraphElement, 49 | React.HTMLAttributes 50 | >(({ className, ...props }, ref) => ( 51 |

56 | )) 57 | CardDescription.displayName = "CardDescription" 58 | 59 | const CardContent = React.forwardRef< 60 | HTMLDivElement, 61 | React.HTMLAttributes 62 | >(({ className, ...props }, ref) => ( 63 |

64 | )) 65 | CardContent.displayName = "CardContent" 66 | 67 | const CardFooter = React.forwardRef< 68 | HTMLDivElement, 69 | React.HTMLAttributes 70 | >(({ className, ...props }, ref) => ( 71 |
76 | )) 77 | CardFooter.displayName = "CardFooter" 78 | 79 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } 80 | -------------------------------------------------------------------------------- /src/lib/backblaze.ts: -------------------------------------------------------------------------------- 1 | import { env } from '@/env.mjs' 2 | import { getRedisValue, setRedisValue } from '@/lib/redis' 3 | 4 | /** 5 | * An authorization token to use with all calls, other than b2_authorize_account, that need an Authorization header. 6 | * This authorization token is valid for at most 24 hours. 7 | * 8 | * If Redis is configured, the token will be stored in Redis and saved for 24 hours; otherwise, the token will be fetched each time. 9 | */ 10 | export async function b2_authorize_account(): Promise { 11 | try { 12 | const accountInfo = await getRedisValue('accountInfo') 13 | if (accountInfo) return accountInfo.split(',') 14 | 15 | const res = await fetch( 16 | 'https://api.backblazeb2.com/b2api/v3/b2_authorize_account', 17 | { 18 | headers: { 19 | Authorization: 20 | 'Basic ' + 21 | Buffer.from(`${env.APP_KEY_ID}:${env.APP_KEY}`).toString('base64'), 22 | }, 23 | }, 24 | ).then((res) => res.json()) 25 | if (res.authorizationToken && res.apiInfo.storageApi.apiUrl) { 26 | await setRedisValue( 27 | 'accountInfo', 28 | `${res.authorizationToken},${res.apiInfo.storageApi.apiUrl}`, 29 | { ex: 3600 }, 30 | ) 31 | return [res.authorizationToken, res.apiInfo.storageApi.apiUrl] 32 | } 33 | 34 | throw new Error('Failed to authorize account') 35 | } catch (error) { 36 | console.log(error, 'b2_authorize_account error') 37 | throw new Error('Failed to authorize account') 38 | } 39 | } 40 | 41 | export async function b2_get_upload_url(): Promise { 42 | try { 43 | const [authorizationToken, apiUrl] = await b2_authorize_account() 44 | 45 | const uploadInfo = await getRedisValue('uploadInfo') 46 | if (uploadInfo) return uploadInfo.split(',') 47 | 48 | const res = await fetch( 49 | `${apiUrl}/b2api/v3/b2_get_upload_url?bucketId=${env.BUCKET_ID}`, 50 | { 51 | headers: { 52 | Authorization: authorizationToken, 53 | }, 54 | }, 55 | ).then((res) => res.json()) 56 | 57 | if (res.authorizationToken && res.uploadUrl) { 58 | await setRedisValue( 59 | 'uploadInfo', 60 | `${res.authorizationToken},${res.uploadUrl}`, 61 | { ex: 3600 }, 62 | ) 63 | return [res.authorizationToken, res.uploadUrl] 64 | } 65 | 66 | throw new Error('Failed to get upload url') 67 | } catch (error) { 68 | console.log(error, 'b2_get_upload_url error') 69 | throw new Error('Failed to get upload url') 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'tailwindcss' 2 | 3 | import { getIconCollections, iconsPlugin } from '@egoist/tailwindcss-icons' 4 | 5 | const config = { 6 | darkMode: ['class'], 7 | content: [ 8 | './pages/**/*.{ts,tsx}', 9 | './components/**/*.{ts,tsx}', 10 | './app/**/*.{ts,tsx}', 11 | './src/**/*.{ts,tsx}', 12 | ], 13 | prefix: '', 14 | theme: { 15 | container: { 16 | center: true, 17 | padding: '2rem', 18 | screens: { 19 | '2xl': '1400px', 20 | }, 21 | }, 22 | extend: { 23 | colors: { 24 | border: 'hsl(var(--border))', 25 | input: 'hsl(var(--input))', 26 | ring: 'hsl(var(--ring))', 27 | background: 'hsl(var(--background))', 28 | foreground: 'hsl(var(--foreground))', 29 | primary: { 30 | DEFAULT: 'hsl(var(--primary))', 31 | foreground: 'hsl(var(--primary-foreground))', 32 | }, 33 | secondary: { 34 | DEFAULT: 'hsl(var(--secondary))', 35 | foreground: 'hsl(var(--secondary-foreground))', 36 | }, 37 | destructive: { 38 | DEFAULT: 'hsl(var(--destructive))', 39 | foreground: 'hsl(var(--destructive-foreground))', 40 | }, 41 | muted: { 42 | DEFAULT: 'hsl(var(--muted))', 43 | foreground: 'hsl(var(--muted-foreground))', 44 | }, 45 | accent: { 46 | DEFAULT: 'hsl(var(--accent))', 47 | foreground: 'hsl(var(--accent-foreground))', 48 | }, 49 | popover: { 50 | DEFAULT: 'hsl(var(--popover))', 51 | foreground: 'hsl(var(--popover-foreground))', 52 | }, 53 | card: { 54 | DEFAULT: 'hsl(var(--card))', 55 | foreground: 'hsl(var(--card-foreground))', 56 | }, 57 | }, 58 | borderRadius: { 59 | lg: 'var(--radius)', 60 | md: 'calc(var(--radius) - 2px)', 61 | sm: 'calc(var(--radius) - 4px)', 62 | }, 63 | keyframes: { 64 | 'accordion-down': { 65 | from: { height: '0' }, 66 | to: { height: 'var(--radix-accordion-content-height)' }, 67 | }, 68 | 'accordion-up': { 69 | from: { height: 'var(--radix-accordion-content-height)' }, 70 | to: { height: '0' }, 71 | }, 72 | }, 73 | animation: { 74 | 'accordion-down': 'accordion-down 0.2s ease-out', 75 | 'accordion-up': 'accordion-up 0.2s ease-out', 76 | }, 77 | }, 78 | }, 79 | plugins: [ 80 | require('tailwindcss-animate'), 81 | iconsPlugin({ 82 | collections: getIconCollections(['mingcute']), 83 | }), 84 | ], 85 | } satisfies Config 86 | 87 | export default config 88 | -------------------------------------------------------------------------------- /src/app/login/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { Loader2 } from 'lucide-react' 4 | import { useRef, useState } from 'react' 5 | import { useRouter } from 'next/navigation' 6 | import { toast } from 'sonner' 7 | 8 | import { setAccessCode } from '@/actions' 9 | import { Button } from '@/components/ui/button' 10 | import { 11 | Card, 12 | CardContent, 13 | CardDescription, 14 | CardFooter, 15 | CardHeader, 16 | CardTitle, 17 | } from '@/components/ui/card' 18 | import { Input } from '@/components/ui/input' 19 | import { Label } from '@/components/ui/label' 20 | 21 | export default function Login() { 22 | const codeRef = useRef(null) 23 | 24 | const [loading, setLoading] = useState(false) 25 | const [value, setValue] = useState('') 26 | 27 | const router = useRouter() 28 | 29 | const onLogin = async () => { 30 | if (!value?.trim()) { 31 | codeRef.current?.focus() 32 | return toast.error('Code is required') 33 | } 34 | 35 | try { 36 | setLoading(true) 37 | const res = await setAccessCode(value?.trim()) 38 | if (!res) { 39 | toast.error('Auth code is invalid') 40 | } else { 41 | router.push('/') 42 | } 43 | } catch (error) { 44 | } finally { 45 | setLoading(false) 46 | } 47 | } 48 | 49 | return ( 50 |
51 | 52 | 53 | Auth Code 54 | 55 | Enter auth code to access the system 56 | 57 | 58 | 59 |
60 | 61 | setValue(e.target.value)} 69 | onKeyDown={(event) => { 70 | if ( 71 | (event.keyCode === 13 || event.keyCode === 10) && 72 | !event.shiftKey 73 | ) { 74 | event.preventDefault() 75 | onLogin() 76 | } 77 | }} 78 | /> 79 |
80 |
81 | 82 | 86 | 87 |
88 |
89 | ) 90 | } 91 | -------------------------------------------------------------------------------- /src/app/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useEffect, useLayoutEffect, useState } from 'react' 4 | import { PhotoProvider } from 'react-photo-view' 5 | import { toast } from 'sonner' 6 | import useSWR from 'swr' 7 | 8 | import { GridData } from '@/components/modules/content/grid' 9 | import { TableData } from '@/components/modules/content/table' 10 | import { Card, CardContent, CardHeader } from '@/components/ui/card' 11 | import { Skeleton } from '@/components/ui/skeleton' 12 | import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs' 13 | 14 | const fetcher = async (url: string) => fetch(url).then((r) => r.json()) 15 | 16 | export default function Home() { 17 | const { data, error, isLoading } = useSWR('/api/v1/file', fetcher) 18 | 19 | const [type, setType] = useState('grid') 20 | 21 | useLayoutEffect(() => { 22 | const localType = localStorage.getItem('display-mode') 23 | if (localType && ['table', 'grid'].includes(localType)) { 24 | setType(localType) 25 | } 26 | }, []) 27 | 28 | useEffect(() => { 29 | if (data?.code) toast.error(data?.msg) 30 | }, [data]) 31 | 32 | return ( 33 |
34 | 35 | 36 | 37 |
38 |

39 | Files 40 |

41 |
42 | { 45 | localStorage.setItem('display-mode', value) 46 | setType(value) 47 | }} 48 | > 49 | 50 | 51 | 52 | Grid 53 | 54 | 55 | 56 | Table 57 | 58 | 59 | 60 |
61 |
62 |
63 | 64 | {isLoading ? ( 65 |
66 | 67 |
68 | 69 | 70 |
71 |
72 | ) : error ? ( 73 |
Error
74 | ) : ( 75 | <>{type === 'grid' ? : } 76 | )} 77 |
78 |
79 |
80 |
81 | ) 82 | } 83 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Backblaze Cloudflare OSS Interface 2 | 3 | Inspired by [Backblaze + Cloudflare搭建个人OSS](https://leezhian.com/faq/other/bb-cf-oss), this project aims to provide a simple interface to manage files in Backblaze B2 using Cloudflare Workers. 4 | 5 | ![cover](./public/cover.png) 6 | 7 | ## Features 8 | 9 | - **Deploy for free with one-click** on Vercel 10 | - Quickly view Backblaze resources 11 | - Support upload and delete files 12 | 13 | ## Roadmap 14 | 15 | - [x] Basic permission verification (to avoid direct viewing of all documents) 16 | - [ ] Batch delete 17 | - [ ] Pagination of Data 18 | 19 | ## Before You Start 20 | 21 | Please carefully read "[Backblaze + Cloudflare搭建个人OSS](https://leezhian.com/faq/other/bb-cf-oss)". Follow the instructions to complete: 22 | 23 | - Register for a Backblaze account 24 | - Set up Cloudflare with domain name resolution 25 | 26 | ## Getting Started 27 | 28 | [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/Peek-A-Booo/Backblaze-Cloudflare-OSS-Interface&env=APP_KEY_ID&env=APP_KEY&env=BUCKET_ID&env=NEXT_PUBLIC_HOSTNAME&env=UPSTASH_REDIS_REST_URL&env=UPSTASH_REDIS_REST_TOKEN&env=ACCESS_CODE) 29 | 30 | ### Environment Variables 31 | 32 | #### `APP_KEY_ID` (required) 33 | 34 | This allows B2 to communicate securely with different devices or apps. 35 | 36 | - Step1: Add a New Application Key 37 | 38 | ![step1](./public/step1.jpg) 39 | 40 | - Step2: Name your key and choose a bucket 41 | 42 | > **Type of Access: Read and Write** 43 | 44 | ![step2](./public/step2.png) 45 | 46 | - Step3: Then you can get your `APP_KEY_ID` and `APP_KEY` 47 | 48 | `keyID` ===> `APP_KEY_ID` 49 | 50 | `applicationKey` ===> `APP_KEY` 51 | 52 | ![step3](./public/step3.png) 53 | 54 | #### `APP_KEY` (required) 55 | 56 | Look up ⬆️ 57 | 58 | #### `BUCKET_ID` (required) 59 | 60 | Create a bucket in Backblaze B2. 61 | 62 | - Step1: Create a bucket 63 | 64 | ![step4](./public/step4.png) 65 | 66 | - Step2: Name your bucket 67 | 68 | > **`Files in Bucket`** select **`Public`** 69 | 70 | ![step5](./public/step5.png) 71 | 72 | - Step3: `Bucket ID` ===> `BUCKET_ID` 73 | 74 | ![step6](./public/step6.png) 75 | 76 | #### `NEXT_PUBLIC_HOSTNAME` (required) 77 | 78 | Please provide the domain name you configured in the Transform Rules on Cloudflare. 79 | 80 | ![step7](./public/step7.png) 81 | 82 | #### `UPSTASH_REDIS_REST_URL` (optional) 83 | 84 | Configuring Redis can reduce the consumption of [Transactions Class C calls](https://www.backblaze.com/cloud-storage/transaction-pricing). 85 | 86 | Reference Document: [Upstash Redis](https://upstash.com/docs/redis/overall/getstarted) 87 | 88 | After completing the creation, you will receive the following information: 89 | 90 | `url` ===> `UPSTASH_REDIS_REST_URL` 91 | 92 | `token` ===> `UPSTASH_REDIS_REST_TOKEN` 93 | 94 | ![step8](./public/step8.png) 95 | 96 | #### `UPSTASH_REDIS_REST_TOKEN` (optional) 97 | 98 | Look up ⬆️ 99 | 100 | #### `ACCESS_CODE` (optional) 101 | 102 | If configured, validation must be completed before entering the system. 103 | 104 | ### Enjoy 105 | -------------------------------------------------------------------------------- /src/components/ui/table.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | import { cn } from '@/lib/utils' 4 | 5 | const Table = React.forwardRef< 6 | HTMLTableElement, 7 | React.HTMLAttributes 8 | >(({ className, ...props }, ref) => ( 9 | 14 | )) 15 | Table.displayName = 'Table' 16 | 17 | const TableHeader = React.forwardRef< 18 | HTMLTableSectionElement, 19 | React.HTMLAttributes 20 | >(({ className, ...props }, ref) => ( 21 | 22 | )) 23 | TableHeader.displayName = 'TableHeader' 24 | 25 | const TableBody = React.forwardRef< 26 | HTMLTableSectionElement, 27 | React.HTMLAttributes 28 | >(({ className, ...props }, ref) => ( 29 | 34 | )) 35 | TableBody.displayName = 'TableBody' 36 | 37 | const TableFooter = React.forwardRef< 38 | HTMLTableSectionElement, 39 | React.HTMLAttributes 40 | >(({ className, ...props }, ref) => ( 41 | tr]:last:border-b-0', 45 | className, 46 | )} 47 | {...props} 48 | /> 49 | )) 50 | TableFooter.displayName = 'TableFooter' 51 | 52 | const TableRow = React.forwardRef< 53 | HTMLTableRowElement, 54 | React.HTMLAttributes 55 | >(({ className, ...props }, ref) => ( 56 | 64 | )) 65 | TableRow.displayName = 'TableRow' 66 | 67 | const TableHead = React.forwardRef< 68 | HTMLTableCellElement, 69 | React.ThHTMLAttributes 70 | >(({ className, ...props }, ref) => ( 71 |
79 | )) 80 | TableHead.displayName = 'TableHead' 81 | 82 | const TableCell = React.forwardRef< 83 | HTMLTableCellElement, 84 | React.TdHTMLAttributes 85 | >(({ className, ...props }, ref) => ( 86 | 91 | )) 92 | TableCell.displayName = 'TableCell' 93 | 94 | const TableCaption = React.forwardRef< 95 | HTMLTableCaptionElement, 96 | React.HTMLAttributes 97 | >(({ className, ...props }, ref) => ( 98 |
103 | )) 104 | TableCaption.displayName = 'TableCaption' 105 | 106 | export { 107 | Table, 108 | TableHeader, 109 | TableBody, 110 | TableFooter, 111 | TableHead, 112 | TableRow, 113 | TableCell, 114 | TableCaption, 115 | } 116 | -------------------------------------------------------------------------------- /src/components/ui/dialog.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import * as DialogPrimitive from '@radix-ui/react-dialog' 4 | import { X } from 'lucide-react' 5 | import * as React from 'react' 6 | 7 | import { cn } from '@/lib/utils' 8 | 9 | const Dialog = DialogPrimitive.Root 10 | 11 | const DialogTrigger = DialogPrimitive.Trigger 12 | 13 | const DialogPortal = DialogPrimitive.Portal 14 | 15 | const DialogClose = DialogPrimitive.Close 16 | 17 | const DialogOverlay = React.forwardRef< 18 | React.ElementRef, 19 | React.ComponentPropsWithoutRef 20 | >(({ className, ...props }, ref) => ( 21 | 29 | )) 30 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName 31 | 32 | const DialogContent = React.forwardRef< 33 | React.ElementRef, 34 | React.ComponentPropsWithoutRef 35 | >(({ className, children, ...props }, ref) => ( 36 | 37 | 38 | 46 | {children} 47 | 48 | 49 | Close 50 | 51 | 52 | 53 | )) 54 | DialogContent.displayName = DialogPrimitive.Content.displayName 55 | 56 | const DialogHeader = ({ 57 | className, 58 | ...props 59 | }: React.HTMLAttributes) => ( 60 |
67 | ) 68 | DialogHeader.displayName = 'DialogHeader' 69 | 70 | const DialogFooter = ({ 71 | className, 72 | ...props 73 | }: React.HTMLAttributes) => ( 74 |
81 | ) 82 | DialogFooter.displayName = 'DialogFooter' 83 | 84 | const DialogTitle = React.forwardRef< 85 | React.ElementRef, 86 | React.ComponentPropsWithoutRef 87 | >(({ className, ...props }, ref) => ( 88 | 96 | )) 97 | DialogTitle.displayName = DialogPrimitive.Title.displayName 98 | 99 | const DialogDescription = React.forwardRef< 100 | React.ElementRef, 101 | React.ComponentPropsWithoutRef 102 | >(({ className, ...props }, ref) => ( 103 | 108 | )) 109 | DialogDescription.displayName = DialogPrimitive.Description.displayName 110 | 111 | export { 112 | Dialog, 113 | DialogPortal, 114 | DialogOverlay, 115 | DialogClose, 116 | DialogTrigger, 117 | DialogContent, 118 | DialogHeader, 119 | DialogFooter, 120 | DialogTitle, 121 | DialogDescription, 122 | } 123 | -------------------------------------------------------------------------------- /src/components/modules/imageItem/index.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { Loader2 } from 'lucide-react' 4 | import { useState } from 'react' 5 | import { PhotoView } from 'react-photo-view' 6 | import { formatDistance } from 'date-fns' 7 | import Image from 'next/image' 8 | import { toast } from 'sonner' 9 | 10 | import { 11 | AlertDialog, 12 | AlertDialogAction, 13 | AlertDialogCancel, 14 | AlertDialogContent, 15 | AlertDialogDescription, 16 | AlertDialogFooter, 17 | AlertDialogHeader, 18 | AlertDialogTitle, 19 | AlertDialogTrigger, 20 | } from '@/components/ui/alert-dialog' 21 | import { Button } from '@/components/ui/button' 22 | import { env } from '@/env.mjs' 23 | import { useCopy } from '@/hooks/useCopy' 24 | import { formatSize } from '@/lib/format' 25 | 26 | export function ImageItem({ 27 | item, 28 | onDelete, 29 | }: { 30 | item: any 31 | onDelete: (fileId: string) => void 32 | }) { 33 | const [isCopied, copy] = useCopy() 34 | const [loading, setLoading] = useState(false) 35 | 36 | const handleDelete = () => { 37 | setLoading(true) 38 | fetch('/api/v1/file', { 39 | method: 'DELETE', 40 | body: JSON.stringify({ fileId: item.fileId, fileName: item.fileName }), 41 | }) 42 | .then((res) => res.json()) 43 | .then((res) => { 44 | if (!res.code) { 45 | toast.success('Deleted successfully') 46 | onDelete(res.data.fileId) 47 | } else { 48 | toast.error(res.msg || 'Delete failed') 49 | } 50 | }) 51 | .finally(() => { 52 | setLoading(false) 53 | }) 54 | } 55 | 56 | return ( 57 |
58 |
59 | 60 | image 67 | 68 |
69 |
70 |
{item.fileName}
71 |
72 |
73 | {formatSize(item.contentLength)} 74 |
75 |
76 | {formatDistance(new Date(item.uploadTimestamp), new Date(), { 77 | addSuffix: true, 78 | })} 79 |
80 |
81 |
82 | 96 | 97 | 98 | 101 | 102 | 103 | 104 | Are you sure to delete? 105 | 106 | This action cannot be undone. It will permanently delete your 107 | data. 108 | 109 | 110 | 111 | Cancel 112 | 117 | {loading && } 118 | Continue 119 | 120 | 121 | 122 | 123 |
124 |
125 |
126 | ) 127 | } 128 | -------------------------------------------------------------------------------- /src/components/ui/alert-dialog.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog' 4 | import * as React from 'react' 5 | import type { ButtonProps } from '@/components/ui/button' 6 | 7 | import { buttonVariants } from '@/components/ui/button' 8 | import { cn } from '@/lib/utils' 9 | 10 | const AlertDialog = AlertDialogPrimitive.Root 11 | 12 | const AlertDialogTrigger = AlertDialogPrimitive.Trigger 13 | 14 | const AlertDialogPortal = AlertDialogPrimitive.Portal 15 | 16 | const AlertDialogOverlay = React.forwardRef< 17 | React.ElementRef, 18 | React.ComponentPropsWithoutRef 19 | >(({ className, ...props }, ref) => ( 20 | 28 | )) 29 | AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName 30 | 31 | const AlertDialogContent = React.forwardRef< 32 | React.ElementRef, 33 | React.ComponentPropsWithoutRef 34 | >(({ className, ...props }, ref) => ( 35 | 36 | 37 | 45 | 46 | )) 47 | AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName 48 | 49 | const AlertDialogHeader = ({ 50 | className, 51 | ...props 52 | }: React.HTMLAttributes) => ( 53 |
60 | ) 61 | AlertDialogHeader.displayName = 'AlertDialogHeader' 62 | 63 | const AlertDialogFooter = ({ 64 | className, 65 | ...props 66 | }: React.HTMLAttributes) => ( 67 |
74 | ) 75 | AlertDialogFooter.displayName = 'AlertDialogFooter' 76 | 77 | const AlertDialogTitle = React.forwardRef< 78 | React.ElementRef, 79 | React.ComponentPropsWithoutRef 80 | >(({ className, ...props }, ref) => ( 81 | 86 | )) 87 | AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName 88 | 89 | const AlertDialogDescription = React.forwardRef< 90 | React.ElementRef, 91 | React.ComponentPropsWithoutRef 92 | >(({ className, ...props }, ref) => ( 93 | 98 | )) 99 | AlertDialogDescription.displayName = 100 | AlertDialogPrimitive.Description.displayName 101 | 102 | const AlertDialogAction = React.forwardRef< 103 | React.ElementRef, 104 | React.ComponentPropsWithoutRef & 105 | ButtonProps 106 | >(({ className, variant = 'default', ...props }, ref) => ( 107 | 112 | )) 113 | AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName 114 | 115 | const AlertDialogCancel = React.forwardRef< 116 | React.ElementRef, 117 | React.ComponentPropsWithoutRef 118 | >(({ className, ...props }, ref) => ( 119 | 128 | )) 129 | AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName 130 | 131 | export { 132 | AlertDialog, 133 | AlertDialogPortal, 134 | AlertDialogOverlay, 135 | AlertDialogTrigger, 136 | AlertDialogContent, 137 | AlertDialogHeader, 138 | AlertDialogFooter, 139 | AlertDialogTitle, 140 | AlertDialogDescription, 141 | AlertDialogAction, 142 | AlertDialogCancel, 143 | } 144 | -------------------------------------------------------------------------------- /src/app/api/v1/file/route.ts: -------------------------------------------------------------------------------- 1 | import CryptoJS from 'crypto-js' 2 | import { nanoid } from 'nanoid' 3 | import { cookies } from 'next/headers' 4 | import { NextResponse } from 'next/server' 5 | 6 | import { env } from '@/env.mjs' 7 | import { b2_authorize_account, b2_get_upload_url } from '@/lib/backblaze' 8 | import { delRedisValue } from '@/lib/redis' 9 | import { CORS_HEADERS } from '@/lib/response' 10 | 11 | export const runtime = 'edge' 12 | 13 | export async function OPTIONS() { 14 | return new Response(null, { headers: CORS_HEADERS }) 15 | } 16 | 17 | export async function GET() { 18 | try { 19 | if (env.ACCESS_CODE) { 20 | const cookieStore = cookies() 21 | const accessCode = cookieStore.get('Access-Code')?.value 22 | 23 | if (env.ACCESS_CODE !== accessCode) { 24 | return NextResponse.json({ 25 | code: -1, 26 | msg: 'Access code is invalid', 27 | }) 28 | } 29 | } 30 | 31 | const [authorizationToken, apiUrl] = await b2_authorize_account() 32 | 33 | const res = await fetch( 34 | `${apiUrl}/b2api/v3/b2_list_file_names?maxFileCount=1000&startFileName=/&bucketId=${env.BUCKET_ID}`, 35 | { 36 | headers: { 37 | Authorization: authorizationToken, 38 | }, 39 | }, 40 | ).then((res) => res.json()) 41 | 42 | if (!res.files) { 43 | return NextResponse.json({ 44 | code: -1, 45 | msg: res.code || 'Internal server error', 46 | }) 47 | } 48 | 49 | return NextResponse.json({ 50 | code: 0, 51 | data: res.files.sort( 52 | (a: any, b: any) => b.uploadTimestamp - a.uploadTimestamp, 53 | ), 54 | }) 55 | } catch (error: any) { 56 | console.log(error.message, 'get error') 57 | await delRedisValue('accountInfo') 58 | return NextResponse.json({ 59 | code: -1, 60 | msg: error.message || 'Internal server error', 61 | }) 62 | } 63 | } 64 | 65 | export async function POST(request: Request) { 66 | try { 67 | const formData = await request.formData() 68 | const file = formData.get('file') as File 69 | const fileName = `${nanoid(64)}.${file.name.split('.').at(-1)}` 70 | const fileBuffer = await file.arrayBuffer() 71 | 72 | const [uploadToken, uploadUrl] = await b2_get_upload_url() 73 | 74 | const res = await fetch(uploadUrl, { 75 | headers: { 76 | Authorization: uploadToken, 77 | 'X-Bz-File-Name': fileName, 78 | 'Content-Type': 'b2/x-auto', 79 | 'Content-Length': Buffer.from(fileBuffer).byteLength.toString(), 80 | 'X-Bz-Content-Sha1': CryptoJS.SHA1( 81 | CryptoJS.lib.WordArray.create(fileBuffer), 82 | ).toString(CryptoJS.enc.Hex), 83 | }, 84 | method: 'POST', 85 | body: Buffer.from(fileBuffer), 86 | }).then((res) => res.json()) 87 | 88 | if (res.fileId) { 89 | return NextResponse.json( 90 | { code: 0, data: res }, 91 | { 92 | headers: CORS_HEADERS, 93 | }, 94 | ) 95 | } 96 | 97 | if (res.code === 'bad_auth_token') { 98 | await delRedisValue('accountInfo') 99 | await delRedisValue('uploadInfo') 100 | } 101 | 102 | return NextResponse.json( 103 | { 104 | code: -1, 105 | msg: res.code || 'Internal server error', 106 | }, 107 | { 108 | headers: CORS_HEADERS, 109 | }, 110 | ) 111 | } catch (error: any) { 112 | console.log(error.message, 'upload error') 113 | return NextResponse.json( 114 | { 115 | code: -1, 116 | msg: error.message || 'Internal server error', 117 | }, 118 | { 119 | headers: CORS_HEADERS, 120 | }, 121 | ) 122 | } 123 | } 124 | 125 | export async function DELETE(request: Request) { 126 | try { 127 | if (env.ACCESS_CODE) { 128 | const cookieStore = cookies() 129 | const accessCode = cookieStore.get('Access-Code')?.value 130 | 131 | if (env.ACCESS_CODE !== accessCode) { 132 | return NextResponse.json({ 133 | code: -1, 134 | msg: 'Access code is invalid', 135 | }) 136 | } 137 | } 138 | 139 | const { fileId, fileName } = await request.json() 140 | 141 | const [authorizationToken, apiUrl] = await b2_authorize_account() 142 | 143 | const res = await fetch(`${apiUrl}/b2api/v3/b2_delete_file_version`, { 144 | headers: { 145 | Authorization: authorizationToken, 146 | }, 147 | method: 'POST', 148 | body: JSON.stringify({ fileId, fileName }), 149 | }).then((res) => res.json()) 150 | 151 | if (res.fileId) { 152 | return NextResponse.json( 153 | { code: 0, data: res }, 154 | { 155 | headers: CORS_HEADERS, 156 | }, 157 | ) 158 | } 159 | 160 | if (res.code === 'bad_auth_token') { 161 | await delRedisValue('accountInfo') 162 | } 163 | 164 | return NextResponse.json( 165 | { 166 | code: -1, 167 | msg: res.code || 'Internal server error', 168 | }, 169 | { 170 | headers: CORS_HEADERS, 171 | }, 172 | ) 173 | } catch (error: any) { 174 | console.log(error.message, 'delete error') 175 | return NextResponse.json( 176 | { 177 | code: -1, 178 | msg: error.message || 'Internal server error', 179 | }, 180 | { 181 | headers: CORS_HEADERS, 182 | }, 183 | ) 184 | } 185 | } 186 | -------------------------------------------------------------------------------- /src/components/modules/header/index.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { Loader2 } from 'lucide-react' 4 | import { useEffect, useState } from 'react' 5 | import { useDropzone } from 'react-dropzone' 6 | import Link from 'next/link' 7 | import { toast } from 'sonner' 8 | import { useSWRConfig } from 'swr' 9 | 10 | import { Button } from '@/components/ui/button' 11 | import { 12 | Dialog, 13 | DialogContent, 14 | DialogHeader, 15 | DialogTitle, 16 | DialogTrigger, 17 | } from '@/components/ui/dialog' 18 | import { Progress } from '@/components/ui/progress' 19 | import { cn } from '@/lib/utils' 20 | 21 | const maxFiles = 5 22 | 23 | export function Header() { 24 | const { cache, mutate } = useSWRConfig() 25 | const [loadingUpload, setLoadingUpload] = useState(false) 26 | const [uploadFiles, setUploadFiles] = useState([]) 27 | const [uploadProcess, setUploadProcess] = useState(0) 28 | const [open, setOpen] = useState(false) 29 | 30 | let { acceptedFiles, fileRejections, getRootProps, getInputProps } = 31 | useDropzone({ 32 | maxFiles, 33 | disabled: loadingUpload, 34 | }) 35 | 36 | const onOpenChange = (isOpen: boolean) => { 37 | if (cache.get('/api/v1/file')?.isLoading) { 38 | return toast.warning('Loading data...') 39 | } 40 | 41 | if (open) { 42 | acceptedFiles = [] 43 | setUploadFiles([]) 44 | } 45 | setOpen(isOpen) 46 | } 47 | 48 | useEffect(() => { 49 | setUploadFiles(acceptedFiles) 50 | }, [acceptedFiles]) 51 | 52 | useEffect(() => { 53 | if (fileRejections.length) { 54 | toast.error('Somthing went wrong!') 55 | } 56 | }, [fileRejections]) 57 | 58 | const onUpload = async () => { 59 | try { 60 | setUploadProcess(0) 61 | setLoadingUpload(true) 62 | 63 | for (const file of uploadFiles) { 64 | const formData = new FormData() 65 | formData.append('file', file) 66 | const res = await fetch('/api/v1/file', { 67 | method: 'POST', 68 | body: formData, 69 | }).then((res) => res.json()) 70 | 71 | if (!res.code) { 72 | setUploadProcess((prev) => { 73 | if (prev + 1 === uploadFiles.length) { 74 | acceptedFiles = [] 75 | setUploadFiles([]) 76 | toast.success('Upload success!') 77 | } 78 | return prev + 1 79 | }) 80 | // addList(res.data) 81 | mutate('/api/v1/file', { 82 | data: [...cache.get('/api/v1/file')?.data.data, res.data], 83 | }) 84 | } else { 85 | toast.error(res.msg || 'Upload failed') 86 | } 87 | } 88 | } catch (error) { 89 | } finally { 90 | setLoadingUpload(false) 91 | } 92 | } 93 | 94 | return ( 95 |
96 |
97 | Backblaze Interface 98 | 99 | 100 | 109 | 110 | 111 | 112 | 113 | Upload Files (max {maxFiles}) 114 | 115 |
119 | 120 |

125 | Drag drop some files here, or click to select files 126 |

127 |
128 |
129 |
130 | {uploadFiles.map((file, index) => ( 131 |
132 |
133 | 134 |
{file.name}
135 |
136 | {!loadingUpload && ( 137 | { 140 | acceptedFiles.splice(index, 1) 141 | setUploadFiles( 142 | uploadFiles.filter((item) => item !== file), 143 | ) 144 | }} 145 | /> 146 | )} 147 |
148 | ))} 149 |
150 |
151 | 160 | {loadingUpload && ( 161 | <> 162 |
163 | {uploadProcess}/{uploadFiles.length} 164 |
165 | 168 | 169 | )} 170 |
171 |
172 |
173 |
174 |
175 | ) 176 | } 177 | -------------------------------------------------------------------------------- /src/components/modules/content/table/index.tsx: -------------------------------------------------------------------------------- 1 | import { Loader2 } from 'lucide-react' 2 | import { useState } from 'react' 3 | import { PhotoView } from 'react-photo-view' 4 | import { formatDistance } from 'date-fns' 5 | import { toast } from 'sonner' 6 | import { useSWRConfig } from 'swr' 7 | 8 | import { 9 | AlertDialog, 10 | AlertDialogAction, 11 | AlertDialogCancel, 12 | AlertDialogContent, 13 | AlertDialogDescription, 14 | AlertDialogFooter, 15 | AlertDialogHeader, 16 | AlertDialogTitle, 17 | AlertDialogTrigger, 18 | } from '@/components/ui/alert-dialog' 19 | import { Button } from '@/components/ui/button' 20 | import { 21 | DropdownMenu, 22 | DropdownMenuContent, 23 | DropdownMenuGroup, 24 | DropdownMenuItem, 25 | DropdownMenuTrigger, 26 | } from '@/components/ui/dropdown-menu' 27 | import { 28 | Table, 29 | TableBody, 30 | TableCell, 31 | TableHead, 32 | TableHeader, 33 | TableRow, 34 | } from '@/components/ui/table' 35 | import { env } from '@/env.mjs' 36 | import { useCopy } from '@/hooks/useCopy' 37 | import { formatSize } from '@/lib/format' 38 | 39 | export function TableData() { 40 | const { cache, mutate } = useSWRConfig() 41 | const [loading, setLoading] = useState(false) 42 | const [, copy] = useCopy() 43 | 44 | const lists: any[] = cache.get('/api/v1/file')?.data?.data || [] 45 | 46 | const handleDelete = (item: any) => { 47 | setLoading(true) 48 | fetch('/api/v1/file', { 49 | method: 'DELETE', 50 | body: JSON.stringify({ fileId: item.fileId, fileName: item.fileName }), 51 | }) 52 | .then((res) => res.json()) 53 | .then((res) => { 54 | if (!res.code) { 55 | toast.success('Deleted successfully') 56 | mutate('/api/v1/file', { 57 | data: lists.filter((list) => list.fileId !== item.fileId), 58 | }) 59 | } else { 60 | toast.error(res.msg || 'Delete failed') 61 | } 62 | }) 63 | .finally(() => { 64 | setLoading(false) 65 | }) 66 | } 67 | 68 | return ( 69 |
70 | 71 | 72 | 73 | Name 74 | Size 75 | Uploaded 76 | Actions 77 | 78 | 79 | 80 | {lists.map((item) => ( 81 | 82 | 83 |
84 | 87 | 88 | {item.fileName} 89 | 90 | 91 |
92 |
93 | {formatSize(item.contentLength)} 94 | 95 | {formatDistance(new Date(item.uploadTimestamp), new Date(), { 96 | addSuffix: true, 97 | })} 98 | 99 | 100 | 101 | 102 | 105 | 106 | 107 | 108 | { 110 | copy( 111 | `https://${env.NEXT_PUBLIC_HOSTNAME}/${item.fileName}`, 112 | ) 113 | toast.success('Copied successfully') 114 | }} 115 | > 116 | 117 | Copy Url 118 | 119 | 120 | 121 | 122 | 123 | 124 | 127 | 128 | 129 | 130 | 131 | Are you sure to delete? 132 | 133 | 134 | This action cannot be undone. It will permanently delete 135 | your data. 136 | 137 | 138 | 139 | Cancel 140 | handleDelete(item)} 143 | disabled={loading} 144 | > 145 | {loading && ( 146 | 147 | )} 148 | Continue 149 | 150 | 151 | 152 | 153 | 154 |
155 | ))} 156 |
157 |
158 |
159 | ) 160 | } 161 | -------------------------------------------------------------------------------- /src/components/ui/dropdown-menu.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" 5 | import { Check, ChevronRight, Circle } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const DropdownMenu = DropdownMenuPrimitive.Root 10 | 11 | const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger 12 | 13 | const DropdownMenuGroup = DropdownMenuPrimitive.Group 14 | 15 | const DropdownMenuPortal = DropdownMenuPrimitive.Portal 16 | 17 | const DropdownMenuSub = DropdownMenuPrimitive.Sub 18 | 19 | const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup 20 | 21 | const DropdownMenuSubTrigger = React.forwardRef< 22 | React.ElementRef, 23 | React.ComponentPropsWithoutRef & { 24 | inset?: boolean 25 | } 26 | >(({ className, inset, children, ...props }, ref) => ( 27 | 36 | {children} 37 | 38 | 39 | )) 40 | DropdownMenuSubTrigger.displayName = 41 | DropdownMenuPrimitive.SubTrigger.displayName 42 | 43 | const DropdownMenuSubContent = React.forwardRef< 44 | React.ElementRef, 45 | React.ComponentPropsWithoutRef 46 | >(({ className, ...props }, ref) => ( 47 | 55 | )) 56 | DropdownMenuSubContent.displayName = 57 | DropdownMenuPrimitive.SubContent.displayName 58 | 59 | const DropdownMenuContent = React.forwardRef< 60 | React.ElementRef, 61 | React.ComponentPropsWithoutRef 62 | >(({ className, sideOffset = 4, ...props }, ref) => ( 63 | 64 | 73 | 74 | )) 75 | DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName 76 | 77 | const DropdownMenuItem = React.forwardRef< 78 | React.ElementRef, 79 | React.ComponentPropsWithoutRef & { 80 | inset?: boolean 81 | } 82 | >(({ className, inset, ...props }, ref) => ( 83 | 92 | )) 93 | DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName 94 | 95 | const DropdownMenuCheckboxItem = React.forwardRef< 96 | React.ElementRef, 97 | React.ComponentPropsWithoutRef 98 | >(({ className, children, checked, ...props }, ref) => ( 99 | 108 | 109 | 110 | 111 | 112 | 113 | {children} 114 | 115 | )) 116 | DropdownMenuCheckboxItem.displayName = 117 | DropdownMenuPrimitive.CheckboxItem.displayName 118 | 119 | const DropdownMenuRadioItem = React.forwardRef< 120 | React.ElementRef, 121 | React.ComponentPropsWithoutRef 122 | >(({ className, children, ...props }, ref) => ( 123 | 131 | 132 | 133 | 134 | 135 | 136 | {children} 137 | 138 | )) 139 | DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName 140 | 141 | const DropdownMenuLabel = React.forwardRef< 142 | React.ElementRef, 143 | React.ComponentPropsWithoutRef & { 144 | inset?: boolean 145 | } 146 | >(({ className, inset, ...props }, ref) => ( 147 | 156 | )) 157 | DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName 158 | 159 | const DropdownMenuSeparator = React.forwardRef< 160 | React.ElementRef, 161 | React.ComponentPropsWithoutRef 162 | >(({ className, ...props }, ref) => ( 163 | 168 | )) 169 | DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName 170 | 171 | const DropdownMenuShortcut = ({ 172 | className, 173 | ...props 174 | }: React.HTMLAttributes) => { 175 | return ( 176 | 180 | ) 181 | } 182 | DropdownMenuShortcut.displayName = "DropdownMenuShortcut" 183 | 184 | export { 185 | DropdownMenu, 186 | DropdownMenuTrigger, 187 | DropdownMenuContent, 188 | DropdownMenuItem, 189 | DropdownMenuCheckboxItem, 190 | DropdownMenuRadioItem, 191 | DropdownMenuLabel, 192 | DropdownMenuSeparator, 193 | DropdownMenuShortcut, 194 | DropdownMenuGroup, 195 | DropdownMenuPortal, 196 | DropdownMenuSub, 197 | DropdownMenuSubContent, 198 | DropdownMenuSubTrigger, 199 | DropdownMenuRadioGroup, 200 | } 201 | --------------------------------------------------------------------------------