migrate to WebSocket for frontend and backend communication

This commit is contained in:
2024-09-09 21:38:10 +07:00
parent b46915e46d
commit b23bf8898b
16 changed files with 309 additions and 202 deletions

2
.gitignore vendored
View File

@ -6,5 +6,7 @@
.env
docker-compose.staging.yaml
*_templ.txt
*_templ.go

View File

@ -9,7 +9,7 @@ RUN npm install -g tailwindcss
RUN npm install -g javascript-obfuscator
RUN npm install -g clean-css-cli
RUN npx tailwindcss -i ./public/input.css -o ./tmp/output.css
RUN javascript-obfuscator ./public/upload.js --compact true --self-defending true --output ./public/upload_obfuscated.js
RUN javascript-obfuscator ./public/main.js --compact true --self-defending true --output ./public/main_obfuscated.js
RUN javascript-obfuscator ./public/validatePassword.js --compact true --self-defending true --output ./public/validatePassword_obfuscated.js
RUN javascript-obfuscator ./public/websocket.js --compact true --self-defending true --output ./public/websocket_obfuscated.js
RUN cleancss -o ./public/output.css ./tmp/output.css
@ -19,7 +19,7 @@ FROM golang:1.22.2-alpine3.19 AS go_builder
WORKDIR /src
COPY . .
COPY --from=node_builder /src/public /src/public
COPY --from=node_builder /src/public/upload_obfuscated.js /src/public/upload.js
COPY --from=node_builder /src/public/main_obfuscated.js /src/public/main.js
COPY --from=node_builder /src/public/validatePassword_obfuscated.js /src/public/validatePassword.js
COPY --from=node_builder /src/public/websocket_obfuscated.js /src/public/websocket.js
@ -28,7 +28,7 @@ RUN update-ca-certificates
RUN go install github.com/a-h/templ/cmd/templ@$(go list -m -f '{{ .Version }}' github.com/a-h/templ)
RUN templ generate
RUN go build -o ./tmp/main
RUN rm /src/public/validatePassword_obfuscated.js /src/public/upload_obfuscated.js
RUN rm /src/public/validatePassword_obfuscated.js /src/public/main_obfuscated.js /src/public/websocket_obfuscated.js
FROM scratch

2
go.mod
View File

