Compare commits

..
14 Commits
29 changed files with 3636 additions and 3 deletions
+38
View File
@@ -0,0 +1,38 @@
package main
import (
"flag"
"log"
"os"
"path/filepath"
"yoresee_dropbox/internal/app"
)
func main() {
addr := flag.String("addr", "127.0.0.1:8999", "Listen address")
workspace := flag.String("workspace", "./workspace", "Workspace directory")
token := flag.String("token", "", "Access token")
flag.Parse()
if *token == "" {
*token = os.Getenv("YDROPBOX_TOKEN")
}
if *token == "" {
log.Fatal("Token required: --token or YDROPBOX_TOKEN env")
}
absWorkspace, _ := filepath.Abs(*workspace)
os.MkdirAll(filepath.Join(absWorkspace, "inbox"), 0755)
os.MkdirAll(filepath.Join(absWorkspace, "outbox"), 0755)
os.MkdirAll(filepath.Join(absWorkspace, ".ydropbox"), 0755)
cfg := app.Config{
Addr: *addr,
Workspace: absWorkspace,
Token: *token,
}
if err := app.Run(cfg); err != nil {
log.Fatal(err)
}
}
File diff suppressed because it is too large Load Diff
@@ -135,7 +135,7 @@ CREATE TABLE shares (
## 7. 部署
1. **构建**: `CGO_ENABLED=0 go build -o yDropbox .`,纯静态单文件。
1. **构建**: `CGO_ENABLED=0 go build -o yDropbox ./cmd/ydropbox`,纯静态单文件。
2. **运行**: systemd 服务,`ExecStart=/usr/local/bin/yDropbox listen --addr=127.0.0.1:8999`,`YDROPBOX_TOKEN` 经 systemd 环境或 credentials 注入;数据目录指向服务器上固定路径(如 `/var/lib/ydropbox/workspace`,`--workspace` 可配)。
3. **反代**: nginx 站点将 HTTPS 子域名反代至 `127.0.0.1:8999`,配置 `client_max_body_size 100m`,certbot 签发续期证书。
4. **DNS**: 子域名 A 记录指向反代服务器公网 IP(具体域名/IP 由部署时确定,不入仓库文档)。
@@ -144,15 +144,22 @@ CREATE TABLE shares (
```
yoresee_dropbox/
├── main.go # 入口: flag/env 解析、路由注册、启动 scanner
├── cmd/ydropbox/main.go # 入口: 仅 flag/env 解析 → app.Run(cfg)
├── go.mod / go.sum
├── internal/
│ ├── app/app.go # 装配: 开库、建路由(server.New)、起 scanner、监听
│ ├── server/
│ │ ├── server.go # New(): 路由注册
│ │ ├── auth.go # 登录/session/锁定
│ │ ├── files.go # 上传/列表/下载/删除
│ │ └── share.go # 分享创建/撤销/公开访问
│ ├── store/store.go # SQLite 层 (modernc.org/sqlite)
│ └── scanner/scanner.go # inbox+outbox 目录对账
├── web/ # go:embed:index.html, style.css, app.js, alpine.min.js(vendor)
├── web/
│ ├── web.go # //go:embed 挂载点,导出 FS
│ ├── index.html
│ ├── style.css
│ ├── app.js
│ └── alpine.min.js # vendor
└── workspace/ # 运行期生成(gitignore): inbox/, outbox/, .ydropbox/db.sqlite
```
+17
View File
@@ -0,0 +1,17 @@
module yoresee_dropbox
go 1.25.0
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/crypto v0.55.0 // indirect
golang.org/x/sys v0.47.0 // indirect
modernc.org/libc v1.74.4 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.57.0 // indirect
)
+22
View File
@@ -0,0 +1,22 @@
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/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.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/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.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
modernc.org/libc v1.74.4 h1:fX1Omw4o2/1C2iRkkIsrQTasJQldLhRmuPreXLoWs9k=
modernc.org/libc v1.74.4/go.mod h1:eeQAS9W3sZeKYMFubydxJpII9ybHWshk+7or7bLG9co=
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.57.0 h1:qNQP6xnx5M0ISNtlnxoOX0+cD5bJ0/gr9aMmndFczzg=
modernc.org/sqlite v1.57.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ=
+42
View File
@@ -0,0 +1,42 @@
package app
import (
"log"
"net/http"
"path/filepath"
"time"
"yoresee_dropbox/internal/scanner"
"yoresee_dropbox/internal/server"
"yoresee_dropbox/internal/store"
)
type Config struct {
Addr string
Workspace string
Token string
}
func NewHandler(cfg Config) (http.Handler, *store.Store, error) {
dbPath := filepath.Join(cfg.Workspace, ".ydropbox", "db.sqlite")
s, err := store.Open(dbPath)
if err != nil {
return nil, nil, err
}
sc := scanner.New(s, cfg.Workspace, 10*time.Second)
sc.Start()
srv := server.New(s, cfg.Token, cfg.Workspace)
return srv.Handler(), s, nil
}
func Run(cfg Config) error {
handler, s, err := NewHandler(cfg)
if err != nil {
return err
}
defer s.Close()
log.Printf("Listening on %s", cfg.Addr)
return http.ListenAndServe(cfg.Addr, handler)
}
+109
View File
@@ -0,0 +1,109 @@
package scanner
import (
"crypto/rand"
"encoding/hex"
"os"
"path/filepath"
"time"
"yoresee_dropbox/internal/store"
)
type Scanner struct {
store *store.Store
workspace string
interval time.Duration
stopCh chan struct{}
}
func New(s *store.Store, workspace string, interval time.Duration) *Scanner {
return &Scanner{
store: s,
workspace: workspace,
interval: interval,
stopCh: make(chan struct{}),
}
}
func (sc *Scanner) Start() {
go sc.run()
}
func (sc *Scanner) Stop() {
close(sc.stopCh)
}
func (sc *Scanner) run() {
ticker := time.NewTicker(sc.interval)
defer ticker.Stop()
sc.scan()
for {
select {
case <-sc.stopCh:
return
case <-ticker.C:
sc.scan()
}
}
}
func (sc *Scanner) scan() {
sc.scanDir("inbox")
sc.scanDir("outbox")
}
func (sc *Scanner) scanDir(dir string) {
dirPath := filepath.Join(sc.workspace, dir)
entries, err := os.ReadDir(dirPath)
if err != nil {
return
}
dbFiles, err := sc.store.FileList(dir)
if err != nil {
return
}
dbMap := make(map[string]*store.File)
for _, f := range dbFiles {
dbMap[f.StorageName] = f
}
diskMap := make(map[string]bool)
for _, entry := range entries {
if entry.IsDir() {
continue
}
info, err := entry.Info()
if err != nil {
continue
}
diskMap[entry.Name()] = true
if _, exists := dbMap[entry.Name()]; !exists {
id := generateUUID()
f := &store.File{
ID: id,
OriginalName: entry.Name(),
StorageName: entry.Name(),
Dir: dir,
Size: info.Size(),
CreatedAt: time.Now().Unix(),
}
sc.store.FileCreate(f)
}
}
for name, f := range dbMap {
if !diskMap[name] {
sc.store.FileDelete(f.ID)
}
}
}
func generateUUID() string {
b := make([]byte, 16)
rand.Read(b)
return hex.EncodeToString(b)
}
+44
View File
@@ -0,0 +1,44 @@
package scanner
import (
"os"
"path/filepath"
"testing"
"time"
"yoresee_dropbox/internal/store"
)
func TestScannerReconciles(t *testing.T) {
dir := t.TempDir()
inbox := filepath.Join(dir, "inbox")
outbox := filepath.Join(dir, "outbox")
os.MkdirAll(inbox, 0755)
os.MkdirAll(outbox, 0755)
dbPath := filepath.Join(dir, "test.db")
s, err := store.Open(dbPath)
if err != nil {
t.Fatal(err)
}
defer s.Close()
// Create a file on disk
testFile := filepath.Join(inbox, "test.txt")
os.WriteFile(testFile, []byte("hello"), 0644)
sc := New(s, dir, 100*time.Millisecond)
sc.Start()
time.Sleep(300 * time.Millisecond)
sc.Stop()
files, err := s.FileList("inbox")
if err != nil {
t.Fatal(err)
}
if len(files) != 1 {
t.Errorf("Expected 1 file, got %d", len(files))
}
if files[0].OriginalName != "test.txt" {
t.Errorf("OriginalName = %q, want %q", files[0].OriginalName, "test.txt")
}
}
+94
View File
@@ -0,0 +1,94 @@
package server
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"net/http"
"time"
)
type loginRequest struct {
Token string `json:"token"`
}
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
ip := r.RemoteAddr
s.mu.Lock()
if lockTime, locked := s.loginLockout[ip]; locked && time.Now().Before(lockTime) {
s.mu.Unlock()
http.Error(w, "Too many attempts, locked for 60s", http.StatusForbidden)
return
}
s.mu.Unlock()
var req loginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
if req.Token != s.accessToken {
s.mu.Lock()
s.loginAttempts[ip]++
if s.loginAttempts[ip] >= 5 {
s.loginLockout[ip] = time.Now().Add(60 * time.Second)
s.loginAttempts[ip] = 0
}
s.mu.Unlock()
http.Error(w, "Invalid token", http.StatusUnauthorized)
return
}
s.mu.Lock()
delete(s.loginAttempts, ip)
delete(s.loginLockout, ip)
s.mu.Unlock()
sessionID := generateSessionID()
s.mu.Lock()
s.sessions[sessionID] = time.Now().Unix()
s.mu.Unlock()
http.SetCookie(w, &http.Cookie{
Name: "ydropbox_session",
Value: sessionID,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: true,
MaxAge: 7 * 24 * 60 * 60,
})
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{})
}
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie("ydropbox_session")
if err != nil {
http.Error(w, "Not logged in", http.StatusUnauthorized)
return
}
s.mu.Lock()
delete(s.sessions, cookie.Value)
s.mu.Unlock()
http.SetCookie(w, &http.Cookie{
Name: "ydropbox_session",
Value: "",
Path: "/",
MaxAge: -1,
})
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{})
}
func generateSessionID() string {
b := make([]byte, 32)
rand.Read(b)
return hex.EncodeToString(b)
}
+50
View File
@@ -0,0 +1,50 @@
package server
import (
"bytes"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestLoginSuccess(t *testing.T) {
s := &Server{
accessToken: "secret123",
sessions: make(map[string]int64),
loginAttempts: make(map[string]int),
loginLockout: make(map[string]time.Time),
}
req := httptest.NewRequest("POST", "/api/login", bytes.NewBufferString(`{"token":"secret123"}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
s.handleLogin(w, req)
if w.Code != http.StatusOK {
t.Errorf("Status = %d, want %d", w.Code, http.StatusOK)
}
if len(w.Header().Values("Set-Cookie")) == 0 {
t.Error("Expected Set-Cookie header")
}
}
func TestLoginFailure(t *testing.T) {
s := &Server{
accessToken: "secret123",
sessions: make(map[string]int64),
loginAttempts: make(map[string]int),
loginLockout: make(map[string]time.Time),
}
req := httptest.NewRequest("POST", "/api/login", bytes.NewBufferString(`{"token":"wrong"}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
s.handleLogin(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("Status = %d, want %d", w.Code, http.StatusUnauthorized)
}
}
+94
View File
@@ -0,0 +1,94 @@
package server
import (
"encoding/json"
"io"
"net/http"
"os"
"path/filepath"
"time"
"yoresee_dropbox/internal/store"
)
func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, 100<<20)
if err := r.ParseMultipartForm(32 << 20); err != nil {
http.Error(w, "File too large", http.StatusRequestEntityTooLarge)
return
}
file, header, err := r.FormFile("file")
if err != nil {
http.Error(w, "Missing file", http.StatusBadRequest)
return
}
defer file.Close()
storageName := generateUUID()
destPath := filepath.Join(s.workspace, "inbox", storageName)
dest, err := os.Create(destPath)
if err != nil {
http.Error(w, "Failed to save", http.StatusInternalServerError)
return
}
defer dest.Close()
if _, err := io.Copy(dest, file); err != nil {
http.Error(w, "Failed to save", http.StatusInternalServerError)
return
}
f := &store.File{
ID: generateUUID(),
OriginalName: header.Filename,
StorageName: storageName,
Dir: "inbox",
Size: header.Size,
CreatedAt: time.Now().Unix(),
}
if err := s.store.FileCreate(f); err != nil {
http.Error(w, "Failed to save metadata", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]any{"file": f})
}
func (s *Server) handleListFiles(w http.ResponseWriter, r *http.Request) {
dir := r.URL.Query().Get("dir")
files, err := s.store.FileList(dir)
if err != nil {
http.Error(w, "Failed to list", http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(map[string]any{"files": files})
}
func (s *Server) handleDownload(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
f, err := s.store.FileGet(id)
if err != nil {
http.Error(w, "Not found", http.StatusNotFound)
return
}
path := filepath.Join(s.workspace, f.Dir, f.StorageName)
w.Header().Set("Content-Disposition", "attachment; filename="+f.OriginalName)
http.ServeFile(w, r, path)
}
func (s *Server) handleDeleteFile(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
f, err := s.store.FileGet(id)
if err != nil {
http.Error(w, "Not found", http.StatusNotFound)
return
}
if err := s.store.FileDelete(id); err != nil {
http.Error(w, "Failed to delete", http.StatusInternalServerError)
return
}
os.Remove(filepath.Join(s.workspace, f.Dir, f.StorageName))
w.WriteHeader(http.StatusNoContent)
}
+41
View File
@@ -0,0 +1,41 @@
package server
import (
"bytes"
"mime/multipart"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"yoresee_dropbox/internal/store"
)
func TestUpload(t *testing.T) {
dir := t.TempDir()
workspace := filepath.Join(dir, "workspace")
os.MkdirAll(filepath.Join(workspace, "inbox"), 0755)
s, _ := store.Open(filepath.Join(dir, "test.db"))
defer s.Close()
srv := &Server{store: s, accessToken: "token", sessions: make(map[string]int64), workspace: workspace}
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
part, _ := writer.CreateFormFile("file", "test.txt")
part.Write([]byte("hello"))
writer.Close()
req := httptest.NewRequest("POST", "/api/upload", body)
req.Header.Set("Content-Type", writer.FormDataContentType())
req.AddCookie(&http.Cookie{Name: "ydropbox_session", Value: "valid"})
srv.sessions["valid"] = 1
w := httptest.NewRecorder()
srv.handleUpload(w, req)
if w.Code != http.StatusCreated {
t.Errorf("Status = %d, want %d", w.Code, http.StatusCreated)
}
}
+64
View File
@@ -0,0 +1,64 @@
package server
import (
"net/http"
"time"
)
const sessionMaxAge = 7 * 86400
func (s *Server) requireAuth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie("ydropbox_session")
if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
s.mu.Lock()
createdAt, exists := s.sessions[cookie.Value]
if exists && time.Now().Unix()-createdAt > sessionMaxAge {
delete(s.sessions, cookie.Value)
exists = false
}
if exists {
s.sessions[cookie.Value] = time.Now().Unix()
}
s.mu.Unlock()
if !exists {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
next(w, r)
}
}
func (s *Server) requireAuthPage(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie("ydropbox_session")
if err != nil {
http.Redirect(w, r, "/login", http.StatusFound)
return
}
s.mu.Lock()
createdAt, exists := s.sessions[cookie.Value]
if exists && time.Now().Unix()-createdAt > sessionMaxAge {
delete(s.sessions, cookie.Value)
exists = false
}
if exists {
s.sessions[cookie.Value] = time.Now().Unix()
}
s.mu.Unlock()
if !exists {
http.Redirect(w, r, "/login", http.StatusFound)
return
}
next(w, r)
}
}
+77
View File
@@ -0,0 +1,77 @@
package server
import (
"crypto/rand"
"encoding/hex"
"net/http"
"sync"
"time"
"yoresee_dropbox/internal/store"
"yoresee_dropbox/web"
)
type Server struct {
store *store.Store
accessToken string
sessions map[string]int64
shareSessions map[string]string
loginAttempts map[string]int
loginLockout map[string]time.Time
shareAttempts map[string]int
shareLockout map[string]time.Time
mu sync.Mutex
workspace string
}
func New(store *store.Store, accessToken string, workspace string) *Server {
return &Server{
store: store,
accessToken: accessToken,
sessions: make(map[string]int64),
shareSessions: make(map[string]string),
loginAttempts: make(map[string]int),
loginLockout: make(map[string]time.Time),
shareAttempts: make(map[string]int),
shareLockout: make(map[string]time.Time),
workspace: workspace,
}
}
func generateUUID() string {
b := make([]byte, 16)
rand.Read(b)
return hex.EncodeToString(b)
}
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("POST /api/login", s.handleLogin)
mux.HandleFunc("POST /api/logout", s.requireAuth(s.handleLogout))
mux.HandleFunc("POST /api/upload", s.requireAuth(s.handleUpload))
mux.HandleFunc("GET /api/files", s.requireAuth(s.handleListFiles))
mux.HandleFunc("GET /api/files/{id}/download", s.requireAuth(s.handleDownload))
mux.HandleFunc("DELETE /api/files/{id}", s.requireAuth(s.handleDeleteFile))
mux.HandleFunc("POST /api/files/{id}/share", s.requireAuth(s.handleCreateShare))
mux.HandleFunc("GET /api/shares", s.requireAuth(s.handleListShares))
mux.HandleFunc("DELETE /api/shares/{id}", s.requireAuth(s.handleDeleteShare))
mux.HandleFunc("GET /s/{token}", s.handleShareAccess)
mux.HandleFunc("POST /s/{token}", s.handleSharePassword)
mux.HandleFunc("GET /login", func(w http.ResponseWriter, r *http.Request) {
data, _ := web.FS.ReadFile("login.html")
w.Write(data)
})
mux.HandleFunc("GET /{$}", s.requireAuthPage(func(w http.ResponseWriter, r *http.Request) {
data, _ := web.FS.ReadFile("index.html")
w.Write(data)
}))
mux.Handle("GET /style.css", http.FileServerFS(web.FS))
mux.Handle("GET /app.js", http.FileServerFS(web.FS))
mux.Handle("GET /alpine.min.js", http.FileServerFS(web.FS))
return mux
}
+183
View File
@@ -0,0 +1,183 @@
package server
import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"net/http"
"path/filepath"
"time"
"golang.org/x/crypto/bcrypt"
"yoresee_dropbox/internal/store"
)
type createShareRequest struct {
Password string `json:"password"`
ExpiresInHours int `json:"expires_in_hours"`
}
func (s *Server) handleCreateShare(w http.ResponseWriter, r *http.Request) {
fileID := r.PathValue("id")
var req createShareRequest
json.NewDecoder(r.Body).Decode(&req)
token := generateShareToken()
share := &store.Share{
ID: generateUUID(),
FileID: fileID,
Token: token,
CreatedAt: time.Now().Unix(),
}
if req.Password != "" {
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), 10)
if err != nil {
http.Error(w, "Failed to hash password", http.StatusInternalServerError)
return
}
share.PasswordHash = string(hash)
}
if req.ExpiresInHours > 0 {
share.ExpiresAt = time.Now().Add(time.Duration(req.ExpiresInHours) * time.Hour).Unix()
}
if err := s.store.ShareCreate(share); err != nil {
http.Error(w, "Failed to create share", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]any{
"url": "/s/" + token,
"token": token,
})
}
func (s *Server) handleListShares(w http.ResponseWriter, r *http.Request) {
shares, err := s.store.ShareList()
if err != nil {
http.Error(w, "Failed to list", http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(map[string]any{"shares": shares})
}
func (s *Server) handleDeleteShare(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if err := s.store.ShareDelete(id); err != nil {
http.Error(w, "Failed to delete", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleShareAccess(w http.ResponseWriter, r *http.Request) {
token := r.PathValue("token")
share, err := s.store.ShareGetByToken(token)
if err != nil {
http.Error(w, "Not found", http.StatusNotFound)
return
}
if share.ExpiresAt > 0 && time.Now().Unix() > share.ExpiresAt {
s.store.ShareDelete(share.ID)
http.Error(w, "Share expired", http.StatusGone)
return
}
if share.PasswordHash != "" {
cookie, err := r.Cookie("ydropbox_share_" + share.ID)
if err != nil || !s.verifyShareCookie(share.ID, cookie.Value) {
w.Header().Set("Content-Type", "text/html")
w.Write([]byte(`<!DOCTYPE html><html><body>
<form method="POST"><input type="password" name="password"><button>Submit</button></form>
</body></html>`))
return
}
}
s.serveSharedFile(w, r, share)
}
func (s *Server) handleSharePassword(w http.ResponseWriter, r *http.Request) {
token := r.PathValue("token")
share, err := s.store.ShareGetByToken(token)
if err != nil {
http.Error(w, "Not found", http.StatusNotFound)
return
}
key := share.ID
s.mu.Lock()
if lockTime, locked := s.shareLockout[key]; locked && time.Now().Before(lockTime) {
s.mu.Unlock()
http.Error(w, "Too many attempts, locked for 60s", http.StatusForbidden)
return
}
s.mu.Unlock()
time.Sleep(500 * time.Millisecond)
password := r.FormValue("password")
if err := bcrypt.CompareHashAndPassword([]byte(share.PasswordHash), []byte(password)); err != nil {
s.mu.Lock()
s.shareAttempts[key]++
if s.shareAttempts[key] >= 5 {
s.shareLockout[key] = time.Now().Add(60 * time.Second)
s.shareAttempts[key] = 0
}
s.mu.Unlock()
http.Error(w, "Wrong password", http.StatusForbidden)
return
}
s.mu.Lock()
delete(s.shareAttempts, key)
delete(s.shareLockout, key)
s.mu.Unlock()
cookieVal := generateSessionID()
s.mu.Lock()
if s.shareSessions == nil {
s.shareSessions = make(map[string]string)
}
s.shareSessions[cookieVal] = share.ID
s.mu.Unlock()
http.SetCookie(w, &http.Cookie{
Name: "ydropbox_share_" + share.ID,
Value: cookieVal,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
MaxAge: 3600,
})
http.Redirect(w, r, "/s/"+token, http.StatusFound)
}
func (s *Server) serveSharedFile(w http.ResponseWriter, r *http.Request, share *store.Share) {
f, err := s.store.FileGet(share.FileID)
if err != nil {
http.Error(w, "File not found", http.StatusNotFound)
return
}
path := filepath.Join(s.workspace, f.Dir, f.StorageName)
w.Header().Set("Content-Disposition", "attachment; filename="+f.OriginalName)
w.Header().Set("X-Content-Type-Options", "nosniff")
http.ServeFile(w, r, path)
}
func (s *Server) verifyShareCookie(shareID, cookieVal string) bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.shareSessions[cookieVal] == shareID
}
func generateShareToken() string {
b := make([]byte, 24)
rand.Read(b)
return base64.URLEncoding.EncodeToString(b)
}
+100
View File
@@ -0,0 +1,100 @@
package server
import (
"bytes"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"yoresee_dropbox/internal/store"
)
func TestCreateShare(t *testing.T) {
dir := t.TempDir()
workspace := filepath.Join(dir, "workspace")
os.MkdirAll(filepath.Join(workspace, "inbox"), 0755)
s, _ := store.Open(filepath.Join(dir, "test.db"))
defer s.Close()
f := &store.File{ID: "file-1", OriginalName: "doc.pdf", StorageName: "uuid-1", Dir: "inbox", Size: 100, CreatedAt: 1750000000}
s.FileCreate(f)
srv := &Server{store: s, workspace: workspace, sessions: make(map[string]int64), shareSessions: make(map[string]string)}
body := bytes.NewBufferString(`{"file_id":"file-1"}`)
req := httptest.NewRequest("POST", "/api/files/file-1/share", body)
req.Header.Set("Content-Type", "application/json")
req.AddCookie(&http.Cookie{Name: "ydropbox_session", Value: "valid"})
srv.sessions["valid"] = 1
mux := http.NewServeMux()
mux.HandleFunc("POST /api/files/{id}/share", srv.handleCreateShare)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Errorf("Status = %d, want %d", w.Code, http.StatusCreated)
}
}
func TestShareAccessNoPassword(t *testing.T) {
dir := t.TempDir()
workspace := filepath.Join(dir, "workspace")
os.MkdirAll(filepath.Join(workspace, "inbox"), 0755)
os.WriteFile(filepath.Join(workspace, "inbox", "uuid-1"), []byte("content"), 0644)
s, _ := store.Open(filepath.Join(dir, "test.db"))
defer s.Close()
f := &store.File{ID: "file-1", OriginalName: "doc.pdf", StorageName: "uuid-1", Dir: "inbox", Size: 7, CreatedAt: 1750000000}
s.FileCreate(f)
sh := &store.Share{ID: "share-1", FileID: "file-1", Token: "tok-abc", CreatedAt: 1750000000}
s.ShareCreate(sh)
srv := &Server{store: s, workspace: workspace, sessions: make(map[string]int64), shareSessions: make(map[string]string)}
req := httptest.NewRequest("GET", "/s/tok-abc", nil)
mux := http.NewServeMux()
mux.HandleFunc("GET /s/{token}", srv.handleShareAccess)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Status = %d, want %d", w.Code, http.StatusOK)
}
}
func TestShareAccessExpired(t *testing.T) {
dir := t.TempDir()
workspace := filepath.Join(dir, "workspace")
os.MkdirAll(filepath.Join(workspace, "inbox"), 0755)
s, _ := store.Open(filepath.Join(dir, "test.db"))
defer s.Close()
f := &store.File{ID: "file-1", OriginalName: "doc.pdf", StorageName: "uuid-1", Dir: "inbox", Size: 7, CreatedAt: 1750000000}
s.FileCreate(f)
sh := &store.Share{ID: "share-1", FileID: "file-1", Token: "tok-exp", ExpiresAt: 1, CreatedAt: 1750000000}
s.ShareCreate(sh)
srv := &Server{store: s, workspace: workspace, sessions: make(map[string]int64), shareSessions: make(map[string]string)}
req := httptest.NewRequest("GET", "/s/tok-exp", nil)
mux := http.NewServeMux()
mux.HandleFunc("GET /s/{token}", srv.handleShareAccess)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusGone {
t.Errorf("Status = %d, want %d", w.Code, http.StatusGone)
}
}
+71
View File
@@ -0,0 +1,71 @@
package store
import "database/sql"
type File struct {
ID string `json:"id"`
OriginalName string `json:"name"`
StorageName string `json:"-"`
Dir string `json:"dir"`
Size int64 `json:"size"`
CreatedAt int64 `json:"created_at"`
}
func (s *Store) FileCreate(f *File) error {
_, err := s.db.Exec(
`INSERT INTO files (id, original_name, storage_name, dir, size, created_at)
VALUES (?, ?, ?, ?, ?, ?)`,
f.ID, f.OriginalName, f.StorageName, f.Dir, f.Size, f.CreatedAt,
)
return err
}
func (s *Store) FileGet(id string) (*File, error) {
row := s.db.QueryRow(
`SELECT id, original_name, storage_name, dir, size, created_at
FROM files WHERE id = ?`, id,
)
return scanFile(row)
}
func (s *Store) FileList(dir string) ([]*File, error) {
query := `SELECT id, original_name, storage_name, dir, size, created_at FROM files`
var args []any
if dir != "" {
query += ` WHERE dir = ?`
args = []any{dir}
}
query += ` ORDER BY created_at DESC`
rows, err := s.db.Query(query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var files []*File
for rows.Next() {
f, err := scanFile(rows)
if err != nil {
return nil, err
}
files = append(files, f)
}
return files, rows.Err()
}
func (s *Store) FileDelete(id string) error {
_, err := s.db.Exec(`DELETE FROM files WHERE id = ?`, id)
return err
}
func scanFile(row interface {
Scan(dest ...any) error
}) (*File, error) {
f := &File{}
err := row.Scan(&f.ID, &f.OriginalName, &f.StorageName, &f.Dir, &f.Size, &f.CreatedAt)
if err == sql.ErrNoRows {
return nil, err
}
return f, err
}
+36
View File
@@ -0,0 +1,36 @@
package store
import (
"path/filepath"
"testing"
)
func TestFileCreate(t *testing.T) {
dir := t.TempDir()
s, err := Open(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatal(err)
}
defer s.Close()
f := &File{
ID: "test-id",
OriginalName: "test.pdf",
StorageName: "storage-uuid",
Dir: "inbox",
Size: 1024,
CreatedAt: 1750000000,
}
if err := s.FileCreate(f); err != nil {
t.Fatalf("FileCreate failed: %v", err)
}
got, err := s.FileGet("test-id")
if err != nil {
t.Fatalf("FileGet failed: %v", err)
}
if got.OriginalName != "test.pdf" {
t.Errorf("OriginalName = %q, want %q", got.OriginalName, "test.pdf")
}
}
+67
View File
@@ -0,0 +1,67 @@
package store
import "database/sql"
type Share struct {
ID string
FileID string
Token string
PasswordHash string
ExpiresAt int64
CreatedAt int64
LastAccessedAt int64
}
func (s *Store) ShareCreate(sh *Share) error {
_, err := s.db.Exec(
`INSERT INTO shares (id, file_id, token, password_hash, expires_at, created_at, last_accessed_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
sh.ID, sh.FileID, sh.Token, sh.PasswordHash, sh.ExpiresAt, sh.CreatedAt, sh.LastAccessedAt,
)
return err
}
func (s *Store) ShareGetByToken(token string) (*Share, error) {
row := s.db.QueryRow(
`SELECT id, file_id, token, password_hash, expires_at, created_at, last_accessed_at
FROM shares WHERE token = ?`, token,
)
return scanShare(row)
}
func (s *Store) ShareList() ([]*Share, error) {
rows, err := s.db.Query(
`SELECT id, file_id, token, password_hash, expires_at, created_at, last_accessed_at
FROM shares ORDER BY created_at DESC`,
)
if err != nil {
return nil, err
}
defer rows.Close()
var shares []*Share
for rows.Next() {
sh, err := scanShare(rows)
if err != nil {
return nil, err
}
shares = append(shares, sh)
}
return shares, rows.Err()
}
func (s *Store) ShareDelete(id string) error {
_, err := s.db.Exec(`DELETE FROM shares WHERE id = ?`, id)
return err
}
func scanShare(row interface {
Scan(dest ...any) error
}) (*Share, error) {
sh := &Share{}
err := row.Scan(&sh.ID, &sh.FileID, &sh.Token, &sh.PasswordHash, &sh.ExpiresAt, &sh.CreatedAt, &sh.LastAccessedAt)
if err == sql.ErrNoRows {
return nil, err
}
return sh, err
}
+70
View File
@@ -0,0 +1,70 @@
package store
import (
"path/filepath"
"testing"
)
func TestShareCreate(t *testing.T) {
dir := t.TempDir()
s, err := Open(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatal(err)
}
defer s.Close()
f := &File{
ID: "file-1",
OriginalName: "doc.pdf",
StorageName: "uuid-1",
Dir: "inbox",
Size: 2048,
CreatedAt: 1750000000,
}
if err := s.FileCreate(f); err != nil {
t.Fatal(err)
}
share := &Share{
ID: "share-1",
FileID: "file-1",
Token: "token-abc",
CreatedAt: 1750000000,
}
if err := s.ShareCreate(share); err != nil {
t.Fatalf("ShareCreate failed: %v", err)
}
got, err := s.ShareGetByToken("token-abc")
if err != nil {
t.Fatalf("ShareGetByToken failed: %v", err)
}
if got.FileID != "file-1" {
t.Errorf("FileID = %q, want %q", got.FileID, "file-1")
}
}
func TestFileDeleteCascadesShares(t *testing.T) {
dir := t.TempDir()
s, err := Open(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatal(err)
}
defer s.Close()
f := &File{ID: "file-2", OriginalName: "x.pdf", StorageName: "uuid-2", Dir: "inbox", Size: 100, CreatedAt: 1750000000}
s.FileCreate(f)
sh := &Share{ID: "share-2", FileID: "file-2", Token: "tok-2", CreatedAt: 1750000000}
s.ShareCreate(sh)
if err := s.FileDelete("file-2"); err != nil {
t.Fatal(err)
}
_, err = s.ShareGetByToken("tok-2")
if err == nil {
t.Error("Share should be deleted after file deletion")
}
}
+58
View File
@@ -0,0 +1,58 @@
package store
import (
"database/sql"
_ "modernc.org/sqlite"
)
type Store struct {
db *sql.DB
}
func Open(dbPath string) (*Store, error) {
db, err := sql.Open("sqlite", dbPath)
if err != nil {
return nil, err
}
if _, err := db.Exec("PRAGMA foreign_keys = ON"); err != nil {
db.Close()
return nil, err
}
if err := migrate(db); err != nil {
db.Close()
return nil, err
}
return &Store{db: db}, nil
}
func (s *Store) Close() error {
return s.db.Close()
}
func migrate(db *sql.DB) error {
schema := `
CREATE TABLE IF NOT EXISTS files (
id TEXT PRIMARY KEY,
original_name TEXT NOT NULL,
storage_name TEXT NOT NULL UNIQUE,
dir TEXT NOT NULL CHECK (dir IN ('inbox','outbox')),
size INTEGER NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS shares (
id TEXT PRIMARY KEY,
file_id TEXT NOT NULL REFERENCES files(id) ON DELETE CASCADE,
token TEXT NOT NULL UNIQUE,
password_hash TEXT,
expires_at INTEGER,
created_at INTEGER NOT NULL,
last_accessed_at INTEGER
);
`
_, err := db.Exec(schema)
return err
}
+22
View File
@@ -0,0 +1,22 @@
package store
import (
"os"
"path/filepath"
"testing"
)
func TestOpen(t *testing.T) {
dir := t.TempDir()
dbPath := filepath.Join(dir, "test.db")
s, err := Open(dbPath)
if err != nil {
t.Fatalf("Open failed: %v", err)
}
defer s.Close()
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
t.Error("Database file not created")
}
}
+108
View File
@@ -0,0 +1,108 @@
package tests
import (
"bytes"
"encoding/json"
"mime/multipart"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"yoresee_dropbox/internal/app"
)
func TestIntegration(t *testing.T) {
dir := t.TempDir()
workspace := filepath.Join(dir, "workspace")
os.MkdirAll(filepath.Join(workspace, "inbox"), 0755)
os.MkdirAll(filepath.Join(workspace, "outbox"), 0755)
os.MkdirAll(filepath.Join(workspace, ".ydropbox"), 0755)
cfg := app.Config{
Workspace: workspace,
Token: "test-token",
}
handler, store, err := app.NewHandler(cfg)
if err != nil {
t.Fatal(err)
}
defer store.Close()
ts := httptest.NewServer(handler)
defer ts.Close()
client := ts.Client()
loginBody := bytes.NewBufferString(`{"token":"test-token"}`)
loginRes, err := client.Post(ts.URL+"/api/login", "application/json", loginBody)
if err != nil || loginRes.StatusCode != 200 {
t.Fatalf("Login failed: %v, status: %d", err, loginRes.StatusCode)
}
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
part, _ := writer.CreateFormFile("file", "test.txt")
part.Write([]byte("hello world"))
writer.Close()
uploadReq, _ := http.NewRequest("POST", ts.URL+"/api/upload", body)
uploadReq.Header.Set("Content-Type", writer.FormDataContentType())
for _, cookie := range loginRes.Cookies() {
uploadReq.AddCookie(cookie)
}
uploadRes, err := client.Do(uploadReq)
if err != nil || uploadRes.StatusCode != 201 {
t.Fatalf("Upload failed: %v, status: %d", err, uploadRes.StatusCode)
}
var uploadResp map[string]any
json.NewDecoder(uploadRes.Body).Decode(&uploadResp)
file := uploadResp["file"].(map[string]any)
fileID := file["id"].(string)
listReq, _ := http.NewRequest("GET", ts.URL+"/api/files?dir=inbox", nil)
for _, cookie := range loginRes.Cookies() {
listReq.AddCookie(cookie)
}
listRes, err := client.Do(listReq)
if err != nil || listRes.StatusCode != 200 {
t.Fatalf("List failed: %v, status: %d", err, listRes.StatusCode)
}
var listResp map[string]any
json.NewDecoder(listRes.Body).Decode(&listResp)
files := listResp["files"].([]any)
if len(files) != 1 {
t.Errorf("Expected 1 file, got %d", len(files))
}
dlReq, _ := http.NewRequest("GET", ts.URL+"/api/files/"+fileID+"/download", nil)
for _, cookie := range loginRes.Cookies() {
dlReq.AddCookie(cookie)
}
dlRes, err := client.Do(dlReq)
if err != nil || dlRes.StatusCode != 200 {
t.Fatalf("Download failed: %v, status: %d", err, dlRes.StatusCode)
}
shareReq, _ := http.NewRequest("POST", ts.URL+"/api/files/"+fileID+"/share", bytes.NewBufferString(`{}`))
shareReq.Header.Set("Content-Type", "application/json")
for _, cookie := range loginRes.Cookies() {
shareReq.AddCookie(cookie)
}
shareRes, err := client.Do(shareReq)
if err != nil || shareRes.StatusCode != 201 {
t.Fatalf("Share create failed: %v, status: %d", err, shareRes.StatusCode)
}
var shareResp map[string]any
json.NewDecoder(shareRes.Body).Decode(&shareResp)
shareURL := shareResp["url"].(string)
pubRes, err := client.Get(ts.URL + shareURL)
if err != nil || pubRes.StatusCode != 200 {
t.Fatalf("Public share access failed: %v, status: %d", err, pubRes.StatusCode)
}
}
+5
View File
File diff suppressed because one or more lines are too long
+38
View File
@@ -0,0 +1,38 @@
function app() {
return {
inboxFiles: [],
outboxFiles: [],
async loadFiles() {
const [inbox, outbox] = await Promise.all([
fetch('/api/files?dir=inbox').then(r => r.json()),
fetch('/api/files?dir=outbox').then(r => r.json())
]);
this.inboxFiles = inbox.files || [];
this.outboxFiles = outbox.files || [];
},
async upload(event, dir) {
const file = event.target.files[0];
const form = new FormData();
form.append('file', file);
form.append('dir', dir);
await fetch('/api/upload', {method: 'POST', body: form});
this.loadFiles();
},
download(id) {
window.location.href = '/api/files/' + id + '/download';
},
async deleteFile(id) {
await fetch('/api/files/' + id, {method: 'DELETE'});
this.loadFiles();
},
async share(id) {
const res = await fetch('/api/files/' + id + '/share', {method: 'POST'});
const data = await res.json();
alert('Share URL: ' + data.url);
},
async logout() {
await fetch('/api/logout', {method: 'POST'});
window.location.href = '/login';
}
};
}
+42
View File
@@ -0,0 +1,42 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>yDropbox</title>
<link rel="stylesheet" href="/style.css">
<script defer src="/alpine.min.js"></script>
</head>
<body>
<div x-data="app()" x-init="loadFiles()">
<header>
<h1>yDropbox</h1>
<button @click="logout()">Logout</button>
</header>
<section>
<h2>Inbox</h2>
<input type="file" @change="upload($event, 'inbox')">
<template x-for="file in inboxFiles" :key="file.id">
<div class="file-row">
<span x-text="file.original_name"></span>
<button @click="download(file.id)">Download</button>
<button @click="deleteFile(file.id)">Delete</button>
<button @click="share(file.id)">Share</button>
</div>
</template>
</section>
<section>
<h2>Outbox</h2>
<template x-for="file in outboxFiles" :key="file.id">
<div class="file-row">
<span x-text="file.original_name"></span>
<button @click="download(file.id)">Download</button>
<button @click="deleteFile(file.id)">Delete</button>
</div>
</template>
</section>
</div>
<script src="/app.js"></script>
</body>
</html>
+30
View File
@@ -0,0 +1,30 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>yDropbox Login</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<div class="container">
<h1>yDropbox</h1>
<form id="loginForm">
<input type="password" id="token" placeholder="Access Token" required>
<button type="submit">Login</button>
</form>
</div>
<script>
document.getElementById('loginForm').addEventListener('submit', async (e) => {
e.preventDefault();
const token = document.getElementById('token').value;
const res = await fetch('/api/login', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({token})
});
if (res.ok) window.location.href = '/';
else alert('Invalid token');
});
</script>
</body>
</html>
+8
View File
@@ -0,0 +1,8 @@
body { font-family: sans-serif; max-width: 800px; margin: 40px auto; padding: 0 20px; }
header { display: flex; justify-content: space-between; align-items: center; }
section { margin: 20px 0; }
.file-row { display: flex; gap: 10px; align-items: center; margin: 10px 0; }
button { cursor: pointer; }
.container { max-width: 400px; margin: 100px auto; }
form { display: flex; flex-direction: column; gap: 10px; }
input[type="password"] { padding: 8px; font-size: 16px; }
+6
View File
@@ -0,0 +1,6 @@
package web
import "embed"
//go:embed login.html index.html style.css app.js alpine.min.js
var FS embed.FS