feat: add tenant entry registration APIs
This commit is contained in:
@@ -12,6 +12,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"math/big"
|
||||
"mime"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -23,6 +24,7 @@ import (
|
||||
"github.com/nyaruka/phonenumbers"
|
||||
"github.com/tinode/chat/server/auth"
|
||||
"github.com/tinode/chat/server/logs"
|
||||
"github.com/tinode/chat/server/media"
|
||||
"github.com/tinode/chat/server/store"
|
||||
"github.com/tinode/chat/server/store/types"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
@@ -48,6 +50,9 @@ const (
|
||||
imReasonIdempotencyConflict = "IDEMPOTENCY_CONFLICT"
|
||||
imReasonIdempotencyPending = "IDEMPOTENCY_IN_PROGRESS"
|
||||
imReasonInternal = "INTERNAL_ERROR"
|
||||
imReasonUnsupportedMedia = "UNSUPPORTED_MEDIA_TYPE"
|
||||
imReasonFileUnavailable = "FILE_UNAVAILABLE"
|
||||
imReasonRateLimited = "RATE_LIMITED"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -60,6 +65,7 @@ const (
|
||||
imIndependentPasswordLockSeconds = 300
|
||||
imQRCodeDefaultExpiresInSeconds = 86400
|
||||
imQRCodeMaxExpiresInSeconds = 7 * 86400
|
||||
imPublicUploadMaxBytes = 10 << 20
|
||||
groupMaxAdminCount = 10
|
||||
)
|
||||
|
||||
@@ -233,6 +239,22 @@ type imTenantResolveRequest struct {
|
||||
AppVersion string `json:"app_version,omitempty"`
|
||||
}
|
||||
|
||||
type imTenantApplicationRequest struct {
|
||||
CreditCode string `json:"credit_code"`
|
||||
EnterpriseEmail string `json:"enterprise_email,omitempty"`
|
||||
LicenseFileID string `json:"license_file_id,omitempty"`
|
||||
TenantCodeHint string `json:"tenant_code_hint,omitempty"`
|
||||
ClientContext map[string]any `json:"client_context,omitempty"`
|
||||
}
|
||||
|
||||
type imTenantEntryFeedbackRequest struct {
|
||||
Description string `json:"description"`
|
||||
Contact string `json:"contact,omitempty"`
|
||||
TenantCodeHint string `json:"tenant_code_hint,omitempty"`
|
||||
ScreenshotFileIDs []string `json:"screenshot_file_ids,omitempty"`
|
||||
ClientContext map[string]any `json:"client_context,omitempty"`
|
||||
}
|
||||
|
||||
type imVerificationCreateRequest struct {
|
||||
TenantCode string `json:"tenant_code,omitempty"`
|
||||
Scene string `json:"scene"`
|
||||
@@ -576,6 +598,182 @@ func serveIMTenantResolve(wrt http.ResponseWriter, req *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
func serveIMPublicUploads(wrt http.ResponseWriter, req *http.Request) {
|
||||
if req.Method != http.MethodPost {
|
||||
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
||||
return
|
||||
}
|
||||
if store.Store == nil || store.Store.GetMediaHandler() == nil {
|
||||
writeIMError(wrt, req, http.StatusServiceUnavailable, 90000, imReasonInternal, "media handler is not configured", nil)
|
||||
return
|
||||
}
|
||||
uploader, ok := store.Store.GetMediaHandler().(media.PublicUploader)
|
||||
if !ok {
|
||||
writeIMError(wrt, req, http.StatusServiceUnavailable, 30000, imReasonUnsupportedMedia, "public upload is not supported", nil)
|
||||
return
|
||||
}
|
||||
req.Body = http.MaxBytesReader(wrt, req.Body, imPublicUploadMaxBytes)
|
||||
if err := req.ParseMultipartForm(imPublicUploadMaxBytes); err != nil {
|
||||
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed multipart form", nil)
|
||||
return
|
||||
}
|
||||
purpose := strings.TrimSpace(req.FormValue("purpose"))
|
||||
if !isAllowedPublicUploadPurpose(purpose) {
|
||||
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "purpose"})
|
||||
return
|
||||
}
|
||||
file, header, err := req.FormFile("file")
|
||||
if err != nil {
|
||||
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "file is required", map[string]any{"field": "file"})
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
mimeType, err := detectIMUploadMime(file, header.Header.Get("Content-Type"))
|
||||
if err != nil {
|
||||
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed file", nil)
|
||||
return
|
||||
}
|
||||
if !isAllowedPublicUploadMime(purpose, mimeType) {
|
||||
writeIMError(wrt, req, http.StatusUnsupportedMediaType, 30000, imReasonUnsupportedMedia, "unsupported media type", map[string]any{"mime_type": mimeType})
|
||||
return
|
||||
}
|
||||
|
||||
token, err := randomIMToken(18)
|
||||
if err != nil {
|
||||
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
||||
return
|
||||
}
|
||||
now := time.Now().UTC().Round(time.Millisecond)
|
||||
publicFile := &media.PublicFileDef{
|
||||
ID: "file_" + token,
|
||||
Purpose: purpose,
|
||||
MimeType: mimeType,
|
||||
CreatedAt: now,
|
||||
}
|
||||
location, size, err := uploader.UploadPublic(publicFile, file)
|
||||
if err != nil {
|
||||
writeIMRelationError(wrt, req, err)
|
||||
return
|
||||
}
|
||||
if location != "" {
|
||||
publicFile.Location = location
|
||||
}
|
||||
upload, err := store.TenantEntry.CreatePublicUpload(&types.PublicUpload{
|
||||
FileID: publicFile.ID,
|
||||
Purpose: purpose,
|
||||
MimeType: mimeType,
|
||||
Size: size,
|
||||
ETag: publicFile.ETag,
|
||||
Location: publicFile.Location,
|
||||
ExpiresAt: now.Add(24 * time.Hour),
|
||||
CreatedAt: now,
|
||||
UploadedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
_ = store.Store.GetMediaHandler().Delete([]string{publicFile.Location})
|
||||
writeIMRelationError(wrt, req, err)
|
||||
return
|
||||
}
|
||||
writeIMSuccess(wrt, req, publicUploadPayload(upload))
|
||||
}
|
||||
|
||||
func serveIMTenantApplications(wrt http.ResponseWriter, req *http.Request) {
|
||||
if req.Method != http.MethodPost {
|
||||
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
||||
return
|
||||
}
|
||||
var body imTenantApplicationRequest
|
||||
if !decodeIMJSONBody(wrt, req, &body) {
|
||||
return
|
||||
}
|
||||
idempotencyKey := strings.TrimSpace(req.Header.Get("Idempotency-Key"))
|
||||
creditCode := strings.ToUpper(strings.TrimSpace(body.CreditCode))
|
||||
email := strings.TrimSpace(body.EnterpriseEmail)
|
||||
if idempotencyKey == "" {
|
||||
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "Idempotency-Key is required", map[string]any{"field": "Idempotency-Key"})
|
||||
return
|
||||
}
|
||||
if !isValidCreditCode(creditCode) {
|
||||
writeIMError(wrt, req, http.StatusBadRequest, 30000, "INVALID_CREDIT_CODE", "invalid credit code", map[string]any{"field": "credit_code"})
|
||||
return
|
||||
}
|
||||
if email != "" && !isValidIMEmail(email) {
|
||||
writeIMError(wrt, req, http.StatusBadRequest, 30000, "INVALID_EMAIL", "invalid email", map[string]any{"field": "enterprise_email"})
|
||||
return
|
||||
}
|
||||
token, err := randomIMToken(18)
|
||||
if err != nil {
|
||||
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
||||
return
|
||||
}
|
||||
app, err := store.TenantEntry.CreateTenantApplication(&types.TenantApplication{
|
||||
ApplicationID: "entapp_" + token,
|
||||
CreditCode: creditCode,
|
||||
EnterpriseEmail: email,
|
||||
LicenseFileID: strings.TrimSpace(body.LicenseFileID),
|
||||
TenantCodeHint: cleanTenantCodeHint(body.TenantCodeHint),
|
||||
ClientContext: body.ClientContext,
|
||||
IdempotencyKey: idempotencyKey,
|
||||
Status: types.TenantApplicationStatusReceived,
|
||||
EstimatedReviewHour: 24,
|
||||
})
|
||||
if err != nil {
|
||||
writeIMRelationError(wrt, req, err)
|
||||
return
|
||||
}
|
||||
writeIMSuccess(wrt, req, tenantApplicationPayload(app))
|
||||
}
|
||||
|
||||
func serveIMTenantEntryFeedback(wrt http.ResponseWriter, req *http.Request) {
|
||||
if req.Method != http.MethodPost {
|
||||
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
||||
return
|
||||
}
|
||||
var body imTenantEntryFeedbackRequest
|
||||
if !decodeIMJSONBody(wrt, req, &body) {
|
||||
return
|
||||
}
|
||||
idempotencyKey := strings.TrimSpace(req.Header.Get("Idempotency-Key"))
|
||||
description := strings.TrimSpace(body.Description)
|
||||
if idempotencyKey == "" {
|
||||
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "Idempotency-Key is required", map[string]any{"field": "Idempotency-Key"})
|
||||
return
|
||||
}
|
||||
if len([]rune(description)) < 10 {
|
||||
writeIMError(wrt, req, http.StatusBadRequest, 30000, "DESCRIPTION_REQUIRED", "description is required", map[string]any{"field": "description"})
|
||||
return
|
||||
}
|
||||
if len([]rune(description)) > 1000 {
|
||||
writeIMError(wrt, req, http.StatusBadRequest, 30000, "DESCRIPTION_TOO_LONG", "description is too long", map[string]any{"field": "description"})
|
||||
return
|
||||
}
|
||||
if len(body.ScreenshotFileIDs) > 9 {
|
||||
writeIMError(wrt, req, http.StatusBadRequest, 30000, "TOO_MANY_FILES", "too many files", map[string]any{"field": "screenshot_file_ids"})
|
||||
return
|
||||
}
|
||||
token, err := randomIMToken(18)
|
||||
if err != nil {
|
||||
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
||||
return
|
||||
}
|
||||
feedback, err := store.TenantEntry.CreateTenantEntryFeedback(&types.TenantEntryFeedback{
|
||||
FeedbackID: "entryfb_" + token,
|
||||
Description: description,
|
||||
Contact: strings.TrimSpace(body.Contact),
|
||||
TenantCodeHint: cleanTenantCodeHint(body.TenantCodeHint),
|
||||
ScreenshotFileIDs: cleanStringList(body.ScreenshotFileIDs, 9),
|
||||
ClientContext: body.ClientContext,
|
||||
IdempotencyKey: idempotencyKey,
|
||||
Status: types.TenantEntryFeedbackReceived,
|
||||
})
|
||||
if err != nil {
|
||||
writeIMRelationError(wrt, req, err)
|
||||
return
|
||||
}
|
||||
writeIMSuccess(wrt, req, tenantEntryFeedbackPayload(feedback))
|
||||
}
|
||||
|
||||
func imPublicEndpoints(req *http.Request) map[string]any {
|
||||
base := imPublicHTTPBase(req)
|
||||
wsBase := base
|
||||
@@ -7237,6 +7435,33 @@ func feedbackSubmissionPayload(feedback *types.FeedbackSubmission) map[string]an
|
||||
}
|
||||
}
|
||||
|
||||
func publicUploadPayload(upload *types.PublicUpload) map[string]any {
|
||||
return map[string]any{
|
||||
"file_id": upload.FileID,
|
||||
"purpose": upload.Purpose,
|
||||
"mime_type": upload.MimeType,
|
||||
"size": upload.Size,
|
||||
"expires_at": upload.ExpiresAt.UTC().Format(time.RFC3339Nano),
|
||||
}
|
||||
}
|
||||
|
||||
func tenantApplicationPayload(application *types.TenantApplication) map[string]any {
|
||||
return map[string]any{
|
||||
"application_id": application.ApplicationID,
|
||||
"status": application.Status,
|
||||
"estimated_review_hours": application.EstimatedReviewHour,
|
||||
"submitted_at": application.SubmittedAt.UTC().Format(time.RFC3339Nano),
|
||||
}
|
||||
}
|
||||
|
||||
func tenantEntryFeedbackPayload(feedback *types.TenantEntryFeedback) map[string]any {
|
||||
return map[string]any{
|
||||
"feedback_id": feedback.FeedbackID,
|
||||
"status": feedback.Status,
|
||||
"submitted_at": feedback.SubmittedAt.UTC().Format(time.RFC3339Nano),
|
||||
}
|
||||
}
|
||||
|
||||
func isAllowedIMWorkbenchRoute(route string) bool {
|
||||
switch route {
|
||||
case "/organization", "/meetings", "/reports/daily", "/reports/monthly", "/executive-mailbox":
|
||||
@@ -8086,6 +8311,80 @@ func cleanStringList(values []string, limit int) []string {
|
||||
return cleaned
|
||||
}
|
||||
|
||||
func isAllowedPublicUploadPurpose(purpose string) bool {
|
||||
switch strings.TrimSpace(purpose) {
|
||||
case types.PublicUploadPurposeEnterpriseLicense, types.PublicUploadPurposeTenantFeedback:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isAllowedPublicUploadMime(purpose, mimeType string) bool {
|
||||
mimeType = strings.ToLower(strings.TrimSpace(mimeType))
|
||||
switch purpose {
|
||||
case types.PublicUploadPurposeEnterpriseLicense:
|
||||
return strings.HasPrefix(mimeType, "image/jpeg") ||
|
||||
strings.HasPrefix(mimeType, "image/png") ||
|
||||
strings.HasPrefix(mimeType, "image/webp") ||
|
||||
mimeType == "application/pdf"
|
||||
case types.PublicUploadPurposeTenantFeedback:
|
||||
return strings.HasPrefix(mimeType, "image/jpeg") ||
|
||||
strings.HasPrefix(mimeType, "image/png") ||
|
||||
strings.HasPrefix(mimeType, "image/webp")
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func detectIMUploadMime(file interface {
|
||||
io.Reader
|
||||
io.Seeker
|
||||
}, contentType string) (string, error) {
|
||||
buff := make([]byte, 512)
|
||||
n, err := file.Read(buff)
|
||||
if err != nil && err != io.EOF {
|
||||
return "", err
|
||||
}
|
||||
if _, err = file.Seek(0, io.SeekStart); err != nil {
|
||||
return "", err
|
||||
}
|
||||
mimeType := http.DetectContentType(buff[:n])
|
||||
if mimeType == "application/octet-stream" {
|
||||
if parsed, _, err := mime.ParseMediaType(contentType); err == nil && parsed != "" {
|
||||
mimeType = parsed
|
||||
}
|
||||
}
|
||||
return strings.ToLower(strings.TrimSpace(mimeType)), nil
|
||||
}
|
||||
|
||||
func isValidCreditCode(code string) bool {
|
||||
if len(code) != 18 {
|
||||
return false
|
||||
}
|
||||
for _, r := range code {
|
||||
if !(r >= '0' && r <= '9' || r >= 'A' && r <= 'Z') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func isValidIMEmail(email string) bool {
|
||||
if len(email) > 255 || strings.ContainsAny(email, "\r\n\t ") {
|
||||
return false
|
||||
}
|
||||
return regexp.MustCompile(`^[^@]+@[^@]+\.[^@]+$`).MatchString(email)
|
||||
}
|
||||
|
||||
func cleanTenantCodeHint(code string) string {
|
||||
code = strings.TrimSpace(code)
|
||||
if len(code) > 64 {
|
||||
return code[:64]
|
||||
}
|
||||
return code
|
||||
}
|
||||
|
||||
func normalizeIMAbuseTarget(tenantID types.TenantID, reporter types.Uid, target types.AbuseTarget) (string, types.Uid, string, int, map[string]any, error) {
|
||||
targetType := strings.ToLower(strings.TrimSpace(target.Type))
|
||||
snapshot := map[string]any{"type": targetType}
|
||||
|
||||
Reference in New Issue
Block a user