@ -7,6 +7,7 @@ require (
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/joho/godotenv v1.5.1
github.com/redis/go-redis/v9 v9.6.1
github.com/shirou/gopsutil/v3 v3.24.5
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
github.com/xlzd/gotp v0.1.0
@ -31,7 +32,6 @@ require (
github.com/jinzhu/now v1.1.5 // indirect
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
github.com/redis/go-redis/v9 v9.6.1 // indirect
github.com/shoenig/go-m1cpu v0.1.6 // indirect
github.com/tklauser/go-sysconf v0.3.12 // indirect
github.com/tklauser/numcpus v0.6.1 // indirect

4
go.sum
View File

@ -2,6 +2,10 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/a-h/templ v0.2.707 h1:T1Gkd2ugbRglZ9rYw/VBchWOSZVKmetDbBkm4YubM7U=
github.com/a-h/templ v0.2.707/go.mod h1:5cqsugkq9IerRNucNsI4DEamdHPsoGMQy99DzydLhM8=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=

View File

@ -1,144 +0,0 @@
package initialisation
import (
"encoding/json"
"errors"
"fmt"
"github.com/fossyy/filekeeper/app"
"io"
"net/http"
"os"
"path/filepath"
"github.com/fossyy/filekeeper/types"
"github.com/fossyy/filekeeper/types/models"
"github.com/google/uuid"
"gorm.io/gorm"
)
func POST(w http.ResponseWriter, r *http.Request) {
userSession := r.Context().Value("user").(types.User)
body, err := io.ReadAll(r.Body)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
var fileInfo types.FileInfo
if err := json.Unmarshal(body, &fileInfo); err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
fileData, err := app.Server.Service.GetUserFile(fileInfo.Name, userSession.UserID.String())
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
upload, err := handleNewUpload(userSession, fileInfo)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
fileData = &types.FileWithDetail{
ID: fileData.ID,
OwnerID: fileData.OwnerID,
Name: fileData.Name,
Size: fileData.Size,
Downloaded: fileData.Downloaded,
}
fileData.Chunk = make(map[string]bool)
fileData.Done = true
saveFolder := filepath.Join("uploads", userSession.UserID.String(), fileData.ID.String(), fileData.Name)
for i := 0; i <= int(fileInfo.Chunk-1); i++ {
fileName := fmt.Sprintf("%s/chunk_%d", saveFolder, i)
if _, err := os.Stat(fileName); os.IsNotExist(err) {
fileData.Chunk[fmt.Sprintf("chunk_%d", i)] = false
fileData.Done = false
} else {
fileData.Chunk[fmt.Sprintf("chunk_%d", i)] = true
}
}
respondJSON(w, upload)
return
}
respondErrorJSON(w, err, http.StatusInternalServerError)
return
}
fileData.Chunk = make(map[string]bool)
fileData.Done = true
saveFolder := filepath.Join("uploads", userSession.UserID.String(), fileData.ID.String(), fileData.Name)
for i := 0; i <= int(fileInfo.Chunk-1); i++ {
fileName := fmt.Sprintf("%s/chunk_%d", saveFolder, i)
if _, err := os.Stat(fileName); os.IsNotExist(err) {
fileData.Chunk[fmt.Sprintf("chunk_%d", i)] = false
fileData.Done = false
} else {
fileData.Chunk[fmt.Sprintf("chunk_%d", i)] = true
}
}
respondJSON(w, fileData)
}
func handleNewUpload(user types.User, file types.FileInfo) (models.File, error) {
uploadDir := "uploads"
if _, err := os.Stat(uploadDir); os.IsNotExist(err) {
app.Server.Logger.Error(err.Error())
err := os.Mkdir(uploadDir, os.ModePerm)
if err != nil {
app.Server.Logger.Error(err.Error())
return models.File{}, err
}
}
fileID := uuid.New()
ownerID := user.UserID
currentDir, _ := os.Getwd()
basePath := filepath.Join(currentDir, uploadDir)
saveFolder := filepath.Join(basePath, ownerID.String(), fileID.String())
if filepath.Dir(saveFolder) != filepath.Join(basePath, ownerID.String()) {
return models.File{}, errors.New("invalid path")
}
err := os.MkdirAll(saveFolder, os.ModePerm)
if err != nil {
app.Server.Logger.Error(err.Error())
return models.File{}, err
}
newFile := models.File{
ID: fileID,
OwnerID: ownerID,
Name: file.Name,
Size: file.Size,
TotalChunk: file.Chunk - 1,
Downloaded: 0,
}
err = app.Server.Database.CreateFile(&newFile)
if err != nil {
app.Server.Logger.Error(err.Error())
return models.File{}, err
}
return newFile, nil
}
func respondJSON(w http.ResponseWriter, data interface{}) {
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(data); err != nil {
handleError(w, err, http.StatusInternalServerError)
}
}
func respondErrorJSON(w http.ResponseWriter, err error, statusCode int) {
w.WriteHeader(statusCode)
respondJSON(w, map[string]string{"error": err.Error()})
}
func handleError(w http.ResponseWriter, err error, statusCode int) {
http.Error(w, err.Error(), statusCode)
app.Server.Logger.Error(err.Error())
}

View File

