26 Commits
Author SHA1 Message Date
Antigravity Bot 2d0605b0ba AI Update: تایید 2026-08-31 12:48:41 +03:30
Antigravity Bot e240f8aa59 AI Update: نمیتونم لاگین شم میگه عدم دریافت لینک احراز هویت از سرور گ 2026-08-31 12:44:18 +03:30
Antigravity Bot a0094b4188 AI Update: تایید مینمایم 2026-08-31 12:31:58 +03:30
Antigravity Bot eaf8aef993 AI Update: حساب اشتراکی رو کامل حذف کن هیچکس حق داشتن حساب اشتراکی ندار 2026-08-31 12:29:26 +03:30
Antigravity Bot 8d52ab36aa AI Update: پیاده‌سازی ماژول حساب‌ها را شروع کن و توکن فعلی را برای من ث 2026-08-31 12:25:05 +03:30
Antigravity Bot 4dc48ed3ab Add multi-user AGY authentication, OAuth login and profile isolation 2026-08-31 12:24:57 +03:30
Antigravity Bot 67bc7fb079 AI Update: یه سوال روی این حالت هر یار با هر درخواست یک درخواست لاگین ب 2026-08-31 12:18:18 +03:30
Antigravity Bot 5df3cd130f AI Update: تایید میکنم اما دقت کن ممکنه با ری استارت خود من رو به عنوان 2026-08-31 12:17:01 +03:30
Antigravity Bot 5bcd63c427 AI Update: پیادش کن 2026-08-31 12:15:25 +03:30
Antigravity Bot 74bdd15bc5 AI Update: خب چطور میتونیم برای هر کاربر با حساب agy متفاوتی لاگین بشیم 2026-08-31 12:12:13 +03:30
Antigravity Bot d497210fab AI Update: میتونی برای هر کاربر از اکانت مخصوص خودش استفاده کنیم؟ 2026-08-31 11:30:34 +03:30
Antigravity Bot 0c20afa5ee AI Update: بازی پرنده 2026-08-31 11:27:22 +03:30
Antigravity Bot d736659b2a AI Update: 📎 چرا این پاسخ اینجوری اوخد؟ چطوری اصلاحش کنیم؟ 2026-08-31 10:52:40 +03:30
Antigravity Bot 56be74ccc9 AI Update: معماری امنیتی و ثبت در دیتابیس مورد تأیید است، لطفاً پیاده‌س 2026-08-31 10:46:10 +03:30
Antigravity Bot 0496369b05 AI Update: یک مورد جدید به هیچ وجه نمیخوام هوش مصنوعی به اطلاعات اتصال 2026-08-31 10:40:26 +03:30
Antigravity Bot 9c047850ff AI Update: ویژگی جدید: افزودن امکان ریموت دولوپ روی سرور های دیگر با اس 2026-08-31 09:52:58 +03:30
Antigravity Bot 390b938c05 AI Update: این خاطره رو به صورت عمومی ثبت کن من مدیر هستم و باید توانای 2026-08-31 01:58:48 +03:30
Antigravity Bot ee474774e1 AI Update: یک سری از فایل ها ممکن است که از روی گیت حذف شده باشه موقع د 2026-08-30 21:44:21 +03:30
Antigravity Bot 5bfa479236 AI Update: دیپلوی با اف تی پی باید به این صورت انجام شود نسخه ی موجود د 2026-08-30 21:37:58 +03:30
Antigravity Bot 5ab0a40b03 AI Update: تست اف تی پی نتیجه رو درست نمیگه 2026-08-30 21:23:34 +03:30
Antigravity Bot ab4764f521 AI Update: بله 2026-08-30 21:18:25 +03:30
Antigravity Bot fb5e5ff404 AI Update: میخوام تمام مقادیر رو خودم وارد کنم و بتونم ویرایشش کنم و هر 2026-08-30 21:16:49 +03:30
Antigravity Bot 314c23f4c9 AI Update: /ftp 2026-08-30 21:13:16 +03:30
Antigravity Bot 78fbf36c38 AI Update: میخوام از طریق ربات و دکمه های تعاملیش این کار رو بکنم 2026-08-30 21:11:14 +03:30
Antigravity Bot 8568e61cf0 AI Update: چطور اطلاعات اف تی پی پروژه هارو وارد کنم 2026-08-30 21:10:17 +03:30
Antigravity Bot 7a26378a09 AI Update: اقدامات پیشنهادی رو انجام بده 2026-08-30 19:04:49 +03:30
50 changed files with 4107 additions and 126 deletions
@@ -0,0 +1,84 @@
Metadata-Version: 2.4
Name: paramiko
Version: 5.0.0
Summary: SSH2 protocol library
Author-email: Jeff Forcier <jeff@bitprophet.org>
License-Expression: LGPL-2.1
Project-URL: Docs, https://docs.paramiko.org
Project-URL: Source, https://github.com/paramiko/paramiko
Project-URL: Changelog, https://www.paramiko.org/changelog.html
Project-URL: CI, https://app.circleci.com/pipelines/github/paramiko/paramiko
Project-URL: Issues, https://github.com/paramiko/paramiko/issues
Classifier: Development Status :: 5 - Production/Stable
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Topic :: Internet
Classifier: Topic :: Security :: Cryptography
Classifier: Programming Language :: Python
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Requires-Python: >=3.9
Description-Content-Type: text/x-rst
License-File: LICENSE
Requires-Dist: bcrypt>=3.2
Requires-Dist: cryptography>=3.3
Requires-Dist: invoke>=2.0
Requires-Dist: pynacl>=1.5
Dynamic: license-file
|version| |python| |license| |ci| |coverage|
.. |version| image:: https://img.shields.io/pypi/v/paramiko
:target: https://pypi.org/project/paramiko/
:alt: PyPI - Package Version
.. |python| image:: https://img.shields.io/pypi/pyversions/paramiko
:target: https://pypi.org/project/paramiko/
:alt: PyPI - Python Version
.. |license| image:: https://img.shields.io/pypi/l/paramiko
:target: https://github.com/paramiko/paramiko/blob/main/LICENSE
:alt: PyPI - License
.. |ci| image:: https://img.shields.io/circleci/build/github/paramiko/paramiko/main
:target: https://app.circleci.com/pipelines/github/paramiko/paramiko
:alt: CircleCI
.. |coverage| image:: https://img.shields.io/codecov/c/gh/paramiko/paramiko
:target: https://app.codecov.io/gh/paramiko/paramiko
:alt: Codecov
Welcome to Paramiko!
====================
Paramiko is a pure-Python [#]_ implementation of the SSHv2 protocol [#]_,
providing both client and server functionality. It provides the foundation for
the high-level SSH library `Fabric <https://fabfile.org>`_, which is what we
recommend you use for common client use-cases such as running remote shell
commands or transferring files.
Direct use of Paramiko itself is only intended for users who need
advanced/low-level primitives or want to run an in-Python sshd.
For installation information, changelogs, FAQs and similar, please visit `our
main project website <https://paramiko.org>`_; for API details, see `the
versioned docs <https://docs.paramiko.org>`_. Additionally, the project
maintainer keeps a `roadmap <http://bitprophet.org/projects#roadmap>`_ on his
personal site.
.. [#]
Paramiko relies on `cryptography <https://cryptography.io>`_ for crypto
functionality, which makes use of C and Rust extensions but has many
precompiled options available. See `our installation page
<https://www.paramiko.org/installing.html>`_ for details.
.. [#]
OpenSSH's RFC specification page is a fantastic resource and collection of
links that we won't bother replicating here:
https://www.openssh.com/specs.html
OpenSSH itself also happens to be our primary reference implementation:
when in doubt, we consult how they do things, unless there are good reasons
not to. There are always some gaps, but we do our best to reconcile them
when possible.
@@ -0,0 +1,275 @@
Metadata-Version: 2.4
Name: PyNaCl
Version: 1.6.2
Summary: Python binding to the Networking and Cryptography (NaCl) library
Home-page: https://github.com/pyca/pynacl/
Author-email: The PyNaCl developers <cryptography-dev@python.org>
License: Apache-2.0
Project-URL: Homepage, https://github.com/pyca/pynacl
Project-URL: Bug Tracker, https://github.com/pyca/pynacl/issues
Project-URL: Documentation, https://pynacl.readthedocs.io
Classifier: License :: OSI Approved :: Apache Software License
Classifier: Programming Language :: Python :: Implementation :: CPython
Classifier: Programming Language :: Python :: Implementation :: PyPy
Classifier: Programming Language :: Python :: Free Threading :: 3 - Stable
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.8
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Requires-Python: >=3.8
License-File: LICENSE
License-File: licenses/LICENSE.libsodium.txt
Requires-Dist: cffi>=1.4.1; platform_python_implementation != "PyPy" and python_version < "3.9"
Requires-Dist: cffi>=2.0.0; platform_python_implementation != "PyPy" and python_version >= "3.9"
Provides-Extra: tests
Requires-Dist: pytest>=7.4.0; extra == "tests"
Requires-Dist: pytest-cov>=2.10.1; extra == "tests"
Requires-Dist: pytest-xdist>=3.5.0; extra == "tests"
Requires-Dist: hypothesis>=3.27.0; extra == "tests"
Provides-Extra: docs
Requires-Dist: sphinx<7; extra == "docs"
Requires-Dist: sphinx_rtd_theme; extra == "docs"
Dynamic: description
Dynamic: home-page
Dynamic: license-file
===============================================
PyNaCl: Python binding to the libsodium library
===============================================
.. image:: https://img.shields.io/pypi/v/pynacl.svg
:target: https://pypi.org/project/PyNaCl/
:alt: Latest Version
PyNaCl is a Python binding to `libsodium`_, which is a fork of the
`Networking and Cryptography library`_. These libraries have a stated goal of
improving usability, security and speed. It supports Python 3.8+ as well as
PyPy 3.
.. _libsodium: https://github.com/jedisct1/libsodium
.. _Networking and Cryptography library: https://nacl.cr.yp.to/
Features
--------
* Digital signatures
* Secret-key encryption
* Public-key encryption
* Hashing and message authentication
* Password based key derivation and password hashing
`Changelog`_
------------
.. _Changelog: https://pynacl.readthedocs.io/en/latest/changelog/
Installation
============
Binary wheel install
--------------------
PyNaCl ships as a binary wheel on macOS, Windows and Linux ``manylinux1`` [#many]_ ,
so all dependencies are included. Make sure you have an up-to-date pip
and run:
.. code-block:: console
$ pip install pynacl
Faster wheel build
------------------
You can define the environment variable ``LIBSODIUM_MAKE_ARGS`` to pass arguments to ``make``
and enable `parallelization`_:
.. code-block:: console
$ LIBSODIUM_MAKE_ARGS=-j4 pip install pynacl
Linux source build
------------------
PyNaCl relies on `libsodium`_, a portable C library. A copy is bundled
with PyNaCl so to install you can run:
.. code-block:: console
$ pip install pynacl
If you'd prefer to use the version of ``libsodium`` provided by your
distribution, you can disable the bundled copy during install by running:
.. code-block:: console
$ SODIUM_INSTALL=system pip install pynacl
.. warning:: Usage of the legacy ``easy_install`` command provided by setuptools
is generally discouraged, and is completely unsupported in PyNaCl's case.
.. _parallelization: https://www.gnu.org/software/make/manual/html_node/Parallel.html
.. _libsodium: https://github.com/jedisct1/libsodium
.. [#many] `manylinux1 wheels <https://www.python.org/dev/peps/pep-0513/>`_
are built on a baseline linux environment based on Centos 5.11
and should work on most x86 and x86_64 glibc based linux environments.
GNU Make (gmake) may be required for newer versions of PyNaCl. You can
set the binary to use by setting the ``MAKE`` environment variable:
.. code-block:: console
$ MAKE=gmake pip install pynacl
Changelog
=========
1.6.2 (2026-01-01)
------------------
* Updated ``libsodium`` to 1.0.20-stable (2025-12-31 build) to resolve
``CVE-2025-69277``.
1.6.1 (2025-11-10)
------------------
* The ``MAKE`` environment variable can now be used to specify the ``make``
binary that should be used in the build process.
1.6.0 (2025-09-11)
------------------
* **BACKWARDS INCOMPATIBLE:** Removed support for Python 3.6 and 3.7.
* Added support for the low level AEAD AES bindings.
* Added support for ``crypto_core_ed25519_from_uniform``.
* Update ``libsodium`` to 1.0.20-stable (2025-08-27 build).
* Added support for free-threaded Python 3.14.
* Added support for Windows on ARM wheels.
1.5.0 (2022-01-07)
------------------
* **BACKWARDS INCOMPATIBLE:** Removed support for Python 2.7 and Python 3.5.
* **BACKWARDS INCOMPATIBLE:** We no longer distribute ``manylinux1``
wheels.
* Added ``manylinux2014``, ``manylinux_2_24``, ``musllinux``, and macOS
``universal2`` wheels (the latter supports macOS ``arm64``).
* Update ``libsodium`` to 1.0.18-stable (July 25, 2021 release).
* Add inline type hints.
1.4.0 (2020-05-25)
------------------
* Update ``libsodium`` to 1.0.18.
* **BACKWARDS INCOMPATIBLE:** We no longer distribute 32-bit ``manylinux1``
wheels. Continuing to produce them was a maintenance burden.
* Added support for Python 3.8, and removed support for Python 3.4.
* Add low level bindings for extracting the seed and the public key
from crypto_sign_ed25519 secret key
* Add low level bindings for deterministic random generation.
* Add ``wheel`` and ``setuptools`` setup_requirements in ``setup.py`` (#485)
* Fix checks on very slow builders (#481, #495)
* Add low-level bindings to ed25519 arithmetic functions
* Update low-level blake2b state implementation
* Fix wrong short-input behavior of SealedBox.decrypt() (#517)
* Raise CryptPrefixError exception instead of InvalidkeyError when trying
to check a password against a verifier stored in a unknown format (#519)
* Add support for minimal builds of libsodium. Trying to call functions
not available in a minimal build will raise an UnavailableError
exception. To compile a minimal build of the bundled libsodium, set
the SODIUM_INSTALL_MINIMAL environment variable to any non-empty
string (e.g. ``SODIUM_INSTALL_MINIMAL=1``) for setup.
1.3.0 2018-09-26
----------------
* Added support for Python 3.7.
* Update ``libsodium`` to 1.0.16.
* Run and test all code examples in PyNaCl docs through sphinx's
doctest builder.
* Add low-level bindings for chacha20-poly1305 AEAD constructions.
* Add low-level bindings for the chacha20-poly1305 secretstream constructions.
* Add low-level bindings for ed25519ph pre-hashed signing construction.
* Add low-level bindings for constant-time increment and addition
on fixed-precision big integers represented as little-endian
byte sequences.
* Add low-level bindings for the ISO/IEC 7816-4 compatible padding API.
* Add low-level bindings for libsodium's crypto_kx... key exchange
construction.
* Set hypothesis deadline to None in tests/test_pwhash.py to avoid
incorrect test failures on slower processor architectures. GitHub
issue #370
1.2.1 - 2017-12-04
------------------
* Update hypothesis minimum allowed version.
* Infrastructure: add proper configuration for readthedocs builder
runtime environment.
1.2.0 - 2017-11-01
------------------
* Update ``libsodium`` to 1.0.15.
* Infrastructure: add jenkins support for automatic build of
``manylinux1`` binary wheels
* Added support for ``SealedBox`` construction.
* Added support for ``argon2i`` and ``argon2id`` password hashing constructs
and restructured high-level password hashing implementation to expose
the same interface for all hashers.
* Added support for 128 bit ``siphashx24`` variant of ``siphash24``.
* Added support for ``from_seed`` APIs for X25519 key pair generation.
* Dropped support for Python 3.3.
1.1.2 - 2017-03-31
------------------
* reorder link time library search path when using bundled
libsodium
1.1.1 - 2017-03-15
------------------
* Fixed a circular import bug in ``nacl.utils``.
1.1.0 - 2017-03-14
------------------
* Dropped support for Python 2.6.
* Added ``shared_key()`` method on ``Box``.
* You can now pass ``None`` to ``nonce`` when encrypting with ``Box`` or
``SecretBox`` and it will automatically generate a random nonce.
* Added support for ``siphash24``.
* Added support for ``blake2b``.
* Added support for ``scrypt``.
* Update ``libsodium`` to 1.0.11.
* Default to the bundled ``libsodium`` when compiling.
* All raised exceptions are defined mixing-in
``nacl.exceptions.CryptoError``
1.0.1 - 2016-01-24
------------------
* Fix an issue with absolute paths that prevented the creation of wheels.
1.0 - 2016-01-23
----------------
* PyNaCl has been ported to use the new APIs available in cffi 1.0+.
Due to this change we no longer support PyPy releases older than 2.6.
* Python 3.2 support has been dropped.
* Functions to convert between Ed25519 and Curve25519 keys have been added.
0.3.0 - 2015-03-04
------------------
* The low-level API (`nacl.c.*`) has been changed to match the
upstream NaCl C/C++ conventions (as well as those of other NaCl bindings).
The order of arguments and return values has changed significantly. To
avoid silent failures, `nacl.c` has been removed, and replaced with
`nacl.bindings` (with the new argument ordering). If you have code which
calls these functions (e.g. `nacl.c.crypto_box_keypair()`), you must review
the new docstrings and update your code/imports to match the new
conventions.
@@ -0,0 +1,74 @@
Metadata-Version: 2.4
Name: invoke
Version: 3.0.3
Summary: Pythonic task execution
Author-email: Jeff Forcier <jeff@bitprophet.org>
License-Expression: BSD-2-Clause
Project-URL: Docs, https://docs.pyinvoke.org
Project-URL: Source, https://github.com/pyinvoke/invoke
Project-URL: Changelog, https://www.pyinvoke.org/changelog.html
Project-URL: CI, https://app.circleci.com/pipelines/github/pyinvoke/invoke
Project-URL: Issues, https://github.com/pyinvoke/invoke/issues
Classifier: Development Status :: 5 - Production/Stable
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: System Administrators
Classifier: Operating System :: POSIX
Classifier: Operating System :: Unix
Classifier: Operating System :: MacOS :: MacOS X
Classifier: Operating System :: Microsoft :: Windows
Classifier: Programming Language :: Python
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Software Development
Classifier: Topic :: Software Development :: Build Tools
Classifier: Topic :: Software Development :: Libraries
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Topic :: System :: Software Distribution
Classifier: Topic :: System :: Systems Administration
Requires-Python: >=3.9
Description-Content-Type: text/x-rst
License-File: LICENSE
Dynamic: license-file
|version| |python| |license| |ci| |coverage|
.. |version| image:: https://img.shields.io/pypi/v/invoke
:target: https://pypi.org/project/invoke/
:alt: PyPI - Package Version
.. |python| image:: https://img.shields.io/pypi/pyversions/invoke
:target: https://pypi.org/project/invoke/
:alt: PyPI - Python Version
.. |license| image:: https://img.shields.io/pypi/l/invoke
:target: https://github.com/pyinvoke/invoke/blob/main/LICENSE
:alt: PyPI - License
.. |ci| image:: https://img.shields.io/circleci/build/github/pyinvoke/invoke/main
:target: https://app.circleci.com/pipelines/github/pyinvoke/invoke
:alt: CircleCI
.. |coverage| image:: https://img.shields.io/codecov/c/gh/pyinvoke/invoke
:target: https://app.codecov.io/gh/pyinvoke/invoke
:alt: Codecov
Welcome to Invoke!
==================
Invoke is a Python library for managing shell-oriented subprocesses and
organizing executable Python code into CLI-invokable tasks. It draws
inspiration from various sources (``make``/``rake``, Fabric 1.x, etc) to arrive
at a powerful & clean feature set.
To find out what's new in this version of Invoke, please see `the changelog
<https://pyinvoke.org/changelog.html#{}>`_.
For a high level introduction, including example code, please see `our main
project website <https://pyinvoke.org>`_; or for detailed API docs, see `the
versioned API website <https://docs.pyinvoke.org>`_.
The project maintainer keeps a `roadmap
<https://bitprophet.org/projects#roadmap>`_ on his website.
@@ -0,0 +1,67 @@
Metadata-Version: 2.4
Name: cffi
Version: 2.1.1
Summary: Foreign Function Interface for Python calling C code.
Author: Armin Rigo, Maciej Fijalkowski
Maintainer: Matt Davis, Matt Clay
License-Expression: MIT-0
Project-URL: Documentation, https://cffi.readthedocs.io/
Project-URL: Changelog, https://cffi.readthedocs.io/en/latest/whatsnew.html
Project-URL: Download, https://github.com/python-cffi/cffi/releases
Project-URL: Contact, https://groups.google.com/forum/#!forum/python-cffi
Project-URL: Source Code, https://github.com/python-cffi/cffi
Project-URL: Issue Tracker, https://github.com/python-cffi/cffi/issues
Classifier: Programming Language :: Python
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Programming Language :: Python :: 3.15
Classifier: Programming Language :: Python :: Free Threading :: 2 - Beta
Classifier: Programming Language :: Python :: Implementation :: CPython
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: pycparser; implementation_name != "PyPy"
Dynamic: license-file
[![GitHub Actions Status](https://github.com/python-cffi/cffi/actions/workflows/ci.yaml/badge.svg?branch=main)](https://github.com/python-cffi/cffi/actions/workflows/ci.yaml?query=branch%3Amain++)
[![PyPI version](https://img.shields.io/pypi/v/cffi.svg)](https://pypi.org/project/cffi)
[![Read the Docs](https://img.shields.io/badge/docs-latest-blue.svg)][Documentation]
CFFI
====
Foreign Function Interface for Python calling C code.
Please see the [Documentation] or uncompiled in the `doc/` subdirectory.
Download
--------
[Download page](https://github.com/python-cffi/cffi/releases)
Source Code
-----------
Source code is publicly available on
[GitHub](https://github.com/python-cffi/cffi).
Contact
-------
[Mailing list](https://groups.google.com/forum/#!forum/python-cffi)
Testing/development tips
------------------------
After `git clone` or `wget && tar`, we will get a directory called `cffi` or `cffi-x.x.x`. We call it `repo-directory`. To run tests under CPython, run the following in the `repo-directory`:
pip install pytest
pip install -e . # editable install of CFFI for local development
pytest src/c/ testing/
[Documentation]: http://cffi.readthedocs.org/
@@ -0,0 +1,343 @@
Metadata-Version: 2.4
Name: bcrypt
Version: 5.0.0
Summary: Modern password hashing for your software and your servers
Author-email: The Python Cryptographic Authority developers <cryptography-dev@python.org>
License: Apache-2.0
Project-URL: homepage, https://github.com/pyca/bcrypt/
Classifier: Development Status :: 5 - Production/Stable
Classifier: License :: OSI Approved :: Apache Software License
Classifier: Programming Language :: Python :: Implementation :: CPython
Classifier: Programming Language :: Python :: Implementation :: PyPy
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.8
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Programming Language :: Python :: Free Threading :: 3 - Stable
Requires-Python: >=3.8
Description-Content-Type: text/x-rst
License-File: LICENSE
Provides-Extra: tests
Requires-Dist: pytest!=3.3.0,>=3.2.1; extra == "tests"
Provides-Extra: typecheck
Requires-Dist: mypy; extra == "typecheck"
Dynamic: license-file
bcrypt
======
.. image:: https://img.shields.io/pypi/v/bcrypt.svg
:target: https://pypi.org/project/bcrypt/
:alt: Latest Version
.. image:: https://github.com/pyca/bcrypt/workflows/CI/badge.svg?branch=main
:target: https://github.com/pyca/bcrypt/actions?query=workflow%3ACI+branch%3Amain
Acceptable password hashing for your software and your servers (but you should
really use argon2id or scrypt)
Installation
============
To install bcrypt, simply:
.. code:: console
$ pip install bcrypt
Note that bcrypt should build very easily on Linux provided you have a C
compiler and a Rust compiler (the minimum supported Rust version is 1.56.0).
For Debian and Ubuntu, the following command will ensure that the required dependencies are installed:
.. code:: console
$ sudo apt-get install build-essential cargo
For Fedora and RHEL-derivatives, the following command will ensure that the required dependencies are installed:
.. code:: console
$ sudo yum install gcc cargo
For Alpine, the following command will ensure that the required dependencies are installed:
.. code:: console
$ apk add --update musl-dev gcc cargo
Alternatives
============
While bcrypt remains an acceptable choice for password storage, depending on your specific use case you may also want to consider using scrypt (either via `standard library`_ or `cryptography`_) or argon2id via `argon2_cffi`_.
Changelog
=========
5.0.0
-----
* Bumped MSRV to 1.74.
* Added support for Python 3.14 and free-threaded Python 3.14.
* Added support for Windows on ARM.
* Passing ``hashpw`` a password longer than 72 bytes now raises a
``ValueError``. Previously the password was silently truncated, following the
behavior of the original OpenBSD ``bcrypt`` implementation.
4.3.0
-----
* Dropped support for Python 3.7.
* We now support free-threaded Python 3.13.
* We now support PyPy 3.11.
* We now publish wheels for free-threaded Python 3.13, for PyPy 3.11 on
``manylinux``, and for ARMv7l on ``manylinux``.
4.2.1
-----
* Bump Rust dependency versions - this should resolve crashes on Python 3.13
free-threaded builds.
* We no longer build ``manylinux`` wheels for PyPy 3.9.
4.2.0
-----
* Bump Rust dependency versions
* Removed the ``BCRYPT_ALLOW_RUST_163`` environment variable.
4.1.3
-----
* Bump Rust dependency versions
4.1.2
-----
* Publish both ``py37`` and ``py39`` wheels. This should resolve some errors
relating to initializing a module multiple times per process.
4.1.1
-----
* Fixed the type signature on the ``kdf`` method.
* Fixed packaging bug on Windows.
* Fixed incompatibility with passlib package detection assumptions.
4.1.0
-----
* Dropped support for Python 3.6.
* Bumped MSRV to 1.64. (Note: Rust 1.63 can be used by setting the ``BCRYPT_ALLOW_RUST_163`` environment variable)
4.0.1
-----
* We now build PyPy ``manylinux`` wheels.
* Fixed a bug where passing an invalid ``salt`` to ``checkpw`` could result in
a ``pyo3_runtime.PanicException``. It now correctly raises a ``ValueError``.
4.0.0
-----
* ``bcrypt`` is now implemented in Rust. Users building from source will need
to have a Rust compiler available. Nothing will change for users downloading
wheels.
* We no longer ship ``manylinux2010`` wheels. Users should upgrade to the latest
``pip`` to ensure this doesnt cause issues downloading wheels on their
platform. We now ship ``manylinux_2_28`` wheels for users on new enough platforms.
* ``NUL`` bytes are now allowed in inputs.
3.2.2
-----
* Fixed packaging of ``py.typed`` files in wheels so that ``mypy`` works.
3.2.1
-----
* Added support for compilation on z/OS
* The next release of ``bcrypt`` with be 4.0 and it will require Rust at
compile time, for users building from source. There will be no additional
requirement for users who are installing from wheels. Users on most
platforms will be able to obtain a wheel by making sure they have an up to
date ``pip``. The minimum supported Rust version will be 1.56.0.
* This will be the final release for which we ship ``manylinux2010`` wheels.
Going forward the minimum supported manylinux ABI for our wheels will be
``manylinux2014``. The vast majority of users will continue to receive
``manylinux`` wheels provided they have an up to date ``pip``.
3.2.0
-----
* Added typehints for library functions.
* Dropped support for Python versions less than 3.6 (2.7, 3.4, 3.5).
* Shipped ``abi3`` Windows wheels (requires pip >= 20).
3.1.7
-----
* Set a ``setuptools`` lower bound for PEP517 wheel building.
* We no longer distribute 32-bit ``manylinux1`` wheels. Continuing to produce
them was a maintenance burden.
3.1.6
-----
* Added support for compilation on Haiku.
3.1.5
-----
* Added support for compilation on AIX.
* Dropped Python 2.6 and 3.3 support.
* Switched to using ``abi3`` wheels for Python 3. If you are not getting a
wheel on a compatible platform please upgrade your ``pip`` version.
3.1.4
-----
* Fixed compilation with mingw and on illumos.
3.1.3
-----
* Fixed a compilation issue on Solaris.
* Added a warning when using too few rounds with ``kdf``.
3.1.2
-----
* Fixed a compile issue affecting big endian platforms.
* Fixed invalid escape sequence warnings on Python 3.6.
* Fixed building in non-UTF8 environments on Python 2.
3.1.1
-----
* Resolved a ``UserWarning`` when used with ``cffi`` 1.8.3.
3.1.0
-----
* Added support for ``checkpw``, a convenience method for verifying a password.
* Ensure that you get a ``$2y$`` hash when you input a ``$2y$`` salt.
* Fixed a regression where ``$2a`` hashes were vulnerable to a wraparound bug.
* Fixed compilation under Alpine Linux.
3.0.0
-----
* Switched the C backend to code obtained from the OpenBSD project rather than
openwall.
* Added support for ``bcrypt_pbkdf`` via the ``kdf`` function.
2.0.0
-----
* Added support for an adjustible prefix when calling ``gensalt``.
* Switched to CFFI 1.0+
Usage
-----
Password Hashing
~~~~~~~~~~~~~~~~
Hashing and then later checking that a password matches the previous hashed
password is very simple:
.. code:: pycon
>>> import bcrypt
>>> password = b"super secret password"
>>> # Hash a password for the first time, with a randomly-generated salt
>>> hashed = bcrypt.hashpw(password, bcrypt.gensalt())
>>> # Check that an unhashed password matches one that has previously been
>>> # hashed
>>> if bcrypt.checkpw(password, hashed):
... print("It Matches!")
... else:
... print("It Does not Match :(")
KDF
~~~
As of 3.0.0 ``bcrypt`` now offers a ``kdf`` function which does ``bcrypt_pbkdf``.
This KDF is used in OpenSSH's newer encrypted private key format.
.. code:: pycon
>>> import bcrypt
>>> key = bcrypt.kdf(
... password=b'password',
... salt=b'salt',
... desired_key_bytes=32,
... rounds=100)
Adjustable Work Factor
~~~~~~~~~~~~~~~~~~~~~~
One of bcrypt's features is an adjustable logarithmic work factor. To adjust
the work factor merely pass the desired number of rounds to
``bcrypt.gensalt(rounds=12)`` which defaults to 12):
.. code:: pycon
>>> import bcrypt
>>> password = b"super secret password"
>>> # Hash a password for the first time, with a certain number of rounds
>>> hashed = bcrypt.hashpw(password, bcrypt.gensalt(14))
>>> # Check that a unhashed password matches one that has previously been
>>> # hashed
>>> if bcrypt.checkpw(password, hashed):
... print("It Matches!")
... else:
... print("It Does not Match :(")
Adjustable Prefix
~~~~~~~~~~~~~~~~~
Another one of bcrypt's features is an adjustable prefix to let you define what
libraries you'll remain compatible with. To adjust this, pass either ``2a`` or
``2b`` (the default) to ``bcrypt.gensalt(prefix=b"2b")`` as a bytes object.
As of 3.0.0 the ``$2y$`` prefix is still supported in ``hashpw`` but deprecated.
Maximum Password Length
~~~~~~~~~~~~~~~~~~~~~~~
The bcrypt algorithm only handles passwords up to 72 characters, any characters
beyond that are ignored. To work around this, a common approach is to hash a
password with a cryptographic hash (such as ``sha256``) and then base64
encode it to prevent NULL byte problems before hashing the result with
``bcrypt``:
.. code:: pycon
>>> password = b"an incredibly long password" * 10
>>> hashed = bcrypt.hashpw(
... base64.b64encode(hashlib.sha256(password).digest()),
... bcrypt.gensalt()
... )
Compatibility
-------------
This library should be compatible with py-bcrypt and it will run on Python
3.8+ (including free-threaded builds), and PyPy 3.
Security
--------
``bcrypt`` follows the `same security policy as cryptography`_, if you
identify a vulnerability, we ask you to contact us privately.
.. _`same security policy as cryptography`: https://cryptography.io/en/latest/security.html
.. _`standard library`: https://docs.python.org/3/library/hashlib.html#hashlib.scrypt
.. _`argon2_cffi`: https://argon2-cffi.readthedocs.io
.. _`cryptography`: https://cryptography.io/en/latest/hazmat/primitives/key-derivation-functions/#cryptography.hazmat.primitives.kdf.scrypt.Scrypt
Submodule projects/232801087/myapp updated: ded79a9a9e...a6bf56fafb
Submodule projects/78649634/bildirchin added at e109f5d777
Submodule projects/78649634/copykar updated: f8468cdd1a...6d194929e4
Submodule projects/78649634/gasemi_tier updated: d27520ec0f...aca18b7229
Submodule projects/78649634/jump-jump added at 0c7b0fb510
+44 -1
View File
@@ -13,6 +13,7 @@ from typing import Optional, Dict, Any, Callable, List, Tuple
from dataclasses import dataclass, field, asdict from dataclasses import dataclass, field, asdict
from config import settings from config import settings
from auth_manager import auth_manager
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -130,6 +131,12 @@ class Project:
ftp_password: Optional[str] = None ftp_password: Optional[str] = None
ftp_path: str = "/" ftp_path: str = "/"
ftp_tls: bool = False ftp_tls: bool = False
ssh_host: Optional[str] = None
ssh_port: int = 22
ssh_user: Optional[str] = None
ssh_password: Optional[str] = None
ssh_key: Optional[str] = None
ssh_path: str = "/"
@dataclass @dataclass
class Session: class Session:
@@ -272,6 +279,12 @@ class SessionManager:
"ftp_password", "ftp_password",
"ftp_path", "ftp_path",
"ftp_tls", "ftp_tls",
"ssh_host",
"ssh_port",
"ssh_user",
"ssh_password",
"ssh_key",
"ssh_path",
) )
} }
if "name" not in clean_p: if "name" not in clean_p:
@@ -360,6 +373,19 @@ class SessionManager:
"created_at": p_obj.created_at, "created_at": p_obj.created_at,
"description": p_obj.description, "description": p_obj.description,
"conversation_titles": p_obj.conversation_titles, "conversation_titles": p_obj.conversation_titles,
"active_branch": p_obj.active_branch,
"ftp_host": p_obj.ftp_host,
"ftp_port": p_obj.ftp_port,
"ftp_user": p_obj.ftp_user,
"ftp_password": p_obj.ftp_password,
"ftp_path": p_obj.ftp_path,
"ftp_tls": p_obj.ftp_tls,
"ssh_host": p_obj.ssh_host,
"ssh_port": p_obj.ssh_port,
"ssh_user": p_obj.ssh_user,
"ssh_password": p_obj.ssh_password,
"ssh_key": p_obj.ssh_key,
"ssh_path": p_obj.ssh_path,
} }
curr = self.get_current_project(sess.chat_id) curr = self.get_current_project(sess.chat_id)
data[str(k)] = { data[str(k)] = {
@@ -1600,6 +1626,22 @@ class AGYEngine:
except Exception as pe: except Exception as pe:
logger.debug(f"Pre-prompt git pull skipped: {pe}") logger.debug(f"Pre-prompt git pull skipped: {pe}")
if not auth_manager.has_custom_account(session.chat_id):
is_fa = (session.language or "").lower() in ("fa", "farsi", "persian", "🇮🇷 persian / farsi (فارسی)")
msg = (
"⚠️ <b>حساب کاربری هوش مصنوعی شما متصل نیست!</b>\n\n"
"در این ربات حساب‌های اشتراکی غیرفعال هستند و هر کاربر ملزم به استفاده از حساب شخصی خود می‌باشد.\n"
"👉 لطفاً با دستور <code>/login</code> وارد حساب اختصاصی گوگل خود شوید یا فایل توکن را ارسال کنید."
if is_fa else
"⚠️ <b>AGY Account Not Connected!</b>\n\nShared accounts are disabled. Please log in with your own account via /login."
)
return AGYResult(
text=msg,
success=False,
error="Account not connected"
)
user_env = auth_manager.get_user_env(session.chat_id)
start_time = time.time() start_time = time.time()
proc = await asyncio.create_subprocess_exec( proc = await asyncio.create_subprocess_exec(
*cmd, *cmd,
@@ -1607,6 +1649,7 @@ class AGYEngine:
stderr=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
limit=100 * 1024 * 1024, # 100 MB buffer limit to handle large stream-json chunks limit=100 * 1024 * 1024, # 100 MB buffer limit to handle large stream-json chunks
cwd=workspace_path, cwd=workspace_path,
env=user_env,
start_new_session=True, start_new_session=True,
) )
@@ -1776,7 +1819,7 @@ class AGYEngine:
text_out = rec["text"] text_out = rec["text"]
elif rec.get("tools"): elif rec.get("tools"):
is_fa = (session.language or "").lower() in ("fa", "farsi", "persian", "🇮🇷 persian / farsi (فارسی)") is_fa = (session.language or "").lower() in ("fa", "farsi", "persian", "🇮🇷 persian / farsi (فارسی)")
tool_bullets = "\n".join(f"<code>{t}</code>" for t in rec["tools"][-10:]) tool_bullets = "\n".join(f"`{t}`" for t in rec["tools"][-10:])
if is_fa: if is_fa:
text_out = f"✅ **دستورات و ابزارهای زیر با موفقیت اجرا شدند:**\n\n{tool_bullets}" text_out = f"✅ **دستورات و ابزارهای زیر با موفقیت اجرا شدند:**\n\n{tool_bullets}"
else: else:
+339
View File
@@ -0,0 +1,339 @@
import os
import re
import json
import time
import shutil
import asyncio
import logging
from pathlib import Path
from typing import Optional, Dict, Any, Tuple
from datetime import datetime
logger = logging.getLogger("AGYAuthManager")
PROFILES_BASE_DIR = Path("/root/.gemini_profiles")
MASTER_TOKEN_PATH = Path("/root/.gemini/antigravity-cli/antigravity-oauth-token")
MASTER_SETTINGS_PATH = Path("/root/.gemini/antigravity-cli/settings.json")
def escape_pre(text: str) -> str:
"""Escapes HTML entities for pre block."""
if not text:
return ""
return text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
class AuthManager:
"""
Manages multi-account authentication, isolated user profiles,
and OAuth login flows for Antigravity (AGY) CLI.
"""
def __init__(self, base_dir: Path = PROFILES_BASE_DIR):
self.base_dir = base_dir
self.base_dir.mkdir(parents=True, exist_ok=True)
# Stores active login subprocesses: {user_id: {"proc": Process, "url": str, "started_at": float}}
self.active_logins: Dict[int, Dict[str, Any]] = {}
def get_profile_dir(self, user_id: int) -> Path:
"""Returns the isolated home directory for a given user."""
return self.base_dir / str(user_id)
def get_config_dir(self, user_id: int) -> Path:
"""Returns the .gemini/antigravity-cli directory inside user profile."""
return self.get_profile_dir(user_id) / ".gemini" / "antigravity-cli"
def get_token_path(self, user_id: int) -> Path:
"""Returns the path to the user's antigravity-oauth-token."""
return self.get_config_dir(user_id) / "antigravity-oauth-token"
def has_custom_account(self, user_id: int) -> bool:
"""Checks if a user has a valid personal AGY account token configured."""
token_file = self.get_token_path(user_id)
if token_file.exists() and token_file.is_file() and token_file.stat().st_size > 10:
return True
return False
def get_user_home(self, user_id: int) -> str:
"""Returns the isolated HOME directory path to use for this user."""
return str(self.get_profile_dir(user_id))
def get_user_env(self, user_id: int) -> Dict[str, str]:
"""Returns environment variables dictionary with the proper HOME set for the user."""
env = os.environ.copy()
env["HOME"] = self.get_user_home(user_id)
return env
def get_account_status(self, user_id: int) -> Dict[str, Any]:
"""Returns detailed status of the user's active account."""
has_custom = self.has_custom_account(user_id)
token_path = self.get_token_path(user_id)
modified_time_str = "تنظیم نشده"
if has_custom and token_path.exists():
try:
mtime = token_path.stat().st_mtime
modified_time_str = datetime.fromtimestamp(mtime).strftime("%Y-%m-%d %H:%M:%S")
except Exception:
pass
return {
"user_id": user_id,
"has_custom_account": has_custom,
"is_default_server": False,
"account_type": "حساب اختصاصی (Custom Account)" if has_custom else "حساب متصل نیست (Disconnected)",
"token_path": str(token_path) if has_custom else "تنظیم نشده",
"last_modified": modified_time_str,
"is_login_in_progress": user_id in self.active_logins,
}
async def start_oauth_login(self, user_id: int, timeout: int = 25) -> Tuple[bool, str, Optional[str]]:
"""
Starts headless OAuth login flow for a user via a pseudo-terminal (PTY)
so that AGY CLI detects a controlling terminal and outputs the Google Auth URL.
"""
import pty
import select
# Cancel any previous login session for this user
self.cancel_oauth_login(user_id)
config_dir = self.get_config_dir(user_id)
config_dir.mkdir(parents=True, exist_ok=True)
token_file = self.get_token_path(user_id)
backup_token = None
if token_file.exists():
backup_token = token_file.read_text(encoding="utf-8", errors="ignore")
try:
token_file.unlink()
except Exception:
pass
env = os.environ.copy()
env["HOME"] = str(self.get_profile_dir(user_id))
try:
master_fd, slave_fd = pty.openpty()
proc = await asyncio.create_subprocess_exec(
"agy", "--print", "/usage",
stdin=slave_fd,
stdout=slave_fd,
stderr=slave_fd,
env=env,
cwd="/root",
close_fds=True,
start_new_session=True,
)
os.close(slave_fd)
auth_url: Optional[str] = None
start_time = time.time()
url_pattern = re.compile(r"https://accounts\.google\.com/o/oauth2/auth\S+")
buffer = ""
loop = asyncio.get_running_loop()
def read_pty_chunk(fd: int) -> str:
try:
r, _, _ = select.select([fd], [], [], 0.1)
if fd in r:
return os.read(fd, 2048).decode("utf-8", errors="replace")
except Exception:
pass
return ""
while time.time() - start_time < timeout:
chunk = await loop.run_in_executor(None, read_pty_chunk, master_fd)
if chunk:
buffer += chunk
logger.debug(f"OAuth PTY chunk for user {user_id}: {chunk}")
m = url_pattern.search(buffer)
if m:
auth_url = m.group(0).rstrip(").,\r\n\t '\"")
break
if proc.returncode is not None:
break
await asyncio.sleep(0.15)
if not auth_url:
try:
proc.kill()
except Exception:
pass
try:
os.close(master_fd)
except Exception:
pass
if backup_token:
token_file.write_text(backup_token, encoding="utf-8")
token_file.chmod(0o600)
return False, "عدم دریافت لینک احراز هویت از سرور گوگل. لطفاً مجدداً تلاش فرمایید.", None
self.active_logins[user_id] = {
"proc": proc,
"master_fd": master_fd,
"url": auth_url,
"backup_token": backup_token,
"started_at": time.time(),
}
return True, "لینک احراز هویت گوگل با موفقیت ایجاد شد.", auth_url
except Exception as e:
logger.error(f"Error starting OAuth login for user {user_id}: {e}", exc_info=True)
if backup_token:
try:
token_file.write_text(backup_token, encoding="utf-8")
token_file.chmod(0o600)
except Exception:
pass
return False, f"خطا در ایجاد فرآیند لاگین: {e}", None
async def complete_oauth_login(self, user_id: int, auth_code: str, timeout: int = 40) -> Tuple[bool, str]:
"""
Completes the OAuth login flow by feeding the authorization code into the waiting AGY PTY process.
"""
import select
if user_id not in self.active_logins:
return False, "هیچ فرآیند لاگین فعالی برای شما یافت نشد. لطفاً دستور /login را مجدداً ارسال کنید."
login_data = self.active_logins[user_id]
proc = login_data["proc"]
master_fd = login_data.get("master_fd")
backup_token = login_data.get("backup_token")
token_file = self.get_token_path(user_id)
clean_code = auth_code.strip()
clean_code = re.sub(r"^[`'\"]+|[`'\"]+$", "", clean_code)
try:
input_bytes = (clean_code + "\n").encode("utf-8")
if master_fd is not None:
os.write(master_fd, input_bytes)
start_t = time.time()
loop = asyncio.get_running_loop()
out_buffer = ""
def read_all_pty(fd: int) -> str:
buf = ""
while True:
r, _, _ = select.select([fd], [], [], 0.3)
if fd in r:
try:
c = os.read(fd, 2048).decode("utf-8", errors="replace")
if not c:
break
buf += c
except Exception:
break
else:
break
return buf
# Poll for token file creation or process termination
while time.time() - start_t < timeout:
if token_file.exists() and token_file.stat().st_size > 10:
token_file.chmod(0o600)
self.cancel_oauth_login(user_id)
return True, "🎉 تبریک! ورود با حساب گوگل اختصاصی شما با موفقیت انجام و ذخیره شد."
if proc.returncode is not None:
break
await asyncio.sleep(0.5)
# Read any output from pty
if master_fd is not None:
out_buffer = await loop.run_in_executor(None, read_all_pty, master_fd)
self.cancel_oauth_login(user_id)
if token_file.exists() and token_file.stat().st_size > 10:
token_file.chmod(0o600)
return True, "🎉 تبریک! ورود با حساب گوگل اختصاصی شما با موفقیت انجام و ذخیره شد."
if backup_token:
token_file.write_text(backup_token, encoding="utf-8")
token_file.chmod(0o600)
err_msg = out_buffer.strip()
return False, f"کد تایید معتبر نبود یا فرآیند با خطا مواجه شد:\n<pre>{escape_pre(err_msg or 'کد ارسالی نامعتبر بود یا منقضی شده است.')}</pre>"
except Exception as e:
logger.error(f"Error completing OAuth login for user {user_id}: {e}", exc_info=True)
self.cancel_oauth_login(user_id)
return False, f"خطا در ثبت کد احراز هویت: {e}"
def cancel_oauth_login(self, user_id: int) -> bool:
"""Cancels any pending login flow for a user and cleans up PTY."""
login_data = self.active_logins.pop(user_id, None)
if login_data:
proc = login_data.get("proc")
master_fd = login_data.get("master_fd")
if proc:
try:
proc.kill()
except Exception:
pass
if master_fd is not None:
try:
os.close(master_fd)
except Exception:
pass
backup_token = login_data.get("backup_token")
token_file = self.get_token_path(user_id)
if backup_token and not token_file.exists():
try:
token_file.write_text(backup_token, encoding="utf-8")
token_file.chmod(0o600)
except Exception:
pass
return True
return False
def import_token(self, user_id: int, token_data_str: str) -> Tuple[bool, str]:
"""
Directly imports an existing antigravity-oauth-token content for the user.
"""
raw_text = token_data_str.strip()
try:
parsed = json.loads(raw_text)
if not isinstance(parsed, dict) or ("token" not in parsed and "access_token" not in parsed and "refresh_token" not in parsed):
return False, "فرمت فایل یا متن توکن معتبر نیست. توکن باید فایل JSON حاوی اطلاعات احراز هویت AGY باشد."
except Exception:
return False, "متن ارسالی یک ساختار JSON معتبر برای توکن AGY نیست."
config_dir = self.get_config_dir(user_id)
config_dir.mkdir(parents=True, exist_ok=True)
token_file = self.get_token_path(user_id)
try:
token_file.write_text(raw_text, encoding="utf-8")
token_file.chmod(0o600)
return True, "✅ فایل توکن حساب اختصاصی شما با موفقیت ثبت و فعال شد."
except Exception as e:
return False, f"خطا در ذخیره‌سازی توکن: {e}"
def logout_user(self, user_id: int) -> Tuple[bool, str]:
"""
Logs out a user by removing their custom token.
"""
self.cancel_oauth_login(user_id)
token_file = self.get_token_path(user_id)
if token_file.exists():
try:
token_file.unlink()
return True, "حساب اختصاصی شما با موفقیت خارج شد. برای ارسال دستورات، ورود مجدد با حساب شخصی الزامی است."
except Exception as e:
return False, f"خطا در حذف توکن اختصاصی: {e}"
return True, "شما در حال حاضر حساب متصلی ندارید."
def copy_master_token_to_user(self, user_id: int) -> Tuple[bool, str]:
"""
Disabled: Shared accounts are completely removed.
"""
return False, "حساب‌های اشتراکی در این ربات غیرفعال شده‌اند و هر کاربر باید با حساب اختصاصی خود وارد شود."
auth_manager = AuthManager()
+1131 -27
View File
File diff suppressed because it is too large Load Diff
+327 -12
View File
@@ -1623,7 +1623,301 @@ async def execute_action(
return badge, side_effects, created_task return badge, side_effects, created_task
# ------------------------------------------------------------- # -------------------------------------------------------------
# 35. SAVE_MEMORY / STORE_MEMORY / SET_MEMORY # 35. SSH_CONFIG / SETUP_SSH / SET_SSH
# -------------------------------------------------------------
elif act in ("SSH_CONFIG", "SETUP_SSH", "SET_SSH", "CONFIGURE_SSH"):
p_name = attrs.get("project") or attrs.get("name") or current_project_name
proj = session.projects.get(p_name) if p_name else session_manager.get_current_project(chat_id)
if not proj:
return f"\n⚠️ پروژه <code>{escape_html(p_name or '')}</code> یافت نشد." if is_fa else f"\n⚠️ Project <code>{escape_html(p_name or '')}</code> not found.", side_effects, created_task
host = attrs.get("host") or attrs.get("server") or attrs.get("ssh_host")
port_raw = attrs.get("port") or attrs.get("ssh_port") or "22"
user = attrs.get("user") or attrs.get("username") or attrs.get("ssh_user") or ""
path = attrs.get("path") or attrs.get("dir") or attrs.get("remote_path") or attrs.get("ssh_path") or "/"
try:
port = int(port_raw)
except Exception:
port = 22
if host:
proj.ssh_host = host.strip()
proj.ssh_port = port
proj.ssh_user = user.strip() if user else proj.ssh_user
proj.ssh_path = path.strip()
session_manager.save()
from remote_audit import remote_audit
remote_audit.log_operation(
user_id=chat_id,
project_name=proj.name,
service_type="ssh",
action_type="update_config",
requester="ai_agent",
status="success",
details=f"Host: {proj.ssh_host}:{proj.ssh_port}, Path: {proj.ssh_path}",
result_summary="SSH configuration updated",
)
# Test connection
from ssh_manager import ssh_manager
test_ok, test_msg = await ssh_manager.test_connection(
host=proj.ssh_host,
port=proj.ssh_port,
user=proj.ssh_user or "",
password=proj.ssh_password,
key=proj.ssh_key,
path=proj.ssh_path,
user_id=chat_id,
project_name=proj.name,
requester="ai_agent",
)
status_icon = "" if test_ok else "⚠️"
badge = (
f"\n\n💻 <b>تنظیمات اتصال SSH برای پروژه <code>{escape_html(proj.name)}</code> به‌روز شد:</b>\n"
f"• 🌐 <b>هاست:</b> <code>{escape_html(proj.ssh_host)}:{proj.ssh_port}</code>\n"
f"• 👤 <b>نام کاربری:</b> <code>{escape_html(proj.ssh_user or '(پیش‌فرض root)')}</code>\n"
f"• 📂 <b>مسیر ریموت:</b> <code>{escape_html(proj.ssh_path)}</code>\n"
f"{status_icon} <b>نتیجه تست اتصال:</b> {test_msg}"
if is_fa else
f"\n\n💻 <b>SSH Configuration updated for project <code>{escape_html(proj.name)}</code>:</b>\n"
f"• 🌐 <b>Host:</b> <code>{escape_html(proj.ssh_host)}:{proj.ssh_port}</code>\n"
f"• 👤 <b>User:</b> <code>{escape_html(proj.ssh_user or '(default root)')}</code>\n"
f"• 📂 <b>Remote Path:</b> <code>{escape_html(proj.ssh_path)}</code>\n"
f"{status_icon} <b>Connection test:</b> {test_msg}"
)
else:
curr_host = proj.ssh_host or "(تنظیم نشده)"
badge = (
f"\n\n️ <b>وضعیت تنظیمات SSH پروژه <code>{escape_html(proj.name)}</code>:</b>\n"
f"• 🌐 <b>هاست:</b> <code>{escape_html(curr_host)}:{proj.ssh_port}</code>\n"
f"• 👤 <b>کاربر:</b> <code>{escape_html(proj.ssh_user or '(تعیین نشده)')}</code>\n"
f"• 📂 <b>مسیر:</b> <code>{escape_html(proj.ssh_path or '/')}</code>"
if is_fa else
f"\n\n️ <b>SSH Status for <code>{escape_html(proj.name)}</code>:</b>\n"
f"• 🌐 <b>Host:</b> <code>{escape_html(curr_host)}:{proj.ssh_port}</code>\n"
f"• 👤 <b>User:</b> <code>{escape_html(proj.ssh_user or '(none)')}</code>\n"
f"• 📂 <b>Path:</b> <code>{escape_html(proj.ssh_path or '/')}</code>"
)
return badge, side_effects, created_task
# -------------------------------------------------------------
# 36. SSH_TEST / TEST_SSH
# -------------------------------------------------------------
elif act in ("SSH_TEST", "TEST_SSH", "CHECK_SSH"):
p_name = attrs.get("project") or attrs.get("name") or current_project_name
proj = session.projects.get(p_name) if p_name else session_manager.get_current_project(chat_id)
if not proj:
return f"\n⚠️ پروژه <code>{escape_html(p_name or '')}</code> یافت نشد." if is_fa else f"\n⚠️ Project <code>{escape_html(p_name or '')}</code> not found.", side_effects, created_task
if not proj.ssh_host:
return "\n⚠️ اطلاعات سرور SSH برای این پروژه ثبت نشده است. می‌توانید از منوی <code>/ssh</code> آن را تنظیم کنید." if is_fa else "\n⚠️ SSH credentials are not set for this project.", side_effects, created_task
from ssh_manager import ssh_manager
test_ok, test_msg = await ssh_manager.test_connection(
host=proj.ssh_host,
port=proj.ssh_port,
user=proj.ssh_user or "",
password=proj.ssh_password,
key=proj.ssh_key,
path=proj.ssh_path or "/",
user_id=chat_id,
project_name=proj.name,
requester="ai_agent",
)
icon = "" if test_ok else ""
badge = (
f"\n\n{icon} <b>تست اتصال سرور SSH پروژه <code>{escape_html(proj.name)}</code>:</b>\n"
f"• 🌐 <b>سرور:</b> <code>{escape_html(proj.ssh_host)}:{proj.ssh_port}</code>\n"
f"• 📂 <b>مسیر:</b> <code>{escape_html(proj.ssh_path or '/')}</code>\n"
f"• 📋 <b>وضعیت:</b>\n{test_msg}"
if is_fa else
f"\n\n{icon} <b>SSH Test Result for <code>{escape_html(proj.name)}</code>:</b>\n"
f"• 🌐 <b>Server:</b> <code>{escape_html(proj.ssh_host)}:{proj.ssh_port}</code>\n"
f"• 📂 <b>Path:</b> <code>{escape_html(proj.ssh_path or '/')}</code>\n"
f"• 📋 <b>Status:</b>\n{test_msg}"
)
return badge, side_effects, created_task
# -------------------------------------------------------------
# 37. SSH_PUSH / PUSH_SSH / SSH_DEPLOY
# -------------------------------------------------------------
elif act in ("SSH_PUSH", "PUSH_SSH", "SSH_DEPLOY", "DEPLOY_SSH"):
p_name = attrs.get("project") or attrs.get("name") or current_project_name
proj = session.projects.get(p_name) if p_name else session_manager.get_current_project(chat_id)
if not proj:
return f"\n⚠️ پروژه <code>{escape_html(p_name or '')}</code> یافت نشد." if is_fa else f"\n⚠️ Project <code>{escape_html(p_name or '')}</code> not found.", side_effects, created_task
if not proj.ssh_host:
return "\n⚠️ مشخصات سرور SSH برای این پروژه یافت نشد. لطفاً ابتدا در منوی <code>/ssh</code> مشخصات را تنظیم نمایید." if is_fa else "\n⚠️ SSH configuration not found for this project.", side_effects, created_task
from ssh_manager import ssh_manager
from git_manager import git_manager
try:
await git_manager.git_publish(proj.workspace, message="Auto-publish before SSH push", repo_name=proj.name)
except Exception as pe:
logger.warning(f"Auto-publish warning before SSH push: {pe}")
res = await ssh_manager.push_project(
workspace_path=proj.workspace,
host=proj.ssh_host,
port=proj.ssh_port,
user=proj.ssh_user or "",
password=proj.ssh_password,
key=proj.ssh_key,
remote_path=proj.ssh_path or "/",
branch="production",
user_id=chat_id,
project_name=proj.name,
requester="ai_agent",
)
if res.get("success"):
kb = round(res.get("bytes_transferred", 0) / 1024, 2)
mb = round(kb / 1024, 2)
size_str = f"{mb} مگابایت" if mb >= 1.0 else f"{kb} کیلوبایت"
files_up = res.get("files_uploaded", 0)
files_skip = res.get("files_skipped", 0)
duration = round(res.get("duration", 0), 2)
target_p = proj.ssh_path or "/"
badge = (
f"\n\n🚀 <b>کدهای شاخه production با موفقیت به سرور SSH منتقل شدند:</b>\n"
f"• 📁 <b>پروژه:</b> <code>{escape_html(proj.name)}</code>\n"
f"• 🌐 <b>مقصد ریموت:</b> <code>{escape_html(proj.ssh_host)}:{proj.ssh_port}</code> (مسیر: <code>{escape_html(target_p)}</code>)\n"
f"• 📤 <b>فایل‌های آپلودشده:</b> <b>{files_up}</b> فایل ({size_str})\n"
f"• 🛡️ <b>داده‌های محلی/دیتابیس/آپلود نادیده گرفته‌شده:</b> <b>{files_skip}</b> آیتم\n"
f"• 🔒 <b>داده‌های سرور پروداکشن:</b> کاملاً دست‌نخورده و ایمن حفظ شدند\n"
f"• ⏱️ <b>مدت زمان انتقال:</b> <code>{duration}</code> ثانیه"
if is_fa else
f"\n\n🚀 <b>Production branch pushed successfully to SSH server:</b>\n"
f"• 📁 <b>Project:</b> <code>{escape_html(proj.name)}</code>\n"
f"• 🌐 <b>Remote Target:</b> <code>{escape_html(proj.ssh_host)}:{proj.ssh_port}</code> (Path: <code>{escape_html(target_p)}</code>)\n"
f"• 📤 <b>Files Uploaded:</b> <b>{files_up}</b> ({size_str})\n"
f"• 🛡️ <b>Skipped Junk/Databases:</b> <b>{files_skip}</b> items\n"
f"• 🔒 <b>Remote production data:</b> Intact & Protected\n"
f"• ⏱️ <b>Duration:</b> <code>{duration}s</code>"
)
else:
err = res.get("error", "Unknown error")
badge = f"\n⚠️ <b>خطا در پوش SSH به سرور مقصد:</b> {escape_html(str(err))}" if is_fa else f"\n⚠️ <b>SSH Push Failed:</b> {escape_html(str(err))}"
return badge, side_effects, created_task
# -------------------------------------------------------------
# 38. SSH_PULL / PULL_SSH / SSH_FETCH
# -------------------------------------------------------------
elif act in ("SSH_PULL", "PULL_SSH", "SSH_FETCH", "FETCH_SSH"):
p_name = attrs.get("project") or attrs.get("name") or current_project_name
proj = session.projects.get(p_name) if p_name else session_manager.get_current_project(chat_id)
if not proj:
return f"\n⚠️ پروژه <code>{escape_html(p_name or '')}</code> یافت نشد." if is_fa else f"\n⚠️ Project <code>{escape_html(p_name or '')}</code> not found.", side_effects, created_task
if not proj.ssh_host:
return "\n⚠️ مشخصات سرور SSH برای این پروژه یافت نشد." if is_fa else "\n⚠️ SSH configuration not found for this project.", side_effects, created_task
from ssh_manager import ssh_manager
res = await ssh_manager.pull_project(
workspace_path=proj.workspace,
host=proj.ssh_host,
port=proj.ssh_port,
user=proj.ssh_user or "",
password=proj.ssh_password,
key=proj.ssh_key,
remote_path=proj.ssh_path or "/",
branch="production",
user_id=chat_id,
project_name=proj.name,
requester="ai_agent",
)
if res.get("success"):
kb = round(res.get("bytes_transferred", 0) / 1024, 2)
mb = round(kb / 1024, 2)
size_str = f"{mb} مگابایت" if mb >= 1.0 else f"{kb} کیلوبایت"
files_down = res.get("files_downloaded", 0)
files_skip = res.get("files_skipped", 0)
duration = round(res.get("duration", 0), 2)
badge = (
f"\n\n📥 <b>فایل‌های پروژه از سرور SSH با موفقیت دریافت شدند (Pull):</b>\n"
f"• 📁 <b>پروژه:</b> <code>{escape_html(proj.name)}</code>\n"
f"• 📥 <b>فایل‌های دریافت‌شده:</b> <b>{files_down}</b> فایل ({size_str})\n"
f"• 🛡️ <b>فایل‌های نادیده گرفته‌شده (دیتابیس/لاگ/آپلود):</b> <b>{files_skip}</b> آیتم\n"
f"• ⏱️ <b>مدت زمان انتقال:</b> <code>{duration}</code> ثانیه"
if is_fa else
f"\n\n📥 <b>Project pulled successfully from SSH server:</b>\n"
f"• 📁 <b>Project:</b> <code>{escape_html(proj.name)}</code>\n"
f"• 📥 <b>Files Downloaded:</b> <b>{files_down}</b> ({size_str})\n"
f"• 🛡️ <b>Skipped (Databases/Logs/Uploads):</b> <b>{files_skip}</b> items\n"
f"• ⏱️ <b>Duration:</b> <code>{duration}s</code>"
)
else:
err = res.get("error", "Unknown error")
badge = f"\n⚠️ <b>خطا در دریافت فایل‌ها از سرور SSH:</b> {escape_html(str(err))}" if is_fa else f"\n⚠️ <b>SSH Pull Failed:</b> {escape_html(str(err))}"
return badge, side_effects, created_task
# -------------------------------------------------------------
# 39. SSH_EXEC / EXEC_SSH / RUN_SSH
# -------------------------------------------------------------
elif act in ("SSH_EXEC", "EXEC_SSH", "RUN_SSH", "SSH_COMMAND"):
p_name = attrs.get("project") or attrs.get("name") or current_project_name
proj = session.projects.get(p_name) if p_name else session_manager.get_current_project(chat_id)
if not proj:
return f"\n⚠️ پروژه <code>{escape_html(p_name or '')}</code> یافت نشد." if is_fa else f"\n⚠️ Project <code>{escape_html(p_name or '')}</code> not found.", side_effects, created_task
if not proj.ssh_host:
return "\n⚠️ مشخصات سرور SSH برای این پروژه یافت نشد." if is_fa else "\n⚠️ SSH configuration not found for this project.", side_effects, created_task
cmd = attrs.get("command") or attrs.get("cmd") or attrs.get("_default") or ""
if not cmd.strip():
return "\n⚠️ دستور اجرایی برای سرور SSH تعیین نشده است." if is_fa else "\n⚠️ SSH command is required.", side_effects, created_task
from ssh_manager import ssh_manager
ok, out, code = await ssh_manager.execute_command(
host=proj.ssh_host,
port=proj.ssh_port,
user=proj.ssh_user or "",
password=proj.ssh_password,
key=proj.ssh_key,
remote_path=proj.ssh_path or "/",
command=cmd,
user_id=chat_id,
project_name=proj.name,
requester="ai_agent",
)
st_icon = "" if ok else ""
badge = (
f"\n\n{st_icon} <b>اجرای دستور روی سرور SSH پروژه <code>{escape_html(proj.name)}</code>:</b>\n"
f"• 💻 <b>دستور:</b> <code>{escape_html(cmd)}</code>\n"
f"• 🔢 <b>کد خروج:</b> <code>{code}</code>\n"
f"• 📜 <b>خروجی سرور:</b>\n<pre>{escape_html(out)}</pre>"
if is_fa else
f"\n\n{st_icon} <b>SSH Command Execution on <code>{escape_html(proj.name)}</code>:</b>\n"
f"• 💻 <b>Command:</b> <code>{escape_html(cmd)}</code>\n"
f"• 🔢 <b>Exit Code:</b> <code>{code}</code>\n"
f"• 📜 <b>Output:</b>\n<pre>{escape_html(out)}</pre>"
)
return badge, side_effects, created_task
# -------------------------------------------------------------
# 40. SSH_LOGS / REMOTE_LOGS / AUDIT_LOGS
# -------------------------------------------------------------
elif act in ("SSH_LOGS", "REMOTE_LOGS", "AUDIT_LOGS", "SSH_AUDIT"):
p_name = attrs.get("project") or attrs.get("name") or current_project_name
proj = session.projects.get(p_name) if p_name else session_manager.get_current_project(chat_id)
proj_name = proj.name if proj else (p_name or "default")
from remote_audit import remote_audit
logs = remote_audit.get_recent_logs(user_id=chat_id, project_name=proj_name, limit=10)
badge = "\n\n" + remote_audit.format_logs_for_tg(logs, project_name=proj_name, is_fa=is_fa)
return badge, side_effects, created_task
# -------------------------------------------------------------
# 41. SAVE_MEMORY / STORE_MEMORY / SET_MEMORY
# ------------------------------------------------------------- # -------------------------------------------------------------
elif act in ("SAVE_MEMORY", "STORE_MEMORY", "SET_MEMORY", "ADD_MEMORY"): elif act in ("SAVE_MEMORY", "STORE_MEMORY", "SET_MEMORY", "ADD_MEMORY"):
from memory_manager import memory_manager from memory_manager import memory_manager
@@ -1649,13 +1943,17 @@ async def execute_action(
created_task, created_task,
) )
# Map types: 'project' -> project tier, 'user'/'private' -> user tier, 'global' -> rejected/downgraded for AI # Map types: 'project' -> project tier, 'user'/'private' -> user tier, 'global' -> global tier (if admin)
is_admin_user = settings.is_admin(chat_id)
if raw_type in ("project", "proj"): if raw_type in ("project", "proj"):
mem_type = "project" mem_type = "project"
proj_name = req_project proj_name = req_project
elif raw_type == "global": elif raw_type == "global":
# Global memories are strictly bot-managed by Admin. AI cannot auto-save global memories. if is_admin_user:
logger.info("AI attempted global memory save for user %s; safely converting to user memory.", chat_id) mem_type = "global"
proj_name = None
else:
logger.info("Non-admin user %s attempted global memory save; converting to user memory.", chat_id)
mem_type = "user" mem_type = "user"
proj_name = None proj_name = None
else: else:
@@ -1667,7 +1965,7 @@ async def execute_action(
type_=mem_type, type_=mem_type,
key=key, key=key,
content=content, content=content,
user_id=chat_id, user_id=None if mem_type == "global" else chat_id,
project_name=proj_name, project_name=proj_name,
category=category, category=category,
importance=importance, importance=importance,
@@ -1720,28 +2018,37 @@ async def execute_action(
created_task, created_task,
) )
is_admin_user = settings.is_admin(chat_id)
if raw_type in ("project", "proj"): if raw_type in ("project", "proj"):
mem_type = "project" mem_type = "project"
proj_name = req_project proj_name = req_project
elif raw_type == "global" and is_admin_user:
mem_type = "global"
proj_name = None
else: else:
mem_type = "user" mem_type = "user"
proj_name = None proj_name = None
try: try:
existing = memory_manager.get_by_key(type_=mem_type, key=key, user_id=chat_id, project_name=proj_name) existing = memory_manager.get_by_key(type_=mem_type, key=key, user_id=None if mem_type == "global" else chat_id, project_name=proj_name)
cat = category or (existing.category if existing else "general") cat = category or (existing.category if existing else "general")
imp = int(importance_raw) if importance_raw and importance_raw.isdigit() else (existing.importance if existing else 1) imp = int(importance_raw) if importance_raw and importance_raw.isdigit() else (existing.importance if existing else 1)
item, is_created = memory_manager.save_or_update( item, is_created = memory_manager.save_or_update(
type_=mem_type, type_=mem_type,
key=key, key=key,
content=content, content=content,
user_id=chat_id, user_id=None if mem_type == "global" else chat_id,
project_name=proj_name, project_name=proj_name,
category=cat, category=cat,
importance=imp, importance=imp,
created_by=chat_id, created_by=chat_id,
) )
type_label = f"📁 پروژه (<code>{escape_html(proj_name or '')}</code>)" if item.is_project else "👤 کاربر (سراسری)" if item.is_project:
type_label = f"📁 پروژه (<code>{escape_html(proj_name or '')}</code>)"
elif item.is_user:
type_label = "👤 کاربر (سراسری)"
else:
type_label = "🌐 عمومی (سیستم)"
badge = ( badge = (
f"\n\n🧠 <b>خاطره هوش مصنوعی به‌روزرسانی شد:</b>\n" f"\n\n🧠 <b>خاطره هوش مصنوعی به‌روزرسانی شد:</b>\n"
f"• 🏷️ <b>بخش:</b> {type_label}\n" f"• 🏷️ <b>بخش:</b> {type_label}\n"
@@ -1768,16 +2075,24 @@ async def execute_action(
key = attrs.get("key") or attrs.get("slug") or attrs.get("_default") key = attrs.get("key") or attrs.get("slug") or attrs.get("_default")
raw_type = (attrs.get("type") or "user").lower().strip() raw_type = (attrs.get("type") or "user").lower().strip()
req_project = attrs.get("project") or attrs.get("proj") or current_project_name req_project = attrs.get("project") or attrs.get("proj") or current_project_name
is_admin_user = settings.is_admin(chat_id)
mem_type = "project" if raw_type in ("project", "proj") else "user" if raw_type in ("project", "proj"):
proj_name = req_project if mem_type == "project" else None mem_type = "project"
proj_name = req_project
elif raw_type == "global" and is_admin_user:
mem_type = "global"
proj_name = None
else:
mem_type = "user"
proj_name = None
try: try:
if mem_id_raw and mem_id_raw.isdigit(): if mem_id_raw and mem_id_raw.isdigit():
ok = memory_manager.delete_by_id(int(mem_id_raw), user_id=chat_id, is_admin=is_admin) ok = memory_manager.delete_by_id(int(mem_id_raw), user_id=chat_id, is_admin=is_admin_user)
elif key: elif key:
ok = memory_manager.delete_by_key( ok = memory_manager.delete_by_key(
type_=mem_type, key=key, user_id=chat_id, project_name=proj_name, is_admin=is_admin type_=mem_type, key=key, user_id=None if mem_type == "global" else chat_id, project_name=proj_name, is_admin=is_admin_user
) )
else: else:
return "\n⚠️ لطفاً شناسه یا کلید خاطره جهت حذف را مشخص کنید." if is_fa else "\n⚠️ Please specify memory id or key to delete.", side_effects, created_task return "\n⚠️ لطفاً شناسه یا کلید خاطره جهت حذف را مشخص کنید." if is_fa else "\n⚠️ Please specify memory id or key to delete.", side_effects, created_task
Binary file not shown.
+67 -5
View File
@@ -622,6 +622,7 @@ def format_ftp_info(
user: Optional[str] = None, user: Optional[str] = None,
path: str = "/", path: str = "/",
tls: bool = False, tls: bool = False,
password: Optional[str] = None,
lang: str = "fa", lang: str = "fa",
) -> str: ) -> str:
"""Formats FTP connection and deployment status for Telegram HTML.""" """Formats FTP connection and deployment status for Telegram HTML."""
@@ -630,18 +631,20 @@ def format_ftp_info(
host_str = f"<code>{escape_html(host)}:{port}</code>" if host else ("<i>(تنظیم نشده)</i>" if is_fa else "<i>(Not configured)</i>") host_str = f"<code>{escape_html(host)}:{port}</code>" if host else ("<i>(تنظیم نشده)</i>" if is_fa else "<i>(Not configured)</i>")
user_str = f"<code>{escape_html(user)}</code>" if user else ("<i>(ندارد)</i>" if is_fa else "<i>(None)</i>") user_str = f"<code>{escape_html(user)}</code>" if user else ("<i>(ندارد)</i>" if is_fa else "<i>(None)</i>")
path_str = f"<code>{escape_html(path)}</code>" path_str = f"<code>{escape_html(path)}</code>"
pass_str = f"<code>{'' * min(len(password), 8)}</code> <i>(تنظیم شده)</i>" if password else ("<i>(ندارد)</i>" if is_fa else "<i>(None)</i>")
tls_str = "بله (FTPS/TLS امن) 🔒" if tls else "خیر (FTP معمولی)" tls_str = "بله (FTPS/TLS امن) 🔒" if tls else "خیر (FTP معمولی)"
tls_str_en = "Yes (Secure FTPS/TLS) 🔒" if tls else "No (Standard FTP)" tls_str_en = "Yes (Secure FTPS/TLS) 🔒" if tls else "No (Standard FTP)"
if is_fa: if is_fa:
return ( return (
f"🚀 <b>تنظیمات استقرار و دیپلوی FTP پروژه</b>\n\n" f"🚀 <b>تنظیمات استقرار و مشخصات FTP پروژه</b>\n\n"
f"• 📁 <b>پروژه:</b> <code>{clean_p}</code>\n" f"• 📁 <b>پروژه:</b> <code>{clean_p}</code>\n"
f"• 🌐 <b>سرور / هاست FTP:</b> {host_str}\n" f"• 🌐 <b>سرور / هاست:</b> {host_str}\n"
f"• 👤 <b>نام کاربری:</b> {user_str}\n" f"• 👤 <b>نام کاربری:</b> {user_str}\n"
f"• 🔑 <b>رمز عبور:</b> {pass_str}\n"
f"• 📂 <b>مسیر مقصد روی هاست:</b> {path_str}\n" f"• 📂 <b>مسیر مقصد روی هاست:</b> {path_str}\n"
f"• 🔒 <b>پروتکل امن (FTPS):</b> {tls_str}\n\n" f"• 🔒 <b>پروتکل امن (FTPS):</b> {tls_str}\n\n"
f"💡 <i>در زمان دیپلوی، هوش مصنوعی فایل‌های زائد، کش، سشن‌ها و مخزن گیت (.git) را به صورت خودکار فیلتر کرده و تنها سورس اصلی و تمیز به سرور منتقل می‌شود.</i>" f"💡 <i>می‌توانید با دکمه‌های زیر هر فیلد را به دلخواه ویرایش، تست یا ذخیره کنید. در زمان دیپلوی، فایل‌های زائد، کش و .git به صورت خودکار فیلتر می‌شوند.</i>"
) )
else: else:
return ( return (
@@ -649,10 +652,69 @@ def format_ftp_info(
f"• 📁 <b>Project:</b> <code>{clean_p}</code>\n" f"• 📁 <b>Project:</b> <code>{clean_p}</code>\n"
f"• 🌐 <b>FTP Host:</b> {host_str}\n" f"• 🌐 <b>FTP Host:</b> {host_str}\n"
f"• 👤 <b>Username:</b> {user_str}\n" f"• 👤 <b>Username:</b> {user_str}\n"
f"📂 <b>Remote Target Path:</b> {path_str}\n" f"🔑 <b>Password:</b> {pass_str}\n"
f"• 📂 <b>Remote Path:</b> {path_str}\n"
f"• 🔒 <b>Secure FTPS/TLS:</b> {tls_str_en}\n\n" f"• 🔒 <b>Secure FTPS/TLS:</b> {tls_str_en}\n\n"
f"💡 <i>During deployment, unnecessary cache, temp, sessions, and .git files are automatically excluded for a clean production upload.</i>" f"💡 <i>Use the buttons below to edit fields, test connection, or deploy. Junk and cache files are excluded automatically.</i>"
)
def format_ssh_info(
project_name: str,
host: Optional[str],
port: int = 22,
user: Optional[str] = None,
path: str = "/",
password: Optional[str] = None,
has_key: bool = False,
lang: str = "fa",
) -> str:
"""Formats SSH connection and remote development status for Telegram HTML."""
is_fa = (lang or "").lower() in ("fa", "farsi", "persian", "🇮🇷 persian / farsi (فارسی)")
clean_p = escape_html(project_name)
host_str = f"<code>{escape_html(host)}:{port}</code>" if host else ("<i>(تنظیم نشده)</i>" if is_fa else "<i>(Not configured)</i>")
user_str = f"<code>{escape_html(user)}</code>" if user else ("<i>(ندارد)</i>" if is_fa else "<i>(None)</i>")
path_str = f"<code>{escape_html(path)}</code>"
auth_methods = []
if password:
auth_methods.append("رمز عبور 🔑" if is_fa else "Password 🔑")
if has_key:
auth_methods.append("کلید اختصاصی SSH 🗝️" if is_fa else "SSH Private Key 🗝️")
if not auth_methods:
auth_str = "<i>(تعیین نشده)</i>" if is_fa else "<i>(Not set)</i>"
else:
auth_str = " + ".join(auth_methods)
if is_fa:
return (
f"💻 <b>تنظیمات ریموت دولوپ و سرور SSH پروژه</b>\n\n"
f"• 📁 <b>پروژه:</b> <code>{clean_p}</code>\n"
f"• 🌐 <b>سرور / هاست:</b> {host_str}\n"
f"• 👤 <b>نام کاربری:</b> {user_str}\n"
f"• 🔐 <b>نوع احراز هویت:</b> {auth_str}\n"
f"• 📂 <b>مسیر مقصد روی سرور:</b> {path_str}\n\n"
f"🛡️ <b>اصول ایمنی پروداکشن:</b>\n"
f"├ پوش کدهای محلی منحصراً از شاخه <code>production</code> انجام می‌شود.\n"
f"├ فایلهای دیتابیس (SQLite)، آپلودهای کاربران و فایل‌های سشن سرور ریموت دست‌نخورده باقی می‌مانند.\n"
f"└ تمام درخواست‌ها و لاگ‌ها به صورت خودکار در دیتابیس ثبت می‌شوند.\n\n"
f"💡 <i>از دکمه‌های زیر برای تست اتصال، ارسال کدها (Push)، دریافت کدها (Pull) یا مدیریت مشخصات استفاده کنید.</i>"
)
else:
return (
f"💻 <b>Project SSH Remote Dev & Server Settings</b>\n\n"
f"• 📁 <b>Project:</b> <code>{clean_p}</code>\n"
f"• 🌐 <b>SSH Host:</b> {host_str}\n"
f"• 👤 <b>Username:</b> {user_str}\n"
f"• 🔐 <b>Authentication:</b> {auth_str}\n"
f"• 📂 <b>Remote Path:</b> {path_str}\n\n"
f"🛡️ <b>Production Safety:</b>\n"
f"├ Pushes strictly deploy from <code>production</code> branch.\n"
f"├ Remote user uploads, SQLite databases, and session files are preserved.\n"
f"└ All operations are audited in SQLite.\n\n"
f"💡 <i>Use buttons below to test connection, push, pull, or manage server config.</i>"
) )
+121 -23
View File
@@ -8,6 +8,9 @@ from pathlib import Path
from typing import Optional, Dict, Any, List, Tuple, Set from typing import Optional, Dict, Any, List, Tuple, Set
import ftplib import ftplib
import socket import socket
import subprocess
import tempfile
import shutil
logger = logging.getLogger("AGYFTPManager") logger = logging.getLogger("AGYFTPManager")
@@ -52,6 +55,10 @@ DEFAULT_FTP_EXCLUDES = [
"env", "env",
"env/*", "env/*",
# Dependencies & local packages
"node_modules",
"node_modules/*",
# Logs & temp files # Logs & temp files
"*.log", "*.log",
"tmp", "tmp",
@@ -77,20 +84,23 @@ def should_exclude(rel_path: str, custom_excludes: Optional[List[str]] = None) -
"""Checks if a relative path matches any exclusion pattern.""" """Checks if a relative path matches any exclusion pattern."""
excludes = DEFAULT_FTP_EXCLUDES + (custom_excludes or []) excludes = DEFAULT_FTP_EXCLUDES + (custom_excludes or [])
norm_path = rel_path.replace("\\", "/").strip("/") norm_path = rel_path.replace("\\", "/").strip("/")
parts = norm_path.split("/") parts = [p for p in norm_path.split("/") if p]
for pattern in excludes: for pattern in excludes:
pat = pattern.replace("\\", "/").strip("/") pat = pattern.replace("\\", "/").strip("/")
clean_pat = pat.rstrip("/*")
# Check against full relative path # Check against full relative path
if fnmatch.fnmatch(norm_path, pat): if fnmatch.fnmatch(norm_path, pat) or fnmatch.fnmatch(norm_path, clean_pat):
return True return True
# If path starts with directory pattern
if norm_path == clean_pat or norm_path.startswith(clean_pat + "/"):
return True
# Check against individual directory / file parts # Check against individual directory / file parts
for part in parts: for part in parts:
if fnmatch.fnmatch(part, pat): if fnmatch.fnmatch(part, pat) or fnmatch.fnmatch(part, clean_pat):
return True
# If pattern is a directory (e.g. .git/* or .git), match if path starts with it
clean_pat = pat.rstrip("/*")
if norm_path == clean_pat or norm_path.startswith(clean_pat + "/"):
return True return True
return False return False
@@ -109,17 +119,27 @@ class FTPManager:
timeout: int = 15, timeout: int = 15,
) -> ftplib.FTP: ) -> ftplib.FTP:
"""Helper to create and authenticate an FTP or FTPS client.""" """Helper to create and authenticate an FTP or FTPS client."""
clean_host = host.strip()
clean_user = user.strip() if user else ""
clean_pass = password.strip() if password else ""
if tls: if tls:
ftp = ftplib.FTP_TLS(timeout=timeout) ftp = ftplib.FTP_TLS(timeout=timeout)
else: else:
ftp = ftplib.FTP(timeout=timeout) ftp = ftplib.FTP(timeout=timeout)
ftp.connect(host=host, port=port, timeout=timeout) ftp.encoding = "utf-8"
ftp.login(user=user or "anonymous", passwd=password or "") ftp.connect(host=clean_host, port=int(port), timeout=timeout)
if tls and isinstance(ftp, ftplib.FTP_TLS): if tls and isinstance(ftp, ftplib.FTP_TLS):
ftp.auth()
ftp.login(user=clean_user or "anonymous", passwd=clean_pass)
ftp.prot_p() # Secure data connection ftp.prot_p() # Secure data connection
else:
ftp.login(user=clean_user or "anonymous", passwd=clean_pass)
# Force passive mode (standard for firewalls / NAT)
ftp.set_pasv(True)
return ftp return ftp
async def test_connection( async def test_connection(
@@ -130,39 +150,72 @@ class FTPManager:
password: str = "", password: str = "",
path: str = "/", path: str = "/",
tls: bool = False, tls: bool = False,
timeout: int = 10, timeout: int = 12,
) -> Tuple[bool, str]: ) -> Tuple[bool, str]:
"""Tests FTP credentials and verifies access to the target remote directory.""" """Tests FTP credentials and verifies access to the target remote directory."""
if not host or not host.strip(): if not host or not host.strip():
return False, "آدرس هاست (Host) FTP مشخص نشده است." return False, "آدرس هاست (Host) FTP مشخص نشده است."
def _test(): def _test():
ftp = None
try: try:
ftp = self._create_ftp_client(host.strip(), int(port), user.strip(), password, tls, timeout) ftp = self._create_ftp_client(host.strip(), int(port), user.strip(), password, tls, timeout)
# Check root PWD
init_pwd = ftp.pwd()
# Test CWD to target path if specified # Test CWD to target path if specified
target_path = path.strip() if path else "/" target_path = path.strip() if path else "/"
if target_path and target_path != "/": if target_path and target_path != "/":
try: try:
ftp.cwd(target_path) ftp.cwd(target_path)
except Exception as e: except Exception as e:
pwd = ftp.pwd() curr_pwd = ftp.pwd() if ftp else "/"
try:
ftp.quit() ftp.quit()
return False, f"اتصال به FTP برقرار شد، اما مسیر ریموت «{target_path}» یافت نشد (مسیر فعلی: {pwd}): {e}" except Exception:
pass
return False, f"اتصال به سرور برقرار شد، اما مسیر ریموت «{target_path}» یافت نشد (مسیر فعلی: {curr_pwd}): {e}"
pwd = ftp.pwd() final_pwd = ftp.pwd()
# Test directory listing
item_count = 0
try: try:
listing = ftp.nlst() listing = ftp.nlst()
count = len(listing) item_count = len(listing)
except Exception: except Exception:
count = 0 try:
lines = []
ftp.dir(lines.append)
item_count = len(lines)
except Exception:
item_count = 0
try:
ftp.quit() ftp.quit()
return True, f"اتصال با موفقیت برقرار شد. مسیر فعلی: <code>{pwd}</code> (تعداد آیتم‌ها: {count})" except Exception:
except (socket.gaierror, socket.timeout) as e: pass
return False, f"خطای شبکه / نامعتبر بودن آدرس سرور ({host}:{port}): {e}"
tls_label = " (FTPS/TLS امن)" if tls else " (FTP معمولی)"
return True, f"اتصال با موفقیت برقرار شد{tls_label} | مسیر: <code>{final_pwd}</code> | تعداد فایل‌ها و پوشه‌ها: {item_count}"
except socket.gaierror as e:
return False, f"نام دامنه/هاست سرور یافت نشد ({host}): {e}"
except (socket.timeout, TimeoutError) as e:
return False, f"مهلت زمانی اتصال به سرور به پایان رسید (Timeout روی پورت {port}): {e}"
except ConnectionRefusedError as e:
return False, f"اتصال توسط سرور رد شد (پورت {port} بسته است یا FTP روی آن فعال نیست): {e}"
except ftplib.error_perm as e: except ftplib.error_perm as e:
return False, f"خطای احراز هویت / دسترسی FTP: {e}" return False, f"خطای نام کاربری یا رمز عبور (دسترسی نامعتبر): {e}"
except Exception as e: except Exception as e:
return False, f"خطا در برقراری ارتباط با FTP: {e}" return False, f"خطا در برقراری ارتباط با FTP: {e}"
finally:
if ftp:
try:
ftp.close()
except Exception:
pass
return await asyncio.to_thread(_test) return await asyncio.to_thread(_test)
@@ -177,11 +230,16 @@ class FTPManager:
tls: bool = False, tls: bool = False,
custom_excludes: Optional[List[str]] = None, custom_excludes: Optional[List[str]] = None,
dry_run: bool = False, dry_run: bool = False,
branch: str = "production",
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Deploys files from workspace_path to the remote FTP server cleanly. Deploys files from the specified git branch (default: production) to the remote FTP server cleanly.
Extracts files directly from the git branch to ensure exact production code is deployed.
Skips all temporary, git, session, and junk files. Skips all temporary, git, session, and junk files.
""" """
import tempfile
import shutil
ws = Path(workspace_path).expanduser().resolve() ws = Path(workspace_path).expanduser().resolve()
if not ws.exists() or not ws.is_dir(): if not ws.exists() or not ws.is_dir():
return { return {
@@ -199,8 +257,42 @@ class FTPManager:
uploaded_files: List[str] = [] uploaded_files: List[str] = []
skipped_files: List[str] = [] skipped_files: List[str] = []
total_bytes = 0 total_bytes = 0
temp_export_dir = None
try: try:
# 1. Determine source directory: if git exists, export the exact production branch
source_dir = ws
if (ws / ".git").exists():
try:
# Check if branch exists
branches_proc = subprocess.run(
["git", "branch", "--list", branch],
cwd=str(ws),
capture_output=True,
text=True,
)
target_branch = branch if branch in branches_proc.stdout else "HEAD"
temp_export_dir = Path(tempfile.mkdtemp(prefix="ftp_deploy_prod_"))
# Use git archive to cleanly extract files from the target branch
archive_proc = subprocess.Popen(
["git", "archive", target_branch],
cwd=str(ws),
stdout=subprocess.PIPE,
)
tar_proc = subprocess.Popen(
["tar", "-x", "-C", str(temp_export_dir)],
stdin=archive_proc.stdout,
)
archive_proc.stdout.close()
tar_proc.communicate()
if tar_proc.returncode == 0:
source_dir = temp_export_dir
except Exception as ge:
logger.warning(f"Git archive export failed for {branch} (falling back to workspace): {ge}")
source_dir = ws
ftp = self._create_ftp_client(host.strip(), int(port), user.strip(), password, tls, timeout=20) ftp = self._create_ftp_client(host.strip(), int(port), user.strip(), password, tls, timeout=20)
# Navigate or create remote root path # Navigate or create remote root path
@@ -230,11 +322,11 @@ class FTPManager:
ensure_remote_dir(target_root) ensure_remote_dir(target_root)
# Scan workspace # Scan source directory
all_files_to_upload: List[Tuple[Path, str]] = [] # (local_path, rel_path) all_files_to_upload: List[Tuple[Path, str]] = [] # (local_path, rel_path)
for root, dirs, files in os.walk(str(ws)): for root, dirs, files in os.walk(str(source_dir)):
rel_dir = os.path.relpath(root, str(ws)) rel_dir = os.path.relpath(root, str(source_dir))
if rel_dir == ".": if rel_dir == ".":
rel_dir = "" rel_dir = ""
@@ -309,6 +401,12 @@ class FTPManager:
"duration": round(time.time() - start_time, 2), "duration": round(time.time() - start_time, 2),
"uploaded_list": uploaded_files, "uploaded_list": uploaded_files,
} }
finally:
if temp_export_dir and Path(temp_export_dir).exists():
try:
shutil.rmtree(temp_export_dir, ignore_errors=True)
except Exception:
pass
return await asyncio.to_thread(_run_deploy) return await asyncio.to_thread(_run_deploy)
+6
View File
@@ -1,6 +1,7 @@
import os import os
import sys import sys
import json import json
import html
import logging import logging
import asyncio import asyncio
import urllib.request import urllib.request
@@ -12,6 +13,11 @@ from config import settings
logger = logging.getLogger("AGYGitManager") logger = logging.getLogger("AGYGitManager")
def escape_html(text: str) -> str:
if not text:
return ""
return html.escape(str(text), quote=False)
DEFAULT_GITIGNORE = """# Byte-compiled / optimized / DLL files DEFAULT_GITIGNORE = """# Byte-compiled / optimized / DLL files
__pycache__/ __pycache__/
*.py[cod] *.py[cod]
+210
View File
@@ -0,0 +1,210 @@
import os
import sys
import time
import sqlite3
import logging
from pathlib import Path
from typing import Optional, Dict, Any, List, Tuple
from datetime import datetime
logger = logging.getLogger("AGYRemoteAudit")
DATA_DIR = Path("/root/telegram-agy-bot/data")
DB_PATH = DATA_DIR / "memory.db"
class RemoteAuditLogger:
"""Manages audit logging for SSH, FTP, and remote operations into SQLite."""
def __init__(self, db_path: Path = DB_PATH):
self.db_path = db_path
self._init_db()
def _get_connection(self) -> sqlite3.Connection:
self.db_path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(self.db_path), timeout=15.0)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL;")
conn.execute("PRAGMA synchronous=NORMAL;")
return conn
def _init_db(self):
"""Initializes remote_audit_logs table and indexes."""
try:
with self._get_connection() as conn:
conn.execute("""
CREATE TABLE IF NOT EXISTS remote_audit_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp REAL NOT NULL,
created_at TEXT NOT NULL,
user_id INTEGER NOT NULL,
project_name TEXT NOT NULL,
service_type TEXT NOT NULL, -- 'ssh', 'ftp'
action_type TEXT NOT NULL, -- 'push', 'pull', 'exec', 'test_connection', 'update_config'
requester TEXT NOT NULL, -- 'ai_agent', 'user_button', 'user_command'
details TEXT, -- command or parameters (never contains passwords)
status TEXT NOT NULL, -- 'success', 'failed', 'running'
result_summary TEXT, -- short summary or error message
duration_ms REAL DEFAULT 0.0
);
""")
conn.execute("""
CREATE INDEX IF NOT EXISTS idx_remote_audit_lookup
ON remote_audit_logs(user_id, project_name, service_type, timestamp DESC);
""")
conn.commit()
except Exception as e:
logger.error(f"Error initializing remote_audit_logs table: {e}")
def log_operation(
self,
user_id: int,
project_name: str,
service_type: str,
action_type: str,
requester: str,
status: str,
details: Optional[str] = None,
result_summary: Optional[str] = None,
duration_ms: float = 0.0,
timestamp: Optional[float] = None,
) -> int:
"""Logs a remote operation into the audit database."""
now_ts = timestamp or time.time()
dt_str = datetime.fromtimestamp(now_ts).strftime("%Y-%m-%d %H:%M:%S")
# Sanitize details (ensure no raw passwords accidentally entered)
safe_details = details or ""
if "password" in safe_details.lower():
safe_details = "[FILTERED DETAILS]"
try:
with self._get_connection() as conn:
cur = conn.execute(
"""
INSERT INTO remote_audit_logs (
timestamp, created_at, user_id, project_name,
service_type, action_type, requester, details,
status, result_summary, duration_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
now_ts,
dt_str,
int(user_id),
str(project_name),
str(service_type).lower(),
str(action_type).lower(),
str(requester).lower(),
safe_details[:1000] if safe_details else None,
str(status).lower(),
str(result_summary)[:1000] if result_summary else None,
float(duration_ms),
),
)
conn.commit()
return cur.lastrowid
except Exception as e:
logger.error(f"Error writing to remote_audit_logs: {e}")
return 0
def get_recent_logs(
self,
user_id: Optional[int] = None,
project_name: Optional[str] = None,
service_type: Optional[str] = None,
limit: int = 15,
) -> List[Dict[str, Any]]:
"""Retrieves recent audit log records."""
try:
with self._get_connection() as conn:
query = "SELECT * FROM remote_audit_logs WHERE 1=1"
params: List[Any] = []
if user_id is not None:
query += " AND user_id = ?"
params.append(user_id)
if project_name:
query += " AND project_name = ?"
params.append(project_name)
if service_type:
query += " AND service_type = ?"
params.append(service_type)
query += " ORDER BY timestamp DESC LIMIT ?"
params.append(int(limit))
cur = conn.execute(query, params)
rows = cur.fetchall()
return [dict(r) for r in rows]
except Exception as e:
logger.error(f"Error fetching remote_audit_logs: {e}")
return []
def format_logs_for_tg(
self,
logs: List[Dict[str, Any]],
project_name: str,
is_fa: bool = True,
) -> str:
"""Formats audit logs into a clean Telegram HTML message."""
if not logs:
if is_fa:
return (
f"📜 <b>تاریخچه و لاگ عملیات ریموت</b>\n\n"
f"• 📁 <b>پروژه:</b> <code>{project_name}</code>\n\n"
f"️ <i>هنوز هیچ عملیات SSH یا FTP برای این پروژه ثبت نشده است.</i>"
)
else:
return (
f"📜 <b>Remote Operations Audit Log</b>\n\n"
f"• 📁 <b>Project:</b> <code>{project_name}</code>\n\n"
f"️ <i>No SSH or FTP operations logged for this project yet.</i>"
)
lines = []
if is_fa:
lines.append(f"📜 <b>تاریخچه عملیات ریموت و SSH/FTP</b>")
lines.append(f"• 📁 <b>پروژه:</b> <code>{project_name}</code>")
lines.append(f"• 📊 <b>تعداد لاگ‌های اخیر:</b> <code>{len(logs)}</code>\n")
else:
lines.append(f"📜 <b>Remote Operations & SSH/FTP Audit Log</b>")
lines.append(f"• 📁 <b>Project:</b> <code>{project_name}</code>")
lines.append(f"• 📊 <b>Recent records:</b> <code>{len(logs)}</code>\n")
for idx, log in enumerate(logs, 1):
st = log.get("status", "unknown")
st_icon = "" if st == "success" else ("" if st == "failed" else "")
srv = log.get("service_type", "").upper()
act = log.get("action_type", "")
req = log.get("requester", "")
req_label = "🤖 AI" if "ai" in req else ("👤 کاربر" if is_fa else "👤 User")
dt = log.get("created_at", "")
dur = log.get("duration_ms", 0.0)
dur_str = f" ({dur:.1f}ms)" if dur > 0 else ""
res = log.get("result_summary") or ""
det = log.get("details") or ""
act_fa = {
"push": "پوش به پروداکشن 🚀",
"pull": "دریافت از سرور 📥",
"exec": "اجرای فرمان 💻",
"test_connection": "تست اتصال 🔍",
"update_config": "ویرایش تنظیمات ⚙️",
}.get(act, act)
act_display = act_fa if is_fa else act.capitalize()
item_text = f"<b>{idx}. {st_icon} [{srv}] {act_display}</b> | {req_label}\n"
item_text += f" 🕒 <code>{dt}</code>{dur_str}\n"
if det:
item_text += f" 📝 <code>{det[:60]}</code>\n"
if res:
item_text += f" 💬 <i>{res[:100]}</i>\n"
lines.append(item_text)
return "\n".join(lines)
# Global singleton instance
remote_audit = RemoteAuditLogger()
File diff suppressed because one or more lines are too long
+719
View File
@@ -0,0 +1,719 @@
import os
import sys
import time
import fnmatch
import logging
import asyncio
from pathlib import Path
from typing import Optional, Dict, Any, List, Tuple, Set
import socket
import subprocess
import tempfile
import shutil
import io
import paramiko
from remote_audit import remote_audit
logger = logging.getLogger("AGYSSHManager")
# Standard patterns for junk, temporary, local configs, and production user data that must NEVER be overwritten or pushed
DEFAULT_SSH_EXCLUDES = [
# Git and version control
".git",
".git/*",
".gitignore",
".gitattributes",
".gitmodules",
# Environment & local secret files
".env",
".env.*",
"*.env",
"*.pem",
"*.key",
# User databases and data stores (never overwrite production sqlite / db files!)
"*.db",
"*.db-shm",
"*.db-wal",
"*.sqlite",
"*.sqlite3",
"data/*.db",
"data/*.sqlite",
# Dynamic uploads & media directories (production user uploads must never be touched)
"uploads",
"uploads/*",
"*/uploads",
"*/uploads/*",
"media",
"media/*",
"storage/uploads",
"storage/uploads/*",
"storage/framework",
"storage/framework/*",
# AI & Bot internal session / agent configs
".agents",
".agents/*",
".gemini",
".gemini/*",
"sessions_data",
"sessions_data/*",
"uploads_temp",
"uploads_temp/*",
# Python cache & virtual environments
"__pycache__",
"__pycache__/*",
"*.pyc",
"*.pyo",
"*.pyd",
".venv",
".venv/*",
"venv",
"venv/*",
"env",
"env/*",
# Node.js dependencies
"node_modules",
"node_modules/*",
# Logs & temporary files
"*.log",
"logs",
"logs/*",
"tmp",
"tmp/*",
"temp",
"temp/*",
# IDE & OS files
".DS_Store",
"Thumbs.db",
"desktop.ini",
".idea",
".idea/*",
".vscode",
".vscode/*",
"*.swp",
"*.swo",
"*~",
]
def should_exclude_ssh(rel_path: str, custom_excludes: Optional[List[str]] = None) -> bool:
"""Checks if a relative path matches any exclusion pattern for SSH sync."""
excludes = DEFAULT_SSH_EXCLUDES + (custom_excludes or [])
norm_path = rel_path.replace("\\", "/").strip("/")
parts = [p for p in norm_path.split("/") if p]
for pattern in excludes:
pat = pattern.replace("\\", "/").strip("/")
clean_pat = pat.rstrip("/*")
if fnmatch.fnmatch(norm_path, pat) or fnmatch.fnmatch(norm_path, clean_pat):
return True
if norm_path == clean_pat or norm_path.startswith(clean_pat + "/"):
return True
for part in parts:
if fnmatch.fnmatch(part, pat) or fnmatch.fnmatch(part, clean_pat):
return True
return False
class SSHManager:
"""Manages SSH connections, remote dev synchronization, execution, and audit logging."""
def _create_ssh_client(
self,
host: str,
port: int = 22,
user: str = "",
password: Optional[str] = None,
key_content_or_path: Optional[str] = None,
timeout: int = 15,
) -> paramiko.SSHClient:
"""Helper to create and authenticate a paramiko SSHClient."""
clean_host = host.strip()
clean_user = user.strip() if user else "root"
clean_pass = password.strip() if password else None
clean_port = int(port) if port else 22
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
pkey = None
if key_content_or_path and key_content_or_path.strip():
raw_k = key_content_or_path.strip()
# Check if key is a file path
if os.path.isfile(raw_k):
try:
pkey = paramiko.RSAKey.from_private_key_file(raw_k, password=clean_pass)
except Exception:
try:
pkey = paramiko.Ed25519Key.from_private_key_file(raw_k, password=clean_pass)
except Exception:
try:
pkey = paramiko.ECDSAKey.from_private_key_file(raw_k, password=clean_pass)
except Exception as e:
logger.warning(f"Could not load private key from file {raw_k}: {e}")
else:
# Key is provided as string text
key_file_obj = io.StringIO(raw_k)
for key_cls in (paramiko.RSAKey, paramiko.Ed25519Key, paramiko.ECDSAKey, paramiko.DSSKey):
try:
key_file_obj.seek(0)
pkey = key_cls.from_private_key(key_file_obj, password=clean_pass)
if pkey:
break
except Exception:
continue
connect_kwargs: Dict[str, Any] = {
"hostname": clean_host,
"port": clean_port,
"username": clean_user,
"timeout": timeout,
"banner_timeout": timeout,
"auth_timeout": timeout,
"allow_agent": False,
"look_for_keys": False,
}
if pkey:
connect_kwargs["pkey"] = pkey
elif clean_pass:
connect_kwargs["password"] = clean_pass
else:
# Try agent / default keys if neither provided
connect_kwargs["allow_agent"] = True
connect_kwargs["look_for_keys"] = True
client.connect(**connect_kwargs)
return client
async def test_connection(
self,
host: str,
port: int = 22,
user: str = "",
password: Optional[str] = None,
key: Optional[str] = None,
path: str = "/",
timeout: int = 12,
user_id: int = 0,
project_name: str = "",
requester: str = "user_button",
) -> Tuple[bool, str]:
"""Tests SSH connection and checks remote path accessibility."""
start_t = time.time()
if not host or not host.strip():
return False, "آدرس هاست (Host) سرور SSH مشخص نشده است."
def _test():
client = None
try:
client = self._create_ssh_client(
host=host.strip(),
port=port,
user=user.strip(),
password=password,
key_content_or_path=key,
timeout=timeout,
)
# Run simple probe command
stdin, stdout, stderr = client.exec_command("uname -sr || echo Linux", timeout=timeout)
os_info = stdout.read().decode("utf-8", errors="ignore").strip()
# Test SFTP session and target path
sftp = client.open_sftp()
target_path = (path or "/").strip()
path_status = "موجود است"
item_count = 0
try:
sftp.stat(target_path)
try:
dir_items = sftp.listdir(target_path)
item_count = len(dir_items)
except Exception:
item_count = 0
except IOError:
path_status = "موجود نیست (در اولین استقرار به صورت خودکار ایجاد می‌شود)"
sftp.close()
client.close()
msg = (
f"اتصال SSH با موفقیت برقرار شد ✅\n"
f"• 🖥️ <b>سیستم‌عامل:</b> <code>{os_info}</code>\n"
f"• 📂 <b>مسیر ریموت:</b> <code>{target_path}</code> ({path_status})\n"
f"• 📊 <b>تعداد آیتم‌ها در مسیر:</b> <code>{item_count}</code>"
)
return True, msg
except socket.gaierror as e:
return False, f"نام دامنه یا IP سرور یافت نشد ({host}): {e}"
except (socket.timeout, TimeoutError) as e:
return False, f"مهلت زمانی اتصال SSH به پایان رسید (Timeout روی پورت {port}): {e}"
except ConnectionRefusedError as e:
return False, f"اتصال SSH توسط سرور رد شد (پورت {port} مسدود است یا SSH اجرا نمی‌شود)."
except paramiko.AuthenticationException as e:
return False, f"خطای احراز هویت SSH: نام کاربری، رمز عبور یا کلید نامعتبر است ({e})."
except Exception as e:
return False, f"خطا در برقراری ارتباط SSH: {e}"
finally:
if client:
try:
client.close()
except Exception:
pass
success, result_msg = await asyncio.to_thread(_test)
dur = (time.time() - start_t) * 1000
# Record audit log
remote_audit.log_operation(
user_id=user_id,
project_name=project_name or "unknown",
service_type="ssh",
action_type="test_connection",
requester=requester,
status="success" if success else "failed",
details=f"Host: {host}:{port}, Path: {path}",
result_summary=result_msg[:200],
duration_ms=dur,
)
return success, result_msg
async def push_project(
self,
workspace_path: str,
host: str,
port: int = 22,
user: str = "",
password: Optional[str] = None,
key: Optional[str] = None,
remote_path: str = "/",
branch: str = "production",
custom_excludes: Optional[List[str]] = None,
user_id: int = 0,
project_name: str = "",
requester: str = "user_button",
) -> Dict[str, Any]:
"""
Pushes files from the git branch (strictly 'production') to remote server via SFTP.
Ensures existing production user data, SQLite databases, and dynamic uploads are NEVER modified.
"""
start_t = time.time()
ws = Path(workspace_path).expanduser().resolve()
if not ws.exists() or not ws.is_dir():
err = f"دایرکتوری محلی پروژه در مسیر {workspace_path} یافت نشد."
remote_audit.log_operation(
user_id=user_id,
project_name=project_name,
service_type="ssh",
action_type="push",
requester=requester,
status="failed",
details=f"Push branch={branch} to {host}:{port}{remote_path}",
result_summary=err,
)
return {
"success": False,
"error": err,
"files_uploaded": 0,
"files_skipped": 0,
"bytes_transferred": 0,
"duration": 0.0,
}
def _run_push():
client = None
temp_export_dir = None
uploaded_files = []
skipped_files = []
total_bytes = 0
try:
# 1. Extract strictly from production git branch
source_dir = ws
if (ws / ".git").exists():
try:
branches_proc = subprocess.run(
["git", "branch", "--list", branch],
cwd=str(ws),
capture_output=True,
text=True,
)
target_branch = branch if branch in branches_proc.stdout else "HEAD"
temp_export_dir = Path(tempfile.mkdtemp(prefix="ssh_push_prod_"))
archive_proc = subprocess.Popen(
["git", "archive", target_branch],
cwd=str(ws),
stdout=subprocess.PIPE,
)
tar_proc = subprocess.Popen(
["tar", "-x", "-C", str(temp_export_dir)],
stdin=archive_proc.stdout,
)
archive_proc.stdout.close()
tar_proc.communicate()
if tar_proc.returncode == 0:
source_dir = temp_export_dir
except Exception as ge:
logger.warning(f"Git archive export failed for {branch} (using ws): {ge}")
source_dir = ws
client = self._create_ssh_client(
host=host.strip(),
port=port,
user=user.strip(),
password=password,
key_content_or_path=key,
timeout=25,
)
sftp = client.open_sftp()
# Prepare remote path
target_root = (remote_path or "/").strip().replace("\\", "/")
if not target_root.startswith("/"):
target_root = "/" + target_root
target_root = target_root.rstrip("/")
if not target_root:
target_root = "/"
def ensure_remote_dir(r_dir: str):
if r_dir in ("/", ""):
return
parts = [p for p in r_dir.split("/") if p]
curr = ""
for p in parts:
curr += "/" + p
try:
sftp.stat(curr)
except IOError:
try:
sftp.mkdir(curr)
except Exception:
pass
ensure_remote_dir(target_root)
# Scan and filter local files
for root, dirs, files in os.walk(str(source_dir)):
rel_dir = os.path.relpath(root, str(source_dir))
if rel_dir == ".":
rel_dir = ""
dirs_to_keep = []
for d in dirs:
dir_rel = f"{rel_dir}/{d}".strip("/")
if should_exclude_ssh(dir_rel, custom_excludes):
skipped_files.append(dir_rel + "/")
else:
dirs_to_keep.append(d)
dirs[:] = dirs_to_keep
for f in files:
file_rel = f"{rel_dir}/{f}".strip("/")
if should_exclude_ssh(file_rel, custom_excludes):
skipped_files.append(file_rel)
else:
local_file = Path(root) / f
remote_file_path = f"{target_root}/{file_rel}".replace("//", "/")
remote_file_dir = os.path.dirname(remote_file_path)
ensure_remote_dir(remote_file_dir)
sftp.put(str(local_file), remote_file_path)
try:
f_size = local_file.stat().st_size
total_bytes += f_size
except Exception:
pass
uploaded_files.append(file_rel)
sftp.close()
client.close()
dur = time.time() - start_t
return {
"success": True,
"files_uploaded": len(uploaded_files),
"files_skipped": len(skipped_files),
"bytes_transferred": total_bytes,
"duration": dur,
"uploaded_list": uploaded_files,
}
except Exception as e:
logger.error(f"SSH push failed: {e}", exc_info=True)
dur = time.time() - start_t
return {
"success": False,
"error": f"خطا در ارسال فایل‌ها به سرور SSH: {e}",
"files_uploaded": len(uploaded_files),
"files_skipped": len(skipped_files),
"bytes_transferred": total_bytes,
"duration": dur,
}
finally:
if client:
try:
client.close()
except Exception:
pass
if temp_export_dir and os.path.exists(temp_export_dir):
shutil.rmtree(temp_export_dir, ignore_errors=True)
res = await asyncio.to_thread(_run_push)
# Audit log record
status_str = "success" if res.get("success") else "failed"
summary_str = f"Uploaded {res.get('files_uploaded', 0)} files ({res.get('bytes_transferred', 0)} bytes)" if res.get("success") else str(res.get("error", "Error"))
remote_audit.log_operation(
user_id=user_id,
project_name=project_name,
service_type="ssh",
action_type="push",
requester=requester,
status=status_str,
details=f"Push to {host}:{port}{remote_path} (Branch: {branch})",
result_summary=summary_str[:200],
duration_ms=res.get("duration", 0.0) * 1000,
)
return res
async def pull_project(
self,
workspace_path: str,
host: str,
port: int = 22,
user: str = "",
password: Optional[str] = None,
key: Optional[str] = None,
remote_path: str = "/",
branch: str = "production",
custom_excludes: Optional[List[str]] = None,
user_id: int = 0,
project_name: str = "",
requester: str = "user_button",
) -> Dict[str, Any]:
"""
Pulls files from the remote SSH server into local workspace / production branch.
Skips remote database and user upload files according to exclusion rules.
"""
start_t = time.time()
ws = Path(workspace_path).expanduser().resolve()
ws.mkdir(parents=True, exist_ok=True)
def _run_pull():
client = None
downloaded_files = []
skipped_files = []
total_bytes = 0
try:
client = self._create_ssh_client(
host=host.strip(),
port=port,
user=user.strip(),
password=password,
key_content_or_path=key,
timeout=25,
)
sftp = client.open_sftp()
target_root = (remote_path or "/").strip().replace("\\", "/").rstrip("/")
if not target_root:
target_root = "/"
def recursive_download(r_dir: str, rel_dir: str = ""):
nonlocal total_bytes
try:
entries = sftp.listdir_attr(r_dir)
except IOError as e:
logger.warning(f"Cannot list remote dir {r_dir}: {e}")
return
for entry in entries:
fname = entry.filename
if fname in (".", ".."):
continue
cur_rel = f"{rel_dir}/{fname}".strip("/")
cur_remote = f"{r_dir}/{fname}".replace("//", "/")
# Check if directory
import stat
is_dir = stat.S_ISDIR(entry.st_mode)
if should_exclude_ssh(cur_rel if not is_dir else cur_rel + "/", custom_excludes):
skipped_files.append(cur_rel + ("/" if is_dir else ""))
continue
if is_dir:
local_sub = ws / cur_rel
local_sub.mkdir(parents=True, exist_ok=True)
recursive_download(cur_remote, cur_rel)
else:
local_dest = ws / cur_rel
local_dest.parent.mkdir(parents=True, exist_ok=True)
sftp.get(cur_remote, str(local_dest))
total_bytes += entry.st_size
downloaded_files.append(cur_rel)
recursive_download(target_root)
sftp.close()
client.close()
dur = time.time() - start_t
return {
"success": True,
"files_downloaded": len(downloaded_files),
"files_skipped": len(skipped_files),
"bytes_transferred": total_bytes,
"duration": dur,
"downloaded_list": downloaded_files,
}
except Exception as e:
logger.error(f"SSH pull failed: {e}", exc_info=True)
dur = time.time() - start_t
return {
"success": False,
"error": f"خطا در دریافت فایل‌ها از سرور SSH: {e}",
"files_downloaded": len(downloaded_files),
"files_skipped": len(skipped_files),
"bytes_transferred": total_bytes,
"duration": dur,
}
finally:
if client:
try:
client.close()
except Exception:
pass
res = await asyncio.to_thread(_run_pull)
# Audit log record
status_str = "success" if res.get("success") else "failed"
summary_str = f"Downloaded {res.get('files_downloaded', 0)} files ({res.get('bytes_transferred', 0)} bytes)" if res.get("success") else str(res.get("error", "Error"))
remote_audit.log_operation(
user_id=user_id,
project_name=project_name,
service_type="ssh",
action_type="pull",
requester=requester,
status=status_str,
details=f"Pull from {host}:{port}{remote_path} to {workspace_path}",
result_summary=summary_str[:200],
duration_ms=res.get("duration", 0.0) * 1000,
)
return res
async def execute_command(
self,
host: str,
port: int = 22,
user: str = "",
password: Optional[str] = None,
key: Optional[str] = None,
remote_path: str = "/",
command: str = "",
timeout: int = 60,
user_id: int = 0,
project_name: str = "",
requester: str = "user_button",
) -> Tuple[bool, str, int]:
"""
Executes a bash command remotely inside the specified remote_path directory.
"""
start_t = time.time()
if not command or not command.strip():
return False, "دستور اجرایی خالی است.", -1
def _run_exec():
client = None
try:
client = self._create_ssh_client(
host=host.strip(),
port=port,
user=user.strip(),
password=password,
key_content_or_path=key,
timeout=timeout,
)
cd_prefix = f"cd {remote_path} && " if remote_path and remote_path != "/" else ""
full_cmd = f"bash -c {subprocess.list2cmdline([cd_prefix + command])}" if cd_prefix else command
stdin, stdout, stderr = client.exec_command(full_cmd, timeout=timeout)
out = stdout.read().decode("utf-8", errors="ignore")
err = stderr.read().decode("utf-8", errors="ignore")
exit_code = stdout.channel.recv_exit_status()
client.close()
combined = ""
if out:
combined += out
if err:
if combined:
combined += "\n"
combined += f"[STDERR]\n{err}"
if not combined:
combined = f"(دستور با کد {exit_code} بدون خروجی اجرا شد)"
# Limit output size to prevent overflow
if len(combined) > 3500:
combined = combined[:3500] + "\n... (خروجی طولانی کوتاه شد)"
return (exit_code == 0), combined, exit_code
except Exception as e:
logger.error(f"SSH command execution failed: {e}")
return False, f"خطا در اجرای فرمان SSH: {e}", -1
finally:
if client:
try:
client.close()
except Exception:
pass
success, output, code = await asyncio.to_thread(_run_exec)
dur = (time.time() - start_t) * 1000
# Audit log record
remote_audit.log_operation(
user_id=user_id,
project_name=project_name,
service_type="ssh",
action_type="exec",
requester=requester,
status="success" if success else "failed",
details=f"cmd: {command[:200]}",
result_summary=f"exit_code={code}, output={output[:100]}",
duration_ms=dur,
)
return success, output, code
# Global singleton instance
ssh_manager = SSHManager()
+19 -1
View File
@@ -1,3 +1,4 @@
import os
import re import re
import time import time
import asyncio import asyncio
@@ -122,8 +123,23 @@ def parse_agy_usage_output(usage_raw: str, credits_raw: str) -> Dict[str, Any]:
return data return data
async def fetch_and_render_usage_report(is_fa: bool = True) -> str: async def fetch_and_render_usage_report(is_fa: bool = True, user_id: Optional[int] = None) -> str:
"""Executes agy /usage and /credits and formats with visual progress bars.""" """Executes agy /usage and /credits and formats with visual progress bars."""
from auth_manager import auth_manager
if user_id and not auth_manager.has_custom_account(user_id):
if is_fa:
return (
"⚠️ <b>حساب کاربری هوش مصنوعی متصل نیست!</b>\n\n"
"در این ربات حساب‌های اشتراکی غیرفعال هستند و هر کاربر باید حساب گوگل اختصاصی خود را متصل کند.\n\n"
"👉 برای ورود و استعلام سهمیه اختصاصی، لطفاً دستور <code>/login</code> را ارسال کنید یا از منوی <code>/account</code> وارد شوید."
)
else:
return (
"⚠️ <b>AGY Account Not Connected!</b>\n\n"
"Shared accounts are disabled. Please log in with your own Google account using <code>/login</code> or <code>/account</code>."
)
env = auth_manager.get_user_env(user_id) if user_id else os.environ.copy()
usage_raw = "" usage_raw = ""
credits_raw = "" credits_raw = ""
try: try:
@@ -131,6 +147,7 @@ async def fetch_and_render_usage_report(is_fa: bool = True) -> str:
"agy", "--print", "/usage", "agy", "--print", "/usage",
stdout=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
env=env,
) )
out1, _ = await proc1.communicate() out1, _ = await proc1.communicate()
usage_raw = out1.decode("utf-8", errors="replace").strip() usage_raw = out1.decode("utf-8", errors="replace").strip()
@@ -139,6 +156,7 @@ async def fetch_and_render_usage_report(is_fa: bool = True) -> str:
"agy", "--print", "/credits", "agy", "--print", "/credits",
stdout=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
env=env,
) )
out2, _ = await proc2.communicate() out2, _ = await proc2.communicate()
credits_raw = out2.decode("utf-8", errors="replace").strip() credits_raw = out2.decode("utf-8", errors="replace").strip()
Binary file not shown.

After

Width:  |  Height:  |  Size: 334 KiB