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
+7
View File
@@ -227,6 +227,13 @@ type ThirdPartyConfigAdapter interface {
ThirdPartyConfig(group string, keys ...string) (map[string]string, error)
}
// TenantEntryAdapter stores public tenant entry data before a user is authenticated.
type TenantEntryAdapter interface {
PublicUploadCreate(*t.PublicUpload) (*t.PublicUpload, error)
TenantApplicationCreate(*t.TenantApplication) (*t.TenantApplication, error)
TenantEntryFeedbackCreate(*t.TenantEntryFeedback) (*t.TenantEntryFeedback, error)
}
// TenantRoutedAdapter can return a tenant-specific adapter, e.g. for the
// "one tenant, one database" deployment model.
type TenantRoutedAdapter interface {
+3
View File
@@ -247,6 +247,9 @@ func (a *adapter) Open(jsonconfig json.RawMessage) error {
if err = ensureIMRegistrationInviteControlTables(controlDB); err != nil {
return errors.New("mysql adapter failed to ensure registration invite control schema: " + err.Error())
}
if err = ensureIMTenantEntryControlTables(controlDB); err != nil {
return errors.New("mysql adapter failed to ensure tenant entry control schema: " + err.Error())
}
}
if !databaseMissing {
if err = ensureIMGroupBusinessTables(a.db); err != nil {
+2
View File
@@ -8,6 +8,8 @@
# - Control database: hotgo
# - im_tenant
# - im_tenant_config
# - im_public_uploads, im_tenant_applications and im_tenant_entry_feedbacks
# for unauthenticated enterprise registration and tenant-entry feedback
#
# - Default tenant business database: tinode
# - im_users, im_topics, im_subscriptions, im_messages and extension tables
+201
View File
@@ -0,0 +1,201 @@
//go:build mysql
// +build mysql
package mysql
import (
"encoding/json"
"time"
"github.com/jmoiron/sqlx"
t "github.com/tinode/chat/server/store/types"
)
func (a *adapter) PublicUploadCreate(upload *t.PublicUpload) (*t.PublicUpload, error) {
db := a.controlDB()
if db == nil {
return nil, t.ErrInternal
}
now := t.TimeNow()
if upload.CreatedAt.IsZero() {
upload.CreatedAt = now
}
if upload.UploadedAt.IsZero() {
upload.UploadedAt = now
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
_, err := db.ExecContext(ctx, `INSERT INTO im_public_uploads
(file_id,purpose,mime_type,size,etag,location,expires_at,created_at,uploaded_at)
VALUES(?,?,?,?,?,?,?,?,?)`,
upload.FileID, upload.Purpose, upload.MimeType, upload.Size, upload.ETag, upload.Location,
nullableTenantEntryTime(upload.ExpiresAt), upload.CreatedAt, upload.UploadedAt)
if err != nil {
return nil, err
}
return upload, nil
}
func (a *adapter) TenantApplicationCreate(application *t.TenantApplication) (*t.TenantApplication, error) {
db := a.controlDB()
if db == nil {
return nil, t.ErrInternal
}
now := t.TimeNow()
if application.Status == "" {
application.Status = t.TenantApplicationStatusReceived
}
if application.EstimatedReviewHour <= 0 {
application.EstimatedReviewHour = 24
}
if application.SubmittedAt.IsZero() {
application.SubmittedAt = now
}
application.CreatedAt = now
application.UpdatedAt = now
clientContext, _ := json.Marshal(application.ClientContext)
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
_, err := db.ExecContext(ctx, `INSERT INTO im_tenant_applications
(application_id,credit_code,enterprise_email,license_file_id,tenant_code_hint,client_context,idempotency_key,status,estimated_review_hour,submitted_at,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`,
application.ApplicationID, application.CreditCode, application.EnterpriseEmail, application.LicenseFileID,
application.TenantCodeHint, string(clientContext), application.IdempotencyKey, application.Status,
application.EstimatedReviewHour, application.SubmittedAt, now, now)
if err != nil {
return a.tenantApplicationByIdempotency(application.IdempotencyKey)
}
return a.tenantApplicationByIdempotency(application.IdempotencyKey)
}
func (a *adapter) TenantEntryFeedbackCreate(feedback *t.TenantEntryFeedback) (*t.TenantEntryFeedback, error) {
db := a.controlDB()
if db == nil {
return nil, t.ErrInternal
}
now := t.TimeNow()
if feedback.Status == "" {
feedback.Status = t.TenantEntryFeedbackReceived
}
if feedback.SubmittedAt.IsZero() {
feedback.SubmittedAt = now
}
feedback.CreatedAt = now
feedback.UpdatedAt = now
screenshots, _ := json.Marshal(feedback.ScreenshotFileIDs)
clientContext, _ := json.Marshal(feedback.ClientContext)
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
_, err := db.ExecContext(ctx, `INSERT INTO im_tenant_entry_feedbacks
(feedback_id,description,contact,tenant_code_hint,screenshot_file_ids,client_context,idempotency_key,status,submitted_at,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?)`,
feedback.FeedbackID, feedback.Description, feedback.Contact, feedback.TenantCodeHint,
string(screenshots), string(clientContext), feedback.IdempotencyKey, feedback.Status,
feedback.SubmittedAt, now, now)
if err != nil {
return a.tenantEntryFeedbackByIdempotency(feedback.IdempotencyKey)
}
return a.tenantEntryFeedbackByIdempotency(feedback.IdempotencyKey)
}
func (a *adapter) tenantApplicationByIdempotency(idempotencyKey string) (*t.TenantApplication, error) {
db := a.controlDB()
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
row := struct {
ApplicationID string `db:"application_id"`
CreditCode string `db:"credit_code"`
EnterpriseEmail string `db:"enterprise_email"`
LicenseFileID string `db:"license_file_id"`
TenantCodeHint string `db:"tenant_code_hint"`
ClientContext []byte `db:"client_context"`
IdempotencyKey string `db:"idempotency_key"`
Status string `db:"status"`
EstimatedReviewHour int `db:"estimated_review_hour"`
SubmittedAt time.Time `db:"submitted_at"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}{}
if err := db.GetContext(ctx, &row, `SELECT application_id,credit_code,enterprise_email,license_file_id,
tenant_code_hint,client_context,idempotency_key,status,estimated_review_hour,submitted_at,created_at,updated_at
FROM im_tenant_applications WHERE idempotency_key=?`, idempotencyKey); err != nil {
return nil, err
}
app := &t.TenantApplication{
ApplicationID: row.ApplicationID,
CreditCode: row.CreditCode,
EnterpriseEmail: row.EnterpriseEmail,
LicenseFileID: row.LicenseFileID,
TenantCodeHint: row.TenantCodeHint,
IdempotencyKey: row.IdempotencyKey,
Status: row.Status,
EstimatedReviewHour: row.EstimatedReviewHour,
SubmittedAt: row.SubmittedAt,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
}
_ = json.Unmarshal(row.ClientContext, &app.ClientContext)
return app, nil
}
func (a *adapter) tenantEntryFeedbackByIdempotency(idempotencyKey string) (*t.TenantEntryFeedback, error) {
db := a.controlDB()
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
row := struct {
FeedbackID string `db:"feedback_id"`
Description string `db:"description"`
Contact string `db:"contact"`
TenantCodeHint string `db:"tenant_code_hint"`
ScreenshotFileIDs []byte `db:"screenshot_file_ids"`
ClientContext []byte `db:"client_context"`
IdempotencyKey string `db:"idempotency_key"`
Status string `db:"status"`
SubmittedAt time.Time `db:"submitted_at"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}{}
if err := db.GetContext(ctx, &row, `SELECT feedback_id,description,contact,tenant_code_hint,
screenshot_file_ids,client_context,idempotency_key,status,submitted_at,created_at,updated_at
FROM im_tenant_entry_feedbacks WHERE idempotency_key=?`, idempotencyKey); err != nil {
return nil, err
}
feedback := &t.TenantEntryFeedback{
FeedbackID: row.FeedbackID,
Description: row.Description,
Contact: row.Contact,
TenantCodeHint: row.TenantCodeHint,
IdempotencyKey: row.IdempotencyKey,
Status: row.Status,
SubmittedAt: row.SubmittedAt,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
}
_ = json.Unmarshal(row.ScreenshotFileIDs, &feedback.ScreenshotFileIDs)
_ = json.Unmarshal(row.ClientContext, &feedback.ClientContext)
return feedback, nil
}
func (a *adapter) controlDB() *sqlx.DB {
if a.registryDB != nil {
return a.registryDB
}
return a.db
}
func nullableTenantEntryTime(value time.Time) any {
if value.IsZero() {
return nil
}
return value
}
+72
View File
@@ -0,0 +1,72 @@
//go:build mysql
// +build mysql
package mysql
import "github.com/jmoiron/sqlx"
func ensureIMTenantEntryControlTables(db *sqlx.DB) error {
statements := []string{
`CREATE TABLE IF NOT EXISTS im_public_uploads(
id BIGINT NOT NULL AUTO_INCREMENT,
file_id VARCHAR(64) NOT NULL,
purpose VARCHAR(64) NOT NULL,
mime_type VARCHAR(128) NOT NULL DEFAULT '',
size BIGINT NOT NULL DEFAULT 0,
etag VARCHAR(128) NOT NULL DEFAULT '',
location VARCHAR(2048) NOT NULL DEFAULT '',
expires_at DATETIME(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
uploaded_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_public_uploads_file(file_id),
INDEX idx_im_public_uploads_purpose(purpose, created_at),
INDEX idx_im_public_uploads_expires(expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS im_tenant_applications(
id BIGINT NOT NULL AUTO_INCREMENT,
application_id VARCHAR(64) NOT NULL,
credit_code VARCHAR(18) NOT NULL,
enterprise_email VARCHAR(255) NOT NULL DEFAULT '',
license_file_id VARCHAR(64) NOT NULL DEFAULT '',
tenant_code_hint VARCHAR(64) NOT NULL DEFAULT '',
client_context JSON,
idempotency_key VARCHAR(128) NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'received',
estimated_review_hour INT NOT NULL DEFAULT 24,
submitted_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_tenant_applications_app(application_id),
UNIQUE INDEX uk_im_tenant_applications_idempotency(idempotency_key),
INDEX idx_im_tenant_applications_credit(credit_code),
INDEX idx_im_tenant_applications_status(status, id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS im_tenant_entry_feedbacks(
id BIGINT NOT NULL AUTO_INCREMENT,
feedback_id VARCHAR(64) NOT NULL,
description TEXT NOT NULL,
contact VARCHAR(255) NOT NULL DEFAULT '',
tenant_code_hint VARCHAR(64) NOT NULL DEFAULT '',
screenshot_file_ids JSON,
client_context JSON,
idempotency_key VARCHAR(128) NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'received',
submitted_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_tenant_entry_feedbacks_feedback(feedback_id),
UNIQUE INDEX uk_im_tenant_entry_feedbacks_idempotency(idempotency_key),
INDEX idx_im_tenant_entry_feedbacks_status(status, id),
INDEX idx_im_tenant_entry_feedbacks_hint(tenant_code_hint, id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
}
for _, stmt := range statements {
if _, err := db.Exec(stmt); err != nil {
return err
}
}
return nil
}
@@ -0,0 +1,40 @@
//go:build mysql
// +build mysql
package mysql
import (
"strings"
"testing"
)
func TestEnsureTenantEntryControlTablesStatements(t *testing.T) {
stmts := []string{
`CREATE TABLE IF NOT EXISTS im_public_uploads(
id BIGINT NOT NULL AUTO_INCREMENT,
file_id VARCHAR(64) NOT NULL,
purpose VARCHAR(64) NOT NULL,
PRIMARY KEY(id),
UNIQUE INDEX uk_im_public_uploads_file(file_id)
)`,
`CREATE TABLE IF NOT EXISTS im_tenant_applications(
id BIGINT NOT NULL AUTO_INCREMENT,
application_id VARCHAR(64) NOT NULL,
credit_code VARCHAR(18) NOT NULL,
idempotency_key VARCHAR(128) NOT NULL,
UNIQUE INDEX uk_im_tenant_applications_idempotency(idempotency_key)
)`,
`CREATE TABLE IF NOT EXISTS im_tenant_entry_feedbacks(
id BIGINT NOT NULL AUTO_INCREMENT,
feedback_id VARCHAR(64) NOT NULL,
idempotency_key VARCHAR(128) NOT NULL,
UNIQUE INDEX uk_im_tenant_entry_feedbacks_idempotency(idempotency_key)
)`,
}
joined := strings.ToLower(strings.Join(stmts, "\n"))
for _, want := range []string{"im_public_uploads", "im_tenant_applications", "im_tenant_entry_feedbacks", "idempotency_key"} {
if !strings.Contains(joined, want) {
t.Fatalf("schema statement missing %s", want)
}
}
}
+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}
+173
View File
@@ -8,8 +8,10 @@ import (
"encoding/binary"
"encoding/json"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
@@ -17,6 +19,7 @@ import (
"github.com/golang/mock/gomock"
"github.com/tinode/chat/server/auth"
"github.com/tinode/chat/server/auth/mock_auth"
"github.com/tinode/chat/server/media"
"github.com/tinode/chat/server/store"
"github.com/tinode/chat/server/store/mock_store"
"github.com/tinode/chat/server/store/types"
@@ -39,6 +42,65 @@ type testHTTPInfrastructureStore struct {
events []types.BusinessEvent
}
type testTenantEntryStore struct {
upload *types.PublicUpload
application *types.TenantApplication
feedback *types.TenantEntryFeedback
}
func (s *testTenantEntryStore) CreatePublicUpload(upload *types.PublicUpload) (*types.PublicUpload, error) {
now := time.Now().UTC().Round(time.Millisecond)
upload.CreatedAt = now
upload.UploadedAt = now
s.upload = upload
return upload, nil
}
func (s *testTenantEntryStore) CreateTenantApplication(application *types.TenantApplication) (*types.TenantApplication, error) {
now := time.Now().UTC().Round(time.Millisecond)
application.Status = types.TenantApplicationStatusReceived
application.EstimatedReviewHour = 24
application.SubmittedAt = now
s.application = application
return application, nil
}
func (s *testTenantEntryStore) CreateTenantEntryFeedback(feedback *types.TenantEntryFeedback) (*types.TenantEntryFeedback, error) {
now := time.Now().UTC().Round(time.Millisecond)
feedback.Status = types.TenantEntryFeedbackReceived
feedback.SubmittedAt = now
s.feedback = feedback
return feedback, nil
}
type testPublicMediaHandler struct {
def *media.PublicFileDef
data string
}
func (h *testPublicMediaHandler) Init(string) error { return nil }
func (h *testPublicMediaHandler) Headers(types.TenantID, string, *url.URL, http.Header, bool) (http.Header, int, error) {
return nil, 0, nil
}
func (h *testPublicMediaHandler) Upload(*types.FileDef, io.Reader) (string, int64, error) {
return "", 0, types.ErrUnsupported
}
func (h *testPublicMediaHandler) Download(types.TenantID, string) (*types.FileDef, media.ReadSeekCloser, error) {
return nil, nil, types.ErrUnsupported
}
func (h *testPublicMediaHandler) Delete([]string) error { return nil }
func (h *testPublicMediaHandler) GetIdFromUrl(string) types.Uid {
return types.ZeroUid
}
func (h *testPublicMediaHandler) UploadPublic(def *media.PublicFileDef, file io.Reader) (string, int64, error) {
data, _ := io.ReadAll(file)
h.def = def
h.data = string(data)
def.Location = "public/" + def.Purpose + "/" + def.ID
def.ETag = "etag"
return def.Location, int64(len(data)), nil
}
func (s *testHTTPInfrastructureStore) recordKey(record *types.HTTPIdempotencyRecord) string {
return record.UserID.String() + "|" + record.Method + "|" + record.Route + "|" + record.Key
}
@@ -1357,6 +1419,117 @@ func TestServeIMTenantResolveHidesMissingTenant(t *testing.T) {
}
}
func TestServeIMTenantApplications(t *testing.T) {
oldTenantEntry := store.TenantEntry
entry := &testTenantEntryStore{}
store.TenantEntry = entry
defer func() { store.TenantEntry = oldTenantEntry }()
req := httptest.NewRequest(http.MethodPost, "/v1/im/tenant-applications", strings.NewReader(`{
"credit_code":"91310000ma1k000000",
"enterprise_email":"admin@example.com",
"license_file_id":"file_123",
"tenant_code_hint":"acme",
"client_context":{"platform":"android"}
}`))
req.Header.Set("Idempotency-Key", "tenant-app-1")
rec := httptest.NewRecorder()
serveIMTenantApplications(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status: got %d, body=%s", rec.Code, rec.Body.String())
}
if entry.application == nil || entry.application.CreditCode != "91310000MA1K000000" ||
entry.application.EnterpriseEmail != "admin@example.com" || entry.application.LicenseFileID != "file_123" {
t.Fatalf("unexpected application: %#v", entry.application)
}
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
if !strings.HasPrefix(data["application_id"].(string), "entapp_") || data["status"] != types.TenantApplicationStatusReceived {
t.Fatalf("unexpected application response: %#v", data)
}
}
func TestServeIMTenantEntryFeedback(t *testing.T) {
oldTenantEntry := store.TenantEntry
entry := &testTenantEntryStore{}
store.TenantEntry = entry
defer func() { store.TenantEntry = oldTenantEntry }()
req := httptest.NewRequest(http.MethodPost, "/v1/im/tenant-entry-feedback", strings.NewReader(`{
"description":"企业码 acme 提示不可用,无法进入企业。",
"contact":"user@example.com",
"tenant_code_hint":"acme",
"screenshot_file_ids":["file_1","file_1","file_2"],
"client_context":{"platform":"ios"}
}`))
req.Header.Set("Idempotency-Key", "entry-feedback-1")
rec := httptest.NewRecorder()
serveIMTenantEntryFeedback(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status: got %d, body=%s", rec.Code, rec.Body.String())
}
if entry.feedback == nil || entry.feedback.Description == "" || len(entry.feedback.ScreenshotFileIDs) != 2 {
t.Fatalf("unexpected feedback: %#v", entry.feedback)
}
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
if !strings.HasPrefix(data["feedback_id"].(string), "entryfb_") || data["status"] != types.TenantEntryFeedbackReceived {
t.Fatalf("unexpected feedback response: %#v", data)
}
}
func TestServeIMPublicUploads(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
oldStore := store.Store
oldTenantEntry := store.TenantEntry
entry := &testTenantEntryStore{}
handler := &testPublicMediaHandler{}
mockStore := mock_store.NewMockPersistentStorageInterface(ctrl)
mockStore.EXPECT().GetMediaHandler().Return(handler).AnyTimes()
store.Store = mockStore
store.TenantEntry = entry
defer func() {
store.Store = oldStore
store.TenantEntry = oldTenantEntry
}()
var body bytes.Buffer
writer := multipart.NewWriter(&body)
if err := writer.WriteField("purpose", types.PublicUploadPurposeTenantFeedback); err != nil {
t.Fatal(err)
}
part, err := writer.CreateFormFile("file", "shot.png")
if err != nil {
t.Fatal(err)
}
if _, err := part.Write([]byte("\x89PNG\r\n\x1a\n" + strings.Repeat("\x00", 64))); err != nil {
t.Fatal(err)
}
if err := writer.Close(); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, "/v1/im/public/uploads", &body)
req.Header.Set("Content-Type", writer.FormDataContentType())
rec := httptest.NewRecorder()
serveIMPublicUploads(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status: got %d, body=%s", rec.Code, rec.Body.String())
}
if entry.upload == nil || entry.upload.Purpose != types.PublicUploadPurposeTenantFeedback ||
entry.upload.Location == "" || handler.def == nil {
t.Fatalf("unexpected upload: %#v handler=%#v", entry.upload, handler.def)
}
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
if !strings.HasPrefix(data["file_id"].(string), "file_") || data["purpose"] != types.PublicUploadPurposeTenantFeedback {
t.Fatalf("unexpected upload response: %#v", data)
}
}
func TestServeIMVerificationCodesCreatesChallenge(t *testing.T) {
oldTenants := store.Tenants
oldVerifications := store.Verifications
+3
View File
@@ -782,6 +782,9 @@ func main() {
mux.HandleFunc(config.ApiPath+"v0/channels", serveWebSocket)
// Product HTTP API.
mux.HandleFunc(config.ApiPath+"v1/im/tenants/resolve", serveIMTenantResolve)
mux.HandleFunc(config.ApiPath+"v1/im/public/uploads", serveIMPublicUploads)
mux.HandleFunc(config.ApiPath+"v1/im/tenant-applications", serveIMTenantApplications)
mux.HandleFunc(config.ApiPath+"v1/im/tenant-entry-feedback", serveIMTenantEntryFeedback)
mux.HandleFunc(config.ApiPath+"v1/im/auth/verification-codes", serveIMVerificationCodes)
mux.HandleFunc(config.ApiPath+"v1/im/auth/verification-codes/", serveIMVerificationCodeVerify)
mux.HandleFunc(config.ApiPath+"v1/im/auth/password-reset/challenges/", serveIMPasswordResetVerify)
+45
View File
@@ -153,6 +153,29 @@ func (fh *fshandler) Upload(fdef *types.FileDef, file io.Reader) (string, int64,
return fh.ServeURL + fname, size, nil
}
func (fh *fshandler) UploadPublic(fdef *media.PublicFileDef, file io.Reader) (string, int64, error) {
location, err := publicFileLocation(fh.FileUploadDirectory, fdef.Purpose, fdef.ID, fdef.CreatedAt)
if err != nil {
return "", 0, err
}
if err = os.MkdirAll(filepath.Dir(location), 0777); err != nil {
return "", 0, err
}
outfile, err := os.Create(location)
if err != nil {
return "", 0, err
}
size, err := io.Copy(outfile, file)
outfile.Close()
if err != nil {
os.Remove(location)
return "", 0, err
}
fdef.Location = location
fdef.ETag = etagFromPath(location)
return location, size, nil
}
// Download processes request for file download.
// The returned ReadSeekCloser must be closed after use.
func (fh *fshandler) Download(tenantID types.TenantID, url string) (*types.FileDef, media.ReadSeekCloser, error) {
@@ -226,6 +249,28 @@ func tenantFileLocation(root string, tenantID types.TenantID, fileID string, cre
createdAt.Format("2006"), createdAt.Format("01"), fileID), nil
}
func publicFileLocation(root, purpose, fileID string, createdAt time.Time) (string, error) {
purpose = cleanPublicPurpose(purpose)
fileID = strings.Trim(fileID, `/\`)
if root == "" || purpose == "" || fileID == "" {
return "", types.ErrMalformed
}
if createdAt.IsZero() {
createdAt = time.Now().UTC()
}
return filepath.Join(root, "public", purpose, createdAt.Format("2006"), createdAt.Format("01"), fileID), nil
}
func cleanPublicPurpose(purpose string) string {
purpose = strings.Trim(strings.ToLower(strings.TrimSpace(purpose)), `/\`)
return strings.Map(func(r rune) rune {
if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '_' || r == '-' {
return r
}
return -1
}, purpose)
}
func init() {
store.RegisterMediaHandler(handlerName, &fshandler{})
}
+14
View File
@@ -9,6 +9,7 @@ import (
"path"
"regexp"
"strings"
"time"
"slices"
@@ -45,6 +46,19 @@ type Handler interface {
GetIdFromUrl(url string) types.Uid
}
type PublicFileDef struct {
ID string
Purpose string
MimeType string
CreatedAt time.Time
Location string
ETag string
}
type PublicUploader interface {
UploadPublic(fdef *PublicFileDef, file io.Reader) (string, int64, error)
}
type AllowedOrigin struct {
Origin string
URL url.URL
+44
View File
@@ -262,6 +262,29 @@ func (oh *ossHandler) Upload(fdef *types.FileDef, file io.Reader) (string, int64
return oh.conf.ServeURL + fname, rc.count, nil
}
func (oh *ossHandler) UploadPublic(fdef *media.PublicFileDef, file io.Reader) (string, int64, error) {
key, err := publicObjectKey(fdef.Purpose, fdef.ID, fdef.CreatedAt)
if err != nil {
return "", 0, err
}
rc := readerCounter{reader: file}
options := []aliyunoss.Option{aliyunoss.CacheControl(oh.conf.CacheControl)}
if fdef.MimeType != "" {
options = append(options, aliyunoss.ContentType(fdef.MimeType))
}
if err = oh.bucket.PutObject(key, &rc, options...); err != nil {
return "", 0, err
}
fdef.Location = key
fdef.ETag = etagFromLocation(key)
if meta, err := oh.bucket.GetObjectDetailedMeta(key); err == nil {
if etag := strings.Trim(meta.Get("ETag"), "\""); etag != "" {
fdef.ETag = etag
}
}
return key, rc.count, nil
}
// Download is unsupported because OSS objects are served through signed redirects.
func (oh *ossHandler) Download(tenantID types.TenantID, url string) (*types.FileDef, media.ReadSeekCloser, error) {
return nil, nil, types.ErrUnsupported
@@ -308,6 +331,27 @@ func tenantObjectKey(tenantID types.TenantID, fileID string, createdAt time.Time
createdAt.Format("2006"), createdAt.Format("01"), fileID), nil
}
func publicObjectKey(purpose, fileID string, createdAt time.Time) (string, error) {
purpose = cleanPublicPurpose(purpose)
if purpose == "" || fileID == "" {
return "", types.ErrMalformed
}
if createdAt.IsZero() {
createdAt = time.Now().UTC()
}
return path.Join("public", purpose, createdAt.Format("2006"), createdAt.Format("01"), strings.Trim(fileID, "/")), nil
}
func cleanPublicPurpose(purpose string) string {
purpose = strings.Trim(strings.ToLower(strings.TrimSpace(purpose)), "/")
return strings.Map(func(r rune) rune {
if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '_' || r == '-' {
return r
}
return -1
}, purpose)
}
func etagFromLocation(location string) string {
hasher := fnv.New128()
hasher.Write([]byte(location))
+47
View File
@@ -173,6 +173,52 @@ func (thirdPartyConfigMapper) Get(group string, keys ...string) (map[string]stri
return configAdp.ThirdPartyConfig(group, keys...)
}
type TenantEntryPersistenceInterface interface {
CreatePublicUpload(upload *types.PublicUpload) (*types.PublicUpload, error)
CreateTenantApplication(application *types.TenantApplication) (*types.TenantApplication, error)
CreateTenantEntryFeedback(feedback *types.TenantEntryFeedback) (*types.TenantEntryFeedback, error)
}
type tenantEntryMapper struct{}
var TenantEntry TenantEntryPersistenceInterface
func (tenantEntryMapper) CreatePublicUpload(upload *types.PublicUpload) (*types.PublicUpload, error) {
if upload == nil || strings.TrimSpace(upload.FileID) == "" || strings.TrimSpace(upload.Purpose) == "" ||
strings.TrimSpace(upload.MimeType) == "" || strings.TrimSpace(upload.Location) == "" || upload.Size <= 0 {
return nil, types.ErrMalformed
}
entryAdp, ok := adp.(adapter.TenantEntryAdapter)
if !ok {
return nil, types.ErrUnsupported
}
return entryAdp.PublicUploadCreate(upload)
}
func (tenantEntryMapper) CreateTenantApplication(application *types.TenantApplication) (*types.TenantApplication, error) {
if application == nil || strings.TrimSpace(application.ApplicationID) == "" ||
strings.TrimSpace(application.CreditCode) == "" || strings.TrimSpace(application.IdempotencyKey) == "" {
return nil, types.ErrMalformed
}
entryAdp, ok := adp.(adapter.TenantEntryAdapter)
if !ok {
return nil, types.ErrUnsupported
}
return entryAdp.TenantApplicationCreate(application)
}
func (tenantEntryMapper) CreateTenantEntryFeedback(feedback *types.TenantEntryFeedback) (*types.TenantEntryFeedback, error) {
if feedback == nil || strings.TrimSpace(feedback.FeedbackID) == "" ||
strings.TrimSpace(feedback.Description) == "" || strings.TrimSpace(feedback.IdempotencyKey) == "" {
return nil, types.ErrMalformed
}
entryAdp, ok := adp.(adapter.TenantEntryAdapter)
if !ok {
return nil, types.ErrUnsupported
}
return entryAdp.TenantEntryFeedbackCreate(feedback)
}
// GetByCode resolves a tenant by its public enterprise code.
func (tenantsMapper) GetByCode(code string) (*types.Tenant, error) {
tenantAdp, ok := adp.(adapter.TenantAdapter)
@@ -3693,6 +3739,7 @@ func init() {
Store = storeObj{}
Tenants = tenantsMapper{}
RegistrationConfig = registrationConfigMapper{}
TenantEntry = tenantEntryMapper{}
Users = usersMapper{}
Topics = topicsMapper{}
Subs = subsMapper{}
+52
View File
@@ -0,0 +1,52 @@
package types
import "time"
const (
PublicUploadPurposeEnterpriseLicense = "enterprise_license"
PublicUploadPurposeTenantFeedback = "tenant_entry_feedback"
TenantApplicationStatusReceived = "received"
TenantEntryFeedbackReceived = "received"
)
type PublicUpload struct {
FileID string
Purpose string
MimeType string
Size int64
ETag string
Location string
ExpiresAt time.Time
CreatedAt time.Time
UploadedAt time.Time
}
type TenantApplication struct {
ApplicationID string
CreditCode string
EnterpriseEmail string
LicenseFileID string
TenantCodeHint string
ClientContext map[string]any
IdempotencyKey string
Status string
EstimatedReviewHour int
SubmittedAt time.Time
CreatedAt time.Time
UpdatedAt time.Time
}
type TenantEntryFeedback struct {
FeedbackID string
Description string
Contact string
TenantCodeHint string
ScreenshotFileIDs []string
ClientContext map[string]any
IdempotencyKey string
Status string
SubmittedAt time.Time
CreatedAt time.Time
UpdatedAt time.Time
}