@ -1,22 +1,63 @@
package userHandler
import (
"encoding/json"
"errors"
"fmt"
"github.com/a-h/templ"
"github.com/fossyy/filekeeper/app"
"github.com/fossyy/filekeeper/types"
"github.com/fossyy/filekeeper/view/client/user"
"net/http"
"github.com/fossyy/filekeeper/session"
"github.com/fossyy/filekeeper/types"
"github.com/fossyy/filekeeper/types/models"
"github.com/fossyy/filekeeper/view/client/user"
"github.com/google/uuid"
"github.com/gorilla/websocket"
"gorm.io/gorm"
"net/http"
"os"
"path/filepath"
)
var errorMessages = map[string]string{
"password_not_match": "The passwords provided do not match. Please try again.",
}
var upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
}
type ActionType string
const (
UploadNewFile ActionType = "UploadNewFile"
DeleteFile ActionType = "DeleteFile"
Ping ActionType = "Ping"
)
type WebsocketAction struct {
Action ActionType `json:"action"`
}
type ActionUploadNewFile struct {
Action string `json:"action"`
Name string `json:"name"`
Size uint64 `json:"size"`
Chunk uint64 `json:"chunk"`
RequestID string `json:"requestID"`
}
func GET(w http.ResponseWriter, r *http.Request) {
var component templ.Component
userSession := r.Context().Value("user").(types.User)
if r.Header.Get("upgrade") == "websocket" {
upgrade, err := upgrader.Upgrade(w, r, nil)
if err != nil {
panic(err)
return
}
handlerWS(upgrade, userSession)
}
var component templ.Component
sessions, err := session.GetSessions(userSession.Email)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
@ -45,3 +86,159 @@ func GET(w http.ResponseWriter, r *http.Request) {
return
}
}
func handlerWS(conn *websocket.Conn, userSession types.User) {
defer conn.Close()
var err error
var message []byte
for {
_, message, err = conn.ReadMessage()
if err != nil {
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
fmt.Println("Unexpected connection closure:", err)
} else {
fmt.Println("Connection closed:", err)
}
return
}
var action WebsocketAction
err = json.Unmarshal(message, &action)
if err != nil {
fmt.Println("Error unmarshalling WebsocketAction:", err)
sendErrorResponse(conn, action.Action)
continue
}
switch action.Action {
case UploadNewFile:
var uploadNewFile ActionUploadNewFile
err = json.Unmarshal(message, &uploadNewFile)
if err != nil {
fmt.Println("Error unmarshalling ActionUploadNewFile:", err)
sendErrorResponse(conn, action.Action)
continue
}
var file *models.File
file, err = app.Server.Database.GetUserFile(uploadNewFile.Name, userSession.UserID.String())
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
newFile := models.File{
ID: uuid.New(),
OwnerID: userSession.UserID,
Name: uploadNewFile.Name,
Size: uploadNewFile.Size,
TotalChunk: uploadNewFile.Chunk,
Downloaded: 0,
}
err := app.Server.Database.CreateFile(&newFile)
if err != nil {
sendErrorResponse(conn, action.Action)
continue
}
fileData := &types.FileWithDetail{
ID: newFile.ID,
OwnerID: newFile.OwnerID,
Name: newFile.Name,
Size: newFile.Size,
Downloaded: newFile.Downloaded,
}
fileData.Chunk = make(map[string]bool)
fileData.Done = true
saveFolder := filepath.Join("uploads", userSession.UserID.String(), newFile.ID.String(), newFile.Name)
for i := 0; i <= int(newFile.TotalChunk); i++ {
fileName := fmt.Sprintf("%s/chunk_%d", saveFolder, i)
if _, err := os.Stat(fileName); os.IsNotExist(err) {
fileData.Chunk[fmt.Sprintf("chunk_%d", i)] = false
fileData.Done = false
} else {
fileData.Chunk[fmt.Sprintf("chunk_%d", i)] = true
}
}
sendSuccessResponseWithID(conn, action.Action, fileData, uploadNewFile.RequestID)
continue
} else {
sendErrorResponse(conn, action.Action)
continue
}
}
fileData := &types.FileWithDetail{
ID: file.ID,
OwnerID: file.OwnerID,
Name: file.Name,
Size: file.Size,
Downloaded: file.Downloaded,
Done: false,
}
fileData.Chunk = make(map[string]bool)
fileData.Done = true
saveFolder := filepath.Join("uploads", userSession.UserID.String(), fileData.ID.String(), fileData.Name)
for i := 0; i <= int(file.TotalChunk-1); i++ {
fileName := fmt.Sprintf("%s/chunk_%d", saveFolder, i)
if _, err := os.Stat(fileName); os.IsNotExist(err) {
fileData.Chunk[fmt.Sprintf("chunk_%d", i)] = false
fileData.Done = false
} else {
fileData.Chunk[fmt.Sprintf("chunk_%d", i)] = true
}
}
sendSuccessResponseWithID(conn, action.Action, fileData, uploadNewFile.RequestID)
continue
case Ping:
sendSuccessResponse(conn, action.Action, map[string]string{"message": "received"})
continue
}
}
}
func sendErrorResponse(conn *websocket.Conn, action ActionType) {
response := map[string]interface{}{
"action": action,
"status": "error",
}
marshal, err := json.Marshal(response)
if err != nil {
fmt.Println("Error marshalling error response:", err)
return
}
err = conn.WriteMessage(websocket.TextMessage, marshal)
if err != nil {
fmt.Println("Error writing error response:", err)
}
}
func sendSuccessResponse(conn *websocket.Conn, action ActionType, response interface{}) {
responseJSON := map[string]interface{}{
"action": action,
"status": "success",
"response": response,
}
marshal, err := json.Marshal(responseJSON)
if err != nil {
fmt.Println("Error marshalling success response:", err)
return
}
err = conn.WriteMessage(websocket.TextMessage, marshal)
if err != nil {
fmt.Println("Error writing success response:", err)
}
}
func sendSuccessResponseWithID(conn *websocket.Conn, action ActionType, response interface{}, responseID string) {
responseJSON := map[string]interface{}{
"action": action,
"status": "success",
"response": response,
"responseID": responseID,
}
marshal, err := json.Marshal(responseJSON)
if err != nil {
fmt.Println("Error marshalling success response:", err)
return
}
err = conn.WriteMessage(websocket.TextMessage, marshal)
if err != nil {
fmt.Println("Error writing success response:", err)
}
}

