share folders and files

This commit is contained in:
m
2026-09-14 14:09:41 +02:00
parent 09669a1e7b
commit e9c85febca
29 changed files with 2289 additions and 101 deletions
+4 -2
View File
@@ -10,14 +10,16 @@ import (
)
// RegisterRoutes wires the full /api/v1 surface (public + protected).
// Public: /health, /devices, /auth/login. Everything else requires a user
// bearer token (subject = user_id, claim = device_id, cf. docs/api-v1.md §2).
// Public: /health, /devices, /auth/login, /shares/links/:token (résolution de
// lien anonyme). Everything else requires a user bearer token (subject =
// user_id, claim = device_id, cf. docs/api-v1.md §2).
func RegisterRoutes(r *gin.Engine) {
public := r.Group("/api/v1")
{
public.GET("/health", Health)
public.POST("/devices", DevicesRegister)
public.POST("/auth/login", AuthLogin)
public.GET("/shares/links/:token", ShareLinkGet)
}
protected := r.Group("/api/v1")
+31
View File
@@ -0,0 +1,31 @@
package handlers
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/vaultdrop/backend/pkg/api"
"github.com/vaultdrop/backend/repository"
)
// ShareLinkGet resolves a public share link without authentication
// (GET /shares/links/:token). Returns resource metadata only — never the
// owner's identity beyond what the contract exposes.
func ShareLinkGet(c *gin.Context) {
if Store == nil || Store.Repository == nil {
api.Error(c, http.StatusServiceUnavailable, "SERVICE_UNAVAILABLE", "backend not initialized")
return
}
token := c.Param("token")
link, err := Store.Repository.Shares.GetPublicLink(token)
if err != nil {
if err == repository.ErrNotFound {
api.Error(c, http.StatusNotFound, "NOT_FOUND", "link not found or revoked")
return
}
api.Error(c, http.StatusInternalServerError, "INTERNAL", "could not resolve link")
return
}
api.OK(c, link)
}
+87
View File
@@ -0,0 +1,87 @@
package handlers_test
import (
"encoding/json"
"net/http"
"testing"
"github.com/vaultdrop/backend/repository"
)
func TestShareLinkPublicResolution(t *testing.T) {
r, _, repo := setup(t)
device := repository.NewID()
token, _ := registerAndLogin(t, r, repo, testUserUsername(device, "sln"), "share-link-pw", device)
// Owner creates a file + a share link via the outbox.
fileID := repository.NewID()
linkToken := repository.NewID()
ops := []map[string]any{
op(repository.NewID(), fileID, "create_resource", "file", map[string]any{"name": "public.pdf"}),
op(repository.NewID(), fileID, "create_link", "file", map[string]any{"token": linkToken, "access": "viewer"}),
}
rec, _ := doRequest(t, r, http.MethodPost, "/api/v1/sync/ops", token, syncOpsBody(ops), "application/json")
env := expectOK(t, rec, "sync-ops-link")
var result struct {
Applied int `json:"applied"`
Failed any `json:"failed"`
}
if err := json.Unmarshal(env.Data, &result); err != nil {
t.Fatalf("sync-ops-link: unmarshal: %v", err)
}
if result.Applied != 2 || result.Failed != nil {
t.Fatalf("attendu applied=2 failed=null, got %+v", result)
}
// Résolution publique, SANS token.
rec, _ = doRequest(t, r, http.MethodGet, "/api/v1/shares/links/"+linkToken, "", nil, "")
env = expectOK(t, rec, "get-public-link")
var link struct {
Token string `json:"token"`
ResourceID string `json:"resource_id"`
ResourceType string `json:"resourceType"`
Name string `json:"name"`
Access string `json:"access"`
}
if err := json.Unmarshal(env.Data, &link); err != nil {
t.Fatalf("get-public-link: unmarshal: %v", err)
}
if link.Token != linkToken || link.ResourceID != fileID || link.Name != "public.pdf" || link.Access != "viewer" || link.ResourceType != "file" {
t.Errorf("lien résolu inattendu: %+v", link)
}
}
func TestShareLinkRevokedNotFound(t *testing.T) {
r, _, repo := setup(t)
device := repository.NewID()
token, _ := registerAndLogin(t, r, repo, testUserUsername(device, "slr"), "share-link-pw", device)
fileID := repository.NewID()
linkToken := repository.NewID()
ops := []map[string]any{
op(repository.NewID(), fileID, "create_resource", "file", map[string]any{"name": "x.pdf"}),
op(repository.NewID(), fileID, "create_link", "file", map[string]any{"token": linkToken, "access": "viewer"}),
op(repository.NewID(), fileID, "revoke_link", "file", map[string]any{"token": linkToken}),
}
rec, _ := doRequest(t, r, http.MethodPost, "/api/v1/sync/ops", token, syncOpsBody(ops), "application/json")
env := expectOK(t, rec, "sync-ops-revoke-link")
var result struct {
Applied int `json:"applied"`
}
if err := json.Unmarshal(env.Data, &result); err != nil {
t.Fatalf("sync-ops-revoke-link: unmarshal: %v", err)
}
if result.Applied != 3 {
t.Fatalf("attendu applied=3, got %d", result.Applied)
}
// Lien révoqué → 404.
rec, _ = doRequest(t, r, http.MethodGet, "/api/v1/shares/links/"+linkToken, "", nil, "")
expectError(t, rec, http.StatusNotFound, "NOT_FOUND", "get-revoked-link")
}
func TestShareLinkUnknownTokenNotFound(t *testing.T) {
r, _, _ := setup(t)
rec, _ := doRequest(t, r, http.MethodGet, "/api/v1/shares/links/"+repository.NewID(), "", nil, "")
expectError(t, rec, http.StatusNotFound, "NOT_FOUND", "get-unknown-link")
}
+2
View File
@@ -21,6 +21,8 @@ func writeError(c *gin.Context, err error) {
switch {
case errors.Is(err, repository.ErrNotFound):
api.Error(c, 404, "NOT_FOUND", "resource not found")
case errors.Is(err, repository.ErrGranteeNotFound):
api.Error(c, 404, "GRANTEE_NOT_FOUND", "the specified user does not exist")
case errors.Is(err, repository.ErrNameConflict):
api.Error(c, 409, "NAME_CONFLICT", "a resource with this name already exists here")
case errors.Is(err, service.FileTooLargeError):