Compare commits
14
Commits
b55c5bb6b5
...
d988d35881
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d988d35881 | ||
|
|
b7af194492 | ||
|
|
f5a21d922d | ||
|
|
c24df57882 | ||
|
|
c59654fcaa | ||
|
|
7c10fdb50b | ||
|
|
04d054afdc | ||
|
|
af6aba3d37 | ||
|
|
2852097b26 | ||
|
|
ee9eaf8817 | ||
|
|
245497999e | ||
|
|
a547b69725 | ||
|
|
6c85b724d0 | ||
|
|
bfcaf489ba |
@@ -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. 部署
|
## 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` 可配)。
|
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 签发续期证书。
|
3. **反代**: nginx 站点将 HTTPS 子域名反代至 `127.0.0.1:8999`,配置 `client_max_body_size 100m`,certbot 签发续期证书。
|
||||||
4. **DNS**: 子域名 A 记录指向反代服务器公网 IP(具体域名/IP 由部署时确定,不入仓库文档)。
|
4. **DNS**: 子域名 A 记录指向反代服务器公网 IP(具体域名/IP 由部署时确定,不入仓库文档)。
|
||||||
@@ -144,15 +144,22 @@ CREATE TABLE shares (
|
|||||||
|
|
||||||
```
|
```
|
||||||
yoresee_dropbox/
|
yoresee_dropbox/
|
||||||
├── main.go # 入口: flag/env 解析、路由注册、启动 scanner
|
├── cmd/ydropbox/main.go # 入口: 仅 flag/env 解析 → app.Run(cfg)
|
||||||
├── go.mod / go.sum
|
├── go.mod / go.sum
|
||||||
├── internal/
|
├── internal/
|
||||||
|
│ ├── app/app.go # 装配: 开库、建路由(server.New)、起 scanner、监听
|
||||||
│ ├── server/
|
│ ├── server/
|
||||||
|
│ │ ├── server.go # New(): 路由注册
|
||||||
│ │ ├── auth.go # 登录/session/锁定
|
│ │ ├── auth.go # 登录/session/锁定
|
||||||
│ │ ├── files.go # 上传/列表/下载/删除
|
│ │ ├── files.go # 上传/列表/下载/删除
|
||||||
│ │ └── share.go # 分享创建/撤销/公开访问
|
│ │ └── share.go # 分享创建/撤销/公开访问
|
||||||
│ ├── store/store.go # SQLite 层 (modernc.org/sqlite)
|
│ ├── store/store.go # SQLite 层 (modernc.org/sqlite)
|
||||||
│ └── scanner/scanner.go # inbox+outbox 目录对账
|
│ └── 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
|
└── workspace/ # 运行期生成(gitignore): inbox/, outbox/, .ydropbox/db.sqlite
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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=
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+5
File diff suppressed because one or more lines are too long
+38
@@ -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';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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; }
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user