From aab13288c973e771e9d3eb83c366835ea69b8ae5 Mon Sep 17 00:00:00 2001 From: Maxwell Jeffress Date: Sun, 11 Jan 2026 15:11:23 +1100 Subject: [PATCH] Initial commit --- .gitignore | 2 + README.md | 125 ++++++++++++++ server/go.mod | 20 +++ server/go.sum | 31 ++++ server/main.go | 436 +++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 614 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 server/go.mod create mode 100644 server/go.sum create mode 100644 server/main.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9ea0abc --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +files +chspfiles.db diff --git a/README.md b/README.md new file mode 100644 index 0000000..3a5bde4 --- /dev/null +++ b/README.md @@ -0,0 +1,125 @@ +# Chookspace Files + +A file management server over HTTP. Currently supports: + +* Accounts with username and password +* Use a token to authenticate yourself +* Upload and download files +* You cannot access other people's files + +## Setup (Server) + +1. Install Go +2. cd to the server directory and run `go build` +3. Run the `files` binary + +## Setup (Client) + +Client coming soon! + +## Usage + +The `files` server binary takes three arguments: + +* `--storage-path`: Tells Files where to keep files that the user has uploaded. Default is `/opt/chookspace-files` +* `--db-path`: Tells Files where to keep the database that tracks user information. Default is `/opt/chookspace-files/files.db` +* `--port`: Tells Files which port to start the server on. Default is `5001` + +### HTTPS + +To reduce the attack surface, Files does not directly support HTTP. We recommend using a reverse proxy such as Caddy or a tunnel system like Cloudflare to allow secure access to the server. + +## API + +### /api/create_user + +Creates a user in the database. Accepts JSON-formatted data. + +Format: + +```json +{ + "username": "YOUR_USERNAME", + "password": "YOUR_PASSWORD" +} +``` + +May return the following status codes: + +* 200: Everything went fine +* 400: Either: + * Bad JSON + * A user with this username already exists +* 500: Either: + * Error with BCrypt for password hashing + * SQL error + +### /api/create_session + +Creates a token which can be used to authenticate yourself when uploading and downloading files. Accepts JSON-formatted data. + +Format: + +```json +{ + "username": "YOUR_USERNAME", + "password": "YOUR_PASSWORD" +} +``` + +May return the following status codes: + +* 200: Everything went fine. Check the JSON field "token" for your token. +* 400: Bad JSON +* 401: Either: + * User not found + * Incorrect password +* 500: SQL error + +### /api/send_file + +Sends a file to the server. Uses a token for authentication. Accepts a file and form data (files and JSON don't mix) + +Format: + +``` +Form Data: + token: YOUR_TOKEN + path: WHERE_TO_STORE_FILE +``` + +May return the following status codes: + +* 200: Everything went fine. Should return JSON with the SHA256 file hash and file size in bytes. +* 400: Either: + * Bad form data + * No file provided +* 401: Unknown token +* 500: Either: + * Failed to read file + * Failed to create storage directory + * Failed to save file + * Failed to save file metadata to database + +### /api/get_file + +Recieves a file from the server. Uses a token for authentication. Accepts JSON-formatted data. + +Format: + +```json +{ + "token": "YOUR_TOKEN", + "path": "FILE_STORAGE_PATH" +} +``` + +May return the following status codes: + +* 200: Everything went fine. The content recieved should be the content of your file. +* 400: Bad JSON +* 401: Unknown token +* 404: File not found +* 500: Either: + * SQL error + * File found in DB but not on disk diff --git a/server/go.mod b/server/go.mod new file mode 100644 index 0000000..75b3dbb --- /dev/null +++ b/server/go.mod @@ -0,0 +1,20 @@ +module chookspace/files + +go 1.25.5 + +require ( + filippo.io/edwards25519 v1.1.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/go-sql-driver/mysql v1.9.3 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/crypto v0.46.0 // indirect + golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect + golang.org/x/sys v0.39.0 // indirect + modernc.org/libc v1.66.10 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect + modernc.org/sqlite v1.43.0 // indirect +) diff --git a/server/go.sum b/server/go.sum new file mode 100644 index 0000000..2712dc1 --- /dev/null +++ b/server/go.sum @@ -0,0 +1,31 @@ +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= +github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= +golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A= +modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/sqlite v1.43.0 h1:8YqiFx3G1VhHTXO2Q00bl1Wz9KhS9Q5okwfp9Y97VnA= +modernc.org/sqlite v1.43.0/go.mod h1:+VkC6v3pLOAE0A0uVucQEcbVW0I5nHCeDaBf+DpsQT8= diff --git a/server/main.go b/server/main.go new file mode 100644 index 0000000..cfcf0ad --- /dev/null +++ b/server/main.go @@ -0,0 +1,436 @@ +package main + +import ( + "crypto/rand" + "crypto/sha256" + "database/sql" + "encoding/hex" + "encoding/json" + "flag" + "fmt" + "io" + "log" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "golang.org/x/crypto/bcrypt" + _ "modernc.org/sqlite" +) + +type User struct { + Username string `json:"username"` + ID uint64 `json:"id"` + Password string `json:"password"` +} + +type File struct { + Name string `json:"name"` + Path string `json:"path"` + ParentId uint64 `json:"parent_id"` + IsDirectory bool `json:"is_directory"` + + Size uint64 `json:"size"` + MimeType string `json:"mime_type"` + Hash string `json:"hash"` + + OwnerId uint64 `json:"owner_id"` + + UploadToken string `json:"token"` +} + +func getUserFromToken(db *sql.DB, token string) (uint64, error) { + var userID uint64 + var expiresAt time.Time + + query := `SELECT user_id, expires_at FROM tokens WHERE token = ?` + err := db.QueryRow(query, token).Scan(&userID, &expiresAt) + + if err == sql.ErrNoRows { + return 0, fmt.Errorf("invalid token") + } + if err != nil { + return 0, err + } + + if time.Now().After(expiresAt) { + return 0, fmt.Errorf("token expired") + } + + return userID, nil +} + +func main() { + + storagePath := flag.String("storage-path", "/opt/chsp_files", "Path where user uploaded files are stored.") + dbPath := flag.String("db-path", "/opt/chsp_files/files.db", "Path to the SQLite database which stores file information") + port := flag.Int("port", 5001, "Port to run the server on") + + flag.Parse() + + log.SetPrefix("chookspace/files ") + + // Set up database + db, err := sql.Open("sqlite", *dbPath) + + if err != nil { + log.Fatal(err) + } + + if err := db.Ping(); err != nil { + log.Fatal(err) + } + + { + // Create tables + query := ` + CREATE TABLE IF NOT EXISTS users( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS tokens( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + token TEXT UNIQUE NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + expires_at DATETIME NOT NULL, + + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS files( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + path TEXT UNIQUE NOT NULL, + parent_id INTEGER, + is_directory BOOLEAN NOT NULL DEFAULT 0, + + size INTEGER DEFAULT 0, + mime_type TEXT, + hash TEXT, + + storage_path TEXT, + + owner_id INTEGER NOT NULL, + + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + + FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (parent_id) REFERENCES files(id) ON DELETE CASCADE + ); + ` + if _, err := db.Exec(query); err != nil { + log.Fatal(err) + } + } + + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, "dingus") + log.Print("we recieved a get to the root") + }) + + // Handle user creation + http.HandleFunc("/api/create_user", func(w http.ResponseWriter, r *http.Request) { + var user User + w.Header().Set("Content-Type", "application/json") + + // Decode JSON + if err := json.NewDecoder(r.Body).Decode(&user); err != nil { + w.WriteHeader(http.StatusBadRequest) + fmt.Fprintf(w, `{ + "status": "error", + "why": "%s" + }`, err.Error()) + return + } + + // Hash password + hash, hasherr := bcrypt.GenerateFromPassword([]byte(user.Password), bcrypt.DefaultCost) + if hasherr != nil { + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprintf(w, `{ + "status": "error", + "why": "%s" + }`, hasherr.Error()) + return + } + + // Do SQL stuff + _, dberr := db.Exec(`INSERT INTO users (username, password) VALUES (?, ?)`, user.Username, string(hash)) + if dberr != nil { + w.WriteHeader(http.StatusBadRequest) + fmt.Fprintf(w, `{ + "status": "error", + "why": "%s" + }`, dberr.Error()) + return + } + + // Yay everything worked it seems + fmt.Fprintf(w, `{ + "status": "success" + }`) + }) + + // Handle getting files + http.HandleFunc("/api/get_file", func(w http.ResponseWriter, r *http.Request) { + // Parse JSON + var file File + if err := json.NewDecoder(r.Body).Decode(&file); err != nil { + w.WriteHeader(http.StatusBadRequest) + fmt.Fprintf(w, `{ + "status": "error", + "description": "bad JSON", + "why": "%s" + }`, err.Error()) + return + } + + // Authenticate user + uid, err := getUserFromToken(db, file.UploadToken) + if err != nil { + w.WriteHeader(http.StatusUnauthorized) + fmt.Fprintf(w, `{ + "status": "error", + "description", "%s" + }`, err.Error()) + return + } + file.OwnerId = uid + + // Find file + fileQuery := `SELECT storage_path, mime_type, size, name FROM files WHERE owner_id = ? AND path = ?` + if err := db.QueryRow(fileQuery, file.OwnerId, file.Path).Scan(&file.Path, &file.MimeType, &file.Size, &file.Name); err != nil { + if err == sql.ErrNoRows { + w.WriteHeader(http.StatusNotFound) + fmt.Fprintf(w, `{ + "status": "error", + "description": "file not found", + "why": "%s" + }`, err.Error()) + return + } + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprintf(w, `{ + "status": "error", + "description": "internal server error", + "why": "%s" + }`, err.Error()) + return + } + + // Get the file + storageFile, err := os.Open(file.Path) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprintf(w, `{ + "status": "error", + "why": "%s" + }`, err.Error()) + return + } + defer storageFile.Close() + + // Set headers + w.Header().Set("Content-Type", file.MimeType) + w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, file.Name)) + w.Header().Set("Content-Length", fmt.Sprintf("%d", file.Size)) + if _, err := io.Copy(w, storageFile); err != nil { + log.Printf("Error while sending file: %s", err) + return + } + }) + + // Handle files sent to the server (special since JSON and files don't mix) + http.HandleFunc("/api/send_file", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + // Parse multipart form (32MB max memory) + if err := r.ParseMultipartForm(32 << 20); err != nil { + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{ + "status": "error", + "why": err.Error(), + }) + return + } + + // Get auth credentials from form fields + token := r.FormValue("token") + + // Authenticate user + uid, err := getUserFromToken(db, token) + if err != nil { + w.WriteHeader(http.StatusUnauthorized) + fmt.Fprintf(w, `{ + "status": "error", + "description", "%s" + }`, err.Error()) + return + } + userID := uid + + // Get the uploaded file + file, header, err := r.FormFile("file") + if err != nil { + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{ + "status": "error", + "why": "no file provided", + }) + return + } + defer file.Close() + + // Read file contents and compute hash + fileBytes, err := io.ReadAll(file) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{ + "status": "error", + "why": "failed to read file", + }) + return + } + + // Compute SHA256 hash + hasher := sha256.New() + hasher.Write(fileBytes) + fileHash := hex.EncodeToString(hasher.Sum(nil)) + + // Create storage directory structure (first 2 chars of hash) + storageDir := filepath.Join(*storagePath, fileHash[:2], fileHash[2:4]) + if err := os.MkdirAll(storageDir, 0755); err != nil { + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{ + "status": "error", + "why": "failed to create storage directory", + }) + return + } + + // Storage path: storage/ab/cd/abcdef123456... + storagePath := filepath.Join(storageDir, fileHash) + + // Write file to disk (only if it doesn't already exist - deduplication!) + if _, err := os.Stat(storagePath); os.IsNotExist(err) { + if err := os.WriteFile(storagePath, fileBytes, 0644); err != nil { + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{ + "status": "error", + "why": "failed to save file", + }) + return + } + } + + // Get file path from form (or default to root) + filePath := r.FormValue("path") + if filePath == "" { + filePath = "/" + header.Filename + } + + // Detect MIME type + mimeType := http.DetectContentType(fileBytes) + + // Insert into database + insertQuery := ` + INSERT INTO files (name, path, is_directory, size, mime_type, hash, storage_path, owner_id) + VALUES (?, ?, 0, ?, ?, ?, ?, ?) + ` + _, err = db.Exec(insertQuery, + header.Filename, + filePath, + len(fileBytes), + mimeType, + fileHash, + storagePath, + userID, + ) + + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{ + "status": "error", + "why": "failed to save metadata", + }) + return + } + + json.NewEncoder(w).Encode(map[string]any{ + "status": "success", + "hash": fileHash, + "size": len(fileBytes), + }) + }) + + + http.HandleFunc("/api/create_session", func(w http.ResponseWriter, r *http.Request) { + var user User + w.Header().Set("Content-Type", "application/json") + + // Decode JSON + if err := json.NewDecoder(r.Body).Decode(&user); err != nil { + w.WriteHeader(http.StatusBadRequest) + fmt.Fprintf(w, `{ + "status": "error", + "description": "bad JSON", + "why": "%s" + }`, err.Error()) + return + } + + // Authenticate user + var hashedPassword string + authQuery := `SELECT id, password FROM users WHERE username = ?` + if err := db.QueryRow(authQuery, user.Username).Scan(&user.ID, &hashedPassword); err != nil { + w.WriteHeader(http.StatusUnauthorized) + fmt.Fprintf(w, `{ + "status": "error", + "description": "user not found", + "why": "%s" + }`, err.Error()) + return + } + + if err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(user.Password)); err != nil { + w.WriteHeader(http.StatusUnauthorized) + fmt.Fprintf(w, `{ + "status": "error", + "description": "incorrect password", + "why": "%s" + }`, err.Error()) + return + } + + tokenBytes := make([]byte, 64) + rand.Read(tokenBytes) + token := hex.EncodeToString(tokenBytes) + expiresAt := time.Now().Add(30 * 24 * time.Hour) + + _, err := db.Exec(`INSERT INTO tokens (user_id, token, expires_at) VALUES (?, ?, ?)`, user.ID, token, expiresAt) + + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprintf(w, `{ + "status": "error", + "description": "internal server error", + "why": "%s" + }`, err.Error()) + return + } + + fmt.Fprintf(w, `{ + "status": "success", + "token": "%s" + }`, token) + }) + + http.ListenAndServe(strings.Join([]string{":", strconv.Itoa(*port)}, ""), nil) +}