feat(backend): implement HTTP API, auth, chat, and admin surfaces
Expose learning, registration controls, mentor chat, realtime WebSocket, and staff administration.
This commit is contained in:
@@ -0,0 +1,228 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/mail"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const (
|
||||
minUsernameLen = 3
|
||||
maxUsernameLen = 32
|
||||
maxNameLen = 100
|
||||
minPasswordLen = 8
|
||||
maxPasswordLen = 72 // bcrypt limit
|
||||
minSecQLen = 3
|
||||
maxSecQLen = 200
|
||||
minSecALen = 2
|
||||
maxSecALen = 100
|
||||
maxInviteLabel = 80
|
||||
maxEmailLen = 200
|
||||
)
|
||||
|
||||
// Persian validation messages — keep in sync with frontend/src/lib/validation.ts
|
||||
const (
|
||||
msgNameRequired = "نام الزامی است"
|
||||
msgNameTooLong = "نام خیلی طولانی است"
|
||||
msgEmailInvalid = "ایمیل نامعتبر است"
|
||||
msgUsernameInvalid = "شناسه باید با حرف انگلیسی شروع شود و ۳ تا ۳۲ نویسهٔ لاتین، عدد، نقطه، خط یا زیرخط باشد"
|
||||
msgPasswordShort = "گذرواژه باید دستکم ۸ نویسه داشته باشد"
|
||||
msgPasswordLong = "گذرواژه بیش از حد طولانی است"
|
||||
msgPasswordMismatch = "گذرواژه و تکرار آن یکسان نیستند"
|
||||
msgPhoneRequired = "شماره تلفن الزامی است"
|
||||
msgPhoneInvalid = "شماره تلفن نامعتبر است (مثال: ۰۹۱۲۱۲۳۴۵۶۷)"
|
||||
msgSecQARequired = "سوال و جواب امنیتی الزامی است"
|
||||
msgSecQShort = "سوال امنیتی خیلی کوتاه است"
|
||||
msgSecQLong = "سوال امنیتی خیلی طولانی است"
|
||||
msgSecAShort = "جواب امنیتی خیلی کوتاه است"
|
||||
msgSecALong = "جواب امنیتی خیلی طولانی است"
|
||||
msgInviteLabelLong = "برچسب لینک عضویت خیلی طولانی است"
|
||||
)
|
||||
|
||||
func validEmail(email string) bool {
|
||||
n := utf8.RuneCountInString(email)
|
||||
if n < 5 || n > maxEmailLen {
|
||||
return false
|
||||
}
|
||||
if strings.ContainsAny(email, " \t\r\n") {
|
||||
return false
|
||||
}
|
||||
addr, err := mail.ParseAddress(email)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
// Reject display-name forms like "Name <a@b.c>".
|
||||
if addr.Address != email {
|
||||
return false
|
||||
}
|
||||
at := strings.LastIndex(email, "@")
|
||||
if at < 1 || at == len(email)-1 {
|
||||
return false
|
||||
}
|
||||
domain := email[at+1:]
|
||||
return strings.Contains(domain, ".")
|
||||
}
|
||||
|
||||
// validUsername: ASCII letter first, then letters/digits/._- ; length 3–32.
|
||||
// Expects already lowercased + trimmed input.
|
||||
func validUsername(username string) bool {
|
||||
n := len(username)
|
||||
if n < minUsernameLen || n > maxUsernameLen {
|
||||
return false
|
||||
}
|
||||
first := username[0]
|
||||
if first < 'a' || first > 'z' {
|
||||
return false
|
||||
}
|
||||
for i := 1; i < n; i++ {
|
||||
c := username[i]
|
||||
switch {
|
||||
case c >= 'a' && c <= 'z', c >= '0' && c <= '9', c == '_' || c == '.' || c == '-':
|
||||
continue
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// normalizePhone keeps digits (maps Persian/Arabic digits) and canonicalizes
|
||||
// Iranian mobiles to 09XXXXXXXXX (from +98… / 98… / 9…).
|
||||
func normalizePhone(raw string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range strings.TrimSpace(raw) {
|
||||
switch {
|
||||
case r >= '۰' && r <= '۹':
|
||||
b.WriteByte(byte('0' + (r - '۰')))
|
||||
case r >= '٠' && r <= '٩':
|
||||
b.WriteByte(byte('0' + (r - '٠')))
|
||||
case r >= '0' && r <= '9':
|
||||
b.WriteRune(r)
|
||||
case r == '+' && b.Len() == 0:
|
||||
// skip; country code handled below from digits
|
||||
}
|
||||
}
|
||||
s := b.String()
|
||||
switch {
|
||||
case len(s) == 12 && strings.HasPrefix(s, "989"):
|
||||
return "0" + s[2:]
|
||||
case len(s) == 10 && s[0] == '9':
|
||||
return "0" + s
|
||||
default:
|
||||
return s
|
||||
}
|
||||
}
|
||||
|
||||
// validPhone accepts empty (optional admin fields) or Iranian mobile 09XXXXXXXXX.
|
||||
func validPhone(phone string) bool {
|
||||
if phone == "" {
|
||||
return true
|
||||
}
|
||||
if len(phone) != 11 || !strings.HasPrefix(phone, "09") {
|
||||
return false
|
||||
}
|
||||
for _, r := range phone {
|
||||
if r < '0' || r > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
// Second digit after 0 must be 9 (already in prefix); third is operator 0–9.
|
||||
return true
|
||||
}
|
||||
|
||||
func validatePersonName(name string) string {
|
||||
if name == "" {
|
||||
return msgNameRequired
|
||||
}
|
||||
if utf8.RuneCountInString(name) > maxNameLen {
|
||||
return msgNameTooLong
|
||||
}
|
||||
// Reject names that are only whitespace/control (trim already applied).
|
||||
for _, r := range name {
|
||||
if !unicode.IsSpace(r) && !unicode.IsControl(r) {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return msgNameRequired
|
||||
}
|
||||
|
||||
func validateRegisterPassword(password, confirm string) string {
|
||||
if len(password) < minPasswordLen {
|
||||
return msgPasswordShort
|
||||
}
|
||||
if len(password) > maxPasswordLen {
|
||||
return msgPasswordLong
|
||||
}
|
||||
if password != confirm {
|
||||
return msgPasswordMismatch
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func validatePasswordOnly(password string) string {
|
||||
if len(password) < minPasswordLen {
|
||||
return msgPasswordShort
|
||||
}
|
||||
if len(password) > maxPasswordLen {
|
||||
return msgPasswordLong
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func validateSecurityQA(question, answer string) string {
|
||||
if question == "" || answer == "" {
|
||||
return msgSecQARequired
|
||||
}
|
||||
qn := utf8.RuneCountInString(question)
|
||||
an := utf8.RuneCountInString(answer)
|
||||
if qn < minSecQLen {
|
||||
return msgSecQShort
|
||||
}
|
||||
if qn > maxSecQLen {
|
||||
return msgSecQLong
|
||||
}
|
||||
if an < minSecALen {
|
||||
return msgSecAShort
|
||||
}
|
||||
if an > maxSecALen {
|
||||
return msgSecALong
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func validateInviteLabel(label string) string {
|
||||
if utf8.RuneCountInString(label) > maxInviteLabel {
|
||||
return msgInviteLabelLong
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// validateStudentRegistrationFields validates public/invite registration payloads.
|
||||
// Phone is always required. Returns the first Persian error message, or "".
|
||||
func validateStudentRegistrationFields(
|
||||
name, email, username, phone, password, passwordConfirm, secQ, secA string,
|
||||
) string {
|
||||
if msg := validatePersonName(name); msg != "" {
|
||||
return msg
|
||||
}
|
||||
if !validEmail(email) {
|
||||
return msgEmailInvalid
|
||||
}
|
||||
if !validUsername(username) {
|
||||
return msgUsernameInvalid
|
||||
}
|
||||
if msg := validateRegisterPassword(password, passwordConfirm); msg != "" {
|
||||
return msg
|
||||
}
|
||||
if phone == "" {
|
||||
return msgPhoneRequired
|
||||
}
|
||||
if !validPhone(phone) {
|
||||
return msgPhoneInvalid
|
||||
}
|
||||
if msg := validateSecurityQA(secQ, secA); msg != "" {
|
||||
return msg
|
||||
}
|
||||
return ""
|
||||
}
|
||||
Reference in New Issue
Block a user