mirror of
https://github.com/Dvorinka/excalidraw-full.git
synced 2026-06-03 22:02:57 +00:00
726 lines
24 KiB
Go
726 lines
24 KiB
Go
package workspace
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"net/netip"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type CreateInviteRequest struct {
|
|
Email string `json:"email"`
|
|
Role string `json:"role"`
|
|
}
|
|
|
|
type CreatePermissionGrantRequest struct {
|
|
SubjectType string `json:"subject_type"`
|
|
SubjectID string `json:"subject_id"`
|
|
Email string `json:"email"`
|
|
Permission string `json:"permission"`
|
|
}
|
|
|
|
type CreateShareLinkRequest struct {
|
|
Permission string `json:"permission"`
|
|
ExpiresAt *time.Time `json:"expires_at"`
|
|
}
|
|
|
|
type CreateAssetRequest struct {
|
|
Kind string `json:"kind"`
|
|
MimeType string `json:"mime_type"`
|
|
Size int64 `json:"size"`
|
|
Width *int `json:"width"`
|
|
Height *int `json:"height"`
|
|
}
|
|
|
|
type CreateEmbedRequest struct {
|
|
SourceURL string `json:"source_url"`
|
|
EmbedType string `json:"embed_type"`
|
|
Title *string `json:"title"`
|
|
}
|
|
|
|
type CreateLinkRequest struct {
|
|
TargetResourceType string `json:"target_resource_type"`
|
|
TargetResourceID string `json:"target_resource_id"`
|
|
Label *string `json:"label"`
|
|
}
|
|
|
|
func (s *Store) ListTeamInvites(ctx context.Context, userID, teamID string) ([]TeamInvite, error) {
|
|
if err := s.ensureTeamPermission(ctx, userID, teamID, "invite"); err != nil {
|
|
return nil, err
|
|
}
|
|
rows, err := s.db.QueryContext(ctx, `SELECT id, team_id, email, role, invited_by, expires_at, created_at
|
|
FROM workspace_team_invites
|
|
WHERE team_id = ? AND accepted_at IS NULL AND revoked_at IS NULL AND expires_at > ?
|
|
ORDER BY created_at DESC`, teamID, time.Now().UTC())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
invites := []TeamInvite{}
|
|
for rows.Next() {
|
|
var invite TeamInvite
|
|
if err := rows.Scan(&invite.ID, &invite.TeamID, &invite.Email, &invite.Role, &invite.InvitedBy, &invite.ExpiresAt, &invite.CreatedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
invites = append(invites, invite)
|
|
}
|
|
return invites, rows.Err()
|
|
}
|
|
|
|
func (s *Store) CreateTeamInvite(ctx context.Context, userID, teamID string, req CreateInviteRequest) (*TeamInvite, string, error) {
|
|
if err := s.ensureTeamPermission(ctx, userID, teamID, "invite"); err != nil {
|
|
return nil, "", err
|
|
}
|
|
email, err := normalizeEmail(req.Email)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
role := req.Role
|
|
if role == "" {
|
|
role = "viewer"
|
|
}
|
|
if !validTeamRole(role) || role == "owner" {
|
|
return nil, "", fmt.Errorf("invalid invite role")
|
|
}
|
|
token, err := randomToken()
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
now := time.Now().UTC()
|
|
invite := &TeamInvite{
|
|
ID: newID(),
|
|
TeamID: teamID,
|
|
Email: email,
|
|
Role: role,
|
|
InvitedBy: userID,
|
|
ExpiresAt: now.Add(14 * 24 * time.Hour),
|
|
CreatedAt: now,
|
|
}
|
|
_, err = s.db.ExecContext(ctx, `INSERT INTO workspace_team_invites
|
|
(id, team_id, email, role, token_hash, invited_by, expires_at, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
invite.ID, invite.TeamID, invite.Email, invite.Role, hashToken(token), invite.InvitedBy, invite.ExpiresAt, invite.CreatedAt,
|
|
)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
_ = s.insertActivity(ctx, &userID, &teamID, "team", teamID, "member_invited", map[string]any{"email": email, "role": role})
|
|
return invite, token, nil
|
|
}
|
|
|
|
func (s *Store) AcceptInvite(ctx context.Context, userID, token string) (*TeamMembership, error) {
|
|
if strings.TrimSpace(token) == "" {
|
|
return nil, fmt.Errorf("invite token is required")
|
|
}
|
|
user, err := s.userByID(ctx, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var invite TeamInvite
|
|
err = s.db.QueryRowContext(ctx, `SELECT id, team_id, email, role, invited_by, expires_at, created_at
|
|
FROM workspace_team_invites
|
|
WHERE token_hash = ? AND accepted_at IS NULL AND revoked_at IS NULL AND expires_at > ?`,
|
|
hashToken(token), time.Now().UTC(),
|
|
).Scan(&invite.ID, &invite.TeamID, &invite.Email, &invite.Role, &invite.InvitedBy, &invite.ExpiresAt, &invite.CreatedAt)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, ErrNotFound
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !strings.EqualFold(user.Email, invite.Email) {
|
|
return nil, ErrForbidden
|
|
}
|
|
now := time.Now().UTC()
|
|
tx, err := s.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer tx.Rollback()
|
|
_, err = tx.ExecContext(ctx, `INSERT INTO workspace_team_memberships (id, team_id, user_id, role, joined_at)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
ON CONFLICT(team_id, user_id) DO UPDATE SET role = excluded.role`, newID(), invite.TeamID, userID, invite.Role, now)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
_, err = tx.ExecContext(ctx, `UPDATE workspace_team_invites SET accepted_at = ? WHERE id = ?`, now, invite.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := insertActivityTx(ctx, tx, &userID, &invite.TeamID, "team", invite.TeamID, "member_joined", map[string]any{"role": invite.Role}); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return nil, err
|
|
}
|
|
return s.teamMembership(ctx, invite.TeamID, userID)
|
|
}
|
|
|
|
func (s *Store) ListPermissionGrants(ctx context.Context, userID, resourceType, resourceID string) ([]PermissionGrant, error) {
|
|
if resourceType == "drawing" {
|
|
if err := s.ensureDrawingAccess(ctx, userID, resourceID, "manage"); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
rows, err := s.db.QueryContext(ctx, `SELECT id, resource_type, resource_id, subject_type, subject_id, permission, inherited_from, created_at
|
|
FROM workspace_permission_grants
|
|
WHERE resource_type = ? AND resource_id = ?
|
|
ORDER BY created_at DESC`, resourceType, resourceID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
grants := []PermissionGrant{}
|
|
for rows.Next() {
|
|
var grant PermissionGrant
|
|
if err := rows.Scan(&grant.ID, &grant.ResourceType, &grant.ResourceID, &grant.SubjectType, &grant.SubjectID, &grant.Permission, &grant.InheritedFrom, &grant.CreatedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
grants = append(grants, grant)
|
|
}
|
|
return grants, rows.Err()
|
|
}
|
|
|
|
func (s *Store) CreateDrawingPermissionGrant(ctx context.Context, userID, drawingID string, req CreatePermissionGrantRequest) (*PermissionGrant, error) {
|
|
if err := s.ensureDrawingAccess(ctx, userID, drawingID, "share"); err != nil {
|
|
return nil, err
|
|
}
|
|
if !validPermission(req.Permission) {
|
|
return nil, fmt.Errorf("invalid permission")
|
|
}
|
|
subjectType := req.SubjectType
|
|
if subjectType == "" {
|
|
subjectType = "user"
|
|
}
|
|
subjectID := strings.TrimSpace(req.SubjectID)
|
|
if subjectType == "user" && subjectID == "" {
|
|
user, err := s.userByEmail(ctx, req.Email)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
subjectID = user.ID
|
|
}
|
|
if subjectType != "user" && subjectType != "team" {
|
|
return nil, fmt.Errorf("invalid subject type")
|
|
}
|
|
if subjectID == "" {
|
|
return nil, fmt.Errorf("subject id is required")
|
|
}
|
|
now := time.Now().UTC()
|
|
grant := &PermissionGrant{
|
|
ID: newID(),
|
|
ResourceType: "drawing",
|
|
ResourceID: drawingID,
|
|
SubjectType: subjectType,
|
|
SubjectID: subjectID,
|
|
Permission: req.Permission,
|
|
CreatedAt: now,
|
|
}
|
|
_, err := s.db.ExecContext(ctx, `INSERT INTO workspace_permission_grants
|
|
(id, resource_type, resource_id, subject_type, subject_id, permission, inherited_from, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(resource_type, resource_id, subject_type, subject_id, permission) DO UPDATE SET created_at = excluded.created_at`,
|
|
grant.ID, grant.ResourceType, grant.ResourceID, grant.SubjectType, grant.SubjectID, grant.Permission, grant.InheritedFrom, grant.CreatedAt,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
_ = s.insertActivity(ctx, &userID, nil, "drawing", drawingID, "permission_changed", map[string]any{"permission": grant.Permission, "subject_type": grant.SubjectType})
|
|
return grant, nil
|
|
}
|
|
|
|
func (s *Store) ListShareLinks(ctx context.Context, userID, resourceType, resourceID string) ([]ShareLink, error) {
|
|
if resourceType == "drawing" {
|
|
if err := s.ensureDrawingAccess(ctx, userID, resourceID, "share"); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
rows, err := s.db.QueryContext(ctx, `SELECT id, resource_type, resource_id, token_hash, permission, expires_at, password_hash, created_by, revoked_at, created_at
|
|
FROM workspace_share_links
|
|
WHERE resource_type = ? AND resource_id = ? AND revoked_at IS NULL
|
|
ORDER BY created_at DESC`, resourceType, resourceID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
links := []ShareLink{}
|
|
for rows.Next() {
|
|
link, err := scanShareLink(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
link.TokenHash = ""
|
|
links = append(links, *link)
|
|
}
|
|
return links, rows.Err()
|
|
}
|
|
|
|
func (s *Store) CreateDrawingShareLink(ctx context.Context, userID, drawingID string, req CreateShareLinkRequest) (*ShareLink, string, error) {
|
|
if err := s.ensureDrawingAccess(ctx, userID, drawingID, "share"); err != nil {
|
|
return nil, "", err
|
|
}
|
|
permission := req.Permission
|
|
if permission == "" {
|
|
permission = "view"
|
|
}
|
|
if permission != "view" && permission != "comment" && permission != "edit" {
|
|
return nil, "", fmt.Errorf("invalid share permission")
|
|
}
|
|
token, err := randomToken()
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
now := time.Now().UTC()
|
|
link := &ShareLink{
|
|
ID: newID(),
|
|
ResourceType: "drawing",
|
|
ResourceID: drawingID,
|
|
TokenHash: hashToken(token),
|
|
Permission: permission,
|
|
ExpiresAt: req.ExpiresAt,
|
|
CreatedBy: userID,
|
|
CreatedAt: now,
|
|
}
|
|
_, err = s.db.ExecContext(ctx, `INSERT INTO workspace_share_links
|
|
(id, resource_type, resource_id, token_hash, permission, expires_at, password_hash, created_by, revoked_at, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
link.ID, link.ResourceType, link.ResourceID, link.TokenHash, link.Permission, link.ExpiresAt, link.PasswordHash, link.CreatedBy, link.RevokedAt, link.CreatedAt,
|
|
)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
link.TokenHash = ""
|
|
_ = s.insertActivity(ctx, &userID, nil, "drawing", drawingID, "drawing_shared", map[string]any{"permission": permission})
|
|
return link, token, nil
|
|
}
|
|
|
|
func (s *Store) SharedResourceByToken(ctx context.Context, token string) (map[string]any, error) {
|
|
if strings.TrimSpace(token) == "" {
|
|
return nil, ErrNotFound
|
|
}
|
|
row := s.db.QueryRowContext(ctx, `SELECT id, resource_type, resource_id, token_hash, permission, expires_at, password_hash, created_by, revoked_at, created_at
|
|
FROM workspace_share_links
|
|
WHERE token_hash = ? AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > ?)`,
|
|
hashToken(token), time.Now().UTC(),
|
|
)
|
|
link, err := scanShareLink(row)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, ErrNotFound
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
link.TokenHash = ""
|
|
payload := map[string]any{"share_link": link}
|
|
if link.ResourceType == "drawing" {
|
|
drawing, err := s.drawingByIDNoAuth(ctx, link.ResourceID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
payload["drawing"] = drawing
|
|
return payload, nil
|
|
}
|
|
return payload, nil
|
|
}
|
|
|
|
func (s *Store) ListDrawingAssets(ctx context.Context, userID, drawingID string) ([]DrawingAsset, error) {
|
|
if err := s.ensureDrawingAccess(ctx, userID, drawingID, "view"); err != nil {
|
|
return nil, err
|
|
}
|
|
rows, err := s.db.QueryContext(ctx, `SELECT id, drawing_id, kind, path, mime_type, size, width, height, uploaded_by, created_at
|
|
FROM workspace_drawing_assets WHERE drawing_id = ? ORDER BY created_at DESC`, drawingID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
return scanAssets(rows)
|
|
}
|
|
|
|
func (s *Store) CreateDrawingAsset(ctx context.Context, userID, drawingID string, req CreateAssetRequest) (*DrawingAsset, error) {
|
|
if err := s.ensureDrawingAccess(ctx, userID, drawingID, "edit"); err != nil {
|
|
return nil, err
|
|
}
|
|
if !validAssetKind(req.Kind) {
|
|
return nil, fmt.Errorf("invalid asset kind")
|
|
}
|
|
if !validAssetMIME(req.MimeType) {
|
|
return nil, fmt.Errorf("invalid asset mime type")
|
|
}
|
|
if req.Size <= 0 || req.Size > 25<<20 {
|
|
return nil, fmt.Errorf("asset size must be between 1 byte and 25 MiB")
|
|
}
|
|
drawing, err := s.drawingByIDNoAuth(ctx, drawingID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
now := time.Now().UTC()
|
|
asset := &DrawingAsset{
|
|
ID: newID(),
|
|
DrawingID: drawingID,
|
|
Kind: req.Kind,
|
|
MimeType: req.MimeType,
|
|
Size: req.Size,
|
|
Width: req.Width,
|
|
Height: req.Height,
|
|
UploadedBy: userID,
|
|
CreatedAt: now,
|
|
}
|
|
asset.Path = fmt.Sprintf("/data/teams/%s/drawings/%s/assets/%s", drawing.TeamID, drawingID, asset.ID)
|
|
_, err = s.db.ExecContext(ctx, `INSERT INTO workspace_drawing_assets
|
|
(id, drawing_id, kind, path, mime_type, size, width, height, uploaded_by, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
asset.ID, asset.DrawingID, asset.Kind, asset.Path, asset.MimeType, asset.Size, asset.Width, asset.Height, asset.UploadedBy, asset.CreatedAt,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return asset, nil
|
|
}
|
|
|
|
func (s *Store) ListEmbeds(ctx context.Context, userID, drawingID string) ([]Embed, error) {
|
|
if err := s.ensureDrawingAccess(ctx, userID, drawingID, "view"); err != nil {
|
|
return nil, err
|
|
}
|
|
rows, err := s.db.QueryContext(ctx, `SELECT id, drawing_id, source_url, canonical_url, provider, embed_type, title, preview_asset_id, safe_embed_html, created_by, created_at
|
|
FROM workspace_embeds WHERE drawing_id = ? ORDER BY created_at DESC`, drawingID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
embeds := []Embed{}
|
|
for rows.Next() {
|
|
var embed Embed
|
|
if err := rows.Scan(&embed.ID, &embed.DrawingID, &embed.SourceURL, &embed.CanonicalURL, &embed.Provider, &embed.EmbedType, &embed.Title, &embed.PreviewAssetID, &embed.SafeEmbedHTML, &embed.CreatedBy, &embed.CreatedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
embeds = append(embeds, embed)
|
|
}
|
|
return embeds, rows.Err()
|
|
}
|
|
|
|
func (s *Store) CreateEmbed(ctx context.Context, userID, drawingID string, req CreateEmbedRequest) (*Embed, error) {
|
|
if err := s.ensureDrawingAccess(ctx, userID, drawingID, "edit"); err != nil {
|
|
return nil, err
|
|
}
|
|
canonical, provider, err := validateEmbedURL(req.SourceURL)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
embedType := req.EmbedType
|
|
if embedType == "" {
|
|
embedType = "link"
|
|
}
|
|
if embedType != "link" && embedType != "iframe" && embedType != "provider" {
|
|
return nil, fmt.Errorf("invalid embed type")
|
|
}
|
|
now := time.Now().UTC()
|
|
embed := &Embed{
|
|
ID: newID(),
|
|
DrawingID: drawingID,
|
|
SourceURL: canonical,
|
|
CanonicalURL: canonical,
|
|
Provider: provider,
|
|
EmbedType: embedType,
|
|
Title: req.Title,
|
|
CreatedBy: userID,
|
|
CreatedAt: now,
|
|
}
|
|
_, err = s.db.ExecContext(ctx, `INSERT INTO workspace_embeds
|
|
(id, drawing_id, source_url, canonical_url, provider, embed_type, title, preview_asset_id, safe_embed_html, created_by, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
embed.ID, embed.DrawingID, embed.SourceURL, embed.CanonicalURL, embed.Provider, embed.EmbedType, embed.Title, embed.PreviewAssetID, embed.SafeEmbedHTML, embed.CreatedBy, embed.CreatedAt,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
_ = s.insertActivity(ctx, &userID, nil, "drawing", drawingID, "embed_created", map[string]any{"provider": provider, "embed_type": embedType})
|
|
return embed, nil
|
|
}
|
|
|
|
func (s *Store) ListLinkReferences(ctx context.Context, userID, resourceType, resourceID string) ([]LinkReference, error) {
|
|
if resourceType == "drawing" {
|
|
if err := s.ensureDrawingAccess(ctx, userID, resourceID, "view"); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
rows, err := s.db.QueryContext(ctx, `SELECT id, source_resource_type, source_resource_id, target_resource_type, target_resource_id, label, created_by, created_at
|
|
FROM workspace_link_references
|
|
WHERE source_resource_type = ? AND source_resource_id = ?
|
|
ORDER BY created_at DESC`, resourceType, resourceID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
links := []LinkReference{}
|
|
for rows.Next() {
|
|
var link LinkReference
|
|
if err := rows.Scan(&link.ID, &link.SourceResourceType, &link.SourceResourceID, &link.TargetResourceType, &link.TargetResourceID, &link.Label, &link.CreatedBy, &link.CreatedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
links = append(links, link)
|
|
}
|
|
return links, rows.Err()
|
|
}
|
|
|
|
func (s *Store) CreateDrawingLinkReference(ctx context.Context, userID, drawingID string, req CreateLinkRequest) (*LinkReference, error) {
|
|
if err := s.ensureDrawingAccess(ctx, userID, drawingID, "edit"); err != nil {
|
|
return nil, err
|
|
}
|
|
drawing, err := s.drawingByIDNoAuth(ctx, drawingID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := s.ensureTargetInTeam(ctx, drawing.TeamID, req.TargetResourceType, req.TargetResourceID); err != nil {
|
|
return nil, err
|
|
}
|
|
now := time.Now().UTC()
|
|
link := &LinkReference{
|
|
ID: newID(),
|
|
SourceResourceType: "drawing",
|
|
SourceResourceID: drawingID,
|
|
TargetResourceType: req.TargetResourceType,
|
|
TargetResourceID: req.TargetResourceID,
|
|
Label: req.Label,
|
|
CreatedBy: userID,
|
|
CreatedAt: now,
|
|
}
|
|
_, err = s.db.ExecContext(ctx, `INSERT INTO workspace_link_references
|
|
(id, source_resource_type, source_resource_id, target_resource_type, target_resource_id, label, created_by, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
link.ID, link.SourceResourceType, link.SourceResourceID, link.TargetResourceType, link.TargetResourceID, link.Label, link.CreatedBy, link.CreatedAt,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return link, nil
|
|
}
|
|
|
|
func (s *Store) ensureTeamPermission(ctx context.Context, userID, teamID, permission string) error {
|
|
role, err := s.teamRole(ctx, userID, teamID)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return ErrForbidden
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if roleAllows(role, permission) {
|
|
return nil
|
|
}
|
|
return ErrForbidden
|
|
}
|
|
|
|
func (s *Store) teamRole(ctx context.Context, userID, teamID string) (string, error) {
|
|
var role string
|
|
err := s.db.QueryRowContext(ctx, `SELECT role FROM workspace_team_memberships WHERE user_id = ? AND team_id = ?`, userID, teamID).Scan(&role)
|
|
return role, err
|
|
}
|
|
|
|
func (s *Store) userByID(ctx context.Context, userID string) (*User, error) {
|
|
var user User
|
|
err := s.db.QueryRowContext(ctx, `SELECT id, name, username, email, avatar_url, locale, timezone, created_at, updated_at FROM workspace_users WHERE id = ?`, userID).
|
|
Scan(&user.ID, &user.Name, &user.Username, &user.Email, &user.AvatarURL, &user.Locale, &user.Timezone, &user.CreatedAt, &user.UpdatedAt)
|
|
return &user, err
|
|
}
|
|
|
|
func (s *Store) userByEmail(ctx context.Context, email string) (*User, error) {
|
|
email, err := normalizeEmail(email)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var user User
|
|
err = s.db.QueryRowContext(ctx, `SELECT id, name, username, email, avatar_url, locale, timezone, created_at, updated_at FROM workspace_users WHERE email = ?`, email).
|
|
Scan(&user.ID, &user.Name, &user.Username, &user.Email, &user.AvatarURL, &user.Locale, &user.Timezone, &user.CreatedAt, &user.UpdatedAt)
|
|
return &user, err
|
|
}
|
|
|
|
func (s *Store) teamMembership(ctx context.Context, teamID, userID string) (*TeamMembership, error) {
|
|
var member TeamMembership
|
|
err := s.db.QueryRowContext(ctx, `SELECT id, team_id, user_id, role, joined_at FROM workspace_team_memberships WHERE team_id = ? AND user_id = ?`, teamID, userID).
|
|
Scan(&member.ID, &member.TeamID, &member.UserID, &member.Role, &member.JoinedAt)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
user, err := s.userByID(ctx, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
member.User = user
|
|
return &member, nil
|
|
}
|
|
|
|
func (s *Store) drawingByIDNoAuth(ctx context.Context, drawingID string) (*Drawing, error) {
|
|
row := s.db.QueryRowContext(ctx, `SELECT d.id, d.team_id, d.folder_id, d.project_id, d.slug, d.title, d.description,
|
|
d.owner_user_id, d.latest_revision_id, d.visibility, d.is_archived, d.thumbnail_asset_id, d.created_at, d.updated_at, d.deleted_at,
|
|
u.id, u.name, u.username, u.email, u.avatar_url, u.locale, u.timezone, u.created_at, u.updated_at
|
|
FROM workspace_drawings d
|
|
JOIN workspace_users u ON u.id = d.owner_user_id
|
|
WHERE d.id = ? AND d.deleted_at IS NULL`, drawingID)
|
|
return scanDrawing(row)
|
|
}
|
|
|
|
func (s *Store) ensureTargetInTeam(ctx context.Context, teamID, resourceType, resourceID string) error {
|
|
var found int
|
|
var query string
|
|
switch resourceType {
|
|
case "drawing":
|
|
query = `SELECT 1 FROM workspace_drawings WHERE id = ? AND team_id = ? AND deleted_at IS NULL`
|
|
case "folder":
|
|
query = `SELECT 1 FROM workspace_folders WHERE id = ? AND team_id = ?`
|
|
case "project":
|
|
query = `SELECT 1 FROM workspace_projects WHERE id = ? AND team_id = ?`
|
|
case "embed":
|
|
query = `SELECT 1 FROM workspace_embeds e JOIN workspace_drawings d ON d.id = e.drawing_id WHERE e.id = ? AND d.team_id = ?`
|
|
default:
|
|
return fmt.Errorf("invalid target resource type")
|
|
}
|
|
err := s.db.QueryRowContext(ctx, query, resourceID, teamID).Scan(&found)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return ErrNotFound
|
|
}
|
|
return err
|
|
}
|
|
|
|
func (s *Store) grantAllows(ctx context.Context, resourceType, resourceID, userID, teamID, required string) (bool, error) {
|
|
rows, err := s.db.QueryContext(ctx, `SELECT permission FROM workspace_permission_grants
|
|
WHERE resource_type = ? AND resource_id = ? AND (
|
|
(subject_type = 'user' AND subject_id = ?) OR
|
|
(subject_type = 'team' AND subject_id = ?)
|
|
)`, resourceType, resourceID, userID, teamID)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var permission string
|
|
if err := rows.Scan(&permission); err != nil {
|
|
return false, err
|
|
}
|
|
if permissionAllows(permission, required) {
|
|
return true, nil
|
|
}
|
|
}
|
|
return false, rows.Err()
|
|
}
|
|
|
|
func scanShareLink(scanner interface{ Scan(dest ...any) error }) (*ShareLink, error) {
|
|
var link ShareLink
|
|
err := scanner.Scan(&link.ID, &link.ResourceType, &link.ResourceID, &link.TokenHash, &link.Permission, &link.ExpiresAt, &link.PasswordHash, &link.CreatedBy, &link.RevokedAt, &link.CreatedAt)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &link, nil
|
|
}
|
|
|
|
func scanAssets(rows *sql.Rows) ([]DrawingAsset, error) {
|
|
assets := []DrawingAsset{}
|
|
for rows.Next() {
|
|
var asset DrawingAsset
|
|
if err := rows.Scan(&asset.ID, &asset.DrawingID, &asset.Kind, &asset.Path, &asset.MimeType, &asset.Size, &asset.Width, &asset.Height, &asset.UploadedBy, &asset.CreatedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
assets = append(assets, asset)
|
|
}
|
|
return assets, rows.Err()
|
|
}
|
|
|
|
func roleAllows(role, permission string) bool {
|
|
switch role {
|
|
case "owner", "admin":
|
|
return true
|
|
case "editor":
|
|
return permission == "view" || permission == "comment" || permission == "edit"
|
|
case "viewer":
|
|
return permission == "view"
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func permissionAllows(grant, required string) bool {
|
|
if grant == required {
|
|
return true
|
|
}
|
|
switch grant {
|
|
case "manage":
|
|
return true
|
|
case "edit":
|
|
return required == "view" || required == "comment" || required == "edit"
|
|
case "comment":
|
|
return required == "view" || required == "comment"
|
|
case "share":
|
|
return required == "view" || required == "share"
|
|
case "invite":
|
|
return required == "view" || required == "invite"
|
|
case "view":
|
|
return required == "view"
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func validPermission(permission string) bool {
|
|
switch permission {
|
|
case "view", "comment", "edit", "manage", "share", "invite":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func validTeamRole(role string) bool {
|
|
switch role {
|
|
case "owner", "admin", "editor", "viewer":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func validAssetKind(kind string) bool {
|
|
switch kind {
|
|
case "image", "export", "attachment", "thumbnail":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func validAssetMIME(mimeType string) bool {
|
|
switch mimeType {
|
|
case "image/png", "image/jpeg", "image/webp", "image/gif", "application/pdf", "application/json", "text/plain":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func validateEmbedURL(raw string) (string, string, error) {
|
|
parsed, err := url.Parse(strings.TrimSpace(raw))
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("invalid URL")
|
|
}
|
|
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
|
return "", "", fmt.Errorf("embed URL must use http or https")
|
|
}
|
|
if parsed.User != nil {
|
|
return "", "", fmt.Errorf("embed URL must not include credentials")
|
|
}
|
|
host := strings.ToLower(parsed.Hostname())
|
|
if host == "" || host == "localhost" || strings.HasSuffix(host, ".localhost") {
|
|
return "", "", fmt.Errorf("embed URL host is not allowed")
|
|
}
|
|
if ip := net.ParseIP(host); ip != nil {
|
|
addr, ok := netip.AddrFromSlice(ip)
|
|
if !ok || !addr.IsGlobalUnicast() || addr.IsPrivate() || addr.IsLoopback() || addr.IsLinkLocalUnicast() {
|
|
return "", "", fmt.Errorf("embed URL host is not allowed")
|
|
}
|
|
}
|
|
parsed.Fragment = ""
|
|
return parsed.String(), host, nil
|
|
}
|