feat: add tenant entry registration APIs

This commit is contained in:
richjohnrambo
2026-08-24 15:08:47 +08:00
parent ed9413d923
commit e61058ec03
16 changed files with 1174 additions and 0 deletions
+299
View File
@@ -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}