View File

@ -1,45 +1,60 @@
document.addEventListener("dragover", function (event) {
event.preventDefault();
});
if (!window.mySocket) {
window.mySocket = new WebSocket(`ws://${window.location.host}/user`);
document.addEventListener("drop", async function (event) {
event.preventDefault();
const file = event.dataTransfer.files[0]
await handleFile(file)
});
window.mySocket.onopen = function(event) {
console.log('WebSocket is open now.');
};
document.getElementById('dropzone-file').addEventListener('change', async function(event) {
event.preventDefault();
const file = event.target.files[0]
await handleFile(file)
});
window.mySocket.onmessage = async function(event) {
try {
const data = JSON.parse(event.data);
if (data.action === "UploadNewFile") {
if (data.response.Done === false) {
const file = window.fileIdMap[data.responseID];
addNewUploadElement(file);
const fileChunks = await splitFile(file, file.chunkSize);
await uploadChunks(file.name, file.size, fileChunks, data.response.Chunk, data.response.ID);
} else {
alert("File already uploaded.");
}
}
} catch (error) {
console.error('Error parsing message data:', error);
}
};
window.mySocket.onerror = function(event) {
console.error('WebSocket error observed:', event);
};
window.mySocket.onclose = function(event) {
console.log('WebSocket is closed now.');
};
}
function generateUniqueId() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
}
async function handleFile(file){
const chunkSize = 2 * 1024 * 1024;
const chunks = Math.ceil(file.size / chunkSize);
const fileId = generateUniqueId();
const data = JSON.stringify({
"action": "UploadNewFile",
"name": file.name,
"size": file.size,
"chunk": chunks,
"requestID": fileId,
});
file.chunkSize = chunkSize;
window.fileIdMap = window.fileIdMap || {};
window.fileIdMap[fileId] = file;
fetch('/upload/init', {
method: 'POST',
body: data,
}).then(async response => {
const responseData = await response.json()
console.log(responseData)
if (responseData.Done === false) {
addNewUploadElement(file)
const fileChunks = await splitFile(file, chunkSize);
await uploadChunks(file.name,file.size, fileChunks, responseData.Chunk, responseData.ID);
} else {
alert("file already uploaded")
}
}).catch(error => {
console.error('Error uploading file:', error);
});
window.mySocket.send(data)
}
function addNewUploadElement(file){

View File

@ -15,7 +15,6 @@ import (
signupHandler "github.com/fossyy/filekeeper/handler/signup"
signupVerifyHandler "github.com/fossyy/filekeeper/handler/signup/verify"
uploadHandler "github.com/fossyy/filekeeper/handler/upload"
"github.com/fossyy/filekeeper/handler/upload/initialisation"
userHandler "github.com/fossyy/filekeeper/handler/user"
userHandlerResetPassword "github.com/fossyy/filekeeper/handler/user/ResetPassword"
userSessionTerminateHandler "github.com/fossyy/filekeeper/handler/user/session/terminate"
@ -118,10 +117,6 @@ func SetupRoutes() *http.ServeMux {
middleware.Auth(uploadHandler.POST, w, r)
})
handler.HandleFunc("POST /upload/init", func(w http.ResponseWriter, r *http.Request) {
middleware.Auth(initialisation.POST, w, r)
})
handler.HandleFunc("GET /download", func(w http.ResponseWriter, r *http.Request) {
middleware.Auth(downloadHandler.GET, w, r)
})

View File

@ -14,7 +14,7 @@ type File struct {
ID uuid.UUID `gorm:"primaryKey;not null;unique"`
OwnerID uuid.UUID `gorm:"not null"`
Name string `gorm:"not null"`
Size int64 `gorm:"not null"`
TotalChunk int64 `gorm:"not null"`
Downloaded int64 `gorm:"not null;default=0"`
Size uint64 `gorm:"not null"`
TotalChunk uint64 `gorm:"not null"`
Downloaded uint64 `gorm:"not null;default=0"`
}

View File

@ -22,23 +22,23 @@ type User struct {
type FileInfo struct {
Name string `json:"name"`
Size int64 `json:"size"`
Chunk int64 `json:"chunk"`
Size uint64 `json:"size"`
Chunk uint64 `json:"chunk"`
}
type FileData struct {
ID string
Name string
Size string
Downloaded int64
Downloaded uint64
}
type FileWithDetail struct {
ID uuid.UUID
OwnerID uuid.UUID
Name string
Size int64
Downloaded int64
Size uint64
Downloaded uint64
Chunk map[string]bool
Done bool
}

View File

@ -87,7 +87,7 @@ func ValidatePassword(password string) bool {
return hasNumber && hasUppercase
}
func ConvertFileSize(byte int64) string {
func ConvertFileSize(byte uint64) string {
if byte < 1024 {
return fmt.Sprintf("%d B", byte)
} else if byte < 1024*1024 {

View File

@ -6,7 +6,7 @@ import (
)
templ component(title string, files []types.FileData){
@layout.Base(title){
@layout.BaseAuth(title){
<div class="dark min-h-screen p-4 sm:p-6 bg-gray-900 text-white">
<div class="space-y-4">
<header class="text-center">

View File

@ -23,6 +23,28 @@ templ Base(title string){
</html>
}
templ BaseAuth(title string){
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Secure and reliable file hosting service. Upload, organize, and share your documents, images, videos, and more. Sign up now to keep your files always within reach." />
<meta name="keywords" content="file hosting, file sharing, cloud storage, data storage, secure file hosting, filekeeper, drive, mega" />
<meta name="author" content="Filekeeper" />
<link href="/public/output.css" rel="stylesheet"/>
<title>{ title }</title>
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
<script src="/public/main.js"></script>
</head>
<body>
<div id="content">
{ children... }
</div>
</body>
</html>
}
templ Navbar(user types.User) {
<header class="flex items-center justify-between border-b border-gray-200 bg-white px-6 py-4">
<div class="flex items-center gap-4">

View File

@ -3,7 +3,7 @@ package uploadView
import "github.com/fossyy/filekeeper/view/client/layout"
templ content(title string) {
@layout.Base(title){
@layout.BaseAuth(title){
<div class="flex items-center min-h-screen p-4 sm:p-6 bg-gray-900 text-white">
<div class="mx-auto w-full max-w-md space-y-8">
<div class="rounded-lg border bg-card text-card-foreground shadow-sm w-full max-w-md" data-v0-t="card">
@ -43,7 +43,23 @@ templ content(title string) {
</div>
</div>
</div>
<script src="/public/upload.js" />
<script type="text/javascript">
document.addEventListener("dragover", function (event) {
event.preventDefault();
});
document.addEventListener("drop", async function (event) {
event.preventDefault();
const file = event.dataTransfer.files[0]
await handleFile(file)
});
document.getElementById('dropzone-file').addEventListener('change', async function(event) {
event.preventDefault();
const file = event.target.files[0]
await handleFile(file)
});
</script>
}
}

View File

@ -6,7 +6,7 @@ import (
)
templ content(title string, qrcode string, code string, user types.User, msg types.Message) {
@layout.Base(title){
@layout.BaseAuth(title){
@layout.Navbar(user)
<main class="container mx-auto px-4 py-12 md:px-6 md:py-16 lg:py-10">
<a

View File

@ -7,7 +7,7 @@ import (
)
templ content(message types.Message, title string, user types.User, ListSession []*session.SessionInfo) {
@layout.Base(title){
@layout.BaseAuth(title){
@layout.Navbar(user)
<main class="container mx-auto px-4 py-12 md:px-6 md:py-16 lg:py-10">
<div class="grid gap-10 lg:grid-cols-[1fr_300px]">