feat(api): add API documentation and ShareX integration
- Add an API documentation page with curl and ShareX examples. - Implement a dynamic ShareX configuration endpoint (`/api/v1/sharex/warpbox-anonymous.sxcu`) that generates a `.sxcu` file pre-configured with the instance's base URL. - Update anonymous uploads to return a private management link (`manageUrl`) and a deletion link (`deleteUrl`) in JSON responses. - Update README with details on Stage 3 Anonymous Integrations. - Add styling for the new API documentation view and management details.
This commit is contained in:
@@ -40,15 +40,16 @@ type UploadOptions struct {
|
||||
}
|
||||
|
||||
type Box struct {
|
||||
ID string `json:"id"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
ExpiresAt time.Time `json:"expiresAt"`
|
||||
MaxDownloads int `json:"maxDownloads"`
|
||||
DownloadCount int `json:"downloadCount"`
|
||||
PasswordSalt string `json:"passwordSalt,omitempty"`
|
||||
PasswordHash string `json:"passwordHash,omitempty"`
|
||||
Obfuscate bool `json:"obfuscate"`
|
||||
Files []File `json:"files"`
|
||||
ID string `json:"id"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
ExpiresAt time.Time `json:"expiresAt"`
|
||||
MaxDownloads int `json:"maxDownloads"`
|
||||
DownloadCount int `json:"downloadCount"`
|
||||
PasswordSalt string `json:"passwordSalt,omitempty"`
|
||||
PasswordHash string `json:"passwordHash,omitempty"`
|
||||
DeleteTokenHash string `json:"deleteTokenHash,omitempty"`
|
||||
Obfuscate bool `json:"obfuscate"`
|
||||
Files []File `json:"files"`
|
||||
}
|
||||
|
||||
type File struct {
|
||||
@@ -66,6 +67,8 @@ type UploadResult struct {
|
||||
BoxID string `json:"boxId"`
|
||||
BoxURL string `json:"boxUrl"`
|
||||
ZipURL string `json:"zipUrl"`
|
||||
ManageURL string `json:"manageUrl"`
|
||||
DeleteURL string `json:"deleteUrl"`
|
||||
ExpiresAt string `json:"expiresAt"`
|
||||
Files []ResultFile `json:"files"`
|
||||
}
|
||||
@@ -169,6 +172,8 @@ func (s *UploadService) CreateBox(files []*multipart.FileHeader, opts UploadOpti
|
||||
Obfuscate: opts.ObfuscateMetadata && strings.TrimSpace(opts.Password) != "",
|
||||
Files: make([]File, 0, len(files)),
|
||||
}
|
||||
deleteToken := randomID(32)
|
||||
box.DeleteTokenHash = deleteTokenHash(box.ID, deleteToken)
|
||||
if strings.TrimSpace(opts.Password) != "" {
|
||||
salt, hash := hashPassword(opts.Password)
|
||||
box.PasswordSalt = salt
|
||||
@@ -227,7 +232,7 @@ func (s *UploadService) CreateBox(files []*multipart.FileHeader, opts UploadOpti
|
||||
"file_count", len(box.Files),
|
||||
)
|
||||
|
||||
return s.resultForBox(box), nil
|
||||
return s.resultForBox(box, deleteToken), nil
|
||||
}
|
||||
|
||||
func (s *UploadService) GetBox(id string) (Box, error) {
|
||||
@@ -327,6 +332,17 @@ func (s *UploadService) DeleteBox(boxID string) error {
|
||||
return s.DeleteBoxWithSource(boxID, "admin")
|
||||
}
|
||||
|
||||
func (s *UploadService) DeleteBoxWithToken(boxID, token string) error {
|
||||
box, err := s.GetBox(boxID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !s.VerifyDeleteToken(box, token) {
|
||||
return os.ErrPermission
|
||||
}
|
||||
return s.DeleteBoxWithSource(boxID, "anonymous-delete")
|
||||
}
|
||||
|
||||
func (s *UploadService) DeleteBoxWithSource(boxID, source string) error {
|
||||
if err := s.db.Update(func(tx *bbolt.Tx) error {
|
||||
return tx.Bucket(boxesBucket).Delete([]byte(boxID))
|
||||
@@ -381,6 +397,14 @@ func (s *UploadService) UnlockToken(box Box) string {
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func (s *UploadService) VerifyDeleteToken(box Box, token string) bool {
|
||||
if box.DeleteTokenHash == "" || strings.TrimSpace(token) == "" {
|
||||
return false
|
||||
}
|
||||
hash := deleteTokenHash(box.ID, token)
|
||||
return subtle.ConstantTimeCompare([]byte(hash), []byte(box.DeleteTokenHash)) == 1
|
||||
}
|
||||
|
||||
func (s *UploadService) CanDownload(box Box) error {
|
||||
if time.Now().UTC().After(box.ExpiresAt) {
|
||||
return fmt.Errorf("box has expired")
|
||||
@@ -462,7 +486,7 @@ func (s *UploadService) SaveBox(box Box) error {
|
||||
})
|
||||
}
|
||||
|
||||
func (s *UploadService) resultForBox(box Box) UploadResult {
|
||||
func (s *UploadService) resultForBox(box Box, deleteToken string) UploadResult {
|
||||
files := make([]ResultFile, 0, len(box.Files))
|
||||
for _, file := range box.Files {
|
||||
files = append(files, ResultFile{
|
||||
@@ -473,13 +497,18 @@ func (s *UploadService) resultForBox(box Box) UploadResult {
|
||||
})
|
||||
}
|
||||
|
||||
return UploadResult{
|
||||
result := UploadResult{
|
||||
BoxID: box.ID,
|
||||
BoxURL: fmt.Sprintf("%s/d/%s", s.baseURL, box.ID),
|
||||
ZipURL: fmt.Sprintf("%s/d/%s/zip", s.baseURL, box.ID),
|
||||
ExpiresAt: box.ExpiresAt.Format(time.RFC3339),
|
||||
Files: files,
|
||||
}
|
||||
if deleteToken != "" {
|
||||
result.ManageURL = fmt.Sprintf("%s/d/%s/manage/%s", s.baseURL, box.ID, deleteToken)
|
||||
result.DeleteURL = fmt.Sprintf("%s/d/%s/manage/%s/delete", s.baseURL, box.ID, deleteToken)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func writeUploadedFile(path string, source multipart.File, maxSize int64) error {
|
||||
@@ -519,6 +548,11 @@ func passwordHash(salt, password string) string {
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func deleteTokenHash(boxID, token string) string {
|
||||
sum := sha256.Sum256([]byte("warpbox-delete:" + boxID + ":" + token))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func previewKind(contentType string) string {
|
||||
switch {
|
||||
case strings.HasPrefix(contentType, "image/"):
|
||||
|
||||
124
backend/libs/services/upload_test.go
Normal file
124
backend/libs/services/upload_test.go
Normal file
@@ -0,0 +1,124 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"log/slog"
|
||||
"mime/multipart"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDeleteTokenVerification(t *testing.T) {
|
||||
service := newTestUploadService(t)
|
||||
result := createTestBox(t, service, "file.txt", "hello")
|
||||
box := getTestBox(t, service, result.BoxID)
|
||||
token := tokenFromManageURL(t, result.ManageURL)
|
||||
|
||||
if box.DeleteTokenHash == "" {
|
||||
t.Fatalf("DeleteTokenHash was not stored")
|
||||
}
|
||||
if strings.Contains(box.DeleteTokenHash, token) {
|
||||
t.Fatalf("DeleteTokenHash contains the raw token")
|
||||
}
|
||||
if !service.VerifyDeleteToken(box, token) {
|
||||
t.Fatalf("VerifyDeleteToken rejected the correct token")
|
||||
}
|
||||
if service.VerifyDeleteToken(box, "wrong-token") {
|
||||
t.Fatalf("VerifyDeleteToken accepted the wrong token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteBoxWithTokenRemovesMetadataAndFiles(t *testing.T) {
|
||||
service := newTestUploadService(t)
|
||||
result := createTestBox(t, service, "file.txt", "hello")
|
||||
box := getTestBox(t, service, result.BoxID)
|
||||
token := tokenFromManageURL(t, result.ManageURL)
|
||||
|
||||
if _, err := os.Stat(filepath.Join(service.filesDir, box.ID)); err != nil {
|
||||
t.Fatalf("box files were not created: %v", err)
|
||||
}
|
||||
if err := service.DeleteBoxWithToken(box.ID, "wrong-token"); err == nil {
|
||||
t.Fatalf("DeleteBoxWithToken accepted the wrong token")
|
||||
}
|
||||
if _, err := service.GetBox(box.ID); err != nil {
|
||||
t.Fatalf("box was deleted after wrong token: %v", err)
|
||||
}
|
||||
|
||||
if err := service.DeleteBoxWithToken(box.ID, token); err != nil {
|
||||
t.Fatalf("DeleteBoxWithToken returned error: %v", err)
|
||||
}
|
||||
if _, err := service.GetBox(box.ID); !os.IsNotExist(err) {
|
||||
t.Fatalf("GetBox after delete error = %v, want os.ErrNotExist", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(service.filesDir, box.ID)); !os.IsNotExist(err) {
|
||||
t.Fatalf("box directory still exists after delete: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func newTestUploadService(t *testing.T) *UploadService {
|
||||
t.Helper()
|
||||
service, err := NewUploadService(1024*1024, t.TempDir(), "http://example.test", slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
if err != nil {
|
||||
t.Fatalf("NewUploadService returned error: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := service.Close(); err != nil {
|
||||
t.Fatalf("Close returned error: %v", err)
|
||||
}
|
||||
})
|
||||
return service
|
||||
}
|
||||
|
||||
func createTestBox(t *testing.T, service *UploadService, filename, body string) UploadResult {
|
||||
t.Helper()
|
||||
result, err := service.CreateBox(testFileHeaders(t, "file", filename, body), UploadOptions{MaxDays: 1})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateBox returned error: %v", err)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func getTestBox(t *testing.T, service *UploadService, boxID string) Box {
|
||||
t.Helper()
|
||||
box, err := service.GetBox(boxID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetBox returned error: %v", err)
|
||||
}
|
||||
return box
|
||||
}
|
||||
|
||||
func testFileHeaders(t *testing.T, field, filename, body string) []*multipart.FileHeader {
|
||||
t.Helper()
|
||||
var payload bytes.Buffer
|
||||
writer := multipart.NewWriter(&payload)
|
||||
part, err := writer.CreateFormFile(field, filename)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateFormFile returned error: %v", err)
|
||||
}
|
||||
if _, err := part.Write([]byte(body)); err != nil {
|
||||
t.Fatalf("part.Write returned error: %v", err)
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatalf("writer.Close returned error: %v", err)
|
||||
}
|
||||
|
||||
request := httptest.NewRequest("POST", "/upload", &payload)
|
||||
request.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
if err := request.ParseMultipartForm(1024 * 1024); err != nil {
|
||||
t.Fatalf("ParseMultipartForm returned error: %v", err)
|
||||
}
|
||||
return request.MultipartForm.File[field]
|
||||
}
|
||||
|
||||
func tokenFromManageURL(t *testing.T, manageURL string) string {
|
||||
t.Helper()
|
||||
parts := strings.Split(strings.TrimRight(manageURL, "/"), "/")
|
||||
if len(parts) == 0 {
|
||||
t.Fatalf("empty manage URL")
|
||||
}
|
||||
return parts[len(parts)-1]
|
||||
}
|
||||
Reference in New Issue
Block a user