===== FILE: common/models/Category.php ===== ``` true), array('name', 'length', 'max'=>255), array('code', 'length', 'max'=>20), // The following rule is used by search(). // @todo Please remove those attributes that should not be searched. array('id, name, sort, code', 'safe', 'on'=>'search'), ); } /** * @return array relational rules. */ public function relations() { // NOTE: you may need to adjust the relation name and the related // class name for the relations automatically generated below. return array( 'interpreters' => array(self::HAS_MANY, 'Interpreter', 'category_id'), 'settings' => array(self::HAS_MANY, 'Settings', 'category_id'), 'places' => array(self::HAS_MANY, 'Place', 'category_id'), ); } /** * @return array customized attribute labels (name=>label) */ public function attributeLabels() { return array( 'id' => 'ID', 'name' => 'Название', 'sort' => 'Сортировка', 'code' => 'Код', ); } /** * Retrieves a list of models based on the current search/filter conditions. * * Typical usecase: * - Initialize the model fields with values from filter form. * - Execute this method to get CActiveDataProvider instance which will filter * models according to data in model fields. * - Pass data provider to CGridView, CListView or any similar widget. * * @return CActiveDataProvider the data provider that can return the models * based on the search/filter conditions. */ public function search() { // @todo Please modify the following code to remove attributes that should not be searched. $criteria=new CDbCriteria; $criteria->compare('id',$this->id,true); $criteria->compare('name',$this->name,true); $criteria->compare('sort',$this->sort); $criteria->compare('code',$this->code,true); return new CActiveDataProvider($this, array( 'criteria'=>$criteria, )); } /** * Returns the static model of the specified AR class. * Please note that you should have this exact method in all your CActiveRecord descendants! * @param string $className active record class name. * @return Category the static model class */ public static function model($className=__CLASS__) { return parent::model($className); } } ``` ===== FILE: common/models/TaskStatus.php ===== ``` 64), // The following rule is used by search(). // @todo Please remove those attributes that should not be searched. array("id, name", "safe", "on" => "search"), ); } public function scopes() { return array( "sorted" => array( "order" => "t.id ASC", ), ); } /** * @return array relational rules. */ public function relations() { // NOTE: you may need to adjust the relation name and the related // class name for the relations automatically generated below. return array( ); } /** * @return array customized attribute labels (name=>label) */ public function attributeLabels($viewLabels = false) { if( $viewLabels ){ return array( "id" => (object) array( "name" => "ID", "width" => "30px", ), "name" => (object) array( "name" => "Наименование", ), ); }else{ return array( "id" => "ID", "name" => "Наименование", ); } } /** * Retrieves a list of models based on the current search/filter conditions. * * Typical usecase: * - Initialize the model fields with values from filter form. * - Execute this method to get CActiveDataProvider instance which will filter * models according to data in model fields. * - Pass data provider to CGridView, CListView or any similar widget. * * @return CActiveDataProvider the data provider that can return the models * based on the search/filter conditions. */ public function search($pages, $count = false) { // @todo Please modify the following code to remove attributes that should not be searched. $criteria=new CDbCriteria; $criteria->addSearchCondition("id", $this->id); $criteria->addSearchCondition("name", $this->name); if( $count ){ return TaskStatus::model()->count($criteria); }else{ return new CActiveDataProvider($this, array( "criteria" => $criteria, "pagination" => array("pageSize" => $pages, "route" => "taskstatus/adminindex") )); } } public function updateObj($attributes){ foreach ($attributes as &$value) { $value = trim($value); } $this->attributes = $attributes; if($this->save()){ return true; }else{ print_r($this->getErrors()); return false; } } /** * Returns the static model of the specified AR class. * Please note that you should have this exact method in all your CActiveRecord descendants! * @param string $className active record class name. * @return TaskStatus the static model class */ public static function model($className=__CLASS__) { return parent::model($className); } } ``` ===== FILE: common/models/CreditsLedger.php ===== ``` true), array("reason", "length", "max" => 32), array("comment", "length", "max" => 255), array("subscription_id, task_id, cp_transaction_id", "safe"), // The following rule is used by search(). // @todo Please remove those attributes that should not be searched. array("id, user_id, delta, reason, comment, subscription_id, task_id, cp_transaction_id, created_at", "safe", "on" => "search"), ); } /** * @return array relational rules. */ public function relations() { // NOTE: you may need to adjust the relation name and the related // class name for the relations automatically generated below. return array( "subscription" => array(self::BELONGS_TO, 'UserSubscription', 'subscription_id'), "task" => array(self::BELONGS_TO, 'Task', 'task_id'), "user" => array(self::BELONGS_TO, 'User', 'user_id'), ); } /** * @return array customized attribute labels (name=>label) */ public function attributeLabels() { return array( "id" => "ID", "user_id" => "User", "delta" => "Delta", "reason" => "Reason", "comment" => "Comment", "subscription_id" => "Subscription", "task_id" => "Task", "cp_transaction_id" => "Cp Transaction", "created_at" => "Created At", ); } /** * Retrieves a list of models based on the current search/filter conditions. * * Typical usecase: * - Initialize the model fields with values from filter form. * - Execute this method to get CActiveDataProvider instance which will filter * models according to data in model fields. * - Pass data provider to CGridView, CListView or any similar widget. * * @return CActiveDataProvider the data provider that can return the models * based on the search/filter conditions. */ public function search($pages, $count = false) { // @todo Please modify the following code to remove attributes that should not be searched. $criteria=new CDbCriteria; $criteria->addSearchCondition("id", $this->id); $criteria->addSearchCondition("user_id", $this->user_id); $criteria->compare("delta", $this->delta); $criteria->addSearchCondition("reason", $this->reason); $criteria->addSearchCondition("comment", $this->comment); $criteria->addSearchCondition("subscription_id", $this->subscription_id); $criteria->addSearchCondition("task_id", $this->task_id); $criteria->addSearchCondition("cp_transaction_id", $this->cp_transaction_id); $criteria->addSearchCondition("created_at", $this->created_at); if( $count ){ return CreditsLedger::model()->count($criteria); }else{ return new CActiveDataProvider($this, array( "criteria" => $criteria, "pagination" => array("pageSize" => $pages, "route" => "creditsLedger/adminindex") )); } } public function updateObj($attributes){ foreach ($attributes as &$value) { $value = trim($value); } $this->attributes = $attributes; if($this->save()){ return true; }else{ print_r($this->getErrors()); return false; } } /** * Returns the static model of the specified AR class. * Please note that you should have this exact method in all your CActiveRecord descendants! * @param string $className active record class name. * @return CreditsLedger the static model class */ public static function model($className=__CLASS__) { return parent::model($className); } } ``` ===== FILE: common/models/PlanVersion.php ===== ``` "₽" ]; /** * @return string the associated database table name */ public function tableName() { return "plan_version"; } /** * @return array validation rules for model attributes. */ public function rules() { // NOTE: you should only define rules for those attributes that // will receive user inputs. return array( array("plan_id, version, price, credits_per_month, created_at", "required"), array("is_active_for_new", "numerical", "integerOnly" => true), array("currency", "length", "max" => 3), array("features_json", "length", "max" => 2048), // The following rule is used by search(). // @todo Please remove those attributes that should not be searched. array("id, plan_id, version, price, currency, credits_per_month, features_json, is_active_for_new, created_at", "safe", "on" => "search"), ); } /** * @return array relational rules. */ public function relations() { // NOTE: you may need to adjust the relation name and the related // class name for the relations automatically generated below. return array( "plan" => array(self::BELONGS_TO, 'Plan', 'plan_id'), "userSubscriptions" => array(self::HAS_MANY, 'UserSubscription', 'plan_version_id'), ); } public function scopes() { return array( "active" => array( "order" => "t.price ASC", "condition" => "t.is_active_for_new = 1" ), ); } /** * @return array customized attribute labels (name=>label) */ public function attributeLabels() { return array( "id" => "ID", "plan_id" => "Plan", "version" => "Version", "price" => "Price", "currency" => "Currency", "credits_per_month" => "Credits Per Month", "features_json" => "Features Json", "is_active_for_new" => "Is Active For New", "created_at" => "Created At", ); } /** * Retrieves a list of models based on the current search/filter conditions. * * Typical usecase: * - Initialize the model fields with values from filter form. * - Execute this method to get CActiveDataProvider instance which will filter * models according to data in model fields. * - Pass data provider to CGridView, CListView or any similar widget. * * @return CActiveDataProvider the data provider that can return the models * based on the search/filter conditions. */ public function search($pages, $count = false) { // @todo Please modify the following code to remove attributes that should not be searched. $criteria=new CDbCriteria; $criteria->addSearchCondition("id", $this->id); $criteria->addSearchCondition("plan_id", $this->plan_id); $criteria->addSearchCondition("version", $this->version); $criteria->addSearchCondition("price", $this->price); $criteria->addSearchCondition("currency", $this->currency); $criteria->addSearchCondition("credits_per_month", $this->credits_per_month); $criteria->addSearchCondition("features_json", $this->features_json); $criteria->compare("is_active_for_new", $this->is_active_for_new); $criteria->addSearchCondition("created_at", $this->created_at); if( $count ){ return PlanVersion::model()->count($criteria); }else{ return new CActiveDataProvider($this, array( "criteria" => $criteria, "pagination" => array("pageSize" => $pages, "route" => "planVersion/adminindex") )); } } public function updateObj($attributes){ foreach ($attributes as &$value) { $value = trim($value); } $this->attributes = $attributes; if($this->save()){ return true; }else{ print_r($this->getErrors()); return false; } } public function getKeyboard(){ $items = $this->active()->with("plan")->findAll(); $out = []; $raw = []; foreach ($items as $item) { $raw[] = [ "text" => $item->plan->title." – ".Yii::app()->controller->number_format($item->price)." ".$item->getRusCurrency()."/мес.", "callback_data" => "/buyplan_".$item->id, ]; if( count($raw) == 2 ){ $out[] = $raw; $raw = []; } } if( count($raw) ){ $out[] = $raw; } return $out; } public function getHTML($currency){ $items = $this->active()->with("plan")->findAll(); $html = ""; foreach ($items as $item) { $html .= "🔹 ".$item->plan->title." – ".Yii::app()->controller->number_format($item->price)." ".$item->getRusCurrency()."/мес.\n{$item->credits_per_month} ".Yii::app()->controller->pluralForm($item->credits_per_month, $currency)." · {$item->plan->description}\n\n"; } return $html; } public function getRusCurrency(){ return $this->currencies[$this->currency] ?? $this->currency; } /** * Returns the static model of the specified AR class. * Please note that you should have this exact method in all your CActiveRecord descendants! * @param string $className active record class name. * @return PlanVersion the static model class */ public static function model($className=__CLASS__) { return parent::model($className); } } ``` ===== FILE: common/models/Prompt.php ===== ``` 64), array("text", "length", "max" => 2048), array("prompt", "length", "max" => 8192), array("photo", "length", "max" => 1024), array("rubric_id, person_type_id, status_id, counter", "numerical"), // The following rule is used by search(). // @todo Please remove those attributes that should not be searched. array("id, name, text, prompt, photo, rubric_id, person_type_id, status_id, counter", "safe", "on" => "search"), ); } public function scopes() { return array( "sorted" => array( "order" => "t.id DESC", ), ); } /** * @return array relational rules. */ public function relations() { // NOTE: you may need to adjust the relation name and the related // class name for the relations automatically generated below. return array( "rubric" => array(self::BELONGS_TO, "Rubric", "rubric_id"), "tasks" => array(self::HAS_MANY, "Task", "prompt_id", "order" => "tasks.id DESC"), "personType" => array(self::BELONGS_TO, "PersonType", "person_type_id"), "status" => array(self::BELONGS_TO, "PromptStatus", "status_id"), ); } /** * @return array customized attribute labels (name=>label) */ public function attributeLabels($viewLabels = false) { if( $viewLabels ){ return array( "id" => (object) array( "name" => "ID", "width" => "30px", ), "name" => (object) array( "name" => "Наименование", ), "text" => (object) array( "name" => "Описание", "type" => "text", ), "prompt" => (object) array( "name" => "Текст промта", "type" => "text", ), "photo" => (object) array( "name" => "Фото-пример", "type" => "photo" ), "rubric_id" => (object) array( "name" => "Рубрика", "type" => "select", "model" => "Rubric", "value" => "name", "relation" => "rubric" ), "person_type_id" => (object) array( "name" => "Тип личности", "type" => "select", "model" => "PersonType", "value" => "name", "relation" => "personType" ), "status_id" => (object) array( "name" => "Статус", "type" => "select", "model" => "PromptStatus", "value" => "name", "relation" => "status" ), ); }else{ return array( "id" => "ID", "name" => "Наименование", "text" => "Описание", "prompt" => "Текст промта", "photo" => "Фото-пример", "rubric_id" => "Рубрика", "person_type_id" => "Тип личности", "status_id" => "Статус", "counter" => "Счетчик", ); } } /** * Retrieves a list of models based on the current search/filter conditions. * * Typical usecase: * - Initialize the model fields with values from filter form. * - Execute this method to get CActiveDataProvider instance which will filter * models according to data in model fields. * - Pass data provider to CGridView, CListView or any similar widget. * * @return CActiveDataProvider the data provider that can return the models * based on the search/filter conditions. */ public function search($pages, $count = false) { // @todo Please modify the following code to remove attributes that should not be searched. $criteria=new CDbCriteria; $criteria->addSearchCondition("id", $this->id); $criteria->addSearchCondition("name", $this->name); $criteria->addSearchCondition("text", $this->text); $criteria->addSearchCondition("prompt", $this->prompt); $criteria->addSearchCondition("photo", $this->photo); $criteria->compare("rubric_id", $this->rubric_id); $criteria->compare("person_type_id", $this->person_type_id); $criteria->compare("status_id", $this->status_id); $criteria->compare("counter", $this->counter); if( $count ){ return Prompt::model()->count($criteria); }else{ return new CActiveDataProvider($this, array( "criteria" => $criteria, "pagination" => array("pageSize" => $pages, "route" => "prompt/adminindex") )); } } public function updateObj($attributes){ foreach ($attributes as &$value) { $value = trim($value); } $this->attributes = $attributes; if($this->save()){ return true; }else{ print_r($this->getErrors()); return false; } } protected function beforeSave() { if (!parent::beforeSave()) { return false; } $this->counter ??= 0; if( !intval($this->status_id) ){ $this->status_id = Prompt::STATUS_GENERATING; } return true; } public function increaseCounter(){ $this->counter ++; $this->save(); } public function afterSave() { parent::afterSave(); if( !($task = Task::model()->find("user_id = 0 AND status_id = '".Task::STATUS_NEW."' AND session_id = '{$this->id}'")) && $this->status_id == Prompt::STATUS_GENERATING ){ $task = new Task(); $task->user_id = 0; $task->input_photo = $this->personType->photo ?? null; $task->prompt = $this->prompt; $task->session_id = $this->id; if( !$task->save() ){ var_dump($task->getErrors()); exit; } } return true; } public function getKeyboard(){ $items = $this->findAll(); $out = []; $raw = []; foreach ($items as $item) { $raw[] = [ "text" => $item->name, "callback_data" => "/sessioninfo_".$item->id, ]; if( count($raw) == 2 ){ $out[] = $raw; $raw = []; } } if( count($raw) ){ $out[] = $raw; } return $out; } /** * Returns the static model of the specified AR class. * Please note that you should have this exact method in all your CActiveRecord descendants! * @param string $className active record class name. * @return Prompt the static model class */ public static function model($className=__CLASS__) { return parent::model($className); } } ``` ===== FILE: common/models/PromptStatus.php ===== ``` 16), // The following rule is used by search(). // @todo Please remove those attributes that should not be searched. array("id, name", "safe", "on" => "search"), ); } /** * @return array relational rules. */ public function relations() { // NOTE: you may need to adjust the relation name and the related // class name for the relations automatically generated below. return array( ); } public function scopes() { return array( "sorted" => array( "order" => "t.id ASC", ), ); } /** * @return array customized attribute labels (name=>label) */ public function attributeLabels($viewLabels = false) { if( $viewLabels ){ return array( "id" => (object) array( "name" => "ID", "width" => "30px", ), "name" => (object) array( "name" => "Наименование", ), ); }else{ return array( "id" => "ID", "name" => "Наименование", ); } } /** * Retrieves a list of models based on the current search/filter conditions. * * Typical usecase: * - Initialize the model fields with values from filter form. * - Execute this method to get CActiveDataProvider instance which will filter * models according to data in model fields. * - Pass data provider to CGridView, CListView or any similar widget. * * @return CActiveDataProvider the data provider that can return the models * based on the search/filter conditions. */ public function search($pages, $count = false) { // @todo Please modify the following code to remove attributes that should not be searched. $criteria=new CDbCriteria; $criteria->addSearchCondition("id", $this->id); $criteria->addSearchCondition("name", $this->name); if( $count ){ return PromptStatus::model()->count($criteria); }else{ return new CActiveDataProvider($this, array( "criteria" => $criteria, "pagination" => array("pageSize" => $pages, "route" => "promptStatus/adminindex") )); } } public function updateObj($attributes){ foreach ($attributes as &$value) { $value = trim($value); } $this->attributes = $attributes; if($this->save()){ return true; }else{ print_r($this->getErrors()); return false; } } /** * Returns the static model of the specified AR class. * Please note that you should have this exact method in all your CActiveRecord descendants! * @param string $className active record class name. * @return PromptStatus the static model class */ public static function model($className=__CLASS__) { return parent::model($className); } } ``` ===== FILE: common/models/Settings.php ===== ``` true), array('category_id, parent_id', 'length', 'max'=>10), array('name, code', 'length', 'max'=>255), // The following rule is used by search(). // @todo Please remove those attributes that should not be searched. array('id, category_id, name, value, code, sort, parent_id', 'safe', 'on'=>'search'), ); } /** * @return array relational rules. */ public function relations() { // NOTE: you may need to adjust the relation name and the related // class name for the relations automatically generated below. return array( 'category' => array(self::BELONGS_TO, 'Category', 'category_id'), ); } /** * @return array customized attribute labels (name=>label) */ public function attributeLabels() { return array( 'id' => 'ID', 'category_id' => 'Раздел', 'name' => 'Название', 'value' => 'Значение', 'code' => 'Код', 'sort' => 'Сортировка', 'parent_id' => 'Родительский элемент', ); } /** * Retrieves a list of models based on the current search/filter conditions. * * Typical usecase: * - Initialize the model fields with values from filter form. * - Execute this method to get CActiveDataProvider instance which will filter * models according to data in model fields. * - Pass data provider to CGridView, CListView or any similar widget. * * @return CActiveDataProvider the data provider that can return the models * based on the search/filter conditions. */ public function search() { // @todo Please modify the following code to remove attributes that should not be searched. $criteria=new CDbCriteria; $criteria->compare('id',$this->id,true); $criteria->compare('category_id',$this->category_id,true); $criteria->compare('name',$this->name,true); $criteria->compare('value',$this->value,true); $criteria->compare('code',$this->code,true); $criteria->compare('sort',$this->sort); $criteria->compare('parent_id',$this->parent_id,true); return new CActiveDataProvider($this, array( 'criteria'=>$criteria, )); } public function getParam($category_code = NULL,$param_code = NULL){ if( $category_code && $param_code ){ $param = Settings::model()->with("category")->find(array("limit"=>1,"condition"=>"category.code='$category_code' AND t.code='$param_code'")); if( $param ) return $param->value; } return NULL; } /** * Returns the static model of the specified AR class. * Please note that you should have this exact method in all your CActiveRecord descendants! * @param string $className active record class name. * @return Settings the static model class */ public static function model($className=__CLASS__) { return parent::model($className); } } ``` ===== FILE: common/models/Package.php ===== ``` 256), array("amount", "length", "max" => 11), array("price, old_price", "length", "max" => 10), // The following rule is used by search(). // @todo Please remove those attributes that should not be searched. array("id, description, amount, price, old_price", "safe", "on" => "search"), ); } /** * @return array relational rules. */ public function relations() { // NOTE: you may need to adjust the relation name and the related // class name for the relations automatically generated below. return array( ); } public function scopes() { return array( "sorted" => array( "order" => "t.amount ASC", ), ); } /** * @return array customized attribute labels (name=>label) */ public function attributeLabels($viewLabels = false) { if( $viewLabels ){ return array( "id" => (object) array( "name" => "ID", "width" => "30px" ), "amount" => (object) array( "name" => "Количество генераций", "type" => "number" ), "description" => (object) array( "name" => "Описание" ), "price" => (object) array( "name" => "Цена", "type" => "ruble" ), "old_price" => (object) array( "name" => "Старая цена", "type" => "ruble" ), ); }else{ return array( "id" => "ID", "amount" => "Количество генераций", "description" => "Описание", "price" => "Цена", "old_price" => "Старая цена", ); } } /** * Retrieves a list of models based on the current search/filter conditions. * * Typical usecase: * - Initialize the model fields with values from filter form. * - Execute this method to get CActiveDataProvider instance which will filter * models according to data in model fields. * - Pass data provider to CGridView, CListView or any similar widget. * * @return CActiveDataProvider the data provider that can return the models * based on the search/filter conditions. */ public function search($pages, $count = false) { // @todo Please modify the following code to remove attributes that should not be searched. $criteria=new CDbCriteria; $criteria->addSearchCondition("id", $this->id); $criteria->addSearchCondition("description", $this->description); $criteria->addSearchCondition("amount", $this->amount); $criteria->addSearchCondition("price", $this->price); $criteria->addSearchCondition("old_price", $this->old_price); if( $count ){ return Package::model()->count($criteria); }else{ return new CActiveDataProvider($this, array( "criteria" => $criteria, "pagination" => array("pageSize" => $pages, "route" => "package/adminindex") )); } } public function updateObj($attributes){ foreach ($attributes as &$value) { $value = trim($value); } $this->attributes = $attributes; if($this->save()){ return true; }else{ print_r($this->getErrors()); return false; } } public function getHTML($currency){ $items = $this->sorted()->findAll(); $html = ""; foreach ($items as $item) { $html .= "🔹 {$item->amount} ".Yii::app()->controller->pluralForm($item->amount, $currency)." — {$item->price}₽ {$item->old_price}₽\n{$item->description}\n\n"; } return $html; } public function getTitle(){ $currency = Yii::app()->controller->getCurrency(); return "Пакет «{$this->amount} ".Yii::app()->controller->pluralForm($this->amount, $currency)."»"; } public function getKeyboard($currency){ $items = $this->findAll(); $out = []; $raw = []; foreach ($items as $item) { $raw[] = [ "text" => $item->amount." ".Yii::app()->controller->pluralForm($item->amount, $currency), "callback_data" => "/buy_".$item->id, ]; if( count($raw) == 2 ){ $out[] = $raw; $raw = []; } } if( count($raw) ){ $out[] = $raw; } return $out; } /** * Returns the static model of the specified AR class. * Please note that you should have this exact method in all your CActiveRecord descendants! * @param string $className active record class name. * @return Package the static model class */ public static function model($className=__CLASS__) { return parent::model($className); } } ``` ===== FILE: common/models/Order.php ===== ``` true), array("user_id, sum, package_id", "length", "max" => 10), array("last_error", "length", "max" => 1024), array("created_at", "safe"), // The following rule is used by search(). // @todo Please remove those attributes that should not be searched. array("id, user_id, status_id, sum, created_at, paid_at, last_error, package_id", "safe", "on" => "search"), ); } /** * @return array relational rules. */ public function relations() { // NOTE: you may need to adjust the relation name and the related // class name for the relations automatically generated below. return array( "user" => array(self::BELONGS_TO, "User", "user_id"), "status" => array(self::BELONGS_TO, "OrderStatus", "status_id"), "package" => array(self::BELONGS_TO, "Package", "package_id"), ); } public function scopes() { return array( "sorted" => array( "order" => "t.id DESC", ), ); } /** * @return array customized attribute labels (name=>label) */ public function attributeLabels($viewLabels = false) { if( $viewLabels ){ return array( "id" => (object) array( "name" => "ID", "width" => "30px" ), "user_id" => (object) array( "name" => "Пользователь", "type" => "select", "model" => "User", "value" => "name", "relation" => "user" ), "status_id" => (object) array( "name" => "Статус", "type" => "select", "model" => "OrderStatus", "value" => "name", "relation" => "status" ), "sum" => (object) array( "name" => "Сумма", "type" => "ruble" ), "created_at" => (object) array( "name" => "Дата создания", "type" => "date", "withoutForm" => true ), "paid_at" => (object) array( "name" => "Дата оплаты", "type" => "date", "withoutForm" => true ), "package_id" => (object) array( "name" => "Пакет", "type" => "select", "model" => "Package", "value" => "amount", "relation" => "package" ), "last_error" => (object) array( "name" => "Текст ошибки", ), ); }else{ return array( "id" => "ID", "user_id" => "Пользователь", "status_id" => "Статус", "sum" => "Сумма", "created_at" => "Дата создания", "paid_at" => "Дата оплаты", "package_id" => "Пакет", "last_error" => "Текст ошибки" ); } } /** * Retrieves a list of models based on the current search/filter conditions. * * Typical usecase: * - Initialize the model fields with values from filter form. * - Execute this method to get CActiveDataProvider instance which will filter * models according to data in model fields. * - Pass data provider to CGridView, CListView or any similar widget. * * @return CActiveDataProvider the data provider that can return the models * based on the search/filter conditions. */ public function search($pages, $count = false) { // @todo Please modify the following code to remove attributes that should not be searched. $criteria=new CDbCriteria; $criteria->addSearchCondition("id", $this->id); $criteria->addSearchCondition("user_id", $this->user_id); $criteria->compare("status_id", $this->status_id); $criteria->addSearchCondition("sum", $this->sum); $criteria->addSearchCondition("created_at", $this->created_at); $criteria->addSearchCondition("paid_at", $this->paid_at); $criteria->addSearchCondition("last_error", $this->last_error); $criteria->addSearchCondition("package_id", $this->package_id); if( $count ){ return Order::model()->count($criteria); }else{ return new CActiveDataProvider($this, array( "criteria" => $criteria, "pagination" => array("pageSize" => $pages, "route" => "order/adminindex") )); } } protected function beforeSave() { if (!parent::beforeSave()) { return false; } $this->created_at ??= date('Y-m-d H:i:s'); if( !$this->paid_at ){ $this->paid_at = null; } return true; } public function updateObj($attributes){ foreach ($attributes as &$value) { $value = trim($value); } $this->attributes = $attributes; if($this->save()){ return true; }else{ print_r($this->getErrors()); return false; } } public function getTitle(){ if( isset($this->package) && !empty($this->package) ){ return "Оплата пакета «{$this->package->amount} ".Yii::app()->controller->pluralForm($this->package->amount, Yii::app()->controller->currency)."»"; }else{ return "Оплата пакета"; } } public function getTotalToday() { $total = Yii::app()->db->createCommand() ->select('SUM(sum)') ->from('orders') ->where( 'status_id = :status AND paid_at BETWEEN :from AND :to', [ ':status' => Order::STATUS_PAID, ':from' => date('Y-m-d 00:00:00'), ':to' => date('Y-m-d 23:59:59'), ] ) ->queryScalar(); return $total; } /** * Returns the static model of the specified AR class. * Please note that you should have this exact method in all your CActiveRecord descendants! * @param string $className active record class name. * @return Order the static model class */ public static function model($className=__CLASS__) { return parent::model($className); } } ``` ===== FILE: common/models/LoginForm.php ===== ``` "Поле «{attribute}» не может быть пустым"), // rememberMe needs to be a boolean array('rememberMe', 'boolean'), // password needs to be authenticated array('password', 'authenticate'), ); } /** * Declares attribute labels. */ public function attributeLabels() { return array( 'username'=>'Логин', 'password'=>'Пароль', 'rememberMe'=>'Запомнить меня', ); } /** * Authenticates the password. * This is the 'authenticate' validator as declared in rules(). */ public function authenticate($attribute,$params) { $this->_identity=new UserIdentity($this->username,$this->password); if(!$this->_identity->authenticate()) $this->addError('password','Неверный логин или пароль'); } /** * Logs in the user using the given username and password in the model. * @return boolean whether login is successful */ public function login() { if($this->_identity===null) { $this->_identity=new UserIdentity($this->username, $this->password); $this->_identity->authenticate(); } $user = Admin::model()->find("login = '".$this->username."'"); if($this->_identity->errorCode===UserIdentity::ERROR_NONE && $user && $user->active) { // $duration=$this->rememberMe ? 3600*24*30 : 0; // 30 days Yii::app()->user->login($this->_identity,3600*24*30); return true; } else return false; } } ``` ===== FILE: common/models/PersonType.php ===== ``` 16), array("photo", "length", "max" => 1024), // The following rule is used by search(). // @todo Please remove those attributes that should not be searched. array("id, name, name_many, photo", "safe", "on" => "search"), ); } /** * @return array relational rules. */ public function relations() { // NOTE: you may need to adjust the relation name and the related // class name for the relations automatically generated below. return array( ); } public function scopes() { return array( "sorted" => array( "order" => "t.id ASC", ), ); } /** * @return array customized attribute labels (name=>label) */ public function attributeLabels($viewLabels = false) { if( $viewLabels ){ return array( "id" => (object) array( "name" => "ID", "width" => "30px" ), "name" => (object) array( "name" => "Наименование", ), "name_many" => (object) array( "name" => "Фотосессии ...", ), "photo" => (object) array( "name" => "Дефолтное фото", "type" => "photo" ), ); }else{ return array( "id" => "ID", "name" => "Наименование", "name_many" => "Фотосессии ...", "photo" => "Дефолтное фото", ); } } /** * Retrieves a list of models based on the current search/filter conditions. * * Typical usecase: * - Initialize the model fields with values from filter form. * - Execute this method to get CActiveDataProvider instance which will filter * models according to data in model fields. * - Pass data provider to CGridView, CListView or any similar widget. * * @return CActiveDataProvider the data provider that can return the models * based on the search/filter conditions. */ public function search($pages, $count = false) { // @todo Please modify the following code to remove attributes that should not be searched. $criteria=new CDbCriteria; $criteria->addSearchCondition("id", $this->id); $criteria->addSearchCondition("name", $this->name); $criteria->addSearchCondition("name_many", $this->name_many); $criteria->addSearchCondition("photo", $this->photo); if( $count ){ return PersonType::model()->count($criteria); }else{ return new CActiveDataProvider($this, array( "criteria" => $criteria, "pagination" => array("pageSize" => $pages, "route" => "personType/adminindex") )); } } public function updateObj($attributes){ foreach ($attributes as &$value) { $value = trim($value); } $this->attributes = $attributes; if($this->save()){ return true; }else{ print_r($this->getErrors()); return false; } } /** * Returns the static model of the specified AR class. * Please note that you should have this exact method in all your CActiveRecord descendants! * @param string $className active record class name. * @return PersonType the static model class */ public static function model($className=__CLASS__) { return parent::model($className); } } ``` ===== FILE: common/models/Advice.php ===== ``` true), array("text", "length", "max" => 512), // The following rule is used by search(). // @todo Please remove those attributes that should not be searched. array("id, text, active", "safe", "on" => "search"), ); } /** * @return array relational rules. */ public function relations() { // NOTE: you may need to adjust the relation name and the related // class name for the relations automatically generated below. return array( ); } public function scopes() { return array( "sorted" => array( "order" => "t.id DESC", ), ); } /** * @return array customized attribute labels (name=>label) */ public function attributeLabels($viewLabels = false) { if( $viewLabels ){ return array( "id" => (object) array( "name" => "ID", "width" => "30px" ), "text" => (object) array( "name" => "Текст", "type" => "text", ), "active" => (object) array( "name" => "Активность", "type" => "bool", ), ); }else{ return array( "id" => "ID", "text" => "Текст", "active" => "Активность", ); } } /** * Retrieves a list of models based on the current search/filter conditions. * * Typical usecase: * - Initialize the model fields with values from filter form. * - Execute this method to get CActiveDataProvider instance which will filter * models according to data in model fields. * - Pass data provider to CGridView, CListView or any similar widget. * * @return CActiveDataProvider the data provider that can return the models * based on the search/filter conditions. */ public function search($pages, $count = false) { // @todo Please modify the following code to remove attributes that should not be searched. $criteria=new CDbCriteria; $criteria->addSearchCondition("id", $this->id); $criteria->addSearchCondition("text", $this->text); $criteria->compare("active", $this->active); if( $count ){ return Advice::model()->count($criteria); }else{ return new CActiveDataProvider($this, array( "criteria" => $criteria, "pagination" => array("pageSize" => $pages, "route" => "advice/adminindex") )); } } public function updateObj($attributes){ foreach ($attributes as &$value) { $value = trim($value); } $this->attributes = $attributes; if($this->save()){ return true; }else{ print_r($this->getErrors()); return false; } } public function getRandomAdvice(){ $model = Advice::model()->find([ "condition" => "active = '1'", "order" => "RAND()", ]); return $model->text ?? null; } /** * Returns the static model of the specified AR class. * Please note that you should have this exact method in all your CActiveRecord descendants! * @param string $className active record class name. * @return Advice the static model class */ public static function model($className=__CLASS__) { return parent::model($className); } } ``` ===== FILE: common/models/User.php ===== ``` 128), array("photo, photo_full", "length", "max" => 1024), array("balance, paid", "numerical"), array("language_code, ref_id, source_id, root_source_id, root_ref_id, person_type_id", "length", "max" => 10), array("telegram_id", "length", "max" => 20), // The following rule is used by search(). // @todo Please remove those attributes that should not be searched. array("id, name, telegram_id, username, language_code, balance, enabled, paid, is_waiting_photo, is_waiting_photo_full, is_waiting_prompt, photo, photo_full, ref_id, source_id, root_source_id, root_ref_id, person_type_id, created_at", "safe", "on" => "search"), ); } public function scopes() { return array( "sorted" => array( "order" => "t.id DESC", ), ); } /** * @return array relational rules. */ public function relations() { // NOTE: you may need to adjust the relation name and the related // class name for the relations automatically generated below. return array( "ref" => array(self::BELONGS_TO, "User", "ref_id"), "rootRef" => array(self::BELONGS_TO, "User", "root_ref_id"), "source" => array(self::BELONGS_TO, "Source", "source_id"), "rootSource" => array(self::BELONGS_TO, "Source", "root_source_id"), "orders" => array(self::HAS_MANY, "Order", "user_id", "order" => "orders.id DESC"), "tasks" => array(self::HAS_MANY, "Task", "user_id", "order" => "tasks.id DESC"), "personType" => array(self::BELONGS_TO, "PersonType", "person_type_id"), ); } /** * @return array customized attribute labels (name=>label) */ public function attributeLabels($viewLabels = false) { if( $viewLabels ){ return array( "id" => (object) array( "name" => "ID", "width" => "30px", "type" => "default" ), "name" => (object) array( "name" => "Имя", "type" => "default" ), "telegram_id" => (object) array( "name" => "Telegram ID", "type" => "number" ), "username" => (object) array( "name" => "Telegram Ник", "type" => "default" ), "language_code" => (object) array( "name" => "Язык", "type" => "default" ), "balance" => (object) array( "name" => "Баланс", "type" => "ruble" ), "enabled" => (object) array( "name" => "Активность", "type" => "bool" ), "ref_id" => (object) array( "name" => "Реферер", "type" => "select", "model" => "User", "value" => "name", "relation" => "ref" ), "root_ref_id" => (object) array( "name" => "Истинный реферер", "type" => "select", "model" => "User", "value" => "name", "relation" => "rootRef" ), "source_id" => (object) array( "name" => "Источник", "type" => "select", "model" => "Source", "value" => "name", "relation" => "source" ), "root_source_id" => (object) array( "name" => "Истинный источник", "type" => "select", "model" => "Source", "value" => "name", "relation" => "rootSource" ), "photo" => (object) array( "name" => "Портрет", "type" => "photo", ), "photo_full" => (object) array( "name" => "Во весь рост", "type" => "photo", "withoutForm" => true ), "is_waiting_photo" => (object) array( "name" => "В ожидании портрета", "type" => "bool" ), "is_waiting_photo_full" => (object) array( "name" => "В ожидании фото во весь рост", "type" => "bool" ), "is_waiting_prompt" => (object) array( "name" => "В ожидании промта", "type" => "bool" ), "person_type_id" => (object) array( "name" => "Тип личности", "type" => "select", "model" => "PersonType", "value" => "name", "relation" => "personType" ), "paid" => (object) array( "name" => "Оплатил", "type" => "bool" ), "created_at" => (object) array( "name" => "Дата создания", "type" => "date", "withoutForm" => true ), ); }else{ return array( "id" => "ID", "name" => "Имя", "telegram_id" => "Telegram ID", "username" => "Telegram Ник", "language_code" => "Язык", "balance" => "Баланс", "enabled" => "Активность", "ref_id" => "Реферер", "root_ref_id" => "Истинный реферер", "photo" => "Портрет", "photo_full" => "Во весь рост", "source_id" => "Источник", "root_source_id" => "Истинный источник", "is_waiting_photo" => "В ожидании портрета", "is_waiting_photo_full" => "В ожидании фото во весь рост", "is_waiting_prompt" => "В ожидании промта", "person_type_id" => "Тип личности", "paid" => "Активность", "created_at" => "Дата создания", ); } } /** * Retrieves a list of models based on the current search/filter conditions. * * Typical usecase: * - Initialize the model fields with values from filter form. * - Execute this method to get CActiveDataProvider instance which will filter * models according to data in model fields. * - Pass data provider to CGridView, CListView or any similar widget. * * @return CActiveDataProvider the data provider that can return the models * based on the search/filter conditions. */ public function search($pages, $count = false) { // @todo Please modify the following code to remove attributes that should not be searched. $criteria=new CDbCriteria; $criteria->addSearchCondition("id", $this->id); $criteria->addSearchCondition("name", $this->name); $criteria->addSearchCondition("telegram_id", $this->telegram_id); $criteria->addSearchCondition("username", $this->username); $criteria->addSearchCondition("language_code", $this->language_code); $criteria->addSearchCondition("balance", $this->balance); $criteria->addSearchCondition("photo", $this->photo); $criteria->addSearchCondition("photo_full", $this->photo_full); $criteria->addSearchCondition("created_at", $this->created_at); $criteria->compare("enabled", $this->enabled); $criteria->compare("paid", $this->paid); $criteria->compare("is_waiting_photo", $this->is_waiting_photo); $criteria->compare("is_waiting_prompt", $this->is_waiting_prompt); $criteria->compare("source_id", $this->source_id); $criteria->compare("root_source_id", $this->root_source_id); $criteria->compare("person_type_id", $this->person_type_id); $criteria->compare("ref_id", $this->ref_id); $criteria->compare("root_ref_id", $this->root_ref_id); if( $count ){ return User::model()->count($criteria); }else{ return new CActiveDataProvider($this, array( "criteria" => $criteria, "pagination" => array("pageSize" => $pages, "route" => "user/adminindex") )); } } public function afterSave() { parent::afterSave(); return true; } public function beforeDelete() { parent::beforeDelete(); return true; } public function afterFind(){ parent::afterFind(); } protected function beforeSave() { if (!parent::beforeSave()) { return false; } if ($this->isNew) { $this->root_source_id = $this->getRootSourceId(); $this->root_ref_id = $this->getRootRefId(); } $this->person_type_id = $this->person_type_id ?: null; $this->root_source_id = $this->root_source_id ?: null; $this->root_ref_id = $this->root_ref_id ?: null; $this->source_id = $this->source_id ?: null; $this->ref_id = $this->ref_id ?: null; $this->paid ??= 0; $this->created_at ??= date('Y-m-d H:i:s'); return true; } public function getRootSourceId(){ if( $this->ref_id ){ $ref = User::model()->findByPk($this->ref_id, ['select' => 'root_source_id']); return $ref ? $ref->root_source_id : $this->source_id; }else{ return $this->source_id; } } public function getRootRefId(){ if( $this->ref_id ){ $ref = User::model()->findByPk($this->ref_id, ['select' => 'root_ref_id']); if( $ref ){ return ($ref->root_ref_id) ? $ref->root_ref_id : $this->ref_id; }else{ return $this->ref_id; } }else{ return $this->ref_id; } } public function updateObj($attributes){ foreach ($attributes as &$value) { $value = trim($value); } $this->attributes = $attributes; if($this->save()){ return true; }else{ print_r($this->getErrors()); return false; } } public function getDays(){ return round($this->balance/($this->rate/$this->period)); } public function getBalance(){ return (int) $this->balance; } public function increaseBalance($amount = 1){ $this->balance = (int) $this->balance + $amount; return $this->save(); } public function decreaseBalance($amount = 1){ $balance = (int) $this->balance; $amount = (int) $amount; if( $balance - $amount < 0 ){ return false; }else{ $this->balance = $balance - $amount; if( !$this->save() ){ var_dump($this->getErrors()); die(); } return true; } } public function setPaid($bool = true){ $this->paid = $bool?1:0; $this->save(); } public function downloadAndUpdatePhoto($fileId = null, $field = "photo"){ if( isset($fileId) ){ $tg = new Telegram($this->telegram_id); $savedPath = $tg->downloadTelegramFile($fileId, Yii::app()->params['saveFolder']."/"); $this->{$field} = $savedPath; if (!$this->save()){ var_dump($this->getErrors()); die(); } } } public function setWaitingPrompt($bool){ $this->is_waiting_prompt = boolval($bool); $this->save(); } public function isWaitingPrompt(){ return $this->is_waiting_prompt; } public function setWaitingPhoto($bool){ $this->is_waiting_photo = boolval($bool); $this->save(false); } public function isWaitingPhoto(){ return $this->is_waiting_photo; } public function isWaitingPhotoFull(){ return $this->is_waiting_photo_full; } public function setWaitingPhotoFull($bool){ $this->is_waiting_photo_full = boolval($bool); $this->save(false); } public function isWaitingAnyPhoto(){ return $this->isWaitingPhoto()? "photo": ( $this->isWaitingPhotoFull()?"photo_full":false ); } public function getInviteLink(){ return Yii::app()->params["botLink"]."?start=ref-".$this->id; } /** * Demo-пользователь, если: * сумма списаний за generation < сумма бонусных начислений (refBonus + startBonus). * * Списания в ledger у нас отрицательные (delta = -amount), поэтому spent считаем как -SUM(delta). */ public function isDemo(): bool { if ($this->_isDemoCache !== null) { return (bool)$this->_isDemoCache; } $db = Yii::app()->db; $row = $db->createCommand(" SELECT COALESCE(SUM(CASE WHEN reason = :rGen AND delta < 0 THEN -delta ELSE 0 END), 0) AS spent_generation, COALESCE(SUM(CASE WHEN (reason = :rRef OR reason = :rStart) AND delta > 0 THEN delta ELSE 0 END), 0) AS bonus_added FROM credits_ledger WHERE user_id = :uid ")->queryRow(true, [ ':uid' => (int)$this->id, ':rGen' => 'generation', ':rRef' => 'refBonus', ':rStart' => 'startBonus', ]); $spent = (int)($row['spent_generation'] ?? 0); $bonus = (int)($row['bonus_added'] ?? 0); // если списания >= бонусов => demo=false, иначе true $this->_isDemoCache = ($spent < $bonus); return (bool)$this->_isDemoCache; } /** * Возвращает активную (или "действующую до конца периода") подписку пользователя. * Учитывает grace-период после Cancelled до next_charge_at. * * @return UserSubscription|null */ public function getActiveSubscription(): ?UserSubscription { if ($this->_activeSubscriptionLoaded) { return $this->_activeSubscriptionCache; } $this->_activeSubscriptionLoaded = true; $criteria = new CDbCriteria(); $criteria->condition = " user_id = :uid AND ( (status = :stActive AND (canceled_at IS NULL OR (next_charge_at IS NOT NULL AND next_charge_at > NOW()))) OR (status = :stCancelled AND next_charge_at IS NOT NULL AND next_charge_at > NOW()) ) "; $criteria->params = [ ':uid' => (int)$this->id, ':stActive' => 'Active', ':stCancelled' => 'Cancelled', ]; $criteria->order = "t.id DESC"; $criteria->limit = 1; $this->_activeSubscriptionCache = UserSubscription::model()->find($criteria); return $this->_activeSubscriptionCache; } /** * Было: искал только status=Active. * Стало: bool-метод, учитывает next_charge_at после отмены. */ public function hasActiveSubscribtion(): bool { return $this->getActiveSubscription() !== null; } public function canGenerate(): bool { return $this->hasActiveSubscribtion() || $this->isDemo(); } /** * Returns the static model of the specified AR class. * Please note that you should have this exact method in all your CActiveRecord descendants! * @param string $className active record class name. * @return User the static model class */ public static function model($className=__CLASS__) { return parent::model($className); } } ``` ===== FILE: common/models/OrderStatus.php ===== ``` 32), // The following rule is used by search(). // @todo Please remove those attributes that should not be searched. array("id, name", "safe", "on" => "search"), ); } /** * @return array relational rules. */ public function relations() { // NOTE: you may need to adjust the relation name and the related // class name for the relations automatically generated below. return array( ); } public function scopes() { return array( "sorted" => array( "order" => "t.id ASC", ), ); } /** * @return array customized attribute labels (name=>label) */ public function attributeLabels($viewLabels = false) { if( $viewLabels ){ return array( "id" => (object) array( "name" => "ID", "width" => "30px" ), "name" => (object) array( "name" => "Наименование", ), ); }else{ return array( "id" => "ID", "name" => "Наименование", ); } } /** * Retrieves a list of models based on the current search/filter conditions. * * Typical usecase: * - Initialize the model fields with values from filter form. * - Execute this method to get CActiveDataProvider instance which will filter * models according to data in model fields. * - Pass data provider to CGridView, CListView or any similar widget. * * @return CActiveDataProvider the data provider that can return the models * based on the search/filter conditions. */ public function search($pages, $count = false) { // @todo Please modify the following code to remove attributes that should not be searched. $criteria=new CDbCriteria; $criteria->addSearchCondition("id", $this->id); $criteria->addSearchCondition("name", $this->name); if( $count ){ return OrderStatus::model()->count($criteria); }else{ return new CActiveDataProvider($this, array( "criteria" => $criteria, "pagination" => array("pageSize" => $pages, "route" => "orderStatus/adminindex") )); } } public function updateObj($attributes){ foreach ($attributes as &$value) { $value = trim($value); } $this->attributes = $attributes; if($this->save()){ return true; }else{ print_r($this->getErrors()); return false; } } /** * Returns the static model of the specified AR class. * Please note that you should have this exact method in all your CActiveRecord descendants! * @param string $className active record class name. * @return OrderStatus the static model class */ public static function model($className=__CLASS__) { return parent::model($className); } } ``` ===== FILE: common/models/Role.php ===== ``` 20), array("name", "length", "max" => 50), // The following rule is used by search(). // @todo Please remove those attributes that should not be searched. array("id, code, name", "safe", "on" => "search"), ); } public function scopes() { return array( "sorted" => array( "order" => "t.id ASC", ), ); } /** * @return array relational rules. */ public function relations() { // NOTE: you may need to adjust the relation name and the related // class name for the relations automatically generated below. return array( "users" => array(self::HAS_MANY, "UserRole", "role_id"), ); } /** * @return array customized attribute labels (name=>label) */ public function attributeLabels($viewLabels = false) { if( $viewLabels ){ return array( "id" => (object) array( "name" => "ID", "width" => "30px" ), "code" => (object) array( "name" => "Код" ), "name" => (object) array( "name" => "Наименование" ), ); }else{ return array( "id" => "ID", "code" => "Код", "name" => "Наименование", ); } } public function updateObj($attributes){ foreach ($attributes as &$value) { $value = trim($value); } $this->attributes = $attributes; if($this->save()){ return true; }else{ print_r($this->getErrors()); return false; } } /** * Retrieves a list of models based on the current search/filter conditions. * * Typical usecase: * - Initialize the model fields with values from filter form. * - Execute this method to get CActiveDataProvider instance which will filter * models according to data in model fields. * - Pass data provider to CGridView, CListView or any similar widget. * * @return CActiveDataProvider the data provider that can return the models * based on the search/filter conditions. */ public function search($pages, $count = false) { // @todo Please modify the following code to remove attributes that should not be searched. $criteria=new CDbCriteria; $criteria->compare("id", $this->id, true); $criteria->compare("code", $this->code, true); $criteria->compare("name", $this->name, true); if( $count ){ return Role::model()->count($criteria); }else{ return new CActiveDataProvider($this, array( "criteria" => $criteria, "pagination" => array("pageSize" => $pages, "route" => "role/adminindex") )); } } /** * Returns the static model of the specified AR class. * Please note that you should have this exact method in all your CActiveRecord descendants! * @param string $className active record class name. * @return Role the static model class */ public static function model($className=__CLASS__) { return parent::model($className); } } ``` ===== FILE: common/models/Task.php ===== ``` true), array("result_photo, input_photo", "length", "max" => 1024), array("prompt", "length", "max" => 8192), array("error_message", "length", "max" => 256), array("external_code, model", "length", "max" => 32), array("completed_at", "safe"), // The following rule is used by search(). // @todo Please remove those attributes that should not be searched. array("id, created_at, completed_at, status_id, user_id, session_id, tryon_deeplink_id, message_id, update_id, result_photo, input_photo, prompt, error_message, external_code, model", "safe", "on" => "search"), ); } /** * @return array relational rules. */ public function relations() { // NOTE: you may need to adjust the relation name and the related // class name for the relations automatically generated below. return array( "user" => array(self::BELONGS_TO, "User", "user_id"), "status" => array(self::BELONGS_TO, "TaskStatus", "status_id"), "session" => array(self::BELONGS_TO, "Prompt", "session_id"), "tryonDeeplink" => array(self::BELONGS_TO, "TryonDeeplink", "tryon_deeplink_id"), ); } public function scopes() { return array( "sorted" => array( "order" => "t.id DESC", ), ); } /** * @return array customized attribute labels (name=>label) */ public function attributeLabels($viewLabels = false) { if( $viewLabels ){ return array( "id" => (object) array( "name" => "ID", "width" => "30px", ), "user_id" => (object) array( "name" => "Пользователь", "type" => "select", "model" => "User", "value" => "name", "relation" => "user" ), "created_at" => (object) array( "name" => "Дата создания", "type" => "date", "withoutForm" => true ), "completed_at" => (object) array( "name" => "Дата выполнения", "type" => "date", "withoutForm" => true ), "status_id" => (object) array( "name" => "Статус", "type" => "select", "model" => "TaskStatus", "value" => "name", "relation" => "status" ), "result_photo" => (object) array( "name" => "Сгенеренное фото", "type" => "photoLink", "height" => 300 ), "input_photo" => (object) array( "name" => "Исходное фото", "type" => "photo", "height" => 300 ), "prompt" => (object) array( "name" => "Промт", "type" => "text", "width" => "400px", ), "session_id" => (object) array( "name" => "Фотосессия", "type" => "select", "model" => "Prompt", "value" => "name", "relation" => "session" ), "error_message" => (object) array( "name" => "Текст ошибки", ), "external_code" => (object) array( "name" => "Внешний код", ), "model" => (object) array( "name" => "Модель", ), "message_id" => (object) array( "name" => "ID сообщения в телеге", ), "update_id" => (object) array( "name" => "ID update", ), "tryon_deeplink_id" => (object) array( "name" => "Диплинк примерки", "type" => "select", "model" => "TryonDeeplink", "value" => "product_name", "relation" => "tryonDeeplink" ), ); }else{ return array( "id" => "ID", "user_id" => "Пользователь", "created_at" => "Дата создания", "completed_at" => "Дата выполнения", "status_id" => "Статус", "result_photo" => "Сгенеренное фото", "input_photo" => "Исходное фото", "prompt" => "Промт", "session_id" => "Фотосессия", "error_message" => "Текст ошибки", "external_code" => "Внешний код", "model" => "Модель", "message_id" => "ID сообщения в телеге", "update_id" => "ID update" ); } } protected function beforeSave() { if (!parent::beforeSave()) { return false; } $this->created_at ??= date('Y-m-d H:i:s'); $this->status_id ??= Task::STATUS_NEW; if( !$this->message_id ){ $this->message_id = null; } if( !$this->completed_at ){ $this->completed_at = null; } if( !$this->update_id ){ $this->update_id = null; } return true; } /** * Retrieves a list of models based on the current search/filter conditions. * * Typical usecase: * - Initialize the model fields with values from filter form. * - Execute this method to get CActiveDataProvider instance which will filter * models according to data in model fields. * - Pass data provider to CGridView, CListView or any similar widget. * * @return CActiveDataProvider the data provider that can return the models * based on the search/filter conditions. */ public function search($pages, $count = false) { // @todo Please modify the following code to remove attributes that should not be searched. $criteria=new CDbCriteria; $criteria->addSearchCondition("id", $this->id); $criteria->addSearchCondition("created_at", $this->created_at); $criteria->addSearchCondition("completed_at", $this->completed_at); $criteria->compare("status_id", $this->status_id); $criteria->compare("user_id", $this->user_id); $criteria->compare("session_id", $this->session_id); $criteria->compare("message_id", $this->message_id); $criteria->compare("update_id", $this->update_id); $criteria->addSearchCondition("result_photo", $this->result_photo); $criteria->addSearchCondition("input_photo", $this->input_photo); $criteria->addSearchCondition("prompt", $this->prompt); $criteria->addSearchCondition("error_message", $this->error_message); $criteria->addSearchCondition("external_code", $this->external_code); $criteria->addSearchCondition("model", $this->model); if( $count ){ return Task::model()->count($criteria); }else{ return new CActiveDataProvider($this, array( "criteria" => $criteria, "pagination" => array("pageSize" => $pages, "route" => "task/adminindex") )); } } public function updateObj($attributes){ foreach ($attributes as &$value) { $value = trim($value); } $this->attributes = $attributes; if($this->save()){ return true; }else{ print_r($this->getErrors()); return false; } } /** * Returns the static model of the specified AR class. * Please note that you should have this exact method in all your CActiveRecord descendants! * @param string $className active record class name. * @return Task the static model class */ public static function model($className=__CLASS__) { return parent::model($className); } } ``` ===== FILE: common/models/UserSubscription.php ===== ``` 64), array("status", "length", "max" => 16), array("card_last4", "length", "max" => 4), array("card_type", "length", "max" => 32), array("started_at, last_paid_at, next_charge_at, reminder_for, canceled_at, updated_at", "safe"), // The following rule is used by search(). // @todo Please remove those attributes that should not be searched. array("id, user_id, plan_version_id, cp_subscription_id, status, started_at, last_paid_at, next_charge_at, reminder_for, card_last4, card_type, canceled_at, created_at, updated_at", "safe", "on" => "search"), ); } /** * @return array relational rules. */ public function relations() { // NOTE: you may need to adjust the relation name and the related // class name for the relations automatically generated below. return array( "creditsLedgers" => array(self::HAS_MANY, 'CreditsLedger', 'subscription_id'), "charges" => array(self::HAS_MANY, 'SubscriptionCharge', 'subscription_id'), "planVersion" => array(self::BELONGS_TO, 'PlanVersion', 'plan_version_id'), "user" => array(self::BELONGS_TO, 'User', 'user_id'), ); } /** * @return array customized attribute labels (name=>label) */ public function attributeLabels() { return array( "id" => "ID", "user_id" => "User", "plan_version_id" => "Plan Version", "cp_subscription_id" => "Cp Subscription", "status" => "Status", "started_at" => "Started At", "last_paid_at" => "Last Paid At", "next_charge_at" => "Next Charge At", "reminder_for" => "Reminder For", "card_last4" => "Card Last4", "card_type" => "Card Type", "canceled_at" => "Canceled At", "created_at" => "Created At", "updated_at" => "Updated At", ); } /** * Retrieves a list of models based on the current search/filter conditions. * * Typical usecase: * - Initialize the model fields with values from filter form. * - Execute this method to get CActiveDataProvider instance which will filter * models according to data in model fields. * - Pass data provider to CGridView, CListView or any similar widget. * * @return CActiveDataProvider the data provider that can return the models * based on the search/filter conditions. */ public function search($pages, $count = false) { // @todo Please modify the following code to remove attributes that should not be searched. $criteria=new CDbCriteria; $criteria->addSearchCondition("id", $this->id); $criteria->addSearchCondition("user_id", $this->user_id); $criteria->addSearchCondition("plan_version_id", $this->plan_version_id); $criteria->addSearchCondition("cp_subscription_id", $this->cp_subscription_id); $criteria->addSearchCondition("status", $this->status); $criteria->addSearchCondition("started_at", $this->started_at); $criteria->addSearchCondition("last_paid_at", $this->last_paid_at); $criteria->addSearchCondition("next_charge_at", $this->next_charge_at); $criteria->addSearchCondition("reminder_for", $this->reminder_for); $criteria->addSearchCondition("card_last4", $this->card_last4); $criteria->addSearchCondition("card_type", $this->card_type); $criteria->addSearchCondition("canceled_at", $this->canceled_at); $criteria->addSearchCondition("created_at", $this->created_at); $criteria->addSearchCondition("updated_at", $this->updated_at); if( $count ){ return UserSubscription::model()->count($criteria); }else{ return new CActiveDataProvider($this, array( "criteria" => $criteria, "pagination" => array("pageSize" => $pages, "route" => "userSubscription/adminindex") )); } } public function updateObj($attributes){ foreach ($attributes as &$value) { $value = trim($value); } $this->attributes = $attributes; if($this->save()){ return true; }else{ print_r($this->getErrors()); return false; } } /** * Returns the static model of the specified AR class. * Please note that you should have this exact method in all your CActiveRecord descendants! * @param string $className active record class name. * @return UserSubscription the static model class */ public static function model($className=__CLASS__) { return parent::model($className); } } ``` ===== FILE: common/models/TryonDeeplink.php ===== ``` 32), array("payload_hash", "length", "max" => 40), array("product_name", "length", "max" => 255), array("last_used_at, use_count, gen_count, expire_at", "safe"), // The following rule is used by search(). // @todo Please remove those attributes that should not be searched. array("id, token, payload_hash, img_url, product_name, back_url, created_at, updated_at, last_used_at, use_count, gen_count, expire_at", "safe", "on" => "search"), ); } /** * @return array relational rules. */ public function relations() { // NOTE: you may need to adjust the relation name and the related // class name for the relations automatically generated below. return array( ); } public function scopes() { return array( "sorted" => array( "order" => "t.id DESC", ), ); } /** * @return array customized attribute labels (name=>label) */ public function attributeLabels() { return array( "id" => "ID", "token" => "Token", "payload_hash" => "Payload Hash", "img_url" => "Img Url", "product_name" => "Product Name", "back_url" => "Back Url", "created_at" => "Created At", "updated_at" => "Updated At", "last_used_at" => "Last Used At", "use_count" => "Use Count", "gen_count" => "Gen Count", "expire_at" => "Expire At", ); } /** * Retrieves a list of models based on the current search/filter conditions. * * Typical usecase: * - Initialize the model fields with values from filter form. * - Execute this method to get CActiveDataProvider instance which will filter * models according to data in model fields. * - Pass data provider to CGridView, CListView or any similar widget. * * @return CActiveDataProvider the data provider that can return the models * based on the search/filter conditions. */ public function search($pages, $count = false) { // @todo Please modify the following code to remove attributes that should not be searched. $criteria=new CDbCriteria; $criteria->addSearchCondition("id", $this->id); $criteria->addSearchCondition("token", $this->token); $criteria->addSearchCondition("payload_hash", $this->payload_hash); $criteria->addSearchCondition("img_url", $this->img_url); $criteria->addSearchCondition("product_name", $this->product_name); $criteria->addSearchCondition("back_url", $this->back_url); $criteria->addSearchCondition("created_at", $this->created_at); $criteria->addSearchCondition("updated_at", $this->updated_at); $criteria->addSearchCondition("last_used_at", $this->last_used_at); $criteria->addSearchCondition("use_count", $this->use_count); $criteria->addSearchCondition("gen_count", $this->gen_count); $criteria->addSearchCondition("expire_at", $this->expire_at); if( $count ){ return TryonDeeplink::model()->count($criteria); }else{ return new CActiveDataProvider($this, array( "criteria" => $criteria, "pagination" => array("pageSize" => $pages, "route" => "tryonDeeplink/adminindex") )); } } public function updateObj($attributes){ foreach ($attributes as &$value) { $value = trim($value); } $this->attributes = $attributes; if($this->save()){ return true; }else{ print_r($this->getErrors()); return false; } } /** * Returns the static model of the specified AR class. * Please note that you should have this exact method in all your CActiveRecord descendants! * @param string $className active record class name. * @return TryonDeeplink the static model class */ public static function model($className=__CLASS__) { return parent::model($className); } } ``` ===== FILE: common/models/AdminRole.php ===== ``` 10), // The following rule is used by search(). // @todo Please remove those attributes that should not be searched. array("admin_id, role_id", "safe", "on" => "search"), ); } /** * @return array relational rules. */ public function relations() { // NOTE: you may need to adjust the relation name and the related // class name for the relations automatically generated below. return array( "admin" => array(self::BELONGS_TO, "Admin", "admin_id"), "role" => array(self::BELONGS_TO, "Role", "role_id"), ); } /** * @return array customized attribute labels (name=>label) */ public function attributeLabels() { return array( "admin_id" => "Admin", "role_id" => "Role", ); } /** * Retrieves a list of models based on the current search/filter conditions. * * Typical usecase: * - Initialize the model fields with values from filter form. * - Execute this method to get CActiveDataProvider instance which will filter * models according to data in model fields. * - Pass data provider to CGridView, CListView or any similar widget. * * @return CActiveDataProvider the data provider that can return the models * based on the search/filter conditions. */ public function search() { // @todo Please modify the following code to remove attributes that should not be searched. $criteria=new CDbCriteria; $criteria->compare("admin_id", $this->admin_id,true); $criteria->compare("role_id", $this->role_id,true); return new CActiveDataProvider($this, array( "criteria" => $criteria, )); } public function afterSave() { parent::afterSave(); $auth = Yii::app()->authManager; $auth->revoke($this->role->code, $this->admin_id); $auth->assign($this->role->code, $this->admin_id); $auth->save(); return true; } public function beforeDelete() { parent::beforeDelete(); $auth = Yii::app()->authManager; $auth->revoke($this->role->code, $this->admin_id); $auth->save(); return true; } /** * Returns the static model of the specified AR class. * Please note that you should have this exact method in all your CActiveRecord descendants! * @param string $className active record class name. * @return AdminRole the static model class */ public static function model($className=__CLASS__) { return parent::model($className); } } ``` ===== FILE: common/models/ModelNames.php ===== ``` array( "order" => "t.id ASC", ), ); } /** * @return array validation rules for model attributes. */ public function rules() { // NOTE: you should only define rules for those attributes that // will receive user inputs. return array( array("code, name, vin_name, rod_name, menu_name", "required"), array("sort, parent_id", "numerical", "integerOnly" => true), array("code, name, vin_name, rod_name", "length", "max" => 128), array("rule, menu_name", "length", "max" => 32), // The following rule is used by search(). // @todo Please remove those attributes that should not be searched. array("id, code, name, vin_name, rod_name, menu_name, rule, sort, parent_id", "safe", "on" => "search"), ); } /** * @return array relational rules. */ public function relations() { // NOTE: you may need to adjust the relation name and the related // class name for the relations automatically generated below. return array( ); } /** * @return array customized attribute labels (name=>label) */ public function attributeLabels($viewLabels = false) { if( $viewLabels ){ return array( "id" => (object) array( "name" => "ID", "width" => "30px" ), "code" => (object) array( "name" => "Код" ), "name" => (object) array( "name" => "Название раздела" ), "vin_name" => (object) array( "name" => "Винительный падеж элемента" ), "rod_name" => (object) array( "name" => "Родительный падеж элемента" ), "rule" => (object) array( "name" => "Правило доступа" ), "sort" => (object) array( "name" => "Сортировка" ), "menu_name" => (object) array( "name" => "Название в меню" ), "parent_id" => (object) array( "name" => "Родительский раздел" ) ); }else{ return array( "id" => "ID", "code" => "Код", "name" => "Название раздела", "vin_name" => "Винительный падеж элемента", "rod_name" => "Родительный падеж элемента", "rule" => "Правило доступа", "sort" => "Сортировка", // "parent" => "Родитель", "menu_name" => "Название в меню", "parent_id" => "Родительский раздел", ); } } /** * Retrieves a list of models based on the current search/filter conditions. * * Typical usecase: * - Initialize the model fields with values from filter form. * - Execute this method to get CActiveDataProvider instance which will filter * models according to data in model fields. * - Pass data provider to CGridView, CListView or any similar widget. * * @return CActiveDataProvider the data provider that can return the models * based on the search/filter conditions. */ public function search($pages, $count = false) { // @todo Please modify the following code to remove attributes that should not be searched. $criteria = new CDbCriteria; $criteria->compare("id", $this->id); $criteria->compare("code", $this->code,true); $criteria->compare("name", $this->name,true); $criteria->compare("vin_name", $this->vin_name,true); $criteria->compare("rod_name", $this->rod_name,true); // $criteria->compare("admin_menu", $this->admin_menu); // $criteria->compare("sort", $this->sort); // $criteria->compare("parent_id", $this->parent_id); if( $count ){ return ModelNames::model()->count($criteria); }else{ return new CActiveDataProvider($this, array( "criteria" => $criteria, "pagination" => array("pageSize" => $pages, "route" => "point/index") )); } } public function updateObj($attributes){ foreach ($attributes as &$value) { $value = trim($value); } $this->attributes = $attributes; if($this->save()){ return true; }else{ print_r($this->getErrors()); return false; } } /** * Returns the static model of the specified AR class. * Please note that you should have this exact method in all your CActiveRecord descendants! * @param string $className active record class name. * @return ModelNames the static model class */ public static function model($className = __CLASS__) { return parent::model($className); } } ``` ===== FILE: common/models/SubscriptionCharge.php ===== ``` 16), array("currency", "length", "max" => 3), array("reason", "length", "max" => 255), array("transaction_id, raw_json", "safe"), // The following rule is used by search(). // @todo Please remove those attributes that should not be searched. array("id, subscription_id, kind, amount, currency, status, transaction_id, reason, raw_json, created_at", "safe", "on" => "search"), ); } /** * @return array relational rules. */ public function relations() { // NOTE: you may need to adjust the relation name and the related // class name for the relations automatically generated below. return array( "subscription" => array(self::BELONGS_TO, 'UserSubscription', 'subscription_id'), ); } /** * @return array customized attribute labels (name=>label) */ public function attributeLabels() { return array( "id" => "ID", "subscription_id" => "Subscription", "kind" => "Kind", "amount" => "Amount", "currency" => "Currency", "status" => "Status", "transaction_id" => "Transaction", "reason" => "Reason", "raw_json" => "Raw Json", "created_at" => "Created At", ); } /** * Retrieves a list of models based on the current search/filter conditions. * * Typical usecase: * - Initialize the model fields with values from filter form. * - Execute this method to get CActiveDataProvider instance which will filter * models according to data in model fields. * - Pass data provider to CGridView, CListView or any similar widget. * * @return CActiveDataProvider the data provider that can return the models * based on the search/filter conditions. */ public function search($pages, $count = false) { // @todo Please modify the following code to remove attributes that should not be searched. $criteria=new CDbCriteria; $criteria->addSearchCondition("id", $this->id); $criteria->addSearchCondition("subscription_id", $this->subscription_id); $criteria->addSearchCondition("kind", $this->kind); $criteria->addSearchCondition("amount", $this->amount); $criteria->addSearchCondition("currency", $this->currency); $criteria->addSearchCondition("status", $this->status); $criteria->addSearchCondition("transaction_id", $this->transaction_id); $criteria->addSearchCondition("reason", $this->reason); $criteria->addSearchCondition("raw_json", $this->raw_json); $criteria->addSearchCondition("created_at", $this->created_at); if( $count ){ return SubscriptionCharge::model()->count($criteria); }else{ return new CActiveDataProvider($this, array( "criteria" => $criteria, "pagination" => array("pageSize" => $pages, "route" => "subscriptionCharge/adminindex") )); } } public function updateObj($attributes){ foreach ($attributes as &$value) { $value = trim($value); } $this->attributes = $attributes; if($this->save()){ return true; }else{ print_r($this->getErrors()); return false; } } /** * Returns the static model of the specified AR class. * Please note that you should have this exact method in all your CActiveRecord descendants! * @param string $className active record class name. * @return SubscriptionCharge the static model class */ public static function model($className=__CLASS__) { return parent::model($className); } } ``` ===== FILE: common/models/Rubric.php ===== ``` 64), array("sort", "numerical"), // The following rule is used by search(). // @todo Please remove those attributes that should not be searched. array("id, name, sort", "safe", "on" => "search"), ); } public function scopes() { return array( "sorted" => array( "order" => "t.sort ASC", ), ); } /** * @return array relational rules. */ public function relations() { // NOTE: you may need to adjust the relation name and the related // class name for the relations automatically generated below. return array( "prompts" => array(self::HAS_MANY, "Prompt", "rubric_id"), ); } /** * @return array customized attribute labels (name=>label) */ public function attributeLabels($viewLabels = false) { if( $viewLabels ){ return array( "id" => (object) array( "name" => "ID", "width" => "30px", ), "name" => (object) array( "name" => "Наименование", ), "sort" => (object) array( "name" => "Сортировка", "type" => "number" ), ); }else{ return array( "id" => "ID", "name" => "Наименование", "sort" => "Сортировка", ); } } /** * Retrieves a list of models based on the current search/filter conditions. * * Typical usecase: * - Initialize the model fields with values from filter form. * - Execute this method to get CActiveDataProvider instance which will filter * models according to data in model fields. * - Pass data provider to CGridView, CListView or any similar widget. * * @return CActiveDataProvider the data provider that can return the models * based on the search/filter conditions. */ public function search($pages, $count = false) { // @todo Please modify the following code to remove attributes that should not be searched. $criteria=new CDbCriteria; $criteria->addSearchCondition("id", $this->id); $criteria->addSearchCondition("name", $this->name); $criteria->addSearchCondition("sort", $this->sort); if( $count ){ return Rubric::model()->count($criteria); }else{ return new CActiveDataProvider($this, array( "criteria" => $criteria, "pagination" => array("pageSize" => $pages, "route" => "rubric/adminindex") )); } } public function updateObj($attributes){ foreach ($attributes as &$value) { $value = trim($value); } $this->attributes = $attributes; if($this->save()){ return true; }else{ print_r($this->getErrors()); return false; } } /** * Returns the static model of the specified AR class. * Please note that you should have this exact method in all your CActiveRecord descendants! * @param string $className active record class name. * @return Rubric the static model class */ public static function model($className=__CLASS__) { return parent::model($className); } } ``` ===== FILE: common/models/Source.php ===== ``` 64), array("code", "length", "max" => 32), // The following rule is used by search(). // @todo Please remove those attributes that should not be searched. array("id, name, code", "safe", "on" => "search"), ); } /** * @return array relational rules. */ public function relations() { // NOTE: you may need to adjust the relation name and the related // class name for the relations automatically generated below. return array( ); } public function scopes() { return array( "sorted" => array( "order" => "t.id ASC", ), ); } /** * @return array customized attribute labels (name=>label) */ public function attributeLabels($viewLabels = false) { if( $viewLabels ){ return array( "id" => (object) array( "name" => "ID", "width" => "30px" ), "name" => (object) array( "name" => "Наименование", ), "code" => (object) array( "name" => "Символьный код", ), "sourceLink" => (object) array( "name" => "Ссылка", "type" => "sourceLink", "withoutForm" => true ), ); }else{ return array( "id" => "ID", "name" => "Наименование", "code" => "Символьный код", "code" => "Символьный код", ); } } /** * Retrieves a list of models based on the current search/filter conditions. * * Typical usecase: * - Initialize the model fields with values from filter form. * - Execute this method to get CActiveDataProvider instance which will filter * models according to data in model fields. * - Pass data provider to CGridView, CListView or any similar widget. * * @return CActiveDataProvider the data provider that can return the models * based on the search/filter conditions. */ public function search($pages, $count = false) { // @todo Please modify the following code to remove attributes that should not be searched. $criteria=new CDbCriteria; $criteria->addSearchCondition("id", $this->id); $criteria->addSearchCondition("name", $this->name); $criteria->addSearchCondition("code", $this->code); if( $count ){ return Source::model()->count($criteria); }else{ return new CActiveDataProvider($this, array( "criteria" => $criteria, "pagination" => array("pageSize" => $pages, "route" => "source/adminindex") )); } } public function updateObj($attributes){ foreach ($attributes as &$value) { $value = trim($value); } $this->attributes = $attributes; if($this->save()){ return true; }else{ print_r($this->getErrors()); return false; } } public function getSourceLink(){ return Yii::app()->params["botLink"]."?start=source-".$this->code; } /** * Returns the static model of the specified AR class. * Please note that you should have this exact method in all your CActiveRecord descendants! * @param string $className active record class name. * @return Source the static model class */ public static function model($className=__CLASS__) { return parent::model($className); } } ``` ===== FILE: common/models/Admin.php ===== ``` array( "order" => "t.name ASC", ), "active" => array( "condition" => "active = 1", ), ); } /** * @return array validation rules for model attributes. */ public function rules() { // NOTE: you should only define rules for those attributes that // will receive Admin inputs. return array( array("login, password, name", "required"), array("active", "numerical", "integerOnly"=>true), array("login, password, name", "length", "max"=>128), // The following rule is used by search(). // @todo Please remove those attributes that should not be searched. array("id, login, password, name, active", "safe", "on"=>"search"), ); } /** * @return array relational rules. */ public function relations() { // NOTE: you may need to adjust the relation name and the related // class name for the relations automatically generated below. return array( "roles" => array(self::HAS_MANY, "AdminRole", "admin_id"), ); } /** * @return array customized attribute labels (name=>label) */ public function attributeLabels() { return array( "id" => "ID", "login" => "Логин", "password" => "Пароль", "name" => "Имя", "roles" => "Доступ", "active" => "Активность", ); } public function beforeSave() { parent::beforeSave(); $this->password = ( $this->prevPass == $this->password ) ? $this->password : md5($this->password."eduplan"); if( preg_match("/^[0-9]+$/", $this->login) ){ Controller::returnError("Логин не может состоять только из цифр"); } if( !$this->login || !$this->password ){ return false; } if( !Yii::app()->user->checkAccess("updateAdmin") ) throw new CHttpException(403,"Доступ запрещен"); return true; } public function afterSave() { parent::afterSave(); // if( $this->isNewRecord ){ // $auth = Yii::app()->authManager; // $auth->assign("readAll", $this->id); // $auth->save(); // } return true; } /** * Retrieves a list of models based on the current search/filter conditions. * * Typical usecase: * - Initialize the model fields with values from filter form. * - Execute this method to get CActiveDataProvider instance which will filter * models according to data in model fields. * - Pass data provider to CGridView, CListView or any similar widget. * * @return CActiveDataProvider the data provider that can return the models * based on the search/filter conditions. */ public function search($pages, $count = false) { // @todo Please modify the following code to remove attributes that should not be searched. $criteria=new CDbCriteria; $criteria->compare("id", $this->id); $criteria->compare("login", $this->login, true); $criteria->compare("password", $this->password, true); $criteria->compare("name", $this->name, true); $criteria->compare("active", $this->active); if( $count ){ return Admin::model()->count($criteria); }else{ return new CActiveDataProvider($this, array( "criteria" => $criteria, "pagination" => array("pageSize" => $pages, "route" => "admin/index") )); } } public function getRoleNames(){ $out = array(); foreach ($this->roles as $i => $role) { $out[$role->role->id] = $role->role->name; } return $out; } public function beforeDelete() { parent::beforeDelete(); foreach ($this->roles as $key => $role) { $role->delete(); } return true; } /** * Returns the static model of the specified AR class. * Please note that you should have this exact method in all your CActiveRecord descendants! * @param string $className active record class name. * @return Admin the static model class */ public static function model($className=__CLASS__) { return parent::model($className); } } ``` ===== FILE: common/models/Plan.php ===== ``` 32), array("title", "length", "max" => 64), // The following rule is used by search(). // @todo Please remove those attributes that should not be searched. array("id, code, title", "safe", "on" => "search"), ); } /** * @return array relational rules. */ public function relations() { // NOTE: you may need to adjust the relation name and the related // class name for the relations automatically generated below. return array( "planVersions" => array(self::HAS_MANY, 'PlanVersion', 'plan_id'), ); } /** * @return array customized attribute labels (name=>label) */ public function attributeLabels() { return array( "id" => "ID", "code" => "Code", "title" => "Title", ); } /** * Retrieves a list of models based on the current search/filter conditions. * * Typical usecase: * - Initialize the model fields with values from filter form. * - Execute this method to get CActiveDataProvider instance which will filter * models according to data in model fields. * - Pass data provider to CGridView, CListView or any similar widget. * * @return CActiveDataProvider the data provider that can return the models * based on the search/filter conditions. */ public function search($pages, $count = false) { // @todo Please modify the following code to remove attributes that should not be searched. $criteria=new CDbCriteria; $criteria->addSearchCondition("id", $this->id); $criteria->addSearchCondition("code", $this->code); $criteria->addSearchCondition("title", $this->title); if( $count ){ return Plan::model()->count($criteria); }else{ return new CActiveDataProvider($this, array( "criteria" => $criteria, "pagination" => array("pageSize" => $pages, "route" => "plan/adminindex") )); } } public function updateObj($attributes){ foreach ($attributes as &$value) { $value = trim($value); } $this->attributes = $attributes; if($this->save()){ return true; }else{ print_r($this->getErrors()); return false; } } /** * Returns the static model of the specified AR class. * Please note that you should have this exact method in all your CActiveRecord descendants! * @param string $className active record class name. * @return Plan the static model class */ public static function model($className=__CLASS__) { return parent::model($className); } } ``` ===== FILE: common/extensions/Telegram.php ===== ``` token = Yii::app()->params["telegramToken"]; $this->apiBaseUrl = "https://api.telegram.org/bot{$this->token}"; $this->fileBaseUrl = "https://api.telegram.org/file/bot{$this->token}"; $this->chatId = $chatId; } public function sendMessage($data) { if( !empty($this->chatId) ){ $data["chat_id"] = $this->chatId; } $curl = curl_init(); curl_setopt_array($curl, [ CURLOPT_POST => 1, CURLOPT_HEADER => 0, CURLOPT_RETURNTRANSFER => 1, CURLOPT_URL => $this->apiBaseUrl . '/sendMessage', CURLOPT_POSTFIELDS => json_encode($data), CURLOPT_HTTPHEADER => array("Content-Type: application/json") ]); $result = curl_exec($curl); curl_close($curl); return (json_decode($result, 1) ? json_decode($result, 1) : $result); } public function sendPhoto($data) { if( !empty($this->chatId) ){ $data["chat_id"] = $this->chatId; } $ch = curl_init($this->apiBaseUrl.'/sendPhoto'); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true, CURLOPT_POSTFIELDS => $data, ]); $res = curl_exec($ch); curl_close($ch); return $res; } public function deleteMessage($messageId) { $url = $this->apiBaseUrl."/deleteMessage"; $data = [ 'message_id' => $messageId, ]; if( !empty($this->chatId) ){ $data["chat_id"] = $this->chatId; } $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true, CURLOPT_POSTFIELDS => $data, ]); $response = curl_exec($ch); $err = curl_error($ch); curl_close($ch); if ($err) { error_log("deleteMessage CURL ERROR: $err"); } return json_decode($response, true); } /** * Скачивает файл из Telegram по file_id и сохраняет на диск. * * @param string $fileId * @param string $uploadDir Каталог для сохранения * @param string|null $preferredName Имя файла, если хотим использовать оригинальное * * @return string|null Путь к сохранённому файлу или null при ошибке */ public function downloadTelegramFile( string $fileId, string $uploadDir, ?string $preferredName = null ): ?string { // 1. Узнаем file_path через getFile $url = $this->apiBaseUrl . '/getFile?file_id=' . urlencode($fileId); $resp = file_get_contents($url); if ($resp === false) { error_log("Failed to call getFile for file_id={$fileId}"); return null; } $data = json_decode($resp, true); if (!is_array($data) || empty($data['ok']) || empty($data['result']['file_path'])) { error_log("Invalid getFile response for file_id={$fileId}: " . $resp); return null; } $filePath = $data['result']['file_path']; // 2. Качаем файл по прямой ссылке $downloadUrl = $this->fileBaseUrl . '/' . $filePath; $fileBinary = file_get_contents($downloadUrl); if ($fileBinary === false) { error_log("Failed to download file from {$downloadUrl}"); return null; } // 3. Определяем имя файла $basename = $preferredName ?: basename($filePath); // Можно добавить префикс времени/ID чата, чтобы имена не пересекались $safeName = time() . '_' . preg_replace('~[^a-zA-Z0-9\.\-_]+~u', '_', $basename); $fullPath = $uploadDir.$safeName; if (file_put_contents($fullPath, $fileBinary) === false) { error_log("Failed to save file to {$fullPath}"); return null; } return $fullPath; } } ?> ``` ===== FILE: common/extensions/NanoBananaPro.php ===== ``` generateImage( * "Cute maltipoo puppy in a luxury dog bed, soft pastel colors", * __DIR__ . '/output.png' * ); * * echo "Saved to: " . $result['file_path'] . PHP_EOL; */ class NanoBananaPro { private $apiKey; private $model; private $baseUrl; private $proxy = null; /** * @param string $apiKey API key из Google AI Studio (GEMINI_API_KEY). * @param string $model Модель; по умолчанию Nano Banana Pro (Gemini 3 Pro Image Preview). * @param string $baseUrl Базовый URL Gemini Developer API. */ public function __construct( $apiKey, $proxy, $isPro = true, $baseUrl = 'https://generativelanguage.googleapis.com/v1beta' ) { $this->apiKey = $apiKey; $this->model = ($isPro) ? 'gemini-3-pro-image-preview' : 'gemini-2.5-flash-image-preview'; $this->baseUrl = rtrim($baseUrl, '/'); $arr = explode("@", $proxy); $this->proxy = (object) [ "user" => $arr[0], "host" => $arr[1], ]; } /** * Сгенерировать картинку по текстовому описанию. * * @param string $prompt Описание изображения. * @param string|null $saveToPath Путь, куда сохранить файл. Можно null — тогда только вернётся base64. * @param array $options Доп. настройки: * - 'safety_settings' => [...] * - 'generation_config' => [...] (сырой generationConfig) * - 'aspect_ratio' => '1:1'|'3:4'|'4:3'|'16:9'|'9:16' * - 'image_size' => '1K'|'2K'|'4K' * @param array $referenceImages Массив путей к локальным файлам-референсам. * Каждый элемент — строка (путь к файлу). * * @return array [ * 'mime_type' => string, * 'data_base64' => string, * 'binary' => string, // сырые байты * 'file_path' => string|null // куда сохранено (если $saveToPath указан) * ] * * @throws RuntimeException при ошибке HTTP или если модель не вернула картинку. */ public function generateImageClean( Task $task, array $options = [] ): array { // 1. Собираем parts: сначала референсы, потом текст $parts = []; $referenceImages = $this->getReferenceImages($task); foreach ($referenceImages as $path) { if (!is_string($path) || !is_file($path)) { throw new InvalidArgumentException("Reference image not found: {$path}"); } $binary = file_get_contents($path); if ($binary === false) { throw new RuntimeException("Failed to read reference image: {$path}"); } $base64 = base64_encode($binary); // Определяем MIME-тип $mimeType = $this->detectMimeType($path) ?? 'image/png'; $parts[] = [ 'inline_data' => [ 'mime_type' => $mimeType, 'data' => $base64, ], ]; } // В конце — текстовый промпт $parts[] = ['text' => $task->prompt]; // Базовый payload $payload = [ 'contents' => [[ 'parts' => $parts, ]], ]; // 2. Безопасность (как было) if (isset($options['safety_settings'])) { $payload['safetySettings'] = $options['safety_settings']; } // 3. Собираем generationConfig $generationConfig = $options['generation_config'] ?? []; // Удобные шорткаты: aspect_ratio / image_size if (isset($options['aspect_ratio']) || isset($options['image_size']) && $this->model == 'gemini-3-pro-image-preview') { if (!isset($generationConfig['imageConfig'])) { $generationConfig['imageConfig'] = []; } if (isset($options['aspect_ratio'])) { $generationConfig['imageConfig']['aspectRatio'] = $options['aspect_ratio']; } if (isset($options['image_size']) && $this->model == 'gemini-3-pro-image-preview') { $generationConfig['imageConfig']['imageSize'] = $options['image_size']; } } if (!empty($generationConfig)) { $payload['generationConfig'] = $generationConfig; } // 4. Запрос к API $url = sprintf('%s/models/%s:generateContent', $this->baseUrl, $this->model); $response = $this->postJson($url, $payload); // var_dump($response); // die(); // 5. Извлекаем первую картинку из ответа $imagePart = $this->extractFirstImagePart($response); if ($imagePart === null) { throw new RuntimeException('No image data found in Gemini response'); } $mimeType = $imagePart['mime_type']; $dataBase64 = $imagePart['data']; $binary = base64_decode($dataBase64, true); if ($binary === false) { throw new RuntimeException('Failed to decode base64 image data'); } $saveToPath = Yii::app()->params["generationFolder"] . '/tg_' . uniqid('', true) . '.png'; $savedPath = null; if ($saveToPath !== null) { // Создадим директорию, если нет $dir = dirname($saveToPath); if (!is_dir($dir)) { if (!mkdir($dir, 0775, true) && !is_dir($dir)) { throw new RuntimeException("Failed to create directory: $dir"); } } if (file_put_contents($saveToPath, $binary) === false) { throw new RuntimeException("Failed to save image to: $saveToPath"); } $savedPath = $saveToPath; } return [ "code" => $response["code"], "filePath" => $savedPath ]; } public function generateImage( Task $task, array $options = [] ): array { try { $response = $this->generateImageClean($task, $options); return [ "success" => true, "code" => $response["code"], "filePath" => $response["filePath"], "errorMessage" => null, "model" => $this->model ]; } catch (Throwable $e) { $errorText = $e->getMessage(); $code = $e->getCode(); return [ "success" => false, "code" => $code, "taskId" => null, "errorMessage" => $errorText, "model" => $this->model ]; } } /** * Определение MIME-типа по файлу (совместимо с PHP 7.1) */ private function detectMimeType(string $path): ?string { // ------- 1) Пытаемся определить через finfo ------- if (function_exists('finfo_open')) { $finfo = finfo_open(FILEINFO_MIME_TYPE); if ($finfo) { $mime = finfo_file($finfo, $path); finfo_close($finfo); if (is_string($mime)) { return $mime; } } } // ------- 2) Fallback по расширению (PHP 7.1-friendly) ------- $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION)); switch ($ext) { case 'jpg': case 'jpeg': return 'image/jpeg'; case 'png': return 'image/png'; case 'webp': return 'image/webp'; case 'gif': return 'image/gif'; default: return null; } } /** * Внутренний метод: POST JSON через cURL. * * @param string $url * @param array $payload * * @return array Декодированный JSON-ответ. */ private function postJson(string $url, array $payload): array { $ch = curl_init($url); if ($ch === false) { throw new RuntimeException('Failed to initialize cURL'); } $jsonPayload = json_encode($payload); if ($jsonPayload === false) { throw new RuntimeException('Failed to encode JSON payload'); } $headers = [ 'Content-Type: application/json', 'x-goog-api-key: ' . $this->apiKey, ]; curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => $headers, CURLOPT_POSTFIELDS => $jsonPayload, CURLOPT_TIMEOUT => 600, CURLOPT_PROXY => $this->proxy->host, CURLOPT_PROXYTYPE => CURLPROXY_HTTP, CURLOPT_PROXYUSERPWD => $this->proxy->user, ]); $body = curl_exec($ch); $errno = curl_errno($ch); $error = curl_error($ch); $status = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($errno) { throw new RuntimeException("cURL error ({$errno}): {$error}"); } // $status = 503; if ($status < 200 || $status >= 300) { throw new RuntimeException("Gemini API HTTP error: {$status}. Body: {$body}", $status); } $data = json_decode($body, true); if (!is_array($data)) { throw new RuntimeException('Failed to decode Gemini API response as JSON', 500); } $data["code"] = $status; return $data; } private function getReferenceImages($task){ $referenceImages = []; $raw = (string)$task->input_photo; $decoded = null; if ($raw !== '' && $raw[0] === '[') { $decoded = json_decode($raw, true); } $items = (is_array($decoded) && !empty($decoded)) ? $decoded : [$raw]; // нормализуем к локальным файлам foreach ($items as $p) { $p = trim((string)$p); if ($p === '') continue; if (is_file($p)) { $referenceImages[] = $p; continue; } $abs = realpath(Yii::getPathOfAlias('webroot') . DIRECTORY_SEPARATOR . ltrim($p, '/')); if ($abs && is_file($abs)) { $referenceImages[] = $abs; } } return $referenceImages; } /** * Извлечь первую часть ответа с картинкой. * * Gemini ответ: candidates[0].content.parts[].inline_data / inlineData * * @param array $response * * @return array|null ['mime_type' => string, 'data' => string] или null */ private function extractFirstImagePart(array $response): ?array { if (!isset($response['candidates'][0]['content']['parts'])) { return null; } $parts = $response['candidates'][0]['content']['parts']; foreach ($parts as $part) { // В REST-формате поле называется inline_data (snake_case), в некоторых примерах inlineData $inline = $part['inline_data'] ?? $part['inlineData'] ?? null; if ($inline && isset($inline['data'])) { return [ 'mime_type' => $inline['mime_type'] ?? $inline['mimeType'] ?? 'image/png', 'data' => $inline['data'], ]; } } return null; } } ``` ===== FILE: common/extensions/Mailer.php ===== ``` basePath.'/extensions/PHPMailer.php'; } public function send() { $mail = new PHPMailer(); try { $mail->setFrom('robot@spp.com', 'Служба Пассажирских Перевозок'); $mail->addAddress('mike@kitaev.pro'); $mail->isHTML(true); $mail->Subject = 'Here is the subject'; $mail->Body = 'This is the HTML message body in bold!'; $mail->send(); echo 'Message has been sent'; } catch (Exception $e) { echo "Message could not be sent. Mailer Error: {$mail->ErrorInfo}"; } } } ?> ``` ===== FILE: common/extensions/PHPMailer.php ===== ``` * @author Jim Jagielski (jimjag) * @author Andy Prevost (codeworxtech) * @author Brent R. Matzelle (original founder) * @copyright 2012 - 2017 Marcus Bointon * @copyright 2010 - 2012 Jim Jagielski * @copyright 2004 - 2009 Andy Prevost * @license http://www.gnu.org/copyleft/lesser.html GNU Lesser General Public License * @note This program is distributed in the hope that it will be useful - WITHOUT * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or * FITNESS FOR A PARTICULAR PURPOSE. */ /** * PHPMailer - PHP email creation and transport class. * * @author Marcus Bointon (Synchro/coolbru) * @author Jim Jagielski (jimjag) * @author Andy Prevost (codeworxtech) * @author Brent R. Matzelle (original founder) */ class PHPMailer { const CHARSET_ISO88591 = 'iso-8859-1'; const CHARSET_UTF8 = 'utf-8'; const CONTENT_TYPE_PLAINTEXT = 'text/plain'; const CONTENT_TYPE_TEXT_CALENDAR = 'text/calendar'; const CONTENT_TYPE_TEXT_HTML = 'text/html'; const CONTENT_TYPE_MULTIPART_ALTERNATIVE = 'multipart/alternative'; const CONTENT_TYPE_MULTIPART_MIXED = 'multipart/mixed'; const CONTENT_TYPE_MULTIPART_RELATED = 'multipart/related'; const ENCODING_7BIT = '7bit'; const ENCODING_8BIT = '8bit'; const ENCODING_BASE64 = 'base64'; const ENCODING_BINARY = 'binary'; const ENCODING_QUOTED_PRINTABLE = 'quoted-printable'; /** * Email priority. * Options: null (default), 1 = High, 3 = Normal, 5 = low. * When null, the header is not set at all. * * @var int */ public $Priority; /** * The character set of the message. * * @var string */ public $CharSet = self::CHARSET_UTF8; /** * The MIME Content-type of the message. * * @var string */ public $ContentType = self::CONTENT_TYPE_PLAINTEXT; /** * The message encoding. * Options: "8bit", "7bit", "binary", "base64", and "quoted-printable". * * @var string */ public $Encoding = self::ENCODING_8BIT; /** * Holds the most recent mailer error message. * * @var string */ public $ErrorInfo = ''; /** * The From email address for the message. * * @var string */ public $From = 'root@localhost'; /** * The From name of the message. * * @var string */ public $FromName = 'Root User'; /** * The envelope sender of the message. * This will usually be turned into a Return-Path header by the receiver, * and is the address that bounces will be sent to. * If not empty, will be passed via `-f` to sendmail or as the 'MAIL FROM' value over SMTP. * * @var string */ public $Sender = ''; /** * The Subject of the message. * * @var string */ public $Subject = ''; /** * An HTML or plain text message body. * If HTML then call isHTML(true). * * @var string */ public $Body = ''; /** * The plain-text message body. * This body can be read by mail clients that do not have HTML email * capability such as mutt & Eudora. * Clients that can read HTML will view the normal Body. * * @var string */ public $AltBody = ''; /** * An iCal message part body. * Only supported in simple alt or alt_inline message types * To generate iCal event structures, use classes like EasyPeasyICS or iCalcreator. * * @see http://sprain.ch/blog/downloads/php-class-easypeasyics-create-ical-files-with-php/ * @see http://kigkonsult.se/iCalcreator/ * * @var string */ public $Ical = ''; /** * The complete compiled MIME message body. * * @var string */ protected $MIMEBody = ''; /** * The complete compiled MIME message headers. * * @var string */ protected $MIMEHeader = ''; /** * Extra headers that createHeader() doesn't fold in. * * @var string */ protected $mailHeader = ''; /** * Word-wrap the message body to this number of chars. * Set to 0 to not wrap. A useful value here is 78, for RFC2822 section 2.1.1 compliance. * * @see static::STD_LINE_LENGTH * * @var int */ public $WordWrap = 0; /** * Which method to use to send mail. * Options: "mail", "sendmail", or "smtp". * * @var string */ public $Mailer = 'mail'; /** * The path to the sendmail program. * * @var string */ public $Sendmail = '/usr/sbin/sendmail'; /** * Whether mail() uses a fully sendmail-compatible MTA. * One which supports sendmail's "-oi -f" options. * * @var bool */ public $UseSendmailOptions = true; /** * The email address that a reading confirmation should be sent to, also known as read receipt. * * @var string */ public $ConfirmReadingTo = ''; /** * The hostname to use in the Message-ID header and as default HELO string. * If empty, PHPMailer attempts to find one with, in order, * $_SERVER['SERVER_NAME'], gethostname(), php_uname('n'), or the value * 'localhost.localdomain'. * * @var string */ public $Hostname = ''; /** * An ID to be used in the Message-ID header. * If empty, a unique id will be generated. * You can set your own, but it must be in the format "", * as defined in RFC5322 section 3.6.4 or it will be ignored. * * @see https://tools.ietf.org/html/rfc5322#section-3.6.4 * * @var string */ public $MessageID = ''; /** * The message Date to be used in the Date header. * If empty, the current date will be added. * * @var string */ public $MessageDate = ''; /** * SMTP hosts. * Either a single hostname or multiple semicolon-delimited hostnames. * You can also specify a different port * for each host by using this format: [hostname:port] * (e.g. "smtp1.example.com:25;smtp2.example.com"). * You can also specify encryption type, for example: * (e.g. "tls://smtp1.example.com:587;ssl://smtp2.example.com:465"). * Hosts will be tried in order. * * @var string */ public $Host = 'localhost'; /** * The default SMTP server port. * * @var int */ public $Port = 25; /** * The SMTP HELO of the message. * Default is $Hostname. If $Hostname is empty, PHPMailer attempts to find * one with the same method described above for $Hostname. * * @see PHPMailer::$Hostname * * @var string */ public $Helo = ''; /** * What kind of encryption to use on the SMTP connection. * Options: '', 'ssl' or 'tls'. * * @var string */ public $SMTPSecure = ''; /** * Whether to enable TLS encryption automatically if a server supports it, * even if `SMTPSecure` is not set to 'tls'. * Be aware that in PHP >= 5.6 this requires that the server's certificates are valid. * * @var bool */ public $SMTPAutoTLS = true; /** * Whether to use SMTP authentication. * Uses the Username and Password properties. * * @see PHPMailer::$Username * @see PHPMailer::$Password * * @var bool */ public $SMTPAuth = false; /** * Options array passed to stream_context_create when connecting via SMTP. * * @var array */ public $SMTPOptions = []; /** * SMTP username. * * @var string */ public $Username = ''; /** * SMTP password. * * @var string */ public $Password = ''; /** * SMTP auth type. * Options are CRAM-MD5, LOGIN, PLAIN, XOAUTH2, attempted in that order if not specified. * * @var string */ public $AuthType = ''; /** * An instance of the PHPMailer OAuth class. * * @var OAuth */ protected $oauth; /** * The SMTP server timeout in seconds. * Default of 5 minutes (300sec) is from RFC2821 section 4.5.3.2. * * @var int */ public $Timeout = 300; /** * SMTP class debug output mode. * Debug output level. * Options: * * `0` No output * * `1` Commands * * `2` Data and commands * * `3` As 2 plus connection status * * `4` Low-level data output. * * @see SMTP::$do_debug * * @var int */ public $SMTPDebug = 0; /** * How to handle debug output. * Options: * * `echo` Output plain-text as-is, appropriate for CLI * * `html` Output escaped, line breaks converted to `
`, appropriate for browser output * * `error_log` Output to error log as configured in php.ini * By default PHPMailer will use `echo` if run from a `cli` or `cli-server` SAPI, `html` otherwise. * Alternatively, you can provide a callable expecting two params: a message string and the debug level: * * ```php * $mail->Debugoutput = function($str, $level) {echo "debug level $level; message: $str";}; * ``` * * Alternatively, you can pass in an instance of a PSR-3 compatible logger, though only `debug` * level output is used: * * ```php * $mail->Debugoutput = new myPsr3Logger; * ``` * * @see SMTP::$Debugoutput * * @var string|callable|\Psr\Log\LoggerInterface */ public $Debugoutput = 'echo'; /** * Whether to keep SMTP connection open after each message. * If this is set to true then to close the connection * requires an explicit call to smtpClose(). * * @var bool */ public $SMTPKeepAlive = false; /** * Whether to split multiple to addresses into multiple messages * or send them all in one message. * Only supported in `mail` and `sendmail` transports, not in SMTP. * * @var bool */ public $SingleTo = false; /** * Storage for addresses when SingleTo is enabled. * * @var array */ protected $SingleToArray = []; /** * Whether to generate VERP addresses on send. * Only applicable when sending via SMTP. * * @see https://en.wikipedia.org/wiki/Variable_envelope_return_path * @see http://www.postfix.org/VERP_README.html Postfix VERP info * * @var bool */ public $do_verp = false; /** * Whether to allow sending messages with an empty body. * * @var bool */ public $AllowEmpty = false; /** * DKIM selector. * * @var string */ public $DKIM_selector = ''; /** * DKIM Identity. * Usually the email address used as the source of the email. * * @var string */ public $DKIM_identity = ''; /** * DKIM passphrase. * Used if your key is encrypted. * * @var string */ public $DKIM_passphrase = ''; /** * DKIM signing domain name. * * @example 'example.com' * * @var string */ public $DKIM_domain = ''; /** * DKIM Copy header field values for diagnostic use. * * @var bool */ public $DKIM_copyHeaderFields = true; /** * DKIM Extra signing headers. * * @example ['List-Unsubscribe', 'List-Help'] * * @var array */ public $DKIM_extraHeaders = []; /** * DKIM private key file path. * * @var string */ public $DKIM_private = ''; /** * DKIM private key string. * * If set, takes precedence over `$DKIM_private`. * * @var string */ public $DKIM_private_string = ''; /** * Callback Action function name. * * The function that handles the result of the send email action. * It is called out by send() for each email sent. * * Value can be any php callable: http://www.php.net/is_callable * * Parameters: * bool $result result of the send action * array $to email addresses of the recipients * array $cc cc email addresses * array $bcc bcc email addresses * string $subject the subject * string $body the email body * string $from email address of sender * string $extra extra information of possible use * "smtp_transaction_id' => last smtp transaction id * * @var string */ public $action_function = ''; /** * What to put in the X-Mailer header. * Options: An empty string for PHPMailer default, whitespace for none, or a string to use. * * @var string */ public $XMailer = ''; /** * Which validator to use by default when validating email addresses. * May be a callable to inject your own validator, but there are several built-in validators. * The default validator uses PHP's FILTER_VALIDATE_EMAIL filter_var option. * * @see PHPMailer::validateAddress() * * @var string|callable */ public static $validator = 'php'; /** * An instance of the SMTP sender class. * * @var SMTP */ protected $smtp; /** * The array of 'to' names and addresses. * * @var array */ protected $to = []; /** * The array of 'cc' names and addresses. * * @var array */ protected $cc = []; /** * The array of 'bcc' names and addresses. * * @var array */ protected $bcc = []; /** * The array of reply-to names and addresses. * * @var array */ protected $ReplyTo = []; /** * An array of all kinds of addresses. * Includes all of $to, $cc, $bcc. * * @see PHPMailer::$to * @see PHPMailer::$cc * @see PHPMailer::$bcc * * @var array */ protected $all_recipients = []; /** * An array of names and addresses queued for validation. * In send(), valid and non duplicate entries are moved to $all_recipients * and one of $to, $cc, or $bcc. * This array is used only for addresses with IDN. * * @see PHPMailer::$to * @see PHPMailer::$cc * @see PHPMailer::$bcc * @see PHPMailer::$all_recipients * * @var array */ protected $RecipientsQueue = []; /** * An array of reply-to names and addresses queued for validation. * In send(), valid and non duplicate entries are moved to $ReplyTo. * This array is used only for addresses with IDN. * * @see PHPMailer::$ReplyTo * * @var array */ protected $ReplyToQueue = []; /** * The array of attachments. * * @var array */ protected $attachment = []; /** * The array of custom headers. * * @var array */ protected $CustomHeader = []; /** * The most recent Message-ID (including angular brackets). * * @var string */ protected $lastMessageID = ''; /** * The message's MIME type. * * @var string */ protected $message_type = ''; /** * The array of MIME boundary strings. * * @var array */ protected $boundary = []; /** * The array of available languages. * * @var array */ protected $language = []; /** * The number of errors encountered. * * @var int */ protected $error_count = 0; /** * The S/MIME certificate file path. * * @var string */ protected $sign_cert_file = ''; /** * The S/MIME key file path. * * @var string */ protected $sign_key_file = ''; /** * The optional S/MIME extra certificates ("CA Chain") file path. * * @var string */ protected $sign_extracerts_file = ''; /** * The S/MIME password for the key. * Used only if the key is encrypted. * * @var string */ protected $sign_key_pass = ''; /** * Whether to throw exceptions for errors. * * @var bool */ protected $exceptions = false; /** * Unique ID used for message ID and boundaries. * * @var string */ protected $uniqueid = ''; /** * The PHPMailer Version number. * * @var string */ const VERSION = '6.0.5'; /** * Error severity: message only, continue processing. * * @var int */ const STOP_MESSAGE = 0; /** * Error severity: message, likely ok to continue processing. * * @var int */ const STOP_CONTINUE = 1; /** * Error severity: message, plus full stop, critical error reached. * * @var int */ const STOP_CRITICAL = 2; /** * SMTP RFC standard line ending. * * @var string */ protected static $LE = "\r\n"; /** * The maximum line length allowed by RFC 2822 section 2.1.1. * * @var int */ const MAX_LINE_LENGTH = 998; /** * The lower maximum line length allowed by RFC 2822 section 2.1.1. * This length does NOT include the line break * 76 means that lines will be 77 or 78 chars depending on whether * the line break format is LF or CRLF; both are valid. * * @var int */ const STD_LINE_LENGTH = 76; /** * Constructor. * * @param bool $exceptions Should we throw external exceptions? */ public function __construct($exceptions = null) { if (null !== $exceptions) { $this->exceptions = (bool) $exceptions; } //Pick an appropriate debug output format automatically $this->Debugoutput = (strpos(PHP_SAPI, 'cli') !== false ? 'echo' : 'html'); } /** * Destructor. */ public function __destruct() { //Close any open SMTP connection nicely $this->smtpClose(); } /** * Call mail() in a safe_mode-aware fashion. * Also, unless sendmail_path points to sendmail (or something that * claims to be sendmail), don't pass params (not a perfect fix, * but it will do). * * @param string $to To * @param string $subject Subject * @param string $body Message Body * @param string $header Additional Header(s) * @param string|null $params Params * * @return bool */ private function mailPassthru($to, $subject, $body, $header, $params) { //Check overloading of mail function to avoid double-encoding if (ini_get('mbstring.func_overload') & 1) { $subject = $this->secureHeader($subject); } else { $subject = $this->encodeHeader($this->secureHeader($subject)); } //Calling mail() with null params breaks if (!$this->UseSendmailOptions or null === $params) { $result = @mail($to, $subject, $body, $header); } else { $result = @mail($to, $subject, $body, $header, $params); } return $result; } /** * Output debugging info via user-defined method. * Only generates output if SMTP debug output is enabled (@see SMTP::$do_debug). * * @see PHPMailer::$Debugoutput * @see PHPMailer::$SMTPDebug * * @param string $str */ protected function edebug($str) { if ($this->SMTPDebug <= 0) { return; } //Is this a PSR-3 logger? if ($this->Debugoutput instanceof \Psr\Log\LoggerInterface) { $this->Debugoutput->debug($str); return; } //Avoid clash with built-in function names if (!in_array($this->Debugoutput, ['error_log', 'html', 'echo']) and is_callable($this->Debugoutput)) { call_user_func($this->Debugoutput, $str, $this->SMTPDebug); return; } switch ($this->Debugoutput) { case 'error_log': //Don't output, just log error_log($str); break; case 'html': //Cleans up output a bit for a better looking, HTML-safe output echo htmlentities( preg_replace('/[\r\n]+/', '', $str), ENT_QUOTES, 'UTF-8' ), "
\n"; break; case 'echo': default: //Normalize line breaks $str = preg_replace('/\r\n|\r/ms', "\n", $str); echo gmdate('Y-m-d H:i:s'), "\t", //Trim trailing space trim( //Indent for readability, except for trailing break str_replace( "\n", "\n \t ", trim($str) ) ), "\n"; } } /** * Sets message type to HTML or plain. * * @param bool $isHtml True for HTML mode */ public function isHTML($isHtml = true) { if ($isHtml) { $this->ContentType = static::CONTENT_TYPE_TEXT_HTML; } else { $this->ContentType = static::CONTENT_TYPE_PLAINTEXT; } } /** * Send messages using SMTP. */ public function isSMTP() { $this->Mailer = 'smtp'; } /** * Send messages using PHP's mail() function. */ public function isMail() { $this->Mailer = 'mail'; } /** * Send messages using $Sendmail. */ public function isSendmail() { $ini_sendmail_path = ini_get('sendmail_path'); if (false === stripos($ini_sendmail_path, 'sendmail')) { $this->Sendmail = '/usr/sbin/sendmail'; } else { $this->Sendmail = $ini_sendmail_path; } $this->Mailer = 'sendmail'; } /** * Send messages using qmail. */ public function isQmail() { $ini_sendmail_path = ini_get('sendmail_path'); if (false === stripos($ini_sendmail_path, 'qmail')) { $this->Sendmail = '/var/qmail/bin/qmail-inject'; } else { $this->Sendmail = $ini_sendmail_path; } $this->Mailer = 'qmail'; } /** * Add a "To" address. * * @param string $address The email address to send to * @param string $name * * @return bool true on success, false if address already used or invalid in some way */ public function addAddress($address, $name = '') { return $this->addOrEnqueueAnAddress('to', $address, $name); } /** * Add a "CC" address. * * @param string $address The email address to send to * @param string $name * * @return bool true on success, false if address already used or invalid in some way */ public function addCC($address, $name = '') { return $this->addOrEnqueueAnAddress('cc', $address, $name); } /** * Add a "BCC" address. * * @param string $address The email address to send to * @param string $name * * @return bool true on success, false if address already used or invalid in some way */ public function addBCC($address, $name = '') { return $this->addOrEnqueueAnAddress('bcc', $address, $name); } /** * Add a "Reply-To" address. * * @param string $address The email address to reply to * @param string $name * * @return bool true on success, false if address already used or invalid in some way */ public function addReplyTo($address, $name = '') { return $this->addOrEnqueueAnAddress('Reply-To', $address, $name); } /** * Add an address to one of the recipient arrays or to the ReplyTo array. Because PHPMailer * can't validate addresses with an IDN without knowing the PHPMailer::$CharSet (that can still * be modified after calling this function), addition of such addresses is delayed until send(). * Addresses that have been added already return false, but do not throw exceptions. * * @param string $kind One of 'to', 'cc', 'bcc', or 'ReplyTo' * @param string $address The email address to send, resp. to reply to * @param string $name * * @throws Exception * * @return bool true on success, false if address already used or invalid in some way */ protected function addOrEnqueueAnAddress($kind, $address, $name) { $address = trim($address); $name = trim(preg_replace('/[\r\n]+/', '', $name)); //Strip breaks and trim $pos = strrpos($address, '@'); if (false === $pos) { // At-sign is missing. $error_message = sprintf('%s (%s): %s', $this->lang('invalid_address'), $kind, $address); $this->setError($error_message); $this->edebug($error_message); if ($this->exceptions) { throw new Exception($error_message); } return false; } $params = [$kind, $address, $name]; // Enqueue addresses with IDN until we know the PHPMailer::$CharSet. if ($this->has8bitChars(substr($address, ++$pos)) and static::idnSupported()) { if ('Reply-To' != $kind) { if (!array_key_exists($address, $this->RecipientsQueue)) { $this->RecipientsQueue[$address] = $params; return true; } } else { if (!array_key_exists($address, $this->ReplyToQueue)) { $this->ReplyToQueue[$address] = $params; return true; } } return false; } // Immediately add standard addresses without IDN. return call_user_func_array([$this, 'addAnAddress'], $params); } /** * Add an address to one of the recipient arrays or to the ReplyTo array. * Addresses that have been added already return false, but do not throw exceptions. * * @param string $kind One of 'to', 'cc', 'bcc', or 'ReplyTo' * @param string $address The email address to send, resp. to reply to * @param string $name * * @throws Exception * * @return bool true on success, false if address already used or invalid in some way */ protected function addAnAddress($kind, $address, $name = '') { if (!in_array($kind, ['to', 'cc', 'bcc', 'Reply-To'])) { $error_message = sprintf('%s: %s', $this->lang('Invalid recipient kind'), $kind); $this->setError($error_message); $this->edebug($error_message); if ($this->exceptions) { throw new Exception($error_message); } return false; } if (!static::validateAddress($address)) { $error_message = sprintf('%s (%s): %s', $this->lang('invalid_address'), $kind, $address); $this->setError($error_message); $this->edebug($error_message); if ($this->exceptions) { throw new Exception($error_message); } return false; } if ('Reply-To' != $kind) { if (!array_key_exists(strtolower($address), $this->all_recipients)) { $this->{$kind}[] = [$address, $name]; $this->all_recipients[strtolower($address)] = true; return true; } } else { if (!array_key_exists(strtolower($address), $this->ReplyTo)) { $this->ReplyTo[strtolower($address)] = [$address, $name]; return true; } } return false; } /** * Parse and validate a string containing one or more RFC822-style comma-separated email addresses * of the form "display name
" into an array of name/address pairs. * Uses the imap_rfc822_parse_adrlist function if the IMAP extension is available. * Note that quotes in the name part are removed. * * @see http://www.andrew.cmu.edu/user/agreen1/testing/mrbs/web/Mail/RFC822.php A more careful implementation * * @param string $addrstr The address list string * @param bool $useimap Whether to use the IMAP extension to parse the list * * @return array */ public static function parseAddresses($addrstr, $useimap = true) { $addresses = []; if ($useimap and function_exists('imap_rfc822_parse_adrlist')) { //Use this built-in parser if it's available $list = imap_rfc822_parse_adrlist($addrstr, ''); foreach ($list as $address) { if ('.SYNTAX-ERROR.' != $address->host) { if (static::validateAddress($address->mailbox . '@' . $address->host)) { $addresses[] = [ 'name' => (property_exists($address, 'personal') ? $address->personal : ''), 'address' => $address->mailbox . '@' . $address->host, ]; } } } } else { //Use this simpler parser $list = explode(',', $addrstr); foreach ($list as $address) { $address = trim($address); //Is there a separate name part? if (strpos($address, '<') === false) { //No separate name, just use the whole thing if (static::validateAddress($address)) { $addresses[] = [ 'name' => '', 'address' => $address, ]; } } else { list($name, $email) = explode('<', $address); $email = trim(str_replace('>', '', $email)); if (static::validateAddress($email)) { $addresses[] = [ 'name' => trim(str_replace(['"', "'"], '', $name)), 'address' => $email, ]; } } } } return $addresses; } /** * Set the From and FromName properties. * * @param string $address * @param string $name * @param bool $auto Whether to also set the Sender address, defaults to true * * @throws Exception * * @return bool */ public function setFrom($address, $name = '', $auto = true) { $address = trim($address); $name = trim(preg_replace('/[\r\n]+/', '', $name)); //Strip breaks and trim // Don't validate now addresses with IDN. Will be done in send(). $pos = strrpos($address, '@'); if (false === $pos or (!$this->has8bitChars(substr($address, ++$pos)) or !static::idnSupported()) and !static::validateAddress($address)) { $error_message = sprintf('%s (From): %s', $this->lang('invalid_address'), $address); $this->setError($error_message); $this->edebug($error_message); if ($this->exceptions) { throw new Exception($error_message); } return false; } $this->From = $address; $this->FromName = $name; if ($auto) { if (empty($this->Sender)) { $this->Sender = $address; } } return true; } /** * Return the Message-ID header of the last email. * Technically this is the value from the last time the headers were created, * but it's also the message ID of the last sent message except in * pathological cases. * * @return string */ public function getLastMessageID() { return $this->lastMessageID; } /** * Check that a string looks like an email address. * Validation patterns supported: * * `auto` Pick best pattern automatically; * * `pcre8` Use the squiloople.com pattern, requires PCRE > 8.0; * * `pcre` Use old PCRE implementation; * * `php` Use PHP built-in FILTER_VALIDATE_EMAIL; * * `html5` Use the pattern given by the HTML5 spec for 'email' type form input elements. * * `noregex` Don't use a regex: super fast, really dumb. * Alternatively you may pass in a callable to inject your own validator, for example: * * ```php * PHPMailer::validateAddress('user@example.com', function($address) { * return (strpos($address, '@') !== false); * }); * ``` * * You can also set the PHPMailer::$validator static to a callable, allowing built-in methods to use your validator. * * @param string $address The email address to check * @param string|callable $patternselect Which pattern to use * * @return bool */ public static function validateAddress($address, $patternselect = null) { if (null === $patternselect) { $patternselect = static::$validator; } if (is_callable($patternselect)) { return call_user_func($patternselect, $address); } //Reject line breaks in addresses; it's valid RFC5322, but not RFC5321 if (strpos($address, "\n") !== false or strpos($address, "\r") !== false) { return false; } switch ($patternselect) { case 'pcre': //Kept for BC case 'pcre8': /* * A more complex and more permissive version of the RFC5322 regex on which FILTER_VALIDATE_EMAIL * is based. * In addition to the addresses allowed by filter_var, also permits: * * dotless domains: `a@b` * * comments: `1234 @ local(blah) .machine .example` * * quoted elements: `'"test blah"@example.org'` * * numeric TLDs: `a@b.123` * * unbracketed IPv4 literals: `a@192.168.0.1` * * IPv6 literals: 'first.last@[IPv6:a1::]' * Not all of these will necessarily work for sending! * * @see http://squiloople.com/2009/12/20/email-address-validation/ * @copyright 2009-2010 Michael Rushton * Feel free to use and redistribute this code. But please keep this copyright notice. */ return (bool) preg_match( '/^(?!(?>(?1)"?(?>\\\[ -~]|[^"])"?(?1)){255,})(?!(?>(?1)"?(?>\\\[ -~]|[^"])"?(?1)){65,}@)' . '((?>(?>(?>((?>(?>(?>\x0D\x0A)?[\t ])+|(?>[\t ]*\x0D\x0A)?[\t ]+)?)(\((?>(?2)' . '(?>[\x01-\x08\x0B\x0C\x0E-\'*-\[\]-\x7F]|\\\[\x00-\x7F]|(?3)))*(?2)\)))+(?2))|(?2))?)' . '([!#-\'*+\/-9=?^-~-]+|"(?>(?2)(?>[\x01-\x08\x0B\x0C\x0E-!#-\[\]-\x7F]|\\\[\x00-\x7F]))*' . '(?2)")(?>(?1)\.(?1)(?4))*(?1)@(?!(?1)[a-z0-9-]{64,})(?1)(?>([a-z0-9](?>[a-z0-9-]*[a-z0-9])?)' . '(?>(?1)\.(?!(?1)[a-z0-9-]{64,})(?1)(?5)){0,126}|\[(?:(?>IPv6:(?>([a-f0-9]{1,4})(?>:(?6)){7}' . '|(?!(?:.*[a-f0-9][:\]]){8,})((?6)(?>:(?6)){0,6})?::(?7)?))|(?>(?>IPv6:(?>(?6)(?>:(?6)){5}:' . '|(?!(?:.*[a-f0-9]:){6,})(?8)?::(?>((?6)(?>:(?6)){0,4}):)?))?(25[0-5]|2[0-4][0-9]|1[0-9]{2}' . '|[1-9]?[0-9])(?>\.(?9)){3}))\])(?1)$/isD', $address ); case 'html5': /* * This is the pattern used in the HTML5 spec for validation of 'email' type form input elements. * * @see http://www.whatwg.org/specs/web-apps/current-work/#e-mail-state-(type=email) */ return (bool) preg_match( '/^[a-zA-Z0-9.!#$%&\'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}' . '[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/sD', $address ); case 'php': default: return (bool) filter_var($address, FILTER_VALIDATE_EMAIL); } } /** * Tells whether IDNs (Internationalized Domain Names) are supported or not. This requires the * `intl` and `mbstring` PHP extensions. * * @return bool `true` if required functions for IDN support are present */ public static function idnSupported() { return function_exists('idn_to_ascii') and function_exists('mb_convert_encoding'); } /** * Converts IDN in given email address to its ASCII form, also known as punycode, if possible. * Important: Address must be passed in same encoding as currently set in PHPMailer::$CharSet. * This function silently returns unmodified address if: * - No conversion is necessary (i.e. domain name is not an IDN, or is already in ASCII form) * - Conversion to punycode is impossible (e.g. required PHP functions are not available) * or fails for any reason (e.g. domain contains characters not allowed in an IDN). * * @see PHPMailer::$CharSet * * @param string $address The email address to convert * * @return string The encoded address in ASCII form */ public function punyencodeAddress($address) { // Verify we have required functions, CharSet, and at-sign. $pos = strrpos($address, '@'); if (static::idnSupported() and !empty($this->CharSet) and false !== $pos ) { $domain = substr($address, ++$pos); // Verify CharSet string is a valid one, and domain properly encoded in this CharSet. if ($this->has8bitChars($domain) and @mb_check_encoding($domain, $this->CharSet)) { $domain = mb_convert_encoding($domain, 'UTF-8', $this->CharSet); //Ignore IDE complaints about this line - method signature changed in PHP 5.4 $errorcode = 0; $punycode = idn_to_ascii($domain, $errorcode, INTL_IDNA_VARIANT_UTS46); if (false !== $punycode) { return substr($address, 0, $pos) . $punycode; } } } return $address; } /** * Create a message and send it. * Uses the sending method specified by $Mailer. * * @throws Exception * * @return bool false on error - See the ErrorInfo property for details of the error */ public function send() { try { if (!$this->preSend()) { return false; } return $this->postSend(); } catch (Exception $exc) { $this->mailHeader = ''; $this->setError($exc->getMessage()); if ($this->exceptions) { throw $exc; } return false; } } /** * Prepare a message for sending. * * @throws Exception * * @return bool */ public function preSend() { if ('smtp' == $this->Mailer or ('mail' == $this->Mailer and stripos(PHP_OS, 'WIN') === 0) ) { //SMTP mandates RFC-compliant line endings //and it's also used with mail() on Windows static::setLE("\r\n"); } else { //Maintain backward compatibility with legacy Linux command line mailers static::setLE(PHP_EOL); } //Check for buggy PHP versions that add a header with an incorrect line break if (ini_get('mail.add_x_header') == 1 and 'mail' == $this->Mailer and stripos(PHP_OS, 'WIN') === 0 and ((version_compare(PHP_VERSION, '7.0.0', '>=') and version_compare(PHP_VERSION, '7.0.17', '<')) or (version_compare(PHP_VERSION, '7.1.0', '>=') and version_compare(PHP_VERSION, '7.1.3', '<'))) ) { trigger_error( 'Your version of PHP is affected by a bug that may result in corrupted messages.' . ' To fix it, switch to sending using SMTP, disable the mail.add_x_header option in' . ' your php.ini, switch to MacOS or Linux, or upgrade your PHP to version 7.0.17+ or 7.1.3+.', E_USER_WARNING ); } try { $this->error_count = 0; // Reset errors $this->mailHeader = ''; // Dequeue recipient and Reply-To addresses with IDN foreach (array_merge($this->RecipientsQueue, $this->ReplyToQueue) as $params) { $params[1] = $this->punyencodeAddress($params[1]); call_user_func_array([$this, 'addAnAddress'], $params); } if (count($this->to) + count($this->cc) + count($this->bcc) < 1) { throw new Exception($this->lang('provide_address'), self::STOP_CRITICAL); } // Validate From, Sender, and ConfirmReadingTo addresses foreach (['From', 'Sender', 'ConfirmReadingTo'] as $address_kind) { $this->$address_kind = trim($this->$address_kind); if (empty($this->$address_kind)) { continue; } $this->$address_kind = $this->punyencodeAddress($this->$address_kind); if (!static::validateAddress($this->$address_kind)) { $error_message = sprintf('%s (%s): %s', $this->lang('invalid_address'), $address_kind, $this->$address_kind); $this->setError($error_message); $this->edebug($error_message); if ($this->exceptions) { throw new Exception($error_message); } return false; } } // Set whether the message is multipart/alternative if ($this->alternativeExists()) { $this->ContentType = static::CONTENT_TYPE_MULTIPART_ALTERNATIVE; } $this->setMessageType(); // Refuse to send an empty message unless we are specifically allowing it if (!$this->AllowEmpty and empty($this->Body)) { throw new Exception($this->lang('empty_message'), self::STOP_CRITICAL); } //Trim subject consistently $this->Subject = trim($this->Subject); // Create body before headers in case body makes changes to headers (e.g. altering transfer encoding) $this->MIMEHeader = ''; $this->MIMEBody = $this->createBody(); // createBody may have added some headers, so retain them $tempheaders = $this->MIMEHeader; $this->MIMEHeader = $this->createHeader(); $this->MIMEHeader .= $tempheaders; // To capture the complete message when using mail(), create // an extra header list which createHeader() doesn't fold in if ('mail' == $this->Mailer) { if (count($this->to) > 0) { $this->mailHeader .= $this->addrAppend('To', $this->to); } else { $this->mailHeader .= $this->headerLine('To', 'undisclosed-recipients:;'); } $this->mailHeader .= $this->headerLine( 'Subject', $this->encodeHeader($this->secureHeader($this->Subject)) ); } // Sign with DKIM if enabled if (!empty($this->DKIM_domain) and !empty($this->DKIM_selector) and (!empty($this->DKIM_private_string) or (!empty($this->DKIM_private) and file_exists($this->DKIM_private)) ) ) { $header_dkim = $this->DKIM_Add( $this->MIMEHeader . $this->mailHeader, $this->encodeHeader($this->secureHeader($this->Subject)), $this->MIMEBody ); $this->MIMEHeader = rtrim($this->MIMEHeader, "\r\n ") . static::$LE . static::normalizeBreaks($header_dkim) . static::$LE; } return true; } catch (Exception $exc) { $this->setError($exc->getMessage()); if ($this->exceptions) { throw $exc; } return false; } } /** * Actually send a message via the selected mechanism. * * @throws Exception * * @return bool */ public function postSend() { try { // Choose the mailer and send through it switch ($this->Mailer) { case 'sendmail': case 'qmail': return $this->sendmailSend($this->MIMEHeader, $this->MIMEBody); case 'smtp': return $this->smtpSend($this->MIMEHeader, $this->MIMEBody); case 'mail': return $this->mailSend($this->MIMEHeader, $this->MIMEBody); default: $sendMethod = $this->Mailer . 'Send'; if (method_exists($this, $sendMethod)) { return $this->$sendMethod($this->MIMEHeader, $this->MIMEBody); } return $this->mailSend($this->MIMEHeader, $this->MIMEBody); } } catch (Exception $exc) { $this->setError($exc->getMessage()); $this->edebug($exc->getMessage()); if ($this->exceptions) { throw $exc; } } return false; } /** * Send mail using the $Sendmail program. * * @see PHPMailer::$Sendmail * * @param string $header The message headers * @param string $body The message body * * @throws Exception * * @return bool */ protected function sendmailSend($header, $body) { // CVE-2016-10033, CVE-2016-10045: Don't pass -f if characters will be escaped. if (!empty($this->Sender) and self::isShellSafe($this->Sender)) { if ('qmail' == $this->Mailer) { $sendmailFmt = '%s -f%s'; } else { $sendmailFmt = '%s -oi -f%s -t'; } } else { if ('qmail' == $this->Mailer) { $sendmailFmt = '%s'; } else { $sendmailFmt = '%s -oi -t'; } } $sendmail = sprintf($sendmailFmt, escapeshellcmd($this->Sendmail), $this->Sender); if ($this->SingleTo) { foreach ($this->SingleToArray as $toAddr) { $mail = @popen($sendmail, 'w'); if (!$mail) { throw new Exception($this->lang('execute') . $this->Sendmail, self::STOP_CRITICAL); } fwrite($mail, 'To: ' . $toAddr . "\n"); fwrite($mail, $header); fwrite($mail, $body); $result = pclose($mail); $this->doCallback( ($result == 0), [$toAddr], $this->cc, $this->bcc, $this->Subject, $body, $this->From, [] ); if (0 !== $result) { throw new Exception($this->lang('execute') . $this->Sendmail, self::STOP_CRITICAL); } } } else { $mail = @popen($sendmail, 'w'); if (!$mail) { throw new Exception($this->lang('execute') . $this->Sendmail, self::STOP_CRITICAL); } fwrite($mail, $header); fwrite($mail, $body); $result = pclose($mail); $this->doCallback( ($result == 0), $this->to, $this->cc, $this->bcc, $this->Subject, $body, $this->From, [] ); if (0 !== $result) { throw new Exception($this->lang('execute') . $this->Sendmail, self::STOP_CRITICAL); } } return true; } /** * Fix CVE-2016-10033 and CVE-2016-10045 by disallowing potentially unsafe shell characters. * Note that escapeshellarg and escapeshellcmd are inadequate for our purposes, especially on Windows. * * @see https://github.com/PHPMailer/PHPMailer/issues/924 CVE-2016-10045 bug report * * @param string $string The string to be validated * * @return bool */ protected static function isShellSafe($string) { // Future-proof if (escapeshellcmd($string) !== $string or !in_array(escapeshellarg($string), ["'$string'", "\"$string\""]) ) { return false; } $length = strlen($string); for ($i = 0; $i < $length; ++$i) { $c = $string[$i]; // All other characters have a special meaning in at least one common shell, including = and +. // Full stop (.) has a special meaning in cmd.exe, but its impact should be negligible here. // Note that this does permit non-Latin alphanumeric characters based on the current locale. if (!ctype_alnum($c) && strpos('@_-.', $c) === false) { return false; } } return true; } /** * Send mail using the PHP mail() function. * * @see http://www.php.net/manual/en/book.mail.php * * @param string $header The message headers * @param string $body The message body * * @throws Exception * * @return bool */ protected function mailSend($header, $body) { $toArr = []; foreach ($this->to as $toaddr) { $toArr[] = $this->addrFormat($toaddr); } $to = implode(', ', $toArr); $params = null; //This sets the SMTP envelope sender which gets turned into a return-path header by the receiver if (!empty($this->Sender) and static::validateAddress($this->Sender)) { //A space after `-f` is optional, but there is a long history of its presence //causing problems, so we don't use one //Exim docs: http://www.exim.org/exim-html-current/doc/html/spec_html/ch-the_exim_command_line.html //Sendmail docs: http://www.sendmail.org/~ca/email/man/sendmail.html //Qmail docs: http://www.qmail.org/man/man8/qmail-inject.html //Example problem: https://www.drupal.org/node/1057954 // CVE-2016-10033, CVE-2016-10045: Don't pass -f if characters will be escaped. if (self::isShellSafe($this->Sender)) { $params = sprintf('-f%s', $this->Sender); } } if (!empty($this->Sender) and static::validateAddress($this->Sender)) { $old_from = ini_get('sendmail_from'); ini_set('sendmail_from', $this->Sender); } $result = false; if ($this->SingleTo and count($toArr) > 1) { foreach ($toArr as $toAddr) { $result = $this->mailPassthru($toAddr, $this->Subject, $body, $header, $params); $this->doCallback($result, [$toAddr], $this->cc, $this->bcc, $this->Subject, $body, $this->From, []); } } else { $result = $this->mailPassthru($to, $this->Subject, $body, $header, $params); $this->doCallback($result, $this->to, $this->cc, $this->bcc, $this->Subject, $body, $this->From, []); } if (isset($old_from)) { ini_set('sendmail_from', $old_from); } if (!$result) { throw new Exception($this->lang('instantiate'), self::STOP_CRITICAL); } return true; } /** * Get an instance to use for SMTP operations. * Override this function to load your own SMTP implementation, * or set one with setSMTPInstance. * * @return SMTP */ public function getSMTPInstance() { if (!is_object($this->smtp)) { $this->smtp = new SMTP(); } return $this->smtp; } /** * Provide an instance to use for SMTP operations. * * @param SMTP $smtp * * @return SMTP */ public function setSMTPInstance(SMTP $smtp) { $this->smtp = $smtp; return $this->smtp; } /** * Send mail via SMTP. * Returns false if there is a bad MAIL FROM, RCPT, or DATA input. * * @see PHPMailer::setSMTPInstance() to use a different class. * * @uses \PHPMailer\PHPMailer\SMTP * * @param string $header The message headers * @param string $body The message body * * @throws Exception * * @return bool */ protected function smtpSend($header, $body) { $bad_rcpt = []; if (!$this->smtpConnect($this->SMTPOptions)) { throw new Exception($this->lang('smtp_connect_failed'), self::STOP_CRITICAL); } //Sender already validated in preSend() if ('' == $this->Sender) { $smtp_from = $this->From; } else { $smtp_from = $this->Sender; } if (!$this->smtp->mail($smtp_from)) { $this->setError($this->lang('from_failed') . $smtp_from . ' : ' . implode(',', $this->smtp->getError())); throw new Exception($this->ErrorInfo, self::STOP_CRITICAL); } $callbacks = []; // Attempt to send to all recipients foreach ([$this->to, $this->cc, $this->bcc] as $togroup) { foreach ($togroup as $to) { if (!$this->smtp->recipient($to[0])) { $error = $this->smtp->getError(); $bad_rcpt[] = ['to' => $to[0], 'error' => $error['detail']]; $isSent = false; } else { $isSent = true; } $callbacks[] = ['issent'=>$isSent, 'to'=>$to[0]]; } } // Only send the DATA command if we have viable recipients if ((count($this->all_recipients) > count($bad_rcpt)) and !$this->smtp->data($header . $body)) { throw new Exception($this->lang('data_not_accepted'), self::STOP_CRITICAL); } $smtp_transaction_id = $this->smtp->getLastTransactionID(); if ($this->SMTPKeepAlive) { $this->smtp->reset(); } else { $this->smtp->quit(); $this->smtp->close(); } foreach ($callbacks as $cb) { $this->doCallback( $cb['issent'], [$cb['to']], [], [], $this->Subject, $body, $this->From, ['smtp_transaction_id' => $smtp_transaction_id] ); } //Create error message for any bad addresses if (count($bad_rcpt) > 0) { $errstr = ''; foreach ($bad_rcpt as $bad) { $errstr .= $bad['to'] . ': ' . $bad['error']; } throw new Exception( $this->lang('recipients_failed') . $errstr, self::STOP_CONTINUE ); } return true; } /** * Initiate a connection to an SMTP server. * Returns false if the operation failed. * * @param array $options An array of options compatible with stream_context_create() * * @throws Exception * * @uses \PHPMailer\PHPMailer\SMTP * * @return bool */ public function smtpConnect($options = null) { if (null === $this->smtp) { $this->smtp = $this->getSMTPInstance(); } //If no options are provided, use whatever is set in the instance if (null === $options) { $options = $this->SMTPOptions; } // Already connected? if ($this->smtp->connected()) { return true; } $this->smtp->setTimeout($this->Timeout); $this->smtp->setDebugLevel($this->SMTPDebug); $this->smtp->setDebugOutput($this->Debugoutput); $this->smtp->setVerp($this->do_verp); $hosts = explode(';', $this->Host); $lastexception = null; foreach ($hosts as $hostentry) { $hostinfo = []; if (!preg_match( '/^((ssl|tls):\/\/)*([a-zA-Z0-9\.-]*|\[[a-fA-F0-9:]+\]):?([0-9]*)$/', trim($hostentry), $hostinfo )) { static::edebug($this->lang('connect_host') . ' ' . $hostentry); // Not a valid host entry continue; } // $hostinfo[2]: optional ssl or tls prefix // $hostinfo[3]: the hostname // $hostinfo[4]: optional port number // The host string prefix can temporarily override the current setting for SMTPSecure // If it's not specified, the default value is used //Check the host name is a valid name or IP address before trying to use it if (!static::isValidHost($hostinfo[3])) { static::edebug($this->lang('connect_host') . ' ' . $hostentry); continue; } $prefix = ''; $secure = $this->SMTPSecure; $tls = ('tls' == $this->SMTPSecure); if ('ssl' == $hostinfo[2] or ('' == $hostinfo[2] and 'ssl' == $this->SMTPSecure)) { $prefix = 'ssl://'; $tls = false; // Can't have SSL and TLS at the same time $secure = 'ssl'; } elseif ('tls' == $hostinfo[2]) { $tls = true; // tls doesn't use a prefix $secure = 'tls'; } //Do we need the OpenSSL extension? $sslext = defined('OPENSSL_ALGO_SHA256'); if ('tls' === $secure or 'ssl' === $secure) { //Check for an OpenSSL constant rather than using extension_loaded, which is sometimes disabled if (!$sslext) { throw new Exception($this->lang('extension_missing') . 'openssl', self::STOP_CRITICAL); } } $host = $hostinfo[3]; $port = $this->Port; $tport = (int) $hostinfo[4]; if ($tport > 0 and $tport < 65536) { $port = $tport; } if ($this->smtp->connect($prefix . $host, $port, $this->Timeout, $options)) { try { if ($this->Helo) { $hello = $this->Helo; } else { $hello = $this->serverHostname(); } $this->smtp->hello($hello); //Automatically enable TLS encryption if: // * it's not disabled // * we have openssl extension // * we are not already using SSL // * the server offers STARTTLS if ($this->SMTPAutoTLS and $sslext and 'ssl' != $secure and $this->smtp->getServerExt('STARTTLS')) { $tls = true; } if ($tls) { if (!$this->smtp->startTLS()) { throw new Exception($this->lang('connect_host')); } // We must resend EHLO after TLS negotiation $this->smtp->hello($hello); } if ($this->SMTPAuth) { if (!$this->smtp->authenticate( $this->Username, $this->Password, $this->AuthType, $this->oauth ) ) { throw new Exception($this->lang('authenticate')); } } return true; } catch (Exception $exc) { $lastexception = $exc; $this->edebug($exc->getMessage()); // We must have connected, but then failed TLS or Auth, so close connection nicely $this->smtp->quit(); } } } // If we get here, all connection attempts have failed, so close connection hard $this->smtp->close(); // As we've caught all exceptions, just report whatever the last one was if ($this->exceptions and null !== $lastexception) { throw $lastexception; } return false; } /** * Close the active SMTP session if one exists. */ public function smtpClose() { if (null !== $this->smtp) { if ($this->smtp->connected()) { $this->smtp->quit(); $this->smtp->close(); } } } /** * Set the language for error messages. * Returns false if it cannot load the language file. * The default language is English. * * @param string $langcode ISO 639-1 2-character language code (e.g. French is "fr") * @param string $lang_path Path to the language file directory, with trailing separator (slash) * * @return bool */ public function setLanguage($langcode = 'en', $lang_path = '') { // Backwards compatibility for renamed language codes $renamed_langcodes = [ 'br' => 'pt_br', 'cz' => 'cs', 'dk' => 'da', 'no' => 'nb', 'se' => 'sv', 'sr' => 'rs', ]; if (isset($renamed_langcodes[$langcode])) { $langcode = $renamed_langcodes[$langcode]; } // Define full set of translatable strings in English $PHPMAILER_LANG = [ 'authenticate' => 'SMTP Error: Could not authenticate.', 'connect_host' => 'SMTP Error: Could not connect to SMTP host.', 'data_not_accepted' => 'SMTP Error: data not accepted.', 'empty_message' => 'Message body empty', 'encoding' => 'Unknown encoding: ', 'execute' => 'Could not execute: ', 'file_access' => 'Could not access file: ', 'file_open' => 'File Error: Could not open file: ', 'from_failed' => 'The following From address failed: ', 'instantiate' => 'Could not instantiate mail function.', 'invalid_address' => 'Invalid address: ', 'mailer_not_supported' => ' mailer is not supported.', 'provide_address' => 'You must provide at least one recipient email address.', 'recipients_failed' => 'SMTP Error: The following recipients failed: ', 'signing' => 'Signing Error: ', 'smtp_connect_failed' => 'SMTP connect() failed.', 'smtp_error' => 'SMTP server error: ', 'variable_set' => 'Cannot set or reset variable: ', 'extension_missing' => 'Extension missing: ', ]; if (empty($lang_path)) { // Calculate an absolute path so it can work if CWD is not here $lang_path = dirname(__DIR__) . DIRECTORY_SEPARATOR . 'language' . DIRECTORY_SEPARATOR; } //Validate $langcode if (!preg_match('/^[a-z]{2}(?:_[a-zA-Z]{2})?$/', $langcode)) { $langcode = 'en'; } $foundlang = true; $lang_file = $lang_path . 'phpmailer.lang-' . $langcode . '.php'; // There is no English translation file if ('en' != $langcode) { // Make sure language file path is readable if (!file_exists($lang_file)) { $foundlang = false; } else { // Overwrite language-specific strings. // This way we'll never have missing translation keys. $foundlang = include $lang_file; } } $this->language = $PHPMAILER_LANG; return (bool) $foundlang; // Returns false if language not found } /** * Get the array of strings for the current language. * * @return array */ public function getTranslations() { return $this->language; } /** * Create recipient headers. * * @param string $type * @param array $addr An array of recipients, * where each recipient is a 2-element indexed array with element 0 containing an address * and element 1 containing a name, like: * [['joe@example.com', 'Joe User'], ['zoe@example.com', 'Zoe User']] * * @return string */ public function addrAppend($type, $addr) { $addresses = []; foreach ($addr as $address) { $addresses[] = $this->addrFormat($address); } return $type . ': ' . implode(', ', $addresses) . static::$LE; } /** * Format an address for use in a message header. * * @param array $addr A 2-element indexed array, element 0 containing an address, element 1 containing a name like * ['joe@example.com', 'Joe User'] * * @return string */ public function addrFormat($addr) { if (empty($addr[1])) { // No name provided return $this->secureHeader($addr[0]); } return $this->encodeHeader($this->secureHeader($addr[1]), 'phrase') . ' <' . $this->secureHeader( $addr[0] ) . '>'; } /** * Word-wrap message. * For use with mailers that do not automatically perform wrapping * and for quoted-printable encoded messages. * Original written by philippe. * * @param string $message The message to wrap * @param int $length The line length to wrap to * @param bool $qp_mode Whether to run in Quoted-Printable mode * * @return string */ public function wrapText($message, $length, $qp_mode = false) { if ($qp_mode) { $soft_break = sprintf(' =%s', static::$LE); } else { $soft_break = static::$LE; } // If utf-8 encoding is used, we will need to make sure we don't // split multibyte characters when we wrap $is_utf8 = 'utf-8' == strtolower($this->CharSet); $lelen = strlen(static::$LE); $crlflen = strlen(static::$LE); $message = static::normalizeBreaks($message); //Remove a trailing line break if (substr($message, -$lelen) == static::$LE) { $message = substr($message, 0, -$lelen); } //Split message into lines $lines = explode(static::$LE, $message); //Message will be rebuilt in here $message = ''; foreach ($lines as $line) { $words = explode(' ', $line); $buf = ''; $firstword = true; foreach ($words as $word) { if ($qp_mode and (strlen($word) > $length)) { $space_left = $length - strlen($buf) - $crlflen; if (!$firstword) { if ($space_left > 20) { $len = $space_left; if ($is_utf8) { $len = $this->utf8CharBoundary($word, $len); } elseif ('=' == substr($word, $len - 1, 1)) { --$len; } elseif ('=' == substr($word, $len - 2, 1)) { $len -= 2; } $part = substr($word, 0, $len); $word = substr($word, $len); $buf .= ' ' . $part; $message .= $buf . sprintf('=%s', static::$LE); } else { $message .= $buf . $soft_break; } $buf = ''; } while (strlen($word) > 0) { if ($length <= 0) { break; } $len = $length; if ($is_utf8) { $len = $this->utf8CharBoundary($word, $len); } elseif ('=' == substr($word, $len - 1, 1)) { --$len; } elseif ('=' == substr($word, $len - 2, 1)) { $len -= 2; } $part = substr($word, 0, $len); $word = substr($word, $len); if (strlen($word) > 0) { $message .= $part . sprintf('=%s', static::$LE); } else { $buf = $part; } } } else { $buf_o = $buf; if (!$firstword) { $buf .= ' '; } $buf .= $word; if (strlen($buf) > $length and '' != $buf_o) { $message .= $buf_o . $soft_break; $buf = $word; } } $firstword = false; } $message .= $buf . static::$LE; } return $message; } /** * Find the last character boundary prior to $maxLength in a utf-8 * quoted-printable encoded string. * Original written by Colin Brown. * * @param string $encodedText utf-8 QP text * @param int $maxLength Find the last character boundary prior to this length * * @return int */ public function utf8CharBoundary($encodedText, $maxLength) { $foundSplitPos = false; $lookBack = 3; while (!$foundSplitPos) { $lastChunk = substr($encodedText, $maxLength - $lookBack, $lookBack); $encodedCharPos = strpos($lastChunk, '='); if (false !== $encodedCharPos) { // Found start of encoded character byte within $lookBack block. // Check the encoded byte value (the 2 chars after the '=') $hex = substr($encodedText, $maxLength - $lookBack + $encodedCharPos + 1, 2); $dec = hexdec($hex); if ($dec < 128) { // Single byte character. // If the encoded char was found at pos 0, it will fit // otherwise reduce maxLength to start of the encoded char if ($encodedCharPos > 0) { $maxLength -= $lookBack - $encodedCharPos; } $foundSplitPos = true; } elseif ($dec >= 192) { // First byte of a multi byte character // Reduce maxLength to split at start of character $maxLength -= $lookBack - $encodedCharPos; $foundSplitPos = true; } elseif ($dec < 192) { // Middle byte of a multi byte character, look further back $lookBack += 3; } } else { // No encoded character found $foundSplitPos = true; } } return $maxLength; } /** * Apply word wrapping to the message body. * Wraps the message body to the number of chars set in the WordWrap property. * You should only do this to plain-text bodies as wrapping HTML tags may break them. * This is called automatically by createBody(), so you don't need to call it yourself. */ public function setWordWrap() { if ($this->WordWrap < 1) { return; } switch ($this->message_type) { case 'alt': case 'alt_inline': case 'alt_attach': case 'alt_inline_attach': $this->AltBody = $this->wrapText($this->AltBody, $this->WordWrap); break; default: $this->Body = $this->wrapText($this->Body, $this->WordWrap); break; } } /** * Assemble message headers. * * @return string The assembled headers */ public function createHeader() { $result = ''; $result .= $this->headerLine('Date', '' == $this->MessageDate ? self::rfcDate() : $this->MessageDate); // To be created automatically by mail() if ($this->SingleTo) { if ('mail' != $this->Mailer) { foreach ($this->to as $toaddr) { $this->SingleToArray[] = $this->addrFormat($toaddr); } } } else { if (count($this->to) > 0) { if ('mail' != $this->Mailer) { $result .= $this->addrAppend('To', $this->to); } } elseif (count($this->cc) == 0) { $result .= $this->headerLine('To', 'undisclosed-recipients:;'); } } $result .= $this->addrAppend('From', [[trim($this->From), $this->FromName]]); // sendmail and mail() extract Cc from the header before sending if (count($this->cc) > 0) { $result .= $this->addrAppend('Cc', $this->cc); } // sendmail and mail() extract Bcc from the header before sending if (( 'sendmail' == $this->Mailer or 'qmail' == $this->Mailer or 'mail' == $this->Mailer ) and count($this->bcc) > 0 ) { $result .= $this->addrAppend('Bcc', $this->bcc); } if (count($this->ReplyTo) > 0) { $result .= $this->addrAppend('Reply-To', $this->ReplyTo); } // mail() sets the subject itself if ('mail' != $this->Mailer) { $result .= $this->headerLine('Subject', $this->encodeHeader($this->secureHeader($this->Subject))); } // Only allow a custom message ID if it conforms to RFC 5322 section 3.6.4 // https://tools.ietf.org/html/rfc5322#section-3.6.4 if ('' != $this->MessageID and preg_match('/^<.*@.*>$/', $this->MessageID)) { $this->lastMessageID = $this->MessageID; } else { $this->lastMessageID = sprintf('<%s@%s>', $this->uniqueid, $this->serverHostname()); } $result .= $this->headerLine('Message-ID', $this->lastMessageID); if (null !== $this->Priority) { $result .= $this->headerLine('X-Priority', $this->Priority); } if ('' == $this->XMailer) { $result .= $this->headerLine( 'X-Mailer', 'PHPMailer ' . self::VERSION . ' (https://github.com/PHPMailer/PHPMailer)' ); } else { $myXmailer = trim($this->XMailer); if ($myXmailer) { $result .= $this->headerLine('X-Mailer', $myXmailer); } } if ('' != $this->ConfirmReadingTo) { $result .= $this->headerLine('Disposition-Notification-To', '<' . $this->ConfirmReadingTo . '>'); } // Add custom headers foreach ($this->CustomHeader as $header) { $result .= $this->headerLine( trim($header[0]), $this->encodeHeader(trim($header[1])) ); } if (!$this->sign_key_file) { $result .= $this->headerLine('MIME-Version', '1.0'); $result .= $this->getMailMIME(); } return $result; } /** * Get the message MIME type headers. * * @return string */ public function getMailMIME() { $result = ''; $ismultipart = true; switch ($this->message_type) { case 'inline': $result .= $this->headerLine('Content-Type', static::CONTENT_TYPE_MULTIPART_RELATED . ';'); $result .= $this->textLine("\tboundary=\"" . $this->boundary[1] . '"'); break; case 'attach': case 'inline_attach': case 'alt_attach': case 'alt_inline_attach': $result .= $this->headerLine('Content-Type', static::CONTENT_TYPE_MULTIPART_MIXED . ';'); $result .= $this->textLine("\tboundary=\"" . $this->boundary[1] . '"'); break; case 'alt': case 'alt_inline': $result .= $this->headerLine('Content-Type', static::CONTENT_TYPE_MULTIPART_ALTERNATIVE . ';'); $result .= $this->textLine("\tboundary=\"" . $this->boundary[1] . '"'); break; default: // Catches case 'plain': and case '': $result .= $this->textLine('Content-Type: ' . $this->ContentType . '; charset=' . $this->CharSet); $ismultipart = false; break; } // RFC1341 part 5 says 7bit is assumed if not specified if (static::ENCODING_7BIT != $this->Encoding) { // RFC 2045 section 6.4 says multipart MIME parts may only use 7bit, 8bit or binary CTE if ($ismultipart) { if (static::ENCODING_8BIT == $this->Encoding) { $result .= $this->headerLine('Content-Transfer-Encoding', static::ENCODING_8BIT); } // The only remaining alternatives are quoted-printable and base64, which are both 7bit compatible } else { $result .= $this->headerLine('Content-Transfer-Encoding', $this->Encoding); } } if ('mail' != $this->Mailer) { $result .= static::$LE; } return $result; } /** * Returns the whole MIME message. * Includes complete headers and body. * Only valid post preSend(). * * @see PHPMailer::preSend() * * @return string */ public function getSentMIMEMessage() { return rtrim($this->MIMEHeader . $this->mailHeader, "\n\r") . static::$LE . static::$LE . $this->MIMEBody; } /** * Create a unique ID to use for boundaries. * * @return string */ protected function generateId() { $len = 32; //32 bytes = 256 bits if (function_exists('random_bytes')) { $bytes = random_bytes($len); } elseif (function_exists('openssl_random_pseudo_bytes')) { $bytes = openssl_random_pseudo_bytes($len); } else { //Use a hash to force the length to the same as the other methods $bytes = hash('sha256', uniqid((string) mt_rand(), true), true); } //We don't care about messing up base64 format here, just want a random string return str_replace(['=', '+', '/'], '', base64_encode(hash('sha256', $bytes, true))); } /** * Assemble the message body. * Returns an empty string on failure. * * @throws Exception * * @return string The assembled message body */ public function createBody() { $body = ''; //Create unique IDs and preset boundaries $this->uniqueid = $this->generateId(); $this->boundary[1] = 'b1_' . $this->uniqueid; $this->boundary[2] = 'b2_' . $this->uniqueid; $this->boundary[3] = 'b3_' . $this->uniqueid; if ($this->sign_key_file) { $body .= $this->getMailMIME() . static::$LE; } $this->setWordWrap(); $bodyEncoding = $this->Encoding; $bodyCharSet = $this->CharSet; //Can we do a 7-bit downgrade? if (static::ENCODING_8BIT == $bodyEncoding and !$this->has8bitChars($this->Body)) { $bodyEncoding = static::ENCODING_7BIT; //All ISO 8859, Windows codepage and UTF-8 charsets are ascii compatible up to 7-bit $bodyCharSet = 'us-ascii'; } //If lines are too long, and we're not already using an encoding that will shorten them, //change to quoted-printable transfer encoding for the body part only if (static::ENCODING_BASE64 != $this->Encoding and static::hasLineLongerThanMax($this->Body)) { $bodyEncoding = static::ENCODING_QUOTED_PRINTABLE; } $altBodyEncoding = $this->Encoding; $altBodyCharSet = $this->CharSet; //Can we do a 7-bit downgrade? if (static::ENCODING_8BIT == $altBodyEncoding and !$this->has8bitChars($this->AltBody)) { $altBodyEncoding = static::ENCODING_7BIT; //All ISO 8859, Windows codepage and UTF-8 charsets are ascii compatible up to 7-bit $altBodyCharSet = 'us-ascii'; } //If lines are too long, and we're not already using an encoding that will shorten them, //change to quoted-printable transfer encoding for the alt body part only if (static::ENCODING_BASE64 != $altBodyEncoding and static::hasLineLongerThanMax($this->AltBody)) { $altBodyEncoding = static::ENCODING_QUOTED_PRINTABLE; } //Use this as a preamble in all multipart message types $mimepre = 'This is a multi-part message in MIME format.' . static::$LE; switch ($this->message_type) { case 'inline': $body .= $mimepre; $body .= $this->getBoundary($this->boundary[1], $bodyCharSet, '', $bodyEncoding); $body .= $this->encodeString($this->Body, $bodyEncoding); $body .= static::$LE; $body .= $this->attachAll('inline', $this->boundary[1]); break; case 'attach': $body .= $mimepre; $body .= $this->getBoundary($this->boundary[1], $bodyCharSet, '', $bodyEncoding); $body .= $this->encodeString($this->Body, $bodyEncoding); $body .= static::$LE; $body .= $this->attachAll('attachment', $this->boundary[1]); break; case 'inline_attach': $body .= $mimepre; $body .= $this->textLine('--' . $this->boundary[1]); $body .= $this->headerLine('Content-Type', static::CONTENT_TYPE_MULTIPART_RELATED . ';'); $body .= $this->textLine("\tboundary=\"" . $this->boundary[2] . '"'); $body .= static::$LE; $body .= $this->getBoundary($this->boundary[2], $bodyCharSet, '', $bodyEncoding); $body .= $this->encodeString($this->Body, $bodyEncoding); $body .= static::$LE; $body .= $this->attachAll('inline', $this->boundary[2]); $body .= static::$LE; $body .= $this->attachAll('attachment', $this->boundary[1]); break; case 'alt': $body .= $mimepre; $body .= $this->getBoundary($this->boundary[1], $altBodyCharSet, static::CONTENT_TYPE_PLAINTEXT, $altBodyEncoding); $body .= $this->encodeString($this->AltBody, $altBodyEncoding); $body .= static::$LE; $body .= $this->getBoundary($this->boundary[1], $bodyCharSet, static::CONTENT_TYPE_TEXT_HTML, $bodyEncoding); $body .= $this->encodeString($this->Body, $bodyEncoding); $body .= static::$LE; if (!empty($this->Ical)) { $body .= $this->getBoundary($this->boundary[1], '', static::CONTENT_TYPE_TEXT_CALENDAR . '; method=REQUEST', ''); $body .= $this->encodeString($this->Ical, $this->Encoding); $body .= static::$LE; } $body .= $this->endBoundary($this->boundary[1]); break; case 'alt_inline': $body .= $mimepre; $body .= $this->getBoundary($this->boundary[1], $altBodyCharSet, static::CONTENT_TYPE_PLAINTEXT, $altBodyEncoding); $body .= $this->encodeString($this->AltBody, $altBodyEncoding); $body .= static::$LE; $body .= $this->textLine('--' . $this->boundary[1]); $body .= $this->headerLine('Content-Type', static::CONTENT_TYPE_MULTIPART_RELATED . ';'); $body .= $this->textLine("\tboundary=\"" . $this->boundary[2] . '"'); $body .= static::$LE; $body .= $this->getBoundary($this->boundary[2], $bodyCharSet, static::CONTENT_TYPE_TEXT_HTML, $bodyEncoding); $body .= $this->encodeString($this->Body, $bodyEncoding); $body .= static::$LE; $body .= $this->attachAll('inline', $this->boundary[2]); $body .= static::$LE; $body .= $this->endBoundary($this->boundary[1]); break; case 'alt_attach': $body .= $mimepre; $body .= $this->textLine('--' . $this->boundary[1]); $body .= $this->headerLine('Content-Type', static::CONTENT_TYPE_MULTIPART_ALTERNATIVE . ';'); $body .= $this->textLine("\tboundary=\"" . $this->boundary[2] . '"'); $body .= static::$LE; $body .= $this->getBoundary($this->boundary[2], $altBodyCharSet, static::CONTENT_TYPE_PLAINTEXT, $altBodyEncoding); $body .= $this->encodeString($this->AltBody, $altBodyEncoding); $body .= static::$LE; $body .= $this->getBoundary($this->boundary[2], $bodyCharSet, static::CONTENT_TYPE_TEXT_HTML, $bodyEncoding); $body .= $this->encodeString($this->Body, $bodyEncoding); $body .= static::$LE; if (!empty($this->Ical)) { $body .= $this->getBoundary($this->boundary[2], '', static::CONTENT_TYPE_TEXT_CALENDAR . '; method=REQUEST', ''); $body .= $this->encodeString($this->Ical, $this->Encoding); } $body .= $this->endBoundary($this->boundary[2]); $body .= static::$LE; $body .= $this->attachAll('attachment', $this->boundary[1]); break; case 'alt_inline_attach': $body .= $mimepre; $body .= $this->textLine('--' . $this->boundary[1]); $body .= $this->headerLine('Content-Type', static::CONTENT_TYPE_MULTIPART_ALTERNATIVE . ';'); $body .= $this->textLine("\tboundary=\"" . $this->boundary[2] . '"'); $body .= static::$LE; $body .= $this->getBoundary($this->boundary[2], $altBodyCharSet, static::CONTENT_TYPE_PLAINTEXT, $altBodyEncoding); $body .= $this->encodeString($this->AltBody, $altBodyEncoding); $body .= static::$LE; $body .= $this->textLine('--' . $this->boundary[2]); $body .= $this->headerLine('Content-Type', static::CONTENT_TYPE_MULTIPART_RELATED . ';'); $body .= $this->textLine("\tboundary=\"" . $this->boundary[3] . '"'); $body .= static::$LE; $body .= $this->getBoundary($this->boundary[3], $bodyCharSet, static::CONTENT_TYPE_TEXT_HTML, $bodyEncoding); $body .= $this->encodeString($this->Body, $bodyEncoding); $body .= static::$LE; $body .= $this->attachAll('inline', $this->boundary[3]); $body .= static::$LE; $body .= $this->endBoundary($this->boundary[2]); $body .= static::$LE; $body .= $this->attachAll('attachment', $this->boundary[1]); break; default: // Catch case 'plain' and case '', applies to simple `text/plain` and `text/html` body content types //Reset the `Encoding` property in case we changed it for line length reasons $this->Encoding = $bodyEncoding; $body .= $this->encodeString($this->Body, $this->Encoding); break; } if ($this->isError()) { $body = ''; if ($this->exceptions) { throw new Exception($this->lang('empty_message'), self::STOP_CRITICAL); } } elseif ($this->sign_key_file) { try { if (!defined('PKCS7_TEXT')) { throw new Exception($this->lang('extension_missing') . 'openssl'); } // @TODO would be nice to use php://temp streams here $file = tempnam(sys_get_temp_dir(), 'mail'); if (false === file_put_contents($file, $body)) { throw new Exception($this->lang('signing') . ' Could not write temp file'); } $signed = tempnam(sys_get_temp_dir(), 'signed'); //Workaround for PHP bug https://bugs.php.net/bug.php?id=69197 if (empty($this->sign_extracerts_file)) { $sign = @openssl_pkcs7_sign( $file, $signed, 'file://' . realpath($this->sign_cert_file), ['file://' . realpath($this->sign_key_file), $this->sign_key_pass], [] ); } else { $sign = @openssl_pkcs7_sign( $file, $signed, 'file://' . realpath($this->sign_cert_file), ['file://' . realpath($this->sign_key_file), $this->sign_key_pass], [], PKCS7_DETACHED, $this->sign_extracerts_file ); } @unlink($file); if ($sign) { $body = file_get_contents($signed); @unlink($signed); //The message returned by openssl contains both headers and body, so need to split them up $parts = explode("\n\n", $body, 2); $this->MIMEHeader .= $parts[0] . static::$LE . static::$LE; $body = $parts[1]; } else { @unlink($signed); throw new Exception($this->lang('signing') . openssl_error_string()); } } catch (Exception $exc) { $body = ''; if ($this->exceptions) { throw $exc; } } } return $body; } /** * Return the start of a message boundary. * * @param string $boundary * @param string $charSet * @param string $contentType * @param string $encoding * * @return string */ protected function getBoundary($boundary, $charSet, $contentType, $encoding) { $result = ''; if ('' == $charSet) { $charSet = $this->CharSet; } if ('' == $contentType) { $contentType = $this->ContentType; } if ('' == $encoding) { $encoding = $this->Encoding; } $result .= $this->textLine('--' . $boundary); $result .= sprintf('Content-Type: %s; charset=%s', $contentType, $charSet); $result .= static::$LE; // RFC1341 part 5 says 7bit is assumed if not specified if (static::ENCODING_7BIT != $encoding) { $result .= $this->headerLine('Content-Transfer-Encoding', $encoding); } $result .= static::$LE; return $result; } /** * Return the end of a message boundary. * * @param string $boundary * * @return string */ protected function endBoundary($boundary) { return static::$LE . '--' . $boundary . '--' . static::$LE; } /** * Set the message type. * PHPMailer only supports some preset message types, not arbitrary MIME structures. */ protected function setMessageType() { $type = []; if ($this->alternativeExists()) { $type[] = 'alt'; } if ($this->inlineImageExists()) { $type[] = 'inline'; } if ($this->attachmentExists()) { $type[] = 'attach'; } $this->message_type = implode('_', $type); if ('' == $this->message_type) { //The 'plain' message_type refers to the message having a single body element, not that it is plain-text $this->message_type = 'plain'; } } /** * Format a header line. * * @param string $name * @param string|int $value * * @return string */ public function headerLine($name, $value) { return $name . ': ' . $value . static::$LE; } /** * Return a formatted mail line. * * @param string $value * * @return string */ public function textLine($value) { return $value . static::$LE; } /** * Add an attachment from a path on the filesystem. * Never use a user-supplied path to a file! * Returns false if the file could not be found or read. * * @param string $path Path to the attachment * @param string $name Overrides the attachment name * @param string $encoding File encoding (see $Encoding) * @param string $type File extension (MIME) type * @param string $disposition Disposition to use * * @throws Exception * * @return bool */ public function addAttachment($path, $name = '', $encoding = self::ENCODING_BASE64, $type = '', $disposition = 'attachment') { try { if (!@is_file($path)) { throw new Exception($this->lang('file_access') . $path, self::STOP_CONTINUE); } // If a MIME type is not specified, try to work it out from the file name if ('' == $type) { $type = static::filenameToType($path); } $filename = basename($path); if ('' == $name) { $name = $filename; } $this->attachment[] = [ 0 => $path, 1 => $filename, 2 => $name, 3 => $encoding, 4 => $type, 5 => false, // isStringAttachment 6 => $disposition, 7 => $name, ]; } catch (Exception $exc) { $this->setError($exc->getMessage()); $this->edebug($exc->getMessage()); if ($this->exceptions) { throw $exc; } return false; } return true; } /** * Return the array of attachments. * * @return array */ public function getAttachments() { return $this->attachment; } /** * Attach all file, string, and binary attachments to the message. * Returns an empty string on failure. * * @param string $disposition_type * @param string $boundary * * @return string */ protected function attachAll($disposition_type, $boundary) { // Return text of body $mime = []; $cidUniq = []; $incl = []; // Add all attachments foreach ($this->attachment as $attachment) { // Check if it is a valid disposition_filter if ($attachment[6] == $disposition_type) { // Check for string attachment $string = ''; $path = ''; $bString = $attachment[5]; if ($bString) { $string = $attachment[0]; } else { $path = $attachment[0]; } $inclhash = hash('sha256', serialize($attachment)); if (in_array($inclhash, $incl)) { continue; } $incl[] = $inclhash; $name = $attachment[2]; $encoding = $attachment[3]; $type = $attachment[4]; $disposition = $attachment[6]; $cid = $attachment[7]; if ('inline' == $disposition and array_key_exists($cid, $cidUniq)) { continue; } $cidUniq[$cid] = true; $mime[] = sprintf('--%s%s', $boundary, static::$LE); //Only include a filename property if we have one if (!empty($name)) { $mime[] = sprintf( 'Content-Type: %s; name="%s"%s', $type, $this->encodeHeader($this->secureHeader($name)), static::$LE ); } else { $mime[] = sprintf( 'Content-Type: %s%s', $type, static::$LE ); } // RFC1341 part 5 says 7bit is assumed if not specified if (static::ENCODING_7BIT != $encoding) { $mime[] = sprintf('Content-Transfer-Encoding: %s%s', $encoding, static::$LE); } if (!empty($cid)) { $mime[] = sprintf('Content-ID: <%s>%s', $cid, static::$LE); } // If a filename contains any of these chars, it should be quoted, // but not otherwise: RFC2183 & RFC2045 5.1 // Fixes a warning in IETF's msglint MIME checker // Allow for bypassing the Content-Disposition header totally if (!(empty($disposition))) { $encoded_name = $this->encodeHeader($this->secureHeader($name)); if (preg_match('/[ \(\)<>@,;:\\"\/\[\]\?=]/', $encoded_name)) { $mime[] = sprintf( 'Content-Disposition: %s; filename="%s"%s', $disposition, $encoded_name, static::$LE . static::$LE ); } else { if (!empty($encoded_name)) { $mime[] = sprintf( 'Content-Disposition: %s; filename=%s%s', $disposition, $encoded_name, static::$LE . static::$LE ); } else { $mime[] = sprintf( 'Content-Disposition: %s%s', $disposition, static::$LE . static::$LE ); } } } else { $mime[] = static::$LE; } // Encode as string attachment if ($bString) { $mime[] = $this->encodeString($string, $encoding); } else { $mime[] = $this->encodeFile($path, $encoding); } if ($this->isError()) { return ''; } $mime[] = static::$LE; } } $mime[] = sprintf('--%s--%s', $boundary, static::$LE); return implode('', $mime); } /** * Encode a file attachment in requested format. * Returns an empty string on failure. * * @param string $path The full path to the file * @param string $encoding The encoding to use; one of 'base64', '7bit', '8bit', 'binary', 'quoted-printable' * * @throws Exception * * @return string */ protected function encodeFile($path, $encoding = self::ENCODING_BASE64) { try { if (!file_exists($path)) { throw new Exception($this->lang('file_open') . $path, self::STOP_CONTINUE); } $file_buffer = file_get_contents($path); if (false === $file_buffer) { throw new Exception($this->lang('file_open') . $path, self::STOP_CONTINUE); } $file_buffer = $this->encodeString($file_buffer, $encoding); return $file_buffer; } catch (Exception $exc) { $this->setError($exc->getMessage()); return ''; } } /** * Encode a string in requested format. * Returns an empty string on failure. * * @param string $str The text to encode * @param string $encoding The encoding to use; one of 'base64', '7bit', '8bit', 'binary', 'quoted-printable' * * @return string */ public function encodeString($str, $encoding = self::ENCODING_BASE64) { $encoded = ''; switch (strtolower($encoding)) { case static::ENCODING_BASE64: $encoded = chunk_split( base64_encode($str), static::STD_LINE_LENGTH, static::$LE ); break; case static::ENCODING_7BIT: case static::ENCODING_8BIT: $encoded = static::normalizeBreaks($str); // Make sure it ends with a line break if (substr($encoded, -(strlen(static::$LE))) != static::$LE) { $encoded .= static::$LE; } break; case static::ENCODING_BINARY: $encoded = $str; break; case static::ENCODING_QUOTED_PRINTABLE: $encoded = $this->encodeQP($str); break; default: $this->setError($this->lang('encoding') . $encoding); break; } return $encoded; } /** * Encode a header value (not including its label) optimally. * Picks shortest of Q, B, or none. Result includes folding if needed. * See RFC822 definitions for phrase, comment and text positions. * * @param string $str The header value to encode * @param string $position What context the string will be used in * * @return string */ public function encodeHeader($str, $position = 'text') { $matchcount = 0; switch (strtolower($position)) { case 'phrase': if (!preg_match('/[\200-\377]/', $str)) { // Can't use addslashes as we don't know the value of magic_quotes_sybase $encoded = addcslashes($str, "\0..\37\177\\\""); if (($str == $encoded) and !preg_match('/[^A-Za-z0-9!#$%&\'*+\/=?^_`{|}~ -]/', $str)) { return $encoded; } return "\"$encoded\""; } $matchcount = preg_match_all('/[^\040\041\043-\133\135-\176]/', $str, $matches); break; /* @noinspection PhpMissingBreakStatementInspection */ case 'comment': $matchcount = preg_match_all('/[()"]/', $str, $matches); //fallthrough case 'text': default: $matchcount += preg_match_all('/[\000-\010\013\014\016-\037\177-\377]/', $str, $matches); break; } //RFCs specify a maximum line length of 78 chars, however mail() will sometimes //corrupt messages with headers longer than 65 chars. See #818 $lengthsub = 'mail' == $this->Mailer ? 13 : 0; $maxlen = static::STD_LINE_LENGTH - $lengthsub; // Try to select the encoding which should produce the shortest output if ($matchcount > strlen($str) / 3) { // More than a third of the content will need encoding, so B encoding will be most efficient $encoding = 'B'; //This calculation is: // max line length // - shorten to avoid mail() corruption // - Q/B encoding char overhead ("` =??[QB]??=`") // - charset name length $maxlen = static::STD_LINE_LENGTH - $lengthsub - 8 - strlen($this->CharSet); if ($this->hasMultiBytes($str)) { // Use a custom function which correctly encodes and wraps long // multibyte strings without breaking lines within a character $encoded = $this->base64EncodeWrapMB($str, "\n"); } else { $encoded = base64_encode($str); $maxlen -= $maxlen % 4; $encoded = trim(chunk_split($encoded, $maxlen, "\n")); } $encoded = preg_replace('/^(.*)$/m', ' =?' . $this->CharSet . "?$encoding?\\1?=", $encoded); } elseif ($matchcount > 0) { //1 or more chars need encoding, use Q-encode $encoding = 'Q'; //Recalc max line length for Q encoding - see comments on B encode $maxlen = static::STD_LINE_LENGTH - $lengthsub - 8 - strlen($this->CharSet); $encoded = $this->encodeQ($str, $position); $encoded = $this->wrapText($encoded, $maxlen, true); $encoded = str_replace('=' . static::$LE, "\n", trim($encoded)); $encoded = preg_replace('/^(.*)$/m', ' =?' . $this->CharSet . "?$encoding?\\1?=", $encoded); } elseif (strlen($str) > $maxlen) { //No chars need encoding, but line is too long, so fold it $encoded = trim($this->wrapText($str, $maxlen, false)); if ($str == $encoded) { //Wrapping nicely didn't work, wrap hard instead $encoded = trim(chunk_split($str, static::STD_LINE_LENGTH, static::$LE)); } $encoded = str_replace(static::$LE, "\n", trim($encoded)); $encoded = preg_replace('/^(.*)$/m', ' \\1', $encoded); } else { //No reformatting needed return $str; } return trim(static::normalizeBreaks($encoded)); } /** * Check if a string contains multi-byte characters. * * @param string $str multi-byte text to wrap encode * * @return bool */ public function hasMultiBytes($str) { if (function_exists('mb_strlen')) { return strlen($str) > mb_strlen($str, $this->CharSet); } // Assume no multibytes (we can't handle without mbstring functions anyway) return false; } /** * Does a string contain any 8-bit chars (in any charset)? * * @param string $text * * @return bool */ public function has8bitChars($text) { return (bool) preg_match('/[\x80-\xFF]/', $text); } /** * Encode and wrap long multibyte strings for mail headers * without breaking lines within a character. * Adapted from a function by paravoid. * * @see http://www.php.net/manual/en/function.mb-encode-mimeheader.php#60283 * * @param string $str multi-byte text to wrap encode * @param string $linebreak string to use as linefeed/end-of-line * * @return string */ public function base64EncodeWrapMB($str, $linebreak = null) { $start = '=?' . $this->CharSet . '?B?'; $end = '?='; $encoded = ''; if (null === $linebreak) { $linebreak = static::$LE; } $mb_length = mb_strlen($str, $this->CharSet); // Each line must have length <= 75, including $start and $end $length = 75 - strlen($start) - strlen($end); // Average multi-byte ratio $ratio = $mb_length / strlen($str); // Base64 has a 4:3 ratio $avgLength = floor($length * $ratio * .75); for ($i = 0; $i < $mb_length; $i += $offset) { $lookBack = 0; do { $offset = $avgLength - $lookBack; $chunk = mb_substr($str, $i, $offset, $this->CharSet); $chunk = base64_encode($chunk); ++$lookBack; } while (strlen($chunk) > $length); $encoded .= $chunk . $linebreak; } // Chomp the last linefeed return substr($encoded, 0, -strlen($linebreak)); } /** * Encode a string in quoted-printable format. * According to RFC2045 section 6.7. * * @param string $string The text to encode * * @return string */ public function encodeQP($string) { return static::normalizeBreaks(quoted_printable_encode($string)); } /** * Encode a string using Q encoding. * * @see http://tools.ietf.org/html/rfc2047#section-4.2 * * @param string $str the text to encode * @param string $position Where the text is going to be used, see the RFC for what that means * * @return string */ public function encodeQ($str, $position = 'text') { // There should not be any EOL in the string $pattern = ''; $encoded = str_replace(["\r", "\n"], '', $str); switch (strtolower($position)) { case 'phrase': // RFC 2047 section 5.3 $pattern = '^A-Za-z0-9!*+\/ -'; break; /* * RFC 2047 section 5.2. * Build $pattern without including delimiters and [] */ /* @noinspection PhpMissingBreakStatementInspection */ case 'comment': $pattern = '\(\)"'; /* Intentional fall through */ case 'text': default: // RFC 2047 section 5.1 // Replace every high ascii, control, =, ? and _ characters /** @noinspection SuspiciousAssignmentsInspection */ $pattern = '\000-\011\013\014\016-\037\075\077\137\177-\377' . $pattern; break; } $matches = []; if (preg_match_all("/[{$pattern}]/", $encoded, $matches)) { // If the string contains an '=', make sure it's the first thing we replace // so as to avoid double-encoding $eqkey = array_search('=', $matches[0]); if (false !== $eqkey) { unset($matches[0][$eqkey]); array_unshift($matches[0], '='); } foreach (array_unique($matches[0]) as $char) { $encoded = str_replace($char, '=' . sprintf('%02X', ord($char)), $encoded); } } // Replace spaces with _ (more readable than =20) // RFC 2047 section 4.2(2) return str_replace(' ', '_', $encoded); } /** * Add a string or binary attachment (non-filesystem). * This method can be used to attach ascii or binary data, * such as a BLOB record from a database. * * @param string $string String attachment data * @param string $filename Name of the attachment * @param string $encoding File encoding (see $Encoding) * @param string $type File extension (MIME) type * @param string $disposition Disposition to use */ public function addStringAttachment( $string, $filename, $encoding = self::ENCODING_BASE64, $type = '', $disposition = 'attachment' ) { // If a MIME type is not specified, try to work it out from the file name if ('' == $type) { $type = static::filenameToType($filename); } // Append to $attachment array $this->attachment[] = [ 0 => $string, 1 => $filename, 2 => basename($filename), 3 => $encoding, 4 => $type, 5 => true, // isStringAttachment 6 => $disposition, 7 => 0, ]; } /** * Add an embedded (inline) attachment from a file. * This can include images, sounds, and just about any other document type. * These differ from 'regular' attachments in that they are intended to be * displayed inline with the message, not just attached for download. * This is used in HTML messages that embed the images * the HTML refers to using the $cid value. * Never use a user-supplied path to a file! * * @param string $path Path to the attachment * @param string $cid Content ID of the attachment; Use this to reference * the content when using an embedded image in HTML * @param string $name Overrides the attachment name * @param string $encoding File encoding (see $Encoding) * @param string $type File MIME type * @param string $disposition Disposition to use * * @return bool True on successfully adding an attachment */ public function addEmbeddedImage($path, $cid, $name = '', $encoding = self::ENCODING_BASE64, $type = '', $disposition = 'inline') { if (!@is_file($path)) { $this->setError($this->lang('file_access') . $path); return false; } // If a MIME type is not specified, try to work it out from the file name if ('' == $type) { $type = static::filenameToType($path); } $filename = basename($path); if ('' == $name) { $name = $filename; } // Append to $attachment array $this->attachment[] = [ 0 => $path, 1 => $filename, 2 => $name, 3 => $encoding, 4 => $type, 5 => false, // isStringAttachment 6 => $disposition, 7 => $cid, ]; return true; } /** * Add an embedded stringified attachment. * This can include images, sounds, and just about any other document type. * If your filename doesn't contain an extension, be sure to set the $type to an appropriate MIME type. * * @param string $string The attachment binary data * @param string $cid Content ID of the attachment; Use this to reference * the content when using an embedded image in HTML * @param string $name A filename for the attachment. If this contains an extension, * PHPMailer will attempt to set a MIME type for the attachment. * For example 'file.jpg' would get an 'image/jpeg' MIME type. * @param string $encoding File encoding (see $Encoding), defaults to 'base64' * @param string $type MIME type - will be used in preference to any automatically derived type * @param string $disposition Disposition to use * * @return bool True on successfully adding an attachment */ public function addStringEmbeddedImage( $string, $cid, $name = '', $encoding = self::ENCODING_BASE64, $type = '', $disposition = 'inline' ) { // If a MIME type is not specified, try to work it out from the name if ('' == $type and !empty($name)) { $type = static::filenameToType($name); } // Append to $attachment array $this->attachment[] = [ 0 => $string, 1 => $name, 2 => $name, 3 => $encoding, 4 => $type, 5 => true, // isStringAttachment 6 => $disposition, 7 => $cid, ]; return true; } /** * Check if an embedded attachment is present with this cid. * * @param string $cid * * @return bool */ protected function cidExists($cid) { foreach ($this->attachment as $attachment) { if ('inline' == $attachment[6] and $cid == $attachment[7]) { return true; } } return false; } /** * Check if an inline attachment is present. * * @return bool */ public function inlineImageExists() { foreach ($this->attachment as $attachment) { if ('inline' == $attachment[6]) { return true; } } return false; } /** * Check if an attachment (non-inline) is present. * * @return bool */ public function attachmentExists() { foreach ($this->attachment as $attachment) { if ('attachment' == $attachment[6]) { return true; } } return false; } /** * Check if this message has an alternative body set. * * @return bool */ public function alternativeExists() { return !empty($this->AltBody); } /** * Clear queued addresses of given kind. * * @param string $kind 'to', 'cc', or 'bcc' */ public function clearQueuedAddresses($kind) { $this->RecipientsQueue = array_filter( $this->RecipientsQueue, function ($params) use ($kind) { return $params[0] != $kind; } ); } /** * Clear all To recipients. */ public function clearAddresses() { foreach ($this->to as $to) { unset($this->all_recipients[strtolower($to[0])]); } $this->to = []; $this->clearQueuedAddresses('to'); } /** * Clear all CC recipients. */ public function clearCCs() { foreach ($this->cc as $cc) { unset($this->all_recipients[strtolower($cc[0])]); } $this->cc = []; $this->clearQueuedAddresses('cc'); } /** * Clear all BCC recipients. */ public function clearBCCs() { foreach ($this->bcc as $bcc) { unset($this->all_recipients[strtolower($bcc[0])]); } $this->bcc = []; $this->clearQueuedAddresses('bcc'); } /** * Clear all ReplyTo recipients. */ public function clearReplyTos() { $this->ReplyTo = []; $this->ReplyToQueue = []; } /** * Clear all recipient types. */ public function clearAllRecipients() { $this->to = []; $this->cc = []; $this->bcc = []; $this->all_recipients = []; $this->RecipientsQueue = []; } /** * Clear all filesystem, string, and binary attachments. */ public function clearAttachments() { $this->attachment = []; } /** * Clear all custom headers. */ public function clearCustomHeaders() { $this->CustomHeader = []; } /** * Add an error message to the error container. * * @param string $msg */ protected function setError($msg) { ++$this->error_count; if ('smtp' == $this->Mailer and null !== $this->smtp) { $lasterror = $this->smtp->getError(); if (!empty($lasterror['error'])) { $msg .= $this->lang('smtp_error') . $lasterror['error']; if (!empty($lasterror['detail'])) { $msg .= ' Detail: ' . $lasterror['detail']; } if (!empty($lasterror['smtp_code'])) { $msg .= ' SMTP code: ' . $lasterror['smtp_code']; } if (!empty($lasterror['smtp_code_ex'])) { $msg .= ' Additional SMTP info: ' . $lasterror['smtp_code_ex']; } } } $this->ErrorInfo = $msg; } /** * Return an RFC 822 formatted date. * * @return string */ public static function rfcDate() { // Set the time zone to whatever the default is to avoid 500 errors // Will default to UTC if it's not set properly in php.ini date_default_timezone_set(@date_default_timezone_get()); return date('D, j M Y H:i:s O'); } /** * Get the server hostname. * Returns 'localhost.localdomain' if unknown. * * @return string */ protected function serverHostname() { $result = ''; if (!empty($this->Hostname)) { $result = $this->Hostname; } elseif (isset($_SERVER) and array_key_exists('SERVER_NAME', $_SERVER)) { $result = $_SERVER['SERVER_NAME']; } elseif (function_exists('gethostname') and gethostname() !== false) { $result = gethostname(); } elseif (php_uname('n') !== false) { $result = php_uname('n'); } if (!static::isValidHost($result)) { return 'localhost.localdomain'; } return $result; } /** * Validate whether a string contains a valid value to use as a hostname or IP address. * IPv6 addresses must include [], e.g. `[::1]`, not just `::1`. * * @param string $host The host name or IP address to check * * @return bool */ public static function isValidHost($host) { //Simple syntax limits if (empty($host) or !is_string($host) or strlen($host) > 256 ) { return false; } //Looks like a bracketed IPv6 address if (trim($host, '[]') != $host) { return (bool) filter_var(trim($host, '[]'), FILTER_VALIDATE_IP, FILTER_FLAG_IPV6); } //If removing all the dots results in a numeric string, it must be an IPv4 address. //Need to check this first because otherwise things like `999.0.0.0` are considered valid host names if (is_numeric(str_replace('.', '', $host))) { //Is it a valid IPv4 address? return (bool) filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4); } if (filter_var('http://' . $host, FILTER_VALIDATE_URL, FILTER_FLAG_HOST_REQUIRED)) { //Is it a syntactically valid hostname? return true; } return false; } /** * Get an error message in the current language. * * @param string $key * * @return string */ protected function lang($key) { if (count($this->language) < 1) { $this->setLanguage('en'); // set the default language } if (array_key_exists($key, $this->language)) { if ('smtp_connect_failed' == $key) { //Include a link to troubleshooting docs on SMTP connection failure //this is by far the biggest cause of support questions //but it's usually not PHPMailer's fault. return $this->language[$key] . ' https://github.com/PHPMailer/PHPMailer/wiki/Troubleshooting'; } return $this->language[$key]; } //Return the key as a fallback return $key; } /** * Check if an error occurred. * * @return bool True if an error did occur */ public function isError() { return $this->error_count > 0; } /** * Add a custom header. * $name value can be overloaded to contain * both header name and value (name:value). * * @param string $name Custom header name * @param string|null $value Header value */ public function addCustomHeader($name, $value = null) { if (null === $value) { // Value passed in as name:value $this->CustomHeader[] = explode(':', $name, 2); } else { $this->CustomHeader[] = [$name, $value]; } } /** * Returns all custom headers. * * @return array */ public function getCustomHeaders() { return $this->CustomHeader; } /** * Create a message body from an HTML string. * Automatically inlines images and creates a plain-text version by converting the HTML, * overwriting any existing values in Body and AltBody. * Do not source $message content from user input! * $basedir is prepended when handling relative URLs, e.g. and must not be empty * will look for an image file in $basedir/images/a.png and convert it to inline. * If you don't provide a $basedir, relative paths will be left untouched (and thus probably break in email) * Converts data-uri images into embedded attachments. * If you don't want to apply these transformations to your HTML, just set Body and AltBody directly. * * @param string $message HTML message string * @param string $basedir Absolute path to a base directory to prepend to relative paths to images * @param bool|callable $advanced Whether to use the internal HTML to text converter * or your own custom converter @see PHPMailer::html2text() * * @return string $message The transformed message Body */ public function msgHTML($message, $basedir = '', $advanced = false) { preg_match_all('/(src|background)=["\'](.*)["\']/Ui', $message, $images); if (array_key_exists(2, $images)) { if (strlen($basedir) > 1 && '/' != substr($basedir, -1)) { // Ensure $basedir has a trailing / $basedir .= '/'; } foreach ($images[2] as $imgindex => $url) { // Convert data URIs into embedded images //e.g. "data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==" if (preg_match('#^data:(image/(?:jpe?g|gif|png));?(base64)?,(.+)#', $url, $match)) { if (count($match) == 4 and static::ENCODING_BASE64 == $match[2]) { $data = base64_decode($match[3]); } elseif ('' == $match[2]) { $data = rawurldecode($match[3]); } else { //Not recognised so leave it alone continue; } //Hash the decoded data, not the URL so that the same data-URI image used in multiple places //will only be embedded once, even if it used a different encoding $cid = hash('sha256', $data) . '@phpmailer.0'; // RFC2392 S 2 if (!$this->cidExists($cid)) { $this->addStringEmbeddedImage($data, $cid, 'embed' . $imgindex, static::ENCODING_BASE64, $match[1]); } $message = str_replace( $images[0][$imgindex], $images[1][$imgindex] . '="cid:' . $cid . '"', $message ); continue; } if (// Only process relative URLs if a basedir is provided (i.e. no absolute local paths) !empty($basedir) // Ignore URLs containing parent dir traversal (..) and (strpos($url, '..') === false) // Do not change urls that are already inline images and 0 !== strpos($url, 'cid:') // Do not change absolute URLs, including anonymous protocol and !preg_match('#^[a-z][a-z0-9+.-]*:?//#i', $url) ) { $filename = basename($url); $directory = dirname($url); if ('.' == $directory) { $directory = ''; } $cid = hash('sha256', $url) . '@phpmailer.0'; // RFC2392 S 2 if (strlen($basedir) > 1 and '/' != substr($basedir, -1)) { $basedir .= '/'; } if (strlen($directory) > 1 and '/' != substr($directory, -1)) { $directory .= '/'; } if ($this->addEmbeddedImage( $basedir . $directory . $filename, $cid, $filename, static::ENCODING_BASE64, static::_mime_types((string) static::mb_pathinfo($filename, PATHINFO_EXTENSION)) ) ) { $message = preg_replace( '/' . $images[1][$imgindex] . '=["\']' . preg_quote($url, '/') . '["\']/Ui', $images[1][$imgindex] . '="cid:' . $cid . '"', $message ); } } } } $this->isHTML(true); // Convert all message body line breaks to LE, makes quoted-printable encoding work much better $this->Body = static::normalizeBreaks($message); $this->AltBody = static::normalizeBreaks($this->html2text($message, $advanced)); if (!$this->alternativeExists()) { $this->AltBody = 'This is an HTML-only message. To view it, activate HTML in your email application.' . static::$LE; } return $this->Body; } /** * Convert an HTML string into plain text. * This is used by msgHTML(). * Note - older versions of this function used a bundled advanced converter * which was removed for license reasons in #232. * Example usage: * * ```php * // Use default conversion * $plain = $mail->html2text($html); * // Use your own custom converter * $plain = $mail->html2text($html, function($html) { * $converter = new MyHtml2text($html); * return $converter->get_text(); * }); * ``` * * @param string $html The HTML text to convert * @param bool|callable $advanced Any boolean value to use the internal converter, * or provide your own callable for custom conversion * * @return string */ public function html2text($html, $advanced = false) { if (is_callable($advanced)) { return call_user_func($advanced, $html); } return html_entity_decode( trim(strip_tags(preg_replace('/<(head|title|style|script)[^>]*>.*?<\/\\1>/si', '', $html))), ENT_QUOTES, $this->CharSet ); } /** * Get the MIME type for a file extension. * * @param string $ext File extension * * @return string MIME type of file */ public static function _mime_types($ext = '') { $mimes = [ 'xl' => 'application/excel', 'js' => 'application/javascript', 'hqx' => 'application/mac-binhex40', 'cpt' => 'application/mac-compactpro', 'bin' => 'application/macbinary', 'doc' => 'application/msword', 'word' => 'application/msword', 'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'xltx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.template', 'potx' => 'application/vnd.openxmlformats-officedocument.presentationml.template', 'ppsx' => 'application/vnd.openxmlformats-officedocument.presentationml.slideshow', 'pptx' => 'application/vnd.openxmlformats-officedocument.presentationml.presentation', 'sldx' => 'application/vnd.openxmlformats-officedocument.presentationml.slide', 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'dotx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.template', 'xlam' => 'application/vnd.ms-excel.addin.macroEnabled.12', 'xlsb' => 'application/vnd.ms-excel.sheet.binary.macroEnabled.12', 'class' => 'application/octet-stream', 'dll' => 'application/octet-stream', 'dms' => 'application/octet-stream', 'exe' => 'application/octet-stream', 'lha' => 'application/octet-stream', 'lzh' => 'application/octet-stream', 'psd' => 'application/octet-stream', 'sea' => 'application/octet-stream', 'so' => 'application/octet-stream', 'oda' => 'application/oda', 'pdf' => 'application/pdf', 'ai' => 'application/postscript', 'eps' => 'application/postscript', 'ps' => 'application/postscript', 'smi' => 'application/smil', 'smil' => 'application/smil', 'mif' => 'application/vnd.mif', 'xls' => 'application/vnd.ms-excel', 'ppt' => 'application/vnd.ms-powerpoint', 'wbxml' => 'application/vnd.wap.wbxml', 'wmlc' => 'application/vnd.wap.wmlc', 'dcr' => 'application/x-director', 'dir' => 'application/x-director', 'dxr' => 'application/x-director', 'dvi' => 'application/x-dvi', 'gtar' => 'application/x-gtar', 'php3' => 'application/x-httpd-php', 'php4' => 'application/x-httpd-php', 'php' => 'application/x-httpd-php', 'phtml' => 'application/x-httpd-php', 'phps' => 'application/x-httpd-php-source', 'swf' => 'application/x-shockwave-flash', 'sit' => 'application/x-stuffit', 'tar' => 'application/x-tar', 'tgz' => 'application/x-tar', 'xht' => 'application/xhtml+xml', 'xhtml' => 'application/xhtml+xml', 'zip' => 'application/zip', 'mid' => 'audio/midi', 'midi' => 'audio/midi', 'mp2' => 'audio/mpeg', 'mp3' => 'audio/mpeg', 'm4a' => 'audio/mp4', 'mpga' => 'audio/mpeg', 'aif' => 'audio/x-aiff', 'aifc' => 'audio/x-aiff', 'aiff' => 'audio/x-aiff', 'ram' => 'audio/x-pn-realaudio', 'rm' => 'audio/x-pn-realaudio', 'rpm' => 'audio/x-pn-realaudio-plugin', 'ra' => 'audio/x-realaudio', 'wav' => 'audio/x-wav', 'mka' => 'audio/x-matroska', 'bmp' => 'image/bmp', 'gif' => 'image/gif', 'jpeg' => 'image/jpeg', 'jpe' => 'image/jpeg', 'jpg' => 'image/jpeg', 'png' => 'image/png', 'tiff' => 'image/tiff', 'tif' => 'image/tiff', 'webp' => 'image/webp', 'heif' => 'image/heif', 'heifs' => 'image/heif-sequence', 'heic' => 'image/heic', 'heics' => 'image/heic-sequence', 'eml' => 'message/rfc822', 'css' => 'text/css', 'html' => 'text/html', 'htm' => 'text/html', 'shtml' => 'text/html', 'log' => 'text/plain', 'text' => 'text/plain', 'txt' => 'text/plain', 'rtx' => 'text/richtext', 'rtf' => 'text/rtf', 'vcf' => 'text/vcard', 'vcard' => 'text/vcard', 'ics' => 'text/calendar', 'xml' => 'text/xml', 'xsl' => 'text/xml', 'wmv' => 'video/x-ms-wmv', 'mpeg' => 'video/mpeg', 'mpe' => 'video/mpeg', 'mpg' => 'video/mpeg', 'mp4' => 'video/mp4', 'm4v' => 'video/mp4', 'mov' => 'video/quicktime', 'qt' => 'video/quicktime', 'rv' => 'video/vnd.rn-realvideo', 'avi' => 'video/x-msvideo', 'movie' => 'video/x-sgi-movie', 'webm' => 'video/webm', 'mkv' => 'video/x-matroska', ]; $ext = strtolower($ext); if (array_key_exists($ext, $mimes)) { return $mimes[$ext]; } return 'application/octet-stream'; } /** * Map a file name to a MIME type. * Defaults to 'application/octet-stream', i.e.. arbitrary binary data. * * @param string $filename A file name or full path, does not need to exist as a file * * @return string */ public static function filenameToType($filename) { // In case the path is a URL, strip any query string before getting extension $qpos = strpos($filename, '?'); if (false !== $qpos) { $filename = substr($filename, 0, $qpos); } $ext = static::mb_pathinfo($filename, PATHINFO_EXTENSION); return static::_mime_types($ext); } /** * Multi-byte-safe pathinfo replacement. * Drop-in replacement for pathinfo(), but multibyte- and cross-platform-safe. * * @see http://www.php.net/manual/en/function.pathinfo.php#107461 * * @param string $path A filename or path, does not need to exist as a file * @param int|string $options Either a PATHINFO_* constant, * or a string name to return only the specified piece * * @return string|array */ public static function mb_pathinfo($path, $options = null) { $ret = ['dirname' => '', 'basename' => '', 'extension' => '', 'filename' => '']; $pathinfo = []; if (preg_match('#^(.*?)[\\\\/]*(([^/\\\\]*?)(\.([^\.\\\\/]+?)|))[\\\\/\.]*$#im', $path, $pathinfo)) { if (array_key_exists(1, $pathinfo)) { $ret['dirname'] = $pathinfo[1]; } if (array_key_exists(2, $pathinfo)) { $ret['basename'] = $pathinfo[2]; } if (array_key_exists(5, $pathinfo)) { $ret['extension'] = $pathinfo[5]; } if (array_key_exists(3, $pathinfo)) { $ret['filename'] = $pathinfo[3]; } } switch ($options) { case PATHINFO_DIRNAME: case 'dirname': return $ret['dirname']; case PATHINFO_BASENAME: case 'basename': return $ret['basename']; case PATHINFO_EXTENSION: case 'extension': return $ret['extension']; case PATHINFO_FILENAME: case 'filename': return $ret['filename']; default: return $ret; } } /** * Set or reset instance properties. * You should avoid this function - it's more verbose, less efficient, more error-prone and * harder to debug than setting properties directly. * Usage Example: * `$mail->set('SMTPSecure', 'tls');` * is the same as: * `$mail->SMTPSecure = 'tls';`. * * @param string $name The property name to set * @param mixed $value The value to set the property to * * @return bool */ public function set($name, $value = '') { if (property_exists($this, $name)) { $this->$name = $value; return true; } $this->setError($this->lang('variable_set') . $name); return false; } /** * Strip newlines to prevent header injection. * * @param string $str * * @return string */ public function secureHeader($str) { return trim(str_replace(["\r", "\n"], '', $str)); } /** * Normalize line breaks in a string. * Converts UNIX LF, Mac CR and Windows CRLF line breaks into a single line break format. * Defaults to CRLF (for message bodies) and preserves consecutive breaks. * * @param string $text * @param string $breaktype What kind of line break to use; defaults to static::$LE * * @return string */ public static function normalizeBreaks($text, $breaktype = null) { if (null === $breaktype) { $breaktype = static::$LE; } // Normalise to \n $text = str_replace(["\r\n", "\r"], "\n", $text); // Now convert LE as needed if ("\n" !== $breaktype) { $text = str_replace("\n", $breaktype, $text); } return $text; } /** * Return the current line break format string. * * @return string */ public static function getLE() { return static::$LE; } /** * Set the line break format string, e.g. "\r\n". * * @param string $le */ protected static function setLE($le) { static::$LE = $le; } /** * Set the public and private key files and password for S/MIME signing. * * @param string $cert_filename * @param string $key_filename * @param string $key_pass Password for private key * @param string $extracerts_filename Optional path to chain certificate */ public function sign($cert_filename, $key_filename, $key_pass, $extracerts_filename = '') { $this->sign_cert_file = $cert_filename; $this->sign_key_file = $key_filename; $this->sign_key_pass = $key_pass; $this->sign_extracerts_file = $extracerts_filename; } /** * Quoted-Printable-encode a DKIM header. * * @param string $txt * * @return string */ public function DKIM_QP($txt) { $line = ''; $len = strlen($txt); for ($i = 0; $i < $len; ++$i) { $ord = ord($txt[$i]); if (((0x21 <= $ord) and ($ord <= 0x3A)) or $ord == 0x3C or ((0x3E <= $ord) and ($ord <= 0x7E))) { $line .= $txt[$i]; } else { $line .= '=' . sprintf('%02X', $ord); } } return $line; } /** * Generate a DKIM signature. * * @param string $signHeader * * @throws Exception * * @return string The DKIM signature value */ public function DKIM_Sign($signHeader) { if (!defined('PKCS7_TEXT')) { if ($this->exceptions) { throw new Exception($this->lang('extension_missing') . 'openssl'); } return ''; } $privKeyStr = !empty($this->DKIM_private_string) ? $this->DKIM_private_string : file_get_contents($this->DKIM_private); if ('' != $this->DKIM_passphrase) { $privKey = openssl_pkey_get_private($privKeyStr, $this->DKIM_passphrase); } else { $privKey = openssl_pkey_get_private($privKeyStr); } if (openssl_sign($signHeader, $signature, $privKey, 'sha256WithRSAEncryption')) { openssl_pkey_free($privKey); return base64_encode($signature); } openssl_pkey_free($privKey); return ''; } /** * Generate a DKIM canonicalization header. * Uses the 'relaxed' algorithm from RFC6376 section 3.4.2. * Canonicalized headers should *always* use CRLF, regardless of mailer setting. * * @see https://tools.ietf.org/html/rfc6376#section-3.4.2 * * @param string $signHeader Header * * @return string */ public function DKIM_HeaderC($signHeader) { //Unfold all header continuation lines //Also collapses folded whitespace. //Note PCRE \s is too broad a definition of whitespace; RFC5322 defines it as `[ \t]` //@see https://tools.ietf.org/html/rfc5322#section-2.2 //That means this may break if you do something daft like put vertical tabs in your headers. $signHeader = preg_replace('/\r\n[ \t]+/', ' ', $signHeader); $lines = explode("\r\n", $signHeader); foreach ($lines as $key => $line) { //If the header is missing a :, skip it as it's invalid //This is likely to happen because the explode() above will also split //on the trailing LE, leaving an empty line if (strpos($line, ':') === false) { continue; } list($heading, $value) = explode(':', $line, 2); //Lower-case header name $heading = strtolower($heading); //Collapse white space within the value $value = preg_replace('/[ \t]{2,}/', ' ', $value); //RFC6376 is slightly unclear here - it says to delete space at the *end* of each value //But then says to delete space before and after the colon. //Net result is the same as trimming both ends of the value. //by elimination, the same applies to the field name $lines[$key] = trim($heading, " \t") . ':' . trim($value, " \t"); } return implode("\r\n", $lines); } /** * Generate a DKIM canonicalization body. * Uses the 'simple' algorithm from RFC6376 section 3.4.3. * Canonicalized bodies should *always* use CRLF, regardless of mailer setting. * * @see https://tools.ietf.org/html/rfc6376#section-3.4.3 * * @param string $body Message Body * * @return string */ public function DKIM_BodyC($body) { if (empty($body)) { return "\r\n"; } // Normalize line endings to CRLF $body = static::normalizeBreaks($body, "\r\n"); //Reduce multiple trailing line breaks to a single one return rtrim($body, "\r\n") . "\r\n"; } /** * Create the DKIM header and body in a new message header. * * @param string $headers_line Header lines * @param string $subject Subject * @param string $body Body * * @return string */ public function DKIM_Add($headers_line, $subject, $body) { $DKIMsignatureType = 'rsa-sha256'; // Signature & hash algorithms $DKIMcanonicalization = 'relaxed/simple'; // Canonicalization of header/body $DKIMquery = 'dns/txt'; // Query method $DKIMtime = time(); // Signature Timestamp = seconds since 00:00:00 - Jan 1, 1970 (UTC time zone) $subject_header = "Subject: $subject"; $headers = explode(static::$LE, $headers_line); $from_header = ''; $to_header = ''; $date_header = ''; $current = ''; $copiedHeaderFields = ''; $foundExtraHeaders = []; $extraHeaderKeys = ''; $extraHeaderValues = ''; $extraCopyHeaderFields = ''; foreach ($headers as $header) { if (strpos($header, 'From:') === 0) { $from_header = $header; $current = 'from_header'; } elseif (strpos($header, 'To:') === 0) { $to_header = $header; $current = 'to_header'; } elseif (strpos($header, 'Date:') === 0) { $date_header = $header; $current = 'date_header'; } elseif (!empty($this->DKIM_extraHeaders)) { foreach ($this->DKIM_extraHeaders as $extraHeader) { if (strpos($header, $extraHeader . ':') === 0) { $headerValue = $header; foreach ($this->CustomHeader as $customHeader) { if ($customHeader[0] === $extraHeader) { $headerValue = trim($customHeader[0]) . ': ' . $this->encodeHeader(trim($customHeader[1])); break; } } $foundExtraHeaders[$extraHeader] = $headerValue; $current = ''; break; } } } else { if (!empty($$current) and strpos($header, ' =?') === 0) { $$current .= $header; } else { $current = ''; } } } foreach ($foundExtraHeaders as $key => $value) { $extraHeaderKeys .= ':' . $key; $extraHeaderValues .= $value . "\r\n"; if ($this->DKIM_copyHeaderFields) { $extraCopyHeaderFields .= "\t|" . str_replace('|', '=7C', $this->DKIM_QP($value)) . ";\r\n"; } } if ($this->DKIM_copyHeaderFields) { $from = str_replace('|', '=7C', $this->DKIM_QP($from_header)); $to = str_replace('|', '=7C', $this->DKIM_QP($to_header)); $date = str_replace('|', '=7C', $this->DKIM_QP($date_header)); $subject = str_replace('|', '=7C', $this->DKIM_QP($subject_header)); $copiedHeaderFields = "\tz=$from\r\n" . "\t|$to\r\n" . "\t|$date\r\n" . "\t|$subject;\r\n" . $extraCopyHeaderFields; } $body = $this->DKIM_BodyC($body); $DKIMlen = strlen($body); // Length of body $DKIMb64 = base64_encode(pack('H*', hash('sha256', $body))); // Base64 of packed binary SHA-256 hash of body if ('' == $this->DKIM_identity) { $ident = ''; } else { $ident = ' i=' . $this->DKIM_identity . ';'; } $dkimhdrs = 'DKIM-Signature: v=1; a=' . $DKIMsignatureType . '; q=' . $DKIMquery . '; l=' . $DKIMlen . '; s=' . $this->DKIM_selector . ";\r\n" . "\tt=" . $DKIMtime . '; c=' . $DKIMcanonicalization . ";\r\n" . "\th=From:To:Date:Subject" . $extraHeaderKeys . ";\r\n" . "\td=" . $this->DKIM_domain . ';' . $ident . "\r\n" . $copiedHeaderFields . "\tbh=" . $DKIMb64 . ";\r\n" . "\tb="; $toSign = $this->DKIM_HeaderC( $from_header . "\r\n" . $to_header . "\r\n" . $date_header . "\r\n" . $subject_header . "\r\n" . $extraHeaderValues . $dkimhdrs ); $signed = $this->DKIM_Sign($toSign); return static::normalizeBreaks($dkimhdrs . $signed) . static::$LE; } /** * Detect if a string contains a line longer than the maximum line length * allowed by RFC 2822 section 2.1.1. * * @param string $str * * @return bool */ public static function hasLineLongerThanMax($str) { return (bool) preg_match('/^(.{' . (self::MAX_LINE_LENGTH + strlen(static::$LE)) . ',})/m', $str); } /** * Allows for public read access to 'to' property. * Before the send() call, queued addresses (i.e. with IDN) are not yet included. * * @return array */ public function getToAddresses() { return $this->to; } /** * Allows for public read access to 'cc' property. * Before the send() call, queued addresses (i.e. with IDN) are not yet included. * * @return array */ public function getCcAddresses() { return $this->cc; } /** * Allows for public read access to 'bcc' property. * Before the send() call, queued addresses (i.e. with IDN) are not yet included. * * @return array */ public function getBccAddresses() { return $this->bcc; } /** * Allows for public read access to 'ReplyTo' property. * Before the send() call, queued addresses (i.e. with IDN) are not yet included. * * @return array */ public function getReplyToAddresses() { return $this->ReplyTo; } /** * Allows for public read access to 'all_recipients' property. * Before the send() call, queued addresses (i.e. with IDN) are not yet included. * * @return array */ public function getAllRecipientAddresses() { return $this->all_recipients; } /** * Perform a callback. * * @param bool $isSent * @param array $to * @param array $cc * @param array $bcc * @param string $subject * @param string $body * @param string $from * @param array $extra */ protected function doCallback($isSent, $to, $cc, $bcc, $subject, $body, $from, $extra) { if (!empty($this->action_function) and is_callable($this->action_function)) { call_user_func($this->action_function, $isSent, $to, $cc, $bcc, $subject, $body, $from, $extra); } } /** * Get the OAuth instance. * * @return OAuth */ public function getOAuth() { return $this->oauth; } /** * Set an OAuth instance. * * @param OAuth $oauth */ public function setOAuth(OAuth $oauth) { $this->oauth = $oauth; } } ``` ===== FILE: common/extensions/CDEK.php ===== ``` curl = new Curl(); $params = array( "grant_type" => "client_credentials", "client_id" => "uRpWyq46AogPuMT8114gbroKVABhqEUS", "client_secret" => "ufnr9up2TQgNRVDvsVRLK5cJ99R45UOj" ); $result = $this->curl->request("https://api.cdek.ru/v2/oauth/token", $params); $json = json_decode($result); $this->token = $json->access_token; } function __destruct() { } public function getTrackNumber($id){ $result = $this->curl->request("https://api.cdek.ru/v2/orders?im_number=".$id, false, array( "Authorization: Bearer ".$this->token )); $json = json_decode($result); $request = $json->requests[0]; if( isset($json->entity) ){ return $json->entity->cdek_number; }else{ foreach ($request->errors as $key => $error) { echo $error->message."
"; } } return false; } } ?> ``` ===== FILE: common/extensions/Debug.php ===== ``` basePath."/logs/".$dirname."/".$name.".txt", $string."\n", FILE_APPEND); if( $error === true ) file_put_contents(Yii::app()->basePath."/logs/errors.txt", $string."\n", FILE_APPEND); } public function log($message, $echo = false, $error = false ){ if( $echo ) echo $message."\n"; Debug::set("debug", $message, $error); } public function error($message, $echo = false){ if( $echo ) echo $message."\n"; Debug::set("debug", "Ошибка: ".$message, true); } } ?> ``` ===== FILE: common/extensions/Curl.php ===== ``` proxySet($type); // $this->checkProxy(); // } else $this->ip = $type; // } if( $type ){ $this->cookie = $type; }else{ $this->cookie = md5(rand().time()); } // $this->cookie = "kitaev.team"; // $this->cookie = "valeriyatk"; // $this->removeCookies(); } function __destruct() { // if(!$this->cookieChanged) // $this->removeCookies(); } public function setBasicAuth($username, $password){ $this->username = $username; $this->password = $password; } public function request($url = NULL, $post = NULL, $headers = false){ $ch = curl_init(); curl_setopt($ch, CURLOPT_AUTOREFERER, 1); curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); curl_setopt($ch, CURLOPT_FOLLOWLOCATION, 1); curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, 0); curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 30); // if($this->proxy_login && $this->proxy_ip) { // curl_setopt($ch, CURLOPT_PROXY, $this->proxy_ip); // curl_setopt($ch, CURLOPT_PROXYUSERPWD, $this->proxy_login); // } curl_setopt($ch, CURLINFO_HEADER_OUT, true); // if( $this->username !== NULL && $this->password !== NULL ){ // curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC); // curl_setopt($ch, CURLOPT_USERPWD, $this->username . ":" . $this->password); // } // if( $this->gzip === true ){ // curl_setopt($ch, CURLOPT_HTTPHEADER, array("Accept-Encoding:gzip", "Content-Type:application/json")); // } if( $headers ){ curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); } curl_setopt($ch, CURLOPT_URL, $url); if (!is_dir(dirname(__FILE__).'/cookies')) mkdir(dirname(__FILE__).'/cookies',0777, true); curl_setopt($ch, CURLOPT_COOKIEJAR, dirname(__FILE__).'/cookies/'.$this->cookie.'.txt'); curl_setopt($ch, CURLOPT_COOKIEFILE, dirname(__FILE__).'/cookies/'.$this->cookie.'.txt'); if( isset($post) && $post ){ curl_setopt($ch, CURLOPT_POST, 1); curl_setopt($ch, CURLOPT_POSTFIELDS, $post); } // if( $delete ){ // curl_setopt($ch, CURLOPT_CUSTOMREQUEST, "DELETE"); // } $result = curl_exec($ch); curl_close( $ch ); if( $this->gzip === true ){ $result = gzdecode($result); } return $result; } public function proxySet($proxy) { $proxy = explode("@", $proxy); $this->proxy_login = $proxy[0]; $this->proxy_ip = $proxy[1]; } public function checkProxy(){ include_once Yii::app()->basePath.'/extensions/simple_html_dom.php'; $i = 0; do { $i++; $result = $this->request("http://www.seogadget.ru/location"); $html = str_get_html($result); } while ( !is_object($html) && $i < 5 ); if( !is_object($html) ){ Log::debug("Прокси ".$temp_ip[0]." упал"); return false; } $ip = $html->find('.url',0)->value; $temp_ip = explode(":", $this->proxy_ip); if( $ip == $temp_ip[0]) { Log::debug("Прокси ".$ip." успешно установлен"); return true; }else{ Log::debug("Прокси ".$temp_ip[0]." не был установлен. Выдало ".$ip); return false; } } public function proxyUnset() { $this->$proxy_login = NULL; $this->$proxy_ip = NULL; } public function changeCookies($login){ $this->removeCookies(); $this->cookie = md5($login); $this->cookieChanged = true; } public function removeCookies(){ // if($this->ip) { // $this->request(NULL,array("remove" => 1)); // } else { // if( file_exists(dirname(__FILE__).'/cookies/'.$this->cookie.'.txt') ) // unlink(dirname(__FILE__).'/cookies/'.$this->cookie.'.txt'); // } } } ?> ``` ===== FILE: common/extensions/phpmail.php ===== ``` '; if($data_charset != $send_charset) { $body = iconv($data_charset, $send_charset, $body); } $headers = "From: $from\r\n"; $type = ($html) ? 'html' : 'plain'; $headers .= "Content-type: text/$type; charset=$send_charset\r\n"; $headers .= "Mime-Version: 1.0\r\n"; if ($reply_to) { $headers .= "Reply-To: $reply_to"; } return mail($to, $subject, $body, $headers); } function mime_header_encode($str, $data_charset, $send_charset) { if($data_charset != $send_charset) { $str = iconv($data_charset, $send_charset, $str); } return '=?' . $send_charset . '?B?' . base64_encode($str) . '?='; } ?> ``` ===== FILE: common/extensions/Translit.php ===== ``` 'a', 'б'=>'b', 'в'=>'v', 'г'=>'g', 'д'=>'d', 'е'=>'e', 'ё'=>'e', 'ж'=>'zh', 'з'=>'z', 'и'=>'i', 'й'=>'i', 'к'=>'k', 'л'=>'l', 'м'=>'m', 'н'=>'n', 'о'=>'o', 'п'=>'p', 'р'=>'r', 'с'=>'s', 'т'=>'t', 'у'=>'u', 'ф'=>'f', 'х'=>'kh', 'ц'=>'ts', 'ч'=>'ch', 'ш'=>'sh', 'щ'=>'shh', 'ъ'=>'j', 'ы'=>'y', 'ь'=>'', 'э'=>'e', 'ю'=>'yu', 'я'=>'ya', // Всякие знаки препинания и пробелы ' '=>'-', ' - '=>'-', '_'=>'-', '/'=>'-', '*'=>'-', '+'=>'-', //Удаляем // '.'=>'', ':'=>'', ';'=>'', ','=>'', '!'=>'', '?'=>'', '>'=>'', '<'=>'', '&'=>'', '%'=>'', '$'=>'', '"'=>'', '\''=>'', '('=>'', ')'=>'', '`'=>'', '\\'=>'', ); $insert = preg_replace("/ +/"," ",$insert); // Удаляем лишние пробелы $insert = strtr($insert,$replase); $insert = str_replace(" ", "", $insert); // Удаляем неразрывные пробелы return $insert; } } ?> ``` ===== FILE: common/extensions/Captcha.php ===== ``` curl = new Curl(); } function __destruct() { } public function getCaptcha($url){ // Скачиваем капчу $image_path = $this->loadImage($url); // Пробуем отправлять капчу $params = array( 'key' => $this->key, 'file'=> new CurlFile($image_path) ); $result = $this->curl->request("http://rucaptcha.com/in.php", $params); // Повторять, пока не будут свободные операторы // while( $result = 'ERROR_NO_SLOT_AVAILABLE' ) { // sleep(5); // $result = $this->curl->request("http://rucaptcha.com/in.php", $params); // } if(strpos($result, "|") !== false) { $url = "http://rucaptcha.com/res.php?"; $url_params = array( 'key' => $this->key, 'action' => 'get', 'id' => substr($result, 3) ); $url .= urldecode(http_build_query($url_params)); // Ждем, пока капчу не введут $result = $this->curl->request($url); while ($result == 'CAPCHA_NOT_READY') { sleep(2); $result = $this->curl->request($url); } // print_r($result."
"); if(strpos($result, "|") !== false) { $captcha = substr($result, 3); return $captcha; }else{ // TODO: Обработать ошибку } }else{ // TODO: Обработать ошибку } } private function loadImage($url){ $captcha_path = Yii::app()->basePath.'/extensions/captcha/'.md5(time()."rank").'.gif'; file_put_contents($captcha_path, file_get_contents($url) ); return $captcha_path; } } ?> ``` ===== FILE: common/extensions/Log.php ===== ``` echo = $echo; } public function set($dirname,$message,$error = false){ $string = date("Y-m-d H:i:s", time())." "; $name = date("Y-m-d", time()); $string .= ( ( $error === true )?"Ошибка":( ($error === false)?"Сообщение":$error ) ).": "; $string .= $message; if( $this->echo ) echo $message."
"; file_put_contents(Yii::app()->basePath."/logs/".$dirname."/".$name.".txt", $string."\n", FILE_APPEND); if( $error === true ) file_put_contents(Yii::app()->basePath."/logs/errors.txt", $string."\n", FILE_APPEND); } public function debug($message, $error = false){ Log::set("debug", $message, $error); } public function error($message){ Log::set("debug","ОШИБКА: ". $message, true); return false; } } ?> ``` ===== FILE: common/extensions/Resize.php ===== ``` image = $this->openImage($fileName); // die($fileName); $this->width = imagesx($this->image); $this->height = imagesy($this->image); } private function openImage($file) { $extension = strtolower(strrchr($file, '.')); switch($extension) { case '.jpg': case '.jpeg': $img = @imagecreatefromjpeg($file); break; case '.gif': $img = @imagecreatefromgif($file); break; case '.png': $img = @imagecreatefrompng($file); break; default: $img = false; break; } return $img; } public function resizeImage($newWidth, $newHeight, $option="auto") { // *** Get optimal width and height - based on $option $optionArray = $this->getDimensions($newWidth, $newHeight, strtolower($option)); $optimalWidth = $optionArray['optimalWidth']; $optimalHeight = $optionArray['optimalHeight']; $this->imageResized = imagecreatetruecolor($optimalWidth, $optimalHeight); imagealphablending($this->imageResized, false); imagesavealpha($this->imageResized,true); $transparent = imagecolorallocatealpha($this->imageResized, 255, 255, 255, 127); imagefilledrectangle($this->imageResized, 0, 0, $optimalWidth, $optimalHeight, $transparent); imagecopyresampled($this->imageResized, $this->image, 0, 0, 0, 0, $optimalWidth, $optimalHeight, $this->width, $this->height); // // *** Resample - create image canvas of x, y size // $this->imageResized = imagecreatetruecolor($optimalWidth, $optimalHeight); // imagecopyresampled($this->imageResized, $this->image, 0, 0, 0, 0, $optimalWidth, $optimalHeight, $this->width, $this->height); // *** if option is 'crop', then crop too if ($option == 'crop') { $this->crop($optimalWidth, $optimalHeight, $newWidth, $newHeight); } } public function setWatermark() { $stamp = imagecreatefrompng('upload/watermark.png'); // Установка полей для штампа и получение высоты/ширины штампа $marge_right = 10; $marge_bottom = 10; $sx = imagesx($stamp); $sy = imagesy($stamp); $this->imageResized = $this->image; // Копирование изображения штампа на фотографию с помощью смещения края // и ширины фотографии для расчета позиционирования штампа. imagecopy($this->imageResized, $stamp, (imagesx($this->imageResized) - $sx)/2, (imagesy($this->imageResized) - $sy)/2, 0, 0, $sx, $sy); } private function getDimensions($newWidth, $newHeight, $option) { switch ($option) { case 'exact': $optimalWidth = $newWidth; $optimalHeight= $newHeight; break; case 'portrait': $optimalWidth = $this->getSizeByFixedHeight($newHeight); $optimalHeight= $newHeight; break; case 'landscape': $optimalWidth = $newWidth; $optimalHeight= $this->getSizeByFixedWidth($newWidth); break; case 'auto': $optionArray = $this->getSizeByAuto($newWidth, $newHeight); $optimalWidth = $optionArray['optimalWidth']; $optimalHeight = $optionArray['optimalHeight']; break; case 'crop': $optionArray = $this->getOptimalCrop($newWidth, $newHeight); $optimalWidth = $optionArray['optimalWidth']; $optimalHeight = $optionArray['optimalHeight']; break; } return array('optimalWidth' => $optimalWidth, 'optimalHeight' => $optimalHeight); } private function getSizeByFixedHeight($newHeight) { $ratio = $this->width / $this->height; $newWidth = $newHeight * $ratio; return $newWidth; } private function getSizeByFixedWidth($newWidth) { $ratio = $this->height / $this->width; $newHeight = $newWidth * $ratio; return $newHeight; } private function getSizeByAuto($newWidth, $newHeight) { // *** Image to be resized is wider (landscape) if ($this->height < $this->width) { $optimalWidth = $newWidth; $optimalHeight= $this->getSizeByFixedWidth($newWidth); } // *** Image to be resized is taller (portrait) elseif ($this->height > $this->width) { $optimalWidth = $this->getSizeByFixedHeight($newHeight); $optimalHeight= $newHeight; } // *** Image to be resizerd is a square else { if ($newHeight < $newWidth) { $optimalWidth = $newWidth; $optimalHeight= $this->getSizeByFixedWidth($newWidth); } else if ($newHeight > $newWidth) { $optimalWidth = $this->getSizeByFixedHeight($newHeight); $optimalHeight= $newHeight; } else { // *** Sqaure being resized to a square $optimalWidth = $newWidth; $optimalHeight= $newHeight; } } return array('optimalWidth' => $optimalWidth, 'optimalHeight' => $optimalHeight); } private function getOptimalCrop($newWidth, $newHeight) { $heightRatio = $this->height / $newHeight; $widthRatio = $this->width / $newWidth; if ($heightRatio < $widthRatio) { $optimalRatio = $heightRatio; } else { $optimalRatio = $widthRatio; } $optimalHeight = $this->height / $optimalRatio; $optimalWidth = $this->width / $optimalRatio; return array('optimalWidth' => $optimalWidth, 'optimalHeight' => $optimalHeight); } private function crop($optimalWidth, $optimalHeight, $newWidth, $newHeight) { // *** Find center - this will be used for the crop $cropStartX = ( $optimalWidth / 2) - ( $newWidth /2 ); $cropStartY = ( $optimalHeight/ 2) - ( $newHeight/2 ); $crop = $this->imageResized; //imagedestroy($this->imageResized); // *** Now crop from center to exact requested size $this->imageResized = imagecreatetruecolor($newWidth , $newHeight); imagecopyresampled($this->imageResized, $crop , 0, 0, $cropStartX, $cropStartY, $newWidth, $newHeight , $newWidth, $newHeight); } public function saveImage($savePath, $imageQuality="100") { // $stamp = imagecreatefrompng('upload/watermark.png'); // // Установка полей для штампа и получение высоты/ширины штампа // $marge_right = 10; // $marge_bottom = 10; // $sx = imagesx($stamp); // $sy = imagesy($stamp); // // Копирование изображения штампа на фотографию с помощью смещения края // // и ширины фотографии для расчета позиционирования штампа. // imagecopy($this->imageResized, $stamp, imagesx($this->imageResized) - $sx - $marge_right, imagesy($this->imageResized) - $sy - $marge_bottom, 0, 0, $sx, $sy); // *** Get extension $extension = strrchr($savePath, '.'); $extension = strtolower($extension); switch($extension) { case '.jpg': case '.jpeg': if (imagetypes() & IMG_JPG) { imagejpeg($this->imageResized, $savePath, $imageQuality); } break; case '.gif': if (imagetypes() & IMG_GIF) { imagegif($this->imageResized, $savePath); } break; case '.png': // *** Scale quality from 0-100 to 0-9 $scaleQuality = round(($imageQuality/100) * 9); // *** Invert quality setting as 0 is best, not 9 $invertScaleQuality = 9 - $scaleQuality; if (imagetypes() & IMG_PNG) { imagepng($this->imageResized, $savePath, $invertScaleQuality); } break; default: // *** No extension - No save. break; } imagedestroy($this->imageResized); } } ?> ``` ===== FILE: common/extensions/Threexui.php ===== ``` server = $server; $this->curl = new Curl(md5($this->server->ip)); $this->log = new Log(false); } public function auth() { $this->log->debug("Авторизация"); // Проверка от ухода в рекурсию $authCounter++; if( $authCounter >= 3 ){ $this->log->debug("Авторизация больше 3-х раз"); return false; } $postData = [ "username" => $this->server->username, "password" => $this->server->password, ]; if( $json = $this->curl->request("https://".$this->server->name.":".$this->port."/".($this->server->folder?($this->server->folder."/"):"")."login", $postData) ){ $this->log->debug($json); $result = json_decode($json); return $result->success; } $this->log->error("Ошибка авторизации"); return false; } public function addClient($user) { $this->log->debug("Добавление клиента: ".$user->id." на сервер ".$this->server->name); $postData = [ "id" => 1, "settings" => json_encode([ "clients" => [ [ "id" => $user->uid, "flow" => "", "email" => $user->uid, "limitIp" => 3, "totalGB" => 0, // "expiryTime" => 1732283726000, "enable" => true, "tgId" => $user->telegram_id, // "subId" => "2rv0gb458kbfl532", "reset" => 0 ] ] ]) ]; $json = $this->curl->request("https://".$this->server->name.":".$this->port."/".($this->server->folder?($this->server->folder."/"):"")."panel/api/inbounds/addClient", $postData); $this->log->debug($json); if( $json ){ $result = json_decode($json); // Если нет авторизации if( $json == "404 page not found" ){ $this->log->debug("Нет авторизации"); // Аторизовываемся if( $this->auth() ){ // И заново пытаемся добавить клиента return $this->addClient($user); } }else{ if( strpos($result->msg, "Duplicate email") ){ $result->success = true; } $this->log->debug("Результат добавления клиента: ".($result->success?"Успешно":"С ошибкой")); return $result->success; } } return false; } public function addManyClients($users){ $this->log = new Log(true); $this->log->debug("Добавление ".count($users)." пользоветелей на сервер ".$this->server->name); $clients = []; foreach ($users as $key => $user) { $clients[] = [ "id" => $user->uid, "flow" => "", "email" => $user->uid, "limitIp" => 3, "totalGB" => 0, "enable" => true, "tgId" => $user->telegram_id, "reset" => 0 ]; } $postData = [ "id" => 1, "settings" => json_encode([ "clients" => $clients ]) ]; $json = $this->curl->request("https://".$this->server->name.":".$this->port."/".($this->server->folder?($this->server->folder."/"):"")."panel/api/inbounds/addClient", $postData); $this->log->debug($json); if( $json ){ $result = json_decode($json); // Если нет авторизации if( $json == "404 page not found" ){ $this->log->debug("Нет авторизации"); // Аторизовываемся if( $this->auth() ){ // И заново пытаемся добавить клиента return $this->addClient($user); } }else{ if( strpos($result->msg, "Duplicate email") ){ $result->success = true; } $this->log->debug("Результат добавления пользователей: ".($result->success?"Успешно":"С ошибкой")); return $result->success; } } return false; } // public function updateServer($server){ // $postData = [ // "id" => 1, // "settings" => json_encode([ // "clients" => [ // [ // "id" => $user->id, // "flow" => "", // "email" => md5($user->id), // "limitIp" => 2, // "totalGB" => 0, // "expiryTime" => 1732283726000, // "enable" => true, // "tgId" => "", // "subId" => "2rv0gb458kbfl532", // "reset" => 0 // ] // ] // ]) // ]; // // var_dump($postData); // // die(); // if( $json = $this->curl->request("http://".$this->server->ip.":".$this->port."/panel/api/inbounds/get/".$server->inbound_id) ){ // $result = json_decode($json); // $settings = json_decode($result->obj->settings); // var_dump($settings); // return $result->success; // } // return false; // } } ?> ``` ===== FILE: common/extensions/Curl_old.php ===== ``` proxySet($type); $this->checkProxy(); } else $this->ip = $type; } // $this->cookie = md5(rand().time()); $this->cookie = "rank"; // $this->removeCookies(); } function __destruct() { // if(!$this->cookieChanged) // $this->removeCookies(); } public function request($url = NULL,$post = NULL){ $header = array( 'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8', 'Accept-Encoding' => 'gzip, deflate, sdch, br', 'Accept-Language' => 'ru-RU,ru;q=0.8,en-US;q=0.6,en;q=0.4', 'Connection' => 'keep-alive', 'Host' => 'www.avito.ru', 'Upgrade-Insecure-Requests' => 1, 'User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.106 Safari/537.36' ); $ch = curl_init(); curl_setopt($ch, CURLOPT_AUTOREFERER, 1); curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); curl_setopt($ch, CURLOPT_FOLLOWLOCATION, 1); curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 30); if(strpos($url, "avito") !== false) { // if( $post == "image" ){ // $header = array( // "Accept" => "image/webp,image/*,*/*;q=0.8", // "Accept-Encoding" => "gzip, deflate, sdch, br", // "Accept-Language" => "ru-RU,ru;q=0.8,en-US;q=0.6,en;q=0.4", // "Connection" => "keep-alive", // "Host" => "www.avito.ru", // "Referer" => "https://www.avito.ru/additem/confirm", // "User-Agent" => "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/52.0.2743.116 Safari/537.36", // ); // }else{ // $header = array( // "Accept" => "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8", // "Accept-Encoding" => "gzip, deflate, br", // "Accept-Language" => "ru-RU,ru;q=0.8,en-US;q=0.6,en;q=0.4", // "Cache-Control" => "no-cache", // "Connection" => "keep-alive", // "Content-Length" => "60", // "Content-Type" => "application/x-www-form-urlencoded", // "Host" => "www.avito.ru", // "Origin" => "https://www.avito.ru", // "Pragma" => "no-cache", // "Referer" => "https://www.avito.ru/profile/login?next=/Fprofile", // "Upgrade-Insecure-Requests" => "1", // "User-Agent" => "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/52.0.2743.116 Safari/537.36", // ); // } // curl_setopt($ch, CURLOPT_HTTPHEADER, $header); } elseif(strpos($url, "drom") !== false) { // $header['Accept-Encoding'] = 'gzip, deflate, sdch'; // $header['Host'] = 'baza.drom.ru'; // curl_setopt($ch, CURLOPT_HTTPHEADER, $header); } if($this->proxy_login && $this->proxy_ip) { curl_setopt($ch, CURLOPT_PROXY, $this->proxy_ip); curl_setopt($ch, CURLOPT_PROXYUSERPWD, $this->proxy_login); } curl_setopt($ch, CURLINFO_HEADER_OUT, true); if($this->ip) { curl_setopt($ch, CURLOPT_URL, "http://".$this->ip."/redirect.php"); if($post) { $post['cookie'] = $this->cookie; } else $post = array("cookie" => $this->cookie); if($url) $post['url'] = $url; } else { curl_setopt($ch, CURLOPT_URL, $url); if (!is_dir(dirname(__FILE__).'/cookies')) mkdir(dirname(__FILE__).'/cookies',0777, true); curl_setopt($ch, CURLOPT_COOKIEJAR, dirname(__FILE__).'/cookies/'.$this->cookie.'.txt'); curl_setopt($ch, CURLOPT_COOKIEFILE, dirname(__FILE__).'/cookies/'.$this->cookie.'.txt'); } if( is_array($post) ){ // if($this->ip) // $post = array("json" => json_encode($post)); curl_setopt($ch, CURLOPT_POST, 1); curl_setopt($ch, CURLOPT_POSTFIELDS, $post); } $result = curl_exec($ch); $header = curl_getinfo($ch, CURLINFO_HEADER_OUT); // print_r($header); curl_close( $ch ); return $result; } public function proxySet($proxy) { $proxy = explode("@", $proxy); $this->proxy_login = $proxy[0]; $this->proxy_ip = $proxy[1]; } public function checkProxy(){ include_once Yii::app()->basePath.'/extensions/simple_html_dom.php'; $i = 0; do { $i++; $result = $this->request("http://www.seogadget.ru/location"); $html = str_get_html($result); } while ( !is_object($html) && $i < 5 ); if( !is_object($html) ){ Log::debug("Прокси ".$temp_ip[0]." упал"); return false; } $ip = $html->find('.url',0)->value; $temp_ip = explode(":", $this->proxy_ip); if( $ip == $temp_ip[0]) { Log::debug("Прокси ".$ip." успешно установлен"); return true; }else{ Log::debug("Прокси ".$temp_ip[0]." не был установлен. Выдало ".$ip); return false; } } public function proxyUnset() { $this->$proxy_login = NULL; $this->$proxy_ip = NULL; } public function changeCookies($login){ $this->removeCookies(); $this->cookie = md5($login); $this->cookieChanged = true; } public function removeCookies(){ // if($this->ip) { // $this->request(NULL,array("remove" => 1)); // } else { // if( file_exists(dirname(__FILE__).'/cookies/'.$this->cookie.'.txt') ) // unlink(dirname(__FILE__).'/cookies/'.$this->cookie.'.txt'); // } } } ?> ``` ===== FILE: common/extensions/NanoBanana.php ===== ``` ``` ===== FILE: common/extensions/Kie.php ===== ``` apiKey = $apiKey; $this->model = ($isPro) ? 'nano-banana-pro' : 'google/nano-banana-edit'; $this->baseUrl = rtrim($baseUrl, '/'); } public function generateImage( Task $task, array $options = [] ){ $payload = [ "model" => $this->model, "callBackUrl" => Yii::app()->params["basePath"]."api/kieHook", ]; $input = $options['input'] ?? []; $referenceImages = $this->getReferenceImages($task); // Удобные шорткаты: aspect_ratio / image_size if (isset($options['aspect_ratio']) || isset($options['image_size'])) { if( $this->model == 'nano-banana-pro' ){ if( isset($options["aspect_ratio"]) ){ $input["aspect_ratio"] = $options["aspect_ratio"]; } if( isset($options["image_size"]) ){ $input["resolution"] = $options["image_size"]; } $input["image_input"] = $referenceImages; }else if( $this->model == 'google/nano-banana-edit' && isset($options["aspect_ratio"]) ){ $input["image_size"] = $options["aspect_ratio"]; $input["image_urls"] = $referenceImages; } } $input["prompt"] = $task->prompt; $input["output_format"] = "png"; if( count($input) ){ $payload["input"] = $input; } $response = $this->postJson($this->baseUrl, $payload); // Yii::app()->controller->logToTelegram(print_r($response, true)); // var_dump($response); // die(); if( $response["code"] == 200 && $response["msg"] == "success" ){ return [ "success" => true, "code" => $response["code"], "taskId" => $response["data"]["taskId"] ?? null, "errorMessage" => null, "model" => $this->model ]; }else{ return [ "success" => false, "code" => $response["code"], "taskId" => null, "errorMessage" => $response["msg"], "model" => $this->model ]; } } private function postJson(string $url, array $payload): array { $ch = curl_init($url); if ($ch === false) { throw new RuntimeException('Failed to initialize cURL'); } $jsonPayload = json_encode($payload); if ($jsonPayload === false) { throw new RuntimeException('Failed to encode JSON payload'); } $headers = [ 'Content-Type: application/json', 'Authorization: Bearer ' . $this->apiKey, ]; curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => $headers, CURLOPT_POSTFIELDS => $jsonPayload, CURLOPT_TIMEOUT => 600, ]); $body = curl_exec($ch); $errno = curl_errno($ch); $error = curl_error($ch); $status = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($errno) { throw new RuntimeException("cURL error ({$errno}): {$error}"); } if ($status < 200 || $status >= 300) { throw new RuntimeException("Gemini API HTTP error: {$status}. Body: {$body}"); } $data = json_decode($body, true); if (!is_array($data)) { throw new RuntimeException('Failed to decode Gemini API response as JSON'); } return $data; } public function getBalance(): array { // По докам Kie: Get Remaining Credits $url = 'https://api.kie.ai/api/v1/chat/credit'; $response = $this->getJson($url); if (($response['code'] ?? null) == 200 && ($response['msg'] ?? null) === 'success') { return [ 'success' => true, 'code' => 200, 'credits' => $response['data'] ?? null, 'errorMessage' => null, ]; } return [ 'success' => false, 'code' => $response['code'] ?? null, 'credits' => null, 'errorMessage' => $response['msg'] ?? 'Unknown error', ]; } private function getJson(string $url): array { $ch = curl_init($url); if ($ch === false) { throw new RuntimeException('Failed to initialize cURL'); } $headers = [ 'Content-Type: application/json', 'Authorization: Bearer ' . $this->apiKey, ]; curl_setopt_array($ch, [ CURLOPT_HTTPGET => true, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => $headers, CURLOPT_TIMEOUT => 60, ]); $body = curl_exec($ch); $errno = curl_errno($ch); $error = curl_error($ch); $status = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($errno) { throw new RuntimeException("cURL error ({$errno}): {$error}"); } if ($status < 200 || $status >= 300) { throw new RuntimeException("Kie API HTTP error: {$status}. Body: {$body}"); } $data = json_decode($body, true); if (!is_array($data)) { throw new RuntimeException('Failed to decode Kie API response as JSON'); } return $data; } private function getReferenceImages($task){ $raw = (string)$task->input_photo; $decoded = null; if ($raw !== '' && $raw[0] === '[') { $decoded = json_decode($raw, true); } $items = (is_array($decoded) && !empty($decoded)) ? $decoded : [$raw]; $base = rtrim((string)Yii::app()->params["basePath"], '/') . '/'; foreach ($items as $p) { $p = trim((string)$p); if ($p === '') continue; if (strpos($p, 'http://') === 0 || strpos($p, 'https://') === 0) { $referenceImages[] = $p; } else { $referenceImages[] = $base . ltrim($p, '/'); } } return $referenceImages; } } ``` ===== FILE: common/components/UserMenu.php ===== ``` title=CHtml::encode(Yii::app()->user->name); parent::init(); } protected function renderContent() { $this->render('userMenu'); } } ``` ===== FILE: common/components/TagCloud.php ===== ``` findTagWeights($this->maxTags); foreach($tags as $tag=>$weight) { $link=CHtml::link(CHtml::encode($tag), array('post/index','tag'=>$tag)); echo CHtml::tag('span', array( 'class'=>'tag', 'style'=>"font-size:{$weight}pt", ), $link)."\n"; } } } ``` ===== FILE: common/components/Controller.php ===== ``` user = Admin::model()->findByPk(Yii::app()->user->id); $this->isMobile = $this->isMobile(); // $this->isMobile = true; $this->account_id = 1; $adminMenu = ModelNames::model()->findAll(array("order" => "t.sort ASC")); $this->adminMenu["items"] = array(); foreach ($adminMenu as $key => $value) { $this->adminMenu["items"][ $value->code ] = $this->toLowerCaseModelNames($value); } $this->settings = Controller::getSettings(); $this->currency = [ $this->settings["CURRENCY"]["1"], $this->settings["CURRENCY"]["2"], $this->settings["CURRENCY"]["5"], $this->settings["CURRENCY"]["MANY"] ]; // $code = ( isset($_GET["class"]) )?$_GET["class"]:(Yii::app()->controller->id); $code = Yii::app()->controller->id; $this->adminMenu["cur"] = $this->toLowerCaseModelNames(ModelNames::model()->find(array("condition" => "code = '".$code."'"))); $this->start = microtime(true); } public function isMobile(){ $userAgent = $_SERVER['HTTP_USER_AGENT'] ?? ""; return (preg_match('/(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino/i',$userAgent)||preg_match('/1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-/i',substr($userAgent,0,4))); } public function getRequired($model){ $rules = $model->rules(); foreach ($rules as $key => $rule) { if( $rule[1] == "required" ){ $out = explode(",", $rule[0]); foreach ($out as $i => $val) { $out[$i] = trim($val); } return $out; } } return array(); } public function getMaxLength($model){ $rules = $model->rules(); $result = array(); foreach ($rules as $key => $rule) { if( $rule[1] == "length" ){ $tmp = explode(",", $rule[0]); foreach ($tmp as $i => $val) { $result[ trim($val) ] = $rule["max"]; } } } return $result; } public function beforeRender($view){ parent::beforeRender($view); $this->render = microtime(true); $this->debugText = "Controller ".round(microtime(true) - $this->start,4); return true; } public function afterRenderPartial(){ parent::afterRenderPartial(); $this->debugText = ($this->debugText."
View ".round(microtime(true) - $this->render,4)); } public function getParam($category,$code,$reload = false){ if( $this->settings == null || $reload ){ $this->settings = $this->getSettings(); } $category_code = mb_strtoupper($category,"UTF-8"); $param_code = mb_strtoupper($code,"UTF-8"); return ( isset($this->settings[$category_code][$param_code]) )?$this->settings[$category_code][$param_code]:""; } public function getForcedParam($category,$code){ $model = Yii::app()->db->createCommand() ->select('*') ->from(Settings::model()->tableName().' s') ->join(Category::model()->tableName().' c', 'c.id=s.category_id') ->where("c.code='$category' AND s.code='$code'") ->queryAll(); if( !$model ){ return null; }else{ return $model[0]["value"]; } } public function setParam($category,$code,$value){ $model = Settings::model()->with("category")->find("category.code='".$category."' AND t.code='".$code."'"); $model->value = $value; $this->settings[$category][$code] = $value; return $model->save(); } public function getSettings(){ $model = Category::model()->with(array("settings"=>array("select"=>array("code","value"))))->findAll(); $settings = []; foreach ($model as $category) { foreach ($category->settings as $param) { $category_code = mb_strtoupper($category->code,"UTF-8"); $param_code = mb_strtoupper($param->code,"UTF-8"); if( !isset($settings[$category_code]) ) $settings[$category_code] = array(); $settings[$category_code][$param_code] = $param->value; } } return $settings; } public function toLowerCaseModelNames($el){ if( !$el ) return false; $el->vin_name = mb_strtolower($el->vin_name, "UTF-8"); $el->rod_name = mb_strtolower($el->rod_name, "UTF-8"); return $el; } public function getRusMonth($num, $onlyMonth = false){ $rus = array( "января", "февраля", "марта", "апреля", "мая", "июня", "июля", "августа", "сентября", "октября", "ноября", "декабря", ); if( $onlyMonth ){ $rus = array( "Январь", "Февраль", "Март", "Апрель", "Май", "Июнь", "Июль", "Август", "Сентябрь", "Октябрь", "Ноябрь", "Декабрь", ); } return $rus[$num-1]; } public function getRusDate($time, $withTime = false, $withYear = true){ $tmp = explode(" ", $time); $date = explode(".", date("j.n.Y", strtotime($tmp[0]))); return $date[0]." ".Controller::getRusMonth($date[1]).(($withYear)?(" ".$date[2]." г."):"").( (isset($tmp[1]) && $withTime == true)?(" ".$tmp[1]):"" ); } public function getTime($time){ return date("G:i", strtotime($time)); } public function getSum($orders){ $sum = 0; foreach ($orders as $i => $order) { $sum += $order->sum; } return $sum; } public function getMonth($date){ $rusMonth = $this->getRusMonth( (int) date("n", strtotime($date)), true ); if( date("Y") == date("Y", strtotime($date)) ){ return $rusMonth; }else{ return $rusMonth." ".date("Y", strtotime($date)); } } public function getIds($model, $field = null){ $ids = array(); foreach ($model as $key => $value) array_push($ids, (($field !== null)?$value[$field]:$value->id) ); return $ids; } public function getAssoc($model, $field = null){ $out = array(); foreach ($model as $key => $value){ $out[(($field !== null)?$value[$field]:$value->id)] = $value; } return $out; } public function readDates(&$model){ $modelName = get_class($model); if( isset($_GET["previous"]) ){ $previousMonth = $this->getPreviousMonth(); $model->date_from = $previousMonth->from; $model->date_to = $previousMonth->to; return true; } $from = (isset($_GET[$modelName]["date_from"]))?$_GET[$modelName]["date_from"]:null; $to = (isset($_GET[$modelName]["date_to"]))?$_GET[$modelName]["date_to"]:null; if( $from === null && $to === null ){ // Проверка на наличие дат в сессии (пока не заносим туда) // #TODO# сделать запоминание дат if( isset($_SESSION[$modelName]) && isset($_SESSION[$modelName]["date"]) ){ $from = $_SESSION[$modelName]["date"]["from"]; $to = $_SESSION[$modelName]["date"]["to"]; }else{ // Дефолтные значения $from = $this->getCurrentMonthFrom(); } } $model->date_from = $from; $model->date_to = $to; } // public function removeFiles($ids = array()){ // if( !count($ids) ) return false; // $files = File::model()->findAll("id IN (".implode(", ", $ids).")"); // if( count($files) ){ // $out = array(); // foreach ($files as $key => $file) { // array_push($out, $file->original); // } // $note = Note::model()->findByPk($files[0]->note_id); // Log::add("1".$note->type_id, $note->item_id, $note->getItemName()." (".$note->item_id.")", 6, "Примечание от ".$note->date."
".implode("
", $out)); // } // File::model()->updateAll(array("note_id" => 0), "id IN (".implode(", ", $ids).")"); // } public function calculateEval($str){ $out = ""; ini_set('display_errors','Off'); eval("\$out = ".$str.";"); ini_set('display_errors','On'); return $out; } public function savePhoto($width = null, $height = null, $option = "auto"){ if( isset($_POST["uploaderPj_count"]) ){ $count = $_POST["uploaderPj_count"]; $out = array(); for( $i = 0; $i < $count; $i++ ){ $name = $_POST["uploaderPj_".$i."_tmpname"]; $original = $_POST["uploaderPj_".$i."_name"]; $status = $_POST["uploaderPj_".$i."_status"]; if( $status == "done" ){ if( $fileName = $this->saveFile($name, $width, $height, $option) ){ array_push($out, $fileName); } } } if( count($out) ){ return (count($out) > 1)?$out:$out[0]; }else{ return false; } }else{ return false; } } // public function addFiles($noteId){ // $count = $_POST["uploaderPj_count"]; // $out = array(); // for( $i = 0; $i < $count; $i++ ){ // $name = $_POST["uploaderPj_".$i."_tmpname"]; // $original = $_POST["uploaderPj_".$i."_name"]; // $status = $_POST["uploaderPj_".$i."_status"]; // if( $status == "done" ){ // if( $this->saveFile($name) ){ // $file = new File(); // $file->original = $original; // $file->name = $name; // $file->note_id = $noteId; // if( !$file->save() ){ // print_r($file->getErrors()); // }else{ // array_push($out, $file->original); // } // } // } // } // if( count($out) ){ // $note = Note::model()->findByPk($noteId); // Log::add("1".$note->type_id, $note->item_id, $note->getItemName()." (".$note->item_id.")", 5, "Примечание от ".$note->date."
".implode("
", $out)); // } // } public function saveFile($name, $width = null, $height = null, $option = "auto", $saveFolder = null, $tempFolder = null){ $arr = explode("/", $name); $name = array_pop($arr); $tempFolder = ($tempFolder === null)?Yii::app()->params['tempFolder']:$tempFolder; $saveFolder = ($saveFolder === null)?Yii::app()->params['saveFolder']:$saveFolder; $tmpFileName = $tempFolder."/".$name; $fileName = $saveFolder."/".$name; if( $width && $height ){ $resize = new Resize($tmpFileName); $resize->resizeImage($width, $height, $option); $resize->saveImage($fileName, 75); return $fileName; }else{ if( rename($tmpFileName, $fileName) ){ return $fileName; }else{ return false; } } } /** * Генерирует уникальное имя файла для PHP 7.1 * * @param string $extension Расширение файла (png|jpg|webp|txt и т.д.) * @return string */ function generateUniqueFilename($extension = '') { $extension = ltrim($extension, '.'); // убираем точку если передали ".jpg" $name = date('Ymd_His') . '_' . uniqid() . '_' . mt_rand(1000,999999); if ($extension !== '') { return $name . '.' . $extension; } return $name; } public function downloadFile($source,$filename) { if (file_exists($source)) { if (ob_get_level()) { ob_end_clean(); } $arr = explode(".", $source); header("HTTP/1.0 200 OK"); header('Content-Description: File Transfer'); header('Content-Type: application/octet-stream'); header('Content-Disposition: attachment; filename='.$filename ); header('Content-Transfer-Encoding: binary'); header('Expires: 0'); header('Cache-Control: must-revalidate'); header('Pragma: public'); header('Content-Length: ' . filesize($source)); readfile($source); exit; } } public function isCurrentMonth($model){ return $model->date_from == $this->getCurrentMonthFrom() && $model->date_to == null; } public function isPreviousMonth($model){ $previousMonth = $this->getPreviousMonth(); return $model->date_from == $previousMonth->from && $model->date_to == $previousMonth->to; } public function getCurrentMonthFrom(){ return date("d.m.Y", strtotime("first day of this month")); } public function getPreviousMonth(){ return (object) array( "from" => date("d.m.Y", strtotime("first day of previous month")), "to" => date("d.m.Y", strtotime("last day of previous month")), ); } public function accessFilter(&$filter){ if( Yii::app()->user->checkAccess('accessAll') ){ }else if( Yii::app()->user->checkAccess('accessAgency') ){ $filter->agency_id = $this->user->agency_id; }else if( Yii::app()->user->checkAccess('accessOnlyHis') ){ $filter->user_id = Yii::app()->user->id; } } public function getUser(){ return Admin::model()->with("roles.role")->findByPk(Yii::app()->user->id); } public function addDays($indate, $days){ $date = date_create($indate); @date_add($date, date_interval_create_from_date_string("$days days")); return @date_format($date, "d.m.Y"); } public function pluralForm($number, $after) { $cases = array (2, 0, 1, 1, 1, 2); return $after[ ($number%100>4 && $number%100<20)? 2: $cases[min($number%10, 5)] ]; } public function replaceToBr($str){ return str_replace("\n", "
", $str); } public function replaceToSpan($str){ return "".str_replace("
", "
", $str).""; } public function diff($model, $attrs){ $labels = $model->attributeLabels(); $relations = $model->relations(); $out = array(); foreach ($labels as $code => $name) { if( isset($model[$code]) && $model[$code] != $attrs[$code] ){ $tmp = explode("_id", $code); if( count($tmp) && isset($relations[$tmp[0]]) ){ array_push($out, $name.": с «".$model->{$tmp[0]}->name."» на «".$attrs->{$tmp[0]}->name."»"); }else{ array_push($out, $name.": с «".$model[$code]."» на «".$attrs[$code]."»"); } } } return implode("
", $out); } public function textData($model){ $labels = $model->attributeLabels(); $relations = $model->relations(); $out = array(); foreach ($labels as $code => $name) { if( isset($model[$code]) ){ $tmp = explode("_id", $code); if( empty($model->{$tmp[0]}) ) continue; if( count($tmp) && isset($relations[$tmp[0]]) ){ $value = (isset($model->{$tmp[0]}->name))?$model->{$tmp[0]}->name:$model->{$tmp[0]}->id; array_push($out, $name.": «".$value."»"); }else{ array_push($out, $name.": «".$model[$code]."»"); } } } return implode("
", $out); } public function number_format($num, $count = 0, $sep1 = ".", $sep2 = ","){ $out = number_format($num, $count, $sep1, "@"); return str_replace("@", " ", $out); } public function sendMail($subject, $attrs, $email_to){ include_once Yii::app()->basePath.'/extensions/phpmail.php'; $email_from = "robot@bf-nk.ru"; $message = ""; foreach ($attrs as $key => $value){ if( $key != "" ){ $message .= "

".$key.": ".$value."

"; }else{ $message .= "

".$value."

"; } } return send_mime_mail("БизнесФорвард «Суды»", $email_from, $name, $email_to, 'UTF-8', 'UTF-8', $subject, $message, true); } public function mb_ucfirst($text) { return mb_strtoupper(mb_substr($text, 0, 1)) . mb_substr($text, 1); } public function implodeErrors($arr){ $errors = array(); foreach ($arr as $key => $item) { array_push($errors, implode(", ", $item)); } return implode("; ", $errors); } public function cutText($str, $max_char = 255){ if( mb_strlen($str,"UTF-8") >= $max_char-3 ){ $str = mb_substr($str, 0, $max_char-3,"UTF-8")."..."; } return $str; } public function returnError( $text, $array = array() ){ $arResult = array( "success" => false, "message" => $text ); $arResult = $arResult + $array; echo json_encode($arResult); die(); } public function returnSuccess( $array = array() ){ $arResult = array( "success" => true ); $arResult = $arResult + $array; echo json_encode($arResult); die(); } public function convertPhoneNumber($str){ if (strlen($str) == 11 && in_array(substr($str, 0, 1), array("7", "8")) ) { $str = '+7 ('.substr($str, 1, 3).') '.substr($str, 4, 3).'-'.substr($str, 7, 2).'-'.substr($str, 9, 2); } return $str; } public function getPaymentLink($paymentType = "card", $userId, $packageId){ switch ($paymentType) { case 'card': return Yii::app()->params["basePath"]."cloudPayments/pay?userId={$userId}&packageId={$packageId}"; case 'sbp': return Yii::app()->params["basePath"]."cloudPayments/sbp?userId={$userId}&packageId={$packageId}"; default: return Yii::app()->params["basePath"]."cloudPayments/pay?userId={$userId}&packageId={$packageId}"; } } public function getSubscribeLink($paymentType = "card", $userId = null, $planVersionId = null) { $base = Yii::app()->params["basePath"] . "cloudPayments/subscribe"; switch ((string)$paymentType) { case "sbp": // подписки через SBP обычно не делают; оставляю на будущее return $base . "?paymentType=sbp&userId=" . (int)$userId . "&planVersionId=" . (int)$planVersionId; case "card": default: return $base . "?paymentType=card&userId=" . (int)$userId . "&planVersionId=" . (int)$planVersionId; } } public function sendMessageToTelegram($user, $text){ $telegram = new Telegram($user->telegram_id); $telegram->sendMessage( [ "parse_mode" => "HTML", "text" => $text ] ); } public function logToTelegram($message, $chatId = null){ $send_data = [ "parse_mode" => "HTML", "text" => $message ]; $tg = new Telegram( $chatId ?? Yii::app()->params["logsChatId"] ); $tg->sendMessage($send_data); } public function getSourceIdByCode($code){ if( !$this->sources ){ $this->sources = $this->getAssoc(Source::model()->findAll(), "code"); } return $this->sources[$code]->id ?? null; } public function returnXMLFile($name, $output){ header('Content-Description: File Transfer'); header('Content-Type: application/force-download'); header('Content-Type: application/octet-stream'); header('Content-Type: application/download'); header('Content-Disposition: attachment; filename=' . $name . "_" . date('d-m-y_H-i-s') . '.xml' ); header('Content-Transfer-Encoding: binary'); header('Expires: 0'); header('Cache-Control: must-revalidate'); header('Pragma: public'); header('Content-Length: ' . strlen( $output ) ); print( $output ); } public function getBackUrl($default, $id = false){ $param = ""; if(strpos($_SERVER["HTTP_REFERER"], $_SERVER["HTTP_HOST"]) === false){ if($id){ $param = (strpos($default, "?") ? "&" : "?") . "highlight_id=" . $id; } return $default.$param; }else{ if($id){ $param = (strpos($_SERVER["HTTP_REFERER"], "?") ? "&" : "?") . "highlight_id=" . $id; } return $_SERVER["HTTP_REFERER"].$param; } } public function setSortParams(&$filter){ $tableName = strtoupper($filter->tableName()); $sort = array(); if( isset($_GET["sort_field"]) ) { $sort['field'] = $_GET["sort_field"]; $sort['type'] = $_GET["sort_type"]; $this->setUserParam("SORT_".$tableName, $sort); }elseif( $userParams = $this->getUserParam("SORT_".$tableName) ) { $sort['field'] = $userParams->field; $sort['type'] = $userParams->type; }else{ $sort = $filter->sort; } $filter->sort = $sort; } public function getUserParam($code, $reload = false, $int_assoc = false){ if( $this->user_settings == null || $reload ) $this->getUserSettings(); $param_code = mb_strtoupper($code,"UTF-8"); if( isset($this->user_settings[$param_code]) ){ if( $int_assoc ){ $out = array(); foreach ($this->user_settings[$param_code] as $key => $value) $out[intval($key)] = $value; }else{ $out = $this->user_settings[$param_code]; } return $out; }else{ return null; } } public function getUserSettings(){ $out = array(); if( $this->user->settings ) foreach ($this->user->settings as $i => $param) $out[$param->code] = json_decode($param->value); $this->user_settings = $out; } // public function setUserParam($code, $value){ // $param_code = mb_strtoupper($code,"UTF-8"); // if( UserSettings::model()->count("user_id=".$this->user->id." AND code='".$param_code."'") ){ // $model = UserSettings::model()->find(array("limit"=>1,"condition"=>"user_id=".$this->user->id." AND code='".$param_code."'")); // $model->value = json_encode($value); // $model->save(); // }else{ // $this->insertValues(UserSettings::tableName(),array(array("user_id"=>$this->user->id,"code"=>$param_code,"value"=>json_encode($value)))); // } // if( is_array($this->user_settings) ) // $this->user_settings[$param_code] = $value; // } public function insertValues($tableName, $values){ if( !count($values) ) return true; $values = array_values($values); $structure = array(); foreach ($values[0] as $key => $value) { $structure[] = "`".$key."`"; } $structure = "(".implode(",", $structure).")"; $sql = "INSERT INTO `$tableName` ".$structure." VALUES "; $vals = array(); foreach ($values as $value) { $item = array(); foreach ($value as $el) { if( $el === null ){ $item[] = "null"; }else{ $item[] = "'".addslashes($el)."'"; } } $vals[] = "(".implode(",", $item).")"; } $sql .= implode(",", $vals); return Yii::app()->db->createCommand($sql)->execute(); } public function getCurrency($code = null){ if( isset($this) && isset($this->currency) ){ $currency = $this->currency; }else{ if( isset($this) && isset($this->settings) ){ $settings = $this->settings; }else{ $settings = Controller::getSettings(); } $currency = [ $settings["CURRENCY"]["1"], $settings["CURRENCY"]["2"], $settings["CURRENCY"]["5"], $settings["CURRENCY"]["MANY"] ]; } if( !empty($code) ){ return $currency[$code] ?? ""; }else{ return $currency; } } public function getRealImageLink($link){ if( strpos($link, "https:") !== false ){ return $link; }else{ return Yii::app()->params["basePath"].$link; } } // public function createQRCode($link, $name){ // $filename = Yii::app()->params['qrFolder'].'/'.$name.'.png'; // QRcode::png($link, $filename, "Q", 4 ); // /* Замена белых пикселей на прозрачный */ // $im = imagecreatefrompng($filename); // $width = imagesx($im); // $height = imagesy($im); // $bg_color = imageColorAllocate($im, 0, 0, 0); // imagecolortransparent($im, $bg_color); // for ($x = 0; $x < $width; $x++) { // for ($y = 0; $y < $height; $y++) { // $color = imagecolorat($im, $x, $y); // if ($color == 0) { // imageSetPixel($im, $x, $y, $bg_color); // } // } // } // imagepng($im, $filename); // return $filename; // } public function getImageHtml($link, $height = null){ $items = $this->getJsonArray($link); $out = ''; foreach ($items as $href) { $str = 'paid === 1) { return true; } return Order::model()->exists( "user_id = :uid AND status_id = :status", [":uid" => (int)$user->id, ":status" => Order::STATUS_PAID] ); } } function addHash($n) { return "#".$n; } ``` ===== FILE: common/components/Feature.php ===== ``` params['features'] ?? []; if (!is_array($features)) return $default; if (!array_key_exists($key, $features)) return $default; return (bool)$features[$key]; } /** * Feature::value('ai.primary', 'google') * Feature::value('ai.maxGoogleTries', 3) */ public static function value(string $path, $default = null) { $parts = explode('.', $path); $array = (array) Yii::app()->params[$parts[0]] ?? []; return $array[end($parts)] ?? $default; } } ``` ===== FILE: common/components/UserIdentity.php ===== ``` find('LOWER(login)=?',array(strtolower($this->username))); if($user===null) $this->errorCode=self::ERROR_USERNAME_INVALID; else if($user->password != md5($this->password."eduplan")) $this->errorCode=self::ERROR_PASSWORD_INVALID; else { $this->_id=$user->id; $this->username=$user->name; $this->errorCode=self::ERROR_NONE; } return $this->errorCode==self::ERROR_NONE; } /** * @return integer the ID of the user record */ public function getId() { return $this->_id; } } ``` ===== FILE: common/components/CloudPaymentsApi.php ===== ``` params['cloudpayments']) ? Yii::app()->params['cloudpayments'] : []; $this->publicId = ($publicId !== null) ? (string)$publicId : (string)($cfg['publicId'] ?? ''); $this->apiSecret = ($apiSecret !== null) ? (string)$apiSecret : (string)($cfg['apiSecret'] ?? ''); $this->baseUrl = ($baseUrl !== null) ? rtrim((string)$baseUrl, '/') : 'https://api.cloudpayments.ru'; if ($this->publicId === '' || $this->apiSecret === '') { throw new RuntimeException('CloudPayments credentials are not configured (publicId/apiSecret).'); } } /** * Создать SBP-ссылку (QrUrl) для редиректа клиента. * * @param array $payload * @return string */ public function createSbpLink(array $payload) { $resp = $this->postJson('/payments/qr/sbp/link', $payload); if (isset($resp['Success']) && $resp['Success'] === false) { $msg = isset($resp['Message']) && $resp['Message'] ? $resp['Message'] : 'CloudPayments returned Success=false'; throw new RuntimeException($msg); } $qrUrl = $resp['Model']['QrUrl'] ?? null; if (!$qrUrl) { $msg = isset($resp['Message']) && $resp['Message'] ? $resp['Message'] : 'CloudPayments: empty Model.QrUrl'; throw new RuntimeException($msg); } return (string)$qrUrl; } /** * Создать рекуррентную подписку (если вы используете API /subscriptions/create). * * ВАЖНО: Id подписки у CloudPayments на практике бывает строкой (например "sc_xxx"), * поэтому возвращаем string, а не int. * * @param array $payload * @return string CloudPayments subscription id (например "sc_...") */ public function createSubscription(array $payload) { $resp = $this->postJson('/subscriptions/create', $payload); if (isset($resp['Success']) && $resp['Success'] === true && isset($resp['Model']['Id'])) { return (string)$resp['Model']['Id']; } $msg = isset($resp['Message']) && $resp['Message'] ? $resp['Message'] : 'CloudPayments: cannot create subscription'; throw new RuntimeException($msg); } /** * Получить детали подписки (важно для NextTransactionDateIso/Status). * * @param string $subscriptionId * @return array Model (как отдаёт CloudPayments) */ public function getSubscription($subscriptionId) { $subscriptionId = (string)$subscriptionId; if ($subscriptionId === '') { throw new InvalidArgumentException('CloudPayments: subscriptionId is empty'); } $resp = $this->postJson('/subscriptions/get', ['Id' => $subscriptionId]); if (isset($resp['Success']) && $resp['Success'] === true && isset($resp['Model']) && is_array($resp['Model'])) { return $resp['Model']; } $msg = isset($resp['Message']) && $resp['Message'] ? $resp['Message'] : 'CloudPayments: cannot get subscription'; throw new RuntimeException($msg); } /** * Удобный хелпер: достать NextTransactionDate (в приоритете Iso) и вернуть в формате DB "Y-m-d H:i:s" (UTC). * * @param string $subscriptionId * @return string|null */ public function getNextChargeAtDb($subscriptionId) { $model = $this->getSubscription($subscriptionId); // CP часто отдаёт NextTransactionDateIso (ISO 8601) if (!empty($model['NextTransactionDateIso'])) { return self::isoToDbDate((string)$model['NextTransactionDateIso']); } // Иногда может быть NextTransactionDate (не ISO) if (!empty($model['NextTransactionDate'])) { // если уже в "Y-m-d H:i:s" — вернём как есть $v = trim((string)$model['NextTransactionDate']); if ($v !== '') { return $v; } } return null; } /** * Удобный хелпер: текущий статус подписки (Active/PastDue/Cancelled/Rejected/Expired и т.п.) * * @param string $subscriptionId * @return string|null */ public function getSubscriptionStatus($subscriptionId) { $model = $this->getSubscription($subscriptionId); return isset($model['Status']) ? (string)$model['Status'] : null; } /** * Отменить подписку в CloudPayments. * * ВАЖНО: Id подписки может быть строкой (sc_...), поэтому НЕ кастим в int. * * @param string $subscriptionId * @return bool */ public function cancelSubscription($subscriptionId) { $subscriptionId = (string)$subscriptionId; if ($subscriptionId === '') { throw new InvalidArgumentException('CloudPayments: subscriptionId is empty'); } $resp = $this->postJson('/subscriptions/cancel', ['Id' => $subscriptionId]); if (isset($resp['Success']) && $resp['Success'] === true) { return true; } $msg = isset($resp['Message']) && $resp['Message'] ? $resp['Message'] : 'CloudPayments: cannot cancel subscription'; throw new RuntimeException($msg); } /** * Конвертация ISO 8601 (например 2025-12-24T10:11:15Z или 2025-12-24T10:11:15) * в DB формат "Y-m-d H:i:s" (UTC). * * @param string|null $iso * @return string|null */ public static function isoToDbDate($iso) { if (!$iso) return null; $iso = trim((string)$iso); if ($iso === '') return null; try { // DateTime понимает большинство ISO вариантов $dt = new DateTime($iso); // Приведём к UTC, чтобы хранить единообразно $dt->setTimezone(new DateTimeZone('UTC')); return $dt->format('Y-m-d H:i:s'); } catch (Exception $e) { // fallback: если это "YYYY-mm-ddTHH:ii:ss" без TZ if (strlen($iso) >= 19) { $v = substr($iso, 0, 19); return str_replace('T', ' ', $v); } return null; } } /** * @param string $path * @param array $payload * @return array */ private function postJson($path, array $payload) { $url = $this->baseUrl . $path; $ch = curl_init($url); if (!$ch) { throw new RuntimeException('Failed to init curl'); } $body = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); if ($body === false) { curl_close($ch); throw new RuntimeException('CloudPayments: failed to json_encode request payload'); } curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_POSTFIELDS => $body, CURLOPT_HTTPHEADER => [ 'Content-Type: application/json', 'Accept: application/json', ], CURLOPT_USERPWD => $this->publicId . ':' . $this->apiSecret, CURLOPT_TIMEOUT => 30, CURLOPT_CONNECTTIMEOUT => 10, ]); $raw = curl_exec($ch); if ($raw === false) { $err = curl_error($ch); $no = curl_errno($ch); curl_close($ch); throw new RuntimeException("CloudPayments cURL error ({$no}): {$err}"); } $http = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); $decoded = json_decode($raw, true); if (!is_array($decoded)) { $cut = mb_substr((string)$raw, 0, 300, 'UTF-8'); throw new RuntimeException("CloudPayments non-JSON response (HTTP {$http}): {$cut}"); } // CP может вернуть ошибку как HTTP>=400, но с JSON телом if ($http >= 400) { $msg = $decoded['Message'] ?? ('HTTP ' . $http); throw new RuntimeException('CloudPayments HTTP error: ' . $msg); } return $decoded; } } ``` ===== FILE: common/components/StatsService.php ===== ``` controller->getParam("PARAMS", "KIE_COST") ?? 1.7; // 1) Заказы (оплаченные) $orders = Yii::app()->db->createCommand() ->select([ "DATE(paid_at) AS day", "COUNT(*) AS orders_count", "SUM(CAST(sum AS DECIMAL(12,2))) AS orders_sum", "AVG(CAST(sum AS DECIMAL(12,2))) AS avg_check", ]) ->from("orders") ->where("status_id = :paid AND paid_at >= :from AND paid_at < :to", [ ":paid" => Order::STATUS_PAID, ":from" => $fromDt, ":to" => $toExclusive, ]) ->group("DATE(paid_at)") ->queryAll(); // 2) Новые пользователи $users = Yii::app()->db->createCommand() ->select([ "DATE(created_at) AS day", "COUNT(*) AS new_users", ]) ->from("user") ->where("created_at >= :from AND created_at < :to", [ ":from" => $fromDt, ":to" => $toExclusive, ]) ->group("DATE(created_at)") ->queryAll(); // 3) Генерации (таски) $tasks = Yii::app()->db->createCommand() ->select([ "DATE(created_at) AS day", "COUNT(*) AS generations", ]) ->from("task") ->where("created_at >= :from AND created_at < :to", [ ":from" => $fromDt, ":to" => $toExclusive, ]) ->group("DATE(created_at)") ->queryAll(); // Собираем все дни, чтобы не терять пустые $days = $this->daysRange($from, $to); $map = []; foreach ($days as $d) { $map[$d] = [ "day" => date("d.m", strtotime($d)), "orders_sum" => 0.0, "orders_count" => 0, "avg_check" => 0.0, "new_users" => 0, "generations" => 0, ]; } foreach ($orders as $row) { $d = $row["day"]; if (!isset($map[$d])) continue; $map[$d]["orders_sum"] = (float)$row["orders_sum"]; $map[$d]["orders_count"] = (int)$row["orders_count"]; $map[$d]["avg_check"] = (float)$row["avg_check"]; } foreach ($users as $row) { $d = $row["day"]; if (!isset($map[$d])) continue; $map[$d]["new_users"] = (int)$row["new_users"]; } foreach ($tasks as $row) { $d = $row["day"]; if (!isset($map[$d])) continue; $map[$d]["generations"] = (int)$row["generations"]; $map[$d]["gen_cost"] = (float)$row["generations"]*$kieCost; $map[$d]["gen_cost_percent"] = ($map[$d]["orders_sum"] > 0)?$map[$d]["gen_cost"]/$map[$d]["orders_sum"]*100:0; $map[$d]["profit"] = $map[$d]["orders_sum"] - $map[$d]["gen_cost"]; $map[$d]["profit_percent"] = ($map[$d]["orders_sum"] > 0)?$map[$d]["profit"]/$map[$d]["orders_sum"]*100:0; } // В админке обычно удобнее видеть свежие сверху $out = array_values($map); usort($out, fn($a,$b) => strcmp($b["day"], $a["day"])); return $out; } /** * Сводка по месяцу (весь месяц). */ public function getMonthSummary(int $year, int $month): array { $from = sprintf('%04d-%02d-01', $year, $month); $to = date('Y-m-t', strtotime($from)); // последний день месяца $fromDt = $from . ' 00:00:00'; $toExclusive = date('Y-m-d', strtotime($to . ' +1 day')) . ' 00:00:00'; $orders = Yii::app()->db->createCommand() ->select([ "COUNT(*) AS orders_count", "SUM(CAST(sum AS DECIMAL(12,2))) AS orders_sum", "AVG(CAST(sum AS DECIMAL(12,2))) AS avg_check", ]) ->from("orders") ->where("status_id = :paid AND paid_at >= :from AND paid_at < :to", [ ":paid" => Order::STATUS_PAID, ":from" => $fromDt, ":to" => $toExclusive, ]) ->queryRow(); $newUsers = (int)Yii::app()->db->createCommand() ->select("COUNT(*)") ->from("user") ->where("created_at >= :from AND created_at < :to", [ ":from" => $fromDt, ":to" => $toExclusive, ]) ->queryScalar(); $generations = (int)Yii::app()->db->createCommand() ->select("COUNT(*)") ->from("task") ->where("created_at >= :from AND created_at < :to", [ ":from" => $fromDt, ":to" => $toExclusive, ]) ->queryScalar(); return [ "year" => $year, "month" => $month, "new_users" => $newUsers, "orders_sum" => (float)($orders["orders_sum"] ?? 0), "orders_count"=> (int)($orders["orders_count"] ?? 0), "avg_check" => (float)($orders["avg_check"] ?? 0), "generations" => $generations, ]; } /** * Статистика по месяцам за N месяцев назад включая текущий. */ public function getMonthlyStats(int $monthsBack = 12): array { $start = date('Y-m-01', strtotime("-" . ($monthsBack - 1) . " months")); $end = date('Y-m-01', strtotime("+1 month")); // эксклюзив // Заказы $orders = Yii::app()->db->createCommand() ->select([ "DATE_FORMAT(paid_at, '%Y-%m') AS ym", "COUNT(*) AS orders_count", "SUM(CAST(sum AS DECIMAL(12,2))) AS orders_sum", "AVG(CAST(sum AS DECIMAL(12,2))) AS avg_check", ]) ->from("orders") ->where("status_id = :paid AND paid_at >= :from AND paid_at < :to", [ ":paid" => Order::STATUS_PAID, ":from" => $start . " 00:00:00", ":to" => $end . " 00:00:00", ]) ->group("DATE_FORMAT(paid_at, '%Y-%m')") ->queryAll(); // Новые пользователи $users = Yii::app()->db->createCommand() ->select([ "DATE_FORMAT(created_at, '%Y-%m') AS ym", "COUNT(*) AS new_users", ]) ->from("user") ->where("created_at >= :from AND created_at < :to", [ ":from" => $start . " 00:00:00", ":to" => $end . " 00:00:00", ]) ->group("DATE_FORMAT(created_at, '%Y-%m')") ->queryAll(); // Генерации $tasks = Yii::app()->db->createCommand() ->select([ "DATE_FORMAT(created_at, '%Y-%m') AS ym", "COUNT(*) AS generations", ]) ->from("task") ->where("created_at >= :from AND created_at < :to", [ ":from" => $start . " 00:00:00", ":to" => $end . " 00:00:00", ]) ->group("DATE_FORMAT(created_at, '%Y-%m')") ->queryAll(); // Шаблон месяцев $months = $this->monthsRange($start, date('Y-m-01')); // до текущего месяца включительно $map = []; foreach ($months as $ym) { $map[$ym] = [ "ym" => $ym, "new_users" => 0, "orders_sum" => 0.0, "orders_count"=> 0, "avg_check" => 0.0, "generations" => 0, ]; } foreach ($orders as $row) { $ym = $row["ym"]; if (!isset($map[$ym])) continue; $map[$ym]["orders_sum"] = (float)$row["orders_sum"]; $map[$ym]["orders_count"] = (int)$row["orders_count"]; $map[$ym]["avg_check"] = (float)$row["avg_check"]; } foreach ($users as $row) { $ym = $row["ym"]; if (!isset($map[$ym])) continue; $map[$ym]["new_users"] = (int)$row["new_users"]; } foreach ($tasks as $row) { $ym = $row["ym"]; if (!isset($map[$ym])) continue; $map[$ym]["generations"] = (int)$row["generations"]; } // свежие сверху $out = array_values($map); usort($out, fn($a,$b) => strcmp($b["ym"], $a["ym"])); return $out; } private function daysRange(string $from, string $to): array { $out = []; $cur = strtotime($from); $end = strtotime($to); while ($cur <= $end) { $out[] = date('Y-m-d', $cur); $cur = strtotime('+1 day', $cur); } return $out; } private function monthsRange(string $fromMonthStart, string $toMonthStart): array { $out = []; $cur = strtotime($fromMonthStart); $end = strtotime($toMonthStart); while ($cur <= $end) { $out[] = date('Y-m', $cur); $cur = strtotime('+1 month', $cur); } return $out; } } ``` ===== FILE: common/components/RecentComments.php ===== ``` findRecentComments($this->maxComments); } protected function renderContent() { $this->render('recentComments'); } } ``` ===== FILE: common/components/Thumb.php ===== ``` dbg('WARN: unsupported ext, fallback to jpg', compact('ext')); $ext = 'jpg'; } if ($ext === 'jpeg') $ext = 'jpg'; $w = (int)$w; $h = (int)$h; if ($w <= 0 || $h <= 0) { return $this->fail('Invalid target size', compact('w', 'h', 'relPath')); } // 1) Resolve aliases $baseDir = $this->safeGetAliasPath($this->baseDirAlias, 'baseDirAlias'); if ($baseDir === null) return $this->fail('Cannot resolve baseDirAlias', compact('relPath')); $thumbBaseDir = $this->safeGetAliasPath($this->thumbDirAlias, 'thumbDirAlias'); if ($thumbBaseDir === null) return $this->fail('Cannot resolve thumbDirAlias', compact('relPath')); // 2) Source path $srcPath = rtrim($baseDir, '/\\') . DIRECTORY_SEPARATOR . ltrim($relPath, '/\\'); $this->dbg('Resolved paths', array( 'baseDirAlias' => $this->baseDirAlias, 'baseDir' => $baseDir, 'thumbDirAlias' => $this->thumbDirAlias, 'thumbBaseDir' => $thumbBaseDir, 'relPath' => $relPath, 'srcPath' => $srcPath, )); // 3) Basic source checks if (!$this->checkOpenBasedir($srcPath)) { return $this->fail('open_basedir restriction for source path', array('srcPath' => $srcPath, 'open_basedir' => ini_get('open_basedir'))); } if (!is_file($srcPath)) { return $this->fail('Source file not found', array('srcPath' => $srcPath)); } if (!is_readable($srcPath)) { return $this->fail('Source file not readable', array('srcPath' => $srcPath)); } $mtime = @filemtime($srcPath); if ($mtime === false) { return $this->fail('Cannot get filemtime for source', array('srcPath' => $srcPath)); } // 4) Destination path $key = sha1($relPath . "|$w|$h|$mode|$mtime"); $subDir = $w . 'x' . $h . '_' . $mode; $dstDir = rtrim($thumbBaseDir, '/\\') . DIRECTORY_SEPARATOR . $subDir; if (!$this->checkOpenBasedir($dstDir)) { return $this->fail('open_basedir restriction for destination dir', array('dstDir' => $dstDir, 'open_basedir' => ini_get('open_basedir'))); } if (!is_dir($dstDir)) { $mkOk = @mkdir($dstDir, 0775, true); $this->dbg('mkdir dstDir', array('dstDir' => $dstDir, 'ok' => $mkOk, 'last_error' => $this->lastPhpError())); if (!$mkOk && !is_dir($dstDir)) { return $this->fail('Cannot create destination directory', array('dstDir' => $dstDir)); } } if (!is_writable($dstDir)) { // иногда is_writable врёт на NFS, но всё равно полезно залогировать $this->dbg('WARN: dstDir is not writable by is_writable()', array('dstDir' => $dstDir)); } $dstPath = $dstDir . DIRECTORY_SEPARATOR . $key . '.' . $ext; // 5) Cache hit if (is_file($dstPath) && filesize($dstPath) > 0) { $url = rtrim($this->thumbUrl, '/') . '/' . $subDir . '/' . basename($dstPath); $this->dbg('Cache hit', array('dstPath' => $dstPath, 'url' => $url, 'ms' => (int)((microtime(true) - $t0) * 1000))); return $url; } // 6) Generate $ok = $this->generateLocked($srcPath, $dstPath, $w, $h, $mode, $ext); // 7) Verify result if (!$ok || !is_file($dstPath) || filesize($dstPath) <= 0) { $this->dbg('Generation failed or output missing', array( 'ok' => $ok, 'dstPath' => $dstPath, 'exists' => is_file($dstPath), 'size' => is_file($dstPath) ? filesize($dstPath) : null, 'dstDirPerms' => @substr(sprintf('%o', @fileperms($dstDir)), -4), 'dstPerms' => is_file($dstPath) ? @substr(sprintf('%o', @fileperms($dstPath)), -4) : null, 'last_error' => $this->lastPhpError(), )); return $this->fail('Thumbnail generation failed', array('srcPath' => $srcPath, 'dstPath' => $dstPath)); } $url = rtrim($this->thumbUrl, '/') . '/' . $subDir . '/' . basename($dstPath); $this->dbg('Generated OK', array('dstPath' => $dstPath, 'url' => $url, 'ms' => (int)((microtime(true) - $t0) * 1000))); return $url; } private function generateLocked($srcPath, $dstPath, $w, $h, $mode, $ext) { $lock = $dstPath . '.lock'; $fp = @fopen($lock, 'c'); if (!$fp) { $this->dbg('WARN: cannot open lock file (continuing without lock)', array('lock' => $lock, 'last_error' => $this->lastPhpError())); } else { $locked = @flock($fp, LOCK_EX); $this->dbg('Lock acquired', array('lock' => $lock, 'locked' => $locked)); } // пока ждали lock — файл мог появиться if (is_file($dstPath) && filesize($dstPath) > 0) { if ($fp) { @flock($fp, LOCK_UN); @fclose($fp); } @unlink($lock); return true; } $ok = $this->resizeGd($srcPath, $dstPath, $w, $h, $mode, $ext); if ($fp) { @flock($fp, LOCK_UN); @fclose($fp); } @unlink($lock); return $ok; } private function resizeGd($srcPath, $dstPath, $w, $h, $mode, $ext) { // GD checks if (!extension_loaded('gd')) { $this->fail('GD extension not loaded', array('srcPath' => $srcPath)); return false; } if (!function_exists('getimagesize')) { $this->fail('getimagesize() is not available', array()); return false; } $info = @getimagesize($srcPath); if ($info === false) { $this->fail('getimagesize() failed', array('srcPath' => $srcPath, 'last_error' => $this->lastPhpError())); return false; } $srcW = (int)$info[0]; $srcH = (int)$info[1]; $type = (int)$info[2]; if ($srcW <= 0 || $srcH <= 0) { $this->fail('Invalid source dimensions', array('srcW' => $srcW, 'srcH' => $srcH, 'srcPath' => $srcPath)); return false; } $this->dbg('Source image info', array( 'srcPath' => $srcPath, 'srcW' => $srcW, 'srcH' => $srcH, 'type' => $type, 'mime' => isset($info['mime']) ? $info['mime'] : null, )); // load source $src = null; switch ($type) { case IMAGETYPE_JPEG: if (!function_exists('imagecreatefromjpeg')) return $this->failBool('imagecreatefromjpeg() not available'); $src = @imagecreatefromjpeg($srcPath); break; case IMAGETYPE_PNG: if (!function_exists('imagecreatefrompng')) return $this->failBool('imagecreatefrompng() not available'); $src = @imagecreatefrompng($srcPath); break; case IMAGETYPE_GIF: if (!function_exists('imagecreatefromgif')) return $this->failBool('imagecreatefromgif() not available'); $src = @imagecreatefromgif($srcPath); break; default: $this->fail('Unsupported image type', array('type' => $type, 'srcPath' => $srcPath)); return false; } if (!$src) { $this->fail('Failed to create image resource from source', array('srcPath' => $srcPath, 'type' => $type, 'last_error' => $this->lastPhpError())); return false; } // prevent huge memory issues: rough estimate $bytesPerPixel = 4; // truecolor $needBytes = $srcW * $srcH * $bytesPerPixel; $this->dbg('Memory estimate', array( 'needBytes' => $needBytes, 'memory_limit' => ini_get('memory_limit'), )); $dst = null; if ($mode === 'crop') { $scale = max($w / $srcW, $h / $srcH); $tmpW = (int)ceil($srcW * $scale); $tmpH = (int)ceil($srcH * $scale); $tmp = @imagecreatetruecolor($tmpW, $tmpH); if (!$tmp) { imagedestroy($src); $this->fail('imagecreatetruecolor() failed for tmp', array('tmpW' => $tmpW, 'tmpH' => $tmpH)); return false; } $this->preserveAlpha($tmp, $type); $ok1 = @imagecopyresampled($tmp, $src, 0, 0, 0, 0, $tmpW, $tmpH, $srcW, $srcH); if (!$ok1) { imagedestroy($tmp); imagedestroy($src); $this->fail('imagecopyresampled() failed (tmp)', array('srcPath' => $srcPath)); return false; } $dst = @imagecreatetruecolor($w, $h); if (!$dst) { imagedestroy($tmp); imagedestroy($src); $this->fail('imagecreatetruecolor() failed for dst', array('w' => $w, 'h' => $h)); return false; } $this->preserveAlpha($dst, $type); $x = (int)(($tmpW - $w) / 2); $y = (int)(($tmpH - $h) / 2); $ok2 = @imagecopy($dst, $tmp, 0, 0, $x, $y, $w, $h); imagedestroy($tmp); if (!$ok2) { imagedestroy($dst); imagedestroy($src); $this->fail('imagecopy() failed (crop)', array('x' => $x, 'y' => $y)); return false; } } else { // fit $scale = min($w / $srcW, $h / $srcH); $dstW = max(1, (int)floor($srcW * $scale)); $dstH = max(1, (int)floor($srcH * $scale)); $dst = @imagecreatetruecolor($dstW, $dstH); if (!$dst) { imagedestroy($src); $this->fail('imagecreatetruecolor() failed for dst', array('dstW' => $dstW, 'dstH' => $dstH)); return false; } $this->preserveAlpha($dst, $type); $ok = @imagecopyresampled($dst, $src, 0, 0, 0, 0, $dstW, $dstH, $srcW, $srcH); if (!$ok) { imagedestroy($dst); imagedestroy($src); $this->fail('imagecopyresampled() failed (fit)', array('srcPath' => $srcPath)); return false; } } // ensure dst dir exists & writable $dstDir = dirname($dstPath); if (!is_dir($dstDir)) { $mkOk = @mkdir($dstDir, 0775, true); $this->dbg('mkdir dstDir (late)', array('dstDir' => $dstDir, 'ok' => $mkOk, 'last_error' => $this->lastPhpError())); } // save $saved = false; if ($ext === 'png') { if (!function_exists('imagepng')) { imagedestroy($dst); imagedestroy($src); $this->fail('imagepng() not available', array()); return false; } $saved = @imagepng($dst, $dstPath, 7); } else { if (!function_exists('imagejpeg')) { imagedestroy($dst); imagedestroy($src); $this->fail('imagejpeg() not available', array()); return false; } $saved = @imagejpeg($dst, $dstPath, (int)$this->qualityJpeg); } $this->dbg('Save result', array( 'dstPath' => $dstPath, 'saved' => $saved, 'existsAfter' => is_file($dstPath), 'sizeAfter' => is_file($dstPath) ? filesize($dstPath) : null, 'last_error' => $this->lastPhpError(), )); imagedestroy($dst); imagedestroy($src); if (!$saved) { $this->fail('Failed to write output file', array('dstPath' => $dstPath)); return false; } // иногда imagejpeg вернул true, но файл не появился (права/FS) if (!is_file($dstPath) || filesize($dstPath) <= 0) { $this->fail('Output file missing/empty after save', array('dstPath' => $dstPath)); return false; } // выставим права на всякий @chmod($dstPath, 0664); return true; } private function preserveAlpha($img, $type) { if ($type === IMAGETYPE_PNG || $type === IMAGETYPE_GIF) { imagealphablending($img, false); imagesavealpha($img, true); $transparent = imagecolorallocatealpha($img, 0, 0, 0, 127); imagefilledrectangle($img, 0, 0, imagesx($img), imagesy($img), $transparent); } } /** Возвращает путь алиаса или null + лог */ private function safeGetAliasPath($alias, $label) { try { $path = Yii::getPathOfAlias($alias); } catch (Exception $e) { $this->fail("Alias resolution exception for {$label}", array('alias' => $alias, 'exception' => $e->getMessage())); return null; } if (!$path || !is_string($path)) { $this->fail("Alias not found for {$label}", array('alias' => $alias, 'resolved' => $path)); return null; } // Иногда alias резолвится, но путь не существует после переезда if (!file_exists($path)) { $this->dbg("WARN: alias path does not exist for {$label}", array('alias' => $alias, 'path' => $path)); // не валим сразу — иногда это виртуальный путь/монтирование, но чаще проблема } return $path; } /** Проверка open_basedir: логируем, если путь запрещён */ private function checkOpenBasedir($path) { $ob = ini_get('open_basedir'); if (!$ob) return true; // open_basedir может быть списком путей $allowed = preg_split('/[:;]/', $ob); $pathReal = @realpath($path); // если путь не существует — проверим его родителя if ($pathReal === false) { $pathReal = @realpath(dirname($path)); } if ($pathReal === false) { // не можем проверить наверняка, но хотя бы лог $this->dbg('WARN: realpath failed for open_basedir check', array('path' => $path, 'open_basedir' => $ob)); return true; } foreach ($allowed as $a) { $a = trim($a); if ($a === '') continue; $aReal = @realpath($a); if ($aReal && strpos($pathReal, $aReal) === 0) { return true; } } $this->dbg('open_basedir blocks path', array('path' => $path, 'pathReal' => $pathReal, 'open_basedir' => $ob)); return false; } /** Лог */ private function dbg($message, array $context = array()) { if (!$this->debug) return; $payload = $message; if (!empty($context)) { // аккуратно, без огромных дампов $payload .= ' | ' . str_replace(array("\n", "\r"), ' ', var_export($context, true)); } Yii::log($payload, CLogger::LEVEL_INFO, $this->debugLogCategory); } /** Ошибка: лог + optional exception + return ''/null */ private function fail($message, array $context = array()) { $payload = $message; if (!empty($context)) { $payload .= ' | ' . str_replace(array("\n", "\r"), ' ', var_export($context, true)); } Yii::log($payload, CLogger::LEVEL_ERROR, $this->debugLogCategory); if ($this->throwOnError) { throw new CException($message . (!empty($context) ? (' | ' . json_encode($context)) : '')); } return $this->returnNullOnError ? null : ''; } /** Для внутренних bool-return мест */ private function failBool($message, array $context = array()) { $this->fail($message, $context); return false; } /** Последняя PHP ошибка (если была) */ private function lastPhpError() { $e = error_get_last(); if (!$e) return null; return array( 'type' => $e['type'], 'message' => $e['message'], 'file' => $e['file'], 'line' => $e['line'], ); } } ``` ===== FILE: common/components/telegram/UpdateDto.php ===== ``` tg = new Telegram($chatId); } /** * Унифицированная отправка: * - если есть photo => sendPhoto * - иначе sendMessage */ public function send(array $payload) { if (isset($payload['photo'])) { return $this->tg->sendPhoto($payload); } return $this->tg->sendMessage($payload); } public function deleteMessage($messageId) { return $this->tg->deleteMessage($messageId); } public function downloadTelegramFile($fileId, $dir) { return $this->tg->downloadTelegramFile($fileId, $dir); } } ``` ===== FILE: common/components/telegram/UpdateParser.php ===== ``` raw = $data; $dto->updateId = $data['update_id'] ?? null; // 1) callback_query if (!empty($data['callback_query'])) { $cb = $data['callback_query']; $dto->payload = $cb; $from = $cb['from'] ?? []; $dto->telegramUserId = $from['id'] ?? null; $dto->fromFirstName = $from['first_name'] ?? null; $dto->fromUsername = $from['username'] ?? null; $dto->languageCode = $from['language_code'] ?? null; $dto->type = 'callback'; $dto->callbackData = (string)($cb['data'] ?? ''); $dto->rawText = $dto->callbackData; $dto->text = mb_strtolower(trim($dto->rawText), 'utf-8'); // иногда полезно иметь messageId/chatId $msg = $cb['message'] ?? []; $dto->messageId = $msg['message_id'] ?? null; $dto->chatId = $msg['chat']['id'] ?? null; // ВАЖНО: кнопки приходят в callback_query.message, // а контекст фото мы получаем через callback_query.message.reply_to_message if (!empty($msg['reply_to_message']) && is_array($msg['reply_to_message'])) { $dto->replyTo = $this->parseMessageOnly($msg['reply_to_message']); } // replyTo для callback обычно не нужен return $dto; } // 2) message $msg = $data['message'] ?? []; $dto->payload = $msg; $from = $msg['from'] ?? []; $dto->telegramUserId = $from['id'] ?? null; $dto->fromFirstName = $from['first_name'] ?? null; $dto->fromUsername = $from['username'] ?? null; $dto->languageCode = $from['language_code'] ?? null; $dto->messageId = $msg['message_id'] ?? null; $dto->chatId = $msg['chat']['id'] ?? null; // web_app_data (если вдруг используешь) if (!empty($msg['web_app_data']) && is_array($msg['web_app_data'])) { $dto->type = 'web_app'; $dto->rawText = (string)($msg['web_app_data']['data'] ?? ''); $dto->text = mb_strtolower(trim($dto->rawText), 'utf-8'); } // photo if (!empty($msg['photo']) && is_array($msg['photo'])) { $dto->type = 'photo'; $dto->photoSizes = $msg['photo']; foreach ($msg['photo'] as $p) { if (!empty($p['file_id'])) { $dto->photoFileIds[] = $p['file_id']; } } $largest = end($msg['photo']); if (is_array($largest) && !empty($largest['file_id'])) { $dto->largestPhotoFileId = $largest['file_id']; } // caption как rawText (это важно для “фото + caption” сценариев) if (!empty($msg['caption'])) { $dto->rawText = (string)$msg['caption']; $dto->text = mb_strtolower(trim($dto->rawText), 'utf-8'); } } // text/caption fallback if (empty($dto->type)) { if (!empty($msg['text'])) { $dto->type = 'text'; $dto->rawText = (string)$msg['text']; $dto->text = mb_strtolower(trim($dto->rawText), 'utf-8'); } elseif (!empty($msg['caption'])) { $dto->type = 'caption'; $dto->rawText = (string)$msg['caption']; $dto->text = mb_strtolower(trim($dto->rawText), 'utf-8'); } else { $dto->type = 'unknown'; $dto->rawText = ''; $dto->text = ''; } } // ✅ ВАЖНО: reply_to_message парсим в отдельный UpdateDto if (!empty($msg['reply_to_message']) && is_array($msg['reply_to_message'])) { $dto->replyTo = $this->parseMessageOnly($msg['reply_to_message']); } return $dto; } /** * Парсим только вложенное сообщение (reply_to_message), без update_id и без callback_query. */ private function parseMessageOnly(array $msg): UpdateDto { $dto = new UpdateDto(); $dto->payload = $msg; $dto->messageId = $msg['message_id'] ?? null; $dto->chatId = $msg['chat']['id'] ?? null; // photo in reply if (!empty($msg['photo']) && is_array($msg['photo'])) { $dto->type = 'photo'; $dto->photoSizes = $msg['photo']; foreach ($msg['photo'] as $p) { if (!empty($p['file_id'])) { $dto->photoFileIds[] = $p['file_id']; } } $largest = end($msg['photo']); if (is_array($largest) && !empty($largest['file_id'])) { $dto->largestPhotoFileId = $largest['file_id']; } if (!empty($msg['caption'])) { $dto->rawText = (string)$msg['caption']; $dto->text = mb_strtolower(trim($dto->rawText), 'utf-8'); } return $dto; } // text/caption in reply if (!empty($msg['text'])) { $dto->type = 'text'; $dto->rawText = (string)$msg['text']; $dto->text = mb_strtolower(trim($dto->rawText), 'utf-8'); return $dto; } if (!empty($msg['caption'])) { $dto->type = 'caption'; $dto->rawText = (string)$msg['caption']; $dto->text = mb_strtolower(trim($dto->rawText), 'utf-8'); return $dto; } $dto->type = 'unknown'; $dto->rawText = ''; $dto->text = ''; return $dto; } } ``` ===== FILE: common/components/bot/IBotHandler.php ===== ``` handlers = $handlers; } public function handle(UpdateDto $dto, User $user): ?array { foreach ($this->handlers as $h) { if ($h instanceof IBotHandler && $h->supports($dto, $user)) { return $h->handle($dto, $user); } } // fallback — просто главное меню $ui = BotUiFactory::fromApp($user); return $ui->default(); } } ``` ===== FILE: common/components/bot/handlers/ChoosePlanHandler.php ===== ``` text) && $dto->text === "/choose_plan"; } public function handle(UpdateDto $dto, User $user): ?array { return BotUiFactory::fromApp($user)->choosePlan(); } } ``` ===== FILE: common/components/bot/handlers/BackHandler.php ===== ``` text)) return false; return ($dto->text === "⏪ назад" || $dto->text === "/default" || $dto->text === "оставить фото как было"); } public function handle(UpdateDto $dto, User $user): ?array { $user->setWaitingPhoto(false); $user->setWaitingPhotoFull(false); $user->setWaitingPrompt(false); PhotoEditPendingRuntime::clear((int)$user->telegram_id); $ui = BotUiFactory::fromApp($user); return $ui->default(); } } ``` ===== FILE: common/components/bot/handlers/ChoosePackageHandler.php ===== ``` text) && $dto->text === "/choose_package"; } public function handle(UpdateDto $dto, User $user): ?array { return BotUiFactory::fromApp($user)->choosePackage(); } } ``` ===== FILE: common/components/bot/handlers/CancelSubscriptionHandler.php ===== ``` text) && preg_match('~^/cancel_subscription_(\d+)$~', $dto->text); } public function handle(UpdateDto $dto, User $user): ?array { if (!preg_match('~^/cancel_subscription_(\d+)$~', $dto->text, $m)) { return BotUiFactory::fromApp($user)->balance(); } $subId = (int)$m[1]; /** @var UserSubscription $sub */ $sub = UserSubscription::model()->findByPk($subId); if (!$sub || (int)$sub->user_id !== (int)$user->id) { return BotUiFactory::fromApp($user)->balance(); } // Если уже отменена — просто покажем баланс if ($sub->status === 'Cancelled' || !empty($sub->canceled_at)) { return BotUiFactory::fromApp($user)->balance(); } return BotUiFactory::fromApp($user)->cancelSubscriptionWarning($sub); } } ``` ===== FILE: common/components/bot/handlers/CustomPromptHandler.php ===== ``` text)) return false; return in_array($dto->text, [ "/custom_prompt", "фото по описанию", ], true); } public function handle(UpdateDto $dto, User $user): ?array { $controller = Yii::app()->controller; $currency = (is_object($controller) && isset($controller->currency) && is_array($controller->currency)) ? $controller->currency : []; $mg = new MessageGenerator($user); $user->setWaitingPrompt(true); $ui = BotUiFactory::fromApp($user); return [ "parse_mode" => "HTML", "text" => $mg->customPromptMessage(), "reply_markup" => $ui->backKeyboard(), ]; } } ``` ===== FILE: common/components/bot/handlers/TryOnConfirmHandler.php ===== ``` extractCommand($dto); if ($command === '') return false; return strpos($command, '/tryon_confirm_') === 0 || $command === '/tryon_confirm_file'; } public function handle(UpdateDto $dto, User $user): ?array { $command = $this->extractCommand($dto); $tg = new TelegramGateway((int)$user->telegram_id); $tryon = new TryOnDeepLinkService(); // confirm by deeplink id if (strpos($command, '/tryon_confirm_') === 0) { $id = (int)str_replace('/tryon_confirm_', '', $command); if ($id <= 0) { return BotUiFactory::fromApp($user)->default(); } if (empty($user->photo)) { $user->setWaitingPhoto(true); $user->setWaitingPrompt(false); return BotUiFactory::fromApp($user)->changePhoto(false); } $tryon->runTryOnFromDeeplink($user, $tg, $id, $dto->updateId, null); return null; } // confirm by pending file id $pending = TryOnPendingRuntime::load((int)$user->telegram_id); $fileId = (string)($pending['tg_product_file_id'] ?? ''); if ($fileId === '') { return BotUiFactory::fromApp($user)->default(); } if (empty($user->photo)) { $user->setWaitingPhoto(true); $user->setWaitingPrompt(false); return BotUiFactory::fromApp($user)->changePhoto(); } TryOnPendingRuntime::clear((int)$user->telegram_id); $tryon->runTryOnFromTelegramProductFileId($user, $tg, $fileId, $dto->updateId, null); return null; } private function extractCommand(UpdateDto $dto): string { $cmd = trim((string)($dto->callbackData ?: $dto->rawText ?: $dto->text ?: '')); return mb_strtolower($cmd, 'utf-8'); } } ``` ===== FILE: common/components/bot/handlers/BalanceHandler.php ===== ``` text) && $dto->text === "/balance"; } public function handle(UpdateDto $dto, User $user): ?array { return BotUiFactory::fromApp($user)->balance(); } } ``` ===== FILE: common/components/bot/handlers/SessionGenerateHandler.php ===== ``` text)) return false; return (strpos($dto->text, "/sessiongenerate_") === 0); } public function handle(UpdateDto $dto, User $user): ?array { // если нет фото — просим загрузить if (empty($user->photo)) { $ui = BotUiFactory::fromApp($user); return $ui->changePhoto(); } if (!preg_match('~^/sessiongenerate_(\d+)~', $dto->text, $m)) { return BotUiFactory::fromApp($user)->default(); } $sessionId = (int)$m[1]; /** @var Controller $controller */ $controller = Yii::app()->controller; $service = new GenerationService($controller); $service->generateSessionForUser($user, $sessionId, $dto->updateId); // мы сами всё отправили (loading + результат/async), контроллеру слать нечего return null; } } ``` ===== FILE: common/components/bot/handlers/WaitingPromptHandler.php ===== ``` isWaitingPrompt()) return false; if (empty($dto->rawText)) return false; $t = trim((string)$dto->text); if ($t === "" || $t === "⏪ назад") return false; if (strpos($t, "/") === 0) return false; return true; } public function handle(UpdateDto $dto, User $user): ?array { $user->setWaitingPrompt(false); // 1) Если это ожидание промпта для EDIT — берём pending file_id и редактируем его $pending = PhotoEditPendingRuntime::load((int)$user->telegram_id); if (is_array($pending) && !empty($pending['tg_file_id'])) { PhotoEditPendingRuntime::clear((int)$user->telegram_id); $tg = new TelegramGateway((int)$user->telegram_id); $svc = new PhotoEditService(); $svc->runFromTelegramFileId( $user, $tg, (string)$pending['tg_file_id'], (string)$dto->rawText, $dto->updateId, null ); return null; } // 2) Иначе — это обычный custom prompt (Фото по описанию) по сохранённому user->photo if (empty($user->photo)) { $ui = BotUiFactory::fromApp($user); return $ui->changePhoto(); } $service = new GenerationService(Yii::app()->controller); $service->generatePromptForUser( $user, (string)$dto->rawText, [$user->photo, $user->photo_full], "3:4", null, $dto->updateId, null ); return null; } } ``` ===== FILE: common/components/bot/handlers/SupportHandler.php ===== ``` text) && $dto->text === "/support"; } public function handle(UpdateDto $dto, User $user): ?array { return BotUiFactory::fromApp($user)->support(); } } ``` ===== FILE: common/components/bot/handlers/BuyPlanHandler.php ===== ``` text)) return false; return (strpos($dto->text, "/buyplan_") === 0); } public function handle(UpdateDto $dto, User $user): ?array { $t = (string)$dto->text; $id = (int)str_replace("/buyplan_", "", $t); if ($id <= 0) { return BotUiFactory::fromApp($user)->error(); } $planVersion = PlanVersion::model()->with("plan")->findByPk($id); if (!$planVersion) { return BotUiFactory::fromApp($user)->error(); } return BotUiFactory::fromApp($user)->planInfo($planVersion, (int)$user->id); } } ``` ===== FILE: common/components/bot/handlers/ConfirmCancelSubscriptionHandler.php ===== ``` extractCommand($dto); return $cmd !== '' && strpos($cmd, self::PREFIX) === 0; } public function handle(UpdateDto $dto, User $user): ?array { $cmd = $this->extractCommand($dto); $subIdStr = trim(substr($cmd, strlen(self::PREFIX))); $subId = (int)$subIdStr; if ($subId <= 0) { // некорректный callback — просто покажем баланс/меню return BotUiFactory::fromApp($user)->balance(); } try { // основная логика отмены — уже в сервисе SubscriptionsService::cancelUserSubscription((int)$user->id, $subId, 'user'); } catch (Throwable $e) { Yii::log( 'ConfirmCancelSubscriptionHandler: cancel failed: ' . $e->getMessage(), CLogger::LEVEL_ERROR ); return [ 'text' => "Не удалось отменить подписку 😔\nПопробуй ещё раз позже или напиши в поддержку.", ]; } // ВАЖНО: у User есть кеш activeSubscription; чтобы UI не показал старое, // берём свежую модель пользователя из БД. $freshUser = User::model()->findByPk((int)$user->id) ?: $user; // Сообщаем пользователю “Подписка отменена…” return BotUiFactory::fromApp($freshUser)->subscriptionCancelling(); } private function extractCommand(UpdateDto $dto): string { // Для inline callback — берём callbackData, иначе обычный текст $command = (string)($dto->callbackData ?: ($dto->text ?: '')); return trim($command); } } ``` ===== FILE: common/components/bot/handlers/PoliticsHandler.php ===== ``` text) && $dto->text === "/politics"; } public function handle(UpdateDto $dto, User $user): ?array { // просто отдаем текст политики return BotUiFactory::fromApp($user)->politics(); } } ``` ===== FILE: common/components/bot/handlers/InviteHandler.php ===== ``` text) && $dto->text === "/invite"; } public function handle(UpdateDto $dto, User $user): ?array { return BotUiFactory::fromApp($user)->invite(); } } ``` ===== FILE: common/components/bot/handlers/ReplyImageEditHandler.php ===== ``` isWaitingPrompt()) return false; // нужен текст пользователя if (empty($dto->rawText)) return false; $t = trim((string)$dto->text); if ($t === "" || $t === "⏪ назад") return false; if (strpos($t, "/") === 0) return false; // команды не считаем промптом // и нужно, чтобы это был ответ на сообщение с фото if (empty($dto->replyTo) || !($dto->replyTo instanceof UpdateDto)) return false; $hasPhoto = !empty($dto->replyTo->largestPhotoFileId) || !empty($dto->replyTo->photoFileIds); if (!$hasPhoto) return false; return true; } public function handle(UpdateDto $dto, User $user): ?array { $fileId = $dto->replyTo->largestPhotoFileId; if (empty($fileId) && !empty($dto->replyTo->photoFileIds)) { $fileId = end($dto->replyTo->photoFileIds); } if (empty($fileId)) { return BotUiFactory::fromApp($user)->default(); } $tg = new TelegramGateway((int)$user->telegram_id); $svc = new PhotoEditService(); $svc->runFromTelegramFileId( $user, $tg, (string)$fileId, (string)$dto->rawText, $dto->updateId, null ); return null; } } ``` ===== FILE: common/components/bot/handlers/RandomSessionHandler.php ===== ``` text)) return false; return ($dto->text === "/random" || $dto->text === "случайное фото 🎲"); } public function handle(UpdateDto $dto, User $user): ?array { if (empty($user->photo)) { $ui = BotUiFactory::fromApp($user); return $ui->changePhoto(); } $prompts = Prompt::model()->findAll(); if (!$prompts) { return BotUiFactory::fromApp($user)->default(); } $random = $prompts[array_rand($prompts)]; $service = new GenerationService(Yii::app()->controller); $service->generateSessionForUser($user, (int)$random->id, $dto->updateId); return null; } } ``` ===== FILE: common/components/bot/handlers/PhotoUploadHandler.php ===== ``` photoFileIds) || $user->isWaitingAnyPhoto(); } public function handle(UpdateDto $dto, User $user): ?array { $tg = new TelegramGateway($user->telegram_id); $ui = BotUiFactory::fromApp($user); if( empty($dto->photoFileIds) ){ if( $user->isWaitingPhoto() ){ return BotUiFactory::fromApp($user)->changePhoto(); }else{ return $ui->requestPhotoFull( empty($user->photo_full) ); } } $largestFileId = $dto->largestPhotoFileId ?: end($dto->photoFileIds); if( $user->isWaitingPhoto() ){ $user->setWaitingPhoto(false); $user->setWaitingPhotoFull(true); // сохраняем фото пользователя (внутри User уже есть downloadAndUpdatePhoto) if (!empty($largestFileId)) { $user->downloadAndUpdatePhoto($largestFileId, "photo"); } $tg->send( $ui->requestPhotoFull( empty($user->photo_full) ) ); return null; } // 1) Если юзер ЖДЁТ фото — это смена/первичная установка фото if ($user->isWaitingPhotoFull()) { $wasEmptyPhoto = empty($user->photo_full); // сбрасываем флаг ожидания сразу $user->setWaitingPhotoFull(false); // если юзер впервые прислал фото — покажем "генерирую..." и потом сделаем free session 1 $loadingMessageId = null; if ($wasEmptyPhoto) { $res = $tg->send($ui->generating()); if (is_array($res) && !empty($res["ok"]) && !empty($res["result"]["message_id"])) { $loadingMessageId = (int)$res["result"]["message_id"]; } } // сохраняем фото пользователя (внутри User уже есть downloadAndUpdatePhoto) if (!empty($largestFileId)) { $user->downloadAndUpdatePhoto($largestFileId, "photo_full"); } // если есть pending tryon — запускаем примерку вместо автосессии $tryon = new TryOnDeepLinkService(); if ($tryon->handleAfterPhotoUpload($user, $tg, $dto->updateId, $loadingMessageId)) { // deepLink сам всё отправит (loading + результат/async) return null; } // NEW USER: автогенерация первой фотосессии (как у тебя было: sessionId=4, free=true) if ($wasEmptyPhoto) { $service = new GenerationService(Yii::app()->controller); $service->generateSessionForUser($user, Yii::app()->controller->getParam("PARAMS", "FREE_SESSION_ID"), $dto->updateId, $loadingMessageId); // и отправляем клавиатуру/сообщение // $tg->send($ui->sendKeyboard(true)); return null; } // обычная смена фото $tg->send($ui->sendKeyboard(false)); return null; } // 2) Если фото пришло, но юзер НЕ ждал фото — это либо edit по подписи, либо "что делать?" $message = trim((string)$dto->rawText); if ($message !== '') { $svc = new PhotoEditService(); $svc->runFromTelegramFileId( $user, $tg, (string)$largestFileId, (string)$message, $dto->updateId, null ); return null; } if( empty($user->photo_full) ){ // подписи нет и фото не заполнено — говорим, что сейчас будем примерять образ TryOnPendingRuntime::save((int)$user->telegram_id, [ 'tg_product_file_id' => (string)$largestFileId, ], 3600); $user->setWaitingPhoto(true); $user->setWaitingPrompt(false); return $ui->changePhoto(); // $user->setWaitingPhoto(true); // $res = $ui->changePhoto(); // $res = $ui->firstTryonStart(); }else{ // подписи нет и все фотки заполнены — говорим “что делать с фото” $res = $ui->whatToDoWithPhoto(); } // чтобы callback потом видел photo через reply_to_message if (!empty($dto->messageId)) { $res['reply_to_message_id'] = (int)$dto->messageId; } return $res; } } ``` ===== FILE: common/components/bot/handlers/OfertaHandler.php ===== ``` text) && $dto->text === "/oferta"; } public function handle(UpdateDto $dto, User $user): ?array { return BotUiFactory::fromApp($user)->oferta(); } } ``` ===== FILE: common/components/bot/handlers/ChangePhotoHandler.php ===== ``` text)) return false; return in_array($dto->text, [ "/change_photo", "сменить фото 👤", "change_photo", "изменить фото 👤", ], true); } public function handle(UpdateDto $dto, User $user): ?array { // ВАЖНО: включаем режим ожидания фото $user->setWaitingPhoto(true); // На всякий: если юзер был в ожидании промпта — сбросим, чтобы не конфликтовало $user->setWaitingPrompt(false); return BotUiFactory::fromApp($user)->changePhoto(true); } } ``` ===== FILE: common/components/bot/handlers/StartHandler.php ===== ``` rawText) && empty($dto->text)) return false; $t = trim((string)($dto->rawText ?: $dto->text)); // var_dump($t); // die(); return ( $t === "/start" || strpos($t, "/start ") === 0 || // /start ref-123 /start source-xxx $t === "/menu" || $t === "/first_gen" || in_array($dto->text, array("примерка образов 👚", "/first_tryon") ) ); } public function handle(UpdateDto $dto, User $user): ?array { $tg = new TelegramGateway((int)$user->telegram_id); // 1) применяем ref/source из payload $payloadService = new StartPayloadService(); $ok = $payloadService->apply((string)$dto->rawText, $user, $tg); if (!$ok) { // already sent message (например NOT_SELF) — дальше ничего не отвечаем return null; } // tryon deeplink $tryon = new TryOnDeepLinkService(); if ($tryon->handleStart((string)$dto->rawText, $user, $tg, $dto->updateId)) { return null; } // 2) обычная логика $ui = BotUiFactory::fromApp($user); // first_gen if ($dto->text === "/first_gen") { if (empty($user->photo)) { $user->setWaitingPhoto(1); return $ui->firstGen(); } return $ui->default(); } // first_tryon if ( in_array($dto->text, array("примерка образов 👚", "/first_tryon") ) ) { return $ui->firstTryon(); } // start if (empty($user->photo)) { return $ui->start(); } return $ui->default(); } } ``` ===== FILE: common/components/bot/handlers/BuyPackageHandler.php ===== ``` text)) return false; return (strpos($dto->text, "/buy_") === 0); } public function handle(UpdateDto $dto, User $user): ?array { $t = (string)$dto->text; $id = (int)str_replace("/buy_", "", $t); if ($id <= 0) { return BotUiFactory::fromApp($user)->error(); } $package = Package::model()->findByPk($id); if (!$package) { return BotUiFactory::fromApp($user)->error(); } return BotUiFactory::fromApp($user)->prepaymentInfo($package, (int)$user->id); } } ``` ===== FILE: common/components/bot/handlers/PhotoActionsHandler.php ===== ``` text)) return false; $cmd = trim((string)$dto->text); if (!in_array($cmd, [self::CMD_TRYON, self::CMD_EDIT, self::CMD_SAVE], true)) return false; // обязательно нужен контекст фото через reply_to_message if (empty($dto->replyTo) || !($dto->replyTo instanceof UpdateDto)) return false; $fileId = $this->extractPhotoFileId($dto->replyTo); return !empty($fileId); } public function handle(UpdateDto $dto, User $user): ?array { $cmd = trim((string)$dto->text); $ui = BotUiFactory::fromApp($user); $fileId = $this->extractPhotoFileId($dto->replyTo); if (empty($fileId)) { return $ui->default(); } // 1) EDIT: ждём промпт следующим сообщением (как "Фото по описанию") if ($cmd === self::CMD_EDIT) { PhotoEditPendingRuntime::save((int)$user->telegram_id, [ 'tg_file_id' => (string)$fileId, ], 3600); $user->setWaitingPrompt(true); $user->setWaitingPhoto(false); // можно оставить тот же текст, что и у "Фото по описанию" return $ui->editPrompt(); } // 2) SAVE PHOTO: сохранить как фото пользователя для генераций if ($cmd === self::CMD_SAVE) { $wasEmpty = empty($user->photo); $user->downloadAndUpdatePhoto($fileId); $user->setWaitingPhoto(false); $user->setWaitingPhotoFull(true); $user->setWaitingPrompt(false); return $ui->requestPhotoFull( empty($user->photo_full) ); } // 3) TRYON if ($cmd === self::CMD_TRYON) { if (empty($user->photo)) { TryOnPendingRuntime::save((int)$user->telegram_id, [ 'tg_product_file_id' => (string)$fileId, ], 3600); $user->setWaitingPhoto(true); $user->setWaitingPrompt(false); return $ui->changePhoto(); } $tg = new TelegramGateway((int)$user->telegram_id); $tryon = new TryOnDeepLinkService(); $tryon->runTryOnFromTelegramProductFileId($user, $tg, (string)$fileId, $dto->updateId, null); return null; } return $ui->default(); } private function extractPhotoFileId($replyDto): ?string { $fileId = (string)($replyDto->largestPhotoFileId ?? ''); if ($fileId !== '') return $fileId; $arr = $replyDto->photoFileIds ?? null; if (is_array($arr) && !empty($arr)) { $v = end($arr); return $v ? (string)$v : null; } return null; } } ``` ===== FILE: common/components/bot/handlers/HelpHandler.php ===== ``` text)) return false; return ($dto->text === "/help" || $dto->text === "помощь ℹ️"); } public function handle(UpdateDto $dto, User $user): ?array { $controller = Yii::app()->controller; $currency = (is_object($controller) && isset($controller->currency) && is_array($controller->currency)) ? $controller->currency : []; $mg = new MessageGenerator($user); // как в legacy: после help ожидание промпта сбрасывалось $user->setWaitingPrompt(false); return [ "parse_mode" => "HTML", "text" => $mg->helpMessage(), ]; } } ``` ===== FILE: common/components/bot/services/TryOnDeepLinkService.php ===== ``` extractTokenFromStart($rawText); if ($token === null) return false; $row = TryOnDeepLinkStorageDb::loadByToken($token, true); // use_count++ if (!$row) { $tg->send(['text' => "Ссылка на примерку устарела или неверна. Вернись на сайт и нажми «Примерить» ещё раз."]); return true; } $this->askTryOnConfirmation($user, $tg, $row); return true; } public function handleAfterPhotoUpload(User $user, TelegramGateway $tg, ?int $updateId = null, ?int $reuseMessageId = null): bool { $pending = TryOnPendingRuntime::load((int)$user->telegram_id); if (!$pending) return false; // deepLink pending if (!empty($pending['deeplink_id'])) { $this->runTryOnFromDeeplink($user, $tg, (int)$pending['deeplink_id'], $updateId, $reuseMessageId); return true; } // pending от кнопки "Примерить этот образ" if (!empty($pending['tg_product_file_id'])) { TryOnPendingRuntime::clear((int)$user->telegram_id); $this->runTryOnFromTelegramProductFileId($user, $tg, (string)$pending['tg_product_file_id'], $updateId, $reuseMessageId); return true; } return false; } public function runTryOnFromTelegramProductFileId( User $user, TelegramGateway $tg, string $productFileId, ?int $updateId = null, ?int $reuseMessageId = null ): void { $saveDir = rtrim((string)Yii::app()->params["saveFolder"], "/") . "/"; $productLocal = $tg->downloadTelegramFile($productFileId, $saveDir); if (!$productLocal) { $tg->send(['text' => "Не смог скачать фото товара. Попробуй ещё раз."]); return; } $prompt = $this->getTryOnPrompt(); $this->runTryOn($user, $tg, $prompt, $productLocal, null, $updateId, $reuseMessageId); } public function runTryOnFromDeeplink(User $user, TelegramGateway $tg, int $deeplinkId, ?int $updateId, ?int $reuseMessageId): void { $tryonDeeplink = TryonDeeplink::model()->findByPk($deeplinkId); if (!$tryonDeeplink) { TryOnPendingRuntime::clear((int)$user->telegram_id); $tg->send(['text' => "Не нашёл данные примерки. Вернись на сайт и попробуй ещё раз."]); return; } if (!empty($tryonDeeplink->expire_at) && strtotime($tryonDeeplink->expire_at) < time()) { TryOnPendingRuntime::clear((int)$user->telegram_id); $tg->send(['text' => "Ссылка на примерку устарела. Вернись на сайт и нажми «Примерить» ещё раз."]); return; } // считаем запуск генерации TryOnDeepLinkStorageDb::incrementGenCount((int)$tryonDeeplink->id); TryOnPendingRuntime::clear((int)$user->telegram_id); $user->setWaitingPhoto(false); // СКАЧИВАЕМ фото товара локально (чтобы потом отдать как reference image) $productLocal = $this->downloadProductImage((string)$tryonDeeplink->img_url); if ($productLocal === null) { $tg->send(['text' => "Не смог скачать фото товара. Попробуй ещё раз."]); return; } $prompt = $this->getTryOnPrompt((string)$tryonDeeplink->product_name); $this->runTryOn($user, $tg, $prompt, $productLocal, $tryonDeeplink, $updateId, $reuseMessageId); } private function runTryOn( User $user, TelegramGateway $tg, string $prompt, string $productLocal, ?TryonDeeplink $tryonDeeplink, ?int $updateId, ?int $reuseMessageId ): void { $service = new GenerationService(Yii::app()->controller); $service->setIsPro( (bool)Feature::value('tryon.isPro', true) ); $service->setAspectRatio("3:4"); $service->generatePromptForUser( $user, $prompt, [$user->photo, $user->photo_full, $productLocal], null, $updateId, $reuseMessageId, (int)$tryonDeeplink->id ?? null ); } private function getTryOnPrompt(string $productName = ''): string { $prompt = (string)Feature::value('tryon.prompt', ''); if ($prompt !== '') { return $prompt; } return ""; } private function extractTokenFromStart(string $rawText): ?string { $rawText = trim((string)$rawText); if ($rawText === '' || strpos($rawText, '/start') !== 0) return null; $payload = trim(substr($rawText, strlen('/start'))); if ($payload === '') return null; $tokens = preg_split('~\s+~u', $payload, -1, PREG_SPLIT_NO_EMPTY); if (!$tokens) return null; foreach ($tokens as $t) { $t = trim($t); if (strpos($t, 'tryon_') === 0) { return substr($t, 6); } } return null; } private function buildBackMarkup(string $backUrl, string $productName): ?array { $backUrl = trim($backUrl); if ($backUrl === '' || !filter_var($backUrl, FILTER_VALIDATE_URL)) return null; $text = ($productName !== '') ? "Открыть: {$productName}" : "Открыть на сайте"; return [ 'inline_keyboard' => [ [[ 'text' => $text, 'url' => $backUrl ]], ], ]; } private function askTryOnConfirmation(User $user, TelegramGateway $tg, TryonDeeplink $row): void { TryOnPendingRuntime::save((int)$user->telegram_id, [ 'deeplink_id' => (int)$row->id, ], 3600); $markup = [ 'inline_keyboard' => [ [ [ 'text' => 'Примерить образ', 'callback_data' => '/tryon_confirm_' . (int)$row->id, ], ], ], ]; $caption = ($row->product_name !== '') ? ("" . htmlspecialchars($row->product_name, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . "\n\nХочешь покажу, как он выглядит на тебе? ✨") : "Примерить этот образ?\n\nПокажу, как он выглядит на тебе ✨"; $send = [ 'parse_mode' => 'HTML', 'caption' => $caption, 'photo' => (string)$row->img_url, 'reply_markup' => json_encode($markup), ]; $tg->send($send); if (empty($user->photo)) { $user->setWaitingPhoto(true); $tg->send(['text' => "Пришли своё фото (в полный рост или по пояс) — и я примерю одежду."]); } } private function downloadProductImage(string $url): ?string { $saveRel = rtrim((string)Yii::app()->params['saveFolder'], '/') . '/tryon'; $webroot = Yii::getPathOfAlias('webroot'); $saveAbs = rtrim($webroot, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . ltrim($saveRel, '/'); if (!is_dir($saveAbs)) @mkdir($saveAbs, 0777, true); $ext = strtolower((string)pathinfo(parse_url($url, PHP_URL_PATH) ?: '', PATHINFO_EXTENSION)); if (!in_array($ext, ['jpg','jpeg','png','webp'], true)) $ext = 'jpg'; $filename = 'tryon_' . date('Ymd_His') . '_' . uniqid() . '.' . $ext; $abs = $saveAbs . DIRECTORY_SEPARATOR . $filename; $data = @file_get_contents($url); if ($data === false || $data === '') return null; if (@file_put_contents($abs, $data) === false) return null; return $saveRel . '/' . $filename; // как у тебя принято хранить пути (относительно) } } ``` ===== FILE: common/components/bot/services/StartPayloadService.php ===== ``` isNew)) { return true; } $currency = $this->getCurrencySafe(); $mgUser = new MessageGenerator($user); // разбиваем по пробелам: "ref-1 source-abc" $tokens = preg_split('~\s+~u', $payload, -1, PREG_SPLIT_NO_EMPTY); if (!$tokens) return true; foreach ($tokens as $token) { $token = trim($token); // ref-123 if (strpos($token, 'ref-') === 0) { $refId = (int)substr($token, 4); if ($refId <= 0) { continue; } // сам себе реф if (!empty($user->id) && (int)$user->id === $refId) { $tg->send([ "parse_mode" => "HTML", "text" => $mgUser->notSelfRefMessage(), ]); return false; } // уже установлен ref if (!empty($user->ref_id)) { $tg->send([ "parse_mode" => "HTML", "text" => $mgUser->refAlreadyExistRefMessage(), ]); continue; } /** @var User|null $ref */ $ref = User::model()->findByPk($refId); if (!$ref) { $tg->send([ "parse_mode" => "HTML", "text" => $mgUser->notFoundRefMessage(), ]); continue; } // применяем ref + ставим source=referral (если есть такой source) $user->ref_id = $refId; $srcId = $this->getSourceIdByCode('referral'); if (!empty($srcId)) { $user->source_id = $srcId; } $user->save(false); // начисление бонуса пригласившему $bonus = (int)$user->refBonus; if ($bonus > 0) { CreditsService::add((int)$ref->id, $bonus, 'refBonus'); $mgRef = new MessageGenerator($ref); $tgRef = new TelegramGateway((int)$ref->telegram_id); $tgRef->send([ "parse_mode" => "HTML", "text" => $mgRef->refSuccessMessage(), ]); } continue; } // source-xxxx if (strpos($token, 'source-') === 0) { $code = trim(substr($token, 7)); if ($code === '') continue; $srcId = $this->getSourceIdByCode($code); if (!empty($srcId)) { $user->source_id = $srcId; $user->save(false); } continue; } } return true; } private function getCurrencySafe(): array { // в проекте currency обычно лежит в контроллере (как у тебя было раньше) $c = null; if (Yii::app()->controller && property_exists(Yii::app()->controller, 'currency')) { $c = Yii::app()->controller->currency; } return is_array($c) ? $c : ["генерация", "генерации", "генераций", "💎"]; } private function getSourceIdByCode(string $code): ?int { $code = trim($code); if ($code === '') return null; $src = Source::model()->find('code = :c', [':c' => $code]); if (!$src) return null; return (int)$src->id; } } ``` ===== FILE: common/components/bot/services/PhotoEditService.php ===== ``` params["saveFolder"], "/") . "/"; $savedPath = $tg->downloadTelegramFile($fileId, $saveDir); $prompt = trim($userTextPrompt) . ". Остальное оставь как на исходном изображении"; $service = new GenerationService(Yii::app()->controller); $service->useBaseVersion(); $service->setAspectRatio(null); $service->generatePromptForUser( $user, $prompt, [$savedPath], null, $updateId, $reuseMessageId ); } } ``` ===== FILE: common/components/services/CreditsService.php ===== ``` db; $tx = $db->beginTransaction(); try { self::insertLedger($db, $userId, $amount, $reason, $subscriptionId, $taskId, $cpTransactionId, $comment); self::recalcBalance($db, $userId); $tx->commit(); return true; } catch (Exception $e) { $tx->rollback(); // Если это дубль по cp_transaction_id — просто считаем, что "уже начислено" if ($cpTransactionId !== null && stripos($e->getMessage(), 'Duplicate') !== false) { return true; } throw $e; } } /** * Списать credits атомарно (и записать в ledger). */ public static function spend( int $userId, int $amount, string $reason, ?int $subscriptionId = null, ?int $taskId = null, ?string $comment = null ): bool { $amount = (int)$amount; if ($amount <= 0) return false; $db = Yii::app()->db; $tx = $db->beginTransaction(); try { // 1) атомарно уменьшаем balance, если хватает $affected = $db->createCommand(" UPDATE `user` SET `balance` = CAST(`balance` AS SIGNED) - :a WHERE `id` = :uid AND CAST(`balance` AS SIGNED) >= :a ")->execute([':a' => $amount, ':uid' => $userId]); if ($affected <= 0) { $tx->rollback(); return false; // не хватает credits } // 2) ledger self::insertLedger($db, $userId, -$amount, $reason, $subscriptionId, $taskId, null, $comment); // 3) user.balance = сумма delta по ledger (быстрый кэш) self::recalcBalance($db, $userId); $tx->commit(); return true; } catch (Exception $e) { $tx->rollback(); throw $e; } } private static function insertLedger( CDbConnection $db, int $userId, int $delta, string $reason, ?int $subscriptionId, ?int $taskId, ?int $cpTransactionId, ?string $comment ): void { $db->createCommand()->insert(CreditsLedger::model()->tableName(), [ 'user_id' => $userId, 'delta' => $delta, 'reason' => $reason, 'comment' => $comment, 'subscription_id' => $subscriptionId, 'task_id' => $taskId, 'cp_transaction_id' => $cpTransactionId, 'created_at' => date('Y-m-d H:i:s'), ]); } private static function recalcBalance(CDbConnection $db, int $userId): void { $db->createCommand(" UPDATE `user` SET `balance` = ( SELECT COALESCE(SUM(delta), 0) FROM credits_ledger WHERE user_id = :uid ) WHERE `id` = :uid ")->execute([':uid' => $userId]); } } ``` ===== FILE: common/components/services/GenerationService.php ===== ``` maxPrimaryTries = (int)Feature::value('ai.maxPrimaryTries', $this->maxPrimaryTries); $this->useFallback = (bool)Feature::value('ai.useFallback', $this->useFallback); $this->imageSize = (string)Feature::value('ai.imageSize', $this->imageSize); $this->aspectRatio = (string)Feature::value('ai.aspectRatio', $this->aspectRatio); $this->isPro = (bool)Feature::value('ai.isPro', $this->isPro); $this->fallback = (string)Feature::value('ai.fallback', $this->fallback); $this->primary = (string)Feature::value('ai.primary', $this->primary); $this->controller = $controller; $this->ai = $this->getAIGenerator( $this->primary ); } public function generateSessionForUser(User $user, int $sessionId, ?int $updateId = null, ?int $reuseMessageId = null): void { $promptModel = Prompt::model()->findByPk($sessionId); if (!$promptModel) { $tg = new TelegramGateway($user->telegram_id); $ui = BotUiFactory::fromApp($user); $tg->send($ui->errorGenerating()); return; } $this->generatePromptForUser( $user, $promptModel->prompt, [$user->photo, $user->photo_full], $sessionId, $updateId, $reuseMessageId ); } public function generatePromptForUser( User $user, string $prompt, array $referenceImages, ?int $sessionId = null, ?int $updateId = null, ?int $reuseMessageId = null, ?int $tryonDeeplinkId = null ): void { $tg = new TelegramGateway($user->telegram_id); $ui = BotUiFactory::fromApp($user); if ( !$user->canGenerate() ) { if (!empty($reuseMessageId)) { $tg->deleteMessage((int)$reuseMessageId); } $tg->send($ui->subscriptionRequired()); return; } $task = new Task(); $task->user_id = (int)$user->id; $task->input_photo = $this->packInputPhotos($referenceImages); $task->prompt = $prompt; $task->session_id = $sessionId; if (!empty($tryonDeeplinkId)) { $task->tryon_deeplink_id = (int)$tryonDeeplinkId; } if (!empty($updateId)) { $task->update_id = $updateId; } if (!$task->save()) { $this->controller->logToTelegram("Task save error: " . print_r($task->getErrors(), true)); if ($reuseMessageId) $tg->deleteMessage($reuseMessageId); $tg->send($ui->errorGenerating()); return; } if (!CreditsService::spend((int)$user->id, 1, 'generation', null, $task->id, null)) { $tg->send($ui->emptyBalance()); $task->delete(); return; } // Отправляем прелоудер и записываем в таску его айдишник $messageId = $reuseMessageId ?: $this->showLoading($tg, $ui); $task->message_id = $messageId ?: null; $task->save(false); if( $session = Prompt::model()->findByPk($task->session_id) ){ $session->increaseCounter(); } $result = $this->generate($task); if (is_array($result) && !empty($result["success"])) { // sync result if (!empty($result["filePath"])) { $task->completed_at = date('Y-m-d H:i:s'); $task->status_id = Task::STATUS_COMPLETED; $task->result_photo = $result["filePath"]; if ($messageId) $tg->deleteMessage($messageId); $this->sendImageToTelegram($tg, $user, $result["filePath"], $task); } else { // async $task->status_id = Task::STATUS_PENDING; if (!empty($result["taskId"])) { $task->external_code = $result["taskId"]; } } } else { $task->status_id = Task::STATUS_CREATE_FAILED; $task->error_message = is_array($result) ? ($result["errorMessage"] ?? "unknown error") : "generation failed"; if ($messageId) $tg->deleteMessage($messageId); $tg->send($ui->errorGenerating()); } if (is_array($result) && isset($result["model"])) { $task->model = $result["model"]; } $task->save(false); } private function showLoading(TelegramGateway $tg, BotUiFactory $ui): ?int { $res = $tg->send($ui->generating()); if (is_array($res) && !empty($res["ok"]) && !empty($res["result"]["message_id"])) { return (int)$res["result"]["message_id"]; } return null; } public function generate(Task $task) { try { $options = [ "image_size" => $this->imageSize, ]; $options["aspect_ratio"] = $this->aspectRatio; $result = $this->ai->generateImage($task, $options); if (is_array($result) && isset($result["code"])) { if ((int)$result["code"] === 402) { $this->controller->logToTelegram("ВНИМАНИЕ! Кончились средства на KIE"); } elseif ((int)$result["code"] !== 200) { $this->controller->logToTelegram($result["code"] . ": " . ($result["errorMessage"] ?? '')); return $this->tryChangeModel($task, (int)$result["code"]); } } return $result; } catch (Throwable $e) { $this->controller->logToTelegram($e->getMessage()); return $this->tryChangeModel($task, (int)$e->getCode()); } } private function tryChangeModel(Task $task, int $code = 500) { $this->generateCount++; // 1-2 попытки: ретрай if ($this->generateCount < $this->maxPrimaryTries) { if ($code === 503) { $this->controller->logToTelegram("Код 503, ждем 10 секунд"); sleep(10); } $this->controller->logToTelegram("Пробуем повторную генерацию"); return $this->generate($task); } // 3 попытка: переключаем на Kie if ($this->generateCount === $this->maxPrimaryTries && $this->useFallback) { $this->ai = $this->getAIGenerator( $this->fallback ); $this->controller->logToTelegram("Переключаем на Kie"); return $this->generate($task); } return [ "success" => false, "errorMessage" => "Generation failed after retries", "code" => $code, ]; } private function getAIGenerator(string $type = "kie") { switch ($type) { case "kie": return new Kie($this->controller->getParam("KEYS", "KIE"), $this->isPro); case "gemini": return new NanoBananaPro( $this->controller->getParam("KEYS", "GEMINI"), $this->controller->getParam("KEYS", "PROXY"), $this->isPro ); default: $this->controller->logToTelegram("Ошибка: AIGenerator «{$type}» не найден"); throw new RuntimeException("AI generator not found: {$type}"); } } public function sendImageToTelegram( TelegramGateway $tg, User $user, string $filePath, Task $task ): void { // Это будет "первая генерация", если ДО текущей задачи у пользователя ещё не было completed-задач. // Важно: на момент вызова этого метода текущая задача ещё не сохранена как COMPLETED (и в sync, и в async), // поэтому count() корректно показывает "были ли completed раньше". $isFirstGeneration = ((int)Task::model()->count( 'user_id = :uid AND status_id = :st', [ ':uid' => (int)$user->id, ':st' => Task::STATUS_COMPLETED, ] ) === 0); $replyMarkup = $this->buildReplyMarkup( $user, $task ); if (!empty($task->tryon_deeplink_id)) { $text = "Образ сгенерирован искусственным интеллектом в " . $this->controller->getParam("TITLE", "IN"); } else { $text = "Фотография сгенерирована искусственным интеллектом в " . $this->controller->getParam("TITLE", "IN"); } // 1) Сначала отправляем фото $photoRes = $tg->send([ "photo" => $this->getCorrectFile($filePath), "parse_mode" => "HTML", "caption" => "{$text}", "reply_markup"=> json_encode($replyMarkup), ]); // Проверяем, что фото реально ушло (sendPhoto у тебя возвращает строку JSON) $photoOk = true; if (is_array($photoRes)) { $photoOk = !empty($photoRes['ok']); } elseif (is_string($photoRes) && $photoRes !== '') { $decoded = json_decode($photoRes, true); if (is_array($decoded) && array_key_exists('ok', $decoded)) { $photoOk = !empty($decoded['ok']); } } // 2) И только ПОСЛЕ отправки фото — один раз за всю жизнь пользователя — шлём подсказку + главное меню (reply keyboard) if ($photoOk && $isFirstGeneration) { $ui = BotUiFactory::fromApp($user); $mg = new MessageGenerator($user); // Берём стандартный payload главного меню, но подменяем текст на нужный. // Это гарантирует, что "основное меню" будет внизу, где клавиатура. $payload = $ui->default(); $payload['text'] = $mg->firstGenerationSuccessMessage(); $tg->send($payload); } } private function buildReplyMarkup(User $user, Task $task): array { if ($task->tryon_deeplink_id && $tryonDeeplink = TryonDeeplink::model()->findByPk((int)$task->tryon_deeplink_id) ) { $back = trim((string)($tryonDeeplink->back_url ?? '')); if ($back !== '' && filter_var($back, FILTER_VALIDATE_URL)) { $name = trim((string)($tryonDeeplink->product_name ?? '')); $text = ($name !== '') ? "Открыть: {$name}" : "Смотреть на сайте"; return [ "inline_keyboard" => [ [[ "text" => $text, "url" => $back ]], ], ]; } } if (!empty($task->session_id)) { return [ "inline_keyboard" => [ [ [ "text" => "Классно! Хочу еще вариант 🔄", "callback_data" => "/sessiongenerate_" . $task->session_id, ], ], ], ]; } return BotUiFactory::fromApp($user)->mainKeyboard(); } private function getCorrectFile(string $filePath) { if (strpos($filePath, "https:") !== false) { $size = $this->getRemoteFileSize($filePath); if ($size !== null && $size < 5242880) { return $filePath; } $tmpPath = Yii::app()->params["tempFolder"] . '/tg_' . uniqid('', true) . '.png'; $data = @file_get_contents($filePath); if ($data !== false) { @file_put_contents($tmpPath, $data); } $this->deleteOldFiles(Yii::app()->params["tempFolder"]); return new CURLFile(realpath($tmpPath)); } return new CURLFile(realpath($filePath)); } private function deleteOldFiles(string $dirPath, int $hours = 1): void { if (!is_dir($dirPath)) return; $limitTime = time() - ($hours * 3600); $files = @scandir($dirPath); if ($files === false) return; foreach ($files as $file) { if ($file === '.' || $file === '..') continue; $fullPath = $dirPath . DIRECTORY_SEPARATOR . $file; if (is_file($fullPath)) { $fileMTime = filemtime($fullPath); if ($fileMTime !== false && $fileMTime < $limitTime) { @unlink($fullPath); } } } } private function getRemoteFileSize(string $url): ?int { $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_NOBODY => true, CURLOPT_RETURNTRANSFER => true, CURLOPT_HEADER => true, CURLOPT_FOLLOWLOCATION => true, CURLOPT_TIMEOUT => 20, ]); $response = curl_exec($ch); if ($response === false) { return null; } $size = curl_getinfo($ch, CURLINFO_CONTENT_LENGTH_DOWNLOAD); return ($size > 0) ? (int)$size : null; } private function packInputPhotos(array $referenceImages): string { $imgs = []; foreach ($referenceImages as $v) { $v = trim((string)$v); if ($v !== '') $imgs[] = $v; } if (count($imgs) <= 1) { return (string)($imgs[0] ?? ''); } return json_encode($imgs, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); } public function setImageSize(string $imageSize = "2K"){ $this->imageSize = $imageSize; } public function setAspectRatio(?string $aspectRatio = "3:4"){ $this->aspectRatio = $aspectRatio; } public function setIsPro(bool $isPro = true){ $this->isPro = $isPro; $this->ai = $this->getAIGenerator( $this->primary ); } public function setUseFallback(bool $useFallback = true){ $this->useFallback = $useFallback; } public function setMaxPrimaryTries(int $maxPrimaryTries = 3){ $this->maxPrimaryTries = $maxPrimaryTries; } public function useProVersion() { $this->setIsPro(true); } public function useBaseVersion() { $this->setIsPro(false); } } ``` ===== FILE: common/components/services/AiCallbackHandler.php ===== ``` find("external_code = :code", [":code" => (string)$data["taskId"]]); if (!$task) { return; } /** @var User|null $user */ $user = $task->user; if (!$user) { return; } $tg = new TelegramGateway((int)$user->telegram_id); $ui = BotUiFactory::fromApp($user); $task->completed_at = date('Y-m-d H:i:s'); if (!empty($task->message_id)) { $tg->deleteMessage((int)$task->message_id); } $code = (int)($input["code"] ?? 0); if ($code === 200) { $result = []; if (!empty($data["resultJson"])) { $result = json_decode((string)$data["resultJson"], true) ?: []; } $urls = $result["resultUrls"] ?? null; if (is_array($urls) && !empty($urls)) { $task->status_id = Task::STATUS_COMPLETED; $task->result_photo = (string)end($urls); $gs = new GenerationService(Yii::app()->controller); $gs->sendImageToTelegram( $tg, $user, $task->result_photo, $task ); } else { $task->status_id = Task::STATUS_FAILED; $task->error_message = "Kie: resultUrls пустой"; $tg->send($ui->errorGenerating()); $this->logToTelegramSafe("AiCallback error: resultUrls empty. task={$task->id}"); } } else { $task->status_id = Task::STATUS_FAILED; $task->error_message = (string)($data["failMsg"] ?? ("Kie error code: ".$code)); $tg->send($ui->errorGenerating()); $this->logToTelegramSafe("AiCallback error: ".$task->error_message." task={$task->id}"); } $task->save(false); } private function logToTelegramSafe(string $text): void { $c = Yii::app()->controller; if (is_object($c) && method_exists($c, 'logToTelegram')) { $c->logToTelegram($text); } } } ``` ===== FILE: common/components/services/TaskWorker.php ===== ``` controller = $controller ?: Yii::app()->controller; $this->generationService = new GenerationService($this->controller); $this->generationService->setImageSize($this->imageSize); $this->generationService->setAspectRatio($this->aspectRatio); $this->generationService->setIsPro($this->isPro); $this->generationService->setUseFallback($this->useFallback); $this->generationService->setMaxPrimaryTries($this->maxPrimaryTries); } /** * @param int $limit * @param array $filters ['userIds'=>[0,5], 'status'=>Task::STATUS_NEW, 'aspectRatio'=>'3:4'] */ public function run(int $limit = 10, array $filters = []): void { $limit = max(1, $limit); $userIds = $filters['userIds'] ?? [2]; $status = $filters['status'] ?? Task::STATUS_NEW; $aspectRatio = $filters['aspectRatio'] ?? "3:4"; $count = 0; while ($count < $limit) { /** @var Task|null $task */ $task = Task::model()->find( "(user_id IN (" . implode(',', array_map('intval', (array)$userIds)) . ")) AND status_id = :st", [":st" => $status] ); if (!$task) { return; } $count++; $task->status_id = Task::STATUS_PENDING; $task->save(false); // Вся логика генерации теперь в GenerationService $result = $this->generationService->generate($task); if (is_array($result) && !empty($result["success"]) && !empty($result["filePath"])) { $task->completed_at = date('Y-m-d H:i:s'); $task->status_id = Task::STATUS_COMPLETED; $task->result_photo = (string)$result["filePath"]; // особый кейс: user_id=2 — генерация фото для Prompt (как в legacy) if ((int)$task->user_id === 2 && !empty($task->session)) { try { // если у тебя Controller::saveFile есть — используй, чтобы привести к локальному пути if (method_exists($this->controller, 'saveFile')) { $task->session->photo = $this->controller->saveFile( $task->result_photo, null, null, "auto", null, Yii::app()->params['generationFolder'] ); } else { $task->session->photo = $task->result_photo; } if ((int)$task->session->status_id === (int)Prompt::STATUS_GENERATING) { $task->session->status_id = Prompt::STATUS_TESTING; } $task->session->save(false); // logToTelegramSafe заменяем на logToTelegram $this->controller->logToTelegram( "Фотография для фотосессии «{$task->session->name}» ({$task->session->id}) успешно сгенерена" ); } catch (Throwable $e) { $this->controller->logToTelegram("Cron: session update error " . $e->getMessage()); } } } else { $task->status_id = Task::STATUS_FAILED; $task->error_message = is_array($result) ? (string)($result["errorMessage"] ?? "unknown") : "generation failed"; $this->controller->logToTelegram("Ошибка генерации (cron). task={$task->id} " . $task->error_message); } if (is_array($result) && isset($result["model"])) { $task->model = (string)$result["model"]; } $task->save(false); } } } ``` ===== FILE: common/components/services/PhotoEditPendingRuntime.php ===== ``` runtimePath, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . 'photo_edit_pending'; if (!is_dir($dir)) { @mkdir($dir, 0777, true); } return $dir; } private static function filePath(int $telegramId): string { return self::dirPath() . DIRECTORY_SEPARATOR . $telegramId . '.json'; } public static function save(int $telegramId, array $data, int $ttlSeconds = 3600): void { $payload = $data; $payload['_exp'] = time() + max(1, $ttlSeconds); @file_put_contents(self::filePath($telegramId), json_encode($payload, JSON_UNESCAPED_UNICODE)); } public static function load(int $telegramId): ?array { $path = self::filePath($telegramId); if (!is_file($path)) return null; $raw = @file_get_contents($path); $data = json_decode((string)$raw, true); if (!is_array($data)) { @unlink($path); return null; } $exp = (int)($data['_exp'] ?? 0); if ($exp > 0 && $exp < time()) { @unlink($path); return null; } return $data; } public static function clear(int $telegramId): void { $path = self::filePath($telegramId); if (is_file($path)) { @unlink($path); } } } ``` ===== FILE: common/components/services/UserResolver.php ===== ``` find("telegram_id = :id", [':id' => $dto->telegramUserId]); if ($user) { // аккуратно обновим то, что могло поменяться/не было $needSave = false; if (!empty($dto->fromFirstName) && empty($user->name)) { $user->name = $dto->fromFirstName; $needSave = true; } if (!empty($dto->fromUsername) && empty($user->username)) { $user->username = $dto->fromUsername; $needSave = true; } if (!empty($dto->languageCode) && empty($user->language_code)) { $user->language_code = $dto->languageCode; $needSave = true; } if ($needSave) { $user->save(false); } return $user; } $user = new User(); $user->telegram_id = $dto->telegramUserId ?? null; $user->name = $dto->fromFirstName ?: null; $user->username = $dto->fromUsername ?: null; $user->language_code = $dto->languageCode ?: null; $user->enabled = 1; $user->is_waiting_photo = 0; $user->isNew = true; $user->save(false); CreditsService::add( (int)$user->id, $user->startBalance + 1, 'startBonus' ); // if( !$user->save(false) ){ // var_dump($user->getErrors()); // }else{ // echo "
";
        //     var_dump($user);
        // }

        return $user;
    }
}

```


===== FILE: common/components/services/UpdateDeduplicator.php =====
```
updateId)) {
            return true;
        }

        $file = Yii::app()->runtimePath . DIRECTORY_SEPARATOR . 'tg_update_ids.log';

        // гарантируем, что runtime существует
        if (!is_dir(Yii::app()->runtimePath)) {
            @mkdir(Yii::app()->runtimePath, 0777, true);
        }

        $fp = @fopen($file, 'c+');
        if (!$fp) {
            // если не можем дедупить — лучше пропустить апдейт, чем убить бота
            return true;
        }

        try {
            if (!flock($fp, LOCK_EX)) {
                return true;
            }

            // читаем текущие
            $lines = [];
            rewind($fp);
            while (($line = fgets($fp)) !== false) {
                $line = trim($line);
                if ($line !== '') $lines[] = $line;
            }

            $idStr = (string)$dto->updateId;

            if (in_array($idStr, $lines, true)) {
                return false;
            }

            // добавляем
            $lines[] = $idStr;

            // трим
            if (count($lines) > $this->maxLines) {
                $lines = array_slice($lines, -$this->maxLines);
            }

            // перезаписываем файл
            ftruncate($fp, 0);
            rewind($fp);
            fwrite($fp, implode("\n", $lines) . "\n");

            return true;

        } finally {
            @flock($fp, LOCK_UN);
            @fclose($fp);
        }
    }
}
```


===== FILE: common/components/services/TryOnPendingRuntime.php =====
```
runtimePath, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . 'tryon_pending';
        if (!is_dir($dir)) @mkdir($dir, 0777, true);
        return $dir;
    }

    public static function save(int $telegramId, array $data, int $ttlSeconds = 3600): void
    {
        $path = self::dirPath() . DIRECTORY_SEPARATOR . 'pending_' . $telegramId . '.json';
        @file_put_contents($path, json_encode([
            'exp'  => time() + $ttlSeconds,
            'data' => $data,
        ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
    }

    public static function load(int $telegramId): ?array
    {
        $path = self::dirPath() . DIRECTORY_SEPARATOR . 'pending_' . $telegramId . '.json';
        if (!is_file($path)) return null;

        $raw = @file_get_contents($path);
        $j = $raw ? json_decode($raw, true) : null;
        if (!is_array($j)) return null;

        if (!empty($j['exp']) && time() > (int)$j['exp']) {
            @unlink($path);
            return null;
        }

        return is_array($j['data'] ?? null) ? $j['data'] : null;
    }

    public static function clear(int $telegramId): void
    {
        $path = self::dirPath() . DIRECTORY_SEPARATOR . 'pending_' . $telegramId . '.json';
        @unlink($path);
    }
}
```


===== FILE: common/components/services/SubscriptionsService.php =====
```
find(
            'user_id=:u AND plan_version_id=:p AND (status IS NULL OR status=:st) AND cp_subscription_id IS NULL AND created_at >= DATE_SUB(NOW(), INTERVAL 30 MINUTE)',
            [':u' => $userId, ':p' => $planVersionId, ':st' => 'Pending']
        );

        if ($draft) {
            return $draft;
        }

        $sub = new UserSubscription();
        $sub->user_id = $userId;
        $sub->plan_version_id = $planVersionId;
        $sub->status = 'Pending';
        $sub->created_at = date('Y-m-d H:i:s');
        $sub->updated_at = date('Y-m-d H:i:s');

        if (!$sub->save(false)) {
            throw new RuntimeException('Cannot create subscription draft');
        }

        return $sub;
    }

    public static function handlePay(array $payload, string $rawBody): void
    {
        $meta = self::decodeMeta($payload);

        $subscription = self::resolveSubscription($payload, $meta);
        if (!$subscription) {
            Yii::log('SubscriptionsService: Pay: cannot resolve subscription. payload=' . $rawBody, CLogger::LEVEL_ERROR);
            return;
        }

        $planVersion = PlanVersion::model()->with('plan')->findByPk((int)$subscription->plan_version_id);
        if (!$planVersion) {
            Yii::log('SubscriptionsService: Pay: planVersion not found for subscription_id=' . (int)$subscription->id, CLogger::LEVEL_ERROR);
            return;
        }

        $txId = isset($payload['TransactionId']) ? (int)$payload['TransactionId'] : null;
        $amount = isset($payload['Amount']) ? (float)$payload['Amount'] : (float)$planVersion->price;
        $currency = isset($payload['Currency']) ? (string)$payload['Currency'] : (string)$planVersion->currency;

        // Идемпотентность по транзакции
        if ($txId) {
            $exists = SubscriptionCharge::model()->find(
                'transaction_id=:t AND subscription_id=:s',
                [':t' => (string)$txId, ':s' => (int)$subscription->id]
            );
            if ($exists) {
                return;
            }
        }

        $now = date('Y-m-d H:i:s');

        // Обновляем подписку
        $cpSubId = isset($payload['SubscriptionId']) ? trim((string)$payload['SubscriptionId']) : null;
        if ($cpSubId !== null && $cpSubId !== '') {
            $subscription->cp_subscription_id = $cpSubId;
        }

        try {
            $cp = new CloudPaymentsApi();
            $next = $cp->getNextChargeAtDb($subscription->cp_subscription_id);
            if ($next) {
                $subscription->next_charge_at = $next;
            }
        } catch (Exception $e) {
            Yii::log('Cannot fetch next_charge_at from CP: ' . $e->getMessage(), CLogger::LEVEL_WARNING);
        }

        $wasActiveBefore = ($subscription->status === 'Active');
        $subscription->status = 'Active';

        if (empty($subscription->started_at)) {
            $subscription->started_at = self::payloadDateTime($payload) ?: $now;
        }
        $subscription->last_paid_at = self::payloadDateTime($payload) ?: $now;

        if (!empty($payload['CardLastFour'])) $subscription->card_last4 = (string)$payload['CardLastFour'];
        if (!empty($payload['CardType']))     $subscription->card_type  = (string)$payload['CardType'];

        $subscription->updated_at = $now;
        $subscription->save(false);

        // Записываем списание
        $charge = new SubscriptionCharge();
        $charge->subscription_id = (int)$subscription->id;
        $charge->kind = $wasActiveBefore ? 'payment' : 'initial';
        $charge->amount = $amount;
        $charge->currency = $currency;
        $charge->status = 'Paid';
        $charge->transaction_id = $txId ? (string)$txId : null;
        $charge->reason = null;

        // !!! ВАЖНО: raw_json — колонка MySQL JSON. Сохраняем валидный JSON.
        $charge->raw_json = self::packRawJson($payload, $rawBody);

        $charge->created_at = $now;
        $charge->save(false);

        // Начисляем кредиты
        $credits = (int)$planVersion->credits_per_month;
        if ($credits > 0) {
            CreditsService::add(
                (int)$subscription->user_id,
                $credits,
                'subscriptionCredits',
                (int)$subscription->id,
                null,
                $txId,
                'Subscription: ' . (string)$planVersion->plan->title
            );
        }

        if ($user = User::model()->findByPk($subscription->user_id)) {
            $messageGenerator = new MessageGenerator($user);

            Yii::app()->controller->sendMessageToTelegram(
                $user,
                (count($subscription->charges) > 1)?
                $messageGenerator->subscriptionRenewalMessage($credits):
                $messageGenerator->subscriptionSuccessMessage($credits)
            );
        }
    }

    public static function handleFail(array $payload, string $rawBody): void
    {
        $meta = self::decodeMeta($payload);
        $subscription = self::resolveSubscription($payload, $meta);

        if (!$subscription) {
            Yii::log('SubscriptionsService: Fail: cannot resolve subscription. payload=' . $rawBody, CLogger::LEVEL_WARNING);
            return;
        }

        $txId = isset($payload['TransactionId']) ? (int)$payload['TransactionId'] : null;

        if ($txId) {
            $exists = SubscriptionCharge::model()->find(
                'transaction_id=:t AND subscription_id=:s',
                [':t' => (string)$txId, ':s' => (int)$subscription->id]
            );
            if ($exists) return;
        }

        $now = date('Y-m-d H:i:s');

        $charge = new SubscriptionCharge();
        $charge->subscription_id = (int)$subscription->id;
        $charge->kind = 'payment';
        $charge->amount = isset($payload['Amount']) ? (float)$payload['Amount'] : null;
        $charge->currency = isset($payload['Currency']) ? (string)$payload['Currency'] : null;
        $charge->status = 'Fail';
        $charge->transaction_id = $txId ? (string)$txId : null;
        $charge->reason = self::failReason($payload);

        // !!! ВАЖНО: raw_json — JSON
        $charge->raw_json = self::packRawJson($payload, $rawBody);

        $charge->created_at = $now;
        $charge->save(false);
    }

    public static function handleRecurrent(array $payload, string $rawBody): void
    {
        $cpSubId = isset($payload['Id']) ? trim((string)$payload['Id']) : null;
        if (!$cpSubId) {
            Yii::log('SubscriptionsService: Recurrent: missing Id. payload=' . $rawBody, CLogger::LEVEL_WARNING);
            return;
        }

        $subscription = UserSubscription::model()->find('cp_subscription_id=:id', [':id' => $cpSubId]);

        if (!$subscription && !empty($payload['AccountId'])) {
            $userId = (int)$payload['AccountId'];
            $subscription = UserSubscription::model()->find(
                'user_id=:u AND cp_subscription_id IS NULL AND (status IS NULL OR status=:st) ORDER BY id DESC',
                [':u' => $userId, ':st' => 'Pending']
            );
            if ($subscription) {
                $subscription->cp_subscription_id = $cpSubId;
            }
        }

        if (!$subscription) {
            Yii::log('SubscriptionsService: Recurrent: subscription not found for cpId=' . $cpSubId, CLogger::LEVEL_WARNING);
            return;
        }

        $now = date('Y-m-d H:i:s');
        $status = isset($payload['Status']) ? (string)$payload['Status'] : null;

        if ($status) {
            $subscription->status = $status;
        }

        if (!empty($payload['LastTransactionDate'])) {
            $subscription->last_paid_at = self::payloadDateTime($payload, 'LastTransactionDate');
        }
        if (!empty($payload['NextTransactionDate'])) {
            $subscription->next_charge_at = self::payloadDateTime($payload, 'NextTransactionDate');
        }

        if (in_array($status, ['Cancelled', 'Rejected', 'Expired'], true)) {
            if (empty($subscription->canceled_at)) {
                $subscription->canceled_at = $now;
            }

            if ($subscription->user) {
                $messageGenerator = new MessageGenerator($subscription->user);

                Yii::app()->controller->sendMessageToTelegram(
                    $subscription->user,
                    $messageGenerator->subscriptionCanceledMessage()
                );
            }
        }

        $subscription->updated_at = $now;
        $subscription->save(false);
    }

    public static function cancelUserSubscription(int $userId, int $subscriptionId, string $reason = 'user'): bool
    {
        /** @var UserSubscription $sub */
        $sub = UserSubscription::model()->findByPk($subscriptionId);
        if (!$sub || (int)$sub->user_id !== $userId) {
            throw new CHttpException(404, 'Subscription not found');
        }

        // Если уже отменена/закрыта — идемпотентно
        if (in_array($sub->status, ['Cancelled', 'Expired', 'Rejected'], true)) {
            return true;
        }

        if (empty($sub->cp_subscription_id)) {
            // нет CP id — нечего отменять на стороне CP
            // но в БД можем пометить как Cancelled, чтобы не считалась Active
            $sub->status = 'Cancelled';
            $sub->canceled_at = date('Y-m-d H:i:s');
            $sub->updated_at = date('Y-m-d H:i:s');
            $sub->save(false);
            return true;
        }

        // 1) Отменяем в CloudPayments
        $cp = new CloudPaymentsApi();
        $cp->cancelSubscription($sub->cp_subscription_id);

        // 2) В БД ставим Cancelled, но доступ "держим" до next_charge_at (если он есть и в будущем)
        $now = date('Y-m-d H:i:s');

        $sub->status = 'Cancelled';
        if (empty($sub->canceled_at)) {
            $sub->canceled_at = $now;
        }
        $sub->updated_at = $now;
        $sub->save(false);

        // 3) (опционально) логируем в subscription_charge как событие, если есть подходящая модель/тип
        // Если у тебя raw_json в JSON-колонке — кладём валидный JSON
        try {
            $charge = new SubscriptionCharge();
            $charge->subscription_id = (int)$sub->id;
            $charge->kind = 'cancel';
            $charge->amount = 0;
            $charge->currency = null;
            $charge->status = 'Ok';
            $charge->transaction_id = null;
            $charge->reason = $reason;
            $charge->raw_json = json_encode([
                'event' => 'cancel',
                'reason' => $reason,
                'cp_subscription_id' => $sub->cp_subscription_id,
            ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '{}';
            $charge->created_at = $now;
            $charge->save(false);
        } catch (Exception $e) {
            // не валим отмену из-за логов
            Yii::log('cancelUserSubscription: cannot write SubscriptionCharge: ' . $e->getMessage(), CLogger::LEVEL_WARNING);
        }

        return true;
    }

    // ---------------- helpers ----------------

    private static function decodeMeta(array $payload): array
    {
        if (empty($payload['Data'])) return [];

        if (is_array($payload['Data'])) return $payload['Data'];

        if (is_string($payload['Data'])) {
            $meta = @json_decode($payload['Data'], true);
            return is_array($meta) ? $meta : [];
        }

        return [];
    }

    private static function resolveSubscription(array $payload, array $meta): ?UserSubscription
    {
        if (!empty($meta['subscription_id'])) {
            return UserSubscription::model()->findByPk((int)$meta['subscription_id']);
        }

        if (!empty($payload['SubscriptionId'])) {
            $cpId = trim((string)$payload['SubscriptionId']);
            if ($cpId !== '') {
                return UserSubscription::model()->find('cp_subscription_id=:id', [':id' => $cpId]);
            }
        }

        // Иногда удобно (если invoiceId = user_subscription.id) — можно добавить fallback:
        // if (!empty($payload['InvoiceId'])) return UserSubscription::model()->findByPk((int)$payload['InvoiceId']);

        return null;
    }

    private static function payloadDateTime(array $payload, string $key = 'DateTime'): ?string
    {
        return empty($payload[$key]) ? null : (string)$payload[$key];
    }

    private static function failReason(array $payload): string
    {
        $reason = isset($payload['Reason']) ? (string)$payload['Reason'] : '';
        $code = isset($payload['ReasonCode']) ? (string)$payload['ReasonCode'] : '';
        $parts = array_filter([$reason, $code]);
        return implode(' | ', $parts);
    }

    /**
     * raw_json колонка = MySQL JSON => сюда НЕЛЬЗЯ писать querystring напрямую.
     * Всегда возвращаем валидный JSON: объект {payload, rawBody}
     */
    private static function packRawJson(array $payload, string $rawBody): string
    {
        $packed = [
            'payload' => $payload,
            'rawBody' => $rawBody,
        ];

        $json = json_encode($packed, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);

        // Если вдруг json_encode упал (редко, но бывает из-за бинарных символов) — кладём хотя бы payload
        if ($json === false) {
            $json = json_encode(['payload' => $payload], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
        }

        // Если и это не удалось — возвращаем пустой валидный JSON
        return $json !== false ? $json : '{}';
    }
}

```


===== FILE: common/controllers/PublicController.php =====
```
 array("index", "privacyPolicy", "termsOfUse", "sessions"),
                "users" => array("*"),
            ),
            array("deny",
                "users" => array("*"),
            ),
        );
    }

    public function actionSessions($user_id = null){
        if( $user_id ){
            $user = User::model()->findByPk($user_id);
        }

        // Админы
        if( isset($user) && $user && in_array($user->id, [1, 6]) ){
            $rubrics = Rubric::model()->sorted()->with([
                "prompts" => [
                    "order" => "prompts.counter DESC", 
                    "condition" => "status_id = '".Prompt::STATUS_PUBLISHED."' OR status_id = '".Prompt::STATUS_TESTING."'"
                ]
            ])->findAll();
        }else{
            $rubrics = Rubric::model()->sorted()->with([
                "prompts" => [
                    "order" => "t.id DESC", 
                    "condition" => "status_id = '".Prompt::STATUS_PUBLISHED."'"
                ]
            ])->findAll();
        }

        // var_dump($sessions);

        $params = [
            "rubrics" => $rubrics
        ];

        // $params = array(
        //     "data" => $dataProvider->getData(),
        //     "pages" => $pages,
        //     "filter" => $filter,
        //     "count" => $count,
        //     "labels" => $className::model()->attributeLabels(true),
        //     "modelName" => $this->adminMenu["items"][ $_GET["class"] ] ?? $this->adminMenu["items"][ mb_strtolower($_GET["class"], "UTF-8") ]
        // );
        
        $this->renderFile(Yii::getPathOfAlias('application.views.public.sessions') . '.php', $params);
    }

    public function actionIndex()
    {
        $this->render("index");
    }

    public function actionPrivacyPolicy()
    {
        $this->render("privacy-policy");
    }

    public function actionTermsOfUse()
    {
        $this->render("terms-of-use");
    }
}

```


===== FILE: common/controllers/AdminController.php =====
```
 array("index"),
                "roles" => array("readAdmin"),
            ),
            array("allow",
                "actions" => array("create", "update", "delete"),
                "roles" => array("updateAdmin"),
            ),
			array("deny",
				"users" => array("*"),
			),
		);
	}

	public function actionCreate()
	{
		$model=new Admin;

		if(isset($_POST["Admin"]))
		{
			$model->attributes=$_POST["Admin"];

			if($model->save()){
				if( isset($_POST["Roles"]) ){
					foreach ($_POST["Roles"] as $key => $roleId) {
						$role = new AdminRole();
						$role->admin_id = $model->id;
						$role->role_id = $roleId;
						$role->save();
					}
				}
				
				$this->actionIndex(true);
				return true;
			}
		}

		$roleList = Role::model()->findAll();

		$this->renderPartial("create",array(
			"model" => $model,
			"roleList" => $roleList
		));

	}

	public function actionUpdate($id)
	{
		$model = $this->loadModel($id);

		if(isset($_POST["Admin"]))
		{
			$model->prevPass = $model->password;
			$model->attributes = $_POST["Admin"];

			AdminRole::model()->deleteAll("admin_id=".$model->id);

			if( isset($_POST["Roles"]) ){
				foreach ($_POST["Roles"] as $key => $roleId) {
					$role = new AdminRole();
					$role->admin_id = $model->id;
					$role->role_id = $roleId;
					$role->save();
				}
			}

			if($model->save()){
				$this->actionIndex(true);
			}
				
		}else{
			$roles = array();
			foreach ($model->roles as $key => $role) {
				array_push($roles, $role->role_id);
			}

			$roleList = Role::model()->findAll();

			$this->renderPartial("update",array(
				"model" => $model,
				"roles" => $roles,
				"roleList" => $roleList
			));
		}
	}

	public function actionDelete($id)
	{
		$this->loadModel($id)->delete();

		$this->actionIndex(true);
	}

	public function actionIndex($partial = false)
	{
		if( !$partial ){
			$this->layout="admin";
			$this->pageTitle = $this->adminMenu["cur"]->name;
		}

        $filter = new Admin('filter');

		if (isset($_GET['Admin'])){
            $filter->attributes = $_GET['Admin'];
        }

        Controller::accessFilter($filter);

        $dataProvider = $filter->search(50);
		$count = $filter->search(50, true);

		$params = array(
			"data" => $dataProvider->getData(),
			"pages" => $dataProvider->getPagination(),
			"filter" => $filter,
			"count" => $count,
			"labels" => Admin::model()->attributeLabels(),
		);

		if( !$partial ){
			$this->render("index".(($this->isMobile)?"Mobile":""), $params);
		}else{
			$this->renderPartial("index".(($this->isMobile)?"Mobile":""), $params);
		}
	}

	/**
	 * Returns the data model based on the primary key given in the GET variable.
	 * If the data model is not found, an HTTP exception will be raised.
	 * @param integer $id the ID of the model to be loaded
	 * @return Admin the loaded model
	 * @throws CHttpException
	 */
	public function loadModel($id)
	{
		$model=Admin::model()->with("roles.role")->findByPk($id);
		if($model===null)
			throw new CHttpException(404, "The requested page does not exist.");
		return $model;
	}

	/**
	 * Performs the AJAX validation.
	 * @param Admin $model the model to be validated
	 */
	protected function performAjaxValidation($model)
	{
		if(isset($_POST["ajax"]) && $_POST["ajax"] === "admin-form")
		{
			echo CActiveForm::validate($model);
			Yii::app()->end();
		}
	}
}

```


===== FILE: common/controllers/StatsController.php =====
```
 array("index", "months", "month"),
                "roles" => array("readAdmin"),
            ),
            array("deny",
                "users" => array("*"),
            ),
        );
    }

    /**
     * Главная: дневная статистика за текущий месяц + “плашка текущего месяца”
     * Плашка ведёт в /stats/months
     */
    public function actionIndex()
    {
        $service = new StatsService();

        $ym = date('Y-m');
        $from = $ym . '-01';
        $to = date('Y-m-d'); // по сегодня

        $kie = new Kie($this->getParam("KEYS", "KIE"));

        $summary = $service->getMonthSummary((int)date('Y'), (int)date('m'));
        $daily = $service->getDailyStats($from, $to);
        $balanceKie = $kie->getBalance();

        $this->render("index", array(
            "ym" => $ym,
            "summary" => $summary,
            "daily" => $daily,
            "balanceKie" => ($balanceKie["success"]) ? $this->number_format($balanceKie["credits"]) : "Не удалось получить",
            "curDate" => strtotime(date('d.m.Y')),
        ));
    }

    /**
     * Раздел “По месяцам” (список последних N месяцев)
     */
    public function actionMonths()
    {
        $service = new StatsService();
        $months = $service->getMonthlyStats(24);

        $this->render("months", array(
            "months" => $months,
        ));
    }

    /**
     * Деталка по конкретному месяцу (дни + сводка)
     * /stats/month?ym=2025-12
     */
    public function actionMonth($ym = null)
    {
        $ym = $ym ?: date('Y-m');

        if (!preg_match('~^\d{4}-\d{2}$~', $ym)) {
            throw new CHttpException(400, "Invalid ym");
        }

        [$y, $m] = array_map('intval', explode('-', $ym));

        $service = new StatsService();
        $summary = $service->getMonthSummary($y, $m);

        $from = $ym . '-01';
        $to = date('Y-m-t', strtotime($from));

        $daily = $service->getDailyStats($from, $to);

        $this->render("month", array(
            "ym" => $ym,
            "summary" => $summary,
            "daily" => $daily,
        ));
    }
}
```


===== FILE: common/controllers/UploaderController.php =====
```
 array('index','upload','getForm'),
                'users' => array('*')
            )
        );
    }

    /*! Всплывающее окно с формой для загрузки изображений.\n
    Плагин plupload. */
    public function actionGetForm() {
        $this->renderPartial('form');
    }

    /*! Страница, на которую плагин отправляет изображения. */
    public function actionUpload() {
        
        header("Expires: Mon, 26 Jul 1997 05:00:00 GMT");
        header("Last-Modified: " . gmdate("D, d M Y H:i:s") . " GMT");
        header("Cache-Control: no-store, no-cache, must-revalidate");
        header("Cache-Control: post-check=0, pre-check=0", false);
        header("Pragma: no-cache");

        // 5 minutes execution time
        @set_time_limit(5 * 60);

        // Uncomment this one to fake upload time
        // usleep(5000);

        // Settings
        $targetDir = Yii::app()->params['tempFolder'];
        //$targetDir = 'uploads';
        $cleanupTargetDir = true; // Remove old files
        $maxFileAge = 5*3600; // Temp file age in seconds


        // Create target dir
        if (!file_exists($targetDir)) {
            @mkdir($targetDir);
        }

        // Get a file name
        if (isset($_REQUEST["name"])) {
            $fileName = $_REQUEST["name"];
        } elseif (!empty($_FILES)) {
            $fileName = $_FILES["file"]["name"];
        } else {
            $fileName = uniqid("file_");
        }

        $filePath = $targetDir . "/" . $fileName;

        // Chunking might be enabled
        $chunk = isset($_REQUEST["chunk"]) ? intval($_REQUEST["chunk"]) : 0;
        $chunks = isset($_REQUEST["chunks"]) ? intval($_REQUEST["chunks"]) : 0;


        // Remove old temp files    
        if ($cleanupTargetDir) {
            if (!is_dir($targetDir) || !$dir = opendir($targetDir)) {
                die('{"jsonrpc" : "2.0", "error" : {"code": 100, "message": "Failed to open temp directory."}, "id" : "id"}');
            }

            while (($file = readdir($dir)) !== false) {
                $tmpfilePath = $targetDir . "/" . $file;

                // If temp file is current file proceed to the next
                if ($tmpfilePath == "{$filePath}.part") {
                    continue;
                }

                // Remove temp file if it is older than the max age and is not the current file
                if (!preg_match('/\.part$/', $file) && (filemtime($tmpfilePath) < time() - $maxFileAge)) {
                    @unlink($tmpfilePath);
                }
            }
            closedir($dir);
        }   


        // Open temp file
        if (!$out = @fopen("{$filePath}.part", $chunks ? "ab" : "wb")) {
            die('{"jsonrpc" : "2.0", "error" : {"code": 102, "message": "Failed to open output stream."}, "id" : "id"}');
        }

        if (!empty($_FILES)) {
            if ($_FILES["file"]["error"] || !is_uploaded_file($_FILES["file"]["tmp_name"])) {
                die('{"jsonrpc" : "2.0", "error" : {"code": 103, "message": "Failed to move uploaded file."}, "id" : "id"}');
            }

            // Read binary input stream and append it to temp file
            if (!$in = @fopen($_FILES["file"]["tmp_name"], "rb")) {
                die('{"jsonrpc" : "2.0", "error" : {"code": 101, "message": "Failed to open input stream."}, "id" : "id"}');
            }
        } else {    
            if (!$in = @fopen("php://input", "rb")) {
                die('{"jsonrpc" : "2.0", "error" : {"code": 101, "message": "Failed to open input stream."}, "id" : "id"}');
            }
        }

        while ($buff = fread($in, 4096)) {
            fwrite($out, $buff);
        }

        @fclose($out);
        @fclose($in);

        // Check if file has been uploaded
        if (!$chunks || $chunk == $chunks - 1) {
            // Strip the temp .part suffix off 
            rename("{$filePath}.part", $filePath);
        }

        // Return Success JSON-RPC response
        die('{"jsonrpc" : "2.0", "result" : "success", "id" : "id"}');
    }
}

```


===== FILE: common/controllers/CloudPaymentsController.php =====
```
 array("*"),
            ),
        );
    }
    
    // 1) Страница оплаты: /cloudpayments/pay?orderId=123
    public function actionPay()
    {
        $this->layout = "public";

        $userId = (int)Yii::app()->request->getParam('userId');
        $packageId = (int)Yii::app()->request->getParam('packageId');

        if( !$package = Package::model()->findByPk($packageId) ){
            throw new CHttpException(404, 'Пакет не найден');
        }

        if( !$user = User::model()->findByPk($userId) ){
            throw new CHttpException(404, 'Пакет не найден');
        }

        if( isset($user->orders) && is_array($user->orders) && count($user->orders) ){
            $order = $user->orders[0];
            if( (int) $order->status_id !== Order::STATUS_PENDING ){
                $order = new Order();
            }else{
                $order->created_at = date('Y-m-d H:i:s');
            }
        }else{
            $order = new Order();
        }

        if( $userId == 1 ){
            $package->price = 1;
        }

        $order->user_id = $userId;
        $order->package_id = $packageId;
        $order->status_id = Order::STATUS_PENDING;
        $order->sum = $package->price;
        $order->save();

        if (!$order || (int) $order->status_id !== Order::STATUS_PENDING) {
            throw new CHttpException(404, 'Не удалось создать заказ');
        }

        // Data — любые дополнительные данные, попадут в webhook в поле Data
        $data = [
            'order_id' => $order->id
        ];

        $this->render('pay', [
            'publicId'   => Yii::app()->params['cloudpayments']['publicId'],
            'amount'     => $order->sum,
            'currency'   => "RUB",
            'description'=> $order->getTitle(),
            'dataJson'   => CJSON::encode($data),
            'orderId'    => $order->id
        ]);
    }

    public function actionSubscribe()
    {
        $this->layout = "public";

        $userId = (int)Yii::app()->request->getParam('userId');
        $planVersionId = (int)Yii::app()->request->getParam('planVersionId');
        $paymentType = (string)Yii::app()->request->getParam('paymentType', 'card');

        if (!$user = User::model()->findByPk($userId)) {
            throw new CHttpException(404, 'Пользователь не найден');
        }

        $planVersion = PlanVersion::model()->with('plan')->findByPk($planVersionId);
        if (!$planVersion) {
            throw new CHttpException(404, 'Тариф не найден');
        }

        // Если уже есть активная подписка — можно не пускать (по желанию)
        $active = UserSubscription::model()->find(
            'user_id=:u AND status=:st AND canceled_at IS NULL',
            [':u' => $userId, ':st' => 'Active']
        );
        if ($active) {
            // минимально: просто покажем страницу/сообщение (можно сделать отдельный view)
            header('Content-Type: text/plain; charset=utf-8');
            echo "У вас уже есть активная подписка.";
            Yii::app()->end();
        }

        // создаём draft подписки у нас
        $sub = SubscriptionsService::createDraft($userId, $planVersionId);

        // Важно: для подписок CloudPayments нужен accountId :contentReference[oaicite:5]{index=5}
        // Важно: recurrent задается в data.cloudPayments.recurrent :contentReference[oaicite:6]{index=6}
        $data = [
            'kind' => 'subscription',
            'subscription_id' => (int)$sub->id,
            'plan_version_id' => (int)$planVersionId,
            'user_id' => (int)$userId,
            'cloudPayments' => [
                'recurrent' => [
                    'interval' => 'Month',
                    'period'   => 1,
                ],
            ],
        ];

        $description = 'Подписка: ' . (string)$planVersion->plan->title;

        if( $userId == 1 ){
            $planVersion->price = 1;
        }

        $this->render('subscribe', [
            'publicId'     => Yii::app()->params['cloudpayments']['publicId'],
            'amount'       => (float)$planVersion->price,
            'currency'     => !empty($planVersion->currency) ? (string)$planVersion->currency : 'RUB',
            'description'  => $description,
            'dataJson'     => CJSON::encode($data),
            'invoiceId'    => (int)$sub->id,
            'accountId'    => (int)$userId,
        ]);
    }

    // 2) Webhook: /pay/cloudpayments/confirm
    public function actionConfirm()
    {
        // 1. Сырое тело запроса (нужно и для логов, и для проверки подписи)
        $requestBody = file_get_contents('php://input');
        file_put_contents("CloudPayments.txt", $requestBody . "\n", FILE_APPEND);

        // // 2. Проверяем подпись Content-HMAC по сырому body
        // $headers = function_exists('getallheaders') ? getallheaders() : [];
        // $signature = null;

        // // на некоторых серверах ключ может приходить в другом регистре
        // foreach ($headers as $key => $value) {
        //     if (strtolower($key) === 'content-hmac') {
        //         $signature = $value;
        //         break;
        //     }
        // }

        // $secret = Yii::app()->params['cloudpayments']['apiSecret'];
        // $calculatedSign = base64_encode(hash_hmac('sha256', $requestBody, $secret, true));

        // if (!$signature || $signature !== $calculatedSign) {
        //     // Неверная подпись
        //     $this->sendJson(['code' => 13]); // неверная подпись
        //     Yii::log('CloudPayments invalid signature: ' . $requestBody, CLogger::LEVEL_ERROR);
        //     Yii::app()->end();
        // }

        // 3. Разбираем тело в массив (формат application/x-www-form-urlencoded)
        $data = [];
        parse_str($requestBody, $data);
        // Альтернатива: $data = $_POST; но для надёжности парсим именно то, по чему считали HMAC

        // Если это подписка — у нас в Data лежит kind/subscription_id
        $meta = [];
        if (!empty($data['Data'])) {
            $meta = is_string($data['Data']) ? @json_decode($data['Data'], true) : (is_array($data['Data']) ? $data['Data'] : []);
            if (!is_array($meta)) $meta = [];
        }

        if (!empty($meta['kind']) && $meta['kind'] === 'subscription' || !empty($meta['subscription_id']) || !empty($data['SubscriptionId'])) {
            SubscriptionsService::handlePay($data, $requestBody);
            $this->sendJson(['code' => 0]);
            Yii::app()->end();
        }

        // 4. Обработка только успешного платежа
        if (empty($data['Status']) || $data['Status'] !== 'Completed') {
            $this->sendJson(['code' => 0]); // просто принимаем
            Yii::app()->end();
        }

        // 5. Достаём orderId — сначала из InvoiceId
        $orderId = !empty($data['InvoiceId']) ? (int)$data['InvoiceId'] : null;

        // Если не передали InvoiceId — пробуем вытащить из Data (внутри — JSON)
        if (!$orderId && !empty($data['Data'])) {
            $meta = @json_decode($data['Data'], true);
            if (is_array($meta) && !empty($meta['order_id'])) {
                $orderId = (int)$meta['order_id'];
            }
        }

        if (!$orderId) {
            Yii::log('CloudPayments: no order id in webhook: ' . $requestBody, CLogger::LEVEL_ERROR);
            $this->sendJson(['code' => 0]);
            Yii::app()->end();
        }

        // 6. Ищем заказ
        $order = Order::model()->findByPk($orderId);

        if (!$order) {
            Yii::log('CloudPayments: order not found: ' . $orderId, CLogger::LEVEL_ERROR);
            $this->sendJson(['code' => 0]);
            Yii::app()->end();
        }

        if ((int)$order->status_id === Order::STATUS_PAID) {
            // Уже отмечен как оплачен
            $this->sendJson(['code' => 0]);
            Yii::app()->end();
        }

        // 7. Проверяем сумму/валюту (при необходимости)
        // В actionPay ты используешь $order->sum и жёстко "RUB",
        // поэтому сверяем именно с ними.
        if (isset($data['Amount']) && isset($data['Currency'])) {
            if ((float)$order->sum != (float)$data['Amount'] || $data['Currency'] !== 'RUB') {
                Yii::log('CloudPayments: amount/currency mismatch for order ' . $orderId, CLogger::LEVEL_ERROR);
                // на твой вкус: либо просто логируем, либо можно не засчитывать оплату
            }
        }

        // 8. Помечаем заказ как оплаченный
        $order->status_id = Order::STATUS_PAID;
        $order->paid_at   = date('Y-m-d H:i:s');
        $order->save(false);

        $logText = "Оплата {$data['Amount']} руб.\n";

        // 9. Начисляем генерации пользователю, если у заказа есть пакет
        if (isset($order->package) && !empty($order->package)) {
            $logText .= $order->package->getTitle()."\n";
            // $user->increaseBalance($order->package->amount);
            CreditsService::add((int)$order->user_id, $order->package->amount, 'buyCredits');
            // $user->setPaid();
        }
        $user = User::model()->findByPk($order->user_id);
        
        $logText .= "Пользователь {$user->name} ({$user->username})\n\nИтого за сегодня: ".$order->getTotalToday()." руб.";
        $this->logToTelegram($logText, Yii::app()->params["paymentLogsChatId"]);

        // 10. Уведомляем пользователя в Telegram
        if ($user) {
            $messageGenerator = new MessageGenerator($user);

            $this->sendMessageToTelegram(
                $user,
                $messageGenerator->paymentSuccessMessage($order->package->amount)
            );
        }

        // 11. Ответ CloudPayments
        $this->sendJson(['code' => 0]); // CloudPayments ожидает {"code":0} при успехе
        Yii::app()->end();
    }

    public function actionSbp()
    {
        $this->layout = "public";

        $userId    = (int)Yii::app()->request->getParam('userId');
        $packageId = (int)Yii::app()->request->getParam('packageId');

        if (!$package = Package::model()->findByPk($packageId)) {
            throw new CHttpException(404, 'Пакет не найден');
        }

        if (!$user = User::model()->findByPk($userId)) {
            throw new CHttpException(404, 'Пользователь не найден');
        }

        // 1) Создаем/переиспользуем pending-заказ (логика как в actionPay, но без "..." и магии)
        $order = Order::model()->find(
            'user_id = :uid AND package_id = :pid AND status_id = :st',
            [':uid' => $userId, ':pid' => $packageId, ':st' => Order::STATUS_PENDING]
        );

        if (!$order) {
            $order = new Order();
            $order->user_id    = $userId;
            $order->package_id = $packageId;
            $order->status_id  = Order::STATUS_PENDING;
        }

        $order->sum = $package->price;
        if (empty($order->created_at)) {
            $order->created_at = date('Y-m-d H:i:s');
        }

        if (!$order->save()) {
            throw new CHttpException(500, 'Не удалось создать заказ');
        }

        // 2) Дергаем CloudPayments SBP link API и редиректим на QrUrl
        try {
            $client = new CloudPaymentsApi();

            $payload = [
                'PublicId'    => Yii::app()->params['cloudpayments']['publicId'],
                'Amount'      => (float)$order->sum,
                'Currency'    => 'RUB',
                'Description' => $order->getTitle(),
                'AccountId'   => (string)$user->id,
                'InvoiceId'   => (string)$order->id,
                'Scheme'      => 'charge',

                // чтобы в вебхуках можно было восстановить контекст
                'JsonData'    => [
                    'order_id'   => (int)$order->id,
                    'user_id'    => (int)$user->id,
                    'package_id' => (int)$package->id,
                ],

                // возвращаем пользователя обратно после оплаты/ошибки
                'SuccessRedirectUrl' => $this->createAbsoluteUrl('cloudPayments/success', ['orderId' => (int)$order->id]),
                'FailRedirectUrl'    => $this->createAbsoluteUrl('cloudPayments/fail',    ['orderId' => (int)$order->id]),
            ];

            $qrUrl = $client->createSbpLink($payload);

            // ВАЖНО: редиректим пользователя прямо в SBP flow
            $this->redirect($qrUrl);
            Yii::app()->end();

        } catch (Throwable $e) {
            $order->status_id  = Order::STATUS_FAILED;
            $order->last_error = $e->getMessage();
            $order->save(false);

            // фоллбек: покажем простую страницу, чтобы не было "белого экрана"
            header('Content-Type: text/plain; charset=utf-8');
            echo "SBP payment init failed: " . $e->getMessage();
            Yii::app()->end();
        }
    }

    public function actionRecurrent()
    {
        $requestBody = file_get_contents('php://input');

        // 1) Подпись (Content-HMAC) — аналогично actionConfirm
        $headers = function_exists('getallheaders') ? getallheaders() : [];
        $signature = null;

        foreach ($headers as $k => $v) {
            if (strtolower($k) === 'content-hmac') {
                $signature = $v;
                break;
            }
        }

        $secret = Yii::app()->params['cloudpayments']['apiSecret'];
        $computed = base64_encode(hash_hmac('sha256', $requestBody, $secret, true));

        if (!hash_equals($computed, $signature)) {
            Yii::log('CloudPayments Recurrent: invalid signature. body=' . $requestBody, CLogger::LEVEL_ERROR);
            $this->sendJson(['code' => 13]);
            Yii::app()->end();
        }

        // 2) Парсим payload (CP шлёт querystring)
        $data = [];
        parse_str($requestBody, $data);

        // fallback: если CP прислал JSON (редко)
        if (empty($data)) {
            $json = @json_decode($requestBody, true);
            if (is_array($json)) {
                $data = $json;
            }
        }

        if (empty($data)) {
            Yii::log('CloudPayments Recurrent: empty payload. body=' . $requestBody, CLogger::LEVEL_WARNING);
            $this->sendJson(['code' => 0]);
            Yii::app()->end();
        }

        // 3) Обрабатываем (Status/LastTransactionDate/NextTransactionDate)
        SubscriptionsService::handleRecurrent($data, $requestBody);

        $this->sendJson(['code' => 0]);
        Yii::app()->end();
    }

    public function actionFail()
    {
        $requestBody = file_get_contents('php://input');
        $data = @json_decode($requestBody, true);

        // fallback если пришло не JSON
        if (!is_array($data)) {
            $data = Yii::app()->request->getIsPostRequest() ? $_POST : $_GET;
        }

        // (опционально) проверка подписи как у actionConfirm
        // Сейчас у вас подпись проверяется только в actionConfirm — можно вынести в helper,
        // но минимально оставим как есть (не блокируем поток).

        $meta = [];
        if (!empty($data['Data'])) {
            $meta = is_string($data['Data']) ? @json_decode($data['Data'], true) : (is_array($data['Data']) ? $data['Data'] : []);
            if (!is_array($meta)) $meta = [];
        }

        if (!empty($meta['kind']) && $meta['kind'] === 'subscription' || !empty($meta['subscription_id']) || !empty($data['SubscriptionId'])) {
            SubscriptionsService::handleFail($data, $requestBody ?: CJSON::encode($data));
            $this->sendJson(['code' => 0]); // Fail ожидает code=0 :contentReference[oaicite:9]{index=9}
            Yii::app()->end();
        }

        // старое поведение: просто лог
        Yii::log('CloudPayments FAIL params: ' . var_export($data, true), CLogger::LEVEL_WARNING);
        $this->sendJson(['code' => 0]);
        Yii::app()->end();
    }

    private function sendJson($data)
    {
        header('Content-Type: application/json; charset=utf-8');
        echo CJSON::encode($data);
    }
}
```


===== FILE: common/controllers/DictionaryController.php =====
```
 array("index", "list"),
				"roles" => array("readDictionary"),
			),
			array("allow",
				"actions" => array("update", "delete", "create"),
				"roles" => array("updateDictionary"),
			),
			array("deny",
				"users" => array("*"),
			),
		);
	}

	public function actionIndex($partial = false){
		unset($_GET["partial"]);

		$this->pageTitle = "Справочники";

		$models = ModelNames::model()->findAll(array("order" => "t.sort ASC", "condition" => "parent_id = '23'"));

		$params = array(
			"data" => $models,
			"count" => count( $models ),
		);

		if( !$partial ){
			$this->render("index".(($this->isMobile)?"Mobile":""), $params);
		}else{
			$this->renderPartial("index".(($this->isMobile)?"Mobile":""), $params);
		}
	}

	public function actionList($partial = false, $class = NULL){
		$_GET["class"] = ucfirst($_GET["class"]);

		unset($_GET["partial"]);
		// $this->title = "asda";

		if( $model = ModelNames::model()->find("code = '".$class."'") ){
			$this->pageTitle = $model->name;
		}

		$className = trim($class);

		if( !$className || !class_exists($className) ){
			throw new CHttpException(404, "Class «".$className."» is not defined");
		}

		$this->checkAccess($class);

        $filter = new $className('filter');
        if( isset($filter->sort) ){
        	$filter->sort = NULL;
        }
        if( isset($filter->active) ){
        	$filter->active = NULL;
        }

        if( !$filter->isDictionary ){
			throw new CHttpException(404, "Class «".$className."» is not dictionary");
		}

		if (isset($_GET[ $className ])){
            $filter->attributes = $_GET[ $className ];
        }

        $count = 50;

        if( isset($filter->countPerPage) ){
        	$count = $filter->countPerPage;
        }

        $dataProvider = $filter->sorted()->search($count);
		$count = $filter->search($count, true);

		$pages = $dataProvider->getPagination();
		$pages->route = "dictionary/list";

		$params = array(
			"data" => $dataProvider->getData(),
			"pages" => $pages,
			"filter" => $filter,
			"count" => $count,
			"labels" => $className::model()->attributeLabels(true),
			"modelName" => $this->adminMenu["items"][ $_GET["class"] ] ?? $this->adminMenu["items"][ mb_strtolower($_GET["class"], "UTF-8") ]
		);

		if( !$partial ){
			$this->render("list", $params);
		}else{
			$this->renderPartial("list", $params);
		}
	}

	public function actionCreate($class = NULL)
	{
		$className = ucfirst(trim($class));

		if( !$className || !class_exists($className) ){
			throw new CHttpException(404, "Class «".$className."» is not defined");
		}

		$this->checkAccess($class);

		$model = new $className();

		if( !$model->isDictionary ){
			throw new CHttpException(404, "Class «".$className."» is not dictionary");
		}

		$fields = $className::model()->attributeLabels(true);

		foreach ($fields as $key => $label) {
			if( $key == "id" && !isset($label->showInput) ){
				unset($fields[$key]);
			}
		}

		if(isset($_POST[ $className ])) {

			if( $photo = Controller::savePhoto(1200, 1200) ){
				$_POST[ $className ]["photo"] = $photo;
			}

			if( $model->updateObj($_POST[ $className ]) ){
				$this->actionList(true, $_GET["class"]);
				return true;
			}
		} else {
			if(isset($_GET[ $className ])) {
				$model->attributes = $_GET[ $className ];
			}
			$this->renderPartial("create",array(
				"model" => $model,
				"fields" => $fields,
				"modelName" => $this->adminMenu["items"][ $_GET["class"] ] ?? $this->adminMenu["items"][ mb_strtolower($_GET["class"], "UTF-8") ]
			));
		}
	}

	public function actionUpdate($id, $class = NULL)
	{
		$className = ucfirst(trim($class));

		if( !$className || !class_exists($className) ){
			throw new CHttpException(404, "Class «".$className."» is not defined");
		}

		$this->checkAccess($class);

		$model = $this->loadModel($id, $className);

		if( !$model->isDictionary ){
			throw new CHttpException(404, "Class «".$className."» is not dictionary");
		}

		$fields = $className::model()->attributeLabels(true);

		foreach ($fields as $key => $label) {
			if( $key == "id" && !isset($label->showInput) ){
				unset($fields[$key]);
			}
		}

		if(isset($_POST[ $className ])) {

			if( $photo = Controller::savePhoto(1200, 1200) ){
				if( $photo != $model->photo && !empty($model->photo) ){
					unlink($model->photo);
				}
				$_POST[ $className ]["photo"] = $photo;
			}

			if( $model->updateObj($_POST[ $className ]) ){
				$this->actionList(true, $_GET["class"]);
				return true;
			}
		} else {
			$this->renderPartial("update",array(
				"model" => $model,
				"fields" => $fields,
				"modelName" => $this->adminMenu["items"][ $_GET["class"] ] ?? $this->adminMenu["items"][ mb_strtolower($_GET["class"], "UTF-8") ]
			));
		}
	}

	public function actionDelete($id, $class = NULL)
	{
		$className = ucfirst(trim($class));

		if( !$className || !class_exists($className) ){
			throw new CHttpException(404, "Class «".$className."» is not defined");
		}

		$this->checkAccess($class);

		$model = $this->loadModel($id, $className);

		if( !$model->isDictionary ){
			throw new CHttpException(404, "Class «".$className."» is not dictionary");
		}

		$model->delete();

		$this->actionList(true, $_GET["class"]);
	}

	public function checkAccess($class){
		$modelName = ModelNames::model()->find(array(
			'condition' => "code='".$class."'",
		));

		if ( !Yii::app()->user->checkAccess($modelName->rule) ) {
            throw new CHttpException(403, "Forbidden");
        }
	}

	public function loadModel($id, $className)
	{
		$model = $className::model()->findByPk($id);

		if($model===null)
			throw new CHttpException(404, "The requested page does not exist.");
		return $model;
	}
}

```


===== FILE: common/controllers/User3Controller.php =====
```
 array("index"),
                "roles" => array("root"),
            ),
            array("allow",
                "actions" => array("create", "update", "delete"),
                "roles" => array("root"),
            ),
			array("deny",
				"users" => array("*"),
			),
		);
	}

	public function actionCreate()
	{
		$model=new User;

		if(isset($_POST["User"]))
		{
			$model->attributes=$_POST["User"];

			if($model->save()){
				if( isset($_POST["Roles"]) ){
					foreach ($_POST["Roles"] as $key => $roleId) {
						$role = new UserRole();
						$role->user_id = $model->id;
						$role->role_id = $roleId;
						$role->save();
					}
				}
				
				$this->actionIndex(true);
				return true;
			}
		}

		$roleList = Role::model()->findAll();

		$this->renderPartial("create",array(
			"model" => $model,
			"roleList" => $roleList
		));

	}

	public function actionUpdate($id)
	{
		$model = $this->loadModel($id);

		if(isset($_POST["User"]))
		{
			$model->prevPass = $model->password;
			$model->attributes = $_POST["User"];

			UserRole::model()->deleteAll("user_id=".$model->id);

			if( isset($_POST["Roles"]) ){
				foreach ($_POST["Roles"] as $key => $roleId) {
					$role = new UserRole();
					$role->user_id = $model->id;
					$role->role_id = $roleId;
					$role->save();
				}
			}

			if($model->save()){
				$this->actionIndex(true);
			}
				
		}else{
			$roles = array();
			foreach ($model->roles as $key => $role) {
				array_push($roles, $role->role_id);
			}

			$roleList = Role::model()->findAll();

			$this->renderPartial("update",array(
				"model" => $model,
				"roles" => $roles,
				"roleList" => $roleList
			));
		}
	}

	public function actionDelete($id)
	{
		$this->loadModel($id)->delete();

		$this->actionIndex(true);
	}

	public function actionIndex($partial = false)
	{
		if( !$partial ){
			$this->layout="admin";
			$this->pageTitle = $this->adminMenu["cur"]->name;
		}

        $filter = new User('filter');

		if (isset($_GET['User'])){
            $filter->attributes = $_GET['User'];
        }

        Controller::accessFilter($filter);

        $dataProvider = $filter->search(50);
		$count = $filter->search(50, true);

		$params = array(
			"data" => $dataProvider->getData(),
			"pages" => $dataProvider->getPagination(),
			"filter" => $filter,
			"count" => $count,
			"labels" => User::attributeLabels(),
		);

		if( !$partial ){
			$this->render("index".(($this->isMobile)?"Mobile":""), $params);
		}else{
			$this->renderPartial("index".(($this->isMobile)?"Mobile":""), $params);
		}
	}

	/**
	 * Returns the data model based on the primary key given in the GET variable.
	 * If the data model is not found, an HTTP exception will be raised.
	 * @param integer $id the ID of the model to be loaded
	 * @return User the loaded model
	 * @throws CHttpException
	 */
	public function loadModel($id)
	{
		$model=User::model()->with("roles.role")->findByPk($id);
		if($model===null)
			throw new CHttpException(404, "The requested page does not exist.");
		return $model;
	}

	/**
	 * Performs the AJAX validation.
	 * @param User $model the model to be validated
	 */
	protected function performAjaxValidation($model)
	{
		if(isset($_POST["ajax"]) && $_POST["ajax"] === "user-form")
		{
			echo CActiveForm::validate($model);
			Yii::app()->end();
		}
	}
}

```


===== FILE: common/controllers/SettingsController.php =====
```
array('index','create','update','delete','list',"categoryCreate","categoryUpdate","categoryDelete"),
				'roles'=>array('updateSettings'),
			),
			array('deny',
				'users'=>array('*'),
			),
		);
	}

	public function actionCategoryCreate()
	{
		$model=new Category;

		if(isset($_POST['Category']))
		{
			$model->attributes=$_POST['Category'];
			if($model->save()){
				$this->actionIndex(true);
				return true;
			}
		}

		$this->renderPartial('categoryCreate',array(
			'model'=>$model,
		));

	}

	public function actionCategoryUpdate($id)
	{
		$model=Category::model()->findByPk($id);

		if(isset($_POST['Category']))
		{
			$model->attributes=$_POST['Category'];
			if($model->save())
				$this->actionIndex(true);
		}else{
			$this->renderPartial('categoryUpdate',array(
				'model'=>$model,
			));
		}
	}

	public function actionCategoryDelete($id)
	{

		$model=Category::model()->findByPk($id);
		$model->delete();

		$this->actionIndex(true);
	}

	public function actionCreate()
	{
		$model=new Settings;

		if(isset($_POST['Settings']))
		{
			$model->attributes=$_POST['Settings'];
			if($model->save()){
				$this->actionList($model->parent_id,$model->category_id,true);
				return true;
			}
		}

		$this->renderPartial('create',array(
			'model'=>$model,
		));

	}

	public function actionUpdate($id)
	{
		$model=$this->loadModel($id);

		if(isset($_POST['Settings']))
		{
			$model->attributes=$_POST['Settings'];
			if($model->save())
				$this->actionList($model->parent_id,$model->category_id,true);
		}else{
			$this->renderPartial('update',array(
				'model'=>$model,
			));
		}
	}

	public function actionDelete($id)
	{
		$model = $this->loadModel($id);
		$model->delete();

		$this->actionList($model->parent_id, $model->category_id, true);
	}

	public function actionIndex($partial = false)
	{
		if( !$partial ){
			$this->layout='admin';
		}
  
		$model = Category::model()->findAll(array("order"=>'sort ASC'));

		$option = array(
			'data'=>$model,
			'labels'=>Category::model()->attributeLabels()
		);
		if( !$partial ){
			$this->render('index',$option);
		}else{
			$this->renderPartial('index',$option);
		}
	}

	public function actionList($parent_id = false,$id = false,$partial = false)
	{
		if( !$partial ){
			$this->layout='admin';
		}

		if( $id ){
        	$category = Category::model()->findByPk($id);
        }else if( $parent_id ){
        	$parent = Settings::model()->find("id=".$parent_id);
        }

		$filter = new Settings('filter');
		$criteria = new CDbCriteria();

		if (isset($_GET['Settings']))
        {
            $filter->attributes = $_GET['Settings'];
            foreach ($_GET['Settings'] AS $key => $val)
            {
                if ($val != '')
                {
                    $criteria->addSearchCondition($key, $val);
                }
            }
        }

        $criteria->order = 'sort ASC';

        if( $id ){
        	$criteria->addSearchCondition('category_id', $id);
        	$back_link = $this->createUrl('/'.$this->adminMenu["cur"]->code.'/index');
        }else if( $parent_id ){
        	$criteria->addSearchCondition('parent_id', $parent_id);

        	if( $parent->parent_id == 0 ){
        		$back_link = $this->createUrl('/'.$this->adminMenu["cur"]->code.'/list',array('id'=>$parent->category_id));
        	}else{
        		$back_link = $this->createUrl('/'.$this->adminMenu["cur"]->code.'/list',array('parent_id'=>$parent->parent_id));
        	}
        }
  
		$model = Settings::model()->findAll($criteria);

		$option = array(
			'data'=>$model,
			'filter'=>$filter,
			'back_link'=>$back_link,
			'labels'=>Settings::model()->attributeLabels()
		);

		if( $id ){
        	$option['category'] = $category;
        }else if( $parent_id ){
        	$option['parent'] = $parent;
        }

    	if( !$partial ){
			$this->render('list'.(($this->isMobile)?"Mobile":""),$option);
		}else{
			$this->renderPartial('list'.(($this->isMobile)?"Mobile":""),$option);
		}

	}

	public function loadModel($id)
	{
		$model=Settings::model()->findByPk($id);
		if($model===null)
			throw new CHttpException(404,'The requested page does not exist.');
		return $model;
	}
}

```


===== FILE: common/controllers/SiteController.php =====
```
 array("notifications"),
                "users" => array("*"),
            ),
            array("allow",
                "actions" => array("download", "viewFile"),
                "roles" => array("readAll"),
            ),
            // array("allow",
            //     "actions" => array("upload", "install"),
            //     "roles" => array("root"),
            // ),
            array("allow",
                "actions" => array("error", "index", "login", "logout", "install"),
                "users" => array("*"),
            ),
            array("deny",
                "users" => array("*"),
            ),
        );
    }

	/**
	 * Declares class-based actions.
	 */
	public function actions()
	{
		return array(
			// captcha action renders the CAPTCHA image displayed on the contact page
			"captcha" => array(
				"class" => "CCaptchaAction",
				"backColor" => 0xFFFFFF,
			),
			// page action renders "static" pages stored under "protected/views/site/pages"
			// They can be accessed via: index.php?r=site/page&view=FileName
			"page" => array(
				"class" => "CViewAction",
			),
		);
	}

    public function actionDownload($file_id){
        $file = File::model()->findByPk($file_id);
        if($file===null)
            throw new CHttpException(404, "The requested page does not exist.");

        $this->downloadFile(Yii::app()->params['saveFolder']."/".$file->name, $file->original);
    }

    public function actionViewFile($file_id){
        $file = File::model()->findByPk($file_id);
        if($file===null)
            throw new CHttpException(404, "The requested page does not exist.");

        $filename = Yii::app()->params['saveFolder']."/".$file->name;
        if (file_exists($filename)) {
            $ext = array_pop(explode(".", $filename));
            // header('Content-Description: File Transfer');
            switch (strtolower($ext)) {
                case "pdf":
                    header('Content-Type: application/pdf');
                    break;
                case "jpg":
                case "jpeg":
                case "png":
                case "gif":
                case "gif":
                    header('Content-Type: image/'.$ext);
                    break;
                
                default:
                    header('Content-Type: application/octet-stream');
                    break;
            }
            // header('Content-Disposition: attachment; filename="'.basename($filename).'"');
            // header('Expires: 0');
            // header('Cache-Control: must-revalidate');
            // header('Pragma: public');
            header('Content-Length: ' . filesize($filename));
            readfile($filename);
            exit;
        }

        // readfile(Yii::app()->params['saveFolder']."/".$file->name);
        // $this->downloadFile(, $file->original);
    }

	/**
	 * This is the action to handle external exceptions.
	 */
	public function actionError()
	{
        $this->layout="public";
	    if($error=Yii::app()->errorHandler->error)
	    {
	    	// if(Yii::app()->request->isAjaxRequest)
	    		// echo $error["message"];
	    	// else
	        	$this->render("error", $error);
	    }
	}

	/**
	 * Displays the contact page
	 */
	public function actionContact()
	{
		$model=new ContactForm;
		if(isset($_POST["ContactForm"]))
		{
			$model->attributes=$_POST["ContactForm"];
			if($model->validate())
			{
				$headers="From: {$model->email}\r\nReply-To: {$model->email}";
				mail(Yii::app()->params["adminEmail"], $model->subject, $model->body, $headers);
				Yii::app()->user->setFlash("contact", "Thank you for contacting us. We will respond to you as soon as possible.");
				$this->refresh();
			}
		}
		$this->render("contact",array("model" => $model));
	}

	/**
	 * Displays the login page
	 */
	public function actionIndex(){
        if( !Yii::app()->user->isGuest ){
            $this->redirect($this->createUrl(Yii::app()->params["defaultAdminRedirect"]));
        }else{
            echo "Пусто :(";
        }
	}

	public function actionLogin()
	{
        $this->layout="service";
		if( !Yii::app()->user->isGuest ) $this->redirect($this->createUrl(Yii::app()->params["defaultAdminRedirect"]));

		// $this->layout="admin";
		if (!defined("CRYPT_BLOWFISH")||!CRYPT_BLOWFISH)
			throw new CHttpException(500, "This application requires that PHP was compiled with Blowfish support for crypt().");

		$model=new LoginForm;

		// if it is ajax validation request
		if(isset($_POST["ajax"]) && $_POST["ajax"]==="login-form")
		{
			echo CActiveForm::validate($model);
			Yii::app()->end();
		}

		// collect user input data
		if(isset($_POST["LoginForm"]))
		{
			$model->attributes=$_POST["LoginForm"];
			// validate user input and redirect to the previous page if valid
			if($model->validate() && $model->login())
				$this->redirect($this->createUrl(Yii::app()->params["defaultAdminRedirect"]));
		}
		// display the login form
		$this->render("login",array("model" => $model));
	}

	/**
	 * Logs out the current user and redirect to homepage.
	 */
	public function actionLogout()
	{
		Yii::app()->user->logout();
		$this->redirect(Yii::app()->homeUrl);
	}

	public function actionUpload(){
        // Make sure file is not cached (as it happens for example on iOS devices)
        header("Expires: Mon, 26 Jul 1997 05:00:00 GMT");
        header("Last-Modified: " . gmdate("D, d M Y H:i:s") . " GMT");
        header("Cache-Control: no-store, no-cache, must-revalidate");
        header("Cache-Control: post-check=0, pre-check=0", false);
        header("Pragma: no-cache");

        /* 
        // Support CORS
        header("Access-Control-Allow-Origin: *");
        // other CORS headers if any...
        if ($_SERVER["REQUEST_METHOD"] == "OPTIONS") {
            exit; // finish preflight CORS requests here
        }
        */

        // 5 minutes execution time
        @set_time_limit(5 * 60);

        // Uncomment this one to fake upload time
        // usleep(5000);

        // Settings
        $targetDir = "upload/images";
        //$targetDir = "uploads";
        $cleanupTargetDir = true; // Remove old files
        $maxFileAge = 60 * 3600; // Temp file age in seconds


        // Create target dir
        if (!file_exists($targetDir)) {
            @mkdir($targetDir);
        }

        // Get a file name
        if (isset($_REQUEST["name"])) {
            $fileName = $_REQUEST["name"];
        } elseif (!empty($_FILES)) {
            $fileName = $_FILES["file"]["name"];
        } else {
            $fileName = uniqid("file_");
        }

        $filePath = $targetDir . DIRECTORY_SEPARATOR . $fileName;
        echo $filePath;

        // Chunking might be enabled
        $chunk = isset($_REQUEST["chunk"]) ? intval($_REQUEST["chunk"]) : 0;
        $chunks = isset($_REQUEST["chunks"]) ? intval($_REQUEST["chunks"]) : 0;


        // Remove old temp files    
        if ($cleanupTargetDir) {
            if (!is_dir($targetDir) || !$dir = opendir($targetDir)) {
                die('{"jsonrpc" : "2.0", "error" : {"code": 100, "message": "Failed to open temp directory."}, "id" : "id"}');
            }

            while (($file = readdir($dir)) !== false) {
                $tmpfilePath = $targetDir . DIRECTORY_SEPARATOR . $file;

                // If temp file is current file proceed to the next
                if ($tmpfilePath == "{$filePath}.part") {
                    continue;
                }

                // Remove temp file if it is older than the max age and is not the current file
                if (preg_match("/\.part$/", $file) && (filemtime($tmpfilePath) < time() - $maxFileAge)) {
                    @unlink($tmpfilePath);
                }
            }
            closedir($dir);
        }   


        // Open temp file
        if (!$out = @fopen("{$filePath}.part", $chunks ? "ab" : "wb")) {
            die('{"jsonrpc" : "2.0", "error" : {"code": 102, "message": "Failed to open output stream."}, "id" : "id"}');
        }

        if (!empty($_FILES)) {
            if ($_FILES["file"]["error"] || !is_uploaded_file($_FILES["file"]["tmp_name"])) {
                die('{"jsonrpc" : "2.0", "error" : {"code": 103, "message": "Failed to move uploaded file."}, "id" : "id"}');
            }

            // Read binary input stream and append it to temp file
            if (!$in = @fopen($_FILES["file"]["tmp_name"], "rb")) {
                die('{"jsonrpc" : "2.0", "error" : {"code": 101, "message": "Failed to open input stream."}, "id" : "id"}');
            }
        } else {    
            if (!$in = @fopen("php://input", "rb")) {
                die('{"jsonrpc" : "2.0", "error" : {"code": 101, "message": "Failed to open input stream."}, "id" : "id"}');
            }
        }

        while ($buff = fread($in, 4096)) {
            fwrite($out, $buff);
        }

        @fclose($out);
        @fclose($in);

        // Check if file has been uploaded
        if (!$chunks || $chunk == $chunks - 1) {
            // Strip the temp .part suffix off 
            rename("{$filePath}.part", $filePath);
        }

        // Return Success JSON-RPC response
        die('{"jsonrpc" : "2.0", "result" : null, "id" : "id"}');
    }

	/*! Сброс всех правил. */
    public function actionInstall() {

        // die();
        if ( Yii::app()->user->id != 1 ) {
            throw new CHttpException(403, "Forbidden");
        }

        $auth=Yii::app()->authManager;
        
        //сбрасываем все существующие правила
        $auth->clearAll();

        // Пользователи
        $auth->createOperation("readAdmin", "Просмотр пользователей");
        $auth->createOperation("updateAdmin", "Создание/изменение/удаление пользователей");

        // Поставки
        $auth->createOperation("readTracking", "Просмотр поставок");
        $auth->createOperation("updateTracking", "Создание/изменение поставок");

        // Партнерка
        $auth->createOperation("readRefer", "Просмотр партнеров");
        $auth->createOperation("updateRefer", "Создание/изменение партнеров");

        // Статистика
        $auth->createOperation("readStat", "Просмотр аналитики");
        $auth->createOperation("updateStat", "Создание/изменение аналитики");

        // Оптовые заказы
        $auth->createOperation("readOpt", "Просмотр оптовых заказов");
        $auth->createOperation("updateOpt", "Создание/изменение оптовых заказов");

        // Справочники
        $auth->createOperation("readDictionary", "Просмотр справочников");
        $auth->createOperation("updateDictionary", "Создание/изменение справочников");
        $auth->createOperation("accessBreed", "Доступ к породам");
        $auth->createOperation("accessTk", "Доступ к ТК");
        $auth->createOperation("accessSupplier", "Доступ к поставщикам");
        $auth->createOperation("accessCity", "Доступ к городам");

        // Заказы
        $auth->createOperation("readOrder", "Просмотр заказов");
        $auth->createOperation("readSumOrder", "Просмотр суммы заказов");
        $auth->createOperation("updateOrder", "Создание/изменение заказов");
        $auth->createOperation("deleteOrder", "Удаление заказов");
        $auth->createOperation("changeStatusOrder", "Изменения статуса заказа");
        $auth->createOperation("markPlatesOrder", "Массово помечать таблички готовностью");

        // Зарплата
        $auth->createOperation("readSalary", "Просмотр зарплаты");
        $auth->createOperation("updateSalary", "Редактирование зарплаты");

        //Настройки
        $auth->createOperation("updateSettings", "Изменение настроек");
    // Права --------------------------------------------------- Права

        // Мастер
        $role = $auth->createRole("master");
        $role->addChild("readOrder");
        $role->addChild("accessBreed");
        $role->addChild("readDictionary");

        // Швея
        $role = $auth->createRole("sew");
        $role->addChild("master");

        // Фотограф/упаковщик
        $role = $auth->createRole("packer");
        $role->addChild("master");
        $role->addChild("updateOrder");
        $role->addChild("readTracking");
        $role->addChild("updateTracking");
        $role->addChild("readSumOrder");
        $role->addChild("changeStatusOrder");
        $role->addChild("markPlatesOrder");

        // Продажник
        $role = $auth->createRole("salesman");
        $role->addChild("packer");
        $role->addChild("updateOrder");
        $role->addChild("deleteOrder");
        $role->addChild("updateDictionary");
        $role->addChild("accessCity");

        // Руководитель
        $role = $auth->createRole("supervisor");
        $role->addChild("salesman");
        $role->addChild("readAdmin");
        $role->addChild("updateAdmin");
        $role->addChild("readTracking");
        $role->addChild("updateTracking");
        $role->addChild("readStat");
        $role->addChild("readDictionary");
        $role->addChild("accessTk");
        $role->addChild("accessSupplier");
        $role->addChild("markPlatesOrder");
        $role->addChild("readSalary");
        $role->addChild("readRefer");
        $role->addChild("updateRefer");
        $role->addChild("readOpt");
        $role->addChild("updateOpt");
        
        // Root-доступ
        $role = $auth->createRole("root");
        $role->addChild("supervisor");
        $role->addChild("updateStat");
        $role->addChild("updateSalary");
        $role->addChild("updateSettings");

    // Роли --------------------------------------------------- Роли
        
        // Связываем пользователей с ролями
        $users = Admin::model()->with("roles.role")->findAll();
        foreach ($users as $i => $user) {

            foreach ($user->roles as $j => $role) {
                $auth->assign($role->role->code, $user->id);
            }
        }

        // Сохраняем роли и операции
        $auth->save();
        
        // die();
        $this->render("install");
    }
}

```


===== FILE: common/views/uploader/form.php =====
```
Ваш браузер не поддерживает Flash.
``` ===== FILE: common/views/settings/categoryUpdate.php ===== ```

Редактирование категории

renderPartial('_categoryForm', array('model'=>$model)); ?>
``` ===== FILE: common/views/settings/update.php ===== ```

Редактирование adminMenu["cur"]->rod_name?>

renderPartial('_form', array('model'=>$model)); ?>
``` ===== FILE: common/views/settings/index.php ===== ```

adminMenu["cur"]->name?>

code.'/categorycreate')?>" class="ajax-form ajax-create b-butt b-top-butt">Добавить beginWidget('CActiveForm'); ?> isMobile ): ?> endWidget(); ?> ``` ===== FILE: common/views/settings/_categoryForm.php ===== ```
beginWidget('CActiveForm', array( 'id'=>'faculties-form', 'enableAjaxValidation'=>false, )); ?> errorSummary($model); ?>
labelEx($model,'name'); ?> textField($model,'name',array('maxlength'=>255,'required'=>true)); ?> error($model,'name'); ?>
labelEx($model,'code'); ?> textField($model,'code',array('maxlength'=>255,'required'=>true)); ?> error($model,'code'); ?>
labelEx($model,'sort'); ?> textField($model,'sort',array('maxlength'=>255,'required'=>true)); ?> error($model,'sort'); ?>
isNewRecord ? 'Добавить' : 'Сохранить'); ?>
endWidget(); ?>
``` ===== FILE: common/views/settings/categoryCreate.php ===== ```

Добавление категории

renderPartial('_categoryForm', array('model'=>$model)); ?>
``` ===== FILE: common/views/settings/create.php ===== ```

Добавление adminMenu["cur"]->rod_name?>

renderPartial('_form', array('model'=>$model)); ?>
``` ===== FILE: common/views/settings/list.php ===== ``` Назад

adminMenu["cur"]->name?>: name;}else{echo $parent->name;}?>

code.'/create',array((($category)?'category_id':'parent_id')=>($category)?$category->id:$parent->id))?>" class="ajax-form ajax-create b-butt b-top-butt">Добавить beginWidget('CActiveForm'); ?> $item): ?> code ): ?>
Действия
name?> code?> replaceToBr($this->cutText($item->value))?> code.'/list',array('parent_id'=>$item->id))?>">name?> code.'/update',array('id'=>$item->id))?>" class="ajax-form ajax-update b-tool b-tool-update b-double-click-click" title="Редактировать adminMenu["cur"]->vin_name?>"> code.'/delete',array('id'=>$item->id))?>" class="ajax-form ajax-delete b-tool b-tool-delete" data-name="adminMenu["cur"]->vin_name?>" title="Удалить adminMenu["cur"]->vin_name?>">
Пусто
endWidget(); ?> ``` ===== FILE: common/views/settings/_form.php ===== ```
beginWidget('CActiveForm', array( 'id'=>'faculties-form', 'enableAjaxValidation'=>false, )); ?> errorSummary($model); ?>
labelEx($model,'name'); ?> textField($model,'name',array('maxlength'=>255,'required'=>true)); ?> error($model,'name'); ?>
labelEx($model,'code'); ?> textField($model,'code',array('maxlength'=>255)); ?> error($model,'code'); ?>
labelEx($model,'sort'); ?> numberField($model,'sort',array('maxlength'=>255,'required'=>true)); ?> error($model,'sort'); ?>
labelEx($model,'value'); ?> textArea($model,'value',array('class'=>"b-settings-textarea")); ?> error($model,'value'); ?>
isNewRecord ? 'Добавить' : 'Сохранить'); ?>
endWidget(); ?>
``` ===== FILE: common/views/settings/listMobile.php ===== ``` Назад

adminMenu["cur"]->name?>: name;}else{echo $parent->name;}?>

code.'/create',array((($category)?'category_id':'parent_id')=>($category)?$category->id:$parent->id))?>" class="ajax-form ajax-create b-butt b-top-butt">Добавить beginWidget('CActiveForm'); ?> $item): ?> code ): ?>
, Действия

name?>

code?>
replaceToBr($this->cutText($item->value))?> code.'/list',array('parent_id'=>$item->id))?>">name?> code.'/update',array('id'=>$item->id))?>" class="ajax-form ajax-update b-tool b-tool-update b-double-click-click" title="Редактировать adminMenu["cur"]->vin_name?>"> code.'/delete',array('id'=>$item->id))?>" class="ajax-form ajax-delete b-tool b-tool-delete" data-name="adminMenu["cur"]->vin_name?>" title="Удалить adminMenu["cur"]->vin_name?>">
Пусто
endWidget(); ?> ``` ===== FILE: common/views/admin/update.php ===== ```

Редактирование администратора

renderPartial("_form", array("model" => $model, "roles" => $roles, "roleList" => $roleList)); ?>
``` ===== FILE: common/views/admin/index.php ===== ```

Администраторы

" class="ajax-form ajax-create b-butt b-top-butt">Добавить beginWidget('CActiveForm'); ?> $item): ?>
Действия
1, "placeholder" => "Поиск по логину")); ?> Сбросить
id?> name?> login?> getRoleNames())?> $item->id))?>" class="ajax-form b-double-click-click ajax-update b-tool b-tool-update" title="Редактировать adminMenu["cur"]->vin_name?>">$item->id))?>" class="ajax-form ajax-delete b-tool b-tool-delete" title="Удалить adminMenu["cur"]->vin_name?>" data-name="adminMenu["cur"]->vin_name?>">
Ничего не найдено, попробуйте изменить фильтр
endWidget(); ?>
widget('CLinkPager', array( 'header' => '', 'lastPageLabel' => 'последняя »', 'firstPageLabel' => '« первая', 'pages' => $pages, 'prevPageLabel' => '< назад', 'nextPageLabel' => 'далее >' )) ?>
Всего администраторов:
``` ===== FILE: common/views/admin/indexMobile.php ===== ```

Администраторы

" class="ajax-form ajax-create b-butt b-top-butt">Добавить beginWidget('CActiveForm'); ?>
$item): ?>
Действия
1, "placeholder" => "Поиск по логину")); ?> 2, "placeholder" => "Поиск по email")); ?> Сбросить
id?> fio?> login?> email?> getRoleNames())?> $item->id))?>" class="ajax-form b-double-click-click ajax-update b-tool b-tool-update" title="Редактировать adminMenu["cur"]->vin_name?>">$item->id))?>" class="ajax-form ajax-delete b-tool b-tool-delete" title="Удалить adminMenu["cur"]->vin_name?>" data-name="adminMenu["cur"]->vin_name?>">
Ничего не найдено, попробуйте изменить фильтр
endWidget(); ?>
widget('CLinkPager', array( 'header' => '', 'lastPageLabel' => 'последняя »', 'firstPageLabel' => '« первая', 'pages' => $pages, 'prevPageLabel' => '< назад', 'nextPageLabel' => 'далее >' )) ?>
Всего администраторов:
``` ===== FILE: common/views/admin/create.php ===== ```

Добавление администратора

renderPartial("_form", array("model" => $model, "roles" => array(), "roleList" => $roleList)); ?>
``` ===== FILE: common/views/admin/_form.php ===== ```
beginWidget("CActiveForm", array( "id" => "faculties-form", "enableAjaxValidation" => false, )); ?> errorSummary($model); ?>
labelEx($model, "active"); ?> checkbox($model, "active"); ?> error($model, "active"); ?>
labelEx($model, "name"); ?> textField($model, "name", array("maxlength" => 255, "required" => true)); ?> error($model, "name"); ?>
labelEx($model, "login"); ?> textField($model, "login", array("maxlength" => 255, "required" => true)); ?> error($model, "login"); ?>
labelEx($model, "password"); ?> passwordField($model, "password", array("size" => 60, "maxlength" => 128, "required" => true)); ?> error($model, "password"); ?>
labelEx($model, "roles"); ?> '
{input}{label}
', "separator" => "")); ?> error($model, "Roles"); ?>
isNewRecord ? "Добавить" : "Сохранить"); ?>
endWidget(); ?>
``` ===== FILE: common/views/cloudPayments/subscribe.php ===== ``` Подписка ``` ===== FILE: common/views/cloudPayments/pay.php ===== ``` Оплата пакета генераций ``` ===== FILE: common/views/layouts/service.php ===== ``` /css/reset.css" media="screen, projection" /> /css/screen.css" media="screen, projection" /> /css/print.css" media="print" /> /css/main.css" /> /css/form.css" /> <?php echo CHtml::encode($this->pageTitle); ?>
``` ===== FILE: common/views/layouts/public.php ===== ``` ``` ===== FILE: common/views/layouts/admin.php ===== ``` <?=$this->pageTitle?> /css/jquery.qtip.min.css" type="text/css"> /css/reset.css" /> /css/jquery.fancybox.css" /> /css/jquery-ui.min.css" /> /css/preloader.css" /> /css/select2.css" /> /js/plupload/jquery.plupload.queue/css/jquery.plupload.queue.css" /> /css/layout.css?" /> isMobile): ?> /css/admin-mobile.css?" /> includeChart ): ?> /css/chart.css" /> includeTinymce ): ?> scripts AS $script): ?> isMobile): ?> class="is-mobile">
user->isGuest ): ?>

Выйти

Меню

adminMenu["items"] as $i => $menuItem):?> rule == NULL || Yii::app()->user->checkAccess($menuItem->rule)) && $menuItem->parent_id == 0 ): ?>

isMobile)?$menuItem->name:$menuItem->menu_name)?>

params['debug']): ?>
debugText?>

user->checkAccess("root") && Yii::app()->params['debug']): ?>
db->getStats(); echo "Кол-во запросов: $queryCount, Общее время запросов: ".sprintf('%0.5f',$queryTime)."s"; ?>

Вы действительно хотите удалить ?

``` ===== FILE: common/views/dictionary/update.php ===== ```

Редактирование rod_name?>

renderPartial("_form", array("model" => $model, "fields" => $fields)); ?>
``` ===== FILE: common/views/dictionary/index.php ===== ```

adminMenu["cur"]->name?>

beginWidget('CActiveForm'); ?> $item): ?> user->checkAccess($item->rule) ): ?>
Наименование
code.'/list',array('class'=>$item->code))?>" class="b-double-click">name?>
Ничего не найдено, попробуйте изменить фильтр
endWidget(); ?>
Всего элементов:
``` ===== FILE: common/views/dictionary/indexMobile.php ===== ```

adminMenu["cur"]->name?>

$item): ?> user->checkAccess($item->rule) ): ?>
Наименование
code.'/list',array('class'=>$item->code))?>" class="b-double-click">name?>
``` ===== FILE: common/views/dictionary/create.php ===== ```

Добавление rod_name?>

renderPartial("_form", array("model" => $model, "fields" => $fields)); ?>
``` ===== FILE: common/views/dictionary/list.php ===== ``` parent_id != 0 ): ?> adminMenu["cur"]->code."/list", array("class" => "Field"))?>" class="b-back">Назад adminMenu["cur"]->code."/index")?>" class="b-back">Назад

name?>

user->checkAccess('updateDictionary') ): ?>adminMenu["cur"]->code."/create", array('class' => $_GET["class"], 'Variant[field_id]' => ((isset($_GET["Variant"]) && $_GET["Variant"]["field_id"])?$_GET["Variant"]["field_id"]:NULL) ))?>" class="ajax-form ajax-create b-butt b-top-butt">Добавить beginWidget('CActiveForm'); ?> isMobile() ): ?>
$label): ?> $label): ?> type ?? "default"): case "bool": ?> model))?CHtml::listData($label->model::model()->sorted()->findAll($label->condition ?? null), $label->key ?? 'id', $label->value ?? 'name'):$label->list ?> $item): ?> $field): ?>
width) ): ?>style="width: width?>; min-width: width?>;">name?> px;">Действия
"Нет", 1 => "Да"), array("class" => "select2", "empty" => "Да/нет", "tabindex" => 1, "placeholder" => "Да/нет")); ?> "select2", "id" => $key."_filter", "empty" => "Все", "tabindex" => 1, "placeholder" => "Не выбрано")); ?> 1, "placeholder" => "Поиск")); } ?> Сбросить">
class) ): ?>class="class?>"> type ?? "default"): case "bool": ?> {$key})?"Да":"Нет" )?> {$key}, 0, ',', ' ' )?> {$key}, 0, ',', ' ' )?> руб. height)):?>style="height: height?>px" class="b-list-image" alt=""> getImageHtml($item->{$key}, 300)?> {$key}?> getSourceLink()?> cutText($item->{$key}, 256)?> type == "select" || $item->type == "radio"): ?> code.'/list',array('class'=>"Variant", "Variant[field_id]" => $item->id))?>">{$key}?> {$key}?> getFieldsHTML()?> relation) && isset($item->{$field->relation}) ): ?> {$field->relation}->{$field->value??'name'}?> {$key}?> withoutActions) ): ?> user->checkAccess('updateDictionary') && isset($item->id) ): ?> code.'/update',array('id'=>$item->id, 'class' => $_GET["class"]))?>" class="ajax-form b-double-click-click ajax-update b-tool b-tool-update" title="Редактировать vin_name?>"> user->checkAccess('root') && isset($item->id) ): ?>code.'/delete',array('id'=>$item->id, 'class' => $_GET["class"]))?>" class="ajax-form ajax-delete b-tool b-tool-delete" title="Удалить vin_name?>" data-name="vin_name?>">
Ничего не найдено
isMobile() ): ?> endWidget(); ?>
widget('CLinkPager', array( 'header' => '', 'lastPageLabel' => 'последняя »', 'firstPageLabel' => '« первая', 'pages' => $pages, 'prevPageLabel' => '< назад', 'nextPageLabel' => 'далее >' )) ?>
Всего элементов:
``` ===== FILE: common/views/dictionary/_form.php ===== ```
beginWidget("CActiveForm", array( "id" => "faculties-form", "enableAjaxValidation" => false, )); ?> errorSummary($model); ?> $field): ?> withoutForm) && $field->withoutForm == true){ continue; } ?> type ?? "default"): case "bool": ?>
labelEx($model, $code); ?> checkbox($model, $code); ?> error($model, $code); ?>
labelEx($model, $code); ?>
renderPartial("/uploader/form", array('maxFiles'=>40,'extensions'=>'png,jpg,jpeg,JPEG,gif,pdf,xml,doc,docx,txt,xls,xlsx,csv', 'title' => 'Прикрепление файлов', 'selector' => '#files', 'maxFiles' => 1)); ?>
{$code})): ?>
model))?CHtml::listData($field->model::model()->sorted()->findAll($field->condition ?? null), "id", $field->value ?? "name"):$field->list ?>
labelEx($model, $code); ?> dropDownList($model, $code, array(0 => "Не выбрано") + $arData, array("class" => "select2", "required" => in_array($code, $required))); ?> error($model, $code); ?>
labelEx($model, $code); ?> textField($model, $code, array("maxlength" => 32, "autocomplete" => "off", "placeholder" => "...", "class" => "date", "id" => "date_".$code, "title" => "Поле обязательно")); ?>
labelEx($model, $code); ?> textField($model, $code, array("maxlength" => 11, "autocomplete" => "off", "placeholder" => "...", "class" => "float", "id" => "ruble_".$code, "title" => "Поле обязательно")); ?>
labelEx($model, $code); ?> textField($model, $code, array("maxlength" => 11, "autocomplete" => "off", "placeholder" => "...", "class" => "numeric", "id" => "numeric_".$code, "title" => "Поле обязательно")); ?>

Параметры

findAll(); ?> getFieldsAssoc(); ?> $attr): ?>
id])): ?> checked="checked" name="Fields[]" value="id?>"> id.']', $goodAttrs[$attr->id]->required, array("class" => "filter", "id" => $attr->id."_required")); ?> " placeholder="Сортировка"> " placeholder="Единицы измерения">
labelEx($model, $code); ?> textArea($model, $code, array("maxlength" => ( (in_array($code, $maxlength))?$maxlength[$code]:4096 ), "required" => in_array($code, $required), "class" => $field->class ?? "" )); ?> error($model, $code); ?>
user->checkAccess('root') || $code != "code_1c" ): ?>
labelEx($model, $code); ?> textField($model, $code, array("maxlength" => ( (in_array($code, $maxlength))?$maxlength[$code]:4096 ), "required" => in_array($code, $required), "class" => $field->class ?? "" )); ?> error($model, $code); ?>
isNewRecord ? "Добавить" : "Сохранить"); ?>
endWidget(); ?>
``` ===== FILE: common/views/dictionary/listMobile.php ===== ``` adminMenu["cur"]->code."/list", array("class" => "Field"))?>" class="b-back">Назад adminMenu["cur"]->code."/index")?>" class="b-back">Назад

adminMenu["items"][ $_GET["class"] ]->name?>

user->checkAccess('updateDictionary') ): ?>adminMenu["cur"]->code."/create", array('class' => $_GET["class"], 'Variant[field_id]' => (($_GET["Variant"]["field_id"])?$_GET["Variant"]["field_id"]:NULL) ))?>" class="ajax-form ajax-create b-butt b-top-butt">Добавить beginWidget('CActiveForm'); ?> isMobile() ): ?>
$label): ?> $label): ?> type): case "bool": ?> model))?CHtml::listData($label->model::model()->sorted()->findAll($label->condition), ($label->key)?$label->key:'id', 'name'):$label->list ?> $item): ?> $field): ?> isMobile() ): ?>
width) ): ?>style="width: width?>;">name?> Действия
"Нет", 1 => "Да"), array("class" => "select2", "empty" => "Не выбрано", "tabindex" => 1, "placeholder" => "Не выбрано")); ?> "select2", "empty" => "Все", "tabindex" => 1, "placeholder" => "Не выбрано")); ?> 1, "placeholder" => "Поиск")); } ?> Сбросить">
class) ): ?>class="class?>"> type): case "bool": ?> {$key})?"Да":"Нет" )?> {$key}?>" class="b-list-image" alt=""> type == "select" || $item->type == "radio"): ?> code.'/list',array('class'=>"Variant", "Variant[field_id]" => $item->id))?>">{$key}?> {$key}?> cutText($item->{$key}, 128)?> getFieldsHTML()?> relation ): ?> {$field->relation}->name?> {$key}?> user->checkAccess('updateDictionary') ): ?>code.'/update',array('id'=>$item->id, 'class' => $_GET["class"]))?>" class="ajax-form b-double-click-click ajax-update b-tool b-tool-update" title="Редактировать adminMenu["items"][ $_GET["class"] ]->vin_name?>"> user->checkAccess('updateDictionary') ): ?>code.'/delete',array('id'=>$item->id, 'class' => $_GET["class"]))?>" class="ajax-form ajax-delete b-tool b-tool-delete" title="Удалить adminMenu["items"][ $_GET["class"] ]->vin_name?>" data-name="adminMenu["items"][ $_GET["class"] ]->vin_name?>">
Ничего не найдено
endWidget(); ?>
widget('CLinkPager', array( 'header' => '', 'lastPageLabel' => 'последняя »', 'firstPageLabel' => '« первая', 'pages' => $pages, 'prevPageLabel' => '< назад', 'nextPageLabel' => 'далее >' )) ?>
Всего элементов:
``` ===== FILE: common/views/site/login.php ===== ``` pageTitle=Yii::app()->name . ' - Авторизация'; $this->breadcrumbs=array( 'Login', ); ?>

Авторизация

beginWidget('CActiveForm', array( 'id'=>'login-form', 'enableAjaxValidation'=>false, )); ?>
labelEx($model,'username'); ?> textField($model,'username'); ?>
labelEx($model,'password'); ?> passwordField($model,'password'); ?> error($model,'username'); ?> error($model,'password'); ?>
endWidget(); ?> ``` ===== FILE: common/views/site/install.php ===== ```

Установка завершена

``` ===== FILE: common/views/site/contact.php ===== ``` pageTitle=Yii::app()->name . ' - Contact Us'; $this->breadcrumbs=array( 'Contact', ); ?>

Contact Us

user->hasFlash('contact')): ?>
user->getFlash('contact'); ?>

If you have business inquiries or other questions, please fill out the following form to contact us. Thank you.

beginWidget('CActiveForm'); ?>

Fields with * are required.

errorSummary($model); ?>
labelEx($model,'name'); ?> textField($model,'name'); ?>
labelEx($model,'email'); ?> textField($model,'email'); ?>
labelEx($model,'subject'); ?> textField($model,'subject',array('size'=>60,'maxlength'=>128)); ?>
labelEx($model,'body'); ?> textArea($model,'body',array('rows'=>6, 'cols'=>50)); ?>
labelEx($model,'verifyCode'); ?>
widget('CCaptcha'); ?> textField($model,'verifyCode'); ?>
Please enter the letters as they are shown in the image above.
Letters are not case-sensitive.
endWidget(); ?>
``` ===== FILE: common/views/site/error.php ===== ``` pageTitle=Yii::app()->name . ' - Ошибка'; $this->breadcrumbs=array( 'Error', ); ?>

Ошибка

Error

errorHandler->error["message"]; ?>
errorHandler->error["file"]."(".Yii::app()->errorHandler->error["line"].")"; ?> errorHandler->error["trace"]); foreach ($arr as $key => $value) { echo $value."
"; } ?>
``` ===== FILE: common/views/site/pages/about.php ===== ``` pageTitle=Yii::app()->name . ' - About'; $this->breadcrumbs=array( 'About', ); ?>

About

This is the "about" page for my blog site.

``` ===== FILE: common/views/stats/months.php ===== ```

Статистика по месяцам

"btn btn-default")) ?>
Месяц Новых пользователей Сумма заказов Кол-во заказов Средний чек Генераций
$row["ym"]) ) ?>
``` ===== FILE: common/views/stats/month.php ===== ```

Статистика за месяц:

"btn btn-default")) ?>
Новых пользователей:
Сумма заказов:
Кол-во заказов:
Средний чек:
Генераций:

По дням

День Сумма заказов Кол-во заказов Новых пользователей Генераций Средний чек
``` ===== FILE: common/views/stats/index.php ===== ```

Статистика

руб.

pluralForm($summary["orders_count"], ["заказ", "заказа", "заказов"])?> (чек: руб.)

Новых пользователей:

Генераций:

&Order[date_create_to]=&Order[status_id]=all"> pluralForm($monthSum["COUNT"], array("заказ", "заказа", "заказов"))?> (чек: number_format($monthSum["AVERAGE_SUM"])?> руб.)code.'/monthStat')?>" class="b-double-click">

*/ ?>

getRusDate(date("d.m.Y", $curDate), false, false)?>

Выручка по дням

Дата
Сумма, руб
Стоимость генераций (%)
Чистыми (%)
Средний чек
Кол-во заказов
Новых пользователей
Кол-во генераций

Другие цифры

pluralForm($balanceKie, ["кредит", "кредита", "кредитов"])?>
``` ===== FILE: apps/kadrum/index.php ===== ``` run(); ``` ===== FILE: apps/kadrum/protected/yiic.php ===== ``` "Кадрум", // this is used in error pages "commonPath" => "/common", "adminEmail" => "webmaster@example.com", "docsFolder" => "documents", "saveFolder" => "upload/photo", "tempFolder" => "upload/temp", "fileFolder" => "upload/files", "generationFolder" => "upload/generated", "qrFolder" => "upload/qr", "defaultAdminRedirect" => "/stats/index", "basePath" => "https://kadrum.kitaev.site/", "debug" => true, // the copyright information displayed in the footer section "copyrightInfo" => "Copyright © 2009 by My Company.", "telegramToken" => "8380243134:AAHD7fTL8r46fgFN0u-DQOB96leIa8avOZQ", "botLink" => "https://t.me/kadrum_dev_bot", "logsChatId" => "-5027161710", "paymentLogsChatId" => "-5027161710", // "cloudpayments" => [ // "publicId" => "pk_d8d38eb0f799835e72415141b6d15", // Боевой паблик // "apiSecret" => "c6a94b40f74758785bdc10c3cffbc0c5" // Боевой сикрет // ], "cloudpayments" => [ "publicId" => "pk_82aa984dd3069e91a83a4ef7940b9", // Боевой паблик "apiSecret" => "7ff7654d526d8408131785b126e1b263" // Боевой сикрет ], 'features' => [ // 'sbp' => true, ], 'ai' => [ 'primary' => 'gemini', 'fallback' => 'kie', 'maxPrimaryTries' => 3, 'imageSize' => '2K', 'aspectRatio' => '3:4', 'isPro' => false, 'useFallback' => false, ], 'tryon' => [ 'isPro' => false, 'prompt' => 'Inputs: Image 1 (FACE) is identity reference only. Image 2 (BODY) is full-body reference for proportions, head size, camera distance, perspective, and lighting. Image 3 (OUTFIT) is clothing reference only. Task: Generate ONE coherent photorealistic full-body studio photo (head-to-feet) of the person from Images 1+2 wearing the outfit from Image 3 on a neutral studio background. IMPORTANT: DO NOT copy/paste any pixels from Image 1 or Image 2. Do NOT do any cutout, sticker, collage, or compositing. You must re-render the entire person as a single shot while preserving identity and proportions. Identity lock (from Image 1 only): preserve the same facial geometry and unique traits (eyes, brows, nose, lips, jawline, ears, facial hair, moles/marks). Do not change identity, but do not keep original lighting from Image 1. Proportions + scale lock (from Image 2 only): head size MUST match Image 2 head-to-body ratio and shoulder width. Use Image 2 as the primary reference for camera distance and perspective so the head is not enlarged. Neck thickness and head placement on the neck/shoulders must match Image 2 (no floating head). Lighting lock (from Image 2): re-light the head/face/hair/ears/neck to match Image 2 studio lighting (direction, softness, exposure, white balance, contrast) and match skin tone to the neck/hands/legs. One unified grain/noise and sharpness across the entire image. Outfit transfer (from Image 3 only): apply only the clothing details (color, fabric texture, cut, fit, lengths, seams, logos/text). Image 3 must NOT influence any face/head/hair/body traits. Background: clean neutral studio background (light gray/off-white) with subtle floor shadow. Forbidden: pasted head, face cutout, collage/composite, halo/outline, edge blur rings, mismatched lighting on face, different white balance on face, different grain on head, oversized head, wrong head scale, wrong head-to-body ratio, generic model body, reshaping anatomy, any face/head influence from Image 3.', ] ); ``` ===== FILE: apps/kadrum/protected/config/main.php ===== ``` dirname(__FILE__).DIRECTORY_SEPARATOR."..", "name" => "CRM Magic AI", // preloading "log" component "preload" => array("log"), // autoloading model and component classes "import" => array( "application.models.*", "application.components.*", "application.components.bot.*", "application.controllers.*", "application.extensions.*", "common.models.*", "common.components.*", "common.components.telegram.*", "common.components.services.*", "common.components.bot.*", "common.components.bot.handlers.*", "common.components.bot.services.*", "common.extensions.*", ), 'controllerPath' => Yii::getPathOfAlias('common.controllers'), 'controllerMap' => array( 'api' => array('class' => 'application.controllers.ApiController'), ), 'viewPath' => Yii::getPathOfAlias('common.views'), "modules" => array( "gii" => array( "class" => "system.gii.GiiModule", 'ipFilters'=>array('*'), "password" => "4815162342", ), ), 'timeZone' => 'Asia/Krasnoyarsk', "defaultController" => "site", // application components "components" => array( "user" => array( // enable cookie-based authentication "allowAutoLogin" => true, ), 'thumb' => [ 'class' => 'common.components.Thumb', 'baseDirAlias' => 'webroot', 'thumbDirAlias' => 'webroot.upload._thumbs', 'thumbUrl' => '/upload/_thumbs', 'qualityJpeg' => 75, ], // "db" => array( // "connectionString" => "sqlite:protected/data/blog.db", // "tablePrefix" => "tbl_", // ), // uncomment the following to use a MySQL database "db" => array( "connectionString" => "mysql:host=localhost;dbname=dev_kadrum", "emulatePrepare" => true, "username" => "kadrum_admin", "password" => "rCGhtyGZID25DeCNv6nW", "charset" => "utf8mb4", // "tablePrefix" => "tbl_", "tablePrefix" => "", 'enableProfiling'=>true, ), "errorHandler" => array( // use "site/error" action to display errors "errorAction" => "site/error", ), "urlManager" => array( "urlFormat" => "path", "showScriptName" => false, "appendParams" => false, "rules" => array( "" => "site/index", "privacy-policy" => "public/privacyPolicy", "terms-of-use" => "public/termsOfUse", "monicabelluchi" => "site/login", "user/index" => array("dictionary/list", "defaultParams" => array("class" => "User"), "caseSensitive" => false), "task/index" => array("dictionary/list", "defaultParams" => array("class" => "Task"), "caseSensitive" => false), "order/index" => array("dictionary/list", "defaultParams" => array("class" => "Order"), "caseSensitive" => false), "prompt/index" => array("dictionary/list", "defaultParams" => array("class" => "Prompt"), "caseSensitive" => false), "cfg///" => array("api/getConfig", "caseSensitive" => false), // "config////" => array("api/getConfig", "caseSensitive" => false), ".html" => array("page/index", "caseSensitive" => false), "/" => array("/index", "caseSensitive" => false), // "admin//" => array("/adminIndex", "caseSensitive" => false), "/" => array("/", "caseSensitive" => false), "documents//*" => array("site/viewFile", "caseSensitive" => false), ), ), "log" => array( "class" => "CLogRouter", "routes" => array( array( "class" => "CFileLogRoute", "levels" => "error, warning", ), // uncomment the following to show log messages on web pages /* array( "class" => "CWebLogRoute", ), */ ), ), ), // application-level parameters that can be accessed // using Yii::app()->params["paramName"] "params" => require(dirname(__FILE__)."/params.php"), ); ``` ===== FILE: apps/kadrum/protected/config/test.php ===== ``` array( 'fixture'=>array( 'class'=>'system.test.CDbFixtureManager', ), 'db'=>array( 'connectionString'=>'sqlite:'.dirname(__FILE__).'/../data/blog-test.db', ), // uncomment the following to use a MySQL database /* 'db'=>array( 'connectionString' => 'mysql:host=localhost;dbname=blog-test', 'emulatePrepare' => true, 'username' => 'root', 'password' => '', 'charset' => 'utf8', ), */ ), ) ); ``` ===== FILE: apps/kadrum/protected/config/console.php ===== ``` dirname(__FILE__).DIRECTORY_SEPARATOR.'..', 'name'=>'My Console Application', ); ``` ===== FILE: apps/kadrum/protected/extensions/MessageGenerator.php ===== ``` user = $user; $this->currency = Yii::app()->controller->currency; } public function startMessage() { $message = "Привет, ".$this->user->name."!\n\nДобро пожаловать в Кадрум — место, где можно примерять одежду из любых интернет-магазинов и создавать себя в стильных фотосессиях одним кликом 📸✨\n\n"; // $message .= "Загрузи снимок — и я создам целую фотосессию специально для тебя ✨\n\n"; if( $this->user->startBalance > 1 ){ if( $this->user->ref && !empty($this->user->ref) ){ $message .= "Тебя пригласил ".$this->user->ref->name.", поэтому мы дарим тебе ".number_format( $this->user->balance, 0, ',', ' ' )." ".Yii::app()->controller->pluralForm($this->user->balance, $this->currency)." на баланс! Создавай изображения бесплатно!\n\n"; }else{ $message .= "Дарим ".number_format( $this->user->startBalance, 0, ',', ' ' )." ".Yii::app()->controller->pluralForm($this->user->balance, $this->currency)." первым пользователям! Создавай изображения бесплатно!\n\n"; } } // $message .= "Приглашай друзей и получай +{$this->user->refBonus} ".Yii::app()->controller->pluralForm($this->user->refBonus, $this->currency)." на баланс за каждого друга 🔥"; if( $this->user->startBalance > 1 ){ $message .= " А твои друзья получат ".$this->user->startBalance." бесплатных {$this->currency[2]}!\n\n"; } $message .= "Выбирай, что сделаем сначала:\n👗 Примерим новый образ или\n📸 Проведем твою первую ИИ-фотосессию? 💫\n\nЖми — я создам твой первый кадр! ⬇"; return $message; } public function balanceMessage($user, $paymentSuccess = false){ $message = "✨ Подписка: "; if( $subscription = $user->getActiveSubscription() ){ $message .= "{$subscription->planVersion->plan->title} (".( ($subscription->status == "Active") ? "активна" : "отменена" ).")\nДействует до: ".Yii::app()->controller->getRusDate($subscription->next_charge_at); }else if( $user->isDemo() ){ $message .= "Пробный период"; }else{ $message .= "Не оплачена"; } $message .= "\n\nДоступно {$this->currency[2]}: {$user->balance}\n\n"; $message .= "Если закончатся — всегда можно пополнить баланс и творить дальше 💛"; return $message; } public function errorMessage(){ return "Произошла непредвиденная ошибка:( Напишите в тех. поддержку"; } public function firstGenerationSuccessMessage(){ return "Отлично! Первый кадр готов 📸💫\n\nДальше — ты рулишь 😎 Открывай меню внизу и выбирай, что создаём следующим ✨"; } public function emptyBalanceMessage(){ return "К сожалению, твои {$this->currency[3]} закончились 😔\n\nНо есть хорошая новость — сейчас можно пополнить баланс со скидкой! Все пакеты {$this->currency[2]} доступны по специальным ценам.\n\nСамое выгодное предложение прямо сейчас — 10 {$this->currency[2]} за 250₽. 🎁\n\nВыбирай удобный пакет, пополняй {$this->currency[3]} и продолжай создавать крутые и реалистичные фото по приятной цене! ♥️"; } public function subscriptionRequiredMessage(){ return "У тебя нет активной подписки 😔\n\nОформи подписку, чтобы продолжить примерять образы, а также создавать и редактировать фотографии ✨"; } public function choosePackageMessage(){ return "Выбери свой идеальный пакет {$this->currency[2]} и продолжай создавать магию ✨\n\n".Package::model()->getHTML($this->currency)."Жми на подходящий пакет и продолжай творить! 🎨✨"; } public function choosePlanMessage(){ return "Выбери тариф, который подходит именно тебе ✨\n\nЧем больше кадров — тем больше образов, идей и экспериментов 👗📸\n\n".PlanVersion::model()->getHTML($this->currency)."
Важно: {$this->currency[3]} никогда не сгорают. Они переносятся на следующий месяц ❤️
\n\nЖми на подходящий тариф и начинай творить! 🎨✨"; } public function prepaymentInfoMessage($package){ return "Оплата ".number_format( $package->price, 0, ',', ' ' )." ₽\n\nВы оплачиваете: «Пакет {$package->amount} ".Yii::app()->controller->pluralForm($package->amount, $this->currency)."». Мы не имеем доступа к вашим личным и платежным данным. Ознакомьтесь с пользовательским соглашением.\n\n".Yii::app()->controller->mb_ucfirst($this->currency[3])." - валюта нашего сервиса.\n1 сгенерированное фото = 1 {$this->currency[0]}.\n\nВ случае возникновения проблем обращайтесь в поддержку: mike@kitaev.pro"; } public function planInfoMessage($planVersion){ return "Оплата ".number_format( $planVersion->price, 0, ',', ' ' )." ".$planVersion->getRusCurrency()."\n\nВы оплачиваете ежемесячную подписку: «Тариф {$planVersion->plan->title}». Мы не имеем доступа к вашим личным и платежным данным. Ознакомьтесь с пользовательским соглашением.\n\n".Yii::app()->controller->mb_ucfirst($this->currency[3])." - валюта нашего сервиса.\n1 сгенерированное фото = 1 {$this->currency[0]}.\n\nВ случае возникновения проблем обращайтесь в поддержку: mike@kitaev.pro"; } public function subscriptionSuccessMessage($amount){ return "🎉 Подписка успешно активирована!\n\nНа баланс начислено {$amount} ".Yii::app()->controller->pluralForm($amount, $this->currency)." ✨\nТвой текущий баланс: {$this->user->balance} ".Yii::app()->controller->pluralForm($amount, $this->currency)."\n\nСпасибо за поддержку ❤️"; } public function subscriptionRenewalMessage($amount){ return "🔁 Подписка автоматически продлена \n\nНа баланс начислено {$amount} ".Yii::app()->controller->pluralForm($amount, $this->currency)." ✨\nТвой текущий баланс: {$this->user->balance} ".Yii::app()->controller->pluralForm($amount, $this->currency)."\n\nСпасибо, что ты с нами ❤️"; } public function cancelSubscriptionWarningMessage($until = null) { $msg = "⚠️ Отмена подписки\n\n"; $msg .= "Если ты отменишь подписку — авто-списания остановятся ✔️\n"; if ($until) { $msg .= "\nТы сможешь пользоваться ботом без ограничений до ".Yii::app()->controller->getRusDate($until)." ✨\n"; } else { $msg .= "\nДоступ сохранится до конца оплаченного периода.\n"; } $msg .= "\nПодтвердить отмену?"; return $msg; } public function cancelSubscriptionDoneMessage($until = null) { $msg = "✅ Подписка отменена.\n\n"; if ($until) { $msg .= "Доступ сохранится до: {$until}\n"; } else { $msg .= "Доступ сохранится до конца оплаченного периода.\n"; } return $msg; } public function subscriptionCancellingMessage(){ return "Отменяем подписку..."; } public function subscriptionCanceledMessage(){ return "☑️ Подписка остановлена \n\nНо бот никуда не уходит 🙂 Продолжай экспериментировать и вдохновляться — мы для этого и созданы ❤️"; } public function paymentSuccessMessage($amount){ return "Оплата прошла успешно! 🎉\n\nНа баланс начислено {$amount} ".Yii::app()->controller->pluralForm($amount, $this->currency)." ✨\nТвой текущий баланс: {$this->user->balance} ".Yii::app()->controller->pluralForm($amount, $this->currency)."\n\nМожно продолжать творить без ограничений! ❤️"; } public function customPromptMessage(){ return "Опиши, какую фотографию ты хочешь получить ✨\n\nНапример:\n— «Я на фоне Эйфелевой башни летом с бокалом вина»\n— «Я на море в белом платье на закате»\n\nЧем подробнее опишешь идею — тем точнее и красивее получится результат 📸"; } public function editPromptMessage(){ return "Опиши, что ты хочешь изменить на этом фото ✨\n\nНапример:\n— «Измени цвет одежды на бежевый»\n— «Убери человека на фоне»\n— «Добавь новогоднюю атмосферу»\n\nЧем точнее опишешь правки — тем лучше получится результат 📸"; } public function whatToDoWithPhotoMessage(){ return "Что желаешь сделать с этой фотографией?\n\nВыбери одно из предложенных действий и я сделаю это для тебя ❤️"; } public function politicsMessage(){ return "📘 Чтобы всё работало гладко и приятно, советуем ознакомиться с политикой использования. Вот ссылка — это быстро и полезно ❤️\n\n👉 ".Yii::app()->params["basePath"]; } public function supportMessage(){ return "🛠 Нужна помощь?\n\nМы всегда рядом и готовы подсказать ❤️\nНапишите в техническую поддержку по ссылке ниже:\n👉 ".Yii::app()->params["basePath"]; } public function generatingMessage(){ return "🔮 Нейросеть творит волшебство...\n\nПодожди чуть-чуть — кадры уже в пути 📸💫"; } public function errorGeneratingMessage(){ return "Не удалось сгенерировать изображение :( {$this->currency[0]} не был списан. Попробуйте повторить через пару минут."; } public function firstTryonMessage(){ return "Скинь фото образа, который тебе нравится 👗✨\n\nМожно взять из Pinterest или интернет-магазина — просто сделай скрин и отправь сюда 📸💛"; } public function firstTryonStartMessage(){ return "Стиль вижу, уже вдохновляет 👗🔥\n\nОсталось загрузить твоё фото — и можно примерять ✨"; } public function keyboardMessage(){ return "Отличный результат!\n\nЯ запомнил твоё фото — теперь можем создавать крутые и реалистичные AI-кадры в один клик! 📸\n\nОсновное меню — внизу, прямо на клавиатуре 👇"; } public function requestPhotoFullMessage(){ return "Отлично! 😍 А теперь пришли фото во весь рост 📸\n\n✨ Так я точнее подстрою образы и кадры под тебя 💛"; } public function chooseSessionMessage(){ return "Выберите фотосессию:"; } public function changePhotoMessage(){ return "Пришли, пожалуйста, свой портрет 📸\n\nЛицо крупно, но фото лучше сделать не селфи, а чуть издалека (с зумом) — так будет красивее 💛 ✨🌈"; } public function changePhotoSuccessMessage(){ return "Супер! 💛\n\nЯ запомнил твоё фото — Теперь можно примерять одежду и образы 👗 и запускать фотосеты 📸✨"; } public function helpMessage(){ return "Фотосессии 📷 – выбери тематику и получи готовый фотосет в выбранном стиле.\n\nСлучайное фото 🎲 – один кадр-сюрприз, стиль выбираем за тебя.\n\nФото по описанию 💬 – опиши идею и создай свой идеальный снимок.\n\nСменить фото 👤 — другое своё фото и попробуй новые образы.\n\n/invite - приглашай друзей и получай бесплатные {$this->currency[3]}.\n\n/balance - проверь, сколько {$this->currency[2]} осталось\n\n/support - напиши нам, если что-то не работает или заметил баг 🛠️\n\nА ещё, любое фото можно отредактировать! ✨\nПросто ответь на сообщение с нужной фотографией и напиши, что хочешь изменить, например: «Измени цвет рубашки на бежевый» или «Убери человека на фоне». Бот всё сделает для тебя 💫"; } public function defaultMessage(){ return "В Кадрум можно:\n– Примерять образы 👗👚\n– Запускать фотосессии 📸\n– Создавать фото по описанию 💬✨\n\nНажимай нужную кнопку внизу на клавиатуре, и продолжим творить 💛"; } public function errorFindSessionMessage(){ return "Извините, фотосессия не найдена. Воспользуйтесь меню снизу 👇"; } public function inviteMessage(){ $out = "Отправь друзьям ссылку:\n\n".$this->user->getInviteLink()."\n\nКогда твой друг зайдет в бот по этой ссылке и начнет пользоваться, ты получишь {$this->user->refBonus} ".Yii::app()->controller->pluralForm($this->user->refBonus, $this->currency)." на баланс!"; if( $this->user->startBalance > 1 ){ $out .= " А твои друзья получат по ".$this->user->startBalance." бесплатных {$this->currency[2]}."; } return $out; } public function refSuccessMessage(){ return "Отличные новости!\n\nТвой друг начал пользоваться нашим ботом, за это мы начислили тебе {$this->user->refBonus} ".Yii::app()->controller->pluralForm($this->user->refBonus, $this->currency)."\n\nСпасибо, что делишься нашим крутым сервисом! ♥️"; } public function userAlreadyExistRefMessage(){ return "Обратите внимание: реферальная ссылка не применилась.\nРеферальные ссылки действительны только для новых аккаунтов."; } public function refAlreadyExistRefMessage(){ return "Обратите внимание: реферальная ссылка не применилась.\nВы уже переходили по реферальной ссылке."; } public function notFoundRefMessage(){ return "Обратите внимание: реферальная ссылка не применилась.\nПользователь, пригласивший вас, не существует."; } public function notSelfRefMessage(){ return "Реферальную ссылку нельзя применить к своему же аккаунту 😅\n\nОтправь ссылку друзьям и получай бесплатные {$this->currency[3]}"; } } ?> ``` ===== FILE: apps/kadrum/protected/components/TryOnDeepLinkStorageDb.php ===== ``` $img, 'name' => $name, 'back' => $back, ]; } public static function payloadHash(array $payloadCanon): string { // hash от канонизированного набора, чтобы одинаковые ссылки давали один токен return sha1(json_encode($payloadCanon, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); } public static function getOrCreate(array $payload, ?int $ttlSeconds = null): TryonDeeplink { $canon = self::canonicalize($payload); if ($canon['img'] === '' || !filter_var($canon['img'], FILTER_VALIDATE_URL)) { throw new InvalidArgumentException('img is required and must be URL'); } $hash = self::payloadHash($canon); // 1) сначала пробуем найти $m = TryonDeeplink::model()->findByAttributes(['payload_hash' => $hash]); if ($m) return $m; // 2) если нет — создаём (с защитой от гонки) for ($i = 0; $i < 2; $i++) { $m = new TryonDeeplink(); $m->token = self::generateToken(); $m->payload_hash = $hash; $m->img_url = $canon['img']; $m->product_name = $canon['name']; $m->back_url = $canon['back']; $m->created_at = date('Y-m-d H:i:s'); $m->updated_at = $m->created_at; if ($ttlSeconds && $ttlSeconds > 0) { $m->expire_at = date('Y-m-d H:i:s', time() + $ttlSeconds); } if ($m->save()) { return $m; } // если словили unique payload_hash из-за параллельного запроса — забираем существующий $existing = TryonDeeplink::model()->findByAttributes(['payload_hash' => $hash]); if ($existing) return $existing; } throw new RuntimeException('Failed to create deeplink'); } public static function loadByToken(string $token, bool $touchUseCount = true): ?TryonDeeplink { $token = preg_replace('~[^a-zA-Z0-9]~', '', $token); if ($token === '') return null; /** @var TryonDeeplink|null $m */ $m = TryonDeeplink::model()->findByAttributes(['token' => $token]); if (!$m) return null; if (!empty($m->expire_at) && strtotime($m->expire_at) < time()) { return null; } if ($touchUseCount) { Yii::app()->db->createCommand(" UPDATE tryon_deeplink SET use_count = use_count + 1, last_used_at = NOW(), updated_at = NOW() WHERE id = :id ")->execute([':id' => (int)$m->id]); } return $m; } public static function incrementGenCount(int $id): void { Yii::app()->db->createCommand(" UPDATE tryon_deeplink SET gen_count = gen_count + 1, updated_at = NOW() WHERE id = :id ")->execute([':id' => (int)$id]); } private static function generateToken(): string { try { return bin2hex(random_bytes(8)); // 16 символов } catch (Throwable $e) { return dechex(time()) . dechex(mt_rand(100000, 999999)); } } } ``` ===== FILE: apps/kadrum/protected/components/bot/BotUiFactory.php ===== ``` user = $user; $this->currency = $currency; $this->controller = $controller; $this->mg = new MessageGenerator($user, $currency); } public static function fromApp(User $user): self { $controller = Yii::app()->controller; $currency = null; if ($controller && property_exists($controller, 'currency')) { $currency = $controller->currency; } if (!is_array($currency)) { $currency = ["кадр", "кадра", "кадры", "кадры"]; } return new self($user, $currency, $controller); } // -------------------- BASIC SCREENS -------------------- public function start(): array { return [ "parse_mode" => "HTML", "text" => $this->mg->startMessage(), "reply_markup" => [ "inline_keyboard" => [ [ [ "text" => "Примерить образ", "callback_data" => "/first_tryon", ], ], [ [ "text" => "Хочу фотосессию!", "callback_data" => "/first_gen", ], ], ], ], ]; } public function firstGen(): array { return [ "parse_mode" => "HTML", "text" => $this->mg->changePhotoMessage(), ]; } public function firstTryon(): array { return [ "parse_mode" => "HTML", "text" => $this->mg->firstTryonMessage(), ]; } public function firstTryonStart(): array { return [ "parse_mode" => "HTML", "text" => $this->mg->firstTryonStartMessage(), "reply_markup" => [ "inline_keyboard" => [ [ [ "text" => "Скорее прикрепить фото 📸", "callback_data" => "/tryon", ], ] ], ] ]; } public function default(): array { return [ "parse_mode" => "HTML", "text" => $this->mg->defaultMessage(), "reply_markup" => $this->mainKeyboard(), ]; } public function error(): array { return [ "parse_mode" => "HTML", "text" => $this->mg->errorMessage(), ]; } public function generating(): array { return [ "parse_mode" => "HTML", "text" => $this->mg->generatingMessage(), ]; } public function errorGenerating(): array { return [ "parse_mode" => "HTML", "text" => $this->mg->errorGeneratingMessage(), ]; } public function emptyBalance(): array { return [ "parse_mode" => "HTML", "text" => $this->mg->emptyBalanceMessage(), "reply_markup" => [ "inline_keyboard" => [ [ [ "text" => "Купить {$this->currency[3]}", "callback_data" => "/choose_package", ], ], ], ], ]; } public function subscriptionRequired(): array { return [ "parse_mode" => "HTML", "text" => $this->mg->subscriptionRequiredMessage(), "reply_markup" => [ "inline_keyboard" => [ [ [ "text" => "Выбрать тариф", "callback_data" => "/choose_plan", ], ], ], ], ]; } public function changePhoto($withBackButton = false): array { $out = [ "parse_mode" => "HTML", "text" => $this->mg->changePhotoMessage() ]; if( $withBackButton ){ $out["reply_markup"] = $this->backKeyboard(); } return $out; } public function whatToDoWithPhoto(): array { return [ "parse_mode" => "HTML", "text" => $this->mg->whatToDoWithPhotoMessage(), "reply_markup" => [ "inline_keyboard" => [ [ [ "text" => "Примерить этот образ", "callback_data" => "/tryon", ], ], [ [ "text" => "Редактировать фото", "callback_data" => "/edit", ], ], [ [ "text" => "Запомнить меня для генерации", "callback_data" => "/save_photo", ], ], ], ] ]; } public function customPrompt(): array { return [ "parse_mode" => "HTML", "text" => $this->mg->customPromptMessage(), "reply_markup" => $this->backKeyboard(), ]; } public function editPrompt(): array { return [ "parse_mode" => "HTML", "text" => $this->mg->editPromptMessage(), "reply_markup" => $this->backKeyboard(), ]; } public function sendKeyboard(bool $isNewUser = false): array { return [ "parse_mode" => "HTML", "text" => $isNewUser ? $this->mg->keyboardMessage() : $this->mg->changePhotoSuccessMessage(), "reply_markup" => $this->mainKeyboard(), ]; } public function requestPhotoFull(bool $isPhotoFullEmpty = true): array { $out = [ "parse_mode" => "HTML", "text" => $this->mg->requestPhotoFullMessage() ]; if( !$isPhotoFullEmpty ){ $out["reply_markup"] = $this->backKeyboard("Оставить фото как было"); } return $out; } // -------------------- REFERRAL / PAYMENT / SOURCE -------------------- public function invite(): array { return [ "parse_mode" => "HTML", "disable_web_page_preview" => true, "text" => $this->mg->inviteMessage() ]; } public function balance(): array { // Показываем "Отменить подписку" только если есть подписка, которая ещё автопродлевается // (status=Active и canceled_at IS NULL) $subToCancel = UserSubscription::model()->find( 'user_id=:u AND status=:st AND canceled_at IS NULL AND cp_subscription_id IS NOT NULL ORDER BY id DESC', [':u' => (int)$this->user->id, ':st' => 'Active'] ); return [ "parse_mode" => "HTML", "text" => $this->mg->balanceMessage($this->user), "reply_markup" => [ "inline_keyboard" => [ [ [ "text" => "Купить {$this->currency[3]}", "callback_data" => "/choose_package", ], ], ...(!$this->user->hasActiveSubscribtion() ? [ [ [ "text" => "Оплатить подписку", "callback_data" => "/choose_plan", ] ] ] : []), ...($subToCancel ? [ [ [ "text" => "Отменить подписку", "callback_data" => "/cancel_subscription_" . (int)$subToCancel->id, ], ] ] : []), ...(!$subToCancel && $this->user->hasActiveSubscribtion() ? [ [ [ "text" => "Выбрать другой тариф", "callback_data" => "/choose_plan", ], ] ] : []), ], ], ]; } public function cancelSubscriptionWarning(UserSubscription $sub): array { $until = $sub->next_charge_at ? $sub->next_charge_at : null; return [ "parse_mode" => "HTML", "text" => $this->mg->cancelSubscriptionWarningMessage($until), "reply_markup" => [ "inline_keyboard" => [ [ [ "text" => "Подтвердить отмену подписки", "callback_data" => "/confirm_cancel_subscription_" . (int)$sub->id, ], ], ], ], ]; } public function subscriptionCancelling(): array { return [ "parse_mode" => "HTML", "text" => $this->mg->subscriptionCancellingMessage(), ]; } public function choosePackage(): array { // если хочешь — можно раскомментить пакеты сразу $packagesKb = Package::model()->getKeyboard($this->currency); return [ "parse_mode" => "HTML", "text" => $this->mg->choosePackageMessage(), "reply_markup" => [ "inline_keyboard" => $packagesKb ?: [], ], ]; } public function choosePlan(): array { // если хочешь — можно раскомментить пакеты сразу $planKb = PlanVersion::model()->getKeyboard(); return [ "parse_mode" => "HTML", "text" => $this->mg->choosePlanMessage(), "reply_markup" => [ "inline_keyboard" => $planKb ?: [], ], ]; } public function planInfo(PlanVersion $planVersion, int $userId): array { $cardUrl = $this->controller->getSubscribeLink("card", $userId, (int)$planVersion->id); // $sbpUrl = $this->controller->getSubscribeLink("sbp", $userId, (int)$planVersion->id); return [ "parse_mode" => "HTML", "text" => $this->mg->planInfoMessage($planVersion), "reply_markup" => [ "inline_keyboard" => [ [ [ "text" => "Оплатить картой МИР", "web_app" => [ "url" => $cardUrl, ], ], ], // [ // [ // "text" => "Оплатить по СБП", // "web_app" => [ // "url" => $sbpUrl, // ], // ], // ], ], ], ]; } public function prepaymentInfo(Package $package, int $userId): array { $cardUrl = $this->controller->getPaymentLink("card", $userId, (int)$package->id); $sbpUrl = $this->controller->getPaymentLink("sbp", $userId, (int)$package->id); return [ "parse_mode" => "HTML", "text" => $this->mg->prepaymentInfoMessage($package), "reply_markup" => [ "inline_keyboard" => [ [ [ "text" => "Оплатить картой МИР", "web_app" => [ "url" => $cardUrl, ], ], ], // [ // [ // "text" => "Оплатить по СБП", // "web_app" => [ // "url" => $sbpUrl, // ], // ], // ], ], ], ]; } private function getPaymentLinkSafe(string $type, int $userId, int $packageId): string { // в проекте это было в контроллере (getPaymentLink) if ($this->controller && method_exists($this->controller, 'getPaymentLink')) { return (string)$this->controller->getPaymentLink($type, $userId, $packageId); } // fallback (чтобы не падать) $base = Yii::app()->params["basePath"] ?? "/"; return rtrim($base, "/") . "/payment?type=" . urlencode($type) . "&user_id=" . $userId . "&package_id=" . $packageId; } private function getSubscribeLinkSafe(string $type, int $userId, int $planVersionId): string { // в проекте это было в контроллере (getPaymentLink) if ($this->controller && method_exists($this->controller, 'getSubscribeLink')) { return (string)$this->controller->getSubscribeLink($type, $userId, $planVersionId); } // fallback (чтобы не падать) $base = Yii::app()->params["basePath"] ?? "/"; return rtrim($base, "/") . "/payment?type=" . urlencode($type) . "&user_id=" . $userId . "&planVersionId=" . $planVersionId; } // -------------------- STATIC PAGES -------------------- public function politics(): array { $mg = $this->makeMessageGenerator(); return [ "parse_mode" => "HTML", "text" => $mg->politicsMessage(), ]; } public function support(): array { $mg = $this->makeMessageGenerator(); return [ "parse_mode" => "HTML", "text" => $mg->supportMessage(), ]; } /** * MessageGenerator берём так же безопасно, как в твоих хендлерах (Help/CustomPrompt): * подтягиваем currency из controller, если он есть. */ private function makeMessageGenerator(): MessageGenerator { $controller = Yii::app()->controller; $currency = (is_object($controller) && isset($controller->currency) && is_array($controller->currency)) ? $controller->currency : []; return new MessageGenerator($this->user, $currency); } // -------------------- KEYBOARDS -------------------- public function backKeyboard($title = "⏪ Назад"): array { return [ "keyboard" => [ [ [ "text" => $title, "callback_data" => "/default", ], ], ], "resize_keyboard" => true, "one_time_keyboard" => false, "is_persistent" => true, ]; } public function mainKeyboard(): array { return [ "keyboard" => [ [ [ "text" => "Примерка образов 👚👗", "callback_data" => "/first_tryon", ], ], [ [ "text" => "Фотосессии 📷", "web_app" => [ "url" => Yii::app()->params["basePath"] . "public/sessions?user_id=" . $this->user->id, ], ], [ "text" => "Фото по описанию", "callback_data" => "/custom_prompt", ], ], [ [ "text" => "Помощь ℹ️", "callback_data" => "/help", ], [ "text" => "Сменить фото 👤", "callback_data" => "/change_photo", ], ], ], "resize_keyboard" => true, "one_time_keyboard" => false, "is_persistent" => true, ]; } } ``` ===== FILE: apps/kadrum/protected/controllers/ApiController.php ===== ``` ['*']], ['deny', 'users' => ['?']], ]; } /** * Telegram webhook * Только: read input -> parse -> dedup -> resolve user -> route -> respond */ public function actionHook() { $json = file_get_contents("php://input"); // Если хочешь сохранять сырые апдейты — лучше Yii::log, но можно и файл. file_put_contents("text.txt", $json."\n", FILE_APPEND); try { $parser = new UpdateParser(); $dto = $parser->parse($json); // var_dump($dto); // пустые/непонятные апдейты — просто 200 if (empty($dto->telegramUserId)) { return $this->ok(); } // защита от повторной доставки апдейта $dedup = new UpdateDeduplicator(); if (!$dedup->allow($dto)) { // можно логировать, но без спама // Yii::log("Duplicate update_id={$dto->updateId}", CLogger::LEVEL_INFO, 'bot.webhook'); return $this->ok(); } // загрузка/создание пользователя $userResolver = new UserResolver(); $user = $userResolver->resolve($dto); // Telegram API gateway $tg = new TelegramGateway($user->telegram_id); // Сюда прокинь currency/params при желании $router = new BotRouter([ new BackHandler(), new PhotoUploadHandler(), new BuyPlanHandler(), new ChangePhotoHandler(), new CustomPromptHandler(), new WaitingPromptHandler(), new PhotoActionsHandler(), new ReplyImageEditHandler(), new RandomSessionHandler(), new BalanceHandler(), new CancelSubscriptionHandler(), new ConfirmCancelSubscriptionHandler(), new ChoosePlanHandler(), new ChoosePackageHandler(), new BuyPackageHandler(), new InviteHandler(), new TryOnConfirmHandler(), new PoliticsHandler(), new OfertaHandler(), new SupportHandler(), new HelpHandler(), new StartHandler(), new SessionGenerateHandler(), ]); $response = $router->handle($dto, $user); if ($response) { $tg->send($response); } return $this->ok(); } catch (Throwable $e) { Yii::log( "Hook error: ".$e->getMessage()."\n".$e->getTraceAsString(), CLogger::LEVEL_ERROR, 'bot.webhook' ); // важно: телеге почти всегда отвечаем 200, чтобы не долбила ретраями return $this->ok(); } } /** * Callback от Kie (или другого провайдера) * Только: input -> handler */ public function actionKieHook() { $json = file_get_contents("php://input"); try { $handler = new AiCallbackHandler(); // ты сделаешь его в protected/components/services $handler->handleKie($json); return $this->ok(); } catch (Throwable $e) { Yii::log( "KieHook error: ".$e->getMessage()."\n".$e->getTraceAsString(), CLogger::LEVEL_ERROR, 'bot.kiehook' ); // провайдеры тоже любят ретраить — чаще лучше вернуть 200 return $this->ok(); } } /** * Cron: обработка очереди Task */ public function actionCron() { try { // параметры можно брать из GET или конфига $limit = (int)($_GET['limit'] ?? 10); $worker = new TaskWorker(Yii::app()->controller); $worker->run($limit, [ 'userIds' => [2], 'status' => Task::STATUS_NEW, ]); echo "ok\n"; Yii::app()->end(); } catch (Throwable $e) { Yii::log( "Cron error: ".$e->getMessage()."\n".$e->getTraceAsString(), CLogger::LEVEL_ERROR, 'bot.cron' ); http_response_code(500); echo "error\n"; Yii::app()->end(); } } public function actionTryon() { $img = trim((string)($_GET['img'] ?? '')); $name = trim((string)($_GET['name'] ?? '')); $back = trim((string)($_GET['back'] ?? '')); if ($img === '' || !filter_var($img, FILTER_VALIDATE_URL)) { header('Content-Type: text/plain; charset=utf-8'); echo "Bad request: img is required and must be a valid URL"; Yii::app()->end(); } if ($back !== '' && !filter_var($back, FILTER_VALIDATE_URL)) { $back = ''; } $row = TryOnDeepLinkStorageDb::getOrCreate( ['img' => $img, 'name' => $name, 'back' => $back], null // 30 * 24 * 3600 // TTL 30 дней (можешь сделать null, тогда без TTL) ); $botLink = rtrim((string)Yii::app()->params['botLink'], '/'); // есть в params.php :contentReference[oaicite:2]{index=2} $deeplink = $botLink . '?start=tryon_' . $row->token; $this->redirect($deeplink); Yii::app()->end(); } /** * Тесты / диагностика — лучше вынести в отдельный контроллер или закрыть по IP */ public function actionTest() { // echo "
";
        // $user = User::model()->findByPk(1);
    	// $planKb = PlanVersion::model()->getKeyboard(Yii::app()->controller->currency);
        // var_dump($planKb);
    }

    public function actionGeneratePromptsPhoto(){
		if( $prompts = Prompt::model()->findAll("person_type_id IS NOT NULL AND person_type_id != 0") ){
		// if( $prompts = Prompt::model()->findAll("person_type_id IS NOT NULL AND person_type_id != 0") ){
			foreach ($prompts as $key => $prompt) {
				// code...
				$task = new Task();
				$task->user_id = 2;
				$task->input_photo = $prompt->personType->photo;
				$task->prompt = $prompt->prompt;
				$task->session_id = $prompt->id;

				if( !$task->save(false) ){
					var_dump($task->getErrors());
					exit;
				}
			}
		}
	}

    /**
     * Единая точка для ответа 200
     */
    private function ok()
    {
        http_response_code(200);
        echo "OK";
        Yii::app()->end();
    }
}

```


===== FILE: apps/kadrum/protected/data/auth.php =====
```
 
  array (
    'type' => 0,
    'description' => 'Просмотр пользователей',
    'bizRule' => NULL,
    'data' => NULL,
  ),
  'updateAdmin' => 
  array (
    'type' => 0,
    'description' => 'Создание/изменение/удаление пользователей',
    'bizRule' => NULL,
    'data' => NULL,
  ),
  'readTracking' => 
  array (
    'type' => 0,
    'description' => 'Просмотр поставок',
    'bizRule' => NULL,
    'data' => NULL,
  ),
  'updateTracking' => 
  array (
    'type' => 0,
    'description' => 'Создание/изменение поставок',
    'bizRule' => NULL,
    'data' => NULL,
  ),
  'readRefer' => 
  array (
    'type' => 0,
    'description' => 'Просмотр партнеров',
    'bizRule' => NULL,
    'data' => NULL,
  ),
  'updateRefer' => 
  array (
    'type' => 0,
    'description' => 'Создание/изменение партнеров',
    'bizRule' => NULL,
    'data' => NULL,
  ),
  'readStat' => 
  array (
    'type' => 0,
    'description' => 'Просмотр аналитики',
    'bizRule' => NULL,
    'data' => NULL,
  ),
  'updateStat' => 
  array (
    'type' => 0,
    'description' => 'Создание/изменение аналитики',
    'bizRule' => NULL,
    'data' => NULL,
  ),
  'readOpt' => 
  array (
    'type' => 0,
    'description' => 'Просмотр оптовых заказов',
    'bizRule' => NULL,
    'data' => NULL,
  ),
  'updateOpt' => 
  array (
    'type' => 0,
    'description' => 'Создание/изменение оптовых заказов',
    'bizRule' => NULL,
    'data' => NULL,
  ),
  'readDictionary' => 
  array (
    'type' => 0,
    'description' => 'Просмотр справочников',
    'bizRule' => NULL,
    'data' => NULL,
  ),
  'updateDictionary' => 
  array (
    'type' => 0,
    'description' => 'Создание/изменение справочников',
    'bizRule' => NULL,
    'data' => NULL,
  ),
  'accessBreed' => 
  array (
    'type' => 0,
    'description' => 'Доступ к породам',
    'bizRule' => NULL,
    'data' => NULL,
  ),
  'accessTk' => 
  array (
    'type' => 0,
    'description' => 'Доступ к ТК',
    'bizRule' => NULL,
    'data' => NULL,
  ),
  'accessSupplier' => 
  array (
    'type' => 0,
    'description' => 'Доступ к поставщикам',
    'bizRule' => NULL,
    'data' => NULL,
  ),
  'accessCity' => 
  array (
    'type' => 0,
    'description' => 'Доступ к городам',
    'bizRule' => NULL,
    'data' => NULL,
  ),
  'readOrder' => 
  array (
    'type' => 0,
    'description' => 'Просмотр заказов',
    'bizRule' => NULL,
    'data' => NULL,
  ),
  'readSumOrder' => 
  array (
    'type' => 0,
    'description' => 'Просмотр суммы заказов',
    'bizRule' => NULL,
    'data' => NULL,
  ),
  'updateOrder' => 
  array (
    'type' => 0,
    'description' => 'Создание/изменение заказов',
    'bizRule' => NULL,
    'data' => NULL,
  ),
  'deleteOrder' => 
  array (
    'type' => 0,
    'description' => 'Удаление заказов',
    'bizRule' => NULL,
    'data' => NULL,
  ),
  'changeStatusOrder' => 
  array (
    'type' => 0,
    'description' => 'Изменения статуса заказа',
    'bizRule' => NULL,
    'data' => NULL,
  ),
  'markPlatesOrder' => 
  array (
    'type' => 0,
    'description' => 'Массово помечать таблички готовностью',
    'bizRule' => NULL,
    'data' => NULL,
  ),
  'readSalary' => 
  array (
    'type' => 0,
    'description' => 'Просмотр зарплаты',
    'bizRule' => NULL,
    'data' => NULL,
  ),
  'updateSalary' => 
  array (
    'type' => 0,
    'description' => 'Редактирование зарплаты',
    'bizRule' => NULL,
    'data' => NULL,
  ),
  'updateSettings' => 
  array (
    'type' => 0,
    'description' => 'Изменение настроек',
    'bizRule' => NULL,
    'data' => NULL,
  ),
  'master' => 
  array (
    'type' => 2,
    'description' => '',
    'bizRule' => NULL,
    'data' => NULL,
    'children' => 
    array (
      0 => 'readOrder',
      1 => 'accessBreed',
      2 => 'readDictionary',
    ),
  ),
  'sew' => 
  array (
    'type' => 2,
    'description' => '',
    'bizRule' => NULL,
    'data' => NULL,
    'children' => 
    array (
      0 => 'master',
    ),
  ),
  'packer' => 
  array (
    'type' => 2,
    'description' => '',
    'bizRule' => NULL,
    'data' => NULL,
    'children' => 
    array (
      0 => 'master',
      1 => 'updateOrder',
      2 => 'readTracking',
      3 => 'updateTracking',
      4 => 'readSumOrder',
      5 => 'changeStatusOrder',
      6 => 'markPlatesOrder',
    ),
  ),
  'salesman' => 
  array (
    'type' => 2,
    'description' => '',
    'bizRule' => NULL,
    'data' => NULL,
    'children' => 
    array (
      0 => 'packer',
      1 => 'updateOrder',
      2 => 'deleteOrder',
      3 => 'updateDictionary',
      4 => 'accessCity',
    ),
  ),
  'supervisor' => 
  array (
    'type' => 2,
    'description' => '',
    'bizRule' => NULL,
    'data' => NULL,
    'children' => 
    array (
      0 => 'salesman',
      1 => 'readAdmin',
      2 => 'updateAdmin',
      3 => 'readTracking',
      4 => 'updateTracking',
      5 => 'readStat',
      6 => 'readDictionary',
      7 => 'accessTk',
      8 => 'accessSupplier',
      9 => 'markPlatesOrder',
      10 => 'readSalary',
      11 => 'readRefer',
      12 => 'updateRefer',
      13 => 'readOpt',
      14 => 'updateOpt',
    ),
  ),
  'root' => 
  array (
    'type' => 2,
    'description' => '',
    'bizRule' => NULL,
    'data' => NULL,
    'children' => 
    array (
      0 => 'supervisor',
      1 => 'updateStat',
      2 => 'updateSalary',
      3 => 'updateSettings',
    ),
    'assignments' => 
    array (
      1 => 
      array (
        'bizRule' => NULL,
        'data' => NULL,
      ),
      3 => 
      array (
        'bizRule' => NULL,
        'data' => NULL,
      ),
    ),
  ),
);

```


===== FILE: apps/kadrum/protected/data/dbgen.php =====
```
exec($sql);
}

```


===== FILE: apps/kadrum/protected/views/public/terms-of-use.php =====
```





	One Button VPN - ваш ключ к защищенному интернету!
	
	

	
	

	
	
	
	
	
	

	
	
	
	
	
	
	
	
	
	
	
	
	
	
	



	

Условия использования приложения One Button VPN

Настоящие Условия регулируют использование данного Приложения и любого другого связанного с ним Соглашения или юридических отношений с Владельцем юридически обязательным образом. Слова, написанные с заглавной буквы, определены в соответствующем специальном разделе этого документа.

Пользователь обязан внимательно прочитать настоящий документ.

Хотя все договорные отношения, относящиеся к этим Продуктам, заключаются исключительно Владельцем и Пользователями, Пользователи признают и соглашаются, что, если это Приложение было предоставлено им через Apple App Store, Apple может обеспечить соблюдение настоящих Условий в качестве стороннего бенефициара. .

Это приложение предоставлено:

ИП Китаев Михаил Андреевич. ИНН/ОГРНИП: 421189830262/315422300007697

Контактный адрес электронной почты владельца: hi@onebttn.com

Что должен знать пользователь с первого взгляда

Обратите внимание, что некоторые положения настоящих Условий могут применяться только к определенным категориям Пользователей. В частности, некоторые положения могут применяться только к Потребителям или к тем Пользователям, которые не квалифицируются как Потребители. Такие ограничения всегда прямо упоминаются в каждом затронутом пункте. При отсутствии такого упоминания положения применяются ко всем Пользователям.

Право на отказ распространяется только на европейских потребителей.

Это Приложение использует автоматическое продление подписок на Продукты. Информацию о а) периоде продления, б) деталях прекращения действия и в) уведомлении о прекращении действия можно найти в соответствующем разделе настоящих Условий.

Важно: к потребителям, проживающим в Германии, применяются другие правила, описанные в соответствующем разделе настоящих Условий.

УСЛОВИЯ ЭКСПЛУАТАЦИИ

Если не указано иное, условия использования, подробно описанные в этом разделе, применяются в целом при использовании данного Приложения.

В определенных сценариях могут применяться единые или дополнительные условия использования или доступа, которые в таких случаях дополнительно указываются в настоящем документе.

Используя данное Приложение, Пользователи подтверждают соответствие следующим требованиям:

  • Для Пользователей не существует ограничений в отношении статуса Потребителей или Бизнес-пользователей;
  • Пользователи не находятся в стране, на которую наложено эмбарго правительства США или которая была определена правительством США как страна, «поддерживающая терроризм»;
  • Пользователи не включены ни в один список запрещенных или ограниченных лиц правительства США;

Содержание этого приложения

Если иное не указано или явно не указано, весь контент, доступный в этом Приложении, принадлежит или предоставляется Владельцу или его лицензиарам.

Владелец прилагает все усилия, чтобы гарантировать, что контент, представленный в этом Приложении, не нарушает никакие применимые законодательные положения или права третьих лиц. Однако не всегда возможно добиться такого результата.

В таких случаях, без ущерба для каких-либо юридических прерогатив Пользователей по обеспечению соблюдения своих прав, Пользователей просят сообщать о соответствующих жалобах, используя контактную информацию, указанную в этом документе.

Права на содержимое этого приложения. Все права защищены.

Владелец владеет и сохраняет за собой все права интеллектуальной собственности на любой такой контент.

Таким образом, пользователи не могут использовать такой контент любым способом, который не является необходимым или не подразумевается для надлежащего использования Сервиса. В частности, помимо прочего, Пользователи не имеют права копировать, загружать, делиться (за пределами ограничений, указанных ниже), изменять, переводить, трансформировать, публиковать, передавать, продавать, сублицензировать, редактировать, передавать/переуступать третьим лицам или создавать производные работы. из контента, доступного в этом Приложении, и не позволять третьим лицам делать это через Пользователя или его устройство, даже без ведома Пользователя.

Если это явно указано в этом Приложении, Пользователь может загружать, копировать и/или делиться некоторым контентом, доступным через это Приложение, для своего исключительно личного и некоммерческого использования и при условии, что авторские права и все другие атрибуты, запрошенные Владельцем, реализованы правильно. .

Любые применимые законодательные ограничения или исключения из авторских прав остаются в силе.

Удаление контента из частей этого Приложения, доступных через App Store.

Если сообщенный контент будет сочтен нежелательным, он будет удален в течение 24 часов, а Пользователю, предоставившему такой контент, будет запрещено использовать Сервис.

Доступ к внешним ресурсам

Посредством этого Приложения Пользователи могут иметь доступ к внешним ресурсам, предоставленным третьими лицами. Пользователи признают и соглашаются с тем, что Владелец не контролирует такие ресурсы и, следовательно, не несет ответственности за их содержание и доступность.

Условия, применимые к любым ресурсам, предоставляемым третьими лицами, включая условия, применимые к любому возможному предоставлению прав на контент, вытекают из условий каждой такой третьей стороны или, при отсутствии таковых, из применимого статутного права.

Допустимое использование

Данное Приложение и Сервис можно использовать только в пределах того, для чего они предусмотрены, в соответствии с настоящими Условиями и применимым законодательством.

Пользователи несут единоличную ответственность за то, чтобы использование ими данного Приложения и/или Сервиса не нарушало применимые законы, правила или права третьих лиц.

Таким образом, Владелец оставляет за собой право принять любые соответствующие меры для защиты своих законных интересов, в том числе путем отказа Пользователям в доступе к этому Приложению или Сервису, расторжения контрактов, сообщения о любых неправомерных действиях, совершенных через это Приложение или Сервис, компетентным органам, например, судебным органам. или административные органы - всякий раз, когда Пользователи участвуют или подозреваются в участии в любой из следующих действий:

  • Нарушать законы, правила и/или настоящие Условия;
  • Нарушать любые права третьих лиц;
  • Значительно ущемлять законные интересы Собственника;
  • Оскорбить Владельца или любую третью сторону.

УСЛОВИЯ ПРОДАЖИ

Предоставление персональных данных

Для доступа или получения некоторых Продуктов, предоставляемых через это Приложение в рамках Сервиса, Пользователям может потребоваться предоставить свои личные данные, указанные в этом Приложении.

Платные продукты

Некоторые Продукты, представленные в настоящем Приложении, в рамках Сервиса предоставляются на платной основе.

Платы, продолжительность и условия, применимые к покупке таких Продуктов, описаны ниже и в соответствующих разделах настоящего Приложения.

Описание продукта

Цены, описания или наличие Продуктов указаны в соответствующих разделах настоящего Приложения и могут быть изменены без предварительного уведомления.

Хотя Продукты в этом Приложении представлены с максимально возможной с технической точки зрения точностью, представление в этом Приложении любыми средствами (включая, в зависимости от обстоятельств, графические материалы, изображения, цвета, звуки) предназначено только для справки и не подразумевает никаких гарантий в отношении характеристики приобретаемого Товара.

Характеристики выбранного Товара будут указаны в процессе покупки.

Процесс покупки

Любые шаги, предпринятые от выбора Продукта до подачи заказа, являются частью процесса покупки.

Процесс покупки включает в себя следующие этапы:

  • Пользователи должны выбрать желаемый Продукт и подтвердить выбор покупки.
  • После просмотра информации, отображаемой при выборе покупки, Пользователи могут разместить заказ, отправив его.

Подача заказа

Когда Пользователь отправляет заказ, применяется следующее:

  • Отправка заказа определяет заключение договора и, следовательно, создает для Пользователя обязательство оплатить цену, налоги и возможные дополнительные сборы и расходы, как указано на странице заказа.
  • В случае, если приобретенный Продукт требует от Пользователя действия, такого как предоставление личной информации или данных, спецификаций или особых пожеланий, подача заказа создает для Пользователя обязательство сотрудничать соответствующим образом.
  • После отправки заказа Пользователи получат квитанцию, подтверждающую получение заказа.
  • Все уведомления, связанные с описанным процессом покупки, направляются на адрес электронной почты, указанный Пользователем для этих целей.

Цены

Во время процесса покупки и перед отправкой заказа пользователи информируются о любых сборах, налогах и расходах (включая, если таковые имеются, расходы на доставку), которые с них будут взиматься.

Цены в этом приложении отображаются:

  • Либо без учета, либо с учетом любых применимых сборов, налогов и расходов, в зависимости от раздела, который просматривает Пользователь.

Способы оплаты

Информация о принятых способах оплаты предоставляется в процессе покупки.

Некоторые способы оплаты могут быть доступны только при условии соблюдения дополнительных условий или комиссий. В таких случаях соответствующую информацию можно найти в специальном разделе настоящего Приложения.

Все платежи обрабатываются самостоятельно через сторонние сервисы. Таким образом, это приложение не собирает никакой платежной информации, например данных кредитной карты, а получает уведомление только после успешного завершения платежа. Пользователь может ознакомиться с политикой конфиденциальности данного Приложения, чтобы узнать больше об обработке данных и правах Пользователей в отношении их данных.

Если оплата с использованием доступных методов не удалась или была отклонена поставщиком платежных услуг, Владелец не обязан выполнять заказ на покупку. Если платеж не выполнен или отклонен, Владелец оставляет за собой право требовать от Пользователя любых связанных с этим расходов или убытков.

Сохранение права собственности на продукт

До тех пор, пока Владелец не получит оплату общей стоимости покупки, любые заказанные Продукты не станут собственностью Пользователя.

Сохранение прав использования

Пользователи не приобретают никаких прав на использование приобретенного Продукта до тех пор, пока Владелец не получит полную стоимость покупки.

Доставка

Доставка цифрового контента

Если не указано иное, цифровой контент, приобретенный в этом Приложении, доставляется путем загрузки на устройство(а), выбранное Пользователями.

Пользователи признают и принимают, что для загрузки и/или использования Продукта предполагаемое устройство(а) и соответствующее программное обеспечение (включая операционные системы) должны быть легальными, широко используемыми, актуальными и соответствовать текущим рыночным требованиям. стандарты.

Пользователи признают и принимают, что возможность загрузки приобретенного Продукта может быть ограничена во времени и пространстве.

Срок действия контракта

Подписки

Подписки позволяют Пользователям получать Продукт непрерывно или регулярно с течением времени. Подробная информация о типе подписки и ее прекращении приведена ниже.

Подписки с фиксированным сроком

Платные подписки с фиксированным сроком начинаются в день получения оплаты Владельцем и действуют в течение периода подписки, выбранного Пользователем или иным образом указанного в процессе покупки.

По истечении срока подписки Продукт больше не будет доступен.

Подписки обрабатываются через Apple ID

Пользователи могут подписаться на Продукт, используя Apple ID, связанный с их учетной записью Apple App Store, выполнив соответствующий процесс в этом Приложении. При этом Пользователи признают и принимают, что:

  • любой причитающийся платеж будет снят с их учетной записи Apple ID;
  • подписки автоматически продлеваются на тот же срок, если только Пользователь не отменит их как минимум за 24 часа до истечения текущего периода;
  • любые сборы или платежи, причитающиеся за продление, будут сняты в течение 24 часов до окончания текущего периода;
  • подписками можно управлять или отменять их в настройках учетной записи пользователя в Apple App Store.

Вышеизложенное имеет преимущественную силу перед любыми противоречащими или отличающимися положениями настоящих Условий.

Автоматическое продление подписок на определенный срок

Подписки автоматически продлеваются с помощью способа оплаты, который Пользователь выбрал при покупке.

Продленная подписка будет действовать в течение периода, равного первоначальному сроку.

Пользователь заблаговременно получит напоминание о предстоящем продлении с описанием процедуры, которую необходимо выполнить для отмены автоматического продления.

Прекращение действия

Подписку можно прекратить, отправив Владельцу четкое и недвусмысленное уведомление о прекращении с использованием контактной информации, указанной в этом документе, или — если применимо — с помощью соответствующих элементов управления в этом Приложении.

Если уведомление о прекращении получено Владельцем до продления подписки, прекращение вступит в силу, как только текущий период будет завершен.

Исключение для потребителей, находящихся в Германии

Однако, независимо от вышеизложенного, если Пользователь проживает в Германии и квалифицируется как Потребитель, применяется следующее:

  • По окончании первоначального срока подписки автоматически продлеваются на неограниченный период, если только Пользователь не прекращает действие подписки до окончания такого срока.
  • Плата, причитающаяся при продлении, будет взиматься с помощью метода оплаты, который Пользователь выбрал при покупке.
  • После продления подписка будет действовать на неопределенный срок и может быть прекращена ежемесячно.
  • Пользователь заблаговременно получит напоминание о предстоящем неограниченном продлении с описанием процедуры, которой необходимо следовать, чтобы предотвратить продление или прекратить подписку в дальнейшем.

Прекращение действия

Расширенная подписка может быть прекращена в любое время путем направления Владельцу четкого и недвусмысленного уведомления о прекращении с использованием контактной информации, указанной в этом документе, или — если применимо — с помощью соответствующих элементов управления в этом Приложении.

Если уведомление о прекращении получено Владельцем до конца текущего месяца, срок действия подписки истекает в конце такого месяца.

Права пользователя

Право на отзыв

Если не применяются исключения, Пользователь может иметь право отказаться от договора в течение периода, указанного ниже (обычно 14 дней), по любой причине и без обоснования. Пользователи могут узнать больше об условиях вывода средств в этом разделе.

На кого распространяется право отказа

Если ниже не указано какое-либо применимое исключение, Пользователям, являющимся европейскими потребителями, предоставляется законное право выхода в соответствии с правилами ЕС, позволяющее отказаться от договоров, заключенных онлайн (дистанционные договоры), в течение указанного периода, применимого к их случаю, по любой причине и без обоснования.

Пользователи, не соответствующие этому требованию, не могут воспользоваться правами, описанными в этом разделе. Потребитель несет ответственность перед Продавцом только за любое уменьшение стоимости товара, возникшее в результате обращения с товаром способом, отличным от того, который необходим для ознакомления его с характером, характеристиками и функциональностью товара.

Осуществление права отказа

Чтобы воспользоваться своим правом на выход, Пользователи должны направить Владельцу недвусмысленное заявление о своем намерении выйти из договора.

С этой целью Пользователи могут использовать форму отзыва модели, доступную в разделе «Определения» настоящего документа. Однако пользователи могут свободно выразить свое намерение выйти из договора, сделав недвусмысленное заявление любым другим подходящим способом. Чтобы уложиться в срок, в течение которого они могут воспользоваться таким правом, Пользователи должны отправить уведомление о выходе до истечения периода вывода.

Когда истекает срок вывода средств?

В случае покупки цифрового контента, не представленного на материальном носителе, период вывода средств истекает через 14 дней со дня заключения договора, если только Пользователь не отказался от права на вывод средств.

Последствия отмены

Пользователям, которые правильно отказываются от договора, Владелец возмещает все платежи, произведенные Владельцу, включая, если таковые имеются, расходы на доставку.

Однако любые дополнительные расходы, возникшие в результате выбора конкретного способа доставки, кроме самого дешевого типа стандартной доставки, предлагаемого Владельцем, не возмещаются.

Такое возмещение должно быть произведено без неоправданной задержки и в любом случае не позднее 14 дней со дня уведомления Владельца о решении Пользователя выйти из договора. Если иное не согласовано с Пользователем, возмещение будет производиться с использованием тех же средств платежа, которые использовались для обработки первоначальной транзакции. В любом случае Пользователь не несет никаких расходов или сборов в результате такого возмещения.

Права пользователя в Великобритании

Право на отмену

Если не применяются исключения, Пользователи, являющиеся Потребителями в Соединенном Королевстве, имеют законное право на аннулирование в соответствии с законодательством Великобритании и могут иметь право отказаться от контрактов, заключенных онлайн (дистанционные контракты), в течение периода, указанного ниже (обычно 14 дней), по любой причине и без обоснования.

Пользователи, не являющиеся Потребителями, не могут воспользоваться правами, описанными в этом разделе. Пользователи могут узнать больше об условиях отмены в этом разделе.

Осуществление права на отмену

Чтобы воспользоваться своим правом на расторжение, Пользователи должны направить Владельцу недвусмысленное заявление о своем намерении выйти из договора. С этой целью Пользователи могут использовать форму отзыва модели, доступную в разделе «Определения» настоящего документа. Однако пользователи могут свободно выразить свое намерение выйти из договора, сделав недвусмысленное заявление любым другим подходящим способом. Чтобы уложиться в срок, в течение которого они могут воспользоваться таким правом, Пользователи должны отправить уведомление о выходе до истечения периода отмены.

Когда истекает срок отмены?

В случае приобретения цифрового контента, не представленного на материальном носителе, срок отмены истекает через 14 дней со дня заключения договора, если только Пользователь не отказался от права на расторжение.

Последствия отмены

Пользователям, которые правильно отказываются от договора, Владелец возмещает все платежи, произведенные Владельцу, включая, если таковые имеются, расходы на доставку.

Однако любые дополнительные расходы, возникшие в результате выбора конкретного способа доставки, кроме самого дешевого типа стандартной доставки, предлагаемого Владельцем, не возмещаются.

Такое возмещение должно быть произведено без неоправданной задержки и в любом случае не позднее 14 дней со дня уведомления Владельца о решении Пользователя выйти из договора. Если иное не согласовано с Пользователем, возмещение будет производиться с использованием тех же средств платежа, которые использовались для обработки первоначальной транзакции. В любом случае Пользователь не несет никаких расходов или сборов в результате такого возмещения.

Права пользователя в Бразилии

Право сожаления

Если ниже не указано применимое исключение, Пользователи, являющиеся Потребителями в Бразилии, имеют законное право на сожаление в соответствии с законодательством Бразилии. Это означает, что Потребитель имеет право отказаться от контрактов, заключенных онлайн (дистанционных контрактов или любого контракта, подписанного вне офиса) в течение семи (7) дней с даты заключения контракта или получения продукта или услуги, в течение семи (7) дней с даты заключения контракта или получения продукта или услуги, по любой причине и без обоснования. Пользователи, не являющиеся Потребителями, не могут воспользоваться правами, описанными в этом разделе. Право на сожаление может быть реализовано Потребителем через каналы связи, указанные в начале настоящего документа, и в соответствии с рекомендациями этого раздела.

Осуществление права сожаления

Чтобы воспользоваться своим правом сожаления, Пользователи должны направить Владельцу недвусмысленное заявление о своем намерении выйти из договора. С этой целью Пользователи могут использовать форму отзыва модели, доступную в разделе «Определения» настоящего документа. Однако пользователи могут свободно выразить свое намерение выйти из договора, сделав недвусмысленное заявление любым другим подходящим способом. Чтобы уложиться в срок, в течение которого они могут воспользоваться таким правом, Пользователи должны отправить уведомление о сожалении до истечения периода сожаления.

Когда истекает период сожаления?

В случае покупки цифрового контента период сожаления истекает через семь (7) дней со дня заключения договора и только в том случае, если цифровой контент еще не был предоставлен и интегрирован в устройство Потребителя.

Эффекты сожаления

Пользователям, которые правильно отказываются от договора, Владелец возмещает все платежи, произведенные Владельцу, включая, если таковые имеются, расходы на доставку.

Однако любые дополнительные расходы, возникшие в результате выбора конкретного способа доставки, кроме самого дешевого типа стандартной доставки, предлагаемого Владельцем, не возмещаются.

Такое возмещение должно быть произведено без неоправданной задержки и в любом случае не позднее 14 дней со дня, когда Владелец будет проинформирован о решении Пользователя отказаться от договора или фактическом возврате продукта, в зависимости от того, что произойдет позже. Если иное не согласовано с Пользователем, возмещение будет производиться с использованием тех же средств платежа, которые использовались для обработки первоначальной транзакции. В любом случае Пользователь не несет никаких расходов или сборов в результате такого возмещения.

Гарантии

Юридическая гарантия соответствия цифровых продуктов законодательству ЕС.

В соответствии с законодательством ЕС в течение как минимум 2 лет с момента поставки или, в случае непрерывной поставки Цифровых продуктов в течение более 2 лет, в течение всего периода поставки, торговцы гарантируют соответствие Цифровых продуктов, которые они предоставляют Потребителям.

Если Пользователи квалифицируются как Европейские Потребители, юридическая гарантия соответствия распространяется на Цифровые продукты, доступные в этом Приложении, в соответствии с законодательством страны их обычного проживания. Национальное законодательство такой страны может предоставлять Пользователям более широкие права.

Соответствие контракту для потребителей в Великобритании

Пользователи в Соединенном Королевстве, квалифицируемые как Потребители, имеют право на получение товаров, соответствующих договору.

Юридическая гарантия соответствия товаров для потребителей в Бразилии

Юридическая гарантия, применимая к товарам, продаваемым с помощью этого Приложения (как физическим, так и цифровым), соответствует следующим условиям в соответствии с Кодексом защиты прав потребителей:

  • На товары недлительного пользования предоставляется тридцатидневная (30-дневная) гарантия; и
  • На товары длительного пользования предоставляется гарантия сроком девяносто (90 дней).

Гарантийный срок начинается с момента поставки товара.

Гарантия не распространяется на случаи неправильного использования, стихийных бедствий или если устройство подвергалось любому обслуживанию, кроме предусмотренного настоящим Приложением.

Гарантию можно запросить через каналы связи, предусмотренные настоящим Приложением. Владелец несет расходы по доставке товара на техническую оценку, если это необходимо.

Владелец по своему усмотрению может также предложить договорную гарантию в дополнение к юридической гарантии. Правила, применимые к договорным гарантиям, можно найти в спецификациях настоящего Приложения. Если такая информация не предоставлена, применяются только законодательные положения.

Ответственность и возмещение ущерба

Если иное прямо не указано или не согласовано с Пользователями, ответственность Владельца за ущерб в связи с исполнением Соглашения исключается, ограничивается и/или снижается в максимальной степени, разрешенной применимым законодательством.

Возмещение ущерба

Пользователь соглашается возместить ущерб и оградить Владельца и его дочерние компании, аффилированные лица, должностных лиц, директоров, агентов, партнеров, партнеров и сотрудников от ответственности за любые претензии или требования ⁠ — включая, помимо прочего, гонорары и издержки адвоката ⁠ — предъявленные любой третьей стороной в связи или в связи с любым виновным нарушением настоящих Условий, прав третьих лиц или законодательных положений, связанных с использованием Сервиса Пользователем или его аффилированными лицами, должностными лицами, директорами, агентами, кобрендерами, партнерами и работников в пределах, разрешенных действующим законодательством.

Вышеупомянутое также применимо к любым претензиям третьих лиц (включая, помимо прочего, клиентов или заказчиков Владельца) против Владельца, связанных с Цифровыми продуктами, предоставленными Пользователем, например, к претензиям о соответствии.

Ограничение ответственности

Если иное прямо не указано и без ущерба для действующего законодательства, Пользователи не имеют права требовать возмещения убытков от Владельца (или любого физического или юридического лица, действующего от его имени).

Это не относится к ущербу жизни, здоровью или физической неприкосновенности, ущербу, возникшему в результате нарушения существенных договорных обязательств, например, любого обязательства, строго необходимого для достижения цели договора, и/или ущерба, возникшего в результате умысла или грубой небрежности, при условии, что поскольку данное Приложение использовалось Пользователем надлежащим и правильным образом.

Если ущерб не причинен умыслом или грубой неосторожностью или не затрагивает жизнь, здоровье или физическую неприкосновенность, Владелец несет ответственность только в размере типичного и предсказуемого ущерба на момент заключения договора.

Австралийские пользователи

Ограничение ответственности

Ничто в настоящих Условиях не исключает, не ограничивает и не изменяет какие-либо гарантии, условия, гарантии, права или средства правовой защиты, которые Пользователь может иметь в соответствии с Законом о конкуренции и защите прав потребителей 2010 г. (Cth) или любым аналогичным законодательством штата и территории и которые не могут быть исключены, ограничены или изменены. (неисключаемое право). В максимальной степени, разрешенной законом, наша ответственность перед Пользователем, включая ответственность за нарушение неисключаемых прав и ответственности, которая иным образом не исключена в соответствии с настоящими Условиями использования, ограничивается, по собственному усмотрению Владельца, повторным -оказание услуг или оплата стоимости повторного оказания услуг.

Пользователи из США

Отказ от гарантий

Данное Приложение предоставляется строго на условиях «как есть» и «по мере доступности». Использование Сервиса осуществляется на собственный риск Пользователя. В максимальной степени, разрешенной применимым законодательством, Владелец прямо отказывается от всех условий, заявлений и гарантий — явных, подразумеваемых, установленных законом или иных, включая, помимо прочего, любые подразумеваемые гарантии коммерческой ценности, пригодности для определенной цели или ненарушение прав третьих лиц. Никакие советы или информация, устные или письменные, полученные Пользователем от Владельца или через Сервис, не создают каких-либо гарантий, прямо не указанных в настоящем документе.

Не ограничивая вышесказанное, Владелец, его дочерние компании, аффилированные лица, лицензиары, должностные лица, директора, агенты, совместные бренды, партнеры, поставщики и сотрудники не гарантируют, что контент является точным, надежным или правильным; что Сервис будет соответствовать требованиям Пользователей; что Сервис будет доступен в любое конкретное время и в любом месте, бесперебойно и безопасно; что любые дефекты или ошибки будут исправлены; или что Сервис не содержит вирусов или других вредоносных компонентов. Любой контент, загруженный или иным образом полученный с помощью Сервиса, загружается на собственный риск Пользователей, и Пользователи несут единоличную ответственность за любой ущерб компьютерной системе или мобильному устройству Пользователей или потерю данных, возникшую в результате такой загрузки или использования Пользователями обслуживание.

Владелец не гарантирует, не одобряет, не гарантирует и не принимает на себя ответственность за какой-либо продукт или услугу, рекламируемую или предлагаемую третьей стороной через Сервис или любой веб-сайт или сервис, на который имеется гиперссылка, и Владелец не должен быть стороной или каким-либо образом контролировать любые транзакции между Пользователями и сторонними поставщиками продуктов или услуг.

Сервис может стать недоступным или может не работать должным образом в веб-браузере, мобильном устройстве и/или операционной системе Пользователя. Владелец не может нести ответственность за любой предполагаемый или фактический ущерб, возникший в результате содержания, работы или использования Сервиса.

Федеральное законодательство, законы некоторых штатов и других юрисдикций не допускают исключения и ограничения некоторых подразумеваемых гарантий. Вышеуказанные исключения могут не распространяться на Пользователей. Настоящее Соглашение предоставляет Пользователям определенные законные права, и Пользователи также могут иметь другие права, которые варьируются от штата к штату. Отказ от ответственности и исключения по настоящему соглашению не применяются в степени, запрещенной применимым законодательством.

Ограничения ответственности

В максимальной степени, разрешенной применимым законодательством, ни при каких обстоятельствах Владелец, а также его дочерние компании, филиалы, должностные лица, директора, агенты, со-брендеры, партнеры, поставщики и сотрудники не несут ответственности за:

  • Любые косвенные, штрафные, случайные, особые, косвенные или штрафные убытки, включая, помимо прочего, ущерб от упущенной выгоды, деловой репутации, использования, данных или других нематериальных убытков, возникающих в результате или в связи с использованием или невозможностью использования Сервиса. ; и
  • Любой ущерб, потери или травмы, возникшие в результате взлома, фальсификации или другого несанкционированного доступа или использования Сервиса или учетной записи Пользователя или содержащейся в них информации;
  • Любые ошибки, ошибки или неточности содержания;
  • Травмы или материальный ущерб любого характера, возникшие в результате доступа Пользователя к Сервису или его использования;
  • Любой несанкционированный доступ или использование защищенных серверов Владельца и/или любой личной информации, хранящейся на них;
  • Любое прерывание или прекращение передачи данных в Службу или из нее;
  • Любые ошибки, вирусы, трояны и т.п., которые могут передаваться на Сервис или через него;
  • Любые ошибки или упущения в любом контенте, а также любые убытки или ущерб, понесенные в результате использования любого контента, опубликованного, отправленного по электронной почте, переданного или иным образом предоставленного через Сервис; и/или
  • Клеветническое, оскорбительное или незаконное поведение любого Пользователя или третьей стороны.

Ни при каких обстоятельствах Владелец, а также его дочерние компании, филиалы, должностные лица, директора, агенты, кобрендинговые компании, партнеры, поставщики и сотрудники не несут ответственности за любые претензии, судебные разбирательства, обязательства, обязательства, ущерб, убытки или расходы в сумме, превышающей сумма, выплаченная Пользователем Владельцу по настоящему Соглашению за предыдущие 12 месяцев или в течение срока действия настоящего соглашения между Владельцем и Пользователем, в зависимости от того, что короче.

Этот раздел ограничения ответственности применяется в максимальной степени, разрешенной законодательством применимой юрисдикции, независимо от того, основана ли предполагаемая ответственность на договоре, правонарушении, небрежности, строгой ответственности или любом другом основании, даже если Пользователь был уведомлен о возможности такой ущерб.

В некоторых юрисдикциях не допускается исключение или ограничение ответственности за случайный или косвенный ущерб, поэтому вышеуказанные ограничения или исключения могут не распространяться на Пользователя. Условия предоставляют Пользователю определенные законные права, и Пользователь также может иметь другие права, которые варьируются от юрисдикции к юрисдикции. Отказ от ответственности, исключения и ограничения ответственности в соответствии с условиями не применяются в степени, запрещенной применимым законодательством.

Возмещение ущерба

Пользователь соглашается защищать, возмещать убытки и ограждать Владельца и его дочерние компании, аффилированные лица, должностных лиц, директоров, агентов, со-брендеров, партнеров, поставщиков и сотрудников от любых и всех претензий или требований, убытков, обязательств, убытков, ответственности. , издержки или долги, а также расходы, включая, помимо прочего, судебные издержки и расходы, возникающие в результате:

  • Использование Пользователем Сервиса и доступ к нему, включая любые данные или контент, передаваемые или получаемые Пользователем;
  • Нарушение Пользователем настоящих условий, включая, помимо прочего, нарушение Пользователем любых заявлений и гарантий, изложенных в настоящих условиях;
  • Нарушение Пользователем любых прав третьих лиц, включая, помимо прочего, любое право на неприкосновенность частной жизни или права интеллектуальной собственности;
  • Нарушение Пользователем любого статутного закона, правила или положения;
  • Любой контент, отправленный из учетной записи Пользователя, включая доступ третьих лиц с использованием уникального имени пользователя, пароля или других мер безопасности Пользователя, если применимо, включая, помимо прочего, вводящую в заблуждение, ложную или неточную информацию;
  • Умышленные неправомерные действия Пользователя; или
  • Законодательные положения Пользователя или его аффилированных лиц, должностных лиц, директоров, агентов, совместных брендов, партнеров, поставщиков и сотрудников в степени, разрешенной применимым законодательством.

Общие положения

Без отказа

Неспособность Владельца отстоять какое-либо право или положение в соответствии с настоящими Условиями не является отказом от любого такого права или положения. Никакой отказ не может считаться дальнейшим или продолжающимся отказом от такого условия или любого другого условия.

Прерывание обслуживания

Чтобы обеспечить наилучший уровень обслуживания, Владелец оставляет за собой право прервать предоставление Сервиса для проведения технического обслуживания, обновления системы или любых других изменений, сообщив об этом Пользователям соответствующим образом.

В рамках закона Владелец также может принять решение о приостановке или полном прекращении предоставления Услуги. Если предоставление Услуги будет прекращено, Владелец будет сотрудничать с Пользователями, чтобы дать им возможность отозвать Персональные данные или информацию, и будет уважать права Пользователей, связанные с продолжением использования продукта и/или компенсацией, как это предусмотрено применимым законодательством.

Кроме того, Услуга может быть недоступна по причинам, находящимся вне разумного контроля Владельца, например, в случае «форс-мажорных» обстоятельств (например, сбоев в инфраструктуре или отключений электроэнергии).

Переуступка контракта

Владелец оставляет за собой право передавать, переуступать, распоряжаться путем новации или передавать в субподряд любые или все права или обязательства по настоящим Условиям, принимая во внимание законные интересы Пользователя. Положения, касающиеся изменений настоящих Условий, будут применяться соответствующим образом.

Пользователи не могут каким-либо образом переуступать или передавать свои права или обязанности по настоящим Условиям без письменного разрешения Владельца.

Контакты

Все сообщения, касающиеся использования данного Приложения, должны отправляться с использованием контактной информации, указанной в этом документе.

Делимость

Если какое-либо положение настоящих Условий будет признано или станет недействительным или не имеющим исковой силы в соответствии с применимым законодательством, недействительность или невозможность исковой силы такого положения не повлияет на действительность остальных положений, которые останутся в полной силе и действии.

Пользователи из США: Любое такое недействительное или не имеющее исковой силы положение будет интерпретироваться, истолковываться и изменяться в той степени, в которой это разумно необходимо, чтобы сделать его действительным, исполнимым и соответствующим его первоначальному замыслу. Настоящие Условия представляют собой полное Соглашение между Пользователями и Владельцем в отношении предмета настоящего Соглашения и заменяют все другие сообщения, включая, помимо прочего, все предыдущие соглашения между сторонами в отношении такого предмета. Настоящие Условия будут соблюдаться в полной мере, разрешенной законом.

Пользователи из ЕС: Если какое-либо положение настоящих Условий будет или будет считаться недействительным, недействительным или не имеющим исковой силы, стороны должны сделать все возможное, чтобы мирным путем прийти к соглашению о действительных и исполнимых положениях, тем самым заменив недействительные, недействительные или неисполнимые части. В случае невыполнения этого требования недействительные, недействительные или неисполнимые положения заменяются применимыми законодательными положениями, если это разрешено или указано в соответствии с применимым законодательством. Без ущерба для вышеизложенного недействительность, недействительность или невозможность обеспечить соблюдение конкретного положения настоящих Условий не аннулирует все Соглашение, за исключением случаев, когда отделенные положения являются существенными для Соглашения или настолько важными, что стороны не заключили бы соглашение. контракта, если бы они знали, что это положение не будет действительным, или в случаях, когда остальные положения приведут к неприемлемым трудностям для любой из сторон.

Применимое право

Настоящие Условия регулируются законодательством места нахождения Владельца, как указано в соответствующем разделе настоящего документа, без учета принципов коллизионного права.

Преимущественная сила национального законодательства: однако, независимо от вышеизложенного, если законодательство страны, в которой находится Пользователь, предусматривает более высокие применимые стандарты защиты потребителей, такие более высокие стандарты имеют преимущественную силу.

Исключение для потребителей в Швейцарии: если Пользователь квалифицируется как Потребитель в Швейцарии, будет применяться швейцарское законодательство.

Исключение для потребителей в Бразилии: если Пользователь квалифицируется как Потребитель в Бразилии, а продукт и/или услуга продаются в Бразилии, применяется законодательство Бразилии.

Место юрисдикции

Исключительная компетенция принимать решения по любым разногласиям, возникающим из настоящих Условий или связанным с ними, принадлежит судам по месту нахождения Владельца, как показано в соответствующем разделе настоящего документа.

Исключение для потребителей в Европе: вышеизложенное не применяется ни к Пользователям, которые квалифицируются как Европейские Потребители, ни к Потребителям, проживающим в Великобритании, Швейцарии, Норвегии или Исландии.

Исключение для потребителей в Бразилии

Вышеупомянутое не относится к Пользователям в Бразилии, которые квалифицируются как Потребители.

Разрешение спора

Дружеское разрешение споров

Пользователи могут обращаться с любыми спорами к Владельцу, который постарается разрешить их мирным путем.

Хотя право Пользователей подавать судебные иски всегда остается неизменным, в случае возникновения каких-либо разногласий относительно использования этого Приложения или Сервиса Пользователям предлагается связаться с Владельцем по контактным данным, указанным в этом документе.

Пользователь может подать жалобу, включая краткое описание и, если применимо, подробную информацию о соответствующем заказе, покупке или учетной записи, на адрес электронной почты Владельца, указанный в этом документе.

Владелец обработает жалобу без неоправданной задержки и в течение 3 дней с момента ее получения.

Разрешение споров онлайн для потребителей

Европейская комиссия создала онлайн-платформу для альтернативного разрешения споров, которая облегчает внесудебный метод разрешения споров, связанных и вытекающих из онлайн-контрактов купли-продажи и оказания услуг.

В результате любой европейский потребитель или потребитель, базирующийся в Норвегии, Исландии или Лихтенштейне, может использовать такую ​​платформу для разрешения споров, вытекающих из договоров, заключенных онлайн. Платформа доступна по следующей ссылке.

Определения и юридические ссылки

Данное Приложение (или данное Приложение) — свойство, позволяющее предоставлять Сервис.

Соглашение — любые юридически обязательные или договорные отношения между Владельцем и Пользователем, регулируемые настоящими Условиями.

Бразилия (или Бразилия) — применяется, если Пользователь, независимо от гражданства, находится в Бразилии.

Бизнес-пользователь — любой Пользователь, не являющийся Потребителем.

Цифровой продукт – Продукт, который состоит из: контента, произведенного и поставляемого в цифровой форме; и/или услугу, которая позволяет создавать, обрабатывать, хранить данные или получать к ним доступ в цифровой форме, а также совместно использовать или любую другую форму взаимодействия с цифровыми данными, загруженными или созданными Пользователем или любым другим пользователем данного Приложения.

Европейский (или Европа) — применяется, если Пользователь, независимо от гражданства, находится в ЕС.

Владелец (или Мы) — указывает физическое лицо(а) или юридическое лицо, которое предоставляет это Приложение и/или Сервис Пользователям.

Продукт — товар или услуга, доступные через это Приложение, например, физические товары, цифровые файлы, программное обеспечение, услуги бронирования и т. д., а также любые другие типы продуктов, отдельно определенные в настоящем документе, такие как Цифровые продукты.

Сервис — сервис, предоставляемый данным Приложением, как описано в настоящих Условиях и в настоящем Приложении.

Условия — все положения, применимые к использованию данного Приложения и/или Сервиса, как описано в этом документе, включая любые другие соответствующие документы или соглашения, и которые время от времени обновляются.

Великобритания (или Великобритания) — применяется, если Пользователь, независимо от гражданства, находится в Соединенном Королевстве.

Пользователь (или Вы) — обозначает любое физическое или юридическое лицо, использующее данное Приложение.

Потребитель . Потребителем является любой Пользователь, квалифицируемый как таковой в соответствии с применимым законодательством.

Последнее обновление: 20 ноября 2024 г.

``` ===== FILE: apps/kadrum/protected/views/public/index.php ===== ``` One Button VPN - ваш ключ к защищенному интернету!

Ваш ключ к защищенному интернету

Скачайте наш VPN и оставайтесь защищенными онлайн

Ваш ключ к защищенному интернету

VPN защищает ваши данные, скрывает ваше местоположение и помогает безопасно пользоваться интернетом, даже в общественных сетях Wi-Fi

Высокая скорость соединения

Наслаждайтесь стабильным интернетом без задержек и сбоев с постоянной скоростью до 1 Gb/сек

Управляйте защитой по своему

Выбирайте, какие приложения или сайты использовать через наше приложение, остальное будет работать без VPN

Глобальный доступ в один клик

Наша сеть охватывает десятки стран, чтобы вы всегда оставались на связи с нужным регионом

Мы не собираем ваши данные — конфиденциальность гарантирована

Наш VPN создан для вашей безопасности и анонимности — мы не храним и не передаем персональную информацию пользователей

``` ===== FILE: apps/kadrum/protected/views/public/sessions.php ===== ``` Галерея /css/reset.css" type="text/css">

Фотосессии

``` ===== FILE: apps/kadrum/protected/views/public/privacy-policy.php ===== ``` One Button VPN - ваш ключ к защищенному интернету!

Политика конфиденциальности приложения One Button VPN

Это Приложение собирает некоторые Персональные данные от своих Пользователей.

Права потребителей на конфиденциальность

Этот документ содержит раздел, посвященный потребителям из Калифорнии и их правам на конфиденциальность.

Этот документ содержит раздел, посвященный потребителям штата Вирджиния и их правам на конфиденциальность.

Этот документ содержит раздел, посвященный потребителям из Колорадо и их правам на конфиденциальность.

Этот документ содержит раздел, посвященный потребителям Коннектикута и их правам на конфиденциальность.

Этот документ содержит раздел, посвященный потребителям штата Юта и их правам на конфиденциальность.

Этот документ содержит раздел, посвященный Пользователям в Швейцарии и их правам на конфиденциальность.

Этот документ содержит раздел, посвященный бразильским пользователям и их правам на конфиденциальность.

Этот документ можно распечатать для справки, воспользовавшись командой печати в настройках любого браузера.

Владелец и Контроллер данных

ИП Китаев Михаил Андреевич. ИНН/ОГРНИП: 421189830262/315422300007697

Контактный адрес электронной почты владельца: hi@onebttn.com

Типы собираемых данных

Полная информация о каждом типе собираемых Персональных данных представлена ​​в специальных разделах настоящей политики конфиденциальности или в конкретных поясняющих текстах, отображаемых перед сбором Данных.

Персональные данные могут быть свободно предоставлены Пользователем или, в случае Данных об использовании, собраны автоматически при использовании данного Приложения.

Если не указано иное, все Данные, запрашиваемые этим Приложением, являются обязательными, и непредоставление этих Данных может сделать невозможным предоставление этим Приложением своих услуг. В тех случаях, когда в этом Приложении прямо указано, что некоторые Данные не являются обязательными, Пользователи могут не передавать эти Данные без последствий для доступности или функционирования Сервиса.

Пользователи, которые не уверены в том, какие Персональные данные являются обязательными, могут связаться с Владельцем.

Режим и место обработки Данных

Методы обработки

Владелец принимает соответствующие меры безопасности для предотвращения несанкционированного доступа, раскрытия, изменения или несанкционированного уничтожения Данных.

Обработка Данных осуществляется с использованием компьютеров и/или ИТ-инструментов в соответствии с организационными процедурами и режимами, строго связанными с указанными целями. Помимо Владельца, в некоторых случаях Данные могут быть доступны определенным типам ответственных лиц, участвующих в работе данного Приложения (администрирование, продажи, маркетинг, юриспруденция, системное администрирование) или внешним сторонам (например, третьим лицам). поставщики технических услуг сторон, почтовые перевозчики, хостинг-провайдеры, ИТ-компании, агентства связи), назначенные, при необходимости, Владельцем обработчиками данных. Обновленный список этих сторон можно запросить у Владельца в любое время.

Место

Данные обрабатываются в операционных офисах Владельца и в любых других местах, где находятся лица, участвующие в обработке.

В зависимости от местоположения Пользователя передача данных может включать передачу Данных Пользователя в другую страну, кроме своей страны. Чтобы узнать больше о месте обработки таких передаваемых Данных, Пользователи могут просмотреть раздел, содержащий подробную информацию об обработке Персональных данных.

Время удержания

Если иное не указано в настоящем документе, Персональные данные будут обрабатываться и храниться до тех пор, пока этого требует цель, для которой они были собраны, и могут храниться дольше в соответствии с применимыми юридическими обязательствами или на основании согласия Пользователей.

Цели обработки

Данные, касающиеся Пользователя, собираются для того, чтобы Владелец мог предоставлять свои Услуги, выполнять свои юридические обязательства, реагировать на запросы правоохранительных органов, защищать свои права и интересы (или права и интересы своих Пользователей или третьих лиц), обнаруживать любую вредоносную или мошенническую деятельность, а также следующее: Оптимизация и распределение трафика.

Для получения конкретной информации о Персональных данных, используемых для каждой цели, Пользователь может обратиться к разделу «Подробная информация об обработке Персональных данных».

Подробная информация об обработке Персональных данных

Приложение One Button VPN собирает Персональные данные, которые вы предоставляете при создании или обновлении Учетной записи. Нам требуются Персональные данные, такие как адрес электронной почты и идентификатор устройства, чтобы мы могли предоставлять вам наши Услуги, отправлять вам электронные письма, получать платежи, отвечать на запросы в службу поддержки и делиться соответствующей информацией о вашей Учетной записи и/или Услугах.

Дополнительная информация для пользователей

Правовая основа обработки

Владелец может обрабатывать Персональные данные, относящиеся к Пользователям, если применимо одно из следующих условий:

  • Пользователи дали свое согласие для одной или нескольких конкретных целей.
  • Предоставление Данных необходимо для исполнения соглашения с Пользователем и/или любых его преддоговорных обязательств;
  • Обработка необходима для соблюдения юридического обязательства, которому подчиняется Владелец;
  • Обработка связана с задачей, которая выполняется в общественных интересах или при осуществлении официальных полномочий, предоставленных Владельцу;
  • Обработка необходима для целей законных интересов, преследуемых Владельцем или третьим лицом.

В любом случае Владелец с радостью поможет разъяснить конкретную правовую основу, применимую к обработке, и, в частности, является ли предоставление Персональных данных законодательным или договорным требованием или требованием, необходимым для заключения договора.

Дополнительная информация о времени хранения

Если иное не указано в настоящем документе, Персональные данные будут обрабатываться и храниться до тех пор, пока этого требует цель, для которой они были собраны, и могут храниться дольше в соответствии с применимыми юридическими обязательствами или на основании согласия Пользователей.

Права Пользователей на основании Общего регламента защиты данных (GDPR)

Пользователи могут осуществлять определенные права в отношении своих Данных, обрабатываемых Владельцем. В частности, Пользователи имеют право совершать следующие действия в пределах, разрешенных законодательством:

  • Отозвать свое согласие в любое время. Пользователи имеют право отозвать согласие, если они ранее дали согласие на обработку своих Персональных данных.
  • Возражать против обработки своих Данных. Пользователи имеют право возражать против обработки своих Данных, если обработка осуществляется на правовом основании, отличном от согласия.
  • Доступ к их данным. Пользователи имеют право узнать, обрабатываются ли Данные Владельцем, получить раскрытие информации относительно определенных аспектов обработки и получить копию Данных, подвергающихся обработке.
  • Проверьте и запросите исправление. Пользователи имеют право проверять точность своих Данных и требовать их обновления или исправления.
  • Ограничить обработку своих Данных. Пользователи имеют право ограничить обработку своих Данных. В этом случае Владелец не будет обрабатывать свои Данные ни для каких иных целей, кроме их хранения.
  • Удалить или иным образом удалить свои Персональные данные. Пользователи имеют право потребовать от Владельца удаления своих Данных.
  • Получить свои данные и передать их другому контролеру. Пользователи имеют право получать свои Данные в структурированном, широко используемом и машиночитаемом формате и, если это технически возможно, беспрепятственно передавать их другому контролеру.
  • Подавать жалобу. Пользователи имеют право подать иск в свой компетентный орган по защите данных.

Пользователи также имеют право узнать о правовой основе передачи Данных за границу, в том числе в любую международную организацию, регулируемую международным публичным правом или созданную двумя или более странами, например ООН, а также о мерах безопасности, принимаемых Владельцем для защиты их данных. Данные.

Подробности о праве на возражение против обработки

Если Персональные данные обрабатываются в общественных интересах, при осуществлении официальных полномочий, предоставленных Владельцу, или в целях законных интересов, преследуемых Владельцем, Пользователи могут возразить против такой обработки, предоставив основание, связанное с их конкретной ситуацией. обоснуйте возражение.

Пользователи должны знать, что, однако, если их Персональные данные обрабатываются в целях прямого маркетинга, они могут в любое время возразить против такой обработки, бесплатно и без предоставления каких-либо обоснований. Если Пользователь возражает против обработки в целях прямого маркетинга, Персональные данные больше не будут обрабатываться для таких целей. Чтобы узнать, обрабатывает ли Владелец Персональные данные в целях прямого маркетинга, Пользователи могут обратиться к соответствующим разделам настоящего документа.

Как реализовать эти права

Любые запросы на реализацию прав Пользователя могут быть направлены Владельцу через контактную информацию, указанную в этом документе. Такие запросы бесплатны, и Владелец ответит на них как можно раньше и всегда в течение одного месяца, предоставив Пользователям информацию, необходимую по закону.

Дополнительная информация для пользователей в Бразилии

Этот раздел документа интегрируется и дополняет информацию, содержащуюся в остальной части политики конфиденциальности, и предоставляется организацией, использующей это Приложение, и, если возможно, его материнской компанией, дочерними и аффилированными компаниями (для целей настоящего раздела именуемых в совокупности как «мы», «нас», «наш»).

Этот раздел применим ко всем Пользователям в Бразилии (Пользователи именуются ниже просто «вы», «ваш», «ваш») в соответствии с «Lei Geral de Proteção de Dados» («LGPD»), и для таких Пользователей, она заменяет любую другую, возможно, расходящуюся или противоречивую информацию, содержащуюся в политике конфиденциальности.

В этой части документа используется термин «личная информация», как он определен в LGPD.

Основания, на которых мы обрабатываем вашу личную информацию

Мы можем обрабатывать вашу личную информацию только в том случае, если у нас есть правовое основание для такой обработки. Правовые основы следующие:

  • Ваше согласие на соответствующую обработку;
  • Соблюдение юридических или нормативных обязательств, лежащих на нас;
  • Проведение государственной политики, предусмотренной законами или постановлениями или основанной на контрактах, соглашениях и аналогичных правовых документах;
  • Исследования, проводимые исследовательскими организациями, желательно на анонимной личной информации;
  • Исполнение договора и его предварительные процедуры, если вы являетесь стороной указанного договора;
  • Осуществление наших прав в судебных, административных или арбитражных процедурах;
  • Защита или физическая безопасность себя или третьего лица;
  • Охрана здоровья – в процедурах, выполняемых организациями здравоохранения или специалистами;
  • Наши законные интересы при условии, что ваши основные права и свободы не преобладают над такими интересами; и
  • Кредитная защита.

Чтобы узнать больше о правовых основах, вы можете связаться с нами в любое время, используя контактную информацию, указанную в этом документе.

Категории обрабатываемой личной информации

Чтобы узнать, какие категории вашей личной информации обрабатываются, вы можете прочитать раздел «Подробная информация об обработке персональных данных» данного документа.

Почему мы обрабатываем вашу личную информацию

Чтобы узнать, почему мы обрабатываем вашу личную информацию, вы можете прочитать разделы «Подробная информация об обработке Персональных данных» и «Цели обработки» настоящего документа.

Ваши права на конфиденциальность в Бразилии, как подать запрос и наш ответ на ваши запросы

Ваши права на конфиденциальность в Бразилии:

  • Получить подтверждение факта обработки вашей личной информации;
  • Доступ к вашей личной информации;
  • Исправлять неполную, неточную или устаревшую личную информацию;
  • Добиться анонимности, блокировки или удаления вашей ненужной или чрезмерной личной информации или информации, которая не обрабатывается в соответствии с LGPD;
  • Получить информацию о возможности предоставления или отказа в согласии и последствиях этого;
  • Получать информацию о третьих лицах, которым мы передаем вашу личную информацию;
  • Обеспечить по вашему прямому запросу возможность переноса вашей личной информации (за исключением анонимной информации) другому поставщику услуг или продуктов при условии, что наши коммерческие и промышленные тайны будут защищены;
  • Добиться удаления вашей обрабатываемой личной информации, если обработка была основана на вашем согласии, за исключением одного или нескольких исключений, предусмотренных в ст. применяются статьи 16 LGPD;
  • Отозвать свое согласие в любое время;
  • Подать жалобу, связанную с вашей личной информацией, в ANPD (Национальный орган по защите данных) или в органы по защите прав потребителей;
  • Возражать против обработки данных в случаях, когда обработка не осуществляется в соответствии с положениями закона;
  • Запросить четкую и адекватную информацию о критериях и процедурах, используемых для автоматического принятия решения; и
  • Запрашивать пересмотр решений, принятых исключительно на основе автоматизированной обработки вашей личной информации, затрагивающих ваши интересы. К ним относятся решения по определению вашего личного, профессионального, потребительского и кредитного профиля или аспектов вашей личности.

Вы никогда не подвергнетесь дискриминации или иным образом не пострадаете, если воспользуетесь своими правами.

Как подать заявку

Вы можете подать прямой запрос на бесплатное осуществление своих прав в любое время, используя контактную информацию, указанную в этом документе, или через своего законного представителя.

Как и когда мы ответим на ваш запрос

Мы постараемся оперативно реагировать на ваши запросы. В любом случае, если мы не сможем это сделать, мы обязательно сообщим вам фактические или юридические причины, которые не позволяют нам немедленно или иным образом выполнить ваши запросы. В тех случаях, когда мы не обрабатываем вашу личную информацию, мы укажем вам физическое или юридическое лицо, которому вы должны направить свои запросы, если мы в состоянии это сделать.

Если вы подаете запрос на подтверждение доступа или обработки личной информации, обязательно укажите, хотите ли вы, чтобы ваша личная информация была доставлена ​​в электронной или печатной форме. Вам также необходимо будет сообщить нам, хотите ли вы, чтобы мы ответили на ваш запрос немедленно (в этом случае мы ответим в упрощенном виде) или вместо этого вам потребуется полное раскрытие информации. В последнем случае мы ответим в течение 15 дней с момента вашего запроса, предоставив вам всю информацию о происхождении вашей личной информации, подтверждение того, существуют ли записи, любые критерии, используемые для обработки, и цели. обработки, сохраняя при этом наши коммерческие и промышленные тайны.

В случае, если вы подаете запрос на исправление, удаление, анонимизацию или блокировку личной информации, мы обязательно немедленно передадим ваш запрос другим лицам, которым мы передали вашу личную информацию, чтобы дать возможность таким третьим лицам также соблюдать ваши запрос — за исключением случаев, когда такое общение оказывается невозможным или требует непропорциональных усилий с нашей стороны.

Передача личной информации за пределы Бразилии разрешена законом

Нам разрешено передавать вашу личную информацию за пределы территории Бразилии в следующих случаях:

  • когда передача необходима для международно-правового сотрудничества органов государственной разведки, следствия и прокуратуры в соответствии с правовыми средствами, предусмотренными международным правом;
  • когда передача необходима для защиты вашей жизни или физической безопасности или безопасности третьих лиц;
  • когда передача разрешена ANPD;
  • когда передача является результатом обязательства, взятого на себя в соглашении о международном сотрудничестве;
  • когда перевод необходим для исполнения государственной политики или законного присвоения государственной службы;
  • когда передача необходима для соблюдения юридического или нормативного обязательства, выполнения контракта или предварительных процедур, связанных с контрактом, или регулярного осуществления прав в судебных, административных или арбитражных процедурах.

Дополнительная информация для потребителей Калифорнии

Этот раздел документа интегрируется и дополняет информацию, содержащуюся в остальной части политики конфиденциальности, и предоставляется компанией, использующей это Приложение, и, если возможно, ее материнской компанией, дочерними и аффилированными компаниями (для целей этого раздела в совокупности как «мы», «нас», «наш»).

Этот раздел применяется ко всем Пользователям (далее пользователи именуются просто «вы», «ваш», «ваш»), которые являются потребителями, проживающими в штате Калифорния, Соединенные Штаты Америки, в соответствии с «Законом о конфиденциальности потребителей штата Калифорния». Закон 2018 года» («CCPA») с изменениями, внесенными «Законом Калифорнии о правах на конфиденциальность» («CPRA») и последующими нормативными актами. Для таких потребителей этот раздел заменяет любую другую, возможно, расходящуюся или противоречивую информацию, содержащуюся в политике конфиденциальности.

В этой части документа используется термин «личная информация», как он определен в Законе Калифорнии о конфиденциальности потребителей (CCPA/CPRA).

Уведомление при сборе

Категории личной информации, собираемой, используемой, продаваемой или передаваемой

В этом разделе мы суммируем категории личной информации, которую мы собираем, используем, продаем или передаем, а также ее цели. Подробно об этой деятельности можно прочитать в разделе «Подробная информация об обработке Персональных данных» настоящего документа.

Информация, которую мы собираем: категории личной информации, которую мы собираем.

Мы собрали следующие категории личной информации о вас: информация о деятельности в Интернете или других электронных сетях.

Мы не собираем конфиденциальную личную информацию.

Мы не будем собирать дополнительные категории личной информации без вашего предварительного уведомления.

Для каких целей мы используем вашу личную информацию?

Мы можем использовать вашу личную информацию для обеспечения оперативного функционирования данного Приложения и его функций («деловые цели»). В таких случаях ваша личная информация будет обрабатываться способом, необходимым и соразмерным деловой цели, для которой она была собрана, и строго в пределах совместимых операционных целей.

Мы также можем использовать вашу личную информацию для других целей, например, в коммерческих целях, как указано в разделе «Подробная информация об обработке персональных данных» в этом документе, а также для соблюдения закона и защиты наших прав перед компетентными органами. когда наши права и интересы находятся под угрозой или нам причинен реальный ущерб.

Мы не будем обрабатывать вашу информацию в непредвиденных целях или в целях, несовместимых с первоначально раскрытыми целями, без вашего согласия.

Как долго мы храним вашу личную информацию?

Если иное не указано в разделе «Подробная информация об обработке Персональных данных», мы не будем хранить вашу личную информацию дольше, чем это разумно необходимо для целей, для которых она была собрана.

Как мы собираем информацию: каковы источники собираемой нами личной информации?

Мы собираем вышеупомянутые категории личной информации прямо или косвенно от вас, когда вы используете это Приложение.
Например, вы напрямую предоставляете свою личную информацию при отправке запросов через любые формы в этом Приложении. Вы также предоставляете личную информацию косвенно при навигации по этому Приложению, поскольку личная информация о вас автоматически отслеживается и собирается.
Наконец, мы можем собирать вашу личную информацию от третьих лиц, которые работают с нами в связи с Сервисом или с функционированием данного Приложения и его функций.

Как мы используем информацию, которую собираем: раскрытие вашей личной информации третьим лицам в деловых целях.

Для наших целей слово «третья сторона» означает лицо, которое не является ни одним из следующих: поставщиком услуг или подрядчиком, как это определено CCPA.
Мы раскрываем вашу личную информацию третьим лицам, подробно перечисленным в разделе «Подробная информация об обработке Персональных данных» настоящего документа. Эти третьи стороны сгруппированы и классифицированы в соответствии с различными целями обработки.

Никакой продажи вашей личной информации

Мы не продаем и не передаем вашу личную информацию. В случае, если мы примем такое решение, мы сообщим вам заранее и предоставим вам право отказаться от такой продажи.

Ваши права на конфиденциальность в соответствии с Законом Калифорнии о конфиденциальности потребителей и способы их реализации.

Право на доступ к личной информации: право на знание и на переносимость.

  • Вы имеете право потребовать, чтобы мы раскрыли категории личной информации, которую мы собираем о вас, источники, цели, которым мы раскрываем такую ​​информацию, а также конкретные фрагменты личной информации, которую мы собрали.
  • Вы также имеете право знать, какая личная информация продается или передается и кому, запросив два отдельных списка, в которых мы раскрываем категории личной информации, продаваемой или передаваемой, и категории третьих лиц, которым информация была продана или передана.

Право запросить удаление вашей личной информации с учетом законодательных исключений, а также право исправлять неточную личную информацию.

Право отказаться от продажи или передачи личной информации и ограничить использование вашей конфиденциальной личной информации.

Право на невозмездие после отказа или осуществления других прав (право на недискриминацию).

Как реализовать свои права

Чтобы воспользоваться своими правами, отправьте нам поддающийся проверке запрос, используя предоставленные контактные данные.

  • Предоставьте нам достаточную информацию, чтобы убедиться, что вы являетесь лицом, о котором мы собрали личную информацию, или уполномоченным представителем.
  • Опишите свой запрос достаточно подробно, чтобы мы могли его понять, оценить и ответить на него.

Мы не будем отвечать ни на один запрос, если не сможем подтвердить вашу личность и подтвердить, что личная информация относится к вам.

Для подачи поддающегося проверке запроса потребителя не требуется создавать у нас учетную запись. Если вы не можете лично подать поддающийся проверке запрос, вы можете уполномочить кого-либо действовать от вашего имени.

Вы можете подать максимум 2 запроса в течение 12 месяцев.

Как и когда мы должны обработать ваш запрос

Мы подтвердим получение вашего поддающегося проверке запроса в течение 10 дней и предоставим информацию о том, как мы его обработаем.

Мы ответим в течение 45 дней с момента его получения. Если потребуется больше времени, мы сообщим вам причину и период продления.

Наше раскрытие будет охватывать предыдущий 12-месячный период. В отношении личной информации, собранной 1 января 2022 года или после этой даты, вы можете потребовать, чтобы мы раскрыли информацию по истечении 12-месячного периода.

Если мы отклоним ваш запрос, мы объясним причины нашего отказа. Мы не взимаем плату, за исключением случаев, когда ваш запрос является явно необоснованным или чрезмерным. В таких случаях мы можем взимать разумную плату или отказать в выполнении запроса. В любом случае мы сообщим о своем выборе и объясним его причины.

Дополнительная информация для потребителей Вирджинии

Этот раздел интегрируется и дополняет информацию, содержащуюся в остальной части политики конфиденциальности. Он предоставляется контролером, запускающим это Приложение, в число которого могут входить его материнские, дочерние и аффилированные компании.

Этот раздел относится конкретно к Пользователям, которые являются потребителями, проживающими в Содружестве Вирджиния, в соответствии с Законом штата Вирджиния о защите данных потребителей (VCDPA). Для этих потребителей этот раздел имеет приоритет над любой другой, возможно, расходящейся или противоречивой информацией в политике конфиденциальности.

Здесь используется термин « персональные данные» в соответствии с его определением VCDPA.

Категории обрабатываемых персональных данных

Мы суммируем категории обрабатываемых персональных данных и их цели, как подробно описано в разделе «Подробная информация об обработке персональных данных» настоящего документа.

Категории персональных данных, которые мы собираем

Мы собрали такие категории персональных данных, как информация из Интернета. Мы не собираем конфиденциальные данные и не будем собирать дополнительные категории без предварительного уведомления.

Почему мы обрабатываем ваши персональные данные

Информацию о том, почему мы обрабатываем ваши персональные данные, можно найти в разделах «Подробная информация об обработке Персональных данных» и «Цели обработки» настоящего документа. Мы не будем обрабатывать ваши данные для непредвиденных или несовместимых целей без вашего согласия, которым вы можете управлять, используя предоставленные контактные данные.

Как мы используем данные, которые собираем: передача ваших личных данных третьим лицам

Мы передаем ваши персональные данные третьим лицам, указанным в разделе «Подробная информация об обработке Персональных данных» . Термин «третья сторона» определен в VCDPA.

Продажа ваших личных данных

Мы не продаем ваши персональные данные. Если ситуация изменится, вы будете проинформированы и сможете отказаться от продажи.

Обработка ваших персональных данных для целевой рекламы

Мы не используем ваши персональные данные для целевой рекламы, но обновим нашу политику и предоставим возможность отказа, если она изменится.

Ваши права на конфиденциальность в соответствии с Законом штата Вирджиния о защите потребительских данных и способы их реализации.

Вы можете осуществлять определенные права в отношении ваших данных, обрабатываемых нами, включая право на доступ, исправление, удаление или получение копии ваших личных данных, а также отказаться от определенных действий по обработке данных.

  • Доступ к личным данным: право знать, обрабатываем ли мы ваши личные данные, и иметь к ним доступ.
  • Исправить неточные персональные данные: право на исправление любых неточностей в ваших личных данных.
  • Запрос на удаление: право запросить удаление ваших личных данных.
  • Получить копию: право получить копию ваших данных в портативном формате.
  • Отказ: право отказаться от целевой рекламы, продажи личных данных или профилирования.
  • Недискриминация: мы не будем дискриминировать вас за реализацию ваших прав на конфиденциальность.

Чтобы воспользоваться этими правами, свяжитесь с нами, используя информацию, представленную в этом документе.

Как и когда мы должны обработать ваш запрос

Мы ответим на ваш запрос без неоправданной задержки, в течение 45 дней. Если потребуется больше времени, мы сообщим вам причины и ожидаемые сроки.

Если мы отклоним ваш запрос, мы укажем причины нашего отказа, и вы имеете право подать апелляцию.

Мы не взимаем плату за ответ на ваши запросы, если они не являются необоснованными, чрезмерными или повторяющимися. В таких случаях мы можем взимать плату или отказать в выполнении запроса.

Дополнительная информация для потребителей Колорадо

Этот раздел дополняет политику конфиденциальности конкретными соображениями для пользователей в Колорадо в соответствии с Законом о конфиденциальности штата Колорадо (CPA).

Персональные данные. Термин «персональные данные» определен в CPA.

Категории обрабатываемых персональных данных

Мы суммируем типы персональных данных, которые мы обрабатываем, и их цели. Более подробные пояснения можно найти в разделе «Подробная информация об обработке Персональных данных» настоящего документа.

  • Информация в Интернете: основная категория персональных данных, которые мы собираем.
  • Конфиденциальные данные: мы не собираем конфиденциальные персональные данные.
  • Дополнительные данные: Мы не будем собирать дополнительные категории персональных данных без вашего ведома.

Почему мы обрабатываем ваши персональные данные

Подробную информацию о том, почему мы обрабатываем ваши персональные данные, можно найти в разделе «Цели обработки».

Мы обязуемся не обрабатывать вашу информацию в целях, которые являются неожиданными или несовместимыми с раскрытыми целями, без вашего согласия, которое вы можете дать, отклонить или отозвать в любое время.

Как мы используем данные, которые собираем

Мы передаем ваши персональные данные третьим лицам способом, который соответствует целям обработки, как указано в нашей политике конфиденциальности. Мы определяем термин «третья сторона» в соответствии с CPA.

Продажа ваших личных данных

Мы не продаем персональные данные. Если ситуация изменится, вы будете проинформированы и сможете отказаться от продажи.

Определение «продажи»: для целей CPA «продажа» предполагает обмен персональных данных за денежное или иное ценное вознаграждение.

Исключения: Определенное раскрытие персональных данных, например, обработчику, действующему от нашего имени, не считается продажей в соответствии с CPA. Могут также применяться и другие конкретные исключения.

Обработка ваших персональных данных для целевой рекламы

Мы не используем персональные данные для целевой рекламы согласно определению CPA. Если что-то изменится, вы получите уведомление и сможете отказаться.

Определение «целевой рекламы»: это относится к рекламе, основанной на личных данных, собранных с течением времени в результате вашей деятельности на несвязанных веб-сайтах или онлайн-сервисах.

Исключения: Определенные виды рекламы не считаются целевой рекламой в соответствии с CPA, например, те, которые непосредственно связаны с запросами потребителей, на собственных веб-сайтах или в приложениях контролера или для измерения рекламы без профилирования потребителей.

Ваши права на конфиденциальность в соответствии с Законом штата Колорадо о конфиденциальности и способы их реализации.

Вы можете осуществлять определенные права в отношении ваших данных, обрабатываемых нами. В частности, вы имеете право сделать следующее:

  • Отказаться: от обработки ваших персональных данных в целях целевой рекламы, продажи персональных данных или профилирования для принятия решений, которые приводят к юридическим или аналогичным значимым последствиям в отношении вас.
  • Доступ к личным данным: вы имеете право запросить у нас подтверждение того, обрабатываем ли мы ваши личные данные. Вы также имеете право на доступ к таким личным данным.
  • Исправить неточные персональные данные: вы имеете право потребовать, чтобы мы исправили любые неточные персональные данные, которые мы храним о вас, принимая во внимание характер персональных данных и цели обработки персональных данных.
  • Запросить удаление ваших личных данных. Вы имеете право потребовать, чтобы мы удалили любые ваши личные данные.
  • Получите копию ваших личных данных: мы предоставим ваши личные данные в портативном и удобном формате, который позволит вам легко передавать данные другому лицу – при условии, что это технически осуществимо.

В любом случае мы не будем увеличивать стоимость или уменьшать доступность продукта или услуги исключительно на основании осуществления каких-либо ваших прав и не связанных с осуществимостью или ценностью услуги. Однако в той степени, в которой это разрешено законом, мы можем предложить вам другую цену, тариф, уровень, качество или выбор товаров или услуг, в том числе предлагать товары или услуги бесплатно, если наше предложение связано с вашим добровольным участием. в добросовестной программе лояльности, вознаграждениях, премиальных функциях, скидках или клубных картах.

Как реализовать свои права

Чтобы воспользоваться описанными выше правами, вам необходимо отправить нам запрос, связавшись с нами через контактную информацию, указанную в этом документе.

Чтобы мы могли ответить на ваш запрос, нам необходимо знать, кто вы и какое право вы хотите реализовать.

Мы не будем отвечать на любой запрос, если мы не сможем подтвердить вашу личность, используя коммерчески разумные усилия, и, следовательно, подтвердить, что личные данные, которыми мы располагаем, действительно относятся к вам. В таких случаях мы можем запросить у вас дополнительную информацию, которая разумно необходима для аутентификации вас и вашего запроса.

Для подачи потребительского запроса вам не требуется создавать у нас учетную запись. Однако мы можем потребовать от вас использовать существующую учетную запись. Мы будем использовать любые персональные данные, полученные от вас в связи с вашим запросом, исключительно в целях аутентификации, без дальнейшего раскрытия персональных данных, хранения их дольше, чем необходимо для целей аутентификации, или использования их для несвязанных целей.

Если вы взрослый человек, вы можете подать запрос от имени ребенка, находящегося под вашей родительской опекой.

Как и когда мы должны обработать ваш запрос

Мы ответим на ваш запрос без неоправданной задержки, но во всех случаях и не позднее, чем в течение 45 дней с момента его получения. Если нам понадобится больше времени, мы объясним вам причины и сколько еще времени нам нужно. В связи с этим обратите внимание, что выполнение вашего запроса может занять до 90 дней.

Если мы отклоним ваш запрос, мы объясним вам причины нашего отказа без неоправданной задержки, но во всех случаях и не позднее, чем в течение 45 дней с момента получения запроса. Вы имеете право обжаловать такое решение, отправив нам запрос по реквизитам, указанным в этом документе. В течение 45 дней с момента получения апелляции мы сообщим вам в письменной форме о любых действиях, предпринятых или не предпринятых в ответ на апелляцию, включая письменное объяснение причин принятых решений. Если апелляция отклонена, вы можете обратиться к Генеральному прокурору и подать жалобу.

Мы не взимаем плату за ответ на ваш запрос (до двух запросов в год).

Дополнительная информация для потребителей Коннектикута

Этот раздел документа интегрируется и дополняет информацию, содержащуюся в остальной части политики конфиденциальности, и предоставляется контролером, использующим это Приложение, и, если возможно, его материнской компанией, дочерними и аффилированными компаниями (для целей этого раздела). совместно именуемые «мы», «нас», «наш»).

Этот раздел применяется ко всем Пользователям (далее Пользователи именуются просто «вы», «ваш», «ваш»), которые являются потребителями, проживающими в штате Коннектикут, в соответствии с «Законом о конфиденциальности персональных данных и онлайн-мониторинге». (также известный как «Закон Коннектикута о конфиденциальности данных» или «CTDPA»), и для таких потребителей он заменяет любую другую, возможно, расходящуюся или противоречивую информацию, содержащуюся в политике конфиденциальности.

В этой части документа используется термин «персональные данные», как он определен в CTDPA.

Категории обрабатываемых персональных данных

В этом разделе мы суммируем категории персональных данных, которые мы обрабатываем, и их цели. Подробно об этой деятельности можно прочитать в разделе «Подробная информация об обработке Персональных данных» настоящего документа.

  • Категории персональных данных, которые мы собираем: Мы собрали следующие категории персональных данных: информация в Интернете.
  • Мы не собираем конфиденциальные данные.
  • Мы не будем собирать дополнительные категории персональных данных без вашего уведомления.

Почему мы обрабатываем ваши персональные данные

Чтобы узнать, почему мы обрабатываем ваши персональные данные, вы можете прочитать разделы «Подробная информация об обработке Персональных данных» и «Цели обработки» настоящего документа.

Мы не будем обрабатывать вашу информацию в непредвиденных целях или в целях, несовместимых с первоначально раскрытыми целями, без вашего согласия. Вы можете свободно дать, отклонить или отозвать такое согласие в любое время, используя контактную информацию, указанную в этом документе.

Как мы используем данные, которые собираем: передача ваших личных данных третьим лицам

Мы передаем ваши персональные данные третьим лицам, подробно перечисленным в разделе «Подробная информация об обработке персональных данных» настоящего документа. Эти третьи стороны сгруппированы и классифицированы в соответствии с различными целями обработки.

Для наших целей слово «третья сторона» означает «лицо, государственный орган, агентство или орган, не являющийся потребителем, контролером, обработчиком или аффилированным лицом обработчика или контролера», как это определено CTDPA.

Продажа ваших личных данных

Мы не продаем ваши персональные данные. В случае, если мы примем такое решение, мы сообщим вам заранее и предоставим вам право отказаться от такой продажи.

Для наших целей слова «продажа», «продать» или «проданный» означают «обмен персональных данных за денежное или иное ценное вознаграждение контролером третьей стороне», как это определено CTDPA.

Обратите внимание, что согласно CTDPA раскрытие персональных данных обработчику, который обрабатывает персональные данные от имени контролера, не является продажей. Кроме того, могут применяться другие конкретные исключения, изложенные в CTDPA, такие как, помимо прочего, раскрытие персональных данных третьей стороне для предоставления запрошенного вами продукта или услуги.

Обработка ваших персональных данных для таргетированной рекламы

Мы не обрабатываем ваши персональные данные для таргетированной рекламы. Если мы примем такое решение, мы заранее сообщим вам об этом и предоставим вам право отказаться от обработки ваших личных данных для целевой рекламы.

Для наших целей слово «таргетированная реклама» означает «показ потребителю рекламы, которая выбрана на основе личных данных, полученных или выведенных с течением времени из действий потребителя на неаффилированных веб-сайтах, в приложениях или онлайн-сервисах для прогнозирования потребительских предпочтений или интересов». согласно определению CTDPA.

Обратите внимание, что согласно CTDPA, целевая реклама не включает: «рекламу, основанную на действиях на собственных веб-сайтах или онлайн-приложениях контролера; рекламные объявления, основанные на контексте текущего поискового запроса потребителя, посещения интернет-сайта или онлайн-приложения; рекламные объявления, направленные потребителю в ответ на запрос потребителя о предоставлении информации или отзыве; или обработку персональных данных исключительно для измерения или сообщения о частоте, эффективности или охвате рекламы».

Ваши права на конфиденциальность в соответствии с Законом штата Коннектикут о конфиденциальности данных и способы их реализации

Вы можете осуществлять определенные права в отношении ваших данных, обрабатываемых нами. В частности, вы имеете право сделать следующее:

  • Доступ к персональным данным. Вы имеете право запросить подтверждение того, обрабатываем ли мы ваши персональные данные. Вы также имеете право на доступ к таким личным данным.
  • Исправить неточные персональные данные: вы имеете право потребовать, чтобы мы исправили любые неточные персональные данные, которые мы храним о вас, принимая во внимание характер персональных данных и цели обработки персональных данных.
  • Запросить удаление ваших личных данных. Вы имеете право потребовать, чтобы мы удалили любые ваши личные данные.
  • Получите копию ваших личных данных: мы предоставим ваши личные данные в переносимом и удобном формате, который позволит вам легко передавать данные другому лицу — при условии, что это технически осуществимо.
  • Отказ от участия: вы можете отказаться от обработки ваших персональных данных в целях целевой рекламы, продажи персональных данных или профилирования для принятия решений, которые имеют в отношении вас юридические или аналогичные значимые последствия.

В любом случае мы не будем увеличивать стоимость или уменьшать доступность продукта или услуги исключительно на основании осуществления каких-либо ваших прав и не связанных с осуществимостью или ценностью услуги. Однако в той степени, в которой это разрешено законом, мы можем предложить вам другую цену, тариф, уровень, качество или выбор товаров или услуг, в том числе предлагать товары или услуги бесплатно, если наше предложение связано с вашим добровольным участием. в добросовестной программе лояльности, вознаграждениях, премиальных функциях, скидках или клубных картах.

Как реализовать свои права

Чтобы воспользоваться описанными выше правами, вам необходимо отправить нам запрос, связавшись с нами через контактную информацию, указанную в этом документе.

Чтобы мы могли ответить на ваш запрос, нам необходимо знать, кто вы и какое право вы хотите реализовать. Мы не будем отвечать на любой запрос, если мы не сможем подтвердить вашу личность, используя коммерчески разумные усилия, и, следовательно, подтвердить, что личные данные, которыми мы располагаем, действительно относятся к вам. В таких случаях мы можем запросить у вас дополнительную информацию, которая разумно необходима для аутентификации вас и вашего запроса.

Для подачи потребительского запроса вам не требуется создавать у нас учетную запись. Однако мы можем потребовать от вас использовать существующую учетную запись. Мы будем использовать любые персональные данные, полученные от вас в связи с вашим запросом, исключительно в целях аутентификации, без дальнейшего раскрытия персональных данных, хранения их дольше, чем необходимо для целей аутентификации, или использования их для несвязанных целей.

Если вы взрослый человек, вы можете подать запрос от имени ребенка, находящегося под вашей родительской опекой.

Как и когда мы должны обработать ваш запрос

Мы ответим на ваш запрос без неоправданной задержки, но во всех случаях и не позднее, чем в течение 45 дней с момента его получения. Если нам понадобится больше времени, мы объясним вам причины и сколько еще времени нам нужно. В связи с этим обратите внимание, что выполнение вашего запроса может занять до 90 дней.

Если мы отклоним ваш запрос, мы объясним вам причины нашего отказа без неоправданной задержки, но во всех случаях и не позднее, чем в течение 45 дней с момента получения запроса. Вы имеете право обжаловать такое решение, отправив нам запрос по реквизитам, указанным в этом документе. В течение 45 дней с момента получения апелляции мы сообщим вам в письменной форме о любых действиях, предпринятых или не предпринятых в ответ на апелляцию, включая письменное объяснение причин принятых решений. Если апелляция отклонена, вы можете обратиться к Генеральному прокурору и подать жалобу.

Мы не взимаем плату за ответ на ваш запрос, но не более одного запроса в год.

Дополнительная информация для потребителей штата Юта

Этот раздел документа интегрируется и дополняет информацию, содержащуюся в остальной части политики конфиденциальности, и предоставляется контролером, использующим это Приложение, и, если возможно, его материнской компанией, дочерними и аффилированными компаниями (для целей этого раздела). вместе именуемые «мы», «нас», «наш»).

Этот раздел применяется ко всем Пользователям (далее пользователи именуются просто «вы», «ваш», «ваш»), которые являются потребителями, проживающими в штате Юта, в соответствии с «Законом о конфиденциальности потребителей» («UCPA '), и для таких потребителей она заменяет любую другую, возможно, расходящуюся или противоречивую информацию, содержащуюся в политике конфиденциальности.

В этой части документа используется термин «персональные данные», как он определен в UCPA.

Категории обрабатываемых персональных данных

В этом разделе мы суммируем категории персональных данных, которые мы обрабатываем, и их цели. Подробно об этих действиях можно прочитать в разделе «Подробная информация об обработке Персональных данных» настоящего документа.

Категории персональных данных, которые мы собираем

  • Мы собрали следующие категории персональных данных: информация в Интернете.
  • Мы не собираем конфиденциальные данные.
  • Мы не будем собирать дополнительные категории персональных данных без вашего уведомления.

Почему мы обрабатываем ваши персональные данные

Чтобы узнать, почему мы обрабатываем ваши персональные данные, вы можете прочитать разделы «Подробная информация об обработке Персональных данных» и «Цели обработки» настоящего документа.

Как мы используем данные, которые собираем: передача ваших личных данных третьим лицам

Мы передаем ваши персональные данные третьим лицам, подробно перечисленным в разделе «Подробная информация об обработке персональных данных» настоящего документа. Эти третьи стороны сгруппированы и классифицированы в соответствии с различными целями обработки.

Для наших целей слово «третья сторона» означает «лицо, кроме: потребителя, контролера или процессора; или аффилированное лицо или подрядчик контролера или обработчика данных, как это определено UCPA.

Продажа ваших личных данных

Мы не продаем ваши персональные данные. В случае, если мы примем такое решение, мы сообщим вам заранее и предоставим вам право отказаться от такой продажи.

Для наших целей слова «продажа», «продать» или «проданный» означают «обмен персональных данных за денежное или иное ценное вознаграждение контролером третьей стороне», как это определено UCPA.

Обратите внимание, что согласно UCPA раскрытие персональных данных обработчику, который обрабатывает персональные данные от имени контролера, не является продажей. Кроме того, могут применяться другие конкретные исключения, изложенные в UCPA, такие как, помимо прочего, раскрытие персональных данных третьей стороне для предоставления запрошенного вами продукта или услуги.

Обработка ваших персональных данных для таргетированной рекламы

Мы не обрабатываем ваши персональные данные для таргетированной рекламы. Если мы примем такое решение, мы заранее сообщим вам об этом и предоставим вам право отказаться от обработки ваших личных данных для целевой рекламы.

Для наших целей слово «таргетированная реклама» означает «показ потребителю рекламы, которая выбрана на основе личных данных, полученных или выведенных с течением времени из действий потребителя на неаффилированных веб-сайтах, в приложениях или онлайн-сервисах для прогнозирования потребительских предпочтений или интересов». согласно определению UCPA.

Обратите внимание, что согласно UCPA, целевая реклама не включает: «рекламу, основанную на действиях на собственных веб-сайтах или онлайн-приложениях контролера, а также на любом дочернем веб-сайте или онлайн-приложении; рекламные объявления, основанные на контексте текущего поискового запроса потребителя, посещения веб-сайта или онлайн-приложения; рекламные объявления, направленные потребителю в ответ на запрос потребителя о информации, продукте, услуге или отзыве; или обработку персональных данных исключительно для измерения или составления отчета об эффективности, охвате или частоте рекламы».

Ваши права на конфиденциальность в соответствии с Законом штата Юта о конфиденциальности потребителей и способы их реализации

Вы можете осуществлять определенные права в отношении ваших данных, обрабатываемых нами. В частности, вы имеете право сделать следующее:

  • Доступ к личным данным: вы имеете право запросить у нас подтверждение того, обрабатываем ли мы ваши личные данные. Вы также имеете право на доступ к таким личным данным.
  • Запросить удаление ваших личных данных. Вы имеете право потребовать, чтобы мы удалили любые ваши личные данные.
  • Получите копию ваших личных данных: мы предоставим ваши личные данные в портативном и удобном формате, который позволит вам легко передавать данные другому лицу – при условии, что это технически осуществимо.
  • Отказаться от обработки ваших персональных данных: В целях целевой рекламы или продажи персональных данных.

В любом случае мы не будем увеличивать стоимость или уменьшать доступность продукта или услуги исключительно на основании осуществления каких-либо ваших прав и не связанных с осуществимостью или ценностью услуги. Однако в той степени, в которой это разрешено законом, мы можем предложить вам другую цену, тариф, уровень, качество или выбор товаров или услуг, в том числе предлагать товары или услуги бесплатно, если наше предложение связано с вашим добровольным участием. в добросовестной программе лояльности, вознаграждениях, премиальных функциях, скидках или клубных картах.

Как реализовать свои права

Чтобы воспользоваться описанными выше правами, вам необходимо отправить нам запрос, связавшись с нами через контактную информацию, указанную в этом документе.

Чтобы мы могли ответить на ваш запрос, нам необходимо знать, кто вы и какое право вы хотите реализовать. Мы не будем отвечать на любой запрос, если мы не сможем подтвердить вашу личность, используя коммерчески разумные усилия, и, следовательно, подтвердить, что личные данные, которыми мы располагаем, действительно относятся к вам. В таких случаях мы можем запросить у вас дополнительную информацию, которая разумно необходима для аутентификации вас и вашего запроса. Мы можем сохранить ваш адрес электронной почты, чтобы ответить на ваш запрос.

Если вы взрослый человек, вы можете подать запрос от имени ребенка, находящегося под вашей родительской опекой.

Как и когда мы должны обработать ваш запрос

Мы ответим на ваш запрос без неоправданной задержки, но во всех случаях и не позднее, чем в течение 45 дней с момента его получения. Если нам понадобится больше времени, мы объясним вам причины и сколько еще времени нам нужно. В связи с этим обратите внимание, что выполнение вашего запроса может занять до 90 дней.

Если мы отклоним ваш запрос, мы объясним вам причины нашего отказа без неоправданной задержки, но во всех случаях и не позднее, чем в течение 45 дней с момента получения запроса.

Мы не взимаем плату за ответ на ваш запрос, но не более одного запроса в год.

Дополнительная информация о сборе и обработке данных

Юридический иск

Персональные данные Пользователя могут использоваться Владельцем в юридических целях в суде или на стадиях, ведущих к возможным судебным искам, возникающим в результате ненадлежащего использования данного Приложения или связанных с ним Услуг. Пользователь заявляет, что осознает, что от Владельца может потребоваться раскрытие персональных данных по запросу государственных органов.

Дополнительная информация о персональных данных Пользователя

Помимо информации, содержащейся в настоящей политике конфиденциальности, данное Приложение может по запросу предоставлять Пользователю дополнительную и контекстную информацию, касающуюся конкретных Сервисов или сбора и обработки Персональных данных.

Системные журналы и обслуживание

В целях эксплуатации и обслуживания данное Приложение и любые сторонние сервисы могут собирать файлы, записывающие взаимодействие с этим Приложением (Системные журналы), использовать для этой цели другие Персональные данные (например, IP-адрес).

Информация, не содержащаяся в настоящей политике

Более подробную информацию о сборе или обработке Персональных данных можно запросить у Владельца в любое время. Пожалуйста, смотрите контактную информацию в начале этого документа.

Изменения в настоящей Политике конфиденциальности

Владелец оставляет за собой право вносить изменения в настоящую политику конфиденциальности в любое время, уведомив своих Пользователей на этой странице и, возможно, в этом Приложении и/или – насколько это технически и юридически возможно – отправив уведомление Пользователям через любую контактную информацию, доступную владелец. Настоятельно рекомендуется часто проверять эту страницу, обращая внимание на дату последнего изменения, указанную внизу.

Если изменения затрагивают действия по обработке, выполняемые на основании согласия Пользователя, Владелец должен получить от Пользователя новое согласие, если это необходимо.

Определения и юридические ссылки

  • Персональные данные (или Данные) — любая информация, которая прямо, косвенно или в связи с другой информацией, включая личный идентификационный номер, позволяет идентифицировать или идентифицировать физическое лицо.
  • Данные об использовании — информация, автоматически собираемая через это Приложение (или сторонние службы, используемые в этом Приложении), которая может включать в себя: IP-адреса или доменные имена компьютеров, используемых Пользователями, использующими это Приложение, адреса URI (унифицированный идентификатор ресурса). ), время запроса, метод отправки запроса на сервер, размер файла, полученного в ответ, числовой код, указывающий статус ответа сервера (успешный результат, ошибка и т. д.), страна происхождение, особенности браузера и операционной системы, используемые Пользователем, различные сведения о времени каждого посещения (например, время, проведенное на каждой странице в Приложении), а также сведения о пути, пройденном в Приложении, со специальной ссылкой на последовательность посещенных страниц и другие параметры операционной системы устройства и/или ИТ-среды Пользователя.
  • Пользователь – физическое лицо, использующее данное Приложение, которое, если не указано иное, совпадает с Субъектом данных.
  • Субъект данных – физическое лицо, к которому относятся Персональные данные.
  • Обработчик данных (или Обработчик) — физическое или юридическое лицо, государственный орган, агентство или другой орган, который обрабатывает Персональные данные от имени Контролера, как описано в настоящей политике конфиденциальности.
  • Контроллер данных (или владелец) — физическое или юридическое лицо, государственный орган, агентство или другой орган, который самостоятельно или совместно с другими определяет цели и средства обработки Персональных данных, включая меры безопасности, касающиеся эксплуатации и использования это приложение. Контроллер данных, если не указано иное, является Владельцем настоящего Приложения.
  • Настоящее Приложение – Средство, с помощью которого собираются и обрабатываются Персональные данные Пользователя.
  • Сервис — сервис, предоставляемый данным Приложением, как описано в соответствующих условиях (если таковые имеются) и на этом сайте/приложении.
  • Европейский Союз (или ЕС) . Если не указано иное, все ссылки на Европейский Союз в этом документе включают все нынешние государства-члены Европейского Союза и Европейской экономической зоны.
  • Файлы cookie — файлы cookie представляют собой трекеры, состоящие из небольших наборов данных, хранящихся в браузере Пользователя.
  • Трекер . Трекер обозначает любую технологию (например, файлы cookie, уникальные идентификаторы, веб-маяки, встроенные скрипты, электронные теги и снятие отпечатков пальцев), которая позволяет отслеживать Пользователей, например, путем доступа к информации или ее сохранения на устройстве Пользователя.

Легальная информация

Настоящее заявление о конфиденциальности было подготовлено на основе положений нескольких законодательных актов.

Настоящая политика конфиденциальности относится исключительно к данному Приложению, если иное не указано в настоящем документе.

Последнее обновление: 20 ноября 2024 г.

```