Files
ahp 422e6ed289 feat(backend): implement HTTP API, auth, chat, and admin surfaces
Expose learning, registration controls, mentor chat, realtime WebSocket, and staff administration.
2026-10-07 19:40:26 +03:30

229 lines
5.8 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 ""
}