forked from Blaster4385/file-share
Compare commits
No commits in common. "main" and "main" have entirely different histories.
11 changed files with 716 additions and 137 deletions
11
Makefile
Normal file
11
Makefile
Normal file
|
@ -0,0 +1,11 @@
|
|||
.PHONY: fileshare clean
|
||||
|
||||
fileshare:
|
||||
rm -rf server/dist
|
||||
cp -r client server/dist
|
||||
cd server && go build -ldflags "-s -w"
|
||||
mv server/fileshare fileshare
|
||||
|
||||
clean:
|
||||
rm -rf server/dist
|
||||
rm fileshare
|
9
client/.prettierrc
Normal file
9
client/.prettierrc
Normal file
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"semi": false,
|
||||
"trailingComma": "none",
|
||||
"singleQuote": true,
|
||||
"printWidth": 80,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"bracketSpacing": true
|
||||
}
|
22
client/assets/fonts.css
Normal file
22
client/assets/fonts.css
Normal file
|
@ -0,0 +1,22 @@
|
|||
/* latin-ext */
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono';
|
||||
font-style: normal;
|
||||
font-weight: 100 800;
|
||||
font-display: swap;
|
||||
src: url(/assets/fonts/JetBrains_Mono_latin-ext.woff2) format('woff2');
|
||||
unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF,
|
||||
U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
/* latin */
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono';
|
||||
font-style: normal;
|
||||
font-weight: 100 800;
|
||||
font-display: swap;
|
||||
src: url(/assets/fonts/JetBrains_Mono_latin.woff2) format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
|
||||
U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191,
|
||||
U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
BIN
client/assets/fonts/JetBrainsMono-VariableFont_wght.ttf
Normal file
BIN
client/assets/fonts/JetBrainsMono-VariableFont_wght.ttf
Normal file
Binary file not shown.
BIN
client/assets/fonts/JetBrains_Mono_latin-ext.woff2
Normal file
BIN
client/assets/fonts/JetBrains_Mono_latin-ext.woff2
Normal file
Binary file not shown.
BIN
client/assets/fonts/JetBrains_Mono_latin.woff2
Normal file
BIN
client/assets/fonts/JetBrains_Mono_latin.woff2
Normal file
Binary file not shown.
183
client/assets/index.js
Normal file
183
client/assets/index.js
Normal file
|
@ -0,0 +1,183 @@
|
|||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
const fileId = urlParams.get('id')
|
||||
const key = urlParams.get('key')
|
||||
|
||||
if (fileId && key) {
|
||||
displayFileDetails(fileId, key)
|
||||
} else {
|
||||
document.getElementById('upload__form').style.display = 'block'
|
||||
setupUploadForm()
|
||||
}
|
||||
})
|
||||
|
||||
const baseUrl = window.location.origin
|
||||
const CHUNK_SIZE = 100 * 1024 * 1024 // 100 MB
|
||||
|
||||
async function displayFileDetails(fileId, key) {
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/get/${fileId}?key=${key}`, {
|
||||
method: 'GET'
|
||||
})
|
||||
|
||||
const fileDetails = document.getElementById('file__details')
|
||||
const fileNameElement = document.getElementById('file__name')
|
||||
const fileSizeElement = document.getElementById('file__size')
|
||||
const downloadButton = document.getElementById('download__btn')
|
||||
const copyButton = document.getElementById('copy__btn')
|
||||
|
||||
if (!response.ok) {
|
||||
fileDetails.textContent = `Error: ${response.statusText}`
|
||||
fileDetails.style.display = 'flex'
|
||||
return
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('Content-Type')
|
||||
|
||||
if (contentType && contentType.includes('application/json')) {
|
||||
const result = await response.json()
|
||||
const downloadUrl = `${baseUrl}/download/${fileId}?key=${key}`
|
||||
const pageUrl = `${baseUrl}/?id=${fileId}&key=${key}`
|
||||
fileNameElement.textContent = `${result.fileName}`
|
||||
fileSizeElement.textContent = `${result.fileSize}`
|
||||
downloadButton.innerHTML = `<a href="${downloadUrl}">Download</a>`
|
||||
copyButton.onclick = () => {
|
||||
navigator.clipboard.writeText(pageUrl)
|
||||
copyButton.textContent = 'Copied!'
|
||||
}
|
||||
} else {
|
||||
const result = await response.text()
|
||||
fileDetails.textContent = result
|
||||
}
|
||||
|
||||
fileDetails.style.display = 'flex'
|
||||
} catch (error) {
|
||||
console.error('Error:', error)
|
||||
document.getElementById('file__details').textContent =
|
||||
'An error occurred. Please try again.'
|
||||
document.getElementById('file__details').style.display = 'flex'
|
||||
}
|
||||
}
|
||||
|
||||
function setupUploadForm() {
|
||||
const fileInput = document.querySelector('.upload__input')
|
||||
const overlayText = document.querySelector('.upload__input__overlay__text')
|
||||
|
||||
fileInput.addEventListener('change', () => {
|
||||
if (fileInput.files.length > 0) {
|
||||
overlayText.textContent = fileInput.files[0].name
|
||||
} else {
|
||||
overlayText.textContent = 'Choose a file or drag it here'
|
||||
}
|
||||
})
|
||||
|
||||
document
|
||||
.getElementById('upload__form')
|
||||
.addEventListener('submit', async (event) => {
|
||||
event.preventDefault()
|
||||
|
||||
const file = fileInput.files[0]
|
||||
|
||||
if (!file) {
|
||||
console.log('No file selected.')
|
||||
return
|
||||
}
|
||||
|
||||
const progressBar = document.getElementById('upload__progress')
|
||||
const progressFill = document.getElementById('progress__fill')
|
||||
const uploadButton = document.getElementById('upload__btn')
|
||||
|
||||
uploadButton.style.display = 'none'
|
||||
progressBar.style.display = 'block'
|
||||
fileInput.disabled = true
|
||||
|
||||
try {
|
||||
await uploadFileInChunks(file, progressFill)
|
||||
} catch (error) {
|
||||
console.error('Error:', error)
|
||||
document.getElementById('upload__result').textContent =
|
||||
'An error occurred. Please try again.'
|
||||
document
|
||||
.getElementById('upload__result')
|
||||
.classList.add('upload__result__visible')
|
||||
} finally {
|
||||
progressBar.style.display = 'none'
|
||||
uploadButton.style.display = 'inline-block'
|
||||
fileInput.disabled = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function uploadFileInChunks(file, progressFill) {
|
||||
const fileSize = file.size
|
||||
const chunkCount = Math.ceil(fileSize / CHUNK_SIZE)
|
||||
const uploadId = generateUploadId()
|
||||
let uploadedSize = 0
|
||||
|
||||
for (let chunkIndex = 0; chunkIndex < chunkCount; chunkIndex++) {
|
||||
const start = chunkIndex * CHUNK_SIZE
|
||||
const end = Math.min(start + CHUNK_SIZE, fileSize)
|
||||
const chunk = file.slice(start, end)
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('chunk', chunk)
|
||||
formData.append('uploadId', uploadId)
|
||||
formData.append('chunkIndex', chunkIndex)
|
||||
formData.append('chunkCount', chunkCount)
|
||||
formData.append('fileName', file.name)
|
||||
|
||||
await uploadChunk(formData, progressFill, uploadedSize, fileSize)
|
||||
|
||||
uploadedSize += chunk.size
|
||||
}
|
||||
|
||||
// Call upload_complete endpoint
|
||||
const completeFormData = new FormData()
|
||||
completeFormData.append('uploadId', uploadId)
|
||||
completeFormData.append('chunkCount', chunkCount)
|
||||
completeFormData.append('fileName', file.name)
|
||||
|
||||
const completeResponse = await fetch(`${baseUrl}/upload_complete`, {
|
||||
method: 'POST',
|
||||
body: completeFormData
|
||||
})
|
||||
|
||||
if (!completeResponse.ok) {
|
||||
throw new Error(`Error completing upload: ${completeResponse.statusText}`)
|
||||
}
|
||||
|
||||
const result = await completeResponse.json()
|
||||
const pageUrl = `${baseUrl}/?id=${result.id}&key=${result.key}`
|
||||
window.location.href = pageUrl
|
||||
}
|
||||
|
||||
async function uploadChunk(formData, progressFill, uploadedSize, fileSize) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest()
|
||||
xhr.open('POST', `${baseUrl}/upload_chunk`, true)
|
||||
|
||||
xhr.upload.onprogress = (event) => {
|
||||
if (event.lengthComputable) {
|
||||
const totalUploaded = uploadedSize + event.loaded
|
||||
const progress = Math.round((totalUploaded / fileSize) * 100)
|
||||
progressFill.style.width = `${progress}%`
|
||||
}
|
||||
}
|
||||
|
||||
xhr.onload = () => {
|
||||
if (xhr.status === 200) {
|
||||
resolve()
|
||||
} else {
|
||||
reject(new Error(`Error uploading chunk: ${xhr.statusText}`))
|
||||
}
|
||||
}
|
||||
|
||||
xhr.onerror = () => reject(new Error('Network error occurred'))
|
||||
|
||||
xhr.send(formData)
|
||||
})
|
||||
}
|
||||
|
||||
function generateUploadId() {
|
||||
return Math.random().toString(36).substr(2, 9)
|
||||
}
|
179
client/assets/styles.css
Normal file
179
client/assets/styles.css
Normal file
|
@ -0,0 +1,179 @@
|
|||
@import url('fonts.css');
|
||||
|
||||
body {
|
||||
background-color: #282828;
|
||||
color: #ebdbb2;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
#root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.upload__container,
|
||||
.file__details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 600px;
|
||||
min-width: 600px;
|
||||
border-radius: 2rem;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
background-color: #ebdbb2;
|
||||
color: #282828;
|
||||
overflow-wrap: anywhere;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.file__details__text {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.file__details__button__container {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.upload__button,
|
||||
.download__button {
|
||||
font-size: 1.5rem;
|
||||
padding: 1rem;
|
||||
background-color: #282828;
|
||||
border-radius: 16px;
|
||||
border: 1px solid #282828;
|
||||
color: #ebdbb2;
|
||||
cursor: pointer;
|
||||
transition: transform 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.upload__button:hover,
|
||||
.download__button:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.upload__button {
|
||||
position: absolute;
|
||||
bottom: 5.75%;
|
||||
}
|
||||
|
||||
.upload__info {
|
||||
font-size: 0.8rem;
|
||||
color: #454545;
|
||||
}
|
||||
|
||||
.upload__input {
|
||||
color: #282828;
|
||||
border: 2px solid #282828;
|
||||
padding: 2rem;
|
||||
border-radius: 0.5rem;
|
||||
min-height: 300px;
|
||||
min-width: 300px;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
opacity: 0;
|
||||
z-index: 2;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.upload__input__overlay {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
color: #282828;
|
||||
border: 2px solid #282828;
|
||||
padding: 2rem;
|
||||
border-radius: 0.5rem;
|
||||
min-height: 300px;
|
||||
min-width: 300px;
|
||||
}
|
||||
|
||||
.upload__input__overlay__text {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.upload__result {
|
||||
opacity: 0;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.upload__result__visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.upload__progress {
|
||||
width: 90%;
|
||||
height: 20px;
|
||||
background-color: #a89984;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
margin: 1rem;
|
||||
position: absolute;
|
||||
bottom: 5%;
|
||||
}
|
||||
|
||||
.upload__progress__fill {
|
||||
height: 100%;
|
||||
width: 0;
|
||||
background-color: #504945;
|
||||
transition: width 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.upload__container,
|
||||
.file__details {
|
||||
min-height: 400px;
|
||||
min-width: 300px;
|
||||
max-height: 400px;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.upload__input,
|
||||
.upload__input__overlay {
|
||||
min-height: 100px;
|
||||
min-width: 250px;
|
||||
max-width: 250px;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.upload__input__overlay__text {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.upload__info {
|
||||
font-size: 0.6rem;
|
||||
}
|
||||
|
||||
.upload__button,
|
||||
.download__button {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.file__details__text {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
}
|
||||
|
48
client/index.html
Normal file
48
client/index.html
Normal file
|
@ -0,0 +1,48 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>File Share</title>
|
||||
<link rel="stylesheet" href="assets/styles.css" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root">
|
||||
<div class="header">
|
||||
<h2 class="header__title">File Share</h2>
|
||||
</div>
|
||||
<form id="upload__form" style="display: none">
|
||||
<div class="upload__container">
|
||||
<div class="upload__input__overlay">
|
||||
<p class="upload__input__overlay__text">
|
||||
Choose a file or drag it here<br><span class="upload__info">(It will be deleted after 24 hours.)</span>
|
||||
</p>
|
||||
</div>
|
||||
<input type="file" class="upload__input" name="file" />
|
||||
<div
|
||||
class="upload__progress"
|
||||
id="upload__progress"
|
||||
style="display: none"
|
||||
>
|
||||
<div class="upload__progress__fill" id="progress__fill"></div>
|
||||
</div>
|
||||
<button type="submit" class="upload__button" id="upload__btn">
|
||||
Upload
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<div class="file__details" id="file__details" style="display: none">
|
||||
<p class="file__details__text" id="file__name"></p>
|
||||
<p class="file__details__text" id="file__size"></p>
|
||||
<div class="file__details__button__container">
|
||||
<button class="download__button" id="download__btn">Download</button>
|
||||
<button class="download__button" id="copy__btn">Copy Link</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="upload__result" id="upload__result">Error Placeholder</div>
|
||||
</div>
|
||||
|
||||
<script src="assets/index.js"></script>
|
||||
</body>
|
||||
</html>
|
|
@ -1,4 +1,4 @@
|
|||
module file-share
|
||||
module fileshare
|
||||
|
||||
go 1.22.2
|
||||
|
||||
|
|
399
server/main.go
399
server/main.go
|
@ -1,16 +1,21 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"embed"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
|
@ -19,23 +24,37 @@ import (
|
|||
)
|
||||
|
||||
const (
|
||||
maxUploadSize = 10 * 1024 * 1024 // 10 MB
|
||||
maxUploadSize = 3 * 1024 * 1024 * 1024 // 3 GB
|
||||
keySize = 32
|
||||
nonceSize = 12
|
||||
)
|
||||
|
||||
var db *sql.DB
|
||||
var port string
|
||||
|
||||
func RegisterHandlers(e *echo.Echo) {
|
||||
//go:embed all:dist
|
||||
var dist embed.FS
|
||||
|
||||
func registerHandlers(e *echo.Echo) {
|
||||
e.Use(middleware.BodyLimit(fmt.Sprintf("%dM", maxUploadSize/(1024*1024))))
|
||||
e.Use(middleware.StaticWithConfig(middleware.StaticConfig{
|
||||
Root: "dist",
|
||||
Index: "index.html",
|
||||
HTML5: true,
|
||||
Filesystem: http.FS(dist),
|
||||
}))
|
||||
e.Use(middleware.Logger())
|
||||
e.Use(middleware.Recover())
|
||||
e.Use(middleware.CORS())
|
||||
e.POST("/upload", handleUpload)
|
||||
e.POST("/upload_chunk", handleUploadChunk)
|
||||
e.POST("/upload_complete", handleUploadComplete)
|
||||
e.GET("/download/:id", handleDownload)
|
||||
e.GET("/get/:id", handleGetFileInfo)
|
||||
}
|
||||
|
||||
func main() {
|
||||
flag.StringVar(&port, "port", "8080", "HTTP server port")
|
||||
flag.Parse()
|
||||
var err error
|
||||
db, err = initDB()
|
||||
if err != nil {
|
||||
|
@ -44,12 +63,20 @@ func main() {
|
|||
defer db.Close()
|
||||
|
||||
e := echo.New()
|
||||
RegisterHandlers(e)
|
||||
e.Logger.Fatal(e.Start(":8080"))
|
||||
registerHandlers(e)
|
||||
|
||||
startCleanupScheduler()
|
||||
|
||||
e.Logger.Fatal(e.Start(":" + port))
|
||||
}
|
||||
|
||||
func initDB() (*sql.DB, error) {
|
||||
db, err := sql.Open("postgres", "postgres://file:password@localhost/filedb?sslmode=disable")
|
||||
user := os.Getenv("POSTGRES_USER")
|
||||
password := os.Getenv("POSTGRES_PASSWORD")
|
||||
dbname := os.Getenv("POSTGRES_DB")
|
||||
|
||||
dbURL := fmt.Sprintf("postgres://%s:%s@localhost/%s?sslmode=disable", user, password, dbname)
|
||||
db, err := sql.Open("postgres", dbURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
@ -57,60 +84,103 @@ func initDB() (*sql.DB, error) {
|
|||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := createFilesTable(ctx, db); err != nil {
|
||||
if err := createTables(ctx, db); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func createFilesTable(ctx context.Context, db *sql.DB) error {
|
||||
func createTables(ctx context.Context, db *sql.DB) error {
|
||||
_, err := db.ExecContext(ctx, `
|
||||
CREATE TABLE IF NOT EXISTS chunks (
|
||||
upload_id TEXT,
|
||||
chunk_index INT,
|
||||
chunk_data BYTEA,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
PRIMARY KEY (upload_id, chunk_index)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS files (
|
||||
id TEXT PRIMARY KEY,
|
||||
id TEXT,
|
||||
name TEXT,
|
||||
data BYTEA
|
||||
chunk_index INT,
|
||||
chunk_data BYTEA,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
PRIMARY KEY (id, chunk_index)
|
||||
);
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
func handleUpload(c echo.Context) error {
|
||||
r := c.Request()
|
||||
if err := r.ParseMultipartForm(maxUploadSize); err != nil {
|
||||
return handleError(c, fmt.Errorf("error parsing multipart form: %v", err), http.StatusBadRequest)
|
||||
func handleUploadChunk(c echo.Context) error {
|
||||
uploadId := c.FormValue("uploadId")
|
||||
chunkIndex, err := strconv.Atoi(c.FormValue("chunkIndex"))
|
||||
if err != nil {
|
||||
return handleError(c, fmt.Errorf("invalid chunk index: %v", err), http.StatusBadRequest)
|
||||
}
|
||||
chunk, err := c.FormFile("chunk")
|
||||
if err != nil {
|
||||
return handleError(c, fmt.Errorf("error getting form file: %v", err), http.StatusBadRequest)
|
||||
}
|
||||
|
||||
src, err := chunk.Open()
|
||||
if err != nil {
|
||||
return handleError(c, fmt.Errorf("error opening chunk: %v", err), http.StatusInternalServerError)
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
chunkData, err := io.ReadAll(src)
|
||||
if err != nil {
|
||||
return handleError(c, fmt.Errorf("error reading chunk data: %v", err), http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
if err := storeChunkInDB(c.Request().Context(), uploadId, chunkIndex, chunkData); err != nil {
|
||||
return handleError(c, fmt.Errorf("error storing chunk in database: %v", err), http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
return c.NoContent(http.StatusOK)
|
||||
}
|
||||
|
||||
func storeChunkInDB(ctx context.Context, uploadId string, chunkIndex int, chunkData []byte) error {
|
||||
_, err := db.ExecContext(ctx, "INSERT INTO chunks (upload_id, chunk_index, chunk_data, created_at) VALUES ($1, $2, $3, NOW())", uploadId, chunkIndex, chunkData)
|
||||
return err
|
||||
}
|
||||
|
||||
func handleUploadComplete(c echo.Context) error {
|
||||
uploadId := c.FormValue("uploadId")
|
||||
chunkCount, err := strconv.Atoi(c.FormValue("chunkCount"))
|
||||
if err != nil {
|
||||
return handleError(c, fmt.Errorf("invalid chunk count: %v", err), http.StatusBadRequest)
|
||||
}
|
||||
fileName := c.FormValue("fileName")
|
||||
|
||||
key, err := generateRandomKey()
|
||||
if err != nil {
|
||||
return handleError(c, fmt.Errorf("error generating encryption key: %v", err), http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
file, handler, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
return handleError(c, fmt.Errorf("error getting form file: %v", err), http.StatusBadRequest)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
id := generateID()
|
||||
for i := 0; i < chunkCount; i++ {
|
||||
chunkData, err := getChunkFromDB(c.Request().Context(), uploadId, i)
|
||||
if err != nil {
|
||||
return handleError(c, fmt.Errorf("error retrieving chunk data: %v", err), http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
encryptedData, err := encryptFile(file, key)
|
||||
if err != nil {
|
||||
return handleError(c, fmt.Errorf("error encrypting file: %v", err), http.StatusInternalServerError)
|
||||
}
|
||||
encryptedData, err := encryptFile(bytes.NewReader(chunkData), key)
|
||||
if err != nil {
|
||||
return handleError(c, fmt.Errorf("error encrypting chunk: %v", err), http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
if err := storeFileInDB(r.Context(), id, handler.Filename, encryptedData); err != nil {
|
||||
return handleError(c, fmt.Errorf("error storing file in database: %v", err), http.StatusInternalServerError)
|
||||
if err := storeChunkInFilesTable(c.Request().Context(), id, fileName, i, encryptedData); err != nil {
|
||||
return handleError(c, fmt.Errorf("error storing chunk in database: %v", err), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
encodedKey := hex.EncodeToString(key)
|
||||
|
||||
type UploadResponse struct {
|
||||
response := struct {
|
||||
ID string `json:"id"`
|
||||
Key string `json:"key"`
|
||||
}
|
||||
|
||||
response := UploadResponse{
|
||||
}{
|
||||
ID: id,
|
||||
Key: encodedKey,
|
||||
}
|
||||
|
@ -118,6 +188,17 @@ func handleUpload(c echo.Context) error {
|
|||
return c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
func storeChunkInFilesTable(ctx context.Context, id, fileName string, chunkIndex int, encryptedData []byte) error {
|
||||
_, err := db.ExecContext(ctx, "INSERT INTO files (id, name, chunk_index, chunk_data, created_at) VALUES ($1, $2, $3, $4, NOW())", id, fileName, chunkIndex, encryptedData)
|
||||
return err
|
||||
}
|
||||
|
||||
func getChunkFromDB(ctx context.Context, uploadId string, chunkIndex int) ([]byte, error) {
|
||||
var chunkData []byte
|
||||
err := db.QueryRowContext(ctx, "SELECT chunk_data FROM chunks WHERE upload_id = $1 AND chunk_index = $2", uploadId, chunkIndex).Scan(&chunkData)
|
||||
return chunkData, err
|
||||
}
|
||||
|
||||
func handleDownload(c echo.Context) error {
|
||||
id := c.Param("id")
|
||||
keyHex := c.QueryParam("key")
|
||||
|
@ -127,14 +208,14 @@ func handleDownload(c echo.Context) error {
|
|||
return handleError(c, fmt.Errorf("invalid key: %v", err), http.StatusBadRequest)
|
||||
}
|
||||
|
||||
fileName, encryptedData, err := getFileFromDB(c.Request().Context(), id)
|
||||
fileName, err := getFileNameFromDB(c.Request().Context(), id)
|
||||
if err != nil {
|
||||
return handleError(c, fmt.Errorf("error getting file from database: %v", err), http.StatusInternalServerError)
|
||||
return handleError(c, fmt.Errorf("error getting file name from database: %v", err), http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
c.Response().Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, fileName))
|
||||
|
||||
err = decryptAndStreamFile(c.Response(), encryptedData, key)
|
||||
err = decryptAndStreamChunks(c.Response(), id, key)
|
||||
if err != nil {
|
||||
return handleError(c, fmt.Errorf("error decrypting and streaming file: %v", err), http.StatusInternalServerError)
|
||||
}
|
||||
|
@ -142,6 +223,41 @@ func handleDownload(c echo.Context) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func getFileNameFromDB(ctx context.Context, id string) (fileName string, err error) {
|
||||
err = db.QueryRowContext(ctx, "SELECT name FROM files WHERE id = $1 LIMIT 1", id).Scan(&fileName)
|
||||
if err == sql.ErrNoRows {
|
||||
return "", errors.New("file not found")
|
||||
}
|
||||
return fileName, err
|
||||
}
|
||||
|
||||
func decryptAndStreamChunks(w io.Writer, id string, key []byte) error {
|
||||
rows, err := db.Query("SELECT chunk_data FROM files WHERE id = $1 ORDER BY chunk_index", id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var encryptedData []byte
|
||||
if err := rows.Scan(&encryptedData); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
plaintext, err := decryptFile(encryptedData, key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = w.Write(plaintext)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return rows.Err()
|
||||
}
|
||||
|
||||
func handleGetFileInfo(c echo.Context) error {
|
||||
id := c.Param("id")
|
||||
keyHex := c.QueryParam("key")
|
||||
|
@ -151,22 +267,14 @@ func handleGetFileInfo(c echo.Context) error {
|
|||
return handleError(c, fmt.Errorf("invalid key: %v", err), http.StatusBadRequest)
|
||||
}
|
||||
|
||||
fileName, encryptedData, err := getFileFromDB(c.Request().Context(), id)
|
||||
fileName, err := getFileNameFromDB(c.Request().Context(), id)
|
||||
if err != nil {
|
||||
return handleError(c, fmt.Errorf("error getting file from database: %v", err), http.StatusInternalServerError)
|
||||
return handleError(c, fmt.Errorf("error getting file name from database: %v", err), http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
plaintext, err := decryptFile(encryptedData, key)
|
||||
fileSize, err := getTotalFileSize(id, key)
|
||||
if err != nil {
|
||||
return handleError(c, fmt.Errorf("error decrypting file: %v", err), http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
fileSizeBytes := len(plaintext)
|
||||
var fileSize string
|
||||
if fileSizeBytes >= 1024*1024 {
|
||||
fileSize = fmt.Sprintf("%.2f MB", float64(fileSizeBytes)/(1024*1024))
|
||||
} else {
|
||||
fileSize = fmt.Sprintf("%.2f KB", float64(fileSizeBytes)/1024)
|
||||
return handleError(c, fmt.Errorf("error getting file size: %v", err), http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
fileInfo := struct {
|
||||
|
@ -180,100 +288,41 @@ func handleGetFileInfo(c echo.Context) error {
|
|||
return c.JSON(http.StatusOK, fileInfo)
|
||||
}
|
||||
|
||||
func storeFileInDB(ctx context.Context, id, fileName string, encryptedData []byte) error {
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
func getTotalFileSize(id string, key []byte) (string, error) {
|
||||
var totalSize int64
|
||||
rows, err := db.Query("SELECT chunk_data FROM files WHERE id = $1 ORDER BY chunk_index", id)
|
||||
if err != nil {
|
||||
return err
|
||||
return "", err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
defer rows.Close()
|
||||
|
||||
_, err = tx.ExecContext(ctx, "INSERT INTO files (id, name, data) VALUES ($1, $2, $3)", id, fileName, encryptedData)
|
||||
if err != nil {
|
||||
return err
|
||||
for rows.Next() {
|
||||
var encryptedData []byte
|
||||
if err := rows.Scan(&encryptedData); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
plaintext, err := decryptFile(encryptedData, key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
totalSize += int64(len(plaintext))
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
var fileSize string
|
||||
if totalSize >= 1024*1024 {
|
||||
fileSize = fmt.Sprintf("%.2f MB", float64(totalSize)/(1024*1024))
|
||||
} else {
|
||||
fileSize = fmt.Sprintf("%.2f KB", float64(totalSize)/1024)
|
||||
}
|
||||
|
||||
return fileSize, rows.Err()
|
||||
}
|
||||
|
||||
func getFileFromDB(ctx context.Context, id string) (fileName string, encryptedData []byte, err error) {
|
||||
err = db.QueryRowContext(ctx, "SELECT name, data FROM files WHERE id = $1", id).Scan(&fileName, &encryptedData)
|
||||
if err == sql.ErrNoRows {
|
||||
return "", nil, errors.New("file not found")
|
||||
}
|
||||
return fileName, encryptedData, err
|
||||
}
|
||||
|
||||
func handleError(c echo.Context, err error, code int) error {
|
||||
return c.JSON(code, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
func encryptFile(in io.Reader, key []byte) ([]byte, error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
aesgcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
nonce := make([]byte, nonceSize)
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
plaintext, err := io.ReadAll(in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ciphertext := aesgcm.Seal(nil, nonce, plaintext, nil)
|
||||
return append(nonce, ciphertext...), nil
|
||||
}
|
||||
|
||||
func decryptAndStreamFile(w io.Writer, encryptedData []byte, key []byte) error {
|
||||
if len(encryptedData) < nonceSize {
|
||||
return errors.New("ciphertext too short")
|
||||
}
|
||||
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
aesgcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
nonce, ciphertext := encryptedData[:nonceSize], encryptedData[nonceSize:]
|
||||
plaintext, err := aesgcm.Open(nil, nonce, ciphertext, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = w.Write(plaintext)
|
||||
return err
|
||||
}
|
||||
|
||||
func decryptFile(encryptedData []byte, key []byte) ([]byte, error) {
|
||||
if len(encryptedData) < nonceSize {
|
||||
return nil, errors.New("ciphertext too short")
|
||||
}
|
||||
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
aesgcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
nonce, ciphertext := encryptedData[:nonceSize], encryptedData[nonceSize:]
|
||||
return aesgcm.Open(nil, nonce, ciphertext, nil)
|
||||
func handleError(c echo.Context, err error, status int) error {
|
||||
fmt.Printf("error: %v\n", err)
|
||||
return c.JSON(status, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
func generateRandomKey() ([]byte, error) {
|
||||
|
@ -284,8 +333,86 @@ func generateRandomKey() ([]byte, error) {
|
|||
|
||||
func generateID() string {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
func encryptFile(plaintext io.Reader, key []byte) ([]byte, error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
nonce := make([]byte, nonceSize)
|
||||
if _, err := rand.Read(nonce); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
aesgcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
plaintextBytes, err := io.ReadAll(plaintext)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ciphertext := aesgcm.Seal(nonce, nonce, plaintextBytes, nil)
|
||||
return ciphertext, nil
|
||||
}
|
||||
|
||||
func decryptFile(ciphertext, key []byte) ([]byte, error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(ciphertext) < nonceSize {
|
||||
return nil, errors.New("ciphertext too short")
|
||||
}
|
||||
|
||||
nonce, ciphertext := ciphertext[:nonceSize], ciphertext[nonceSize:]
|
||||
|
||||
aesgcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
plaintext, err := aesgcm.Open(nil, nonce, ciphertext, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return plaintext, nil
|
||||
}
|
||||
|
||||
func startCleanupScheduler() {
|
||||
ticker := time.NewTicker(24 * time.Hour)
|
||||
go func() {
|
||||
for range ticker.C {
|
||||
cleanupChunks()
|
||||
cleanupOldFiles()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func cleanupChunks() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
_, err := db.ExecContext(ctx, "DELETE FROM chunks WHERE created_at < NOW() - INTERVAL '1 day'")
|
||||
if err != nil {
|
||||
fmt.Printf("error cleaning up chunks: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
func cleanupOldFiles() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
_, err := db.ExecContext(ctx, "DELETE FROM files WHERE created_at < NOW() - INTERVAL '1 day'")
|
||||
if err != nil {
|
||||
fmt.Printf("error cleaning up old files: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue